Compare commits
No commits in common. 'master' and 'feature/ci' have entirely different histories.
master
...
feature/ci
@ -1,5 +1,5 @@
|
|||||||
blank_issues_enabled: false
|
blank_issues_enabled: false
|
||||||
contact_links:
|
contact_links:
|
||||||
- name: ⚠️ Application issue
|
- name: ⚠️Application issue
|
||||||
url: https://github.com/KotatsuApp/Kotatsu/issues/new/choose
|
url: https://github.com/nv95/Kotatsu/issues/new/choose
|
||||||
about: Issues and requests about the app itself should be opened in the Kotatsu repository instead
|
about: Issues and requests about the app itself should be opened in the Kotatsu repository instead
|
||||||
@ -1,66 +1,94 @@
|
|||||||
name: 🐞 Issue report
|
name: 🐞 Issue report
|
||||||
description: Report a source issue with a source
|
description: Report a source issue in Kotatsu
|
||||||
labels: [ bug ]
|
labels: [bug]
|
||||||
body:
|
body:
|
||||||
|
|
||||||
- type: input
|
- type: input
|
||||||
id: source
|
id: source
|
||||||
attributes:
|
attributes:
|
||||||
label: Source information
|
label: Source information
|
||||||
description: |
|
description: |
|
||||||
You can find the source name in navigation drawer.
|
You can find the source name in navigation drawer.
|
||||||
placeholder: |
|
placeholder: |
|
||||||
Example: "MangaDex"
|
Example: "MangaDex"
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: reproduce-steps
|
id: reproduce-steps
|
||||||
attributes:
|
attributes:
|
||||||
label: Steps to reproduce
|
label: Steps to reproduce
|
||||||
description: Provide an example of the issue.
|
description: Provide an example of the issue.
|
||||||
placeholder: |
|
placeholder: |
|
||||||
Example:
|
Example:
|
||||||
1. First step
|
1. First step
|
||||||
2. Second step
|
2. Second step
|
||||||
3. Issue here
|
3. Issue here
|
||||||
Please use English language
|
validations:
|
||||||
validations:
|
required: true
|
||||||
required: false
|
|
||||||
|
|
||||||
- type: input
|
- type: textarea
|
||||||
id: kotatsu-version
|
id: expected-behavior
|
||||||
attributes:
|
attributes:
|
||||||
label: Kotatsu version
|
label: Expected behavior
|
||||||
description: |
|
placeholder: |
|
||||||
You can find your Kotatsu version in **Settings → About**.
|
Example:
|
||||||
placeholder: |
|
"This should happen..."
|
||||||
Example: "3.3"
|
validations:
|
||||||
validations:
|
required: true
|
||||||
required: true
|
|
||||||
|
|
||||||
- type: input
|
- type: textarea
|
||||||
id: android-version
|
id: actual-behavior
|
||||||
attributes:
|
attributes:
|
||||||
label: Android version
|
label: Actual behavior
|
||||||
description: |
|
placeholder: |
|
||||||
You can find this somewhere in your Android settings.
|
Example:
|
||||||
placeholder: |
|
"This happened instead..."
|
||||||
Example: "Android 12"
|
validations:
|
||||||
validations:
|
required: true
|
||||||
required: false
|
|
||||||
|
|
||||||
- type: textarea
|
- type: input
|
||||||
id: other-details
|
id: kotatsu-version
|
||||||
attributes:
|
attributes:
|
||||||
label: Other details
|
label: Kotatsu version
|
||||||
placeholder: |
|
description: |
|
||||||
Additional details and attachments.
|
You can find your Kotatsu version in **Settings → About**.
|
||||||
|
placeholder: |
|
||||||
|
Example: "3.3"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
- type: checkboxes
|
- type: input
|
||||||
id: acknowledgements
|
id: android-version
|
||||||
attributes:
|
attributes:
|
||||||
label: Acknowledgements
|
label: Android version
|
||||||
options:
|
description: |
|
||||||
- label: I have searched the existing issues and this is a new ticket, **NOT** a duplicate or related to another open issue.
|
You can find this somewhere in your Android settings.
|
||||||
required: true
|
placeholder: |
|
||||||
|
Example: "Android 12"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: other-details
|
||||||
|
attributes:
|
||||||
|
label: Other details
|
||||||
|
placeholder: |
|
||||||
|
Additional details and attachments.
|
||||||
|
|
||||||
|
- type: checkboxes
|
||||||
|
id: acknowledgements
|
||||||
|
attributes:
|
||||||
|
label: Acknowledgements
|
||||||
|
description: Your issue will be closed if you haven't done these steps.
|
||||||
|
options:
|
||||||
|
- label: I have searched the existing issues and this is a new ticket, **NOT** a duplicate or related to another open issue.
|
||||||
|
required: true
|
||||||
|
- label: I have written a short but informative title.
|
||||||
|
required: true
|
||||||
|
- label: I have updated the app to version **[3.3](https://github.com/nv95/Kotatsu/releases/latest)**.
|
||||||
|
required: true
|
||||||
|
- label: If this is an issue with the app itself, I should be opening an issue in the [app repository](https://github.com/nv95/Kotatsu/issues/new/choose).
|
||||||
|
required: true
|
||||||
|
- label: I will fill out all of the requested information in this form.
|
||||||
|
required: true
|
||||||
@ -1,31 +1,39 @@
|
|||||||
name: ⭐ Feature request
|
name: ⭐ Feature request
|
||||||
description: Suggest a feature to improve a source
|
description: Suggest a feature to improve a source
|
||||||
labels: [ feature request ]
|
labels: [feature request]
|
||||||
body:
|
body:
|
||||||
|
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: feature-description
|
id: feature-description
|
||||||
attributes:
|
attributes:
|
||||||
label: Describe your suggested feature
|
label: Describe your suggested feature
|
||||||
description: How can an existing source be improved?
|
description: How can an existing source be improved?
|
||||||
placeholder: |
|
placeholder: |
|
||||||
Example:
|
Example:
|
||||||
"It should work like this..."
|
"It should work like this..."
|
||||||
Please use English language
|
validations:
|
||||||
validations:
|
required: true
|
||||||
required: true
|
|
||||||
|
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: other-details
|
id: other-details
|
||||||
attributes:
|
attributes:
|
||||||
label: Other details
|
label: Other details
|
||||||
placeholder: |
|
placeholder: |
|
||||||
Additional details and attachments.
|
Additional details and attachments.
|
||||||
|
|
||||||
- type: checkboxes
|
- type: checkboxes
|
||||||
id: acknowledgements
|
id: acknowledgements
|
||||||
attributes:
|
attributes:
|
||||||
label: Acknowledgements
|
label: Acknowledgements
|
||||||
options:
|
description: Your issue will be closed if you haven't done these steps.
|
||||||
- label: I have searched the existing issues and this is a new ticket, **NOT** a duplicate or related to another open issue.
|
options:
|
||||||
required: true
|
- label: I have searched the existing issues and this is a new ticket, **NOT** a duplicate or related to another open issue.
|
||||||
|
required: true
|
||||||
|
- label: I have written a short but informative title.
|
||||||
|
required: true
|
||||||
|
- label: If this is an issue with the app itself, I should be opening an issue in the [app repository](https://github.com/nv95/Kotatsu/issues/new/choose).
|
||||||
|
required: true
|
||||||
|
- label: I have updated the app to version **[3.3](https://github.com/nv95/Kotatsu/releases/latest)**.
|
||||||
|
required: true
|
||||||
|
- label: I will fill out all of the requested information in this form.
|
||||||
|
required: true
|
||||||
@ -1,31 +1,33 @@
|
|||||||
name: 🗑 Source removal request
|
name: 🗑 Source removal request
|
||||||
description: Scanlators can request their site to be removed
|
description: Scanlators can request their site to be removed
|
||||||
labels: [ source removal ]
|
labels: [source removal]
|
||||||
body:
|
body:
|
||||||
|
|
||||||
- type: input
|
- type: input
|
||||||
id: link
|
id: link
|
||||||
attributes:
|
attributes:
|
||||||
label: Source link
|
label: Source link
|
||||||
placeholder: |
|
placeholder: |
|
||||||
Example: "https://example.org"
|
Example: "https://example.org"
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: other-details
|
id: other-details
|
||||||
attributes:
|
attributes:
|
||||||
label: Other details (reason for removal, etc)
|
label: Other details
|
||||||
placeholder: |
|
placeholder: |
|
||||||
Additional details and attachments.
|
Additional details and attachments.
|
||||||
|
|
||||||
- type: checkboxes
|
- type: checkboxes
|
||||||
id: requirements
|
id: requirements
|
||||||
attributes:
|
attributes:
|
||||||
label: Requirements
|
label: Requirements
|
||||||
description: Your request will be denied if you don't meet these requirements.
|
description: Your request will be denied if you don't meet these requirements.
|
||||||
options:
|
options:
|
||||||
- label: Proof of ownership of the website is sent to a Kotatsu [Discord server](https://discord.gg/NNJ5RgVBC5) or [Telegram community](https://t.me/kotatsuapp)
|
- label: Proof of ownership/intent to remove sent to a Kotatsu Discord server mod via DM
|
||||||
required: true
|
required: true
|
||||||
- label: Site only hosts content scanlated by the group and not stolen from other scanlators or official releases (i.e., not an aggregator site)
|
- label: Site only hosts content scanlated by the group and not stolen from other scanlators or official releases (i.e., not an aggregator site)
|
||||||
required: true
|
required: true
|
||||||
|
- label: Site is not infested with user-hostile features (e.g., invasive or malicious ads)
|
||||||
|
required: true
|
||||||
@ -1,53 +1,55 @@
|
|||||||
name: 🌐 Source request
|
name: 🌐 Source request
|
||||||
description: Suggest a new source for Kotatsu
|
description: Suggest a new source for Kotatsu
|
||||||
labels: [ source request ]
|
labels: [source request]
|
||||||
body:
|
body:
|
||||||
|
|
||||||
- type: markdown
|
- type: input
|
||||||
attributes:
|
id: name
|
||||||
value: Please specify source **name** and **language** in the issue title
|
attributes:
|
||||||
- type: input
|
label: Source name
|
||||||
id: name
|
placeholder: |
|
||||||
attributes:
|
Example: "Example Scans"
|
||||||
label: Source name
|
validations:
|
||||||
placeholder: |
|
required: true
|
||||||
Example: "Example Scans"
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
|
|
||||||
- type: input
|
- type: input
|
||||||
id: link
|
id: link
|
||||||
attributes:
|
attributes:
|
||||||
label: Source link
|
label: Source link
|
||||||
placeholder: |
|
placeholder: |
|
||||||
Example: "https://example.org"
|
Example: "https://example.org"
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
- type: input
|
- type: input
|
||||||
id: language
|
id: language
|
||||||
attributes:
|
attributes:
|
||||||
label: Language
|
label: Language
|
||||||
placeholder: |
|
placeholder: |
|
||||||
Example: "English"
|
Example: "English"
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: other-details
|
id: other-details
|
||||||
attributes:
|
attributes:
|
||||||
label: Other details
|
label: Other details
|
||||||
placeholder: |
|
placeholder: |
|
||||||
Additional details and attachments.
|
Additional details and attachments.
|
||||||
|
|
||||||
- type: checkboxes
|
- type: checkboxes
|
||||||
id: acknowledgements
|
id: acknowledgements
|
||||||
attributes:
|
attributes:
|
||||||
label: Acknowledgements
|
label: Acknowledgements
|
||||||
options:
|
description: Your issue will be closed if you haven't done these steps.
|
||||||
- label: I have searched the existing issues and this is a new ticket, **NOT** a duplicate or related to another open issue.
|
options:
|
||||||
required: true
|
- label: I have searched the existing issues and this is a new ticket, **NOT** a duplicate or related to another open issue.
|
||||||
- label: I have checked that the source does not already exist on the app.
|
required: true
|
||||||
required: true
|
- label: I have written a title with source name.
|
||||||
- label: I have checked that the source does not already exist by searching the [GitHub repository](https://github.com/KotatsuApp/kotatsu-parsers) and verified it does not appear in the code base.
|
required: true
|
||||||
required: true
|
- label: I have checked that the source does not already exist on the app.
|
||||||
|
required: true
|
||||||
|
- label: I have checked that the source does not already exist by searching the [GitHub repository](https://github.com/nv95/kotatsu-parsers) and verified it does not appear in the code base.
|
||||||
|
required: true
|
||||||
|
- label: I will fill out all of the requested information in this form.
|
||||||
|
required: true
|
||||||
@ -1 +0,0 @@
|
|||||||
total: 1256
|
|
||||||
@ -1,25 +0,0 @@
|
|||||||
name: Check & Test latest parsers
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- master
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
check-and-build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository 🌏
|
|
||||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
|
||||||
|
|
||||||
- name: Set up enviroment 🔧
|
|
||||||
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
|
||||||
with:
|
|
||||||
java-version: '21'
|
|
||||||
distribution: 'temurin'
|
|
||||||
|
|
||||||
- name: Set up Gradle 📦
|
|
||||||
uses: gradle/actions/setup-gradle@ed408507eac070d1f99cc633dbcf757c94c7933a # v4.4.3
|
|
||||||
|
|
||||||
- name: Compile parsers 🚀
|
|
||||||
run: ./gradlew compileKotlin
|
|
||||||
@ -1,29 +1,28 @@
|
|||||||
name: Parsers test for PRs
|
name: Parsers test
|
||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
pull_request:
|
||||||
pull_request:
|
branches: [ "master" ]
|
||||||
paths:
|
paths:
|
||||||
- 'src/main/kotlin/org/koitharu/kotatsu/parsers/**'
|
- 'src/main/kotlin/org/koitharu/kotatsu/parsers/site'
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-and-test:
|
build:
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository 🌏
|
|
||||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
|
||||||
|
|
||||||
- name: Set up enviroment 🔧
|
runs-on: ubuntu-latest
|
||||||
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
|
||||||
with:
|
|
||||||
java-version: '21'
|
|
||||||
distribution: 'temurin'
|
|
||||||
|
|
||||||
- name: Set up Gradle 📦
|
steps:
|
||||||
uses: gradle/actions/setup-gradle@ed408507eac070d1f99cc633dbcf757c94c7933a # v4.4.3
|
- uses: actions/checkout@v3
|
||||||
|
- uses: actions/setup-java@v3
|
||||||
- name: Compile parsers 🚀
|
with:
|
||||||
run: ./gradlew compileKotlin
|
java-version: '11'
|
||||||
|
distribution: 'temurin'
|
||||||
|
cache: 'gradle'
|
||||||
|
- run: ./gradlew :test --tests "org.koitharu.kotatsu.parsers.MangaParserTest"
|
||||||
|
- uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: Report
|
||||||
|
path: build/reports/tests/test
|
||||||
@ -1,96 +0,0 @@
|
|||||||
# Contributing
|
|
||||||
|
|
||||||
The following is a guide for creating Kotatsu parsers. Thanks for taking the time to contribute!
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
Before you start, please note that the ability to use the 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 are not a part of the 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 the `kotatsu-parsers` project structure. Each parser is a single class that
|
|
||||||
extends the `MangaParser` class and has a `MangaSourceParser` annotation.
|
|
||||||
Also, pay attention to extensions in the `util` package. For example, extensions from the `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 to learn about 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/ru/DesuMeParser.kt)
|
|
||||||
of Json API usage.
|
|
||||||
- [Example](https://github.com/KotatsuApp/kotatsu-parsers/blob/master/src/main/kotlin/org/koitharu/kotatsu/parsers/site/be/AnibelParser.kt)
|
|
||||||
of GraphQL API usage
|
|
||||||
- [Example](https://github.com/KotatsuApp/kotatsu-parsers/blob/master/src/main/kotlin/org/koitharu/kotatsu/parsers/site/en/MangaTownParser.kt)
|
|
||||||
of pure HTML parsing.
|
|
||||||
|
|
||||||
If the website is based on some engine it is rationally to use a common base class for this one (for example, Madara
|
|
||||||
Wordpress theme and the `MadaraParser` class)
|
|
||||||
|
|
||||||
### Parser class skeleton
|
|
||||||
|
|
||||||
The parser class must have exactly one primary constructor parameter of type `MangaLoaderContext` and have an
|
|
||||||
`MangaSourceParser` annotation that provides the internal name, title, and language of a manga source.
|
|
||||||
|
|
||||||
All members of the `MangaParser` class are documented. Pay attention to some peculiarities:
|
|
||||||
|
|
||||||
- Never hardcode domain. Specify the default domain in the `configKeyDomain` field and obtain an actual one using
|
|
||||||
`domain`.
|
|
||||||
- All IDs must be unique and domain-independent. Use `generateUid` functions with a relative URL or some internal id
|
|
||||||
that is unique across the manga source.
|
|
||||||
- The `availableSortOrders` set should not be empty. If your source does not support sorting, specify one most relevant
|
|
||||||
value.
|
|
||||||
- If you cannot obtain direct links to page images inside the `getPages` method, it is ok to use an intermediate URL
|
|
||||||
as `Page.url` and fetch a direct link in the `getPageUrl` function.
|
|
||||||
- You can use _asserts_ to check some optional fields. For example, the `Manga.author` field is not required, but if
|
|
||||||
your source provides this information, add `assert(it != null)`. This will not have any effect on production but help
|
|
||||||
to find issues during unit testing.
|
|
||||||
- Your parser may also implement the `Interceptor` interface for additional manipulation of all network requests and
|
|
||||||
responses, including image loading.
|
|
||||||
- If your source website (or its API) uses pages for pagination instead of offset you should extend `PagedMangaParser`
|
|
||||||
instead of `MangaParser`.
|
|
||||||
- If your source website (or its API) does not provide pagination (has only one page of content) you should extend
|
|
||||||
`SinglePageMangaParser` instead of `MangaParser` or `PagedMangaParser`.
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
## Development process
|
|
||||||
|
|
||||||
During the development, it is recommended (but not necessary) to write it directly
|
|
||||||
in the Kotatsu Android application project. You can use the `core.parser.DummyParser` class as a sandbox. The `Dummy`
|
|
||||||
manga source is available in the debug Kotatsu build.
|
|
||||||
|
|
||||||
Once the parser is ready you can relocate your code into the `kotatsu-parsers` library project in a `site` package and
|
|
||||||
create a Pull Request.
|
|
||||||
|
|
||||||
### Testing
|
|
||||||
|
|
||||||
It is recommended that unit tests be run 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 help or have some questions, ask a community in our [Telegram chat](https://t.me/kotatsuapp)
|
|
||||||
or [Discord server](https://discord.gg/NNJ5RgVBC5).
|
|
||||||
@ -1,74 +1,49 @@
|
|||||||
# Kotatsu parsers
|
# Kotatsu parsers
|
||||||
|
|
||||||
This library provides a collection of manga parsers for convenient access manga available on the web. It can be used in
|
Library that provides manga sources.
|
||||||
JVM and Android applications.
|
|
||||||
|
|
||||||
 [](https://jitpack.io/#KotatsuApp/kotatsu-parsers)  [](https://t.me/kotatsuapp) [](https://discord.gg/NNJ5RgVBC5)
|
[](https://jitpack.io/#nv95/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 {
|
||||||
repositories {
|
repositories {
|
||||||
...
|
...
|
||||||
maven { url 'https://jitpack.io' }
|
maven { url 'https://jitpack.io' }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Add the dependency
|
2. Add the dependency
|
||||||
|
|
||||||
For Java/Kotlin project:
|
For Java/Kotlin project:
|
||||||
```groovy
|
```groovy
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation("com.github.KotatsuApp:kotatsu-parsers:$parsers_version")
|
implementation("com.github.nv95:kotatsu-parsers:$parsers_version")
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
For Android project:
|
For Android project:
|
||||||
```groovy
|
```groovy
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation("com.github.KotatsuApp:kotatsu-parsers:$parsers_version") {
|
implementation("com.github.nv95:kotatsu-parsers:$parsers_version") {
|
||||||
exclude group: 'org.json', module: 'json'
|
exclude group: 'org.json', module: 'json'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Versions are available on [JitPack](https://jitpack.io/#KotatsuApp/kotatsu-parsers)
|
See for versions at [JitPack](https://jitpack.io/#nv95/kotatsu-parsers)
|
||||||
|
|
||||||
When used in Android
|
|
||||||
projects, [core library desugaring](https://developer.android.com/studio/write/java8-support#library-desugaring) with
|
|
||||||
the [NIO specification](https://developer.android.com/studio/write/java11-nio-support-table) should be enabled to
|
|
||||||
support Java 8+ features.
|
|
||||||
|
|
||||||
|
|
||||||
3. Usage in code
|
3. Usage in code
|
||||||
|
|
||||||
```kotlin
|
```kotlin
|
||||||
val parser = mangaLoaderContext.newParserInstance(MangaParserSource.MANGADEX)
|
val parser = MangaSource.MANGADEX.newParser(mangaLoaderContext)
|
||||||
```
|
```
|
||||||
|
|
||||||
`mangaLoaderContext` is an implementation of the `MangaLoaderContext` class.
|
`mangaLoaderContext` is an implementation of the `MangaLoaderContext` class.
|
||||||
See examples
|
See [Android](https://github.com/nv95/Kotatsu/blob/devel/app/src/main/java/org/koitharu/kotatsu/core/parser/MangaLoaderContextImpl.kt) and [Non-Android](https://github.com/nv95/kotatsu-dl/blob/master/src/main/kotlin/org/koitharu/kotatsu_dl/env/MangaLoaderContextImpl.kt) implementation examples.
|
||||||
of [Android](https://github.com/KotatsuApp/Kotatsu/blob/devel/app/src/main/kotlin/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/parsers/MangaLoaderContextImpl.kt)
|
|
||||||
implementation.
|
|
||||||
|
|
||||||
## Projects that use the library
|
|
||||||
|
|
||||||
- [Kotatsu](https://github.com/KotatsuApp/Kotatsu)
|
|
||||||
- [Doki](https://github.com/DokiTeam/Doki)
|
|
||||||
- [kotatsu-dl](https://github.com/KotatsuApp/kotatsu-dl)
|
|
||||||
- [Shirizu (WIP)](https://github.com/ztimms73/shirizu)
|
|
||||||
- [OtakuWorld](https://github.com/jakepurple13/OtakuWorld)
|
|
||||||
|
|
||||||
## 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
|
Note that the `MangaSource.LOCAL` and `MangaSource.DUMMY` parsers cannot be instantiated.
|
||||||
sources freely available through any web browser.
|
|
||||||
@ -0,0 +1,68 @@
|
|||||||
|
plugins {
|
||||||
|
id 'java-library'
|
||||||
|
id 'org.jetbrains.kotlin.jvm'
|
||||||
|
id 'com.google.devtools.ksp'
|
||||||
|
id 'maven-publish'
|
||||||
|
}
|
||||||
|
|
||||||
|
group = 'org.koitharu'
|
||||||
|
version = '1.0'
|
||||||
|
|
||||||
|
test {
|
||||||
|
useJUnitPlatform()
|
||||||
|
}
|
||||||
|
|
||||||
|
compileKotlin {
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = '1.8'
|
||||||
|
freeCompilerArgs += [
|
||||||
|
'-opt-in=kotlin.RequiresOptIn',
|
||||||
|
'-opt-in=kotlin.contracts.ExperimentalContracts',
|
||||||
|
'-opt-in=org.koitharu.kotatsu.parsers.InternalParsersApi',
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
compileTestKotlin {
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = '1.8'
|
||||||
|
freeCompilerArgs += [
|
||||||
|
'-opt-in=kotlin.RequiresOptIn',
|
||||||
|
'-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi',
|
||||||
|
'-opt-in=org.koitharu.kotatsu.parsers.InternalParsersApi',
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlin {
|
||||||
|
sourceSets {
|
||||||
|
main.kotlin.srcDirs += 'build/generated/ksp/main/kotlin'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEvaluate {
|
||||||
|
publishing {
|
||||||
|
publications {
|
||||||
|
mavenJava(MavenPublication) {
|
||||||
|
from components.java
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.2'
|
||||||
|
implementation 'com.squareup.okhttp3:okhttp:4.10.0'
|
||||||
|
implementation 'com.squareup.okio:okio:3.1.0'
|
||||||
|
implementation 'org.jsoup:jsoup:1.15.1'
|
||||||
|
implementation 'org.json:json:20220320'
|
||||||
|
implementation 'androidx.collection:collection-ktx:1.2.0'
|
||||||
|
|
||||||
|
ksp project(':kotatsu-parsers-ksp')
|
||||||
|
|
||||||
|
testImplementation 'org.junit.jupiter:junit-jupiter-api:5.8.2'
|
||||||
|
testImplementation 'org.junit.jupiter:junit-jupiter-engine:5.8.2'
|
||||||
|
testImplementation 'org.junit.jupiter:junit-jupiter-params:5.8.2'
|
||||||
|
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.2'
|
||||||
|
testImplementation 'io.webfolder:quickjs:1.1.0'
|
||||||
|
}
|
||||||
@ -1,64 +0,0 @@
|
|||||||
import tasks.ReportGenerateTask
|
|
||||||
|
|
||||||
plugins {
|
|
||||||
`java-library`
|
|
||||||
`maven-publish`
|
|
||||||
alias(libs.plugins.kotlin.jvm)
|
|
||||||
alias(libs.plugins.ksp)
|
|
||||||
}
|
|
||||||
|
|
||||||
group = "org.koitharu"
|
|
||||||
version = "1.0"
|
|
||||||
|
|
||||||
tasks.test {
|
|
||||||
useJUnitPlatform()
|
|
||||||
}
|
|
||||||
|
|
||||||
ksp {
|
|
||||||
arg("summaryOutputDir", "${projectDir}/.github")
|
|
||||||
}
|
|
||||||
|
|
||||||
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach {
|
|
||||||
compilerOptions {
|
|
||||||
freeCompilerArgs.addAll(
|
|
||||||
"-opt-in=kotlin.RequiresOptIn",
|
|
||||||
"-opt-in=kotlin.contracts.ExperimentalContracts",
|
|
||||||
"-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi",
|
|
||||||
"-opt-in=org.koitharu.kotatsu.parsers.InternalParsersApi",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
kotlin {
|
|
||||||
jvmToolchain(8)
|
|
||||||
explicitApiWarning()
|
|
||||||
sourceSets["main"].kotlin.srcDirs("build/generated/ksp/main/kotlin")
|
|
||||||
}
|
|
||||||
|
|
||||||
publishing {
|
|
||||||
publications {
|
|
||||||
create<MavenPublication>("mavenJava") {
|
|
||||||
from(components["java"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dependencies {
|
|
||||||
implementation(libs.kotlinx.coroutines.core)
|
|
||||||
implementation(libs.okhttp)
|
|
||||||
implementation(libs.okio)
|
|
||||||
implementation(libs.json)
|
|
||||||
implementation(libs.androidx.collection)
|
|
||||||
api(libs.jsoup)
|
|
||||||
|
|
||||||
ksp(project(":kotatsu-parsers-ksp"))
|
|
||||||
|
|
||||||
testImplementation(libs.junit.api)
|
|
||||||
testImplementation(libs.junit.engine)
|
|
||||||
testImplementation(libs.junit.params)
|
|
||||||
testRuntimeOnly(libs.junit.launcher)
|
|
||||||
testImplementation(libs.kotlinx.coroutines.test)
|
|
||||||
testImplementation(libs.quickjs)
|
|
||||||
}
|
|
||||||
|
|
||||||
tasks.register<ReportGenerateTask>("generateTestsReport")
|
|
||||||
@ -1,17 +0,0 @@
|
|||||||
plugins {
|
|
||||||
`kotlin-dsl`
|
|
||||||
}
|
|
||||||
|
|
||||||
repositories {
|
|
||||||
mavenCentral()
|
|
||||||
}
|
|
||||||
|
|
||||||
kotlin {
|
|
||||||
jvmToolchain(8)
|
|
||||||
}
|
|
||||||
|
|
||||||
dependencies {
|
|
||||||
implementation(libs.korte)
|
|
||||||
implementation(libs.simplexml)
|
|
||||||
implementation(libs.kotlinx.coroutines.core)
|
|
||||||
}
|
|
||||||
Binary file not shown.
@ -1,7 +0,0 @@
|
|||||||
distributionBase=GRADLE_USER_HOME
|
|
||||||
distributionPath=wrapper/dists
|
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.0.0-bin.zip
|
|
||||||
networkTimeout=10000
|
|
||||||
validateDistributionUrl=true
|
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
|
||||||
zipStorePath=wrapper/dists
|
|
||||||
@ -1,251 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
|
|
||||||
#
|
|
||||||
# Copyright © 2015-2021 the original authors.
|
|
||||||
#
|
|
||||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
# you may not use this file except in compliance with the License.
|
|
||||||
# You may obtain a copy of the License at
|
|
||||||
#
|
|
||||||
# https://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
#
|
|
||||||
# Unless required by applicable law or agreed to in writing, software
|
|
||||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
# See the License for the specific language governing permissions and
|
|
||||||
# limitations under the License.
|
|
||||||
#
|
|
||||||
# SPDX-License-Identifier: Apache-2.0
|
|
||||||
#
|
|
||||||
|
|
||||||
##############################################################################
|
|
||||||
#
|
|
||||||
# Gradle start up script for POSIX generated by Gradle.
|
|
||||||
#
|
|
||||||
# Important for running:
|
|
||||||
#
|
|
||||||
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
|
||||||
# noncompliant, but you have some other compliant shell such as ksh or
|
|
||||||
# bash, then to run this script, type that shell name before the whole
|
|
||||||
# command line, like:
|
|
||||||
#
|
|
||||||
# ksh Gradle
|
|
||||||
#
|
|
||||||
# Busybox and similar reduced shells will NOT work, because this script
|
|
||||||
# requires all of these POSIX shell features:
|
|
||||||
# * functions;
|
|
||||||
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
|
||||||
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
|
||||||
# * compound commands having a testable exit status, especially «case»;
|
|
||||||
# * various built-in commands including «command», «set», and «ulimit».
|
|
||||||
#
|
|
||||||
# Important for patching:
|
|
||||||
#
|
|
||||||
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
|
||||||
# by Bash, Ksh, etc; in particular arrays are avoided.
|
|
||||||
#
|
|
||||||
# The "traditional" practice of packing multiple parameters into a
|
|
||||||
# space-separated string is a well documented source of bugs and security
|
|
||||||
# problems, so this is (mostly) avoided, by progressively accumulating
|
|
||||||
# options in "$@", and eventually passing that to Java.
|
|
||||||
#
|
|
||||||
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
|
||||||
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
|
||||||
# see the in-line comments for details.
|
|
||||||
#
|
|
||||||
# There are tweaks for specific operating systems such as AIX, CygWin,
|
|
||||||
# Darwin, MinGW, and NonStop.
|
|
||||||
#
|
|
||||||
# (3) This script is generated from the Groovy template
|
|
||||||
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
|
||||||
# within the Gradle project.
|
|
||||||
#
|
|
||||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
|
||||||
#
|
|
||||||
##############################################################################
|
|
||||||
|
|
||||||
# Attempt to set APP_HOME
|
|
||||||
|
|
||||||
# Resolve links: $0 may be a link
|
|
||||||
app_path=$0
|
|
||||||
|
|
||||||
# Need this for daisy-chained symlinks.
|
|
||||||
while
|
|
||||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
|
||||||
[ -h "$app_path" ]
|
|
||||||
do
|
|
||||||
ls=$( ls -ld "$app_path" )
|
|
||||||
link=${ls#*' -> '}
|
|
||||||
case $link in #(
|
|
||||||
/*) app_path=$link ;; #(
|
|
||||||
*) app_path=$APP_HOME$link ;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
|
|
||||||
# This is normally unused
|
|
||||||
# shellcheck disable=SC2034
|
|
||||||
APP_BASE_NAME=${0##*/}
|
|
||||||
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
|
||||||
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
|
||||||
|
|
||||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
|
||||||
MAX_FD=maximum
|
|
||||||
|
|
||||||
warn () {
|
|
||||||
echo "$*"
|
|
||||||
} >&2
|
|
||||||
|
|
||||||
die () {
|
|
||||||
echo
|
|
||||||
echo "$*"
|
|
||||||
echo
|
|
||||||
exit 1
|
|
||||||
} >&2
|
|
||||||
|
|
||||||
# OS specific support (must be 'true' or 'false').
|
|
||||||
cygwin=false
|
|
||||||
msys=false
|
|
||||||
darwin=false
|
|
||||||
nonstop=false
|
|
||||||
case "$( uname )" in #(
|
|
||||||
CYGWIN* ) cygwin=true ;; #(
|
|
||||||
Darwin* ) darwin=true ;; #(
|
|
||||||
MSYS* | MINGW* ) msys=true ;; #(
|
|
||||||
NONSTOP* ) nonstop=true ;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
CLASSPATH="\\\"\\\""
|
|
||||||
|
|
||||||
|
|
||||||
# Determine the Java command to use to start the JVM.
|
|
||||||
if [ -n "$JAVA_HOME" ] ; then
|
|
||||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
|
||||||
# IBM's JDK on AIX uses strange locations for the executables
|
|
||||||
JAVACMD=$JAVA_HOME/jre/sh/java
|
|
||||||
else
|
|
||||||
JAVACMD=$JAVA_HOME/bin/java
|
|
||||||
fi
|
|
||||||
if [ ! -x "$JAVACMD" ] ; then
|
|
||||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
|
||||||
|
|
||||||
Please set the JAVA_HOME variable in your environment to match the
|
|
||||||
location of your Java installation."
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
JAVACMD=java
|
|
||||||
if ! command -v java >/dev/null 2>&1
|
|
||||||
then
|
|
||||||
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
|
||||||
|
|
||||||
Please set the JAVA_HOME variable in your environment to match the
|
|
||||||
location of your Java installation."
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Increase the maximum file descriptors if we can.
|
|
||||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
|
||||||
case $MAX_FD in #(
|
|
||||||
max*)
|
|
||||||
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
|
||||||
# shellcheck disable=SC2039,SC3045
|
|
||||||
MAX_FD=$( ulimit -H -n ) ||
|
|
||||||
warn "Could not query maximum file descriptor limit"
|
|
||||||
esac
|
|
||||||
case $MAX_FD in #(
|
|
||||||
'' | soft) :;; #(
|
|
||||||
*)
|
|
||||||
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
|
||||||
# shellcheck disable=SC2039,SC3045
|
|
||||||
ulimit -n "$MAX_FD" ||
|
|
||||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
|
||||||
esac
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Collect all arguments for the java command, stacking in reverse order:
|
|
||||||
# * args from the command line
|
|
||||||
# * the main class name
|
|
||||||
# * -classpath
|
|
||||||
# * -D...appname settings
|
|
||||||
# * --module-path (only if needed)
|
|
||||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
|
||||||
|
|
||||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
|
||||||
if "$cygwin" || "$msys" ; then
|
|
||||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
|
||||||
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
|
||||||
|
|
||||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
|
||||||
|
|
||||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
|
||||||
for arg do
|
|
||||||
if
|
|
||||||
case $arg in #(
|
|
||||||
-*) false ;; # don't mess with options #(
|
|
||||||
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
|
||||||
[ -e "$t" ] ;; #(
|
|
||||||
*) false ;;
|
|
||||||
esac
|
|
||||||
then
|
|
||||||
arg=$( cygpath --path --ignore --mixed "$arg" )
|
|
||||||
fi
|
|
||||||
# Roll the args list around exactly as many times as the number of
|
|
||||||
# args, so each arg winds up back in the position where it started, but
|
|
||||||
# possibly modified.
|
|
||||||
#
|
|
||||||
# NB: a `for` loop captures its iteration list before it begins, so
|
|
||||||
# changing the positional parameters here affects neither the number of
|
|
||||||
# iterations, nor the values presented in `arg`.
|
|
||||||
shift # remove old arg
|
|
||||||
set -- "$@" "$arg" # push replacement arg
|
|
||||||
done
|
|
||||||
fi
|
|
||||||
|
|
||||||
|
|
||||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
|
||||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
|
||||||
|
|
||||||
# Collect all arguments for the java command:
|
|
||||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
|
||||||
# and any embedded shellness will be escaped.
|
|
||||||
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
|
||||||
# treated as '${Hostname}' itself on the command line.
|
|
||||||
|
|
||||||
set -- \
|
|
||||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
|
||||||
-classpath "$CLASSPATH" \
|
|
||||||
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
|
||||||
"$@"
|
|
||||||
|
|
||||||
# Stop when "xargs" is not available.
|
|
||||||
if ! command -v xargs >/dev/null 2>&1
|
|
||||||
then
|
|
||||||
die "xargs is not available"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Use "xargs" to parse quoted args.
|
|
||||||
#
|
|
||||||
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
|
||||||
#
|
|
||||||
# In Bash we could simply go:
|
|
||||||
#
|
|
||||||
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
|
||||||
# set -- "${ARGS[@]}" "$@"
|
|
||||||
#
|
|
||||||
# but POSIX shell has neither arrays nor command substitution, so instead we
|
|
||||||
# post-process each arg (as a line of input to sed) to backslash-escape any
|
|
||||||
# character that might be a shell metacharacter, then use eval to reverse
|
|
||||||
# that process (while maintaining the separation between arguments), and wrap
|
|
||||||
# the whole thing up as a single "set" statement.
|
|
||||||
#
|
|
||||||
# This will of course break if any of these variables contains a newline or
|
|
||||||
# an unmatched quote.
|
|
||||||
#
|
|
||||||
|
|
||||||
eval "set -- $(
|
|
||||||
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
|
||||||
xargs -n1 |
|
|
||||||
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
|
||||||
tr '\n' ' '
|
|
||||||
)" '"$@"'
|
|
||||||
|
|
||||||
exec "$JAVACMD" "$@"
|
|
||||||
@ -1,94 +0,0 @@
|
|||||||
@rem
|
|
||||||
@rem Copyright 2015 the original author or authors.
|
|
||||||
@rem
|
|
||||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
@rem you may not use this file except in compliance with the License.
|
|
||||||
@rem You may obtain a copy of the License at
|
|
||||||
@rem
|
|
||||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
@rem
|
|
||||||
@rem Unless required by applicable law or agreed to in writing, software
|
|
||||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
@rem See the License for the specific language governing permissions and
|
|
||||||
@rem limitations under the License.
|
|
||||||
@rem
|
|
||||||
@rem SPDX-License-Identifier: Apache-2.0
|
|
||||||
@rem
|
|
||||||
|
|
||||||
@if "%DEBUG%"=="" @echo off
|
|
||||||
@rem ##########################################################################
|
|
||||||
@rem
|
|
||||||
@rem Gradle startup script for Windows
|
|
||||||
@rem
|
|
||||||
@rem ##########################################################################
|
|
||||||
|
|
||||||
@rem Set local scope for the variables with windows NT shell
|
|
||||||
if "%OS%"=="Windows_NT" setlocal
|
|
||||||
|
|
||||||
set DIRNAME=%~dp0
|
|
||||||
if "%DIRNAME%"=="" set DIRNAME=.
|
|
||||||
@rem This is normally unused
|
|
||||||
set APP_BASE_NAME=%~n0
|
|
||||||
set APP_HOME=%DIRNAME%
|
|
||||||
|
|
||||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
|
||||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
|
||||||
|
|
||||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
|
||||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
|
||||||
|
|
||||||
@rem Find java.exe
|
|
||||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
|
||||||
|
|
||||||
set JAVA_EXE=java.exe
|
|
||||||
%JAVA_EXE% -version >NUL 2>&1
|
|
||||||
if %ERRORLEVEL% equ 0 goto execute
|
|
||||||
|
|
||||||
echo. 1>&2
|
|
||||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
|
||||||
echo. 1>&2
|
|
||||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
|
||||||
echo location of your Java installation. 1>&2
|
|
||||||
|
|
||||||
goto fail
|
|
||||||
|
|
||||||
:findJavaFromJavaHome
|
|
||||||
set JAVA_HOME=%JAVA_HOME:"=%
|
|
||||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
|
||||||
|
|
||||||
if exist "%JAVA_EXE%" goto execute
|
|
||||||
|
|
||||||
echo. 1>&2
|
|
||||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
|
||||||
echo. 1>&2
|
|
||||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
|
||||||
echo location of your Java installation. 1>&2
|
|
||||||
|
|
||||||
goto fail
|
|
||||||
|
|
||||||
:execute
|
|
||||||
@rem Setup the command line
|
|
||||||
|
|
||||||
set CLASSPATH=
|
|
||||||
|
|
||||||
|
|
||||||
@rem Execute Gradle
|
|
||||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
|
|
||||||
|
|
||||||
:end
|
|
||||||
@rem End local scope for the variables with windows NT shell
|
|
||||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
|
||||||
|
|
||||||
:fail
|
|
||||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
|
||||||
rem the _cmd.exe /c_ return code!
|
|
||||||
set EXIT_CODE=%ERRORLEVEL%
|
|
||||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
|
||||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
|
||||||
exit /b %EXIT_CODE%
|
|
||||||
|
|
||||||
:mainEnd
|
|
||||||
if "%OS%"=="Windows_NT" endlocal
|
|
||||||
|
|
||||||
:omega
|
|
||||||
@ -1,7 +0,0 @@
|
|||||||
dependencyResolutionManagement {
|
|
||||||
versionCatalogs {
|
|
||||||
create("libs") {
|
|
||||||
from(files("../gradle/libs.versions.toml"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,71 +0,0 @@
|
|||||||
package tasks
|
|
||||||
|
|
||||||
import korlibs.template.Template
|
|
||||||
import kotlinx.coroutines.runBlocking
|
|
||||||
import org.gradle.api.DefaultTask
|
|
||||||
import org.gradle.api.tasks.TaskAction
|
|
||||||
import org.simpleframework.xml.Serializer
|
|
||||||
import org.simpleframework.xml.core.Persister
|
|
||||||
import tasks.model.TestCase
|
|
||||||
import tasks.model.TestSuite
|
|
||||||
import java.io.File
|
|
||||||
import kotlin.math.roundToInt
|
|
||||||
|
|
||||||
open class ReportGenerateTask : DefaultTask() {
|
|
||||||
|
|
||||||
@TaskAction
|
|
||||||
fun execute() {
|
|
||||||
val reportsRoot = File(project.rootDir, "build/test-results/test")
|
|
||||||
val outputRoot = File(project.rootDir, "build/test-results-html")
|
|
||||||
outputRoot.deleteRecursively()
|
|
||||||
outputRoot.mkdir()
|
|
||||||
for (file in checkNotNull(reportsRoot.listFiles()) {
|
|
||||||
"No files found in $reportsRoot"
|
|
||||||
}) {
|
|
||||||
if (file.isDirectory) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
val report = makeReport(file)
|
|
||||||
val output = File(outputRoot, file.nameWithoutExtension + ".htm")
|
|
||||||
output.writeText(report)
|
|
||||||
println("Report generated: ${output.absolutePath}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun makeReport(file: File): String {
|
|
||||||
val serializer: Serializer = Persister()
|
|
||||||
val testSuite = serializer.read(TestSuite::class.java, file)
|
|
||||||
val templateText = javaClass.classLoader.getResourceAsStream("report.html")?.use {
|
|
||||||
it.bufferedReader().readText()
|
|
||||||
}
|
|
||||||
|
|
||||||
val results = LinkedHashMap<String, LinkedHashMap<String, TestCase>>()
|
|
||||||
val tests = LinkedHashSet<String>()
|
|
||||||
for (case in testSuite.testCases) {
|
|
||||||
if (!case.isValid()) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
tests.add(case.testName)
|
|
||||||
val map = results.getOrPut(case.source) { LinkedHashMap() }
|
|
||||||
val oldValue = map.put(case.testName, case)
|
|
||||||
check(oldValue == null) { "Check failed: $oldValue" }
|
|
||||||
}
|
|
||||||
|
|
||||||
val failPercent = (testSuite.failures.toDouble() / testSuite.tests * 100.0).roundToInt()
|
|
||||||
val errorPercent = (testSuite.errors.toDouble() / testSuite.tests * 100.0).roundToInt()
|
|
||||||
return runBlocking {
|
|
||||||
val template = Template(requireNotNull(templateText))
|
|
||||||
template(
|
|
||||||
mapOf(
|
|
||||||
"testSuite" to testSuite,
|
|
||||||
"tests" to tests,
|
|
||||||
"results" to results,
|
|
||||||
"success_percent" to 100 - (failPercent + errorPercent),
|
|
||||||
"error_percent" to errorPercent,
|
|
||||||
"fail_percent" to failPercent,
|
|
||||||
"success" to testSuite.tests - (testSuite.failures + testSuite.errors),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,24 +0,0 @@
|
|||||||
package tasks.model
|
|
||||||
|
|
||||||
import org.simpleframework.xml.Attribute
|
|
||||||
import org.simpleframework.xml.Root
|
|
||||||
import org.simpleframework.xml.Text
|
|
||||||
|
|
||||||
@Root(name = "failure", strict = false)
|
|
||||||
class Failure {
|
|
||||||
@JvmField
|
|
||||||
@field:Attribute(name = "message")
|
|
||||||
var message: String = ""
|
|
||||||
|
|
||||||
@JvmField
|
|
||||||
@field:Attribute(name = "type")
|
|
||||||
var type: String = ""
|
|
||||||
|
|
||||||
@JvmField
|
|
||||||
@field:Text
|
|
||||||
var text: String = ""
|
|
||||||
|
|
||||||
fun textHtml(): String {
|
|
||||||
return text.replace("\n", "<br>")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,34 +0,0 @@
|
|||||||
package tasks.model
|
|
||||||
|
|
||||||
import org.simpleframework.xml.Attribute
|
|
||||||
import org.simpleframework.xml.Element
|
|
||||||
import org.simpleframework.xml.Root
|
|
||||||
|
|
||||||
@Root(name = "testcase", strict = false)
|
|
||||||
class TestCase {
|
|
||||||
@JvmField
|
|
||||||
@field:Attribute(name = "name")
|
|
||||||
var name: String = ""
|
|
||||||
|
|
||||||
@JvmField
|
|
||||||
@field:Attribute(name = "time")
|
|
||||||
var time: Float = 0f
|
|
||||||
|
|
||||||
@JvmField
|
|
||||||
@field:Element(name = "failure", required = false)
|
|
||||||
var failure: Failure? = null
|
|
||||||
|
|
||||||
val index by lazy {
|
|
||||||
name.split('|').getOrNull(0)?.toIntOrNull() ?: 0
|
|
||||||
}
|
|
||||||
|
|
||||||
val testName by lazy {
|
|
||||||
name.split('|').getOrNull(1).orEmpty()
|
|
||||||
}
|
|
||||||
|
|
||||||
val source by lazy {
|
|
||||||
name.split('|').getOrNull(2).orEmpty()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun isValid() = name.count { it == '|' } == 2
|
|
||||||
}
|
|
||||||
@ -1,27 +0,0 @@
|
|||||||
package tasks.model
|
|
||||||
|
|
||||||
import org.simpleframework.xml.Attribute
|
|
||||||
import org.simpleframework.xml.ElementList
|
|
||||||
import org.simpleframework.xml.Root
|
|
||||||
|
|
||||||
@Root(name = "testsuite", strict = false)
|
|
||||||
class TestSuite {
|
|
||||||
@JvmField
|
|
||||||
@field:Attribute(name = "name")
|
|
||||||
var name: String = ""
|
|
||||||
|
|
||||||
@JvmField
|
|
||||||
@field:Attribute(name = "tests")
|
|
||||||
var tests: Int = 0
|
|
||||||
|
|
||||||
@JvmField
|
|
||||||
@field:Attribute(name = "failures")
|
|
||||||
var failures: Int = 0
|
|
||||||
|
|
||||||
@JvmField
|
|
||||||
@field:Attribute(name = "errors")
|
|
||||||
var errors: Int = 0
|
|
||||||
|
|
||||||
@field:ElementList(entry = "testcase", inline = true)
|
|
||||||
lateinit var testCases: List<TestCase>
|
|
||||||
}
|
|
||||||
@ -1,106 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta content="width=device-width, initial-scale=1" name="viewport">
|
|
||||||
<title>{{ testSuite.name }}</title>
|
|
||||||
<!-- CSS only -->
|
|
||||||
<link crossorigin="anonymous" href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.2/dist/css/bootstrap.min.css"
|
|
||||||
integrity="sha384-Zenh87qX5JnK2Jl0vWa8Ck2rdkQ2Bzep5IDxbcnCeuOxjzrPF/et3URy9Bv1WTRi" rel="stylesheet">
|
|
||||||
<!-- JavaScript Bundle with Popper -->
|
|
||||||
<script crossorigin="anonymous"
|
|
||||||
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>
|
|
||||||
</head>
|
|
||||||
<body class="py-4">
|
|
||||||
<div class="container">
|
|
||||||
<h1 class="h2">{{ testSuite.name }}</h1>
|
|
||||||
<div class="progress mt-4">
|
|
||||||
<div class="progress-bar bg-success" role="progressbar" style="width: {{ success_percent }}%">
|
|
||||||
{{ success }} ({{ success_percent }}%)
|
|
||||||
</div>
|
|
||||||
<div class="progress-bar bg-danger" role="progressbar" style="width: {{ fail_percent }}%">
|
|
||||||
{{ testSuite.failures }} ({{ fail_percent }}%)
|
|
||||||
</div>
|
|
||||||
<div class="progress-bar bg-danger" role="progressbar" style="width: {{ error_percent }}%">
|
|
||||||
{{ testSuite.errors }} ({{ error_percent }}%)
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<table class="table table-hover">
|
|
||||||
<thead class="sticky-top bg-body">
|
|
||||||
<tr>
|
|
||||||
<th scope="col">Source</th>
|
|
||||||
{% for test in tests %}
|
|
||||||
<th class="text-center" scope="col" style="min-width: 5em;">{{ test }}</th>
|
|
||||||
{% endfor %}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
{% for name, cases in results %}
|
|
||||||
<tr>
|
|
||||||
<th scope="row">{{ name }}</th>
|
|
||||||
{% for test in tests %}
|
|
||||||
{% set case = cases[test] %}
|
|
||||||
{% if case.failure == null %}
|
|
||||||
<td class="table-success text-center">
|
|
||||||
<i data-feather="check"></i>
|
|
||||||
</td>
|
|
||||||
{% else %}
|
|
||||||
{% if case.failure.type == 'java.lang.AssertionError' %}
|
|
||||||
<td class="table-warning text-center" data-bs-target="#failure_{{ case.hashCode }}"
|
|
||||||
data-bs-toggle="modal" style="cursor: pointer;">
|
|
||||||
<i data-feather="alert-triangle"></i>
|
|
||||||
</td>
|
|
||||||
{% elseif case.failure.type == 'java.net.SocketTimeoutException' or case.failure.type ==
|
|
||||||
'java.net.UnknownHostException' %}
|
|
||||||
<td class="table-secondary text-center" data-bs-target="#failure_{{ case.hashCode }}"
|
|
||||||
data-bs-toggle="modal" style="cursor: pointer;">
|
|
||||||
<i data-feather="power"></i>
|
|
||||||
</td>
|
|
||||||
{% elseif case.failure.type == 'org.koitharu.kotatsu.parsers.CloudFlareProtectedException' %}
|
|
||||||
<td class="table-secondary text-center" data-bs-target="#failure_{{ case.hashCode }}"
|
|
||||||
data-bs-toggle="modal" style="cursor: pointer;">
|
|
||||||
<i data-feather="shield"></i>
|
|
||||||
</td>
|
|
||||||
{% elseif case.failure.type == 'org.koitharu.kotatsu.parsers.exception.AuthRequiredException' %}
|
|
||||||
<td class="table-secondary text-center" data-bs-target="#failure_{{ case.hashCode }}"
|
|
||||||
data-bs-toggle="modal" style="cursor: pointer;">
|
|
||||||
<i data-feather="user-x"></i>
|
|
||||||
</td>
|
|
||||||
{% else %}
|
|
||||||
<td class="table-danger text-center" data-bs-target="#failure_{{ case.hashCode }}"
|
|
||||||
data-bs-toggle="modal" style="cursor: pointer;">
|
|
||||||
<i data-feather="x"></i>
|
|
||||||
</td>
|
|
||||||
{% endif %}
|
|
||||||
<!--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>
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<script>
|
|
||||||
feather.replace()
|
|
||||||
|
|
||||||
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@ -1,804 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<gaphor xmlns="https://gaphor.org/model" xmlns:Core="https://gaphor.org/modelinglanguage/Core" xmlns:UML="https://gaphor.org/modelinglanguage/UML" xmlns:general="https://gaphor.org/modelinglanguage/general" version="4" gaphor-version="3.1.0">
|
|
||||||
<model>
|
|
||||||
<Core:StyleSheet id="58d6989a-66f8-11ec-b4c8-0456e5e540ed"/>
|
|
||||||
<UML:Package id="58d6c2e8-66f8-11ec-b4c8-0456e5e540ed">
|
|
||||||
<name>
|
|
||||||
<val>Новая модель</val>
|
|
||||||
</name>
|
|
||||||
<ownedDiagram>
|
|
||||||
<reflist>
|
|
||||||
<ref refid="58d6c536-66f8-11ec-b4c8-0456e5e540ed"/>
|
|
||||||
</reflist>
|
|
||||||
</ownedDiagram>
|
|
||||||
<ownedType>
|
|
||||||
<reflist>
|
|
||||||
<ref refid="0b54a350-f59d-11ef-bfb1-4cbb5880a0b8"/>
|
|
||||||
<ref refid="a300f58a-f5bd-11ef-9ec2-4cbb5880a0b8"/>
|
|
||||||
<ref refid="ad4c68d0-f5bd-11ef-9ec2-4cbb5880a0b8"/>
|
|
||||||
<ref refid="198a3108-f5be-11ef-9ec2-4cbb5880a0b8"/>
|
|
||||||
<ref refid="32081654-f5bf-11ef-9ec2-4cbb5880a0b8"/>
|
|
||||||
</reflist>
|
|
||||||
</ownedType>
|
|
||||||
<packagedElement>
|
|
||||||
<reflist>
|
|
||||||
<ref refid="0b54a350-f59d-11ef-bfb1-4cbb5880a0b8"/>
|
|
||||||
<ref refid="a300f58a-f5bd-11ef-9ec2-4cbb5880a0b8"/>
|
|
||||||
<ref refid="ad4c68d0-f5bd-11ef-9ec2-4cbb5880a0b8"/>
|
|
||||||
<ref refid="198a3108-f5be-11ef-9ec2-4cbb5880a0b8"/>
|
|
||||||
<ref refid="32081654-f5bf-11ef-9ec2-4cbb5880a0b8"/>
|
|
||||||
<ref refid="25a17c58-f5be-11ef-9ec2-4cbb5880a0b8"/>
|
|
||||||
<ref refid="41318a02-f5bf-11ef-9ec2-4cbb5880a0b8"/>
|
|
||||||
</reflist>
|
|
||||||
</packagedElement>
|
|
||||||
</UML:Package>
|
|
||||||
<UML:Diagram id="58d6c536-66f8-11ec-b4c8-0456e5e540ed">
|
|
||||||
<element>
|
|
||||||
<ref refid="58d6c2e8-66f8-11ec-b4c8-0456e5e540ed"/>
|
|
||||||
</element>
|
|
||||||
<name>
|
|
||||||
<val>Новая диаграмма</val>
|
|
||||||
</name>
|
|
||||||
<ownedPresentation>
|
|
||||||
<reflist>
|
|
||||||
<ref refid="0b54edc4-f59d-11ef-bfb1-4cbb5880a0b8"/>
|
|
||||||
<ref refid="a3018fc2-f5bd-11ef-9ec2-4cbb5880a0b8"/>
|
|
||||||
<ref refid="ad4d2aae-f5bd-11ef-9ec2-4cbb5880a0b8"/>
|
|
||||||
<ref refid="198aace6-f5be-11ef-9ec2-4cbb5880a0b8"/>
|
|
||||||
<ref refid="320868c0-f5bf-11ef-9ec2-4cbb5880a0b8"/>
|
|
||||||
<ref refid="b760bcd4-f5bf-11ef-9ec2-4cbb5880a0b8"/>
|
|
||||||
<ref refid="bff32eae-f5bf-11ef-9ec2-4cbb5880a0b8"/>
|
|
||||||
<ref refid="d5113ca4-f5bf-11ef-9ec2-4cbb5880a0b8"/>
|
|
||||||
<ref refid="e8042ad8-f5bf-11ef-9ec2-4cbb5880a0b8"/>
|
|
||||||
<ref refid="531831f2-f5c0-11ef-9ec2-4cbb5880a0b8"/>
|
|
||||||
<ref refid="5ccae8d4-f5c0-11ef-9ec2-4cbb5880a0b8"/>
|
|
||||||
<ref refid="b6e0240e-f5bd-11ef-9ec2-4cbb5880a0b8"/>
|
|
||||||
<ref refid="254e40f6-f5be-11ef-9ec2-4cbb5880a0b8"/>
|
|
||||||
<ref refid="2bb6e87a-f5bf-11ef-9ec2-4cbb5880a0b8"/>
|
|
||||||
<ref refid="40e36d2c-f5bf-11ef-9ec2-4cbb5880a0b8"/>
|
|
||||||
<ref refid="bcc07c64-f5bf-11ef-9ec2-4cbb5880a0b8"/>
|
|
||||||
<ref refid="f6b48e4c-f5bf-11ef-9ec2-4cbb5880a0b8"/>
|
|
||||||
<ref refid="f89ba010-f5bf-11ef-9ec2-4cbb5880a0b8"/>
|
|
||||||
<ref refid="fabe0540-f5bf-11ef-9ec2-4cbb5880a0b8"/>
|
|
||||||
<ref refid="6f993af6-f5c0-11ef-9ec2-4cbb5880a0b8"/>
|
|
||||||
</reflist>
|
|
||||||
</ownedPresentation>
|
|
||||||
</UML:Diagram>
|
|
||||||
<UML:Class id="0b54a350-f59d-11ef-bfb1-4cbb5880a0b8">
|
|
||||||
<clientDependency>
|
|
||||||
<reflist>
|
|
||||||
<ref refid="25a17c58-f5be-11ef-9ec2-4cbb5880a0b8"/>
|
|
||||||
</reflist>
|
|
||||||
</clientDependency>
|
|
||||||
<comment>
|
|
||||||
<reflist>
|
|
||||||
<ref refid="bff31afe-f5bf-11ef-9ec2-4cbb5880a0b8"/>
|
|
||||||
</reflist>
|
|
||||||
</comment>
|
|
||||||
<isAbstract>
|
|
||||||
<val>1</val>
|
|
||||||
</isAbstract>
|
|
||||||
<name>
|
|
||||||
<val>AbstractMangaParser</val>
|
|
||||||
</name>
|
|
||||||
<owningPackage>
|
|
||||||
<ref refid="58d6c2e8-66f8-11ec-b4c8-0456e5e540ed"/>
|
|
||||||
</owningPackage>
|
|
||||||
<package>
|
|
||||||
<ref refid="58d6c2e8-66f8-11ec-b4c8-0456e5e540ed"/>
|
|
||||||
</package>
|
|
||||||
<presentation>
|
|
||||||
<reflist>
|
|
||||||
<ref refid="0b54edc4-f59d-11ef-bfb1-4cbb5880a0b8"/>
|
|
||||||
</reflist>
|
|
||||||
</presentation>
|
|
||||||
<specialization>
|
|
||||||
<reflist>
|
|
||||||
<ref refid="b969dac6-f5bd-11ef-9ec2-4cbb5880a0b8"/>
|
|
||||||
<ref refid="2c236356-f5bf-11ef-9ec2-4cbb5880a0b8"/>
|
|
||||||
</reflist>
|
|
||||||
</specialization>
|
|
||||||
</UML:Class>
|
|
||||||
<UML:ClassItem id="0b54edc4-f59d-11ef-bfb1-4cbb5880a0b8">
|
|
||||||
<matrix>
|
|
||||||
<val>(1.0, 0.0, 0.0, 1.0, 405.16796875, 388.8671875)</val>
|
|
||||||
</matrix>
|
|
||||||
<top-left>
|
|
||||||
<val>(0.0, 0.0)</val>
|
|
||||||
</top-left>
|
|
||||||
<width>
|
|
||||||
<val>158.0</val>
|
|
||||||
</width>
|
|
||||||
<height>
|
|
||||||
<val>60.0</val>
|
|
||||||
</height>
|
|
||||||
<diagram>
|
|
||||||
<ref refid="58d6c536-66f8-11ec-b4c8-0456e5e540ed"/>
|
|
||||||
</diagram>
|
|
||||||
<show_attributes>
|
|
||||||
<val>0</val>
|
|
||||||
</show_attributes>
|
|
||||||
<show_operations>
|
|
||||||
<val>0</val>
|
|
||||||
</show_operations>
|
|
||||||
<subject>
|
|
||||||
<ref refid="0b54a350-f59d-11ef-bfb1-4cbb5880a0b8"/>
|
|
||||||
</subject>
|
|
||||||
</UML:ClassItem>
|
|
||||||
<UML:Class id="a300f58a-f5bd-11ef-9ec2-4cbb5880a0b8">
|
|
||||||
<comment>
|
|
||||||
<reflist>
|
|
||||||
<ref refid="d5112a70-f5bf-11ef-9ec2-4cbb5880a0b8"/>
|
|
||||||
</reflist>
|
|
||||||
</comment>
|
|
||||||
<generalization>
|
|
||||||
<reflist>
|
|
||||||
<ref refid="b969dac6-f5bd-11ef-9ec2-4cbb5880a0b8"/>
|
|
||||||
</reflist>
|
|
||||||
</generalization>
|
|
||||||
<isAbstract>
|
|
||||||
<val>1</val>
|
|
||||||
</isAbstract>
|
|
||||||
<name>
|
|
||||||
<val>PagedMangaParser</val>
|
|
||||||
</name>
|
|
||||||
<owningPackage>
|
|
||||||
<ref refid="58d6c2e8-66f8-11ec-b4c8-0456e5e540ed"/>
|
|
||||||
</owningPackage>
|
|
||||||
<package>
|
|
||||||
<ref refid="58d6c2e8-66f8-11ec-b4c8-0456e5e540ed"/>
|
|
||||||
</package>
|
|
||||||
<presentation>
|
|
||||||
<reflist>
|
|
||||||
<ref refid="a3018fc2-f5bd-11ef-9ec2-4cbb5880a0b8"/>
|
|
||||||
</reflist>
|
|
||||||
</presentation>
|
|
||||||
</UML:Class>
|
|
||||||
<UML:ClassItem id="a3018fc2-f5bd-11ef-9ec2-4cbb5880a0b8">
|
|
||||||
<matrix>
|
|
||||||
<val>(1.0, 0.0, 0.0, 1.0, 476.3368367667698, 525.76953125)</val>
|
|
||||||
</matrix>
|
|
||||||
<top-left>
|
|
||||||
<val>(0.0, 0.0)</val>
|
|
||||||
</top-left>
|
|
||||||
<width>
|
|
||||||
<val>142.0</val>
|
|
||||||
</width>
|
|
||||||
<height>
|
|
||||||
<val>60.0</val>
|
|
||||||
</height>
|
|
||||||
<diagram>
|
|
||||||
<ref refid="58d6c536-66f8-11ec-b4c8-0456e5e540ed"/>
|
|
||||||
</diagram>
|
|
||||||
<show_attributes>
|
|
||||||
<val>0</val>
|
|
||||||
</show_attributes>
|
|
||||||
<show_operations>
|
|
||||||
<val>0</val>
|
|
||||||
</show_operations>
|
|
||||||
<subject>
|
|
||||||
<ref refid="a300f58a-f5bd-11ef-9ec2-4cbb5880a0b8"/>
|
|
||||||
</subject>
|
|
||||||
</UML:ClassItem>
|
|
||||||
<UML:Class id="ad4c68d0-f5bd-11ef-9ec2-4cbb5880a0b8">
|
|
||||||
<comment>
|
|
||||||
<reflist>
|
|
||||||
<ref refid="e80418f4-f5bf-11ef-9ec2-4cbb5880a0b8"/>
|
|
||||||
</reflist>
|
|
||||||
</comment>
|
|
||||||
<generalization>
|
|
||||||
<reflist>
|
|
||||||
<ref refid="2c236356-f5bf-11ef-9ec2-4cbb5880a0b8"/>
|
|
||||||
</reflist>
|
|
||||||
</generalization>
|
|
||||||
<isAbstract>
|
|
||||||
<val>1</val>
|
|
||||||
</isAbstract>
|
|
||||||
<name>
|
|
||||||
<val>SinglePageMangaParser</val>
|
|
||||||
</name>
|
|
||||||
<owningPackage>
|
|
||||||
<ref refid="58d6c2e8-66f8-11ec-b4c8-0456e5e540ed"/>
|
|
||||||
</owningPackage>
|
|
||||||
<package>
|
|
||||||
<ref refid="58d6c2e8-66f8-11ec-b4c8-0456e5e540ed"/>
|
|
||||||
</package>
|
|
||||||
<presentation>
|
|
||||||
<reflist>
|
|
||||||
<ref refid="ad4d2aae-f5bd-11ef-9ec2-4cbb5880a0b8"/>
|
|
||||||
</reflist>
|
|
||||||
</presentation>
|
|
||||||
</UML:Class>
|
|
||||||
<UML:ClassItem id="ad4d2aae-f5bd-11ef-9ec2-4cbb5880a0b8">
|
|
||||||
<matrix>
|
|
||||||
<val>(1.0, 0.0, 0.0, 1.0, 405.16796875, 627.46875)</val>
|
|
||||||
</matrix>
|
|
||||||
<top-left>
|
|
||||||
<val>(0.0, 0.0)</val>
|
|
||||||
</top-left>
|
|
||||||
<width>
|
|
||||||
<val>175.0</val>
|
|
||||||
</width>
|
|
||||||
<height>
|
|
||||||
<val>60.0</val>
|
|
||||||
</height>
|
|
||||||
<diagram>
|
|
||||||
<ref refid="58d6c536-66f8-11ec-b4c8-0456e5e540ed"/>
|
|
||||||
</diagram>
|
|
||||||
<show_attributes>
|
|
||||||
<val>0</val>
|
|
||||||
</show_attributes>
|
|
||||||
<show_operations>
|
|
||||||
<val>0</val>
|
|
||||||
</show_operations>
|
|
||||||
<subject>
|
|
||||||
<ref refid="ad4c68d0-f5bd-11ef-9ec2-4cbb5880a0b8"/>
|
|
||||||
</subject>
|
|
||||||
</UML:ClassItem>
|
|
||||||
<UML:GeneralizationItem id="b6e0240e-f5bd-11ef-9ec2-4cbb5880a0b8">
|
|
||||||
<diagram>
|
|
||||||
<ref refid="58d6c536-66f8-11ec-b4c8-0456e5e540ed"/>
|
|
||||||
</diagram>
|
|
||||||
<horizontal>
|
|
||||||
<val>False</val>
|
|
||||||
</horizontal>
|
|
||||||
<orthogonal>
|
|
||||||
<val>False</val>
|
|
||||||
</orthogonal>
|
|
||||||
<subject>
|
|
||||||
<ref refid="b969dac6-f5bd-11ef-9ec2-4cbb5880a0b8"/>
|
|
||||||
</subject>
|
|
||||||
<matrix>
|
|
||||||
<val>(1.0, 0.0, 0.0, 1.0, 499.2109069824219, 463.45703125)</val>
|
|
||||||
</matrix>
|
|
||||||
<points>
|
|
||||||
<val>[(28.486861756586336, 62.3125), (25.111328125, -14.58984375)]</val>
|
|
||||||
</points>
|
|
||||||
<head-connection>
|
|
||||||
<ref refid="a3018fc2-f5bd-11ef-9ec2-4cbb5880a0b8"/>
|
|
||||||
</head-connection>
|
|
||||||
<tail-connection>
|
|
||||||
<ref refid="0b54edc4-f59d-11ef-bfb1-4cbb5880a0b8"/>
|
|
||||||
</tail-connection>
|
|
||||||
</UML:GeneralizationItem>
|
|
||||||
<UML:Generalization id="b969dac6-f5bd-11ef-9ec2-4cbb5880a0b8">
|
|
||||||
<general>
|
|
||||||
<ref refid="0b54a350-f59d-11ef-bfb1-4cbb5880a0b8"/>
|
|
||||||
</general>
|
|
||||||
<presentation>
|
|
||||||
<reflist>
|
|
||||||
<ref refid="b6e0240e-f5bd-11ef-9ec2-4cbb5880a0b8"/>
|
|
||||||
</reflist>
|
|
||||||
</presentation>
|
|
||||||
<specific>
|
|
||||||
<ref refid="a300f58a-f5bd-11ef-9ec2-4cbb5880a0b8"/>
|
|
||||||
</specific>
|
|
||||||
</UML:Generalization>
|
|
||||||
<UML:Interface id="198a3108-f5be-11ef-9ec2-4cbb5880a0b8">
|
|
||||||
<name>
|
|
||||||
<val>MangaParser</val>
|
|
||||||
</name>
|
|
||||||
<owningPackage>
|
|
||||||
<ref refid="58d6c2e8-66f8-11ec-b4c8-0456e5e540ed"/>
|
|
||||||
</owningPackage>
|
|
||||||
<package>
|
|
||||||
<ref refid="58d6c2e8-66f8-11ec-b4c8-0456e5e540ed"/>
|
|
||||||
</package>
|
|
||||||
<presentation>
|
|
||||||
<reflist>
|
|
||||||
<ref refid="198aace6-f5be-11ef-9ec2-4cbb5880a0b8"/>
|
|
||||||
</reflist>
|
|
||||||
</presentation>
|
|
||||||
<supplierDependency>
|
|
||||||
<reflist>
|
|
||||||
<ref refid="25a17c58-f5be-11ef-9ec2-4cbb5880a0b8"/>
|
|
||||||
<ref refid="41318a02-f5bf-11ef-9ec2-4cbb5880a0b8"/>
|
|
||||||
</reflist>
|
|
||||||
</supplierDependency>
|
|
||||||
</UML:Interface>
|
|
||||||
<UML:InterfaceItem id="198aace6-f5be-11ef-9ec2-4cbb5880a0b8">
|
|
||||||
<matrix>
|
|
||||||
<val>(1.0, 0.0, 0.0, 1.0, 278.00391387939453, 232.92578125)</val>
|
|
||||||
</matrix>
|
|
||||||
<top-left>
|
|
||||||
<val>(0.0, 0.0)</val>
|
|
||||||
</top-left>
|
|
||||||
<width>
|
|
||||||
<val>105.0</val>
|
|
||||||
</width>
|
|
||||||
<height>
|
|
||||||
<val>80.0</val>
|
|
||||||
</height>
|
|
||||||
<diagram>
|
|
||||||
<ref refid="58d6c536-66f8-11ec-b4c8-0456e5e540ed"/>
|
|
||||||
</diagram>
|
|
||||||
<show_attributes>
|
|
||||||
<val>0</val>
|
|
||||||
</show_attributes>
|
|
||||||
<show_operations>
|
|
||||||
<val>0</val>
|
|
||||||
</show_operations>
|
|
||||||
<subject>
|
|
||||||
<ref refid="198a3108-f5be-11ef-9ec2-4cbb5880a0b8"/>
|
|
||||||
</subject>
|
|
||||||
<folded>
|
|
||||||
<val>0</val>
|
|
||||||
</folded>
|
|
||||||
</UML:InterfaceItem>
|
|
||||||
<UML:InterfaceRealizationItem id="254e40f6-f5be-11ef-9ec2-4cbb5880a0b8">
|
|
||||||
<diagram>
|
|
||||||
<ref refid="58d6c536-66f8-11ec-b4c8-0456e5e540ed"/>
|
|
||||||
</diagram>
|
|
||||||
<horizontal>
|
|
||||||
<val>False</val>
|
|
||||||
</horizontal>
|
|
||||||
<orthogonal>
|
|
||||||
<val>False</val>
|
|
||||||
</orthogonal>
|
|
||||||
<subject>
|
|
||||||
<ref refid="25a17c58-f5be-11ef-9ec2-4cbb5880a0b8"/>
|
|
||||||
</subject>
|
|
||||||
<matrix>
|
|
||||||
<val>(1.0, 0.0, 0.0, 1.0, 306.1445007324219, 270.0625)</val>
|
|
||||||
</matrix>
|
|
||||||
<points>
|
|
||||||
<val>[(55.866059373910275, 42.86328125), (164.5765002560883, 118.8046875)]</val>
|
|
||||||
</points>
|
|
||||||
<head-connection>
|
|
||||||
<ref refid="198aace6-f5be-11ef-9ec2-4cbb5880a0b8"/>
|
|
||||||
</head-connection>
|
|
||||||
<tail-connection>
|
|
||||||
<ref refid="0b54edc4-f59d-11ef-bfb1-4cbb5880a0b8"/>
|
|
||||||
</tail-connection>
|
|
||||||
</UML:InterfaceRealizationItem>
|
|
||||||
<UML:InterfaceRealization id="25a17c58-f5be-11ef-9ec2-4cbb5880a0b8">
|
|
||||||
<client>
|
|
||||||
<reflist>
|
|
||||||
<ref refid="0b54a350-f59d-11ef-bfb1-4cbb5880a0b8"/>
|
|
||||||
</reflist>
|
|
||||||
</client>
|
|
||||||
<owningPackage>
|
|
||||||
<ref refid="58d6c2e8-66f8-11ec-b4c8-0456e5e540ed"/>
|
|
||||||
</owningPackage>
|
|
||||||
<presentation>
|
|
||||||
<reflist>
|
|
||||||
<ref refid="254e40f6-f5be-11ef-9ec2-4cbb5880a0b8"/>
|
|
||||||
</reflist>
|
|
||||||
</presentation>
|
|
||||||
<supplier>
|
|
||||||
<reflist>
|
|
||||||
<ref refid="198a3108-f5be-11ef-9ec2-4cbb5880a0b8"/>
|
|
||||||
</reflist>
|
|
||||||
</supplier>
|
|
||||||
</UML:InterfaceRealization>
|
|
||||||
<UML:GeneralizationItem id="2bb6e87a-f5bf-11ef-9ec2-4cbb5880a0b8">
|
|
||||||
<diagram>
|
|
||||||
<ref refid="58d6c536-66f8-11ec-b4c8-0456e5e540ed"/>
|
|
||||||
</diagram>
|
|
||||||
<horizontal>
|
|
||||||
<val>False</val>
|
|
||||||
</horizontal>
|
|
||||||
<orthogonal>
|
|
||||||
<val>False</val>
|
|
||||||
</orthogonal>
|
|
||||||
<subject>
|
|
||||||
<ref refid="2c236356-f5bf-11ef-9ec2-4cbb5880a0b8"/>
|
|
||||||
</subject>
|
|
||||||
<matrix>
|
|
||||||
<val>(1.0, 0.0, 0.0, 1.0, 436.2929382324219, 439.1913757324219)</val>
|
|
||||||
</matrix>
|
|
||||||
<points>
|
|
||||||
<val>[(20.37646032737257, 188.27737426757812), (18.488327026367188, 9.675811767578125)]</val>
|
|
||||||
</points>
|
|
||||||
<head-connection>
|
|
||||||
<ref refid="ad4d2aae-f5bd-11ef-9ec2-4cbb5880a0b8"/>
|
|
||||||
</head-connection>
|
|
||||||
<tail-connection>
|
|
||||||
<ref refid="0b54edc4-f59d-11ef-bfb1-4cbb5880a0b8"/>
|
|
||||||
</tail-connection>
|
|
||||||
</UML:GeneralizationItem>
|
|
||||||
<UML:Generalization id="2c236356-f5bf-11ef-9ec2-4cbb5880a0b8">
|
|
||||||
<general>
|
|
||||||
<ref refid="0b54a350-f59d-11ef-bfb1-4cbb5880a0b8"/>
|
|
||||||
</general>
|
|
||||||
<presentation>
|
|
||||||
<reflist>
|
|
||||||
<ref refid="2bb6e87a-f5bf-11ef-9ec2-4cbb5880a0b8"/>
|
|
||||||
</reflist>
|
|
||||||
</presentation>
|
|
||||||
<specific>
|
|
||||||
<ref refid="ad4c68d0-f5bd-11ef-9ec2-4cbb5880a0b8"/>
|
|
||||||
</specific>
|
|
||||||
</UML:Generalization>
|
|
||||||
<UML:Class id="32081654-f5bf-11ef-9ec2-4cbb5880a0b8">
|
|
||||||
<clientDependency>
|
|
||||||
<reflist>
|
|
||||||
<ref refid="41318a02-f5bf-11ef-9ec2-4cbb5880a0b8"/>
|
|
||||||
</reflist>
|
|
||||||
</clientDependency>
|
|
||||||
<name>
|
|
||||||
<val>MangaParserWrapper</val>
|
|
||||||
</name>
|
|
||||||
<note>
|
|
||||||
<val></val>
|
|
||||||
</note>
|
|
||||||
<owningPackage>
|
|
||||||
<ref refid="58d6c2e8-66f8-11ec-b4c8-0456e5e540ed"/>
|
|
||||||
</owningPackage>
|
|
||||||
<package>
|
|
||||||
<ref refid="58d6c2e8-66f8-11ec-b4c8-0456e5e540ed"/>
|
|
||||||
</package>
|
|
||||||
<presentation>
|
|
||||||
<reflist>
|
|
||||||
<ref refid="320868c0-f5bf-11ef-9ec2-4cbb5880a0b8"/>
|
|
||||||
</reflist>
|
|
||||||
</presentation>
|
|
||||||
</UML:Class>
|
|
||||||
<UML:ClassItem id="320868c0-f5bf-11ef-9ec2-4cbb5880a0b8">
|
|
||||||
<matrix>
|
|
||||||
<val>(1.0, 0.0, 0.0, 1.0, 128.5008992667698, 410.48990205860804)</val>
|
|
||||||
</matrix>
|
|
||||||
<top-left>
|
|
||||||
<val>(0.0, 0.0)</val>
|
|
||||||
</top-left>
|
|
||||||
<width>
|
|
||||||
<val>158.0</val>
|
|
||||||
</width>
|
|
||||||
<height>
|
|
||||||
<val>60.0</val>
|
|
||||||
</height>
|
|
||||||
<diagram>
|
|
||||||
<ref refid="58d6c536-66f8-11ec-b4c8-0456e5e540ed"/>
|
|
||||||
</diagram>
|
|
||||||
<show_attributes>
|
|
||||||
<val>0</val>
|
|
||||||
</show_attributes>
|
|
||||||
<show_operations>
|
|
||||||
<val>0</val>
|
|
||||||
</show_operations>
|
|
||||||
<subject>
|
|
||||||
<ref refid="32081654-f5bf-11ef-9ec2-4cbb5880a0b8"/>
|
|
||||||
</subject>
|
|
||||||
</UML:ClassItem>
|
|
||||||
<UML:InterfaceRealizationItem id="40e36d2c-f5bf-11ef-9ec2-4cbb5880a0b8">
|
|
||||||
<diagram>
|
|
||||||
<ref refid="58d6c536-66f8-11ec-b4c8-0456e5e540ed"/>
|
|
||||||
</diagram>
|
|
||||||
<horizontal>
|
|
||||||
<val>False</val>
|
|
||||||
</horizontal>
|
|
||||||
<orthogonal>
|
|
||||||
<val>False</val>
|
|
||||||
</orthogonal>
|
|
||||||
<subject>
|
|
||||||
<ref refid="41318a02-f5bf-11ef-9ec2-4cbb5880a0b8"/>
|
|
||||||
</subject>
|
|
||||||
<matrix>
|
|
||||||
<val>(1.0, 0.0, 0.0, 1.0, 306.0585632324219, 249.69920349121094)</val>
|
|
||||||
</matrix>
|
|
||||||
<points>
|
|
||||||
<val>[(11.759223915218172, 63.22657775878906), (-98.55766396565207, 160.7906985673971)]</val>
|
|
||||||
</points>
|
|
||||||
<head-connection>
|
|
||||||
<ref refid="198aace6-f5be-11ef-9ec2-4cbb5880a0b8"/>
|
|
||||||
</head-connection>
|
|
||||||
<tail-connection>
|
|
||||||
<ref refid="320868c0-f5bf-11ef-9ec2-4cbb5880a0b8"/>
|
|
||||||
</tail-connection>
|
|
||||||
</UML:InterfaceRealizationItem>
|
|
||||||
<UML:InterfaceRealization id="41318a02-f5bf-11ef-9ec2-4cbb5880a0b8">
|
|
||||||
<client>
|
|
||||||
<reflist>
|
|
||||||
<ref refid="32081654-f5bf-11ef-9ec2-4cbb5880a0b8"/>
|
|
||||||
</reflist>
|
|
||||||
</client>
|
|
||||||
<owningPackage>
|
|
||||||
<ref refid="58d6c2e8-66f8-11ec-b4c8-0456e5e540ed"/>
|
|
||||||
</owningPackage>
|
|
||||||
<presentation>
|
|
||||||
<reflist>
|
|
||||||
<ref refid="40e36d2c-f5bf-11ef-9ec2-4cbb5880a0b8"/>
|
|
||||||
</reflist>
|
|
||||||
</presentation>
|
|
||||||
<supplier>
|
|
||||||
<reflist>
|
|
||||||
<ref refid="198a3108-f5be-11ef-9ec2-4cbb5880a0b8"/>
|
|
||||||
</reflist>
|
|
||||||
</supplier>
|
|
||||||
</UML:InterfaceRealization>
|
|
||||||
<UML:Comment id="b760ac44-f5bf-11ef-9ec2-4cbb5880a0b8">
|
|
||||||
<body>
|
|
||||||
<val>Used for providing external api. Do not use this class directly</val>
|
|
||||||
</body>
|
|
||||||
<presentation>
|
|
||||||
<reflist>
|
|
||||||
<ref refid="b760bcd4-f5bf-11ef-9ec2-4cbb5880a0b8"/>
|
|
||||||
</reflist>
|
|
||||||
</presentation>
|
|
||||||
</UML:Comment>
|
|
||||||
<UML:CommentItem id="b760bcd4-f5bf-11ef-9ec2-4cbb5880a0b8">
|
|
||||||
<matrix>
|
|
||||||
<val>(1.0, 0.0, 0.0, 1.0, 108.0561294327963, 550.1347579956054)</val>
|
|
||||||
</matrix>
|
|
||||||
<top-left>
|
|
||||||
<val>(0.0, 0.0)</val>
|
|
||||||
</top-left>
|
|
||||||
<width>
|
|
||||||
<val>183.21868896484375</val>
|
|
||||||
</width>
|
|
||||||
<height>
|
|
||||||
<val>91.23829650878906</val>
|
|
||||||
</height>
|
|
||||||
<diagram>
|
|
||||||
<ref refid="58d6c536-66f8-11ec-b4c8-0456e5e540ed"/>
|
|
||||||
</diagram>
|
|
||||||
<subject>
|
|
||||||
<ref refid="b760ac44-f5bf-11ef-9ec2-4cbb5880a0b8"/>
|
|
||||||
</subject>
|
|
||||||
</UML:CommentItem>
|
|
||||||
<UML:CommentLineItem id="bcc07c64-f5bf-11ef-9ec2-4cbb5880a0b8">
|
|
||||||
<diagram>
|
|
||||||
<ref refid="58d6c536-66f8-11ec-b4c8-0456e5e540ed"/>
|
|
||||||
</diagram>
|
|
||||||
<horizontal>
|
|
||||||
<val>False</val>
|
|
||||||
</horizontal>
|
|
||||||
<orthogonal>
|
|
||||||
<val>False</val>
|
|
||||||
</orthogonal>
|
|
||||||
<matrix>
|
|
||||||
<val>(1.0, 0.0, 0.0, 1.0, 549.205520203852, 278.05499559311954)</val>
|
|
||||||
</matrix>
|
|
||||||
<points>
|
|
||||||
<val>[(-349.5400462886338, 192.4349064654885), (-349.5400462886338, 272.0797624024858)]</val>
|
|
||||||
</points>
|
|
||||||
<tail-connection>
|
|
||||||
<ref refid="b760bcd4-f5bf-11ef-9ec2-4cbb5880a0b8"/>
|
|
||||||
</tail-connection>
|
|
||||||
</UML:CommentLineItem>
|
|
||||||
<UML:Comment id="bff31afe-f5bf-11ef-9ec2-4cbb5880a0b8">
|
|
||||||
<annotatedElement>
|
|
||||||
<reflist>
|
|
||||||
<ref refid="0b54a350-f59d-11ef-bfb1-4cbb5880a0b8"/>
|
|
||||||
</reflist>
|
|
||||||
</annotatedElement>
|
|
||||||
<body>
|
|
||||||
<val>Extend this class if your manga source provides standard limit-offset based lists (get manga list by offset)</val>
|
|
||||||
</body>
|
|
||||||
<presentation>
|
|
||||||
<reflist>
|
|
||||||
<ref refid="bff32eae-f5bf-11ef-9ec2-4cbb5880a0b8"/>
|
|
||||||
</reflist>
|
|
||||||
</presentation>
|
|
||||||
</UML:Comment>
|
|
||||||
<UML:CommentItem id="bff32eae-f5bf-11ef-9ec2-4cbb5880a0b8">
|
|
||||||
<matrix>
|
|
||||||
<val>(1.0, 0.0, 0.0, 1.0, 673.0610499890082, 367.0515553989646)</val>
|
|
||||||
</matrix>
|
|
||||||
<top-left>
|
|
||||||
<val>(0.0, 0.0)</val>
|
|
||||||
</top-left>
|
|
||||||
<width>
|
|
||||||
<val>228.8028016098773</val>
|
|
||||||
</width>
|
|
||||||
<height>
|
|
||||||
<val>88.0</val>
|
|
||||||
</height>
|
|
||||||
<diagram>
|
|
||||||
<ref refid="58d6c536-66f8-11ec-b4c8-0456e5e540ed"/>
|
|
||||||
</diagram>
|
|
||||||
<subject>
|
|
||||||
<ref refid="bff31afe-f5bf-11ef-9ec2-4cbb5880a0b8"/>
|
|
||||||
</subject>
|
|
||||||
</UML:CommentItem>
|
|
||||||
<UML:Comment id="d5112a70-f5bf-11ef-9ec2-4cbb5880a0b8">
|
|
||||||
<annotatedElement>
|
|
||||||
<reflist>
|
|
||||||
<ref refid="a300f58a-f5bd-11ef-9ec2-4cbb5880a0b8"/>
|
|
||||||
</reflist>
|
|
||||||
</annotatedElement>
|
|
||||||
<body>
|
|
||||||
<val>Extend this class if your manga source provides paged-based lists (get manga list by page number)</val>
|
|
||||||
</body>
|
|
||||||
<presentation>
|
|
||||||
<reflist>
|
|
||||||
<ref refid="d5113ca4-f5bf-11ef-9ec2-4cbb5880a0b8"/>
|
|
||||||
</reflist>
|
|
||||||
</presentation>
|
|
||||||
</UML:Comment>
|
|
||||||
<UML:CommentItem id="d5113ca4-f5bf-11ef-9ec2-4cbb5880a0b8">
|
|
||||||
<matrix>
|
|
||||||
<val>(1.0, 0.0, 0.0, 1.0, 666.7924311664914, 507.7539062499999)</val>
|
|
||||||
</matrix>
|
|
||||||
<top-left>
|
|
||||||
<val>(0.0, 0.0)</val>
|
|
||||||
</top-left>
|
|
||||||
<width>
|
|
||||||
<val>214.34368896484375</val>
|
|
||||||
</width>
|
|
||||||
<height>
|
|
||||||
<val>88.0</val>
|
|
||||||
</height>
|
|
||||||
<diagram>
|
|
||||||
<ref refid="58d6c536-66f8-11ec-b4c8-0456e5e540ed"/>
|
|
||||||
</diagram>
|
|
||||||
<subject>
|
|
||||||
<ref refid="d5112a70-f5bf-11ef-9ec2-4cbb5880a0b8"/>
|
|
||||||
</subject>
|
|
||||||
</UML:CommentItem>
|
|
||||||
<UML:Comment id="e80418f4-f5bf-11ef-9ec2-4cbb5880a0b8">
|
|
||||||
<annotatedElement>
|
|
||||||
<reflist>
|
|
||||||
<ref refid="ad4c68d0-f5bd-11ef-9ec2-4cbb5880a0b8"/>
|
|
||||||
</reflist>
|
|
||||||
</annotatedElement>
|
|
||||||
<body>
|
|
||||||
<val>Extend this class if your manga source does not provide pagination (all manga provided in one list)</val>
|
|
||||||
</body>
|
|
||||||
<presentation>
|
|
||||||
<reflist>
|
|
||||||
<ref refid="e8042ad8-f5bf-11ef-9ec2-4cbb5880a0b8"/>
|
|
||||||
</reflist>
|
|
||||||
</presentation>
|
|
||||||
</UML:Comment>
|
|
||||||
<UML:CommentItem id="e8042ad8-f5bf-11ef-9ec2-4cbb5880a0b8">
|
|
||||||
<matrix>
|
|
||||||
<val>(1.0, 0.0, 0.0, 1.0, 666.7924311664914, 560.9671898788581)</val>
|
|
||||||
</matrix>
|
|
||||||
<top-left>
|
|
||||||
<val>(0.0, 58.00435704705592)</val>
|
|
||||||
</top-left>
|
|
||||||
<width>
|
|
||||||
<val>263.9307954323941</val>
|
|
||||||
</width>
|
|
||||||
<height>
|
|
||||||
<val>78.01706672440287</val>
|
|
||||||
</height>
|
|
||||||
<diagram>
|
|
||||||
<ref refid="58d6c536-66f8-11ec-b4c8-0456e5e540ed"/>
|
|
||||||
</diagram>
|
|
||||||
<subject>
|
|
||||||
<ref refid="e80418f4-f5bf-11ef-9ec2-4cbb5880a0b8"/>
|
|
||||||
</subject>
|
|
||||||
</UML:CommentItem>
|
|
||||||
<UML:CommentLineItem id="f6b48e4c-f5bf-11ef-9ec2-4cbb5880a0b8">
|
|
||||||
<diagram>
|
|
||||||
<ref refid="58d6c536-66f8-11ec-b4c8-0456e5e540ed"/>
|
|
||||||
</diagram>
|
|
||||||
<horizontal>
|
|
||||||
<val>False</val>
|
|
||||||
</horizontal>
|
|
||||||
<orthogonal>
|
|
||||||
<val>False</val>
|
|
||||||
</orthogonal>
|
|
||||||
<matrix>
|
|
||||||
<val>(1.0, 0.0, 0.0, 1.0, 561.8951626340418, 549.6101338901756)</val>
|
|
||||||
</matrix>
|
|
||||||
<points>
|
|
||||||
<val>[(56.44167413272805, 7.038279316310902), (104.89726853244963, 8.304008355003589)]</val>
|
|
||||||
</points>
|
|
||||||
<head-connection>
|
|
||||||
<ref refid="a3018fc2-f5bd-11ef-9ec2-4cbb5880a0b8"/>
|
|
||||||
</head-connection>
|
|
||||||
<tail-connection>
|
|
||||||
<ref refid="d5113ca4-f5bf-11ef-9ec2-4cbb5880a0b8"/>
|
|
||||||
</tail-connection>
|
|
||||||
</UML:CommentLineItem>
|
|
||||||
<UML:CommentLineItem id="f89ba010-f5bf-11ef-9ec2-4cbb5880a0b8">
|
|
||||||
<diagram>
|
|
||||||
<ref refid="58d6c536-66f8-11ec-b4c8-0456e5e540ed"/>
|
|
||||||
</diagram>
|
|
||||||
<horizontal>
|
|
||||||
<val>False</val>
|
|
||||||
</horizontal>
|
|
||||||
<orthogonal>
|
|
||||||
<val>False</val>
|
|
||||||
</orthogonal>
|
|
||||||
<matrix>
|
|
||||||
<val>(1.0, 0.0, 0.0, 1.0, 559.3873501340418, 413.0007588901755)</val>
|
|
||||||
</matrix>
|
|
||||||
<points>
|
|
||||||
<val>[(3.7806186159582467, 0.0), (113.67369985496646, 1.6012844908540842)]</val>
|
|
||||||
</points>
|
|
||||||
<head-connection>
|
|
||||||
<ref refid="0b54edc4-f59d-11ef-bfb1-4cbb5880a0b8"/>
|
|
||||||
</head-connection>
|
|
||||||
<tail-connection>
|
|
||||||
<ref refid="bff32eae-f5bf-11ef-9ec2-4cbb5880a0b8"/>
|
|
||||||
</tail-connection>
|
|
||||||
</UML:CommentLineItem>
|
|
||||||
<UML:CommentLineItem id="fabe0540-f5bf-11ef-9ec2-4cbb5880a0b8">
|
|
||||||
<diagram>
|
|
||||||
<ref refid="58d6c536-66f8-11ec-b4c8-0456e5e540ed"/>
|
|
||||||
</diagram>
|
|
||||||
<horizontal>
|
|
||||||
<val>False</val>
|
|
||||||
</horizontal>
|
|
||||||
<orthogonal>
|
|
||||||
<val>False</val>
|
|
||||||
</orthogonal>
|
|
||||||
<matrix>
|
|
||||||
<val>(1.0, 0.0, 0.0, 1.0, 522.3600063840418, 652.6882588901756)</val>
|
|
||||||
</matrix>
|
|
||||||
<points>
|
|
||||||
<val>[(57.80796236595825, 5.29182139794003), (144.43242478244963, 5.657840086725969)]</val>
|
|
||||||
</points>
|
|
||||||
<head-connection>
|
|
||||||
<ref refid="ad4d2aae-f5bd-11ef-9ec2-4cbb5880a0b8"/>
|
|
||||||
</head-connection>
|
|
||||||
<tail-connection>
|
|
||||||
<ref refid="e8042ad8-f5bf-11ef-9ec2-4cbb5880a0b8"/>
|
|
||||||
</tail-connection>
|
|
||||||
</UML:CommentLineItem>
|
|
||||||
<general:Box id="531831f2-f5c0-11ef-9ec2-4cbb5880a0b8">
|
|
||||||
<matrix>
|
|
||||||
<val>(1.0, 0.0, 0.0, 1.0, 375.05802564891326, 349.05453145170736)</val>
|
|
||||||
</matrix>
|
|
||||||
<top-left>
|
|
||||||
<val>(0.0, 3.15625)</val>
|
|
||||||
</top-left>
|
|
||||||
<width>
|
|
||||||
<val>590.6594026101285</val>
|
|
||||||
</width>
|
|
||||||
<height>
|
|
||||||
<val>368.44140625</val>
|
|
||||||
</height>
|
|
||||||
<diagram>
|
|
||||||
<ref refid="58d6c536-66f8-11ec-b4c8-0456e5e540ed"/>
|
|
||||||
</diagram>
|
|
||||||
</general:Box>
|
|
||||||
<UML:Comment id="5ccad4ca-f5c0-11ef-9ec2-4cbb5880a0b8">
|
|
||||||
<body>
|
|
||||||
<val>To create your own parser you have to extends one of these classes</val>
|
|
||||||
</body>
|
|
||||||
<presentation>
|
|
||||||
<reflist>
|
|
||||||
<ref refid="5ccae8d4-f5c0-11ef-9ec2-4cbb5880a0b8"/>
|
|
||||||
</reflist>
|
|
||||||
</presentation>
|
|
||||||
</UML:Comment>
|
|
||||||
<UML:CommentItem id="5ccae8d4-f5c0-11ef-9ec2-4cbb5880a0b8">
|
|
||||||
<matrix>
|
|
||||||
<val>(1.0, 0.0, 0.0, 1.0, 756.725301794198, 225.57697659840966)</val>
|
|
||||||
</matrix>
|
|
||||||
<top-left>
|
|
||||||
<val>(0.0, 0.0)</val>
|
|
||||||
</top-left>
|
|
||||||
<width>
|
|
||||||
<val>208.99212646484375</val>
|
|
||||||
</width>
|
|
||||||
<height>
|
|
||||||
<val>73.47482464883183</val>
|
|
||||||
</height>
|
|
||||||
<diagram>
|
|
||||||
<ref refid="58d6c536-66f8-11ec-b4c8-0456e5e540ed"/>
|
|
||||||
</diagram>
|
|
||||||
<subject>
|
|
||||||
<ref refid="5ccad4ca-f5c0-11ef-9ec2-4cbb5880a0b8"/>
|
|
||||||
</subject>
|
|
||||||
</UML:CommentItem>
|
|
||||||
<UML:CommentLineItem id="6f993af6-f5c0-11ef-9ec2-4cbb5880a0b8">
|
|
||||||
<diagram>
|
|
||||||
<ref refid="58d6c536-66f8-11ec-b4c8-0456e5e540ed"/>
|
|
||||||
</diagram>
|
|
||||||
<horizontal>
|
|
||||||
<val>False</val>
|
|
||||||
</horizontal>
|
|
||||||
<orthogonal>
|
|
||||||
<val>False</val>
|
|
||||||
</orthogonal>
|
|
||||||
<matrix>
|
|
||||||
<val>(1.0, 0.0, 0.0, 1.0, 943.6141683885666, 419.2772168262177)</val>
|
|
||||||
</matrix>
|
|
||||||
<points>
|
|
||||||
<val>[(-27.404961772030788, -67.06643537451032), (-27.404961772030788, -120.2254155789762)]</val>
|
|
||||||
</points>
|
|
||||||
<head-connection>
|
|
||||||
<ref refid="531831f2-f5c0-11ef-9ec2-4cbb5880a0b8"/>
|
|
||||||
</head-connection>
|
|
||||||
<tail-connection>
|
|
||||||
<ref refid="5ccae8d4-f5c0-11ef-9ec2-4cbb5880a0b8"/>
|
|
||||||
</tail-connection>
|
|
||||||
</UML:CommentLineItem>
|
|
||||||
</model>
|
|
||||||
</gaphor>
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 41 KiB |
@ -1,34 +0,0 @@
|
|||||||
[versions]
|
|
||||||
kotlin = "2.2.10"
|
|
||||||
ksp = "2.2.10-2.0.2"
|
|
||||||
coroutines = "1.10.2"
|
|
||||||
junit = "5.10.1"
|
|
||||||
okhttp = "5.1.0"
|
|
||||||
okio = "3.16.0"
|
|
||||||
json = "20240303"
|
|
||||||
androidx-collection = "1.5.0"
|
|
||||||
jsoup = "1.21.2"
|
|
||||||
quickjs = "1.1.0"
|
|
||||||
korte = "4.0.10"
|
|
||||||
simplexml = "2.7.1"
|
|
||||||
|
|
||||||
[plugins]
|
|
||||||
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
|
|
||||||
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
|
|
||||||
|
|
||||||
[libraries]
|
|
||||||
ksp-symbol-processing-api = { module = "com.google.devtools.ksp:symbol-processing-api", version.ref = "ksp" }
|
|
||||||
kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "coroutines" }
|
|
||||||
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutines" }
|
|
||||||
junit-launcher = { group = "org.junit.platform", name = "junit-platform-launcher" }
|
|
||||||
junit-api = { group = "org.junit.jupiter", name = "junit-jupiter-api", version.ref = "junit" }
|
|
||||||
junit-engine = { group = "org.junit.jupiter", name = "junit-jupiter-engine", version.ref = "junit" }
|
|
||||||
junit-params = { group = "org.junit.jupiter", name = "junit-jupiter-params", version.ref = "junit" }
|
|
||||||
okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }
|
|
||||||
okio = { module = "com.squareup.okio:okio", version.ref = "okio" }
|
|
||||||
json = { module = "org.json:json", version.ref = "json" }
|
|
||||||
androidx-collection = { module = "androidx.collection:collection", version.ref = "androidx-collection" }
|
|
||||||
jsoup = { module = "org.jsoup:jsoup", version.ref = "jsoup" }
|
|
||||||
quickjs = { module = "io.webfolder:quickjs", version.ref = "quickjs" }
|
|
||||||
korte = { module = "com.soywiz.korlibs.korte:korte-jvm", version.ref = "korte" }
|
|
||||||
simplexml = { module = "org.simpleframework:simple-xml", version.ref = "simplexml" }
|
|
||||||
Binary file not shown.
@ -1,7 +1,5 @@
|
|||||||
distributionBase=GRADLE_USER_HOME
|
distributionBase=GRADLE_USER_HOME
|
||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.0.0-bin.zip
|
distributionUrl=https\://services.gradle.org/distributions/gradle-7.3.3-bin.zip
|
||||||
networkTimeout=10000
|
|
||||||
validateDistributionUrl=true
|
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
zipStorePath=wrapper/dists
|
zipStorePath=wrapper/dists
|
||||||
|
|||||||
@ -1,2 +0,0 @@
|
|||||||
jdk:
|
|
||||||
- openjdk17
|
|
||||||
@ -0,0 +1,7 @@
|
|||||||
|
plugins {
|
||||||
|
id 'org.jetbrains.kotlin.jvm'
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation 'com.google.devtools.ksp:symbol-processing-api:1.6.21-1.0.5'
|
||||||
|
}
|
||||||
@ -1,11 +0,0 @@
|
|||||||
plugins {
|
|
||||||
alias(libs.plugins.kotlin.jvm)
|
|
||||||
}
|
|
||||||
|
|
||||||
kotlin {
|
|
||||||
jvmToolchain(8)
|
|
||||||
}
|
|
||||||
|
|
||||||
dependencies {
|
|
||||||
implementation(libs.ksp.symbol.processing.api)
|
|
||||||
}
|
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
pluginManagement {
|
||||||
|
plugins {
|
||||||
|
id 'com.google.devtools.ksp' version '1.6.21-1.0.5'
|
||||||
|
id 'org.jetbrains.kotlin.jvm' version '1.6.21'
|
||||||
|
}
|
||||||
|
repositories {
|
||||||
|
gradlePluginPortal()
|
||||||
|
google()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencyResolutionManagement {
|
||||||
|
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rootProject.name = 'kotatsu-parsers'
|
||||||
|
include 'kotatsu-parsers-ksp'
|
||||||
|
|
||||||
@ -1,18 +0,0 @@
|
|||||||
pluginManagement {
|
|
||||||
repositories {
|
|
||||||
google()
|
|
||||||
mavenCentral()
|
|
||||||
gradlePluginPortal()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dependencyResolutionManagement {
|
|
||||||
repositories {
|
|
||||||
google()
|
|
||||||
mavenCentral()
|
|
||||||
gradlePluginPortal()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
rootProject.name = "kotatsu-parsers"
|
|
||||||
include("kotatsu-parsers-ksp")
|
|
||||||
@ -1,14 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.parsers
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Annotate [MangaParser] implementation to mark this parser as broken instead of removing it
|
|
||||||
*/
|
|
||||||
@Target(AnnotationTarget.CLASS)
|
|
||||||
@Retention(AnnotationRetention.SOURCE)
|
|
||||||
internal annotation class Broken(
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reason why this parser is broken
|
|
||||||
*/
|
|
||||||
val message: String = "",
|
|
||||||
)
|
|
||||||
@ -1,18 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.parsers
|
|
||||||
|
|
||||||
public object ErrorMessages {
|
|
||||||
|
|
||||||
public const val FILTER_MULTIPLE_STATES_NOT_SUPPORTED: String = "Multiple states are not supported by this source"
|
|
||||||
public const val FILTER_MULTIPLE_GENRES_NOT_SUPPORTED: String = "Multiple genres are not supported by this source"
|
|
||||||
public const val FILTER_MULTIPLE_CONTENT_RATING_NOT_SUPPORTED: String =
|
|
||||||
"Multiple Content ratings are not supported by this source"
|
|
||||||
public const val FILTER_MULTIPLE_CONTENT_TYPES_NOT_SUPPORTED: String =
|
|
||||||
"Multiple Content types are not supported by this source"
|
|
||||||
public const val FILTER_MULTIPLE_DEMOGRAPHICS_NOT_SUPPORTED: String =
|
|
||||||
"Multiple Demographics are not supported by this source"
|
|
||||||
public const val FILTER_BOTH_LOCALE_GENRES_NOT_SUPPORTED: String =
|
|
||||||
"Filtering by both genres and locale is not supported by this source"
|
|
||||||
public const val FILTER_BOTH_STATES_GENRES_NOT_SUPPORTED: String =
|
|
||||||
"Filtering by both genres and states is not supported by this source"
|
|
||||||
public const val SEARCH_NOT_SUPPORTED: String = "Search is not supported by this source"
|
|
||||||
}
|
|
||||||
@ -1,78 +1,157 @@
|
|||||||
package org.koitharu.kotatsu.parsers
|
package org.koitharu.kotatsu.parsers
|
||||||
|
|
||||||
import okhttp3.CookieJar
|
import okhttp3.*
|
||||||
import okhttp3.HttpUrl
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
import okhttp3.OkHttpClient
|
import org.json.JSONObject
|
||||||
import okhttp3.Response
|
import org.jsoup.HttpStatusException
|
||||||
import org.koitharu.kotatsu.parsers.bitmap.Bitmap
|
|
||||||
import org.koitharu.kotatsu.parsers.config.MangaSourceConfig
|
import org.koitharu.kotatsu.parsers.config.MangaSourceConfig
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaParserSource
|
import org.koitharu.kotatsu.parsers.exception.GraphQLException
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
import org.koitharu.kotatsu.parsers.util.LinkResolver
|
import org.koitharu.kotatsu.parsers.util.await
|
||||||
|
import org.koitharu.kotatsu.parsers.util.parseJson
|
||||||
import java.util.*
|
import java.util.*
|
||||||
|
|
||||||
public abstract class MangaLoaderContext {
|
abstract class MangaLoaderContext {
|
||||||
|
|
||||||
public abstract val httpClient: OkHttpClient
|
protected abstract val httpClient: OkHttpClient
|
||||||
|
|
||||||
public abstract val cookieJar: CookieJar
|
abstract val cookieJar: CookieJar
|
||||||
|
|
||||||
public fun newParserInstance(source: MangaParserSource): MangaParser = source.newParser(this)
|
/**
|
||||||
|
* Do a GET http request to specific url
|
||||||
public fun newLinkResolver(link: HttpUrl): LinkResolver = LinkResolver(this, link)
|
* @param url
|
||||||
|
* @param headers an additional headers for request, may be null
|
||||||
public fun newLinkResolver(link: String): LinkResolver = newLinkResolver(link.toHttpUrl())
|
*/
|
||||||
|
suspend fun httpGet(url: String, headers: Headers? = null): Response {
|
||||||
public open fun encodeBase64(data: ByteArray): String = Base64.getEncoder().encodeToString(data)
|
val request = Request.Builder()
|
||||||
|
.get()
|
||||||
public open fun decodeBase64(data: String): ByteArray = Base64.getDecoder().decode(data)
|
.url(url)
|
||||||
|
if (headers != null) {
|
||||||
|
request.headers(headers)
|
||||||
|
}
|
||||||
|
return httpClient.newCall(request.build()).await().ensureSuccess()
|
||||||
|
}
|
||||||
|
|
||||||
public open fun getPreferredLocales(): List<Locale> = listOf(Locale.getDefault())
|
/**
|
||||||
|
* Do a HEAD http request to specific url
|
||||||
|
* @param url
|
||||||
|
* @param headers an additional headers for request, may be null
|
||||||
|
*/
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Execute JavaScript code and return result
|
* Do a POST http request to specific url with `multipart/form-data` payload
|
||||||
* @param script JavaScript source code
|
* @param url
|
||||||
* @return execution result as string, may be null
|
* @param form payload as key=>value map
|
||||||
|
* @param headers an additional headers for request, may be null
|
||||||
*/
|
*/
|
||||||
@Deprecated("Provide a base url")
|
suspend fun httpPost(
|
||||||
public abstract suspend fun evaluateJs(script: String): String?
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Execute JavaScript code and return result
|
* Do a POST http request to specific url with `multipart/form-data` payload
|
||||||
* @param script JavaScript source code
|
* @param url
|
||||||
* @param baseUrl url of page script will be executed in context of
|
* @param payload payload as `key=value` string with `&` separator
|
||||||
* @return execution result as string, may be null
|
* @param headers an additional headers for request, may be null
|
||||||
*/
|
*/
|
||||||
public abstract suspend fun evaluateJs(baseUrl: String, script: String): String?
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Open [url] in browser for some external action (e.g. captcha solving or non cookie-based authorization)
|
* Do a GraphQL request to specific url
|
||||||
|
* @param endpoint an url
|
||||||
|
* @param query GraphQL request payload
|
||||||
*/
|
*/
|
||||||
public open fun requestBrowserAction(parser: MangaParser, url: String): Nothing {
|
suspend fun graphQLQuery(endpoint: String, query: String): JSONObject {
|
||||||
throw UnsupportedOperationException("Browser is not available")
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
public abstract fun getConfig(source: MangaSource): MangaSourceConfig
|
open fun encodeBase64(data: ByteArray): String = Base64.getEncoder().encodeToString(data)
|
||||||
|
|
||||||
public abstract fun getDefaultUserAgent(): String
|
open fun decodeBase64(data: String): ByteArray = Base64.getDecoder().decode(data)
|
||||||
|
|
||||||
/**
|
open fun getPreferredLocales(): List<Locale> = listOf(Locale.getDefault())
|
||||||
* Helper function to be used in an interceptor
|
|
||||||
* to descramble images
|
|
||||||
* @param response Image response
|
|
||||||
* @param redraw lambda function to implement descrambling logic
|
|
||||||
*/
|
|
||||||
public abstract fun redrawImageResponse(
|
|
||||||
response: Response,
|
|
||||||
redraw: (image: Bitmap) -> Bitmap,
|
|
||||||
): Response
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* create a new empty Bitmap with given dimensions
|
* Execute JavaScript code and return result
|
||||||
|
* @param script JavaScript source code
|
||||||
|
* @return execution result as string, may be null
|
||||||
*/
|
*/
|
||||||
public abstract fun createBitmap(
|
abstract suspend fun evaluateJs(script: String): String?
|
||||||
width: Int,
|
|
||||||
height: Int,
|
abstract fun getConfig(source: MangaSource): MangaSourceConfig
|
||||||
): Bitmap
|
|
||||||
}
|
private fun Response.ensureSuccess() = apply {
|
||||||
|
val exception: Exception? = when (code) { // Catch some error codes, not all
|
||||||
|
in 500..599 -> HttpStatusException(message, code, request.url.toString())
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
if (exception != null) {
|
||||||
|
runCatching {
|
||||||
|
close()
|
||||||
|
}.onFailure {
|
||||||
|
exception.addSuppressed(it)
|
||||||
|
}
|
||||||
|
throw exception
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,88 +1,163 @@
|
|||||||
package org.koitharu.kotatsu.parsers
|
package org.koitharu.kotatsu.parsers
|
||||||
|
|
||||||
import okhttp3.Headers
|
import androidx.annotation.CallSuper
|
||||||
import okhttp3.HttpUrl
|
|
||||||
import okhttp3.Interceptor
|
|
||||||
import org.koitharu.kotatsu.parsers.config.ConfigKey
|
import org.koitharu.kotatsu.parsers.config.ConfigKey
|
||||||
import org.koitharu.kotatsu.parsers.config.MangaSourceConfig
|
import org.koitharu.kotatsu.parsers.exception.ParseException
|
||||||
import org.koitharu.kotatsu.parsers.model.*
|
import org.koitharu.kotatsu.parsers.model.*
|
||||||
import org.koitharu.kotatsu.parsers.model.search.MangaSearchQuery
|
import org.koitharu.kotatsu.parsers.util.toAbsoluteUrl
|
||||||
import org.koitharu.kotatsu.parsers.model.search.MangaSearchQueryCapabilities
|
|
||||||
import org.koitharu.kotatsu.parsers.util.LinkResolver
|
|
||||||
import org.koitharu.kotatsu.parsers.util.convertToMangaSearchQuery
|
|
||||||
import org.koitharu.kotatsu.parsers.util.toMangaListFilterCapabilities
|
|
||||||
import java.util.*
|
import java.util.*
|
||||||
|
|
||||||
public interface MangaParser : Interceptor {
|
abstract class MangaParser @InternalParsersApi constructor(val source: MangaSource) {
|
||||||
|
|
||||||
public val source: MangaParserSource
|
protected abstract val context: MangaLoaderContext
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Supported [SortOrder] variants. Must not be empty.
|
* Supported [SortOrder] variants. Must not be empty.
|
||||||
*
|
*
|
||||||
* For better performance use [EnumSet] for more than one item.
|
* For better performance use [EnumSet] for more than one item.
|
||||||
*/
|
*/
|
||||||
public val availableSortOrders: Set<SortOrder>
|
abstract val sortOrders: Set<SortOrder>
|
||||||
|
|
||||||
@Deprecated("Too complex. Use filterCapabilities instead")
|
val config by lazy { context.getConfig(source) }
|
||||||
public val searchQueryCapabilities: MangaSearchQueryCapabilities
|
|
||||||
|
|
||||||
public val filterCapabilities: MangaListFilterCapabilities
|
|
||||||
|
|
||||||
public val config: MangaSourceConfig
|
|
||||||
|
|
||||||
public val authorizationProvider: MangaParserAuthProvider?
|
|
||||||
get() = this as? MangaParserAuthProvider
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Provide default domain and available alternatives, if any.
|
* Provide default domain and available alternatives, if any.
|
||||||
*
|
*
|
||||||
* Never hardcode domain in requests, use [domain] instead.
|
* Never hardcode domain in requests, use [getDomain] instead.
|
||||||
*/
|
*/
|
||||||
public val configKeyDomain: ConfigKey.Domain
|
protected abstract val configKeyDomain: ConfigKey.Domain
|
||||||
|
|
||||||
public val domain: String
|
/**
|
||||||
|
* Used as fallback if value of `sortOrder` passed to [getList] is null
|
||||||
|
*/
|
||||||
|
protected open val defaultSortOrder: SortOrder
|
||||||
|
get() {
|
||||||
|
val supported = sortOrders
|
||||||
|
return SortOrder.values().first { it in supported }
|
||||||
|
}
|
||||||
|
|
||||||
@Deprecated("Too complex. Use getList with filter instead")
|
/**
|
||||||
public suspend fun getList(query: MangaSearchQuery): List<Manga>
|
* Parse list of manga by specified criteria
|
||||||
|
*
|
||||||
|
* @param offset starting from 0 and used for pagination.
|
||||||
|
* Note than passed value may not be divisible by internal page size, so you should adjust it manually.
|
||||||
|
* @param query search query, may be null or empty if no search needed
|
||||||
|
* @param tags genres for filtering, values from [getTags] and [Manga.tags]. May be null or empty
|
||||||
|
* @param sortOrder one of [sortOrders] or null for default value
|
||||||
|
*/
|
||||||
|
@JvmSynthetic
|
||||||
|
@InternalParsersApi
|
||||||
|
abstract suspend fun getList(
|
||||||
|
offset: Int,
|
||||||
|
query: String?,
|
||||||
|
tags: Set<MangaTag>?,
|
||||||
|
sortOrder: SortOrder,
|
||||||
|
): List<Manga>
|
||||||
|
|
||||||
public suspend fun getList(offset: Int, order: SortOrder, filter: MangaListFilter): List<Manga>
|
/**
|
||||||
|
* Parse list of manga with search by text query
|
||||||
|
*
|
||||||
|
* @param offset starting from 0 and used for pagination.
|
||||||
|
* @param query search query
|
||||||
|
*/
|
||||||
|
suspend fun getList(offset: Int, query: String): List<Manga> {
|
||||||
|
return getList(offset, query, null, defaultSortOrder)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse list of manga by specified criteria
|
||||||
|
*
|
||||||
|
* @param offset starting from 0 and used for pagination.
|
||||||
|
* Note than passed value may not be divisible by internal page size, so you should adjust it manually.
|
||||||
|
* @param tags genres for filtering, values from [getTags] and [Manga.tags]. May be null or empty
|
||||||
|
* @param sortOrder one of [sortOrders] or null for default value
|
||||||
|
*/
|
||||||
|
suspend fun getList(offset: Int, tags: Set<MangaTag>?, sortOrder: SortOrder?): List<Manga> {
|
||||||
|
return getList(offset, null, tags, sortOrder ?: defaultSortOrder)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse details for [Manga]: chapters list, description, large cover, etc.
|
* Parse details for [Manga]: chapters list, description, large cover, etc.
|
||||||
* Must return the same manga, may change any fields excepts id, url and source
|
* Must return the same manga, may change any fields excepts id, url and source
|
||||||
* @see Manga.copy
|
* @see Manga.copy
|
||||||
*/
|
*/
|
||||||
public suspend fun getDetails(manga: Manga): Manga
|
abstract suspend fun getDetails(manga: Manga): Manga
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse pages list for specified chapter.
|
* Parse pages list for specified chapter.
|
||||||
* @see MangaPage for details
|
* @see MangaPage for details
|
||||||
*/
|
*/
|
||||||
public suspend fun getPages(chapter: MangaChapter): List<MangaPage>
|
abstract suspend fun getPages(chapter: MangaChapter): List<MangaPage>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch direct link to the page image.
|
* Fetch direct link to the page image.
|
||||||
*/
|
*/
|
||||||
public suspend fun getPageUrl(page: MangaPage): String
|
open suspend fun getPageUrl(page: MangaPage): String = page.url.toAbsoluteUrl(getDomain())
|
||||||
|
|
||||||
public suspend fun getFilterOptions(): MangaListFilterOptions
|
/**
|
||||||
|
* Fetch available tags (genres) for source
|
||||||
|
*/
|
||||||
|
abstract suspend fun getTags(): Set<MangaTag>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse favicons from the main page of the source`s website
|
* Returns direct link to the website favicon
|
||||||
*/
|
*/
|
||||||
public suspend fun getFavicons(): Favicons
|
open fun getFaviconUrl() = "https://${getDomain()}/favicon.ico"
|
||||||
|
|
||||||
|
@CallSuper
|
||||||
|
open fun onCreateConfig(keys: MutableCollection<ConfigKey<*>>) {
|
||||||
|
keys.add(configKeyDomain)
|
||||||
|
}
|
||||||
|
|
||||||
public fun onCreateConfig(keys: MutableCollection<ConfigKey<*>>)
|
/* Utils */
|
||||||
|
|
||||||
public suspend fun getRelatedManga(seed: Manga): List<Manga>
|
fun getDomain(): String {
|
||||||
|
return config[configKeyDomain]
|
||||||
|
}
|
||||||
|
|
||||||
public fun getRequestHeaders(): Headers
|
fun getDomain(subdomain: String): String {
|
||||||
|
val domain = getDomain()
|
||||||
|
return subdomain + "." + domain.removePrefix("www.")
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Return [Manga] object by web link to it
|
* Create a unique id for [Manga]/[MangaChapter]/[MangaPage].
|
||||||
* @see [Manga.publicUrl]
|
* @param url must be relative url, without a domain
|
||||||
|
* @see [Manga.id]
|
||||||
|
* @see [MangaChapter.id]
|
||||||
|
* @see [MangaPage.id]
|
||||||
*/
|
*/
|
||||||
@InternalParsersApi
|
@InternalParsersApi
|
||||||
public suspend fun resolveLink(resolver: LinkResolver, link: HttpUrl): Manga?
|
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 parseFailed(message: String? = null): Nothing {
|
||||||
|
throw ParseException(message, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,28 +1,20 @@
|
|||||||
package org.koitharu.kotatsu.parsers
|
package org.koitharu.kotatsu.parsers
|
||||||
|
|
||||||
import org.koitharu.kotatsu.parsers.model.ContentType
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Annotate each [MangaParser] implementation with this annotation, used by codegen
|
* Annotate each [MangaParser] implementation with this annotation, used by codegen
|
||||||
*/
|
*/
|
||||||
@Target(AnnotationTarget.CLASS)
|
@Target(AnnotationTarget.CLASS)
|
||||||
@Retention(AnnotationRetention.SOURCE)
|
annotation class MangaSourceParser(
|
||||||
internal annotation class MangaSourceParser(
|
|
||||||
/**
|
/**
|
||||||
* Name of manga source. Used as an Enum value, must be UPPER_CASE and unique.
|
* Name of manga source. Used as an Enum value, must be UPPER_CASE and unique.
|
||||||
*/
|
*/
|
||||||
val name: String,
|
val name: String,
|
||||||
/**
|
/**
|
||||||
* User-friendly title of manga source. In most case equals the website name.
|
* User-friendly title of manga source. In most case equals the website name.
|
||||||
* Avoid extra whitespaces between the words if it is not required.
|
|
||||||
*/
|
*/
|
||||||
val title: String,
|
val title: String,
|
||||||
/**
|
/**
|
||||||
* Language code (for example "en" or "ru") or blank if parser provide manga on different languages.
|
* Language code (for example "en" or "ru") or blank if parser provide manga on different languages.
|
||||||
*/
|
*/
|
||||||
val locale: String = "",
|
val locale: String = "",
|
||||||
/**
|
)
|
||||||
* Type of content provided by parser. See [ContentType] for more info
|
|
||||||
*/
|
|
||||||
val type: ContentType = ContentType.MANGA,
|
|
||||||
)
|
|
||||||
@ -1,9 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.parsers.bitmap
|
|
||||||
|
|
||||||
public interface Bitmap {
|
|
||||||
|
|
||||||
public val width: Int
|
|
||||||
public val height: Int
|
|
||||||
|
|
||||||
public fun drawBitmap(sourceBitmap: Bitmap, src: Rect, dst: Rect)
|
|
||||||
}
|
|
||||||
@ -1,15 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.parsers.bitmap
|
|
||||||
|
|
||||||
public data class Rect(
|
|
||||||
val left: Int = 0,
|
|
||||||
val top: Int = 0,
|
|
||||||
val right: Int = 0,
|
|
||||||
val bottom: Int = 0,
|
|
||||||
) {
|
|
||||||
|
|
||||||
val width: Int
|
|
||||||
get() = right - left
|
|
||||||
|
|
||||||
val height: Int
|
|
||||||
get() = bottom - top
|
|
||||||
}
|
|
||||||
@ -1,37 +1,13 @@
|
|||||||
package org.koitharu.kotatsu.parsers.config
|
package org.koitharu.kotatsu.parsers.config
|
||||||
|
|
||||||
public sealed class ConfigKey<T>(
|
sealed class ConfigKey<T>(
|
||||||
@JvmField public val key: String,
|
val key: String,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
public abstract val defaultValue: T
|
abstract val defaultValue: T
|
||||||
|
|
||||||
public class Domain(
|
class Domain(
|
||||||
@JvmField @JvmSuppressWildcards public vararg val presetValues: String,
|
|
||||||
) : ConfigKey<String>("domain") {
|
|
||||||
|
|
||||||
init {
|
|
||||||
require(presetValues.isNotEmpty()) { "You must provide at least one domain" }
|
|
||||||
}
|
|
||||||
|
|
||||||
override val defaultValue: String
|
|
||||||
get() = presetValues.first()
|
|
||||||
}
|
|
||||||
|
|
||||||
public class ShowSuspiciousContent(
|
|
||||||
override val defaultValue: Boolean,
|
|
||||||
) : ConfigKey<Boolean>("show_suspicious")
|
|
||||||
|
|
||||||
public class UserAgent(
|
|
||||||
override val defaultValue: String,
|
override val defaultValue: String,
|
||||||
) : ConfigKey<String>("user_agent")
|
val presetValues: Array<String>?,
|
||||||
|
) : ConfigKey<String>("domain")
|
||||||
public class SplitByTranslations(
|
}
|
||||||
override val defaultValue: Boolean,
|
|
||||||
) : ConfigKey<Boolean>("split_translations")
|
|
||||||
|
|
||||||
public class PreferredImageServer(
|
|
||||||
public val presetValues: Map<String?, String?>,
|
|
||||||
override val defaultValue: String?,
|
|
||||||
) : ConfigKey<String?>("img_server")
|
|
||||||
}
|
|
||||||
@ -1,6 +1,6 @@
|
|||||||
package org.koitharu.kotatsu.parsers.config
|
package org.koitharu.kotatsu.parsers.config
|
||||||
|
|
||||||
public interface MangaSourceConfig {
|
interface MangaSourceConfig {
|
||||||
|
|
||||||
public operator fun <T> get(key: ConfigKey<T>): T
|
operator fun <T> get(key: ConfigKey<T>): T
|
||||||
}
|
}
|
||||||
@ -1,105 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.parsers.core
|
|
||||||
|
|
||||||
import androidx.annotation.CallSuper
|
|
||||||
import okhttp3.Headers
|
|
||||||
import okhttp3.HttpUrl
|
|
||||||
import okhttp3.Interceptor
|
|
||||||
import okhttp3.Response
|
|
||||||
import org.koitharu.kotatsu.parsers.InternalParsersApi
|
|
||||||
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
|
||||||
import org.koitharu.kotatsu.parsers.MangaParser
|
|
||||||
import org.koitharu.kotatsu.parsers.config.ConfigKey
|
|
||||||
import org.koitharu.kotatsu.parsers.config.MangaSourceConfig
|
|
||||||
import org.koitharu.kotatsu.parsers.model.*
|
|
||||||
import org.koitharu.kotatsu.parsers.model.search.MangaSearchQuery
|
|
||||||
import org.koitharu.kotatsu.parsers.model.search.MangaSearchQueryCapabilities
|
|
||||||
import org.koitharu.kotatsu.parsers.network.OkHttpWebClient
|
|
||||||
import org.koitharu.kotatsu.parsers.network.WebClient
|
|
||||||
import org.koitharu.kotatsu.parsers.util.*
|
|
||||||
import java.util.*
|
|
||||||
|
|
||||||
@Suppress("OVERRIDE_DEPRECATION")
|
|
||||||
@InternalParsersApi
|
|
||||||
public abstract class AbstractMangaParser @InternalParsersApi constructor(
|
|
||||||
@property:InternalParsersApi public val context: MangaLoaderContext,
|
|
||||||
public final override val source: MangaParserSource,
|
|
||||||
) : MangaParser {
|
|
||||||
|
|
||||||
public final override val searchQueryCapabilities: MangaSearchQueryCapabilities
|
|
||||||
get() = filterCapabilities.toMangaSearchQueryCapabilities()
|
|
||||||
|
|
||||||
public override val config: MangaSourceConfig by lazy { context.getConfig(source) }
|
|
||||||
|
|
||||||
public open val sourceLocale: Locale
|
|
||||||
get() = if (source.locale.isEmpty()) Locale.ROOT else Locale(source.locale)
|
|
||||||
|
|
||||||
protected val sourceContentRating: ContentRating?
|
|
||||||
get() = if (source.contentType == ContentType.HENTAI) {
|
|
||||||
ContentRating.ADULT
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
|
|
||||||
protected val isNsfwSource: Boolean = source.contentType == ContentType.HENTAI
|
|
||||||
|
|
||||||
protected open val userAgentKey: ConfigKey.UserAgent = ConfigKey.UserAgent(context.getDefaultUserAgent())
|
|
||||||
|
|
||||||
override fun getRequestHeaders(): Headers = Headers.Builder()
|
|
||||||
.add("User-Agent", config[userAgentKey])
|
|
||||||
.build()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Used as fallback if value of `order` passed to [getList] is null
|
|
||||||
*/
|
|
||||||
public open val defaultSortOrder: SortOrder
|
|
||||||
get() {
|
|
||||||
val supported = availableSortOrders
|
|
||||||
return SortOrder.entries.first { it in supported }
|
|
||||||
}
|
|
||||||
|
|
||||||
final override val domain: String
|
|
||||||
get() = config[configKeyDomain]
|
|
||||||
|
|
||||||
@JvmField
|
|
||||||
protected val webClient: WebClient = OkHttpWebClient(context.httpClient, source)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Search list of manga by specified searchQuery
|
|
||||||
*
|
|
||||||
* @param query searchQuery
|
|
||||||
*/
|
|
||||||
public final override suspend fun getList(query: MangaSearchQuery): List<Manga> = getList(
|
|
||||||
offset = query.offset,
|
|
||||||
order = query.order ?: defaultSortOrder,
|
|
||||||
filter = convertToMangaListFilter(query),
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch direct link to the page image.
|
|
||||||
*/
|
|
||||||
public override suspend fun getPageUrl(page: MangaPage): String = page.url.toAbsoluteUrl(domain)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse favicons from the main page of the source`s website
|
|
||||||
*/
|
|
||||||
public override suspend fun getFavicons(): Favicons {
|
|
||||||
return FaviconParser(webClient, domain).parseFavicons()
|
|
||||||
}
|
|
||||||
|
|
||||||
@CallSuper
|
|
||||||
public override fun onCreateConfig(keys: MutableCollection<ConfigKey<*>>) {
|
|
||||||
keys.add(configKeyDomain)
|
|
||||||
}
|
|
||||||
|
|
||||||
public override suspend fun getRelatedManga(seed: Manga): List<Manga> {
|
|
||||||
return RelatedMangaFinder(listOf(this)).invoke(seed)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Return [Manga] object by web link to it
|
|
||||||
* @see [Manga.publicUrl]
|
|
||||||
*/
|
|
||||||
override suspend fun resolveLink(resolver: LinkResolver, link: HttpUrl): Manga? = null
|
|
||||||
|
|
||||||
override fun intercept(chain: Interceptor.Chain): Response = chain.proceed(chain.request())
|
|
||||||
}
|
|
||||||
@ -1,94 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.parsers.core
|
|
||||||
|
|
||||||
import androidx.annotation.CallSuper
|
|
||||||
import okhttp3.Headers
|
|
||||||
import okhttp3.HttpUrl
|
|
||||||
import okhttp3.Interceptor
|
|
||||||
import okhttp3.Response
|
|
||||||
import org.koitharu.kotatsu.parsers.InternalParsersApi
|
|
||||||
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
|
||||||
import org.koitharu.kotatsu.parsers.MangaParser
|
|
||||||
import org.koitharu.kotatsu.parsers.config.ConfigKey
|
|
||||||
import org.koitharu.kotatsu.parsers.config.MangaSourceConfig
|
|
||||||
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.*
|
|
||||||
import java.util.*
|
|
||||||
|
|
||||||
@Deprecated("Too complex. Use AbstractMangaParser instead")
|
|
||||||
internal abstract class FlexibleMangaParser @InternalParsersApi constructor(
|
|
||||||
@property:InternalParsersApi val context: MangaLoaderContext,
|
|
||||||
final override val source: MangaParserSource,
|
|
||||||
) : MangaParser {
|
|
||||||
|
|
||||||
override val config: MangaSourceConfig by lazy { context.getConfig(source) }
|
|
||||||
|
|
||||||
open val sourceLocale: Locale
|
|
||||||
get() = if (source.locale.isEmpty()) Locale.ROOT else Locale(source.locale)
|
|
||||||
|
|
||||||
protected open val userAgentKey: ConfigKey.UserAgent = ConfigKey.UserAgent(context.getDefaultUserAgent())
|
|
||||||
|
|
||||||
final override val filterCapabilities: MangaListFilterCapabilities
|
|
||||||
get() = searchQueryCapabilities.toMangaListFilterCapabilities()
|
|
||||||
|
|
||||||
protected val sourceContentRating: ContentRating?
|
|
||||||
get() = if (source.contentType == ContentType.HENTAI) {
|
|
||||||
ContentRating.ADULT
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
|
|
||||||
final override val domain: String
|
|
||||||
get() = config[configKeyDomain]
|
|
||||||
|
|
||||||
@Deprecated("Override intercept() instead")
|
|
||||||
override fun getRequestHeaders(): Headers = Headers.Builder()
|
|
||||||
.add("User-Agent", config[userAgentKey])
|
|
||||||
.build()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Used as fallback if value of `order` passed to [getList] is null
|
|
||||||
*/
|
|
||||||
open val defaultSortOrder: SortOrder
|
|
||||||
get() {
|
|
||||||
val supported = availableSortOrders
|
|
||||||
return SortOrder.entries.first { it in supported }
|
|
||||||
}
|
|
||||||
|
|
||||||
@JvmField
|
|
||||||
protected val webClient: WebClient = OkHttpWebClient(context.httpClient, source)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch direct link to the page image.
|
|
||||||
*/
|
|
||||||
override suspend fun getPageUrl(page: MangaPage): String = page.url.toAbsoluteUrl(domain)
|
|
||||||
|
|
||||||
final override suspend fun getList(offset: Int, order: SortOrder, filter: MangaListFilter): List<Manga> {
|
|
||||||
return getList(convertToMangaSearchQuery(offset, order, filter))
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse favicons from the main page of the source`s website
|
|
||||||
*/
|
|
||||||
override suspend fun getFavicons(): Favicons {
|
|
||||||
return FaviconParser(webClient, domain).parseFavicons()
|
|
||||||
}
|
|
||||||
|
|
||||||
@CallSuper
|
|
||||||
override fun onCreateConfig(keys: MutableCollection<ConfigKey<*>>) {
|
|
||||||
keys.add(configKeyDomain)
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun getRelatedManga(seed: Manga): List<Manga> {
|
|
||||||
return RelatedMangaFinder(listOf(this)).invoke(seed)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Return [Manga] object by web link to it
|
|
||||||
* @see [Manga.publicUrl]
|
|
||||||
*/
|
|
||||||
override suspend fun resolveLink(resolver: LinkResolver, link: HttpUrl): Manga? = null
|
|
||||||
|
|
||||||
override fun intercept(chain: Interceptor.Chain): Response = chain.proceed(chain.request())
|
|
||||||
}
|
|
||||||
@ -1,60 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.parsers.core
|
|
||||||
|
|
||||||
import androidx.annotation.VisibleForTesting
|
|
||||||
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaParserSource
|
|
||||||
import org.koitharu.kotatsu.parsers.model.search.MangaSearchQuery
|
|
||||||
import org.koitharu.kotatsu.parsers.model.search.SearchableField
|
|
||||||
import org.koitharu.kotatsu.parsers.util.Paginator
|
|
||||||
|
|
||||||
@Deprecated("Too complex. Use PagedMangaParser instead")
|
|
||||||
internal abstract class FlexiblePagedMangaParser(
|
|
||||||
context: MangaLoaderContext,
|
|
||||||
source: MangaParserSource,
|
|
||||||
@VisibleForTesting(otherwise = VisibleForTesting.PROTECTED) @JvmField public val pageSize: Int,
|
|
||||||
searchPageSize: Int = pageSize,
|
|
||||||
) : FlexibleMangaParser(context, source) {
|
|
||||||
|
|
||||||
@JvmField
|
|
||||||
protected val paginator: Paginator = Paginator(pageSize)
|
|
||||||
|
|
||||||
@JvmField
|
|
||||||
protected val searchPaginator: Paginator = Paginator(searchPageSize)
|
|
||||||
|
|
||||||
final override suspend fun getList(query: MangaSearchQuery): List<Manga> {
|
|
||||||
var containTitleNameCriteria = false
|
|
||||||
query.criteria.forEach {
|
|
||||||
if (it.field == SearchableField.TITLE_NAME) {
|
|
||||||
containTitleNameCriteria = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return searchManga(
|
|
||||||
paginator = if (containTitleNameCriteria) {
|
|
||||||
paginator
|
|
||||||
} else {
|
|
||||||
searchPaginator
|
|
||||||
},
|
|
||||||
query = query,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
public abstract suspend fun getListPage(query: MangaSearchQuery, page: Int): List<Manga>
|
|
||||||
|
|
||||||
protected fun setFirstPage(firstPage: Int, firstPageForSearch: Int = firstPage) {
|
|
||||||
paginator.firstPage = firstPage
|
|
||||||
searchPaginator.firstPage = firstPageForSearch
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun searchManga(
|
|
||||||
paginator: Paginator,
|
|
||||||
query: MangaSearchQuery,
|
|
||||||
): List<Manga> {
|
|
||||||
val offset: Int = query.offset
|
|
||||||
val page = paginator.getPage(offset)
|
|
||||||
val list = getListPage(query, page)
|
|
||||||
paginator.onListReceived(offset, page, list.size)
|
|
||||||
return list
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,77 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.parsers.core
|
|
||||||
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import okhttp3.Interceptor
|
|
||||||
import okhttp3.Request
|
|
||||||
import okhttp3.Response
|
|
||||||
import org.koitharu.kotatsu.parsers.MangaParser
|
|
||||||
import org.koitharu.kotatsu.parsers.MangaParserAuthProvider
|
|
||||||
import org.koitharu.kotatsu.parsers.model.*
|
|
||||||
import org.koitharu.kotatsu.parsers.model.search.MangaSearchQuery
|
|
||||||
import org.koitharu.kotatsu.parsers.util.mergeWith
|
|
||||||
|
|
||||||
internal class MangaParserWrapper(
|
|
||||||
private val delegate: MangaParser,
|
|
||||||
) : MangaParser by delegate {
|
|
||||||
|
|
||||||
override val authorizationProvider: MangaParserAuthProvider?
|
|
||||||
get() = delegate as? MangaParserAuthProvider
|
|
||||||
|
|
||||||
@Deprecated("Too complex. Use getList with filter instead")
|
|
||||||
override suspend fun getList(query: MangaSearchQuery): List<Manga> = withContext(Dispatchers.Default) {
|
|
||||||
if (!query.skipValidation) {
|
|
||||||
searchQueryCapabilities.validate(query)
|
|
||||||
}
|
|
||||||
delegate.getList(query)
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun getList(
|
|
||||||
offset: Int,
|
|
||||||
order: SortOrder,
|
|
||||||
filter: MangaListFilter,
|
|
||||||
): List<Manga> = withContext(Dispatchers.Default) {
|
|
||||||
delegate.getList(offset, order, filter)
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun getDetails(manga: Manga): Manga = withContext(Dispatchers.Default) {
|
|
||||||
delegate.getDetails(manga)
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> = withContext(Dispatchers.Default) {
|
|
||||||
delegate.getPages(chapter)
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun getPageUrl(page: MangaPage): String = withContext(Dispatchers.Default) {
|
|
||||||
delegate.getPageUrl(page)
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun getFilterOptions(): MangaListFilterOptions = withContext(Dispatchers.Default) {
|
|
||||||
delegate.getFilterOptions()
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun getFavicons(): Favicons = withContext(Dispatchers.Default) {
|
|
||||||
delegate.getFavicons()
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun getRelatedManga(seed: Manga): List<Manga> = withContext(Dispatchers.Default) {
|
|
||||||
delegate.getRelatedManga(seed)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun intercept(chain: Interceptor.Chain): Response {
|
|
||||||
val request = chain.request()
|
|
||||||
val headers = request.headers.newBuilder()
|
|
||||||
.mergeWith(delegate.getRequestHeaders(), replaceExisting = false)
|
|
||||||
.build()
|
|
||||||
val newRequest = request.newBuilder().headers(headers).build()
|
|
||||||
return delegate.intercept(ProxyChain(chain, newRequest))
|
|
||||||
}
|
|
||||||
|
|
||||||
private class ProxyChain(
|
|
||||||
private val delegate: Interceptor.Chain,
|
|
||||||
private val request: Request,
|
|
||||||
) : Interceptor.Chain by delegate {
|
|
||||||
|
|
||||||
override fun request(): Request = request
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,57 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.parsers.core
|
|
||||||
|
|
||||||
import androidx.annotation.VisibleForTesting
|
|
||||||
import org.koitharu.kotatsu.parsers.InternalParsersApi
|
|
||||||
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaListFilter
|
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaParserSource
|
|
||||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
|
||||||
import org.koitharu.kotatsu.parsers.util.Paginator
|
|
||||||
|
|
||||||
@InternalParsersApi
|
|
||||||
public abstract class PagedMangaParser(
|
|
||||||
context: MangaLoaderContext,
|
|
||||||
source: MangaParserSource,
|
|
||||||
@VisibleForTesting(otherwise = VisibleForTesting.PROTECTED) @JvmField public val pageSize: Int,
|
|
||||||
searchPageSize: Int = pageSize,
|
|
||||||
) : AbstractMangaParser(context, source) {
|
|
||||||
|
|
||||||
@JvmField
|
|
||||||
protected val paginator: Paginator = Paginator(pageSize)
|
|
||||||
|
|
||||||
@JvmField
|
|
||||||
protected val searchPaginator: Paginator = Paginator(searchPageSize)
|
|
||||||
|
|
||||||
final override suspend fun getList(offset: Int, order: SortOrder, filter: MangaListFilter): List<Manga> {
|
|
||||||
return getList(
|
|
||||||
paginator = if (filter.query.isNullOrEmpty()) {
|
|
||||||
paginator
|
|
||||||
} else {
|
|
||||||
searchPaginator
|
|
||||||
},
|
|
||||||
offset = offset,
|
|
||||||
order = order,
|
|
||||||
filter = filter,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
public abstract suspend fun getListPage(page: Int, order: SortOrder, filter: MangaListFilter): List<Manga>
|
|
||||||
|
|
||||||
protected fun setFirstPage(firstPage: Int, firstPageForSearch: Int = firstPage) {
|
|
||||||
paginator.firstPage = firstPage
|
|
||||||
searchPaginator.firstPage = firstPageForSearch
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun getList(
|
|
||||||
paginator: Paginator,
|
|
||||||
offset: Int,
|
|
||||||
order: SortOrder,
|
|
||||||
filter: MangaListFilter,
|
|
||||||
): List<Manga> {
|
|
||||||
val page = paginator.getPage(offset)
|
|
||||||
val list = getListPage(page, order, filter)
|
|
||||||
paginator.onListReceived(offset, page, list.size)
|
|
||||||
return list
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,24 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.parsers.core
|
|
||||||
|
|
||||||
import org.koitharu.kotatsu.parsers.InternalParsersApi
|
|
||||||
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaListFilter
|
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaParserSource
|
|
||||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
|
||||||
|
|
||||||
@InternalParsersApi
|
|
||||||
public abstract class SinglePageMangaParser(
|
|
||||||
context: MangaLoaderContext,
|
|
||||||
source: MangaParserSource,
|
|
||||||
) : AbstractMangaParser(context, source) {
|
|
||||||
|
|
||||||
final override suspend fun getList(offset: Int, order: SortOrder, filter: MangaListFilter): List<Manga> {
|
|
||||||
if (offset > 0) {
|
|
||||||
return emptyList()
|
|
||||||
}
|
|
||||||
return getList(order, filter)
|
|
||||||
}
|
|
||||||
|
|
||||||
public abstract suspend fun getList(order: SortOrder, filter: MangaListFilter): List<Manga>
|
|
||||||
}
|
|
||||||
@ -1,13 +1,11 @@
|
|||||||
package org.koitharu.kotatsu.parsers.exception
|
package org.koitharu.kotatsu.parsers.exception
|
||||||
|
|
||||||
import okio.IOException
|
|
||||||
import org.koitharu.kotatsu.parsers.InternalParsersApi
|
import org.koitharu.kotatsu.parsers.InternalParsersApi
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Authorization is required for access to the requested content
|
* Authorization is required for access to the requested content
|
||||||
*/
|
*/
|
||||||
public class AuthRequiredException @InternalParsersApi @JvmOverloads constructor(
|
class AuthRequiredException @InternalParsersApi constructor(
|
||||||
public val source: MangaSource,
|
val source: MangaSource,
|
||||||
cause: Throwable? = null,
|
) : RuntimeException("Authorization required")
|
||||||
) : IOException("Authorization required", cause)
|
|
||||||
@ -0,0 +1,7 @@
|
|||||||
|
package org.koitharu.kotatsu.parsers.exception
|
||||||
|
|
||||||
|
import okio.IOException
|
||||||
|
|
||||||
|
class CloudFlareProtectedException(
|
||||||
|
val url: String,
|
||||||
|
) : IOException("Protected by CloudFlare: $url")
|
||||||
@ -1,3 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.parsers.exception
|
|
||||||
|
|
||||||
public class ContentUnavailableException(message: String) : RuntimeException(message)
|
|
||||||
@ -1,9 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.parsers.exception
|
|
||||||
|
|
||||||
import org.jsoup.HttpStatusException
|
|
||||||
import java.net.HttpURLConnection
|
|
||||||
|
|
||||||
public class NotFoundException(
|
|
||||||
message: String,
|
|
||||||
url: String,
|
|
||||||
) : HttpStatusException(message, HttpURLConnection.HTTP_NOT_FOUND, url)
|
|
||||||
@ -1,31 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.parsers.exception
|
|
||||||
|
|
||||||
import okio.IOException
|
|
||||||
import java.time.Instant
|
|
||||||
import java.time.temporal.ChronoUnit
|
|
||||||
|
|
||||||
public class TooManyRequestExceptions(
|
|
||||||
public val url: String,
|
|
||||||
retryAfter: Long,
|
|
||||||
) : IOException("Too man requests") {
|
|
||||||
|
|
||||||
public val retryAt: Instant? = if (retryAfter > 0 && retryAfter < Long.MAX_VALUE) {
|
|
||||||
Instant.now().plusMillis(retryAfter)
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
|
|
||||||
public fun getRetryDelay(): Long {
|
|
||||||
if (retryAt == null) {
|
|
||||||
return -1L
|
|
||||||
}
|
|
||||||
return Instant.now().until(retryAt, ChronoUnit.MILLIS).coerceAtLeast(0L)
|
|
||||||
}
|
|
||||||
|
|
||||||
override val message: String?
|
|
||||||
get() = if (retryAt != null) {
|
|
||||||
"${super.message}, retry at $retryAt"
|
|
||||||
} else {
|
|
||||||
super.message
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,7 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.parsers.model
|
|
||||||
|
|
||||||
public enum class ContentRating {
|
|
||||||
SAFE,
|
|
||||||
SUGGESTIVE,
|
|
||||||
ADULT
|
|
||||||
}
|
|
||||||
@ -1,36 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.parsers.model
|
|
||||||
|
|
||||||
public enum class ContentType {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Standard manga, manhua, webtoons, etc
|
|
||||||
*/
|
|
||||||
MANGA,
|
|
||||||
|
|
||||||
MANHWA,
|
|
||||||
|
|
||||||
MANHUA,
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Use this if the source provides mostly nsfw content.
|
|
||||||
*/
|
|
||||||
HENTAI,
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Western comics
|
|
||||||
*/
|
|
||||||
COMICS,
|
|
||||||
|
|
||||||
NOVEL,
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Use this type if no other suits your needs. For example, for an indie manga
|
|
||||||
*/
|
|
||||||
|
|
||||||
ONE_SHOT,
|
|
||||||
DOUJINSHI,
|
|
||||||
IMAGE_SET,
|
|
||||||
ARTIST_CG,
|
|
||||||
GAME_CG,
|
|
||||||
OTHER,
|
|
||||||
}
|
|
||||||
@ -1,10 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.parsers.model
|
|
||||||
|
|
||||||
public enum class Demographic {
|
|
||||||
SHOUNEN,
|
|
||||||
SHOUJO,
|
|
||||||
SEINEN,
|
|
||||||
JOSEI,
|
|
||||||
KODOMO,
|
|
||||||
NONE,
|
|
||||||
}
|
|
||||||
@ -1,28 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.parsers.model
|
|
||||||
|
|
||||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
|
||||||
|
|
||||||
public data class Favicon(
|
|
||||||
@JvmField public val url: String,
|
|
||||||
@JvmField public val size: Int,
|
|
||||||
@JvmField internal val rel: String?,
|
|
||||||
) : Comparable<Favicon> {
|
|
||||||
|
|
||||||
@JvmField
|
|
||||||
public val type: String = url.toHttpUrl().pathSegments.last()
|
|
||||||
.substringAfterLast('.', "").lowercase()
|
|
||||||
|
|
||||||
override fun compareTo(other: Favicon): Int {
|
|
||||||
val res = size.compareTo(other.size)
|
|
||||||
if (res != 0) {
|
|
||||||
return res
|
|
||||||
}
|
|
||||||
return relWeightOf(rel).compareTo(relWeightOf(other.rel))
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun relWeightOf(rel: String?) = when (rel) {
|
|
||||||
"apple-touch-icon" -> 1 // Prefer apple-touch-icon because it has a better quality
|
|
||||||
"mask-icon" -> -1
|
|
||||||
else -> 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,59 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.parsers.model
|
|
||||||
|
|
||||||
public class Favicons(
|
|
||||||
favicons: Collection<Favicon>,
|
|
||||||
@JvmField public val referer: String?,
|
|
||||||
) : Collection<Favicon> {
|
|
||||||
|
|
||||||
private val icons = favicons.sortedDescending()
|
|
||||||
|
|
||||||
override val size: Int
|
|
||||||
get() = icons.size
|
|
||||||
|
|
||||||
override fun contains(element: Favicon): Boolean = icons.contains(element)
|
|
||||||
|
|
||||||
override fun containsAll(elements: Collection<Favicon>): Boolean = icons.containsAll(elements)
|
|
||||||
|
|
||||||
override fun isEmpty(): Boolean = icons.isEmpty()
|
|
||||||
|
|
||||||
override fun iterator(): Iterator<Favicon> = icons.iterator()
|
|
||||||
|
|
||||||
public operator fun minus(victim: Favicon): Favicons = Favicons(
|
|
||||||
favicons = icons.filterNot { it == victim },
|
|
||||||
referer = referer,
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Finds a favicon whose size in pixels is greater than or equal to the specified size.
|
|
||||||
* If such icon is not available returns the largest icon
|
|
||||||
* @param size in pixels
|
|
||||||
* @param types supported file types, e.g. png, svg, ico. May be null but not empty
|
|
||||||
*/
|
|
||||||
@JvmOverloads
|
|
||||||
public fun find(size: Int, types: Set<String>? = null): Favicon? {
|
|
||||||
if (icons.isEmpty()) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
var result: Favicon? = null
|
|
||||||
for (icon in icons) {
|
|
||||||
if (types != null && icon.type !in types) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if (result == null || icon.size >= size) {
|
|
||||||
result = icon
|
|
||||||
} else {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
public companion object {
|
|
||||||
|
|
||||||
@JvmStatic
|
|
||||||
public val EMPTY: Favicons = Favicons(emptySet(), null)
|
|
||||||
|
|
||||||
@JvmStatic
|
|
||||||
public fun single(url: String): Favicons = Favicons(setOf(Favicon(url, 0, null)), null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,203 +1,158 @@
|
|||||||
package org.koitharu.kotatsu.parsers.model
|
package org.koitharu.kotatsu.parsers.model
|
||||||
|
|
||||||
import androidx.collection.ArrayMap
|
import org.koitharu.kotatsu.parsers.InternalParsersApi
|
||||||
import org.koitharu.kotatsu.parsers.util.findById
|
|
||||||
import org.koitharu.kotatsu.parsers.util.nullIfEmpty
|
|
||||||
|
|
||||||
public data class Manga(
|
class Manga(
|
||||||
/**
|
/**
|
||||||
* Unique identifier for manga
|
* Unique identifier for manga
|
||||||
*/
|
*/
|
||||||
@JvmField public val id: Long,
|
val id: Long,
|
||||||
/**
|
/**
|
||||||
* Manga title, human-readable
|
* Manga title, human-readable
|
||||||
*/
|
*/
|
||||||
@JvmField public val title: String,
|
val title: String,
|
||||||
/**
|
/**
|
||||||
* Alternative titles (for example on other language), may be empty
|
* Alternative title (for example on other language), may be null
|
||||||
*/
|
*/
|
||||||
@JvmField public val altTitles: Set<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 public 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 public 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 public 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 public val contentRating: ContentRating?,
|
val isNsfw: Boolean,
|
||||||
/**
|
/**
|
||||||
* Absolute link to the cover
|
* Absolute link to the cover
|
||||||
* @see largeCoverUrl
|
* @see largeCoverUrl
|
||||||
*/
|
*/
|
||||||
@JvmField public val coverUrl: String?,
|
val coverUrl: String,
|
||||||
/**
|
/**
|
||||||
* Tags (genres) of the manga
|
* Tags (genres) of the manga
|
||||||
*/
|
*/
|
||||||
@JvmField public 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 public val state: MangaState?,
|
val state: MangaState?,
|
||||||
/**
|
/**
|
||||||
* Authors of the manga
|
* Author of the manga, may be null
|
||||||
*/
|
*/
|
||||||
@JvmField public val authors: Set<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 public val largeCoverUrl: String? = null,
|
val largeCoverUrl: String? = null,
|
||||||
/**
|
/**
|
||||||
* Manga description, may be html or null
|
* Manga description, may be html or null
|
||||||
*/
|
*/
|
||||||
@JvmField public val description: String? = null,
|
val description: String? = null,
|
||||||
/**
|
/**
|
||||||
* List of chapters
|
* List of chapters
|
||||||
*/
|
*/
|
||||||
@JvmField public val chapters: List<MangaChapter>? = null,
|
val chapters: List<MangaChapter>? = null,
|
||||||
/**
|
/**
|
||||||
* Manga source
|
* Manga source
|
||||||
*/
|
*/
|
||||||
@JvmField public val source: MangaSource,
|
val source: MangaSource,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
@Deprecated("Use other constructor")
|
/**
|
||||||
public constructor(
|
* Return if manga has a specified rating
|
||||||
/**
|
* @see rating
|
||||||
* Unique identifier for manga
|
*/
|
||||||
*/
|
val hasRating: Boolean
|
||||||
id: Long,
|
get() = rating in 0f..1f
|
||||||
/**
|
|
||||||
* Manga title, human-readable
|
fun getChapters(branch: String?): List<MangaChapter>? {
|
||||||
*/
|
return chapters?.filter { x -> x.branch == branch }
|
||||||
title: String,
|
}
|
||||||
/**
|
|
||||||
* Alternative title (for example on other language), may be null
|
@InternalParsersApi
|
||||||
*/
|
fun copy(
|
||||||
altTitle: String?,
|
title: String = this.title,
|
||||||
/**
|
altTitle: String? = this.altTitle,
|
||||||
* Relative url to manga (**without** a domain) or any other uri.
|
publicUrl: String = this.publicUrl,
|
||||||
* Used principally in parsers
|
rating: Float = this.rating,
|
||||||
*/
|
isNsfw: Boolean = this.isNsfw,
|
||||||
url: String,
|
coverUrl: String = this.coverUrl,
|
||||||
/**
|
tags: Set<MangaTag> = this.tags,
|
||||||
* Absolute url to manga, must be ready to open in browser
|
state: MangaState? = this.state,
|
||||||
*/
|
author: String? = this.author,
|
||||||
publicUrl: String,
|
largeCoverUrl: String? = this.largeCoverUrl,
|
||||||
/**
|
description: String? = this.description,
|
||||||
* Normalized manga rating, must be in range of 0..1 or [RATING_UNKNOWN] if rating s unknown
|
chapters: List<MangaChapter>? = this.chapters,
|
||||||
* @see hasRating
|
) = Manga(
|
||||||
*/
|
|
||||||
rating: Float,
|
|
||||||
/**
|
|
||||||
* Indicates that manga may contain sensitive information (18+, NSFW)
|
|
||||||
*/
|
|
||||||
isNsfw: Boolean,
|
|
||||||
/**
|
|
||||||
* Absolute link to the cover
|
|
||||||
* @see largeCoverUrl
|
|
||||||
*/
|
|
||||||
coverUrl: String?,
|
|
||||||
/**
|
|
||||||
* Tags (genres) of the manga
|
|
||||||
*/
|
|
||||||
tags: Set<MangaTag>,
|
|
||||||
/**
|
|
||||||
* Manga status (ongoing, finished) or null if unknown
|
|
||||||
*/
|
|
||||||
state: MangaState?,
|
|
||||||
/**
|
|
||||||
* Authors of the manga
|
|
||||||
*/
|
|
||||||
author: String?,
|
|
||||||
/**
|
|
||||||
* Large cover url (absolute), null if is no large cover
|
|
||||||
* @see coverUrl
|
|
||||||
*/
|
|
||||||
largeCoverUrl: String? = null,
|
|
||||||
/**
|
|
||||||
* Manga description, may be html or null
|
|
||||||
*/
|
|
||||||
description: String? = null,
|
|
||||||
/**
|
|
||||||
* List of chapters
|
|
||||||
*/
|
|
||||||
chapters: List<MangaChapter>? = null,
|
|
||||||
/**
|
|
||||||
* Manga source
|
|
||||||
*/
|
|
||||||
source: MangaSource,
|
|
||||||
) : this(
|
|
||||||
id = id,
|
id = id,
|
||||||
title = title,
|
title = title,
|
||||||
altTitles = setOfNotNull(altTitle?.nullIfEmpty()),
|
altTitle = altTitle,
|
||||||
url = url,
|
url = url,
|
||||||
publicUrl = publicUrl,
|
publicUrl = publicUrl,
|
||||||
rating = rating,
|
rating = rating,
|
||||||
contentRating = if (isNsfw) ContentRating.ADULT else null,
|
isNsfw = isNsfw,
|
||||||
coverUrl = coverUrl?.nullIfEmpty(),
|
coverUrl = coverUrl,
|
||||||
tags = tags,
|
tags = tags,
|
||||||
state = state,
|
state = state,
|
||||||
authors = setOfNotNull(author),
|
author = author,
|
||||||
largeCoverUrl = largeCoverUrl?.nullIfEmpty(),
|
largeCoverUrl = largeCoverUrl,
|
||||||
description = description?.nullIfEmpty(),
|
description = description,
|
||||||
chapters = chapters,
|
chapters = chapters,
|
||||||
source = source,
|
source = source,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
override fun equals(other: Any?): Boolean {
|
||||||
* Author of the manga, may be null
|
if (this === other) return true
|
||||||
*/
|
if (javaClass != other?.javaClass) return false
|
||||||
@Deprecated("Please use authors")
|
|
||||||
public val author: String?
|
|
||||||
get() = authors.firstOrNull()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Alternative title (for example on other language), may be null
|
|
||||||
*/
|
|
||||||
@Deprecated("Please use altTitles")
|
|
||||||
public val altTitle: String?
|
|
||||||
get() = altTitles.firstOrNull()
|
|
||||||
|
|
||||||
/**
|
other as Manga
|
||||||
* Return if manga has a specified rating
|
|
||||||
* @see rating
|
|
||||||
*/
|
|
||||||
public val hasRating: Boolean
|
|
||||||
get() = rating > 0f && rating <= 1f
|
|
||||||
|
|
||||||
@Deprecated("Use contentRating instead", ReplaceWith("contentRating == ContentRating.ADULT"))
|
if (id != other.id) return false
|
||||||
public val isNsfw: Boolean
|
if (title != other.title) return false
|
||||||
get() = contentRating == ContentRating.ADULT
|
if (altTitle != other.altTitle) return false
|
||||||
|
if (url != other.url) return false
|
||||||
|
if (publicUrl != other.publicUrl) return false
|
||||||
|
if (rating != other.rating) return false
|
||||||
|
if (isNsfw != other.isNsfw) return false
|
||||||
|
if (coverUrl != other.coverUrl) return false
|
||||||
|
if (tags != other.tags) return false
|
||||||
|
if (state != other.state) return false
|
||||||
|
if (author != other.author) return false
|
||||||
|
if (largeCoverUrl != other.largeCoverUrl) return false
|
||||||
|
if (description != other.description) return false
|
||||||
|
if (chapters != other.chapters) return false
|
||||||
|
if (source != other.source) return false
|
||||||
|
|
||||||
public fun getChapters(branch: String?): List<MangaChapter> {
|
return true
|
||||||
return chapters?.filter { x -> x.branch == branch }.orEmpty()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public fun findChapterById(id: Long): MangaChapter? = chapters?.findById(id)
|
override fun hashCode(): Int {
|
||||||
|
var result = id.hashCode()
|
||||||
public fun requireChapterById(id: Long): MangaChapter = findChapterById(id)
|
result = 31 * result + title.hashCode()
|
||||||
?: throw NoSuchElementException("Chapter with id $id not found")
|
result = 31 * result + (altTitle?.hashCode() ?: 0)
|
||||||
|
result = 31 * result + url.hashCode()
|
||||||
public fun getBranches(): Map<String?, Int> {
|
result = 31 * result + publicUrl.hashCode()
|
||||||
if (chapters.isNullOrEmpty()) {
|
result = 31 * result + rating.hashCode()
|
||||||
return emptyMap()
|
result = 31 * result + isNsfw.hashCode()
|
||||||
}
|
result = 31 * result + coverUrl.hashCode()
|
||||||
val result = ArrayMap<String?, Int>()
|
result = 31 * result + tags.hashCode()
|
||||||
chapters.forEach {
|
result = 31 * result + (state?.hashCode() ?: 0)
|
||||||
val key = it.branch
|
result = 31 * result + (author?.hashCode() ?: 0)
|
||||||
result[key] = result.getOrDefault(key, 0) + 1
|
result = 31 * result + (largeCoverUrl?.hashCode() ?: 0)
|
||||||
}
|
result = 31 * result + (description?.hashCode() ?: 0)
|
||||||
|
result = 31 * result + (chapters?.hashCode() ?: 0)
|
||||||
|
result = 31 * result + source.hashCode()
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1,65 +1,70 @@
|
|||||||
package org.koitharu.kotatsu.parsers.model
|
package org.koitharu.kotatsu.parsers.model
|
||||||
|
|
||||||
import org.koitharu.kotatsu.parsers.util.formatSimple
|
class MangaChapter(
|
||||||
import org.koitharu.kotatsu.parsers.util.ifNullOrEmpty
|
|
||||||
|
|
||||||
public data class MangaChapter(
|
|
||||||
/**
|
/**
|
||||||
* An unique id of chapter
|
* An unique id of chapter
|
||||||
*/
|
*/
|
||||||
@JvmField public val id: Long,
|
val id: Long,
|
||||||
/**
|
|
||||||
* User-readable name of chapter if provided by parser or null instead
|
|
||||||
* Do not pass manga title or chapter number here
|
|
||||||
*/
|
|
||||||
@JvmField public val title: String?,
|
|
||||||
/**
|
/**
|
||||||
* Chapter number starting from 1, 0 if unknown
|
* User-readable name of chapter
|
||||||
*/
|
*/
|
||||||
@JvmField public val number: Float,
|
val name: String,
|
||||||
/**
|
/**
|
||||||
* Volume number starting from 1, 0 if unknown
|
* Chapter number starting from 1
|
||||||
*/
|
*/
|
||||||
@JvmField public val volume: 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 public 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 public val scanlator: String?,
|
val scanlator: String?,
|
||||||
/**
|
/**
|
||||||
* Chapter upload date in milliseconds
|
* Chapter upload date in milliseconds
|
||||||
*/
|
*/
|
||||||
@JvmField public 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 public val branch: String?,
|
val branch: String?,
|
||||||
@JvmField public val source: MangaSource,
|
val source: MangaSource,
|
||||||
) {
|
) : Comparable<MangaChapter> {
|
||||||
|
|
||||||
|
override fun compareTo(other: MangaChapter): Int {
|
||||||
|
return number.compareTo(other.number)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun equals(other: Any?): Boolean {
|
||||||
|
if (this === other) return true
|
||||||
|
if (javaClass != other?.javaClass) return false
|
||||||
|
|
||||||
|
other as MangaChapter
|
||||||
|
|
||||||
@Deprecated("Use title instead", ReplaceWith("title"))
|
if (id != other.id) return false
|
||||||
val name: String
|
if (name != other.name) return false
|
||||||
get() = title.ifNullOrEmpty {
|
if (number != other.number) return false
|
||||||
buildString {
|
if (url != other.url) return false
|
||||||
if (volume > 0) append("Vol ").append(volume).append(' ')
|
if (scanlator != other.scanlator) return false
|
||||||
if (number > 0) append("Chapter ").append(number.formatSimple()) else append("Unnamed")
|
if (uploadDate != other.uploadDate) return false
|
||||||
}
|
if (branch != other.branch) return false
|
||||||
}
|
if (source != other.source) return false
|
||||||
|
|
||||||
public fun numberString(): String? = if (number > 0f) {
|
return true
|
||||||
number.formatSimple()
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public fun volumeString(): String? = if (volume > 0) {
|
override fun hashCode(): Int {
|
||||||
volume.toString()
|
var result = id.hashCode()
|
||||||
} else {
|
result = 31 * result + name.hashCode()
|
||||||
null
|
result = 31 * result + number
|
||||||
|
result = 31 * result + url.hashCode()
|
||||||
|
result = 31 * result + (scanlator?.hashCode() ?: 0)
|
||||||
|
result = 31 * result + uploadDate.hashCode()
|
||||||
|
result = 31 * result + (branch?.hashCode() ?: 0)
|
||||||
|
result = 31 * result + source.hashCode()
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1,88 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.parsers.model
|
|
||||||
|
|
||||||
import java.util.*
|
|
||||||
|
|
||||||
public data class MangaListFilter(
|
|
||||||
@JvmField val query: String? = null,
|
|
||||||
@JvmField val tags: Set<MangaTag> = emptySet(),
|
|
||||||
@JvmField val tagsExclude: Set<MangaTag> = emptySet(),
|
|
||||||
@JvmField val locale: Locale? = null,
|
|
||||||
@JvmField val originalLocale: Locale? = null,
|
|
||||||
@JvmField val states: Set<MangaState> = emptySet(),
|
|
||||||
@JvmField val contentRating: Set<ContentRating> = emptySet(),
|
|
||||||
@JvmField val types: Set<ContentType> = emptySet(),
|
|
||||||
@JvmField val demographics: Set<Demographic> = emptySet(),
|
|
||||||
@JvmField val year: Int = YEAR_UNKNOWN,
|
|
||||||
@JvmField val yearFrom: Int = YEAR_UNKNOWN,
|
|
||||||
@JvmField val yearTo: Int = YEAR_UNKNOWN,
|
|
||||||
@JvmField val author: String? = null,
|
|
||||||
) {
|
|
||||||
|
|
||||||
private fun isNonSearchOptionsEmpty(): Boolean = tags.isEmpty() &&
|
|
||||||
tagsExclude.isEmpty() &&
|
|
||||||
locale == null &&
|
|
||||||
originalLocale == null &&
|
|
||||||
states.isEmpty() &&
|
|
||||||
contentRating.isEmpty() &&
|
|
||||||
year == YEAR_UNKNOWN &&
|
|
||||||
yearFrom == YEAR_UNKNOWN &&
|
|
||||||
yearTo == YEAR_UNKNOWN &&
|
|
||||||
types.isEmpty() &&
|
|
||||||
demographics.isEmpty() &&
|
|
||||||
author.isNullOrEmpty()
|
|
||||||
|
|
||||||
public fun isEmpty(): Boolean = isNonSearchOptionsEmpty() && query.isNullOrEmpty()
|
|
||||||
|
|
||||||
public fun isNotEmpty(): Boolean = !isEmpty()
|
|
||||||
|
|
||||||
public fun hasNonSearchOptions(): Boolean = !isNonSearchOptionsEmpty()
|
|
||||||
|
|
||||||
public companion object {
|
|
||||||
|
|
||||||
@JvmStatic
|
|
||||||
public val EMPTY: MangaListFilter = MangaListFilter()
|
|
||||||
}
|
|
||||||
|
|
||||||
internal class Builder {
|
|
||||||
private var query: String? = null
|
|
||||||
private val tags: MutableSet<MangaTag> = mutableSetOf()
|
|
||||||
private val tagsExclude: MutableSet<MangaTag> = mutableSetOf()
|
|
||||||
private var locale: Locale? = null
|
|
||||||
private var originalLocale: Locale? = null
|
|
||||||
private val states: MutableSet<MangaState> = mutableSetOf()
|
|
||||||
private val contentRating: MutableSet<ContentRating> = mutableSetOf()
|
|
||||||
private val types: MutableSet<ContentType> = mutableSetOf()
|
|
||||||
private val demographics: MutableSet<Demographic> = mutableSetOf()
|
|
||||||
private var year: Int = YEAR_UNKNOWN
|
|
||||||
private var yearFrom: Int = YEAR_UNKNOWN
|
|
||||||
private var yearTo: Int = YEAR_UNKNOWN
|
|
||||||
|
|
||||||
fun query(query: String?): Builder = apply { this.query = query }
|
|
||||||
fun addTag(tag: MangaTag): Builder = apply { tags.add(tag) }
|
|
||||||
fun addTags(tags: Collection<MangaTag>): Builder = apply { this.tags.addAll(tags) }
|
|
||||||
fun excludeTag(tag: MangaTag): Builder = apply { tagsExclude.add(tag) }
|
|
||||||
fun excludeTags(tags: Collection<MangaTag>): Builder = apply { this.tagsExclude.addAll(tags) }
|
|
||||||
fun locale(locale: Locale?): Builder = apply { this.locale = locale }
|
|
||||||
fun originalLocale(locale: Locale?): Builder = apply { this.originalLocale = locale }
|
|
||||||
fun addState(state: MangaState): Builder = apply { states.add(state) }
|
|
||||||
fun addStates(states: Collection<MangaState>): Builder = apply { this.states.addAll(states) }
|
|
||||||
fun addContentRating(rating: ContentRating): Builder = apply { contentRating.add(rating) }
|
|
||||||
fun addContentRatings(ratings: Collection<ContentRating>): Builder =
|
|
||||||
apply { this.contentRating.addAll(ratings) }
|
|
||||||
|
|
||||||
fun addType(type: ContentType): Builder = apply { types.add(type) }
|
|
||||||
fun addTypes(types: Collection<ContentType>): Builder = apply { this.types.addAll(types) }
|
|
||||||
fun addDemographic(demographic: Demographic): Builder = apply { demographics.add(demographic) }
|
|
||||||
fun addDemographics(demographics: Collection<Demographic>): Builder =
|
|
||||||
apply { this.demographics.addAll(demographics) }
|
|
||||||
|
|
||||||
fun year(year: Int): Builder = apply { this.year = year }
|
|
||||||
fun yearFrom(year: Int): Builder = apply { this.yearFrom = year }
|
|
||||||
fun yearTo(year: Int): Builder = apply { this.yearTo = year }
|
|
||||||
|
|
||||||
fun build(): MangaListFilter = MangaListFilter(
|
|
||||||
query, tags, tagsExclude, locale, originalLocale, states,
|
|
||||||
contentRating, types, demographics, year, yearFrom, yearTo,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,56 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.parsers.model
|
|
||||||
|
|
||||||
import org.koitharu.kotatsu.parsers.InternalParsersApi
|
|
||||||
|
|
||||||
public data class MangaListFilterCapabilities @InternalParsersApi constructor(
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether parser supports filtering by more than one tag
|
|
||||||
* @see [MangaListFilter.tags]
|
|
||||||
* @see [MangaListFilterOptions.availableTags]
|
|
||||||
*/
|
|
||||||
val isMultipleTagsSupported: Boolean = false,
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether parser supports tagsExclude field in filter
|
|
||||||
* @see [MangaListFilter.tagsExclude]
|
|
||||||
* @see [MangaListFilterOptions.availableTags]
|
|
||||||
*/
|
|
||||||
val isTagsExclusionSupported: Boolean = false,
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether parser supports searching by string query
|
|
||||||
* @see [MangaListFilter.query]
|
|
||||||
*/
|
|
||||||
val isSearchSupported: Boolean = false,
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether parser supports searching by string query combined within other filters
|
|
||||||
*/
|
|
||||||
val isSearchWithFiltersSupported: Boolean = false,
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether parser supports searching/filtering by year
|
|
||||||
* @see [MangaListFilter.year]
|
|
||||||
*/
|
|
||||||
val isYearSupported: Boolean = false,
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether parser supports searching by year range
|
|
||||||
* @see [MangaListFilter.yearFrom] and [MangaListFilter.yearTo]
|
|
||||||
*/
|
|
||||||
val isYearRangeSupported: Boolean = false,
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether parser supports searching Original Languages
|
|
||||||
* @see [MangaListFilter.originalLocale]
|
|
||||||
* @see [MangaListFilterOptions.availableLocales]
|
|
||||||
*/
|
|
||||||
val isOriginalLocaleSupported: Boolean = false,
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether parser supports searching by author name
|
|
||||||
* @see [MangaListFilter.author]
|
|
||||||
*/
|
|
||||||
val isAuthorSearchSupported: Boolean = false,
|
|
||||||
)
|
|
||||||
@ -1,45 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.parsers.model
|
|
||||||
|
|
||||||
import org.koitharu.kotatsu.parsers.InternalParsersApi
|
|
||||||
import java.util.*
|
|
||||||
|
|
||||||
public data class MangaListFilterOptions @InternalParsersApi constructor(
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Available tags (genres)
|
|
||||||
*/
|
|
||||||
public val availableTags: Set<MangaTag> = emptySet(),
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Supported [MangaState] variants for filtering. May be empty.
|
|
||||||
*
|
|
||||||
* For better performance use [EnumSet] for more than one item.
|
|
||||||
*/
|
|
||||||
public val availableStates: Set<MangaState> = emptySet(),
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Supported [ContentRating] variants for filtering. May be empty.
|
|
||||||
*
|
|
||||||
* For better performance use [EnumSet] for more than one item.
|
|
||||||
*/
|
|
||||||
public val availableContentRating: Set<ContentRating> = emptySet(),
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Supported [ContentType] variants for filtering. May be empty.
|
|
||||||
*
|
|
||||||
* For better performance use [EnumSet] for more than one item.
|
|
||||||
*/
|
|
||||||
public val availableContentTypes: Set<ContentType> = emptySet(),
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Supported [Demographic] variants for filtering. May be empty.
|
|
||||||
*
|
|
||||||
* For better performance use [EnumSet] for more than one item.
|
|
||||||
*/
|
|
||||||
public val availableDemographics: Set<Demographic> = emptySet(),
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Supported content locales for multilingual sources
|
|
||||||
*/
|
|
||||||
public val availableLocales: Set<Locale> = emptySet(),
|
|
||||||
)
|
|
||||||
@ -1,6 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.parsers.model
|
|
||||||
|
|
||||||
public interface MangaSource {
|
|
||||||
|
|
||||||
public val name: String
|
|
||||||
}
|
|
||||||
@ -1,5 +1,5 @@
|
|||||||
package org.koitharu.kotatsu.parsers.model
|
package org.koitharu.kotatsu.parsers.model
|
||||||
|
|
||||||
public enum class MangaState {
|
enum class MangaState {
|
||||||
ONGOING, FINISHED, ABANDONED, PAUSED, UPCOMING, RESTRICTED
|
ONGOING, FINISHED
|
||||||
}
|
}
|
||||||
@ -1,22 +1,9 @@
|
|||||||
package org.koitharu.kotatsu.parsers.model
|
package org.koitharu.kotatsu.parsers.model
|
||||||
|
|
||||||
public enum class SortOrder {
|
enum class SortOrder {
|
||||||
UPDATED,
|
UPDATED,
|
||||||
UPDATED_ASC,
|
|
||||||
POPULARITY,
|
POPULARITY,
|
||||||
POPULARITY_ASC,
|
|
||||||
RATING,
|
RATING,
|
||||||
RATING_ASC,
|
|
||||||
NEWEST,
|
NEWEST,
|
||||||
NEWEST_ASC,
|
ALPHABETICAL
|
||||||
ALPHABETICAL,
|
}
|
||||||
ALPHABETICAL_DESC,
|
|
||||||
ADDED,
|
|
||||||
ADDED_ASC,
|
|
||||||
RELEVANCE,
|
|
||||||
POPULARITY_HOUR,
|
|
||||||
POPULARITY_TODAY,
|
|
||||||
POPULARITY_WEEK,
|
|
||||||
POPULARITY_MONTH,
|
|
||||||
POPULARITY_YEAR,
|
|
||||||
}
|
|
||||||
@ -1,90 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.parsers.model.search
|
|
||||||
|
|
||||||
import androidx.collection.ArrayMap
|
|
||||||
import androidx.collection.ArraySet
|
|
||||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Represents a search query for filtering and sorting manga search results.
|
|
||||||
* This class is immutable and must be constructed using the [Builder].
|
|
||||||
*
|
|
||||||
* @property criteria The set of search criteria applied to the query.
|
|
||||||
* @property order The sorting order for the results (optional).
|
|
||||||
* @property offset The offset number for paginated search results (optional).
|
|
||||||
*/
|
|
||||||
|
|
||||||
@Deprecated("Too complex. Use MangaListFilter instead")
|
|
||||||
@ConsistentCopyVisibility
|
|
||||||
public data class MangaSearchQuery private constructor(
|
|
||||||
@JvmField public val criteria: Set<QueryCriteria<*>>,
|
|
||||||
@JvmField public val order: SortOrder?,
|
|
||||||
@JvmField public val offset: Int,
|
|
||||||
@JvmField public val skipValidation: Boolean,
|
|
||||||
) {
|
|
||||||
|
|
||||||
public fun newBuilder(): Builder = Builder(this)
|
|
||||||
|
|
||||||
public class Builder {
|
|
||||||
|
|
||||||
private val criteria = ArraySet<QueryCriteria<*>>()
|
|
||||||
private var order: SortOrder? = null
|
|
||||||
private var offset: Int = 0
|
|
||||||
private var skipValidation: Boolean = false
|
|
||||||
|
|
||||||
public constructor()
|
|
||||||
|
|
||||||
public constructor(query: MangaSearchQuery) : this() {
|
|
||||||
criteria.addAll(query.criteria)
|
|
||||||
order = query.order
|
|
||||||
offset = query.offset
|
|
||||||
}
|
|
||||||
|
|
||||||
public fun criterion(criterion: QueryCriteria<*>): Builder = apply { criteria.add(criterion) }
|
|
||||||
|
|
||||||
public fun order(order: SortOrder?): Builder = apply { this.order = order }
|
|
||||||
|
|
||||||
public fun offset(offset: Int): Builder = apply { this.offset = offset }
|
|
||||||
|
|
||||||
public fun skipValidation(skip: Boolean): Builder = apply { this.skipValidation = skip }
|
|
||||||
|
|
||||||
@Throws(IllegalArgumentException::class)
|
|
||||||
public fun build(): MangaSearchQuery {
|
|
||||||
return MangaSearchQuery(deduplicateCriteria(criteria), order, offset, skipValidation)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun deduplicateCriteria(criteria: Set<QueryCriteria<*>>): Set<QueryCriteria<*>> {
|
|
||||||
val uniqueCriteria =
|
|
||||||
ArrayMap<Pair<SearchableField, Class<out QueryCriteria<*>>>, QueryCriteria<*>>(criteria.size)
|
|
||||||
|
|
||||||
for (criterion in criteria) {
|
|
||||||
val key = criterion.field to criterion::class.java
|
|
||||||
val existing = uniqueCriteria[key]
|
|
||||||
|
|
||||||
when {
|
|
||||||
existing == null -> uniqueCriteria[key] = criterion
|
|
||||||
|
|
||||||
existing is QueryCriteria.Include<*> && criterion is QueryCriteria.Include<*> -> {
|
|
||||||
uniqueCriteria[key] =
|
|
||||||
QueryCriteria.Include(criterion.field, existing.values union criterion.values)
|
|
||||||
}
|
|
||||||
|
|
||||||
existing is QueryCriteria.Exclude<*> && criterion is QueryCriteria.Exclude<*> -> {
|
|
||||||
uniqueCriteria[key] =
|
|
||||||
QueryCriteria.Exclude(criterion.field, existing.values union criterion.values)
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> throw IllegalArgumentException(
|
|
||||||
"Match and Range have only one criterion per type, but found duplicates for: ${criterion.field} in ${criterion::class.simpleName}",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return uniqueCriteria.values.toSet()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public companion object {
|
|
||||||
|
|
||||||
public val EMPTY: MangaSearchQuery = MangaSearchQuery(emptySet(), null, 0, false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,48 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.parsers.model.search
|
|
||||||
|
|
||||||
import androidx.collection.ArraySet
|
|
||||||
import org.koitharu.kotatsu.parsers.model.search.QueryCriteria.*
|
|
||||||
import org.koitharu.kotatsu.parsers.util.mapToSet
|
|
||||||
|
|
||||||
@Deprecated("Too complex. Use MangaListFilterCapabilities instead")
|
|
||||||
@ExposedCopyVisibility
|
|
||||||
public data class MangaSearchQueryCapabilities internal constructor(
|
|
||||||
public val capabilities: Set<SearchCapability>,
|
|
||||||
) {
|
|
||||||
|
|
||||||
public constructor(vararg capabilities: SearchCapability) : this(ArraySet(capabilities))
|
|
||||||
|
|
||||||
internal fun validate(query: MangaSearchQuery) {
|
|
||||||
val strictFields = capabilities.filter { it.isExclusive }.mapToSet { it.field }
|
|
||||||
val usedStrictFields = query.criteria.mapToSet { it.field }.intersect(strictFields)
|
|
||||||
|
|
||||||
require(usedStrictFields.isEmpty() || query.criteria.size <= 1) {
|
|
||||||
"Query contains multiple criteria, but at least one field (${usedStrictFields.joinToString()}) does not support multiple criteria."
|
|
||||||
}
|
|
||||||
for (criterion in query.criteria) {
|
|
||||||
val capability = requireNotNull(capabilities.find { it.field == criterion.field }) {
|
|
||||||
"Unsupported search field: ${criterion.field}"
|
|
||||||
}
|
|
||||||
|
|
||||||
require(criterion::class in capability.criteriaTypes) {
|
|
||||||
"Unsupported search criterion: ${criterion::class.simpleName} for field ${criterion.field}"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure single value per criterion if supportMultiValue is false
|
|
||||||
if (!capability.isMultiple) {
|
|
||||||
when (criterion) {
|
|
||||||
is Include<*> -> require(criterion.values.size <= 1) {
|
|
||||||
"Multiple values are not allowed for field ${criterion.field}"
|
|
||||||
}
|
|
||||||
|
|
||||||
is Exclude<*> -> require(criterion.values.size <= 1) {
|
|
||||||
"Multiple values are not allowed for field ${criterion.field}"
|
|
||||||
}
|
|
||||||
|
|
||||||
is Range<*> -> Unit // Range is always valid (from, to)
|
|
||||||
is Match<*> -> Unit // Match always has a single value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,106 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.parsers.model.search
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Represents a generic search criterion used for filtering manga search results.
|
|
||||||
* Each criterion applies a specific condition to a [SearchableField] and operates on values of type [T].
|
|
||||||
*
|
|
||||||
* @param T The type of value associated with the search criterion.
|
|
||||||
* @property field The field to which this search criterion applies.
|
|
||||||
*/
|
|
||||||
@Deprecated("Too complex")
|
|
||||||
public sealed interface QueryCriteria<T> {
|
|
||||||
|
|
||||||
public val field: SearchableField
|
|
||||||
|
|
||||||
override fun equals(other: Any?): Boolean
|
|
||||||
|
|
||||||
override fun hashCode(): Int
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Represents an inclusion criterion that allows search results based on a set of allowed values.
|
|
||||||
*
|
|
||||||
* @param T The type of value being included in the search.
|
|
||||||
* @property values The set of values that should be included in the search results.
|
|
||||||
*
|
|
||||||
* ### Example Usage:
|
|
||||||
* ```kotlin
|
|
||||||
* val genreFilter = QueryCriteria.Include(SearchableField.STATE, setOf(MangaState.ONGOING, MangaState.FINISHED))
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
public data class Include<T : Any>(
|
|
||||||
public override val field: SearchableField,
|
|
||||||
@JvmField public val values: Set<T>,
|
|
||||||
) : QueryCriteria<T> {
|
|
||||||
|
|
||||||
init {
|
|
||||||
check(values.all { x -> field.type.isInstance(x) })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Represents an exclusion criterion that exclude results containing certain values.
|
|
||||||
*
|
|
||||||
* @param T The type of value being excluded from the search.
|
|
||||||
* @property values The set of values that should be excluded from the search results.
|
|
||||||
*
|
|
||||||
* ### Example Usage:
|
|
||||||
* ```kotlin
|
|
||||||
* val excludeTag = QueryCriteria.Exclude(SearchableField.TAG, setOf(MangaTag(key, title, source)))
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
public data class Exclude<T : Any>(
|
|
||||||
public override val field: SearchableField,
|
|
||||||
@JvmField public val values: Set<T>,
|
|
||||||
) : QueryCriteria<T> {
|
|
||||||
|
|
||||||
init {
|
|
||||||
check(values.all { x -> field.type.isInstance(x) })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Represents a range criterion that allows search based on a range of values.
|
|
||||||
*
|
|
||||||
* @param T The type of value used in the range (must be comparable).
|
|
||||||
* @property from The starting value of the range (inclusive).
|
|
||||||
* @property to The ending value of the range (inclusive).
|
|
||||||
*
|
|
||||||
* ### Example Usage:
|
|
||||||
* ```kotlin
|
|
||||||
* val yearRange = QueryCriteria.Range(SearchableField.PUBLICATION_YEAR, 2000, 2020)
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
public data class Range<T : Comparable<T>>(
|
|
||||||
public override val field: SearchableField,
|
|
||||||
@JvmField public val from: T,
|
|
||||||
@JvmField public val to: T,
|
|
||||||
) : QueryCriteria<T> {
|
|
||||||
|
|
||||||
init {
|
|
||||||
check(field.type.isInstance(from))
|
|
||||||
check(field.type.isInstance(to))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Represents a match criterion that search results based on an exact match of a value.
|
|
||||||
*
|
|
||||||
* @param T The type of value being matched.
|
|
||||||
* @property value The exact value that must be matched.
|
|
||||||
*
|
|
||||||
* ### Example Usage:
|
|
||||||
* ```kotlin
|
|
||||||
* val titleMatch = QueryCriteria.Match(SearchableField.TITLE, "manga title")
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
public data class Match<T : Any>(
|
|
||||||
public override val field: SearchableField,
|
|
||||||
@JvmField public val value: T,
|
|
||||||
) : QueryCriteria<T> {
|
|
||||||
|
|
||||||
init {
|
|
||||||
check(field.type.isInstance(value))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,34 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.parsers.model.search
|
|
||||||
|
|
||||||
import kotlin.reflect.KClass
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Defines the search capabilities of a given field in the manga search query.
|
|
||||||
*
|
|
||||||
* @property field The searchable field that this capability applies to.
|
|
||||||
* Example values:
|
|
||||||
* - `SearchableField.TITLE_NAME` for searching by title.
|
|
||||||
* - `SearchableField.AUTHOR` for searching by author names.
|
|
||||||
* - `SearchableField.TAG` for filtering by tags.
|
|
||||||
* @property criteriaTypes The set of supported criteria types for the field.
|
|
||||||
* Example values:
|
|
||||||
* - `setOf(Include::class, Exclude::class)` selected field supports inclusion/exclusion criteria.
|
|
||||||
* - `setOf(Range::class)` selected field support numerical range criteria.
|
|
||||||
* @property isMultiValue Indicates whether the field supports multiple values.
|
|
||||||
* - `true` if multiple values can be provided (e.g., multiple tags or authors).
|
|
||||||
* - `false` if only a single value is allowed (e.g., only one tag or author).
|
|
||||||
* @property isExclusive Specifies whether the field can be used alongside other criteria.
|
|
||||||
* - `true` if this field can be used with other search criteria.
|
|
||||||
* - `false` if using this field requires it to be the only criterion in query.
|
|
||||||
*/
|
|
||||||
@Deprecated("Too complex")
|
|
||||||
public data class SearchCapability(
|
|
||||||
/** The searchable field that this capability applies to. */
|
|
||||||
@JvmField public val field: SearchableField,
|
|
||||||
/** The set of supported criteria types for this field. */
|
|
||||||
@JvmField public val criteriaTypes: Set<KClass<out QueryCriteria<*>>>,
|
|
||||||
/** Indicates whether the field supports multiple values. */
|
|
||||||
@JvmField public val isMultiple: Boolean,
|
|
||||||
/** Specifies whether the field can be used alongside other criteria. */
|
|
||||||
@JvmField public val isExclusive: Boolean = false,
|
|
||||||
)
|
|
||||||
@ -1,24 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.parsers.model.search
|
|
||||||
|
|
||||||
import org.koitharu.kotatsu.parsers.model.*
|
|
||||||
import java.util.*
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Represents the various fields that can be used for searching manga.
|
|
||||||
* Each field is associated with a specific data type that defines its expected values.
|
|
||||||
*
|
|
||||||
* @property type The Java class representing the expected type of values for this field.
|
|
||||||
*/
|
|
||||||
@Deprecated("Too complex")
|
|
||||||
public enum class SearchableField(public val type: Class<*>) {
|
|
||||||
TITLE_NAME(String::class.java),
|
|
||||||
TAG(MangaTag::class.java),
|
|
||||||
AUTHOR(MangaTag::class.java),
|
|
||||||
LANGUAGE(Locale::class.java),
|
|
||||||
ORIGINAL_LANGUAGE(Locale::class.java),
|
|
||||||
STATE(MangaState::class.java),
|
|
||||||
CONTENT_TYPE(ContentType::class.java),
|
|
||||||
CONTENT_RATING(ContentRating::class.java),
|
|
||||||
DEMOGRAPHIC(Demographic::class.java),
|
|
||||||
PUBLICATION_YEAR(Int::class.javaObjectType);
|
|
||||||
}
|
|
||||||
@ -1,47 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.parsers.network
|
|
||||||
|
|
||||||
import okhttp3.CookieJar
|
|
||||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
|
||||||
import okhttp3.Response
|
|
||||||
import org.jsoup.Jsoup
|
|
||||||
import java.net.HttpURLConnection.HTTP_FORBIDDEN
|
|
||||||
import java.net.HttpURLConnection.HTTP_UNAVAILABLE
|
|
||||||
|
|
||||||
public object CloudFlareHelper {
|
|
||||||
|
|
||||||
public const val PROTECTION_NOT_DETECTED: Int = 0
|
|
||||||
public const val PROTECTION_CAPTCHA: Int = 1
|
|
||||||
public const val PROTECTION_BLOCKED: Int = 2
|
|
||||||
|
|
||||||
private const val CF_CLEARANCE = "cf_clearance"
|
|
||||||
|
|
||||||
public fun checkResponseForProtection(response: Response): Int {
|
|
||||||
if (response.code != HTTP_FORBIDDEN && response.code != HTTP_UNAVAILABLE) {
|
|
||||||
return PROTECTION_NOT_DETECTED
|
|
||||||
}
|
|
||||||
val content = try {
|
|
||||||
response.peekBody(Long.MAX_VALUE).use {
|
|
||||||
Jsoup.parse(it.byteStream(), Charsets.UTF_8.name(), response.request.url.toString())
|
|
||||||
}
|
|
||||||
} catch (_: IllegalStateException) {
|
|
||||||
return PROTECTION_NOT_DETECTED
|
|
||||||
}
|
|
||||||
return when {
|
|
||||||
content.selectFirst("h2[data-translate=\"blocked_why_headline\"]") != null -> PROTECTION_BLOCKED
|
|
||||||
content.getElementById("challenge-error-title") != null || content.getElementById("challenge-error-text") != null -> PROTECTION_CAPTCHA
|
|
||||||
|
|
||||||
else -> PROTECTION_NOT_DETECTED
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public fun getClearanceCookie(cookieJar: CookieJar, url: String): String? {
|
|
||||||
return cookieJar.loadForRequest(url.toHttpUrl()).find { it.name == CF_CLEARANCE }?.value
|
|
||||||
}
|
|
||||||
|
|
||||||
public fun isCloudFlareCookie(name: String): Boolean {
|
|
||||||
return name.startsWith("cf_")
|
|
||||||
|| name.startsWith("_cf")
|
|
||||||
|| name.startsWith("__cf")
|
|
||||||
|| name == "csrftoken"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,133 +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
|
|
||||||
|
|
||||||
public 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()
|
|
||||||
.addExtraHeaders(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>, extraHeaders: Headers?): Response {
|
|
||||||
val body = FormBody.Builder()
|
|
||||||
form.forEach { (k, v) ->
|
|
||||||
body.addEncoded(k, v)
|
|
||||||
}
|
|
||||||
val request = Request.Builder()
|
|
||||||
.post(body.build())
|
|
||||||
.url(url)
|
|
||||||
.addTags()
|
|
||||||
.addExtraHeaders(extraHeaders)
|
|
||||||
return httpClient.newCall(request.build()).await().ensureSuccess()
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun httpPost(url: HttpUrl, payload: String, extraHeaders: 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)
|
|
||||||
.addTags()
|
|
||||||
.addExtraHeaders(extraHeaders)
|
|
||||||
return httpClient.newCall(request.build()).await().ensureSuccess()
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun httpPost(url: HttpUrl, body: JSONObject, extraHeaders: Headers?): Response {
|
|
||||||
val mediaType = "application/json; charset=utf-8".toMediaType()
|
|
||||||
val requestBody = body.toString().toRequestBody(mediaType)
|
|
||||||
val request = Request.Builder()
|
|
||||||
.post(requestBody)
|
|
||||||
.url(url)
|
|
||||||
.addTags()
|
|
||||||
.addExtraHeaders(extraHeaders)
|
|
||||||
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 mediaType = "application/json; charset=utf-8".toMediaType()
|
|
||||||
val requestBody = body.toString().toRequestBody(mediaType)
|
|
||||||
val request = Request.Builder()
|
|
||||||
.post(requestBody)
|
|
||||||
.url(endpoint)
|
|
||||||
.addTags()
|
|
||||||
val json = httpClient.newCall(request.build()).await().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 Request.Builder.addExtraHeaders(headers: Headers?): Request.Builder {
|
|
||||||
if (headers != null) {
|
|
||||||
headers(headers)
|
|
||||||
}
|
|
||||||
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,17 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.parsers.network
|
|
||||||
|
|
||||||
public object UserAgents {
|
|
||||||
|
|
||||||
public const val CHROME_MOBILE: String =
|
|
||||||
"Mozilla/5.0 (Linux; Android 13) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.5735.196 Mobile Safari/537.36"
|
|
||||||
|
|
||||||
public const val FIREFOX_MOBILE: String =
|
|
||||||
"Mozilla/5.0 (Android 14; Mobile; LG-M255; rv:123.0) Gecko/123.0 Firefox/123.0"
|
|
||||||
|
|
||||||
public const val CHROME_DESKTOP: String =
|
|
||||||
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36"
|
|
||||||
|
|
||||||
public const val FIREFOX_DESKTOP: String = "Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/116.0"
|
|
||||||
|
|
||||||
public const val KOTATSU: String = "Kotatsu/6.8 (Android 13;;; en)"
|
|
||||||
}
|
|
||||||
@ -1,117 +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
|
|
||||||
|
|
||||||
public interface WebClient {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Do a GET http request to specific url
|
|
||||||
* @param url
|
|
||||||
*/
|
|
||||||
public suspend fun httpGet(url: String): Response = httpGet(url.toHttpUrl())
|
|
||||||
|
|
||||||
public suspend fun httpGet(url: String, extraHeaders: Headers?): Response = httpGet(url.toHttpUrl(), extraHeaders)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Do a GET http request to specific url
|
|
||||||
* @param url
|
|
||||||
*/
|
|
||||||
public suspend fun httpGet(url: HttpUrl): Response = httpGet(url, null)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Do a GET http request to specific url
|
|
||||||
* @param url
|
|
||||||
* @param extraHeaders additional HTTP headers for request
|
|
||||||
*/
|
|
||||||
public suspend fun httpGet(url: HttpUrl, extraHeaders: Headers?): Response
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Do a HEAD http request to specific url
|
|
||||||
* @param url
|
|
||||||
*/
|
|
||||||
public suspend fun httpHead(url: String): Response = httpHead(url.toHttpUrl())
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Do a HEAD http request to specific url
|
|
||||||
* @param url
|
|
||||||
*/
|
|
||||||
public 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
|
|
||||||
*/
|
|
||||||
public suspend fun httpPost(url: String, form: Map<String, String>): Response =
|
|
||||||
httpPost(url.toHttpUrl(), form, null)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Do a POST http request to specific url with `multipart/form-data` payload
|
|
||||||
* @param url
|
|
||||||
* @param form payload as key=>value map
|
|
||||||
*/
|
|
||||||
public suspend fun httpPost(url: HttpUrl, form: Map<String, String>): Response = httpPost(url, form, null)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Do a POST http request to specific url with `multipart/form-data` payload
|
|
||||||
* @param url
|
|
||||||
* @param form payload as key=>value map
|
|
||||||
* @param extraHeaders additional HTTP headers for request
|
|
||||||
*/
|
|
||||||
public suspend fun httpPost(url: HttpUrl, form: Map<String, String>, extraHeaders: Headers?): 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
|
|
||||||
*/
|
|
||||||
public suspend fun httpPost(url: String, payload: String): Response = httpPost(url.toHttpUrl(), payload, null)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Do a POST http request to specific url with `multipart/form-data` payload
|
|
||||||
* @param url
|
|
||||||
* @param payload payload as `key=value` string with `&` separator
|
|
||||||
*/
|
|
||||||
public suspend fun httpPost(url: HttpUrl, payload: String): Response = httpPost(url, payload, null)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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 extraHeaders additional HTTP headers for request
|
|
||||||
*/
|
|
||||||
public suspend fun httpPost(url: HttpUrl, payload: String, extraHeaders: Headers?): Response
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Do a POST http request to specific url with json payload
|
|
||||||
* @param url
|
|
||||||
* @param body
|
|
||||||
*/
|
|
||||||
public suspend fun httpPost(url: String, body: JSONObject): Response = httpPost(url.toHttpUrl(), body, null)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Do a POST http request to specific url with json payload
|
|
||||||
* @param url
|
|
||||||
* @param body
|
|
||||||
*/
|
|
||||||
public suspend fun httpPost(url: HttpUrl, body: JSONObject): Response = httpPost(url, body, null)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Do a POST http request to specific url with json payload
|
|
||||||
* @param url
|
|
||||||
* @param body
|
|
||||||
* @param extraHeaders additional HTTP headers for request
|
|
||||||
*/
|
|
||||||
public suspend fun httpPost(url: HttpUrl, body: JSONObject, extraHeaders: Headers?): Response
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Do a GraphQL request to specific url
|
|
||||||
* @param endpoint an url
|
|
||||||
* @param query GraphQL request payload
|
|
||||||
*/
|
|
||||||
public suspend fun graphQLQuery(endpoint: String, query: String): JSONObject
|
|
||||||
}
|
|
||||||
@ -0,0 +1,310 @@
|
|||||||
|
package org.koitharu.kotatsu.parsers.site
|
||||||
|
|
||||||
|
import androidx.collection.ArraySet
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
|
import org.jsoup.nodes.Element
|
||||||
|
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.nio.charset.StandardCharsets
|
||||||
|
import java.security.MessageDigest
|
||||||
|
import java.util.*
|
||||||
|
import javax.crypto.Cipher
|
||||||
|
import javax.crypto.spec.IvParameterSpec
|
||||||
|
import javax.crypto.spec.SecretKeySpec
|
||||||
|
|
||||||
|
private const val PAGE_SIZE = 60
|
||||||
|
private const val PAGE_SIZE_SEARCH = 20
|
||||||
|
|
||||||
|
@MangaSourceParser("BATOTO", "Bato.To")
|
||||||
|
internal class BatoToParser(override val context: MangaLoaderContext) : MangaParser(MangaSource.BATOTO) {
|
||||||
|
|
||||||
|
override val sortOrders: Set<SortOrder> = EnumSet.of(
|
||||||
|
SortOrder.NEWEST,
|
||||||
|
SortOrder.UPDATED,
|
||||||
|
SortOrder.POPULARITY,
|
||||||
|
SortOrder.ALPHABETICAL,
|
||||||
|
)
|
||||||
|
|
||||||
|
override val configKeyDomain = ConfigKey.Domain(
|
||||||
|
"bato.to",
|
||||||
|
arrayOf("bato.to", "mto.to", "mangatoto.com", "battwo.com", "batotwo.com", "comiko.net", "batotoo.com"),
|
||||||
|
)
|
||||||
|
|
||||||
|
override suspend fun getList(
|
||||||
|
offset: Int,
|
||||||
|
query: String?,
|
||||||
|
tags: Set<MangaTag>?,
|
||||||
|
sortOrder: SortOrder,
|
||||||
|
): List<Manga> {
|
||||||
|
if (!query.isNullOrEmpty()) {
|
||||||
|
return search(offset, query)
|
||||||
|
}
|
||||||
|
val page = (offset / PAGE_SIZE) + 1
|
||||||
|
|
||||||
|
@Suppress("NON_EXHAUSTIVE_WHEN_STATEMENT")
|
||||||
|
val url = buildString {
|
||||||
|
append("https://")
|
||||||
|
append(getDomain())
|
||||||
|
append("/browse?sort=")
|
||||||
|
when (sortOrder) {
|
||||||
|
SortOrder.UPDATED,
|
||||||
|
-> append("update.za")
|
||||||
|
SortOrder.POPULARITY -> append("views_a.za")
|
||||||
|
SortOrder.NEWEST -> append("create.za")
|
||||||
|
SortOrder.ALPHABETICAL -> append("title.az")
|
||||||
|
}
|
||||||
|
if (!tags.isNullOrEmpty()) {
|
||||||
|
append("&genres=")
|
||||||
|
appendAll(tags, ",") { it.key }
|
||||||
|
}
|
||||||
|
append("&page=")
|
||||||
|
append(page)
|
||||||
|
}
|
||||||
|
return parseList(url, page)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getDetails(manga: Manga): Manga {
|
||||||
|
val root = context.httpGet(manga.url.toAbsoluteUrl(getDomain())).parseHtml()
|
||||||
|
.getElementById("mainer") ?: parseFailed("Cannot find root")
|
||||||
|
val details = root.selectFirst(".detail-set") ?: parseFailed("Cannot find detail-set")
|
||||||
|
val attrs = details.selectFirst(".attr-main")?.select(".attr-item")?.associate {
|
||||||
|
it.child(0).text().trim() to it.child(1)
|
||||||
|
}.orEmpty()
|
||||||
|
return manga.copy(
|
||||||
|
title = root.selectFirst("h3.item-title")?.text() ?: manga.title,
|
||||||
|
isNsfw = !root.selectFirst("alert")?.getElementsContainingOwnText("NSFW").isNullOrEmpty(),
|
||||||
|
largeCoverUrl = details.selectFirst("img[src]")?.absUrl("src"),
|
||||||
|
description = details.getElementById("limit-height-body-summary")
|
||||||
|
?.selectFirst(".limit-html")
|
||||||
|
?.html(),
|
||||||
|
tags = manga.tags + attrs["Genres:"]?.parseTags().orEmpty(),
|
||||||
|
state = when (attrs["Release status:"]?.text()) {
|
||||||
|
"Ongoing" -> MangaState.ONGOING
|
||||||
|
"Completed" -> MangaState.FINISHED
|
||||||
|
else -> manga.state
|
||||||
|
},
|
||||||
|
author = attrs["Authors:"]?.text()?.trim() ?: manga.author,
|
||||||
|
chapters = root.selectFirst(".episode-list")
|
||||||
|
?.selectFirst(".main")
|
||||||
|
?.children()
|
||||||
|
?.reversed()
|
||||||
|
?.mapIndexedNotNull { i, div ->
|
||||||
|
div.parseChapter(i)
|
||||||
|
}.orEmpty(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
|
||||||
|
val fullUrl = chapter.url.toAbsoluteUrl(getDomain())
|
||||||
|
val scripts = context.httpGet(fullUrl).parseHtml().select("script")
|
||||||
|
for (script in scripts) {
|
||||||
|
val scriptSrc = script.html()
|
||||||
|
val p = scriptSrc.indexOf("const images =")
|
||||||
|
if (p == -1) continue
|
||||||
|
val start = scriptSrc.indexOf('[', p)
|
||||||
|
val end = scriptSrc.indexOf(';', start)
|
||||||
|
if (start == -1 || end == -1) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
val images = JSONArray(scriptSrc.substring(start, end))
|
||||||
|
val batoJs = scriptSrc.substringBetweenFirst("batojs =", ";")?.trim(' ', '"', '\n')
|
||||||
|
?: parseFailed("Cannot find batojs")
|
||||||
|
val server = scriptSrc.substringBetweenFirst("server =", ";")?.trim(' ', '"', '\n')
|
||||||
|
?: parseFailed("Cannot find server")
|
||||||
|
val password = context.evaluateJs(batoJs)?.removeSurrounding('"')
|
||||||
|
?: parseFailed("Cannot evaluate batojs")
|
||||||
|
val serverDecrypted = decryptAES(server, password).removeSurrounding('"')
|
||||||
|
val result = ArrayList<MangaPage>(images.length())
|
||||||
|
repeat(images.length()) { i ->
|
||||||
|
val url = images.getString(i)
|
||||||
|
result += MangaPage(
|
||||||
|
id = generateUid(url),
|
||||||
|
url = if (url.startsWith("http")) url else "$serverDecrypted$url",
|
||||||
|
referer = fullUrl,
|
||||||
|
preview = null,
|
||||||
|
source = source,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
parseFailed("Cannot find images list")
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getTags(): Set<MangaTag> {
|
||||||
|
val scripts = context.httpGet(
|
||||||
|
"https://${getDomain()}/browse",
|
||||||
|
).parseHtml().select("script")
|
||||||
|
for (script in scripts) {
|
||||||
|
val genres = script.html().substringBetweenFirst("const _genres =", ";") ?: continue
|
||||||
|
val jo = JSONObject(genres)
|
||||||
|
val result = ArraySet<MangaTag>(jo.length())
|
||||||
|
jo.keys().forEach { key ->
|
||||||
|
val item = jo.getJSONObject(key)
|
||||||
|
result += MangaTag(
|
||||||
|
title = item.getString("text").toTitleCase(),
|
||||||
|
key = item.getString("file"),
|
||||||
|
source = source,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
parseFailed("Cannot find gernes list")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getFaviconUrl(): String = "https://styles.amarkcdn.com/img/batoto/favicon.ico?v0"
|
||||||
|
|
||||||
|
private suspend fun search(offset: Int, query: String): List<Manga> {
|
||||||
|
val page = (offset / PAGE_SIZE_SEARCH) + 1
|
||||||
|
val url = buildString {
|
||||||
|
append("https://")
|
||||||
|
append(getDomain())
|
||||||
|
append("/search?word=")
|
||||||
|
append(query.replace(' ', '+'))
|
||||||
|
append("&page=")
|
||||||
|
append(page)
|
||||||
|
}
|
||||||
|
return parseList(url, page)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getActivePage(body: Element): Int = body.select("nav ul.pagination > li.page-item.active")
|
||||||
|
.lastOrNull()
|
||||||
|
?.text()
|
||||||
|
?.toIntOrNull() ?: parseFailed("Cannot determine current page")
|
||||||
|
|
||||||
|
private suspend fun parseList(url: String, page: Int): List<Manga> {
|
||||||
|
val body = context.httpGet(url).parseHtml().body()
|
||||||
|
if (body.selectFirst(".browse-no-matches") != null) {
|
||||||
|
return emptyList()
|
||||||
|
}
|
||||||
|
val activePage = getActivePage(body)
|
||||||
|
if (activePage != page) {
|
||||||
|
return emptyList()
|
||||||
|
}
|
||||||
|
val root = body.getElementById("series-list") ?: parseFailed("Cannot find root")
|
||||||
|
return root.children().map { div ->
|
||||||
|
val a = div.selectFirst("a") ?: parseFailed()
|
||||||
|
val href = a.attrAsRelativeUrl("href")
|
||||||
|
val title = div.selectFirst(".item-title")?.text() ?: parseFailed("Title not found")
|
||||||
|
Manga(
|
||||||
|
id = generateUid(href),
|
||||||
|
title = title,
|
||||||
|
altTitle = div.selectFirst(".item-alias")?.text()?.takeUnless { it == title },
|
||||||
|
url = href,
|
||||||
|
publicUrl = a.absUrl("href"),
|
||||||
|
rating = RATING_UNKNOWN,
|
||||||
|
isNsfw = false,
|
||||||
|
coverUrl = div.selectFirst("img[src]")?.absUrl("src").orEmpty(),
|
||||||
|
largeCoverUrl = null,
|
||||||
|
description = null,
|
||||||
|
tags = div.selectFirst(".item-genre")?.parseTags().orEmpty(),
|
||||||
|
state = null,
|
||||||
|
author = null,
|
||||||
|
source = source,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Element.parseTags() = children().mapToSet { span ->
|
||||||
|
val text = span.ownText()
|
||||||
|
MangaTag(
|
||||||
|
title = text.toTitleCase(),
|
||||||
|
key = text.lowercase(Locale.ENGLISH).replace(' ', '_'),
|
||||||
|
source = source,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Element.parseChapter(index: Int): MangaChapter? {
|
||||||
|
val a = selectFirst("a.chapt") ?: return null
|
||||||
|
val extra = selectFirst(".extra")
|
||||||
|
val href = a.attrAsRelativeUrl("href")
|
||||||
|
return MangaChapter(
|
||||||
|
id = generateUid(href),
|
||||||
|
name = a.text(),
|
||||||
|
number = index + 1,
|
||||||
|
url = href,
|
||||||
|
scanlator = extra?.getElementsByAttributeValueContaining("href", "/group/")?.text(),
|
||||||
|
uploadDate = runCatching {
|
||||||
|
parseChapterDate(extra?.select("i")?.lastOrNull()?.ownText())
|
||||||
|
}.getOrDefault(0),
|
||||||
|
branch = null,
|
||||||
|
source = source,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseChapterDate(date: String?): Long {
|
||||||
|
if (date.isNullOrEmpty()) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
val value = date.substringBefore(' ').toInt()
|
||||||
|
val field = when {
|
||||||
|
"sec" in date -> Calendar.SECOND
|
||||||
|
"min" in date -> Calendar.MINUTE
|
||||||
|
"hour" in date -> Calendar.HOUR
|
||||||
|
"day" in date -> Calendar.DAY_OF_MONTH
|
||||||
|
"week" in date -> Calendar.WEEK_OF_YEAR
|
||||||
|
"month" in date -> Calendar.MONTH
|
||||||
|
"year" in date -> Calendar.YEAR
|
||||||
|
else -> return 0
|
||||||
|
}
|
||||||
|
val calendar = Calendar.getInstance()
|
||||||
|
calendar.add(field, -value)
|
||||||
|
return calendar.timeInMillis
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun decryptAES(encrypted: String, password: String): String {
|
||||||
|
val cipherData = context.decodeBase64(encrypted)
|
||||||
|
val saltData = cipherData.copyOfRange(8, 16)
|
||||||
|
val (key, iv) = generateKeyAndIV(
|
||||||
|
keyLength = 32,
|
||||||
|
ivLength = 16,
|
||||||
|
iterations = 1,
|
||||||
|
salt = saltData,
|
||||||
|
password = password.toByteArray(StandardCharsets.UTF_8),
|
||||||
|
md = MessageDigest.getInstance("MD5"),
|
||||||
|
)
|
||||||
|
val encryptedData = cipherData.copyOfRange(16, cipherData.size)
|
||||||
|
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
|
||||||
|
cipher.init(Cipher.DECRYPT_MODE, key, iv)
|
||||||
|
return cipher.doFinal(encryptedData).toString(Charsets.UTF_8)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("SameParameterValue")
|
||||||
|
private fun generateKeyAndIV(
|
||||||
|
keyLength: Int,
|
||||||
|
ivLength: Int,
|
||||||
|
iterations: Int,
|
||||||
|
salt: ByteArray,
|
||||||
|
password: ByteArray,
|
||||||
|
md: MessageDigest,
|
||||||
|
): Pair<SecretKeySpec, IvParameterSpec> {
|
||||||
|
val digestLength = md.digestLength
|
||||||
|
val requiredLength = (keyLength + ivLength + digestLength - 1) / digestLength * digestLength
|
||||||
|
val generatedData = ByteArray(requiredLength)
|
||||||
|
var generatedLength = 0
|
||||||
|
md.reset()
|
||||||
|
while (generatedLength < keyLength + ivLength) {
|
||||||
|
if (generatedLength > 0) {
|
||||||
|
md.update(generatedData, generatedLength - digestLength, digestLength)
|
||||||
|
}
|
||||||
|
md.update(password)
|
||||||
|
md.update(salt, 0, 8)
|
||||||
|
md.digest(generatedData, generatedLength, digestLength)
|
||||||
|
repeat(iterations - 1) {
|
||||||
|
md.update(generatedData, generatedLength, digestLength)
|
||||||
|
md.digest(generatedData, generatedLength, digestLength)
|
||||||
|
}
|
||||||
|
generatedLength += digestLength
|
||||||
|
}
|
||||||
|
|
||||||
|
return SecretKeySpec(generatedData.copyOfRange(0, keyLength), "AES") to IvParameterSpec(
|
||||||
|
if (ivLength > 0) {
|
||||||
|
generatedData.copyOfRange(keyLength, keyLength + ivLength)
|
||||||
|
} else byteArrayOf(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,163 @@
|
|||||||
|
package org.koitharu.kotatsu.parsers.site
|
||||||
|
|
||||||
|
import org.koitharu.kotatsu.parsers.MangaParser
|
||||||
|
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.*
|
||||||
|
|
||||||
|
internal abstract class ChanParser(source: MangaSource) : MangaParser(source) {
|
||||||
|
|
||||||
|
override val sortOrders: Set<SortOrder> = EnumSet.of(
|
||||||
|
SortOrder.NEWEST,
|
||||||
|
SortOrder.POPULARITY,
|
||||||
|
SortOrder.ALPHABETICAL,
|
||||||
|
)
|
||||||
|
|
||||||
|
override suspend fun getList(
|
||||||
|
offset: Int,
|
||||||
|
query: String?,
|
||||||
|
tags: Set<MangaTag>?,
|
||||||
|
sortOrder: SortOrder,
|
||||||
|
): List<Manga> {
|
||||||
|
val domain = getDomain()
|
||||||
|
val url = when {
|
||||||
|
!query.isNullOrEmpty() -> {
|
||||||
|
if (offset != 0) {
|
||||||
|
return emptyList()
|
||||||
|
}
|
||||||
|
"https://$domain/?do=search&subaction=search&story=${query.urlEncoded()}"
|
||||||
|
}
|
||||||
|
!tags.isNullOrEmpty() -> tags.joinToString(
|
||||||
|
prefix = "https://$domain/tags/",
|
||||||
|
postfix = "&n=${getSortKey2(sortOrder)}?offset=$offset",
|
||||||
|
separator = "+",
|
||||||
|
) { tag -> tag.key }
|
||||||
|
else -> "https://$domain/${getSortKey(sortOrder)}?offset=$offset"
|
||||||
|
}
|
||||||
|
val doc = context.httpGet(url).parseHtml()
|
||||||
|
val root = doc.body().selectFirst("div.main_fon")?.getElementById("content")
|
||||||
|
?: parseFailed("Cannot find root")
|
||||||
|
return root.select("div.content_row").mapNotNull { row ->
|
||||||
|
val a = row.selectFirst("div.manga_row1")?.selectFirst("h2")?.selectFirst("a")
|
||||||
|
?: return@mapNotNull null
|
||||||
|
val href = a.attrAsRelativeUrl("href")
|
||||||
|
Manga(
|
||||||
|
id = generateUid(href),
|
||||||
|
url = href,
|
||||||
|
publicUrl = href.toAbsoluteUrl(a.host ?: domain),
|
||||||
|
altTitle = a.attr("title"),
|
||||||
|
title = a.text().substringAfterLast('(').substringBeforeLast(')'),
|
||||||
|
author = row.getElementsByAttributeValueStarting(
|
||||||
|
"href",
|
||||||
|
"/mangaka",
|
||||||
|
).firstOrNull()?.text(),
|
||||||
|
coverUrl = row.selectFirst("div.manga_images")?.selectFirst("img")
|
||||||
|
?.absUrl("src").orEmpty(),
|
||||||
|
tags = runCatching {
|
||||||
|
row.selectFirst("div.genre")?.select("a")?.mapToSet {
|
||||||
|
MangaTag(
|
||||||
|
title = it.text().toTagName(),
|
||||||
|
key = it.attr("href").substringAfterLast('/').urlEncoded(),
|
||||||
|
source = source,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}.getOrNull().orEmpty(),
|
||||||
|
rating = RATING_UNKNOWN,
|
||||||
|
state = null,
|
||||||
|
isNsfw = false,
|
||||||
|
source = source,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getDetails(manga: Manga): Manga {
|
||||||
|
val doc = context.httpGet(manga.url.toAbsoluteUrl(getDomain())).parseHtml()
|
||||||
|
val root =
|
||||||
|
doc.body().getElementById("dle-content") ?: parseFailed("Cannot find root")
|
||||||
|
val dateFormat = SimpleDateFormat("yyyy-MM-dd", Locale.US)
|
||||||
|
return manga.copy(
|
||||||
|
description = root.getElementById("description")?.html()?.substringBeforeLast("<div"),
|
||||||
|
largeCoverUrl = root.getElementById("cover")?.absUrl("src"),
|
||||||
|
chapters = root.select("table.table_cha tr:gt(1)").reversed().mapIndexedNotNull { i, tr ->
|
||||||
|
val href = tr?.selectFirst("a")?.attrAsRelativeUrlOrNull("href")
|
||||||
|
?: return@mapIndexedNotNull null
|
||||||
|
MangaChapter(
|
||||||
|
id = generateUid(href),
|
||||||
|
name = tr.selectFirst("a")?.text().orEmpty(),
|
||||||
|
number = i + 1,
|
||||||
|
url = href,
|
||||||
|
scanlator = null,
|
||||||
|
branch = null,
|
||||||
|
uploadDate = dateFormat.tryParse(tr.selectFirst("div.date")?.text()),
|
||||||
|
source = source,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
|
||||||
|
val fullUrl = chapter.url.toAbsoluteUrl(getDomain())
|
||||||
|
val doc = context.httpGet(fullUrl).parseHtml()
|
||||||
|
val scripts = doc.select("script")
|
||||||
|
for (script in scripts) {
|
||||||
|
val data = script.html()
|
||||||
|
val pos = data.indexOf("\"fullimg")
|
||||||
|
if (pos == -1) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
val json = data.substring(pos).substringAfter('[').substringBefore(';')
|
||||||
|
.substringBeforeLast(']')
|
||||||
|
val domain = getDomain()
|
||||||
|
return json.split(",").mapNotNull {
|
||||||
|
it.trim()
|
||||||
|
.removeSurrounding('"', '\'')
|
||||||
|
.toRelativeUrl(domain)
|
||||||
|
.takeUnless(String::isBlank)
|
||||||
|
}.map { url ->
|
||||||
|
MangaPage(
|
||||||
|
id = generateUid(url),
|
||||||
|
url = url,
|
||||||
|
preview = null,
|
||||||
|
referer = fullUrl,
|
||||||
|
source = source,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parseFailed("Pages list not found at ${chapter.url}")
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getTags(): Set<MangaTag> {
|
||||||
|
val domain = getDomain()
|
||||||
|
val doc = context.httpGet("https://$domain/mostfavorites&sort=manga").parseHtml()
|
||||||
|
val root = doc.body().selectFirst("div.main_fon")?.getElementById("side")
|
||||||
|
?.select("ul")?.last() ?: throw ParseException("Cannot find root")
|
||||||
|
return root.select("li.sidetag").mapToSet { li ->
|
||||||
|
val a = li.children().last() ?: throw ParseException("a is null")
|
||||||
|
MangaTag(
|
||||||
|
title = a.text().toTagName(),
|
||||||
|
key = a.attr("href").substringAfterLast('/'),
|
||||||
|
source = source,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getSortKey(sortOrder: SortOrder) =
|
||||||
|
when (sortOrder) {
|
||||||
|
SortOrder.ALPHABETICAL -> "catalog"
|
||||||
|
SortOrder.POPULARITY -> "mostfavorites"
|
||||||
|
SortOrder.NEWEST -> "manga/new"
|
||||||
|
else -> "mostfavorites"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getSortKey2(sortOrder: SortOrder) =
|
||||||
|
when (sortOrder) {
|
||||||
|
SortOrder.ALPHABETICAL -> "abcasc"
|
||||||
|
SortOrder.POPULARITY -> "favdesc"
|
||||||
|
SortOrder.NEWEST -> "datedesc"
|
||||||
|
else -> "favdesc"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.toTagName() = replace('_', ' ').toTitleCase()
|
||||||
|
}
|
||||||
@ -0,0 +1,216 @@
|
|||||||
|
package org.koitharu.kotatsu.parsers.site
|
||||||
|
|
||||||
|
import androidx.collection.ArraySet
|
||||||
|
import androidx.collection.SparseArrayCompat
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
|
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 org.koitharu.kotatsu.parsers.util.json.*
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
|
/**
|
||||||
|
* https://api.comick.fun/docs/static/index.html
|
||||||
|
*/
|
||||||
|
|
||||||
|
private const val PAGE_SIZE = 20
|
||||||
|
private const val CHAPTERS_LIMIT = 99999
|
||||||
|
|
||||||
|
@MangaSourceParser("COMICK_FUN", "ComicK")
|
||||||
|
internal class ComickFunParser(override val context: MangaLoaderContext) : MangaParser(MangaSource.COMICK_FUN) {
|
||||||
|
|
||||||
|
override val configKeyDomain = ConfigKey.Domain("comick.fun", null)
|
||||||
|
|
||||||
|
override val sortOrders: Set<SortOrder> = EnumSet.of(
|
||||||
|
SortOrder.POPULARITY,
|
||||||
|
SortOrder.UPDATED,
|
||||||
|
SortOrder.RATING,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var cachedTags: SparseArrayCompat<MangaTag>? = null
|
||||||
|
|
||||||
|
override suspend fun getList(
|
||||||
|
offset: Int,
|
||||||
|
query: String?,
|
||||||
|
tags: Set<MangaTag>?,
|
||||||
|
sortOrder: SortOrder,
|
||||||
|
): List<Manga> {
|
||||||
|
val domain = getDomain()
|
||||||
|
val url = buildString {
|
||||||
|
append("https://api.")
|
||||||
|
append(domain)
|
||||||
|
append("/search?tachiyomi=true")
|
||||||
|
if (!query.isNullOrEmpty()) {
|
||||||
|
if (offset > 0) {
|
||||||
|
return emptyList()
|
||||||
|
}
|
||||||
|
append("&q=")
|
||||||
|
append(query.urlEncoded())
|
||||||
|
} else {
|
||||||
|
append("&limit=")
|
||||||
|
append(PAGE_SIZE)
|
||||||
|
append("&page=")
|
||||||
|
append((offset / PAGE_SIZE) + 1)
|
||||||
|
if (!tags.isNullOrEmpty()) {
|
||||||
|
append("&genres=")
|
||||||
|
appendAll(tags, "&genres=", MangaTag::key)
|
||||||
|
}
|
||||||
|
append("&sort=") // view, uploaded, rating, follow, user_follow_count
|
||||||
|
append(
|
||||||
|
when (sortOrder) {
|
||||||
|
SortOrder.POPULARITY -> "view"
|
||||||
|
SortOrder.RATING -> "rating"
|
||||||
|
else -> "uploaded"
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val ja = context.httpGet(url).parseJsonArray()
|
||||||
|
val tagsMap = cachedTags ?: loadTags()
|
||||||
|
return ja.mapJSON { jo ->
|
||||||
|
val slug = jo.getString("slug")
|
||||||
|
Manga(
|
||||||
|
id = generateUid(slug),
|
||||||
|
title = jo.getString("title"),
|
||||||
|
altTitle = null,
|
||||||
|
url = slug,
|
||||||
|
publicUrl = "https://$domain/comic/$slug",
|
||||||
|
rating = jo.getDoubleOrDefault("rating", -10.0).toFloat() / 10f,
|
||||||
|
isNsfw = false,
|
||||||
|
coverUrl = jo.getString("cover_url"),
|
||||||
|
largeCoverUrl = null,
|
||||||
|
description = jo.getStringOrNull("desc"),
|
||||||
|
tags = jo.selectGenres("genres", tagsMap),
|
||||||
|
state = runCatching {
|
||||||
|
if (jo.getBoolean("translation_completed")) {
|
||||||
|
MangaState.FINISHED
|
||||||
|
} else {
|
||||||
|
MangaState.ONGOING
|
||||||
|
}
|
||||||
|
}.getOrNull(),
|
||||||
|
author = null,
|
||||||
|
source = source,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getDetails(manga: Manga): Manga {
|
||||||
|
val domain = getDomain()
|
||||||
|
val url = "https://api.$domain/comic/${manga.url}?tachiyomi=true"
|
||||||
|
val jo = context.httpGet(url).parseJson()
|
||||||
|
val comic = jo.getJSONObject("comic")
|
||||||
|
return manga.copy(
|
||||||
|
title = comic.getString("title"),
|
||||||
|
altTitle = null, // TODO
|
||||||
|
isNsfw = jo.getBoolean("matureContent") || comic.getBoolean("hentai"),
|
||||||
|
description = comic.getStringOrNull("parsed") ?: comic.getString("desc"),
|
||||||
|
tags = manga.tags + jo.getJSONArray("genres").mapJSONToSet {
|
||||||
|
MangaTag(
|
||||||
|
title = it.getString("name"),
|
||||||
|
key = it.getString("slug"),
|
||||||
|
source = source,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
author = jo.getJSONArray("artists").optJSONObject(0)?.getString("name"),
|
||||||
|
chapters = getChapters(comic.getLong("id")),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
|
||||||
|
val jo = context.httpGet(
|
||||||
|
"https://api.${getDomain()}/chapter/${chapter.url}?tachiyomi=true",
|
||||||
|
).parseJson().getJSONObject("chapter")
|
||||||
|
val referer = "https://${getDomain()}/"
|
||||||
|
return jo.getJSONArray("images").mapJSON {
|
||||||
|
val url = it.getString("url")
|
||||||
|
MangaPage(
|
||||||
|
id = generateUid(url),
|
||||||
|
url = url,
|
||||||
|
referer = referer,
|
||||||
|
preview = null,
|
||||||
|
source = source,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getTags(): Set<MangaTag> {
|
||||||
|
val sparseArray = cachedTags ?: loadTags()
|
||||||
|
val set = ArraySet<MangaTag>(sparseArray.size())
|
||||||
|
for (i in 0 until sparseArray.size()) {
|
||||||
|
set.add(sparseArray.valueAt(i))
|
||||||
|
}
|
||||||
|
return set
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun loadTags(): SparseArrayCompat<MangaTag> {
|
||||||
|
val ja = context.httpGet("https://api.${getDomain()}/genre").parseJsonArray()
|
||||||
|
val tags = SparseArrayCompat<MangaTag>(ja.length())
|
||||||
|
for (jo in ja.JSONIterator()) {
|
||||||
|
tags.append(
|
||||||
|
jo.getInt("id"),
|
||||||
|
MangaTag(
|
||||||
|
title = jo.getString("name"),
|
||||||
|
key = jo.getString("slug"),
|
||||||
|
source = source,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
cachedTags = tags
|
||||||
|
return tags
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun getChapters(id: Long): List<MangaChapter> {
|
||||||
|
val ja = context.httpGet(
|
||||||
|
url = "https://api.${getDomain()}/comic/$id/chapter?tachiyomi=true&limit=$CHAPTERS_LIMIT",
|
||||||
|
).parseJson().getJSONArray("chapters")
|
||||||
|
val dateFormat = SimpleDateFormat("yyyy-MM-dd")
|
||||||
|
val counters = HashMap<Locale, Int>()
|
||||||
|
return ja.mapReversed { jo ->
|
||||||
|
val locale = Locale.forLanguageTag(jo.getString("lang"))
|
||||||
|
var number = counters[locale] ?: 0
|
||||||
|
number++
|
||||||
|
counters[locale] = number
|
||||||
|
MangaChapter(
|
||||||
|
id = generateUid(jo.getLong("id")),
|
||||||
|
name = buildString {
|
||||||
|
jo.getStringOrNull("vol")?.let { append("Vol ").append(it).append(' ') }
|
||||||
|
jo.getStringOrNull("chap")?.let { append("Chap ").append(it) }
|
||||||
|
jo.getStringOrNull("title")?.let { append(": ").append(it) }
|
||||||
|
},
|
||||||
|
number = number,
|
||||||
|
url = jo.getString("hid"),
|
||||||
|
scanlator = jo.optJSONArray("group_name")?.optString(0),
|
||||||
|
uploadDate = dateFormat.tryParse(jo.getString("created_at").substringBefore('T')),
|
||||||
|
branch = locale.getDisplayName(locale).toTitleCase(locale),
|
||||||
|
source = source,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private inline fun <R> JSONArray.mapReversed(block: (JSONObject) -> R): List<R> {
|
||||||
|
val len = length()
|
||||||
|
val destination = ArrayList<R>(len)
|
||||||
|
for (i in (0 until len).reversed()) {
|
||||||
|
val jo = getJSONObject(i)
|
||||||
|
destination.add(block(jo))
|
||||||
|
}
|
||||||
|
return destination
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun JSONObject.selectGenres(name: String, tags: SparseArrayCompat<MangaTag>): Set<MangaTag> {
|
||||||
|
val array = optJSONArray(name) ?: return emptySet()
|
||||||
|
val res = ArraySet<MangaTag>(array.length())
|
||||||
|
for (i in 0 until array.length()) {
|
||||||
|
val id = array.getInt(i)
|
||||||
|
val tag = tags.get(id) ?: continue
|
||||||
|
res.add(tag)
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,160 @@
|
|||||||
|
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.exception.ParseException
|
||||||
|
import org.koitharu.kotatsu.parsers.model.*
|
||||||
|
import org.koitharu.kotatsu.parsers.util.*
|
||||||
|
import org.koitharu.kotatsu.parsers.util.json.mapJSON
|
||||||
|
import org.koitharu.kotatsu.parsers.util.json.mapJSONIndexed
|
||||||
|
import org.koitharu.kotatsu.parsers.util.json.mapJSONToSet
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
|
@MangaSourceParser("DESUME", "Desu.me", "ru")
|
||||||
|
internal class DesuMeParser(override val context: MangaLoaderContext) : MangaParser(MangaSource.DESUME) {
|
||||||
|
|
||||||
|
override val configKeyDomain = ConfigKey.Domain("desu.me", null)
|
||||||
|
|
||||||
|
override val sortOrders: Set<SortOrder> = EnumSet.of(
|
||||||
|
SortOrder.UPDATED,
|
||||||
|
SortOrder.POPULARITY,
|
||||||
|
SortOrder.NEWEST,
|
||||||
|
SortOrder.ALPHABETICAL,
|
||||||
|
)
|
||||||
|
|
||||||
|
override suspend fun getList(
|
||||||
|
offset: Int,
|
||||||
|
query: String?,
|
||||||
|
tags: Set<MangaTag>?,
|
||||||
|
sortOrder: SortOrder,
|
||||||
|
): List<Manga> {
|
||||||
|
if (query != null && offset != 0) {
|
||||||
|
return emptyList()
|
||||||
|
}
|
||||||
|
val domain = getDomain()
|
||||||
|
val url = buildString {
|
||||||
|
append("https://")
|
||||||
|
append(domain)
|
||||||
|
append("/manga/api/?limit=20&order=")
|
||||||
|
append(getSortKey(sortOrder))
|
||||||
|
append("&page=")
|
||||||
|
append((offset / 20) + 1)
|
||||||
|
if (!tags.isNullOrEmpty()) {
|
||||||
|
append("&genres=")
|
||||||
|
appendAll(tags, ",") { it.key }
|
||||||
|
}
|
||||||
|
if (query != null) {
|
||||||
|
append("&search=")
|
||||||
|
append(query)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val json = context.httpGet(url).parseJson().getJSONArray("response") ?: parseFailed("Invalid response")
|
||||||
|
val total = json.length()
|
||||||
|
val list = ArrayList<Manga>(total)
|
||||||
|
for (i in 0 until total) {
|
||||||
|
val jo = json.getJSONObject(i)
|
||||||
|
val cover = jo.getJSONObject("image")
|
||||||
|
val id = jo.getLong("id")
|
||||||
|
list += Manga(
|
||||||
|
url = "/manga/api/$id",
|
||||||
|
publicUrl = jo.getString("url"),
|
||||||
|
source = MangaSource.DESUME,
|
||||||
|
title = jo.getString("russian"),
|
||||||
|
altTitle = jo.getString("name"),
|
||||||
|
coverUrl = cover.getString("preview"),
|
||||||
|
largeCoverUrl = cover.getString("original"),
|
||||||
|
state = when {
|
||||||
|
jo.getInt("ongoing") == 1 -> MangaState.ONGOING
|
||||||
|
else -> null
|
||||||
|
},
|
||||||
|
rating = jo.getDouble("score").toFloat().coerceIn(0f, 1f),
|
||||||
|
id = generateUid(id),
|
||||||
|
isNsfw = false,
|
||||||
|
tags = emptySet(),
|
||||||
|
author = null,
|
||||||
|
description = jo.getString("description"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getDetails(manga: Manga): Manga {
|
||||||
|
val url = manga.url.toAbsoluteUrl(getDomain())
|
||||||
|
val json = context.httpGet(url).parseJson().getJSONObject("response")
|
||||||
|
?: throw ParseException("Invalid response")
|
||||||
|
val baseChapterUrl = manga.url + "/chapter/"
|
||||||
|
val chaptersList = json.getJSONObject("chapters").getJSONArray("list")
|
||||||
|
val totalChapters = chaptersList.length()
|
||||||
|
return manga.copy(
|
||||||
|
tags = json.getJSONArray("genres").mapJSONToSet {
|
||||||
|
MangaTag(
|
||||||
|
key = it.getString("text"),
|
||||||
|
title = it.getString("russian").toTitleCase(),
|
||||||
|
source = manga.source,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
publicUrl = json.getString("url"),
|
||||||
|
description = json.getString("description"),
|
||||||
|
chapters = chaptersList.mapJSONIndexed { i, it ->
|
||||||
|
val chid = it.getLong("id")
|
||||||
|
val volChap = "Том " + it.optString("vol", "0") + ". " + "Глава " + it.optString("ch", "0")
|
||||||
|
val title = it.optString("title", "null").takeUnless { it == "null" }
|
||||||
|
MangaChapter(
|
||||||
|
id = generateUid(chid),
|
||||||
|
source = manga.source,
|
||||||
|
url = "$baseChapterUrl$chid",
|
||||||
|
uploadDate = it.getLong("date") * 1000,
|
||||||
|
name = if (title.isNullOrEmpty()) volChap else "$volChap: $title",
|
||||||
|
number = totalChapters - i,
|
||||||
|
scanlator = null,
|
||||||
|
branch = null,
|
||||||
|
)
|
||||||
|
}.reversed(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
|
||||||
|
val fullUrl = chapter.url.toAbsoluteUrl(getDomain())
|
||||||
|
val json = context.httpGet(fullUrl)
|
||||||
|
.parseJson()
|
||||||
|
.getJSONObject("response") ?: throw ParseException("Invalid response")
|
||||||
|
return json.getJSONObject("pages").getJSONArray("list").mapJSON { jo ->
|
||||||
|
MangaPage(
|
||||||
|
id = generateUid(jo.getLong("id")),
|
||||||
|
referer = fullUrl,
|
||||||
|
preview = null,
|
||||||
|
source = chapter.source,
|
||||||
|
url = jo.getString("img"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getTags(): Set<MangaTag> {
|
||||||
|
val doc = context.httpGet("https://${getDomain()}/manga/").parseHtml()
|
||||||
|
val root = doc.body().getElementById("animeFilter")
|
||||||
|
?.selectFirst(".catalog-genres") ?: throw ParseException("Root not found")
|
||||||
|
return root.select("li").mapToSet {
|
||||||
|
val input = it.selectFirst("input") ?: parseFailed()
|
||||||
|
MangaTag(
|
||||||
|
source = source,
|
||||||
|
key = input.attr("data-genre-slug").ifEmpty {
|
||||||
|
parseFailed("data-genre-slug is empty")
|
||||||
|
},
|
||||||
|
title = input.attr("data-genre-name").toTitleCase().ifEmpty {
|
||||||
|
parseFailed("data-genre-name is empty")
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getSortKey(sortOrder: SortOrder) =
|
||||||
|
when (sortOrder) {
|
||||||
|
SortOrder.ALPHABETICAL -> "name"
|
||||||
|
SortOrder.POPULARITY -> "popular"
|
||||||
|
SortOrder.UPDATED -> "updated"
|
||||||
|
SortOrder.NEWEST -> "id"
|
||||||
|
else -> "updated"
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,283 @@
|
|||||||
|
package org.koitharu.kotatsu.parsers.site
|
||||||
|
|
||||||
|
import org.jsoup.nodes.Element
|
||||||
|
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
||||||
|
import org.koitharu.kotatsu.parsers.MangaParser
|
||||||
|
import org.koitharu.kotatsu.parsers.MangaParserAuthProvider
|
||||||
|
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.*
|
||||||
|
import org.koitharu.kotatsu.parsers.util.*
|
||||||
|
import java.util.*
|
||||||
|
import kotlin.math.pow
|
||||||
|
|
||||||
|
private const val DOMAIN_UNAUTHORIZED = "e-hentai.org"
|
||||||
|
private const val DOMAIN_AUTHORIZED = "exhentai.org"
|
||||||
|
|
||||||
|
@MangaSourceParser("EXHENTAI", "ExHentai")
|
||||||
|
internal class ExHentaiParser(
|
||||||
|
override val context: MangaLoaderContext,
|
||||||
|
) : MangaParser(MangaSource.EXHENTAI), MangaParserAuthProvider {
|
||||||
|
|
||||||
|
override val sortOrders: Set<SortOrder> = Collections.singleton(
|
||||||
|
SortOrder.NEWEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
override val configKeyDomain: ConfigKey.Domain
|
||||||
|
get() = ConfigKey.Domain(if (isAuthorized) DOMAIN_AUTHORIZED else DOMAIN_UNAUTHORIZED, null)
|
||||||
|
|
||||||
|
override val authUrl: String
|
||||||
|
get() = "https://${getDomain()}/bounce_login.php"
|
||||||
|
|
||||||
|
private val ratingPattern = Regex("-?[0-9]+px")
|
||||||
|
private val authCookies = arrayOf("ipb_member_id", "ipb_pass_hash")
|
||||||
|
private var updateDm = false
|
||||||
|
|
||||||
|
override val isAuthorized: Boolean
|
||||||
|
get() {
|
||||||
|
val authorized = isAuthorized(DOMAIN_UNAUTHORIZED)
|
||||||
|
if (authorized) {
|
||||||
|
if (!isAuthorized(DOMAIN_AUTHORIZED)) {
|
||||||
|
context.cookieJar.copyCookies(
|
||||||
|
DOMAIN_UNAUTHORIZED,
|
||||||
|
DOMAIN_AUTHORIZED,
|
||||||
|
authCookies,
|
||||||
|
)
|
||||||
|
context.cookieJar.insertCookies(DOMAIN_AUTHORIZED, "yay=louder")
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
init {
|
||||||
|
context.cookieJar.insertCookies(DOMAIN_AUTHORIZED, "nw=1", "sl=dm_2")
|
||||||
|
context.cookieJar.insertCookies(DOMAIN_UNAUTHORIZED, "nw=1", "sl=dm_2")
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getList(
|
||||||
|
offset: Int,
|
||||||
|
query: String?,
|
||||||
|
tags: Set<MangaTag>?,
|
||||||
|
sortOrder: SortOrder,
|
||||||
|
): List<Manga> {
|
||||||
|
val page = (offset / 25f).toIntUp()
|
||||||
|
var search = query?.urlEncoded().orEmpty()
|
||||||
|
val url = buildString {
|
||||||
|
append("https://")
|
||||||
|
append(getDomain())
|
||||||
|
append("/?page=")
|
||||||
|
append(page)
|
||||||
|
if (!tags.isNullOrEmpty()) {
|
||||||
|
var fCats = 0
|
||||||
|
for (tag in tags) {
|
||||||
|
tag.key.toIntOrNull()?.let { fCats = fCats or it } ?: run {
|
||||||
|
search += tag.key + " "
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (fCats != 0) {
|
||||||
|
append("&f_cats=")
|
||||||
|
append(1023 - fCats)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (search.isNotEmpty()) {
|
||||||
|
append("&f_search=")
|
||||||
|
append(search.trim().replace(' ', '+'))
|
||||||
|
}
|
||||||
|
// by unknown reason cookie "sl=dm_2" is ignored, so, we should request it again
|
||||||
|
if (updateDm) {
|
||||||
|
append("&inline_set=dm_e")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val body = context.httpGet(url).parseHtml().body()
|
||||||
|
val root = body.selectFirst("table.itg")
|
||||||
|
?.selectFirst("tbody")
|
||||||
|
?: if (updateDm) {
|
||||||
|
parseFailed("Cannot find root")
|
||||||
|
} else {
|
||||||
|
updateDm = true
|
||||||
|
return getList(offset, query, tags, sortOrder)
|
||||||
|
}
|
||||||
|
updateDm = false
|
||||||
|
return root.children().mapNotNull { tr ->
|
||||||
|
if (tr.childrenSize() != 2) return@mapNotNull null
|
||||||
|
val (td1, td2) = tr.children()
|
||||||
|
val glink = td2.selectFirst("div.glink") ?: parseFailed("glink not found")
|
||||||
|
val a = glink.parents().select("a").first() ?: parseFailed("link not found")
|
||||||
|
val href = a.attrAsRelativeUrl("href")
|
||||||
|
val tagsDiv = glink.nextElementSibling() ?: parseFailed("tags div not found")
|
||||||
|
val mainTag = td2.selectFirst("div.cn")?.let { div ->
|
||||||
|
MangaTag(
|
||||||
|
title = div.text().toTitleCase(),
|
||||||
|
key = tagIdByClass(div.classNames()) ?: return@let null,
|
||||||
|
source = source,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Manga(
|
||||||
|
id = generateUid(href),
|
||||||
|
title = glink.text().cleanupTitle(),
|
||||||
|
altTitle = null,
|
||||||
|
url = href,
|
||||||
|
publicUrl = a.absUrl("href"),
|
||||||
|
rating = td2.selectFirst("div.ir")?.parseRating() ?: RATING_UNKNOWN,
|
||||||
|
isNsfw = true,
|
||||||
|
coverUrl = td1.selectFirst("img")?.absUrl("src").orEmpty(),
|
||||||
|
tags = setOfNotNull(mainTag),
|
||||||
|
state = null,
|
||||||
|
author = tagsDiv.getElementsContainingOwnText("artist:").first()
|
||||||
|
?.nextElementSibling()?.text(),
|
||||||
|
source = source,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getDetails(manga: Manga): Manga {
|
||||||
|
val doc = context.httpGet(manga.url.toAbsoluteUrl(getDomain())).parseHtml()
|
||||||
|
val root = doc.body().selectFirst("div.gm") ?: parseFailed("Cannot find root")
|
||||||
|
val cover = root.getElementById("gd1")?.children()?.first()
|
||||||
|
val title = root.getElementById("gd2")
|
||||||
|
val taglist = root.getElementById("taglist")
|
||||||
|
val tabs = doc.body().selectFirst("table.ptt")?.selectFirst("tr")
|
||||||
|
return manga.copy(
|
||||||
|
title = title?.getElementById("gn")?.text()?.cleanupTitle() ?: manga.title,
|
||||||
|
altTitle = title?.getElementById("gj")?.text()?.cleanupTitle() ?: manga.altTitle,
|
||||||
|
publicUrl = doc.baseUri().ifEmpty { manga.publicUrl },
|
||||||
|
rating = root.getElementById("rating_label")?.text()
|
||||||
|
?.substringAfterLast(' ')
|
||||||
|
?.toFloatOrNull()
|
||||||
|
?.div(5f) ?: manga.rating,
|
||||||
|
largeCoverUrl = cover?.styleValueOrNull("background")?.cssUrl(),
|
||||||
|
description = taglist?.select("tr")?.joinToString("<br>") { tr ->
|
||||||
|
val (tc, td) = tr.children()
|
||||||
|
val subtags = td.select("a").joinToString { it.html() }
|
||||||
|
"<b>${tc.html()}</b> $subtags"
|
||||||
|
},
|
||||||
|
chapters = tabs?.select("a")?.findLast { a ->
|
||||||
|
a.text().toIntOrNull() != null
|
||||||
|
}?.let { a ->
|
||||||
|
val count = a.text().toInt()
|
||||||
|
val chapters = ArrayList<MangaChapter>(count)
|
||||||
|
for (i in 1..count) {
|
||||||
|
val url = "${manga.url}?p=${i - 1}"
|
||||||
|
chapters += MangaChapter(
|
||||||
|
id = generateUid(url),
|
||||||
|
name = "${manga.title} #$i",
|
||||||
|
number = i,
|
||||||
|
url = url,
|
||||||
|
uploadDate = 0L,
|
||||||
|
source = source,
|
||||||
|
scanlator = null,
|
||||||
|
branch = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
chapters
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
|
||||||
|
val doc = context.httpGet(chapter.url.toAbsoluteUrl(getDomain())).parseHtml()
|
||||||
|
val root = doc.body().getElementById("gdt") ?: parseFailed("Root not found")
|
||||||
|
return root.select("a").map { a ->
|
||||||
|
val url = a.attrAsRelativeUrl("href")
|
||||||
|
MangaPage(
|
||||||
|
id = generateUid(url),
|
||||||
|
url = url,
|
||||||
|
referer = a.absUrl("href"),
|
||||||
|
preview = null,
|
||||||
|
source = source,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getPageUrl(page: MangaPage): String {
|
||||||
|
val doc = context.httpGet(page.url.toAbsoluteUrl(getDomain())).parseHtml()
|
||||||
|
return doc.body().getElementById("img")?.absUrl("src")
|
||||||
|
?: parseFailed("Image not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getTags(): Set<MangaTag> {
|
||||||
|
val doc = context.httpGet("https://${getDomain()}").parseHtml()
|
||||||
|
val root = doc.body().getElementById("searchbox")?.selectFirst("table")
|
||||||
|
?: parseFailed("Root not found")
|
||||||
|
return root.select("div.cs").mapNotNullToSet { div ->
|
||||||
|
val id = div.id().substringAfterLast('_').toIntOrNull()
|
||||||
|
?: return@mapNotNullToSet null
|
||||||
|
MangaTag(
|
||||||
|
title = div.text().toTitleCase(),
|
||||||
|
key = id.toString(),
|
||||||
|
source = source,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getUsername(): String {
|
||||||
|
val doc = context.httpGet("https://forums.$DOMAIN_UNAUTHORIZED/").parseHtml().body()
|
||||||
|
val username = doc.getElementById("userlinks")
|
||||||
|
?.getElementsByAttributeValueContaining("href", "showuser=")
|
||||||
|
?.firstOrNull()
|
||||||
|
?.ownText()
|
||||||
|
?: if (doc.getElementById("userlinksguest") != null) {
|
||||||
|
throw AuthRequiredException(source)
|
||||||
|
} else {
|
||||||
|
throw ParseException(null)
|
||||||
|
}
|
||||||
|
return username
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isAuthorized(domain: String): Boolean {
|
||||||
|
val cookies = context.cookieJar.getCookies(domain).mapToSet { x -> x.name }
|
||||||
|
return authCookies.all { it in cookies }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Element.parseRating(): Float {
|
||||||
|
return runCatching {
|
||||||
|
val style = requireNotNull(attr("style"))
|
||||||
|
val (v1, v2) = ratingPattern.find(style)!!.destructured
|
||||||
|
var p1 = v1.dropLast(2).toInt()
|
||||||
|
val p2 = v2.dropLast(2).toInt()
|
||||||
|
if (p2 != -1) {
|
||||||
|
p1 += 8
|
||||||
|
}
|
||||||
|
(80 - p1) / 80f
|
||||||
|
}.getOrDefault(RATING_UNKNOWN)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.cleanupTitle(): String {
|
||||||
|
val result = StringBuilder(length)
|
||||||
|
var skip = false
|
||||||
|
for (c in this) {
|
||||||
|
when {
|
||||||
|
c == '[' -> skip = true
|
||||||
|
c == ']' -> skip = false
|
||||||
|
c.isWhitespace() && result.isEmpty() -> continue
|
||||||
|
!skip -> result.append(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
while (result.lastOrNull()?.isWhitespace() == true) {
|
||||||
|
result.deleteCharAt(result.lastIndex)
|
||||||
|
}
|
||||||
|
return result.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.cssUrl(): String? {
|
||||||
|
val fromIndex = indexOf("url(")
|
||||||
|
if (fromIndex == -1) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
val toIndex = indexOf(')', startIndex = fromIndex)
|
||||||
|
return if (toIndex == -1) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
substring(fromIndex + 4, toIndex).trim()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun tagIdByClass(classNames: Collection<String>): String? {
|
||||||
|
val className = classNames.find { x -> x.startsWith("ct") } ?: return null
|
||||||
|
val num = className.drop(2).toIntOrNull(16) ?: return null
|
||||||
|
return 2.0.pow(num).toInt().toString()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,281 @@
|
|||||||
|
package org.koitharu.kotatsu.parsers.site
|
||||||
|
|
||||||
|
import okhttp3.Headers
|
||||||
|
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||||
|
import okhttp3.Response
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.koitharu.kotatsu.parsers.MangaParser
|
||||||
|
import org.koitharu.kotatsu.parsers.model.*
|
||||||
|
import org.koitharu.kotatsu.parsers.util.*
|
||||||
|
import org.koitharu.kotatsu.parsers.util.json.mapJSON
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
|
private const val PAGE_SIZE = 70
|
||||||
|
private const val PAGE_SIZE_SEARCH = 50
|
||||||
|
private const val NSFW_ALERT = "сексуальные сцены"
|
||||||
|
private const val NOTHING_FOUND = "Ничего не найдено"
|
||||||
|
|
||||||
|
internal abstract class GroupleParser(source: MangaSource, userAgent: String) : MangaParser(source) {
|
||||||
|
|
||||||
|
private val headers = Headers.Builder()
|
||||||
|
.add("User-Agent", userAgent)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
override val sortOrders: Set<SortOrder> = EnumSet.of(
|
||||||
|
SortOrder.UPDATED,
|
||||||
|
SortOrder.POPULARITY,
|
||||||
|
SortOrder.NEWEST,
|
||||||
|
SortOrder.RATING,
|
||||||
|
)
|
||||||
|
|
||||||
|
override suspend fun getList(
|
||||||
|
offset: Int,
|
||||||
|
query: String?,
|
||||||
|
tags: Set<MangaTag>?,
|
||||||
|
sortOrder: SortOrder,
|
||||||
|
): List<Manga> {
|
||||||
|
val domain = getDomain()
|
||||||
|
val doc = when {
|
||||||
|
!query.isNullOrEmpty() -> context.httpPost(
|
||||||
|
"https://$domain/search",
|
||||||
|
mapOf(
|
||||||
|
"q" to query.urlEncoded(),
|
||||||
|
"offset" to (offset upBy PAGE_SIZE_SEARCH).toString(),
|
||||||
|
),
|
||||||
|
headers,
|
||||||
|
)
|
||||||
|
tags.isNullOrEmpty() -> context.httpGet(
|
||||||
|
"https://$domain/list?sortType=${
|
||||||
|
getSortKey(sortOrder)
|
||||||
|
}&offset=${offset upBy PAGE_SIZE}",
|
||||||
|
headers,
|
||||||
|
)
|
||||||
|
tags.size == 1 -> context.httpGet(
|
||||||
|
"https://$domain/list/genre/${tags.first().key}?sortType=${
|
||||||
|
getSortKey(sortOrder)
|
||||||
|
}&offset=${offset upBy PAGE_SIZE}",
|
||||||
|
headers,
|
||||||
|
)
|
||||||
|
offset > 0 -> return emptyList()
|
||||||
|
else -> advancedSearch(domain, tags)
|
||||||
|
}.parseHtml().body()
|
||||||
|
val root = (doc.getElementById("mangaBox") ?: doc.getElementById("mangaResults"))
|
||||||
|
?: parseFailed("Cannot find root")
|
||||||
|
val tiles = root.selectFirst("div.tiles.row") ?: if (
|
||||||
|
root.select(".alert").any { it.ownText() == NOTHING_FOUND }
|
||||||
|
) {
|
||||||
|
return emptyList()
|
||||||
|
} else {
|
||||||
|
parseFailed("No tiles found")
|
||||||
|
}
|
||||||
|
val baseHost = root.baseUri().toHttpUrl().host
|
||||||
|
return tiles.select("div.tile").mapNotNull { node ->
|
||||||
|
val imgDiv = node.selectFirst("div.img") ?: return@mapNotNull null
|
||||||
|
val descDiv = node.selectFirst("div.desc") ?: return@mapNotNull null
|
||||||
|
if (descDiv.selectFirst("i.fa-user") != null) {
|
||||||
|
return@mapNotNull null // skip author
|
||||||
|
}
|
||||||
|
val href = imgDiv.selectFirst("a")?.attrAsAbsoluteUrlOrNull("href")
|
||||||
|
if (href == null || href.toHttpUrl().host != baseHost) {
|
||||||
|
return@mapNotNull null // skip external links
|
||||||
|
}
|
||||||
|
val title = descDiv.selectFirst("h3")?.selectFirst("a")?.text()
|
||||||
|
?: return@mapNotNull null
|
||||||
|
val tileInfo = descDiv.selectFirst("div.tile-info")
|
||||||
|
val relUrl = href.toRelativeUrl(baseHost)
|
||||||
|
Manga(
|
||||||
|
id = generateUid(relUrl),
|
||||||
|
url = relUrl,
|
||||||
|
publicUrl = href,
|
||||||
|
title = title,
|
||||||
|
altTitle = descDiv.selectFirst("h4")?.text(),
|
||||||
|
coverUrl = imgDiv.selectFirst("img.lazy")?.attr("data-original")?.replace("_p.", ".").orEmpty(),
|
||||||
|
rating = runCatching {
|
||||||
|
node.selectFirst("div.rating")
|
||||||
|
?.attr("title")
|
||||||
|
?.substringBefore(' ')
|
||||||
|
?.toFloatOrNull()
|
||||||
|
?.div(10f)
|
||||||
|
}.getOrNull() ?: RATING_UNKNOWN,
|
||||||
|
author = tileInfo?.selectFirst("a.person-link")?.text(),
|
||||||
|
isNsfw = false,
|
||||||
|
tags = runCatching {
|
||||||
|
tileInfo?.select("a.element-link")
|
||||||
|
?.mapToSet {
|
||||||
|
MangaTag(
|
||||||
|
title = it.text().toTitleCase(),
|
||||||
|
key = it.attr("href").substringAfterLast('/'),
|
||||||
|
source = source,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}.getOrNull().orEmpty(),
|
||||||
|
state = when {
|
||||||
|
node.selectFirst("div.tags")
|
||||||
|
?.selectFirst("span.mangaCompleted") != null -> MangaState.FINISHED
|
||||||
|
else -> null
|
||||||
|
},
|
||||||
|
source = source,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getDetails(manga: Manga): Manga {
|
||||||
|
val doc = context.httpGet(manga.url.toAbsoluteUrl(getDomain()), headers).parseHtml()
|
||||||
|
val root = doc.body().getElementById("mangaBox")?.selectFirst("div.leftContent")
|
||||||
|
?: parseFailed("Cannot find root")
|
||||||
|
val dateFormat = SimpleDateFormat("dd.MM.yy", Locale.US)
|
||||||
|
val coverImg = root.selectFirst("div.subject-cover")?.selectFirst("img")
|
||||||
|
return manga.copy(
|
||||||
|
description = root.selectFirst("div.manga-description")?.html(),
|
||||||
|
largeCoverUrl = coverImg?.attr("data-full"),
|
||||||
|
coverUrl = coverImg?.attr("data-thumb") ?: manga.coverUrl,
|
||||||
|
tags = manga.tags + root.select("div.subject-meta").select("span.elem_genre ")
|
||||||
|
.mapNotNull {
|
||||||
|
val a = it.selectFirst("a.element-link") ?: return@mapNotNull null
|
||||||
|
MangaTag(
|
||||||
|
title = a.text().toTitleCase(),
|
||||||
|
key = a.attr("href").substringAfterLast('/'),
|
||||||
|
source = source,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
isNsfw = root.select(".alert-warning").any { it.ownText().contains(NSFW_ALERT) },
|
||||||
|
chapters = root.selectFirst("div.chapters-link")?.selectFirst("table")
|
||||||
|
?.select("tr:has(td > a)")?.asReversed()?.mapIndexedNotNull { i, tr ->
|
||||||
|
val a = tr.selectFirst("a") ?: return@mapIndexedNotNull null
|
||||||
|
val href = a.attrAsRelativeUrl("href")
|
||||||
|
var translators = ""
|
||||||
|
val translatorElement = a.attr("title")
|
||||||
|
if (!translatorElement.isNullOrBlank()) {
|
||||||
|
translators = translatorElement
|
||||||
|
.replace("(Переводчик),", "&")
|
||||||
|
.removeSuffix(" (Переводчик)")
|
||||||
|
}
|
||||||
|
MangaChapter(
|
||||||
|
id = generateUid(href),
|
||||||
|
name = tr.selectFirst("a")?.text().orEmpty().removePrefix(manga.title).trim(),
|
||||||
|
number = i + 1,
|
||||||
|
url = href,
|
||||||
|
uploadDate = dateFormat.tryParse(tr.selectFirst("td.d-none")?.text()),
|
||||||
|
scanlator = translators,
|
||||||
|
source = source,
|
||||||
|
branch = null,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
|
||||||
|
val doc = context.httpGet(chapter.url.toAbsoluteUrl(getDomain()) + "?mtr=1", headers).parseHtml()
|
||||||
|
val scripts = doc.select("script")
|
||||||
|
for (script in scripts) {
|
||||||
|
val data = script.html()
|
||||||
|
val pos = data.indexOf("rm_h.initReader(")
|
||||||
|
if (pos == -1) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
val json = data.substring(pos)
|
||||||
|
.substringAfter('(')
|
||||||
|
.substringBefore('\n')
|
||||||
|
.substringBeforeLast(')')
|
||||||
|
if (json.isEmpty()) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
val ja = JSONArray("[$json]")
|
||||||
|
val pages = ja.getJSONArray(1)
|
||||||
|
val servers = ja.getJSONArray(4).mapJSON { it.getString("path") }
|
||||||
|
val serversStr = servers.joinToString("|")
|
||||||
|
return (0 until pages.length()).map { i ->
|
||||||
|
val page = pages.getJSONArray(i)
|
||||||
|
val primaryServer = page.getString(0)
|
||||||
|
val url = page.getString(2)
|
||||||
|
MangaPage(
|
||||||
|
id = generateUid(url),
|
||||||
|
url = "$primaryServer|$serversStr|$url",
|
||||||
|
preview = null,
|
||||||
|
referer = chapter.url,
|
||||||
|
source = source,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parseFailed("Pages list not found at ${chapter.url}")
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getPageUrl(page: MangaPage): String {
|
||||||
|
val parts = page.url.split('|')
|
||||||
|
val path = parts.last()
|
||||||
|
val servers = parts.dropLast(1).toSet()
|
||||||
|
val headers = Headers.headersOf("Referer", page.referer)
|
||||||
|
for (server in servers) {
|
||||||
|
val url = server + path
|
||||||
|
if (tryHead(url, headers)) {
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val fallbackServer = servers.firstOrNull() ?: parseFailed("Cannot find any page url")
|
||||||
|
return fallbackServer + path
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getTags(): Set<MangaTag> {
|
||||||
|
val doc = context.httpGet("https://${getDomain()}/list/genres/sort_name", headers).parseHtml()
|
||||||
|
val root = doc.body().getElementById("mangaBox")?.selectFirst("div.leftContent")
|
||||||
|
?.selectFirst("table.table") ?: parseFailed("Cannot find root")
|
||||||
|
return root.select("a.element-link").mapToSet { a ->
|
||||||
|
MangaTag(
|
||||||
|
title = a.text().toTitleCase(),
|
||||||
|
key = a.attr("href").substringAfterLast('/'),
|
||||||
|
source = source,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getSortKey(sortOrder: SortOrder) =
|
||||||
|
when (sortOrder) {
|
||||||
|
SortOrder.ALPHABETICAL -> "name"
|
||||||
|
SortOrder.POPULARITY -> "rate"
|
||||||
|
SortOrder.UPDATED -> "updated"
|
||||||
|
SortOrder.NEWEST -> "created"
|
||||||
|
SortOrder.RATING -> "votes"
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun advancedSearch(domain: String, tags: Set<MangaTag>): Response {
|
||||||
|
val url = "https://$domain/search/advanced"
|
||||||
|
// Step 1: map catalog genres names to advanced-search genres ids
|
||||||
|
val tagsIndex = context.httpGet(url, headers).parseHtml()
|
||||||
|
.body().selectFirst("form.search-form")
|
||||||
|
?.select("div.form-group")
|
||||||
|
?.get(1) ?: parseFailed("Genres filter element not found")
|
||||||
|
val tagNames = tags.map { it.title.lowercase() }
|
||||||
|
val payload = HashMap<String, String>()
|
||||||
|
var foundGenres = 0
|
||||||
|
tagsIndex.select("li.property").forEach { li ->
|
||||||
|
val name = li.text().trim().lowercase()
|
||||||
|
val id = li.selectFirst("input")?.id()
|
||||||
|
?: parseFailed("Id for tag $name not found")
|
||||||
|
payload[id] = if (name in tagNames) {
|
||||||
|
foundGenres++
|
||||||
|
"in"
|
||||||
|
} else ""
|
||||||
|
}
|
||||||
|
if (foundGenres != tags.size) {
|
||||||
|
parseFailed("Some genres are not found")
|
||||||
|
}
|
||||||
|
// Step 2: advanced search
|
||||||
|
payload["q"] = ""
|
||||||
|
payload["s_high_rate"] = ""
|
||||||
|
payload["s_single"] = ""
|
||||||
|
payload["s_mature"] = ""
|
||||||
|
payload["s_completed"] = ""
|
||||||
|
payload["s_translated"] = ""
|
||||||
|
payload["s_many_chapters"] = ""
|
||||||
|
payload["s_wait_upload"] = ""
|
||||||
|
payload["s_sale"] = ""
|
||||||
|
payload["years"] = "1900,2099"
|
||||||
|
payload["+"] = "Искать".urlEncoded()
|
||||||
|
return context.httpPost(url, payload, headers)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun tryHead(url: String, headers: Headers): Boolean = runCatching {
|
||||||
|
context.httpHead(url, headers).isSuccessful
|
||||||
|
}.getOrDefault(false)
|
||||||
|
}
|
||||||
@ -0,0 +1,65 @@
|
|||||||
|
package org.koitharu.kotatsu.parsers.site
|
||||||
|
|
||||||
|
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.ParseException
|
||||||
|
import org.koitharu.kotatsu.parsers.model.*
|
||||||
|
import org.koitharu.kotatsu.parsers.util.mapToSet
|
||||||
|
import org.koitharu.kotatsu.parsers.util.parseHtml
|
||||||
|
import org.koitharu.kotatsu.parsers.util.toAbsoluteUrl
|
||||||
|
import org.koitharu.kotatsu.parsers.util.toTitleCase
|
||||||
|
|
||||||
|
@MangaSourceParser("HENCHAN", "Хентай-тян", "ru")
|
||||||
|
internal class HenChanParser(override val context: MangaLoaderContext) : ChanParser(MangaSource.HENCHAN) {
|
||||||
|
|
||||||
|
override val configKeyDomain = ConfigKey.Domain(
|
||||||
|
"xxx.hentaichan.live",
|
||||||
|
arrayOf("xxx.hentaichan.live", "xx.hentaichan.live", "hentaichan.live", "hentaichan.pro"),
|
||||||
|
)
|
||||||
|
|
||||||
|
override suspend fun getList(
|
||||||
|
offset: Int,
|
||||||
|
query: String?,
|
||||||
|
tags: Set<MangaTag>?,
|
||||||
|
sortOrder: SortOrder,
|
||||||
|
): List<Manga> {
|
||||||
|
return super.getList(offset, query, tags, sortOrder).map {
|
||||||
|
it.copy(
|
||||||
|
coverUrl = it.coverUrl.replace("_blur", ""),
|
||||||
|
isNsfw = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getDetails(manga: Manga): Manga {
|
||||||
|
val doc = context.httpGet(manga.url.toAbsoluteUrl(getDomain())).parseHtml()
|
||||||
|
val root =
|
||||||
|
doc.body().getElementById("dle-content") ?: throw ParseException("Cannot find root")
|
||||||
|
val readLink = manga.url.replace("manga", "online")
|
||||||
|
return manga.copy(
|
||||||
|
description = root.getElementById("description")?.html()?.substringBeforeLast("<div"),
|
||||||
|
largeCoverUrl = root.getElementById("cover")?.absUrl("src"),
|
||||||
|
tags = root.selectFirst("div.sidetags")?.select("li.sidetag")?.mapToSet {
|
||||||
|
val a = it.children().last() ?: parseFailed("Invalid tag")
|
||||||
|
MangaTag(
|
||||||
|
title = a.text().toTitleCase(),
|
||||||
|
key = a.attr("href").substringAfterLast('/'),
|
||||||
|
source = source,
|
||||||
|
)
|
||||||
|
} ?: manga.tags,
|
||||||
|
chapters = listOf(
|
||||||
|
MangaChapter(
|
||||||
|
id = generateUid(readLink),
|
||||||
|
url = readLink,
|
||||||
|
source = source,
|
||||||
|
number = 1,
|
||||||
|
uploadDate = 0L,
|
||||||
|
name = manga.title,
|
||||||
|
scanlator = null,
|
||||||
|
branch = null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,14 @@
|
|||||||
|
package org.koitharu.kotatsu.parsers.site
|
||||||
|
|
||||||
|
import org.jsoup.nodes.Document
|
||||||
|
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
||||||
|
import org.koitharu.kotatsu.parsers.MangaSourceParser
|
||||||
|
import org.koitharu.kotatsu.parsers.config.ConfigKey
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
|
|
||||||
|
@MangaSourceParser("HENTAILIB", "HentaiLib", "ru")
|
||||||
|
internal class HentaiLibParser(context: MangaLoaderContext) : MangaLibParser(context, MangaSource.HENTAILIB) {
|
||||||
|
|
||||||
|
override val configKeyDomain = ConfigKey.Domain("hentailib.me", null)
|
||||||
|
override fun isNsfw(doc: Document) = true
|
||||||
|
}
|
||||||
@ -0,0 +1,455 @@
|
|||||||
|
package org.koitharu.kotatsu.parsers.site
|
||||||
|
|
||||||
|
import org.jsoup.nodes.Element
|
||||||
|
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.exception.ParseException
|
||||||
|
import org.koitharu.kotatsu.parsers.model.*
|
||||||
|
import org.koitharu.kotatsu.parsers.util.*
|
||||||
|
import java.text.DateFormat
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
|
private const val PAGE_SIZE = 12
|
||||||
|
|
||||||
|
internal abstract class MadaraParser(
|
||||||
|
override val context: MangaLoaderContext,
|
||||||
|
source: MangaSource,
|
||||||
|
domain: String,
|
||||||
|
) : MangaParser(source) {
|
||||||
|
|
||||||
|
override val configKeyDomain = ConfigKey.Domain(domain, null)
|
||||||
|
|
||||||
|
override val sortOrders: Set<SortOrder> = EnumSet.of(
|
||||||
|
SortOrder.UPDATED,
|
||||||
|
SortOrder.POPULARITY,
|
||||||
|
)
|
||||||
|
|
||||||
|
protected open val tagPrefix = "manga-genre/"
|
||||||
|
protected open val isNsfwSource = false
|
||||||
|
|
||||||
|
override suspend fun getList(
|
||||||
|
offset: Int,
|
||||||
|
query: String?,
|
||||||
|
tags: Set<MangaTag>?,
|
||||||
|
sortOrder: SortOrder,
|
||||||
|
): List<Manga> {
|
||||||
|
val tag = when {
|
||||||
|
tags.isNullOrEmpty() -> null
|
||||||
|
tags.size == 1 -> tags.first()
|
||||||
|
else -> throw IllegalArgumentException("Multiple genres are not supported by this source")
|
||||||
|
}
|
||||||
|
val payload = createRequestTemplate()
|
||||||
|
payload["page"] = (offset / PAGE_SIZE.toFloat()).toIntUp().toString()
|
||||||
|
payload["vars[meta_key]"] = when (sortOrder) {
|
||||||
|
SortOrder.POPULARITY -> "_wp_manga_views"
|
||||||
|
SortOrder.UPDATED -> "_latest_update"
|
||||||
|
else -> "_wp_manga_views"
|
||||||
|
}
|
||||||
|
payload["vars[wp-manga-genre]"] = tag?.key.orEmpty()
|
||||||
|
payload["vars[s]"] = query.orEmpty()
|
||||||
|
val doc = context.httpPost(
|
||||||
|
"https://${getDomain()}/wp-admin/admin-ajax.php",
|
||||||
|
payload,
|
||||||
|
).parseHtml()
|
||||||
|
return doc.select("div.row.c-tabs-item__content").map { div ->
|
||||||
|
val href = div.selectFirst("a")?.attrAsRelativeUrlOrNull("href")
|
||||||
|
?: parseFailed("Link not found")
|
||||||
|
val summary = div.selectFirst(".tab-summary")
|
||||||
|
Manga(
|
||||||
|
id = generateUid(href),
|
||||||
|
url = href,
|
||||||
|
publicUrl = href.toAbsoluteUrl(div.host ?: getDomain()),
|
||||||
|
coverUrl = div.selectFirst("img")?.src().orEmpty(),
|
||||||
|
title = (summary?.selectFirst("h3") ?: summary?.selectFirst("h4"))?.text().orEmpty(),
|
||||||
|
altTitle = null,
|
||||||
|
rating = div.selectFirst("span.total_votes")?.ownText()
|
||||||
|
?.toFloatOrNull()?.div(5f) ?: -1f,
|
||||||
|
tags = summary?.selectFirst(".mg_genres")?.select("a")?.mapToSet { a ->
|
||||||
|
MangaTag(
|
||||||
|
key = a.attr("href").removeSuffix("/").substringAfterLast('/'),
|
||||||
|
title = a.text().toTitleCase(),
|
||||||
|
source = source,
|
||||||
|
)
|
||||||
|
}.orEmpty(),
|
||||||
|
author = summary?.selectFirst(".mg_author")?.selectFirst("a")?.ownText(),
|
||||||
|
state = when (
|
||||||
|
summary?.selectFirst(".mg_status")?.selectFirst(".summary-content")
|
||||||
|
?.ownText()?.trim()?.lowercase()
|
||||||
|
) {
|
||||||
|
"ongoing" -> MangaState.ONGOING
|
||||||
|
"completed" -> MangaState.FINISHED
|
||||||
|
else -> null
|
||||||
|
},
|
||||||
|
source = source,
|
||||||
|
isNsfw = isNsfwSource,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getTags(): Set<MangaTag> {
|
||||||
|
val doc = context.httpGet("https://${getDomain()}/manga/").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) {
|
||||||
|
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().toTitleCase(),
|
||||||
|
source = source,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getDetails(manga: Manga): Manga {
|
||||||
|
val fullUrl = manga.url.toAbsoluteUrl(getDomain())
|
||||||
|
val doc = context.httpGet(fullUrl).parseHtml()
|
||||||
|
val root = doc.body().selectFirst("div.profile-manga")
|
||||||
|
?.selectFirst("div.summary_content")
|
||||||
|
?.selectFirst("div.post-content")
|
||||||
|
?: throw ParseException("Root not found")
|
||||||
|
val root2 = doc.body().selectFirst("div.content-area")
|
||||||
|
?.selectFirst("div.c-page")
|
||||||
|
?: throw ParseException("Root2 not found")
|
||||||
|
val dateFormat = SimpleDateFormat("MMMM dd, yyyy", Locale.US)
|
||||||
|
return 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 = root2.select("li").asReversed().mapIndexed { i, li ->
|
||||||
|
val a = li.selectFirst("a")
|
||||||
|
val href = a?.attrAsRelativeUrlOrNull("href") ?: 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(getDomain())
|
||||||
|
val doc = context.httpGet(fullUrl).parseHtml()
|
||||||
|
val root = doc.body().selectFirst("div.main-col-inner")
|
||||||
|
?.selectFirst("div.reading-content")
|
||||||
|
?: throw ParseException("Root not found")
|
||||||
|
return root.select("div.page-break").map { div ->
|
||||||
|
val img = div.selectFirst("img") ?: parseFailed("Page image not found")
|
||||||
|
val url = img.src()?.toRelativeUrl(getDomain()) ?: parseFailed("Image src not found")
|
||||||
|
MangaPage(
|
||||||
|
id = generateUid(url),
|
||||||
|
url = url,
|
||||||
|
preview = null,
|
||||||
|
referer = fullUrl,
|
||||||
|
source = source,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseChapterDate(dateFormat: DateFormat, date: String?): Long {
|
||||||
|
date ?: return 0
|
||||||
|
return when {
|
||||||
|
date.endsWith(" ago", ignoreCase = true) -> {
|
||||||
|
parseRelativeDate(date)
|
||||||
|
}
|
||||||
|
// Handle translated 'ago' in Portuguese.
|
||||||
|
date.endsWith(" atrás", ignoreCase = true) -> {
|
||||||
|
parseRelativeDate(date)
|
||||||
|
}
|
||||||
|
// Handle translated 'ago' in Turkish.
|
||||||
|
date.endsWith(" önce", ignoreCase = true) -> {
|
||||||
|
parseRelativeDate(date)
|
||||||
|
}
|
||||||
|
// Handle 'yesterday' and 'today', using midnight
|
||||||
|
date.startsWith("year", ignoreCase = true) -> {
|
||||||
|
Calendar.getInstance().apply {
|
||||||
|
add(Calendar.DAY_OF_MONTH, -1) // yesterday
|
||||||
|
set(Calendar.HOUR_OF_DAY, 0)
|
||||||
|
set(Calendar.MINUTE, 0)
|
||||||
|
set(Calendar.SECOND, 0)
|
||||||
|
set(Calendar.MILLISECOND, 0)
|
||||||
|
}.timeInMillis
|
||||||
|
}
|
||||||
|
date.startsWith("today", ignoreCase = true) -> {
|
||||||
|
Calendar.getInstance().apply {
|
||||||
|
set(Calendar.HOUR_OF_DAY, 0)
|
||||||
|
set(Calendar.MINUTE, 0)
|
||||||
|
set(Calendar.SECOND, 0)
|
||||||
|
set(Calendar.MILLISECOND, 0)
|
||||||
|
}.timeInMillis
|
||||||
|
}
|
||||||
|
date.contains(Regex("""\d(st|nd|rd|th)""")) -> {
|
||||||
|
// Clean date (e.g. 5th December 2019 to 5 December 2019) before parsing it
|
||||||
|
date.split(" ").map {
|
||||||
|
if (it.contains(Regex("""\d\D\D"""))) {
|
||||||
|
it.replace(Regex("""\D"""), "")
|
||||||
|
} else {
|
||||||
|
it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.let { dateFormat.tryParse(it.joinToString(" ")) }
|
||||||
|
}
|
||||||
|
else -> dateFormat.tryParse(date)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parses dates in this form:
|
||||||
|
// 21 hours ago
|
||||||
|
private fun parseRelativeDate(date: String): Long {
|
||||||
|
val number = Regex("""(\d+)""").find(date)?.value?.toIntOrNull() ?: return 0
|
||||||
|
val cal = Calendar.getInstance()
|
||||||
|
|
||||||
|
return when {
|
||||||
|
WordSet(
|
||||||
|
"hari",
|
||||||
|
"gün",
|
||||||
|
"jour",
|
||||||
|
"día",
|
||||||
|
"dia",
|
||||||
|
"day",
|
||||||
|
).anyWordIn(date) -> cal.apply { add(Calendar.DAY_OF_MONTH, -number) }.timeInMillis
|
||||||
|
WordSet("jam", "saat", "heure", "hora", "hour").anyWordIn(date) -> cal.apply {
|
||||||
|
add(
|
||||||
|
Calendar.HOUR,
|
||||||
|
-number,
|
||||||
|
)
|
||||||
|
}.timeInMillis
|
||||||
|
WordSet("menit", "dakika", "min", "minute", "minuto").anyWordIn(date) -> cal.apply {
|
||||||
|
add(
|
||||||
|
Calendar.MINUTE,
|
||||||
|
-number,
|
||||||
|
)
|
||||||
|
}.timeInMillis
|
||||||
|
WordSet("detik", "segundo", "second").anyWordIn(date) -> cal.apply {
|
||||||
|
add(
|
||||||
|
Calendar.SECOND,
|
||||||
|
-number,
|
||||||
|
)
|
||||||
|
}.timeInMillis
|
||||||
|
WordSet("month").anyWordIn(date) -> cal.apply { add(Calendar.MONTH, -number) }.timeInMillis
|
||||||
|
WordSet("year").anyWordIn(date) -> cal.apply { add(Calendar.YEAR, -number) }.timeInMillis
|
||||||
|
else -> 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Element.src(): String? {
|
||||||
|
return absUrl("data-src").ifEmpty {
|
||||||
|
absUrl("src")
|
||||||
|
}.takeUnless { it.isEmpty() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createRequestTemplate() =
|
||||||
|
(
|
||||||
|
"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" +
|
||||||
|
"%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=desc&vars%5Bmanga_archives_item_layout%5D=default"
|
||||||
|
).split('&')
|
||||||
|
.map {
|
||||||
|
val pos = it.indexOf('=')
|
||||||
|
it.substring(0, pos) to it.substring(pos + 1)
|
||||||
|
}.toMutableMap()
|
||||||
|
|
||||||
|
@MangaSourceParser("MANGAREAD", "MangaRead", "en")
|
||||||
|
class MangaRead(context: MangaLoaderContext) : MadaraParser(context, MangaSource.MANGAREAD, "www.mangaread.org") {
|
||||||
|
override val tagPrefix = "genres/"
|
||||||
|
}
|
||||||
|
|
||||||
|
@MangaSourceParser("MANGAWEEBS", "MangaWeebs", "en")
|
||||||
|
class MangaWeebs(context: MangaLoaderContext) : MadaraParser(context, MangaSource.MANGAWEEBS, "mangaweebs.in")
|
||||||
|
|
||||||
|
@MangaSourceParser("READMANWHA", "ReadManwha", "en")
|
||||||
|
class ReadManwha(context: MangaLoaderContext) : MadaraParser(context, MangaSource.READMANWHA, "readmanwha.net")
|
||||||
|
|
||||||
|
@MangaSourceParser("MANGATX", "MangaTx", "en")
|
||||||
|
class MangaTx(context: MangaLoaderContext) : MadaraParser(context, MangaSource.MANGATX, "mangatx.com")
|
||||||
|
|
||||||
|
@MangaSourceParser("MANGAEFFECT", "MangaEffect", "en")
|
||||||
|
class MangaEffect(context: MangaLoaderContext) : MadaraParser(context, MangaSource.MANGAEFFECT, "mangaeffect.com")
|
||||||
|
|
||||||
|
@MangaSourceParser("AQUAMANGA", "AquaManga", "en")
|
||||||
|
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("MANGAROCK", "MangaRock", "en")
|
||||||
|
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("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")
|
||||||
|
class MangaKomi(context: MangaLoaderContext) : MadaraParser(context, MangaSource.MANGA_KOMI, "mangakomi.io")
|
||||||
|
|
||||||
|
@MangaSourceParser("MANGA_3S", "Manga3s", "en")
|
||||||
|
class Manga3s(context: MangaLoaderContext) : MadaraParser(context, MangaSource.MANGA_3S, "manga3s.com")
|
||||||
|
|
||||||
|
@MangaSourceParser("MANHWAKOOL", "Manhwa Kool", "en")
|
||||||
|
class ManhwaKool(context: MangaLoaderContext) : MadaraParser(context, MangaSource.MANHWAKOOL, "manhwakool.com") {
|
||||||
|
override fun getFaviconUrl(): String {
|
||||||
|
return "https://${getDomain()}/wp-content/uploads/2021/10/cropped-logo-kool-32x32.jpeg"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MangaSourceParser("TOPMANHUA", "Top Manhua", "en")
|
||||||
|
class TopManhua(context: MangaLoaderContext) : MadaraParser(context, MangaSource.TOPMANHUA, "www.topmanhua.com") {
|
||||||
|
override val tagPrefix = "manhua-genre/"
|
||||||
|
}
|
||||||
|
|
||||||
|
@MangaSourceParser("X2MANGA", "X2Manga", "en")
|
||||||
|
class X2Manga(context: MangaLoaderContext) : MadaraParser(context, MangaSource.X2MANGA, "x2manga.com")
|
||||||
|
|
||||||
|
@MangaSourceParser("SKY_MANGA", "Sky Manga", "en")
|
||||||
|
class SkyManga(context: MangaLoaderContext) : MadaraParser(context, MangaSource.SKY_MANGA, "skymanga.xyz") {
|
||||||
|
|
||||||
|
override val isNsfwSource = true
|
||||||
|
|
||||||
|
override fun getFaviconUrl(): String {
|
||||||
|
return "https://${getDomain()}/wp-content/uploads/cropped-sky-tv-1-32x32.png"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MangaSourceParser("MANGA_DISTRICT", "Manga District", "en")
|
||||||
|
class MangaDistrict(context: MangaLoaderContext) :
|
||||||
|
MadaraParser(context, MangaSource.MANGA_DISTRICT, "mangadistrict.com") {
|
||||||
|
|
||||||
|
override val tagPrefix = "publication-genre/"
|
||||||
|
|
||||||
|
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")
|
||||||
|
class AllPornComic(context: MangaLoaderContext) :
|
||||||
|
MadaraParser(context, MangaSource.ALLPORN_COMIC, "allporncomic.com") {
|
||||||
|
|
||||||
|
override val tagPrefix = "porncomic-genre/"
|
||||||
|
|
||||||
|
override val isNsfwSource = true
|
||||||
|
|
||||||
|
override fun getFaviconUrl(): String {
|
||||||
|
return "https://cdn.${getDomain()}/wp-content/uploads/2019/01/cropped-cropped-pcround-32x32.png"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MangaSourceParser("MANHWA_CHILL", "Manhwa Chill", "en")
|
||||||
|
class ManhwaChill(context: MangaLoaderContext) : MadaraParser(context, MangaSource.MANHWA_CHILL, "manhwachill.me")
|
||||||
|
|
||||||
|
@MangaSourceParser("TREE_MANGA", "Tree Manga", "en")
|
||||||
|
class TreeManga(context: MangaLoaderContext) : MadaraParser(context, MangaSource.TREE_MANGA, "treemanga.com") {
|
||||||
|
|
||||||
|
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")
|
||||||
|
class MangaCv(context: MangaLoaderContext) : MadaraParser(context, MangaSource.MANGACV, "mangacv.com") {
|
||||||
|
|
||||||
|
override fun getFaviconUrl(): String {
|
||||||
|
return "https://${getDomain()}/wp-content/uploads/2020/10/cropped-mangaCV-32x32.png"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MangaSourceParser("MANGA_MANHUA", "Manga Manhua", "en")
|
||||||
|
class MangaManhua(context: MangaLoaderContext) :
|
||||||
|
MadaraParser(context, MangaSource.MANGA_MANHUA, "mangamanhua.online")
|
||||||
|
|
||||||
|
@MangaSourceParser("MANGA_247", "247MANGA", "en")
|
||||||
|
class Manga247(context: MangaLoaderContext) : MadaraParser(context, MangaSource.MANGA_247, "247manga.com") {
|
||||||
|
override val tagPrefix = "manhwa-genre/"
|
||||||
|
}
|
||||||
|
|
||||||
|
@MangaSourceParser("MANGA_365", "365Manga", "en")
|
||||||
|
class Manga365(context: MangaLoaderContext) : MadaraParser(context, MangaSource.MANGA_365, "365manga.com")
|
||||||
|
|
||||||
|
@MangaSourceParser("MANGACLASH", "Mangaclash", "en")
|
||||||
|
class Mangaclash(context: MangaLoaderContext) : MadaraParser(context, MangaSource.MANGACLASH, "mangaclash.com")
|
||||||
|
|
||||||
|
@MangaSourceParser("ZINMANGA", "ZINMANGA", "en")
|
||||||
|
class Zinmanga(context: MangaLoaderContext) : MadaraParser(context, MangaSource.ZINMANGA, "zinmanga.com")
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue