Compare commits
213 Commits
c66abf25b9
...
fc828972d8
Author | SHA1 | Date | |
---|---|---|---|
![]() |
fc828972d8 | ||
![]() |
4630e1ba74 | ||
![]() |
9d20a590ae | ||
![]() |
42a448d15f | ||
![]() |
cb99f8ea64 | ||
![]() |
927507b7ac | ||
![]() |
9a2e34df4d | ||
![]() |
b1e4ab83cc | ||
![]() |
208168943c | ||
![]() |
143e964708 | ||
![]() |
8bd625d00b | ||
![]() |
1f8bb317b6 | ||
![]() |
b09647744c | ||
![]() |
36d7baeae6 | ||
![]() |
6fb92c5ade | ||
![]() |
3018e37492 | ||
![]() |
42e4618a13 | ||
![]() |
187fa7de52 | ||
![]() |
0a7189534e | ||
![]() |
09bab2593b | ||
![]() |
46b898fcd4 | ||
![]() |
2638c08b11 | ||
![]() |
363498aee3 | ||
![]() |
87cd9dc9fb | ||
![]() |
42d0c589d6 | ||
![]() |
b998f4cc58 | ||
![]() |
3993e7349b | ||
![]() |
03b8b9b4ca | ||
![]() |
b747b55681 | ||
![]() |
aedd777371 | ||
![]() |
ea34656edf | ||
![]() |
296a7bf55d | ||
![]() |
d773d2692b | ||
![]() |
827e91d2c6 | ||
![]() |
a5a62a2d4e | ||
![]() |
2142cb32c2 | ||
![]() |
603fd58fff | ||
![]() |
5bdb239025 | ||
![]() |
642156da15 | ||
![]() |
66551c4eca | ||
![]() |
3d797524b2 | ||
![]() |
cdcde3cca1 | ||
![]() |
4050e42337 | ||
![]() |
1980853506 | ||
![]() |
aa40f1101f | ||
![]() |
84dc72f863 | ||
![]() |
54f4552fcc | ||
![]() |
f6945c3b71 | ||
![]() |
dd8c469abf | ||
![]() |
3297983a49 | ||
![]() |
3b64f94ff8 | ||
![]() |
5ec031e3b8 | ||
![]() |
1cbbb8911e | ||
![]() |
a5a70f4a31 | ||
![]() |
0284d48333 | ||
![]() |
b860b15286 | ||
![]() |
2d0e57517e | ||
![]() |
3070ed4967 | ||
![]() |
50e1b1c9fc | ||
![]() |
2512c9e0ad | ||
![]() |
a916378f4e | ||
![]() |
5260aff425 | ||
![]() |
746243a820 | ||
![]() |
7cc89b1d4a | ||
![]() |
4c9e52353a | ||
![]() |
b7b69b73fa | ||
![]() |
e9a8b1b19a | ||
![]() |
791d203f42 | ||
![]() |
717d58a63c | ||
![]() |
e28e6ed77f | ||
![]() |
63ff937ae4 | ||
![]() |
e84d87a883 | ||
![]() |
d482b55bf5 | ||
![]() |
773613ee71 | ||
![]() |
4e0a48fff7 | ||
![]() |
53ff72f22c | ||
![]() |
ee6ec2bd75 | ||
![]() |
cc63389835 | ||
![]() |
074a0d7563 | ||
![]() |
86f9aa6c7e | ||
![]() |
a07358aaa1 | ||
![]() |
50eac7a152 | ||
![]() |
9995c4be38 | ||
![]() |
04c7052e01 | ||
![]() |
dd47332ab9 | ||
![]() |
621dc6c121 | ||
![]() |
6ee4ab2521 | ||
![]() |
2fd8684f2e | ||
![]() |
30e8ccc669 | ||
![]() |
9fb63b4f72 | ||
![]() |
4d61698687 | ||
![]() |
68df5d3b69 | ||
![]() |
8a9231c5af | ||
![]() |
a9aa4705d2 | ||
![]() |
42579db7fe | ||
![]() |
152bc793d2 | ||
![]() |
d89b95a0f2 | ||
![]() |
c2c4cff935 | ||
![]() |
1b46f1f396 | ||
![]() |
325772c8ab | ||
![]() |
67b795a5b8 | ||
![]() |
6af9f2d853 | ||
![]() |
8e7146ec24 | ||
![]() |
5fb99e11eb | ||
![]() |
cdaf1493cd | ||
![]() |
04a963a59a | ||
![]() |
a4347e9da1 | ||
![]() |
a9176c529b | ||
![]() |
205bf49af7 | ||
![]() |
53f87f108e | ||
![]() |
dd595cca72 | ||
![]() |
66f2a0ed6e | ||
![]() |
6022ef39be | ||
![]() |
0c3f9f2736 | ||
![]() |
fcc13a63ed | ||
![]() |
8c6f4bfbcb | ||
![]() |
49ecc98bce | ||
![]() |
3244e3fe53 | ||
![]() |
7b84e27eae | ||
![]() |
e54cab639c | ||
![]() |
8a81d39865 | ||
![]() |
d1878c4183 | ||
![]() |
e3b94d0c75 | ||
![]() |
3f92fc85a0 | ||
![]() |
ee69665a7c | ||
![]() |
a07cc9f52b | ||
![]() |
32a264c4bb | ||
![]() |
f6820edcbd | ||
![]() |
82330730a2 | ||
![]() |
0cec461ff8 | ||
![]() |
026666bc38 | ||
![]() |
d73a90d970 | ||
![]() |
48f590df0c | ||
![]() |
ef9d26cfe8 | ||
![]() |
5406227f0f | ||
![]() |
31e167dd72 | ||
![]() |
62bfe372b2 | ||
![]() |
9b39822d7f | ||
![]() |
aa8ab20b95 | ||
![]() |
801106dee5 | ||
![]() |
748b8c4b11 | ||
![]() |
76fdb2499f | ||
![]() |
8b5bb2643d | ||
![]() |
a5e2da61e2 | ||
![]() |
5cf4e9de71 | ||
![]() |
51b5663380 | ||
![]() |
73d880b915 | ||
![]() |
9814a9770d | ||
![]() |
db240799f0 | ||
![]() |
2a2157d48b | ||
![]() |
57cf47f154 | ||
![]() |
30f9521ed0 | ||
![]() |
7bd7416531 | ||
![]() |
86727e4cde | ||
![]() |
9b9440e3e9 | ||
![]() |
93730a008d | ||
![]() |
f8d70a5252 | ||
![]() |
3b4759ef5a | ||
![]() |
22f8330387 | ||
![]() |
8925b685e9 | ||
![]() |
62212e931d | ||
![]() |
a3532119f8 | ||
![]() |
e55c893b6d | ||
![]() |
1ad39b7ab6 | ||
![]() |
3a0f6ddddf | ||
![]() |
af50939f4e | ||
![]() |
5d970dab5a | ||
![]() |
b364d56096 | ||
![]() |
adaca0c48e | ||
![]() |
ee40d4e9e4 | ||
![]() |
866eeef617 | ||
![]() |
d5301985cf | ||
![]() |
84efd49e17 | ||
![]() |
b1aee99028 | ||
![]() |
989bb4252b | ||
![]() |
5edb0c0e62 | ||
![]() |
c7b13eedb1 | ||
![]() |
597cbcce98 | ||
![]() |
2c457fbd67 | ||
![]() |
40f3502d10 | ||
![]() |
df9a8029d3 | ||
![]() |
a5560df661 | ||
![]() |
9d94e1e704 | ||
![]() |
736d628518 | ||
![]() |
abe89d454c | ||
![]() |
ab711b37ed | ||
![]() |
bb92f316fc | ||
![]() |
d197cd7480 | ||
![]() |
8076818893 | ||
![]() |
453a20d067 | ||
![]() |
12495730dc | ||
![]() |
a5c2daa5f1 | ||
![]() |
ad73c491fa | ||
![]() |
5137bb670c | ||
![]() |
5d1f59089a | ||
![]() |
fc52bba37e | ||
![]() |
b4fede6f9b | ||
![]() |
dc087a1ebb | ||
![]() |
9386947cc2 | ||
![]() |
c464ba31b9 | ||
![]() |
5ee55edbef | ||
![]() |
36295f9b69 | ||
![]() |
c8cc594055 | ||
![]() |
4b44545e76 | ||
![]() |
1b73235d3a | ||
![]() |
0532069813 | ||
![]() |
fcff393118 | ||
![]() |
02c4d7b2a8 | ||
![]() |
60931a9372 | ||
![]() |
06b3b60a31 | ||
![]() |
a5e30ee462 | ||
![]() |
24ba7e3c0c | ||
![]() |
8b7d0ea342 |
1
.github/CODEOWNERS
vendored
Normal file
@ -0,0 +1 @@
|
||||
/src/zh/ @stevenyomi
|
6
.github/ISSUE_TEMPLATE/01_report_issue.yml
vendored
@ -1,5 +1,5 @@
|
||||
name: 🐞 Issue report
|
||||
description: Report a source issue in Tachiyomi
|
||||
description: Report a source issue in Keiyoushi
|
||||
labels: [Bug]
|
||||
body:
|
||||
|
||||
@ -63,7 +63,7 @@ body:
|
||||
description: |
|
||||
You can find your Mihon/Tachiyomi version in **More → About**.
|
||||
placeholder: |
|
||||
Example: "0.16.3"
|
||||
Example: "0.18.0"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
@ -101,7 +101,7 @@ body:
|
||||
required: true
|
||||
- label: I have tried the [troubleshooting guide](https://mihon.app/docs/guides/troubleshooting/).
|
||||
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/tachiyomiorg/tachiyomi/issues/new/choose).
|
||||
- label: If this is an issue with the app itself, I should be opening an issue in the app repository.
|
||||
required: true
|
||||
- label: I will fill out all of the requested information in this form.
|
||||
required: true
|
||||
|
2
.github/ISSUE_TEMPLATE/02_request_source.yml
vendored
@ -1,5 +1,5 @@
|
||||
name: 🌐 Source request
|
||||
description: Suggest a new source for Tachiyomi
|
||||
description: Suggest a new source for Keiyoushi
|
||||
labels: [Source request]
|
||||
body:
|
||||
|
||||
|
1
.gitignore
vendored
@ -11,3 +11,4 @@ apk/
|
||||
gen
|
||||
generated-src/
|
||||
.kotlin
|
||||
*.jks
|
||||
|
@ -1,6 +1,6 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.1-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
|
@ -38,7 +38,7 @@ kotlinter {
|
||||
|
||||
dependencies {
|
||||
compileOnly(versionCatalogs.named("libs").findBundle("common").get())
|
||||
implementation(project(":utils"))
|
||||
implementation(project(":core"))
|
||||
}
|
||||
|
||||
tasks {
|
||||
|
@ -103,7 +103,6 @@ dependencies {
|
||||
if (theme != null) implementation(theme) // Overrides core launcher icons
|
||||
implementation(project(":core"))
|
||||
compileOnly(libs.bundles.common)
|
||||
implementation(project(":utils"))
|
||||
}
|
||||
|
||||
tasks.register("writeManifestFile") {
|
||||
|
@ -1,5 +1,6 @@
|
||||
plugins {
|
||||
id("com.android.library")
|
||||
kotlin("android")
|
||||
}
|
||||
|
||||
android {
|
||||
@ -9,17 +10,18 @@ android {
|
||||
minSdk = AndroidConfig.minSdk
|
||||
}
|
||||
|
||||
namespace = "eu.kanade.tachiyomi.extension.core"
|
||||
|
||||
sourceSets {
|
||||
named("main") {
|
||||
manifest.srcFile("AndroidManifest.xml")
|
||||
res.setSrcDirs(listOf("res"))
|
||||
}
|
||||
}
|
||||
namespace = "keiyoushi.core"
|
||||
|
||||
buildFeatures {
|
||||
resValues = false
|
||||
shaders = false
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
freeCompilerArgs += "-opt-in=kotlinx.serialization.ExperimentalSerializationApi"
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
compileOnly(versionCatalogs.named("libs").findBundle("common").get())
|
||||
}
|
||||
|
@ -10,19 +10,19 @@ import uy.kohesive.injekt.injectLazy
|
||||
val jsonInstance: Json by injectLazy()
|
||||
|
||||
/**
|
||||
* Parses and serializes the String as the type <T>.
|
||||
* Parses JSON string into an object of type [T].
|
||||
*/
|
||||
inline fun <reified T> String.parseAs(json: Json = jsonInstance): T =
|
||||
json.decodeFromString(this)
|
||||
|
||||
/**
|
||||
* Parse and serialize the response body as the type <T>.
|
||||
* Parses the response body into an object of type [T].
|
||||
*/
|
||||
inline fun <reified T> Response.parseAs(json: Json = jsonInstance): T =
|
||||
json.decodeFromStream(body.byteStream())
|
||||
use { json.decodeFromStream(body.byteStream()) }
|
||||
|
||||
/**
|
||||
* Serializes the object to a JSON String.
|
||||
* Serializes the object to a JSON string.
|
||||
*/
|
||||
inline fun <reified T> T.toJsonString(json: Json = jsonInstance): String =
|
||||
json.encodeToString(this)
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 2.6 KiB |
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 3.2 KiB |
Before Width: | Height: | Size: 6.6 KiB After Width: | Height: | Size: 6.6 KiB |
Before Width: | Height: | Size: 9.5 KiB After Width: | Height: | Size: 9.5 KiB |
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
2
gradle/wrapper/gradle-wrapper.properties
vendored
@ -1,6 +1,6 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.1-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
|
4
gradlew
generated
vendored
@ -114,7 +114,7 @@ case "$( uname )" in #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||
CLASSPATH="\\\"\\\""
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
@ -213,7 +213,7 @@ DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-classpath "$CLASSPATH" \
|
||||
org.gradle.wrapper.GradleWrapperMain \
|
||||
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||
"$@"
|
||||
|
||||
# Stop when "xargs" is not available.
|
||||
|
188
gradlew.bat
generated
vendored
@ -1,94 +1,94 @@
|
||||
@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=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
||||
|
||||
: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
|
||||
@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,9 +0,0 @@
|
||||
plugins {
|
||||
id("lib-multisrc")
|
||||
}
|
||||
|
||||
baseVersionCode = 9
|
||||
|
||||
dependencies {
|
||||
api(project(":lib:synchrony"))
|
||||
}
|
@ -1,8 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<application>
|
||||
<activity
|
||||
android:name="eu.kanade.tachiyomi.multisrc.slimereadtheme.SlimeReadThemeUrlActivity"
|
||||
android:name="eu.kanade.tachiyomi.multisrc.etoshore.EtoshoreUrlActivity"
|
||||
android:excludeFromRecents="true"
|
||||
android:exported="true"
|
||||
android:theme="@android:style/Theme.NoDisplay">
|
||||
@ -11,11 +12,10 @@
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
|
||||
<data
|
||||
android:host="${SOURCEHOST}"
|
||||
android:pathPattern="/manga/..*"
|
||||
android:scheme="${SOURCESCHEME}" />
|
||||
<data
|
||||
android:host="${SOURCEHOST}"
|
||||
android:pathPattern="/..*/..*"
|
||||
android:scheme="${SOURCESCHEME}" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
@ -2,4 +2,4 @@ plugins {
|
||||
id("lib-multisrc")
|
||||
}
|
||||
|
||||
baseVersionCode = 4
|
||||
baseVersionCode = 1
|
@ -0,0 +1,247 @@
|
||||
package eu.kanade.tachiyomi.multisrc.etoshore
|
||||
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.source.model.Filter
|
||||
import eu.kanade.tachiyomi.source.model.FilterList
|
||||
import eu.kanade.tachiyomi.source.model.MangasPage
|
||||
import eu.kanade.tachiyomi.source.model.Page
|
||||
import eu.kanade.tachiyomi.source.model.SChapter
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import eu.kanade.tachiyomi.source.online.ParsedHttpSource
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import org.jsoup.Jsoup
|
||||
import org.jsoup.nodes.Document
|
||||
import org.jsoup.nodes.Element
|
||||
import rx.Observable
|
||||
|
||||
abstract class Etoshore(
|
||||
override val name: String,
|
||||
override val baseUrl: String,
|
||||
override val lang: String,
|
||||
) : ParsedHttpSource() {
|
||||
|
||||
override val supportsLatest = true
|
||||
|
||||
override val client = network.cloudflareClient
|
||||
|
||||
override fun headersBuilder() = super.headersBuilder()
|
||||
.add("Referer", "$baseUrl/")
|
||||
|
||||
// ============================== Popular ==============================
|
||||
|
||||
open val popularFilter = FilterList(
|
||||
SelectionList("", listOf(Tag(value = "views", query = "sort"))),
|
||||
)
|
||||
|
||||
override fun popularMangaRequest(page: Int) = searchMangaRequest(page, "", popularFilter)
|
||||
|
||||
override fun popularMangaParse(response: Response) = searchMangaParse(response)
|
||||
|
||||
override fun popularMangaSelector() = throw UnsupportedOperationException()
|
||||
|
||||
override fun popularMangaNextPageSelector() = throw UnsupportedOperationException()
|
||||
|
||||
override fun popularMangaFromElement(element: Element) = throw UnsupportedOperationException()
|
||||
|
||||
// ============================== Latest ===============================
|
||||
|
||||
open val latestFilter = FilterList(
|
||||
SelectionList("", listOf(Tag(value = "date", query = "sort"))),
|
||||
)
|
||||
|
||||
override fun latestUpdatesRequest(page: Int) = searchMangaRequest(page, "", latestFilter)
|
||||
|
||||
override fun latestUpdatesParse(response: Response) = searchMangaParse(response)
|
||||
|
||||
override fun latestUpdatesSelector() = throw UnsupportedOperationException()
|
||||
|
||||
override fun latestUpdatesNextPageSelector() = throw UnsupportedOperationException()
|
||||
|
||||
override fun latestUpdatesFromElement(element: Element) = throw UnsupportedOperationException()
|
||||
|
||||
// ============================== Search ===============================
|
||||
|
||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
||||
val url = "$baseUrl/page/$page".toHttpUrl().newBuilder()
|
||||
.addQueryParameter("s", query)
|
||||
filters.forEach { filter ->
|
||||
when (filter) {
|
||||
is SelectionList -> {
|
||||
val selected = filter.selected().takeIf { it.value.isNotBlank() }
|
||||
?: return@forEach
|
||||
url.addQueryParameter(selected.query, selected.value)
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
return GET(url.build(), headers)
|
||||
}
|
||||
|
||||
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
|
||||
if (query.startsWith(PREFIX_SEARCH)) {
|
||||
val slug = query.substringAfter(PREFIX_SEARCH)
|
||||
return fetchMangaDetails(SManga.create().apply { url = "/manga/$slug/" })
|
||||
.map { manga -> MangasPage(listOf(manga), false) }
|
||||
}
|
||||
|
||||
return super.fetchSearchManga(page, query, filters)
|
||||
}
|
||||
|
||||
override fun searchMangaSelector() = ".search-posts .chapter-box .poster a"
|
||||
|
||||
override fun searchMangaNextPageSelector() = ".navigation .naviright:has(a)"
|
||||
|
||||
override fun searchMangaFromElement(element: Element) = SManga.create().apply {
|
||||
title = element.attr("title")
|
||||
thumbnail_url = element.selectFirst("img")?.let(::imageFromElement)
|
||||
setUrlWithoutDomain(element.absUrl("href"))
|
||||
}
|
||||
|
||||
override fun searchMangaParse(response: Response): MangasPage {
|
||||
if (filterList.isEmpty()) {
|
||||
filterParse(response)
|
||||
}
|
||||
return super.searchMangaParse(response)
|
||||
}
|
||||
|
||||
// ============================== Details ===============================
|
||||
|
||||
override fun mangaDetailsParse(document: Document) = SManga.create().apply {
|
||||
title = document.selectFirst("h1")!!.text()
|
||||
|
||||
description = document.selectFirst(".excerpt p")?.text()
|
||||
|
||||
document.selectFirst(".details-right-con img")?.let { thumbnail_url = imageFromElement(it) }
|
||||
|
||||
genre = document.select("div.meta-item span.meta-title:contains(Genres) + span a")
|
||||
.joinToString { it.text() }
|
||||
|
||||
author = document.selectFirst("div.meta-item span.meta-title:contains(Author) + span a")
|
||||
?.text()
|
||||
|
||||
with(document) {
|
||||
status = when {
|
||||
containsClass(".finished") -> SManga.COMPLETED
|
||||
containsClass(".publishing") -> SManga.ONGOING
|
||||
containsClass(".on-hiatus") -> SManga.ON_HIATUS
|
||||
containsClass(".discontinued") -> SManga.CANCELLED
|
||||
else -> SManga.UNKNOWN
|
||||
}
|
||||
}
|
||||
|
||||
setUrlWithoutDomain(document.location())
|
||||
}
|
||||
|
||||
private fun Element.containsClass(cssSelector: String) = select(cssSelector).isNotEmpty()
|
||||
|
||||
protected open fun imageFromElement(element: Element): String? {
|
||||
val attributes = listOf(
|
||||
"data-src",
|
||||
"data-lazy-src",
|
||||
"data-cfsrc",
|
||||
"src",
|
||||
)
|
||||
return attributes
|
||||
.mapNotNull { attr -> element.takeIf { it.hasAttr(attr) }?.attr("abs:$attr") }
|
||||
.maxOrNull()
|
||||
?: element.takeIf { it.hasAttr("srcset") }?.attr("abs:srcset")?.getSrcSetImage()
|
||||
}
|
||||
|
||||
protected open fun String.getSrcSetImage(): String? {
|
||||
return this.split(" ")
|
||||
.filter(URL_REGEX::matches)
|
||||
.maxOfOrNull(String::toString)
|
||||
}
|
||||
|
||||
// ============================== Chapters ============================
|
||||
|
||||
override fun chapterListSelector() = ".chapter-list li a"
|
||||
|
||||
override fun chapterListParse(response: Response): List<SChapter> {
|
||||
return super.chapterListParse(response)
|
||||
}
|
||||
|
||||
override fun chapterFromElement(element: Element) = SChapter.create().apply {
|
||||
name = element.selectFirst(".title")!!.text()
|
||||
setUrlWithoutDomain(element.absUrl("href"))
|
||||
}
|
||||
|
||||
// ============================== Pages ===============================
|
||||
|
||||
override fun pageListParse(document: Document): List<Page> {
|
||||
return document.select(".chapter-images .chapter-item > img").mapIndexed { index, element ->
|
||||
Page(index, document.location(), imageFromElement(element))
|
||||
}
|
||||
}
|
||||
|
||||
override fun imageUrlParse(document: Document) = ""
|
||||
|
||||
// ============================= Filters ==============================
|
||||
|
||||
private var filterList = emptyList<Pair<String, List<Tag>>>()
|
||||
|
||||
override fun getFilterList(): FilterList {
|
||||
val filters = mutableListOf<Filter<*>>()
|
||||
|
||||
filters += if (filterList.isNotEmpty()) {
|
||||
filterList.map { SelectionList(it.first, it.second) }
|
||||
} else {
|
||||
listOf(Filter.Header("Aperte 'Redefinir' para tentar mostrar os filtros"))
|
||||
}
|
||||
|
||||
return FilterList(filters)
|
||||
}
|
||||
|
||||
protected open fun parseSelection(document: Document, selector: String): Pair<String, List<Tag>>? {
|
||||
val selectorFilter = "#filter-form $selector .select-item-head .text"
|
||||
|
||||
return document.selectFirst(selectorFilter)?.text()?.let { displayName ->
|
||||
val tags = document.select("#filter-form $selector li").map { element ->
|
||||
element.selectFirst("input")!!.let { input ->
|
||||
Tag(
|
||||
name = element.selectFirst(".text")!!.text(),
|
||||
value = input.attr("value"),
|
||||
query = input.attr("name"),
|
||||
)
|
||||
}
|
||||
}
|
||||
displayName to mutableListOf<Tag>().apply {
|
||||
this += Tag("Default")
|
||||
this += tags
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
open val filterListSelector: List<String> = listOf(
|
||||
".filter-genre",
|
||||
".filter-status",
|
||||
".filter-type",
|
||||
".filter-year",
|
||||
".filter-sort",
|
||||
)
|
||||
|
||||
open fun filterParse(response: Response) {
|
||||
val document = Jsoup.parseBodyFragment(response.peekBody(Long.MAX_VALUE).string())
|
||||
filterList = filterListSelector.mapNotNull { selector -> parseSelection(document, selector) }
|
||||
}
|
||||
|
||||
protected data class Tag(val name: String = "", val value: String = "", val query: String = "")
|
||||
|
||||
private open class SelectionList(displayName: String, private val vals: List<Tag>, state: Int = 0) :
|
||||
Filter.Select<String>(displayName, vals.map { it.name }.toTypedArray(), state) {
|
||||
fun selected() = vals[state]
|
||||
}
|
||||
|
||||
// ============================= Utils ==============================
|
||||
|
||||
private fun String.containsIn(array: Array<String>): Boolean {
|
||||
return this.lowercase() in array.map { it.lowercase() }
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val PREFIX_SEARCH = "id:"
|
||||
val URL_REGEX = """^(https?://[^\s/$.?#].[^\s]*)$""".toRegex()
|
||||
}
|
||||
}
|
@ -0,0 +1,42 @@
|
||||
package eu.kanade.tachiyomi.multisrc.etoshore
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import kotlin.system.exitProcess
|
||||
|
||||
class EtoshoreUrlActivity : Activity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
val pathSegments = intent?.data?.pathSegments
|
||||
|
||||
if (pathSegments != null && pathSegments.size >= 2) {
|
||||
val mainIntent = Intent().apply {
|
||||
action = "eu.kanade.tachiyomi.SEARCH"
|
||||
putExtra("query", "${getSLUG(pathSegments)}")
|
||||
putExtra("filter", packageName)
|
||||
}
|
||||
try {
|
||||
startActivity(mainIntent)
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
Log.e("EtoshoreUrl", e.toString())
|
||||
}
|
||||
} else {
|
||||
Log.e("EtoshoreUrl", "could not parse uri from intent $intent")
|
||||
}
|
||||
|
||||
finish()
|
||||
exitProcess(0)
|
||||
}
|
||||
|
||||
private fun getSLUG(pathSegments: MutableList<String>): String? {
|
||||
return if (pathSegments.size >= 2) {
|
||||
val slug = pathSegments[1]
|
||||
"${Etoshore.PREFIX_SEARCH}$slug"
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
@ -2,4 +2,4 @@ plugins {
|
||||
id("lib-multisrc")
|
||||
}
|
||||
|
||||
baseVersionCode = 2
|
||||
baseVersionCode = 3
|
||||
|
@ -84,12 +84,14 @@ open class GoDa(
|
||||
val document = response.asJsoup().selectFirst("main")!!
|
||||
val titleElement = document.selectFirst("h1")!!
|
||||
val elements = titleElement.parent()!!.parent()!!.children()
|
||||
check(elements.size == 6)
|
||||
check(elements[4].tagName() == "p")
|
||||
|
||||
title = titleElement.ownText()
|
||||
status = when (titleElement.child(0).text()) {
|
||||
"連載中", "Ongoing" -> SManga.ONGOING
|
||||
"完結" -> SManga.COMPLETED
|
||||
"停止更新" -> SManga.CANCELLED
|
||||
"休刊" -> SManga.ON_HIATUS
|
||||
else -> SManga.UNKNOWN
|
||||
}
|
||||
author = Entities.unescape(elements[1].children().drop(1).joinToString { it.text().removeSuffix(" ,") })
|
||||
|
@ -2,4 +2,4 @@ plugins {
|
||||
id("lib-multisrc")
|
||||
}
|
||||
|
||||
baseVersionCode = 2
|
||||
baseVersionCode = 5
|
||||
|
@ -1,12 +1,13 @@
|
||||
package eu.kanade.tachiyomi.multisrc.greenshit
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import android.util.Base64
|
||||
import android.widget.Toast
|
||||
import androidx.preference.EditTextPreference
|
||||
import androidx.preference.PreferenceScreen
|
||||
import androidx.preference.SwitchPreferenceCompat
|
||||
import app.cash.quickjs.QuickJs
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.network.POST
|
||||
import eu.kanade.tachiyomi.source.ConfigurableSource
|
||||
import eu.kanade.tachiyomi.source.model.FilterList
|
||||
import eu.kanade.tachiyomi.source.model.MangasPage
|
||||
@ -15,83 +16,68 @@ import eu.kanade.tachiyomi.source.model.SChapter
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||
import eu.kanade.tachiyomi.util.asJsoup
|
||||
import keiyoushi.utils.getPreferences
|
||||
import keiyoushi.utils.getPreferencesLazy
|
||||
import keiyoushi.utils.parseAs
|
||||
import keiyoushi.utils.toJsonString
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import okhttp3.Response
|
||||
import org.jsoup.nodes.Document
|
||||
import org.jsoup.nodes.Element
|
||||
import java.io.IOException
|
||||
import javax.crypto.Mac
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
|
||||
abstract class GreenShit(
|
||||
override val name: String,
|
||||
val url: String,
|
||||
override val baseUrl: String,
|
||||
override val lang: String,
|
||||
val scanId: Long = 1,
|
||||
) : HttpSource(), ConfigurableSource {
|
||||
|
||||
override val supportsLatest = true
|
||||
|
||||
private val isCi = System.getenv("CI") == "true"
|
||||
private val preferences: SharedPreferences by getPreferencesLazy()
|
||||
|
||||
private val preferences: SharedPreferences = getPreferences()
|
||||
|
||||
protected var apiUrl: String
|
||||
get() = preferences.getString(API_BASE_URL_PREF, defaultApiUrl)!!
|
||||
private set(value) = preferences.edit().putString(API_BASE_URL_PREF, value).apply()
|
||||
|
||||
private var restoreDefaultEnable: Boolean
|
||||
get() = preferences.getBoolean(DEFAULT_PREF, false)
|
||||
set(value) = preferences.edit().putBoolean(DEFAULT_PREF, value).apply()
|
||||
|
||||
override val baseUrl: String get() = when {
|
||||
isCi -> defaultBaseUrl
|
||||
else -> preferences.getString(BASE_URL_PREF, defaultBaseUrl)!!
|
||||
}
|
||||
|
||||
private val defaultBaseUrl: String = url
|
||||
private val defaultApiUrl: String = "https://api.sussytoons.wtf"
|
||||
protected open val apiUrl = "https://api.sussytoons.wtf"
|
||||
|
||||
override val client = network.cloudflareClient.newBuilder()
|
||||
.addInterceptor(::imageLocation)
|
||||
.build()
|
||||
|
||||
init {
|
||||
if (restoreDefaultEnable) {
|
||||
restoreDefaultEnable = false
|
||||
preferences.edit().putString(DEFAULT_BASE_URL_PREF, null).apply()
|
||||
preferences.edit().putString(API_DEFAULT_BASE_URL_PREF, null).apply()
|
||||
}
|
||||
open val targetAudience: TargetAudience = TargetAudience.All
|
||||
|
||||
preferences.getString(DEFAULT_BASE_URL_PREF, null).let { domain ->
|
||||
if (domain != defaultBaseUrl) {
|
||||
preferences.edit()
|
||||
.putString(BASE_URL_PREF, defaultBaseUrl)
|
||||
.putString(DEFAULT_BASE_URL_PREF, defaultBaseUrl)
|
||||
.apply()
|
||||
}
|
||||
}
|
||||
preferences.getString(API_DEFAULT_BASE_URL_PREF, null).let { domain ->
|
||||
if (domain != defaultApiUrl) {
|
||||
preferences.edit()
|
||||
.putString(API_BASE_URL_PREF, defaultApiUrl)
|
||||
.putString(API_DEFAULT_BASE_URL_PREF, defaultApiUrl)
|
||||
.apply()
|
||||
}
|
||||
}
|
||||
}
|
||||
open val contentOrigin: ContentOrigin = ContentOrigin.Web
|
||||
|
||||
override fun headersBuilder() = super.headersBuilder()
|
||||
.set("scan-id", scanId.toString())
|
||||
|
||||
// ============================= Popular ==================================
|
||||
|
||||
override fun popularMangaRequest(page: Int) = GET(baseUrl, headers)
|
||||
override fun popularMangaRequest(page: Int) =
|
||||
when (contentOrigin) {
|
||||
ContentOrigin.Mobile -> GET("$apiUrl/obras/top5", headers)
|
||||
else -> GET(baseUrl, headers)
|
||||
}
|
||||
|
||||
override fun popularMangaParse(response: Response): MangasPage {
|
||||
override fun popularMangaParse(response: Response): MangasPage =
|
||||
when (contentOrigin) {
|
||||
ContentOrigin.Mobile -> popularMangaParseMobile(response)
|
||||
else -> popularMangaParseWeb(response)
|
||||
}
|
||||
|
||||
private fun popularMangaParseMobile(response: Response): MangasPage {
|
||||
val mangas = response.parseAs<ResultDto<List<MangaDto>>>().toSMangaList()
|
||||
return MangasPage(mangas, hasNextPage = false)
|
||||
}
|
||||
|
||||
private fun popularMangaParseWeb(response: Response): MangasPage {
|
||||
val json = response.parseScriptToJson().let(POPULAR_JSON_REGEX::find)
|
||||
?.groups?.get(1)?.value
|
||||
?: return MangasPage(emptyList(), false)
|
||||
@ -105,11 +91,16 @@ abstract class GreenShit(
|
||||
val url = "$apiUrl/obras/novos-capitulos".toHttpUrl().newBuilder()
|
||||
.addQueryParameter("pagina", page.toString())
|
||||
.addQueryParameter("limite", "24")
|
||||
.addQueryParameter("gen_id", "4")
|
||||
.addQueryParameterIf(targetAudience != TargetAudience.All, "gen_id", targetAudience.toString())
|
||||
.build()
|
||||
return GET(url, headers)
|
||||
}
|
||||
|
||||
private fun HttpUrl.Builder.addQueryParameterIf(predicate: Boolean, name: String, value: String): HttpUrl.Builder {
|
||||
if (predicate) addQueryParameter(name, value)
|
||||
return this
|
||||
}
|
||||
|
||||
override fun latestUpdatesParse(response: Response): MangasPage {
|
||||
val dto = response.parseAs<ResultDto<List<MangaDto>>>()
|
||||
val mangas = dto.toSMangaList()
|
||||
@ -134,19 +125,60 @@ abstract class GreenShit(
|
||||
}
|
||||
|
||||
// ============================= Details ==================================
|
||||
override fun getMangaUrl(manga: SManga) = when (contentOrigin) {
|
||||
ContentOrigin.Mobile -> "$baseUrl${manga.url}"
|
||||
else -> super.getMangaUrl(manga)
|
||||
}
|
||||
|
||||
override fun mangaDetailsParse(response: Response): SManga {
|
||||
override fun mangaDetailsRequest(manga: SManga): Request =
|
||||
when (contentOrigin) {
|
||||
ContentOrigin.Mobile -> mangaDetailsRequestMobile(manga)
|
||||
else -> super.mangaDetailsRequest(manga)
|
||||
}
|
||||
|
||||
private fun mangaDetailsRequestMobile(manga: SManga): Request {
|
||||
val pathSegment = manga.url.substringBeforeLast("/").replace("obra", "obras")
|
||||
return GET("$apiUrl$pathSegment", headers)
|
||||
}
|
||||
|
||||
override fun mangaDetailsParse(response: Response) =
|
||||
when (contentOrigin) {
|
||||
ContentOrigin.Mobile -> response.parseAs<ResultDto<MangaDto>>().results.toSManga()
|
||||
else -> mangaDetailsParseWeb(response)
|
||||
}
|
||||
|
||||
private fun mangaDetailsParseWeb(response: Response): SManga {
|
||||
val json = response.parseScriptToJson().let(DETAILS_CHAPTER_REGEX::find)
|
||||
?.groups?.get(0)?.value
|
||||
?.groups?.get(1)?.value
|
||||
?: throw IOException("Details do mangá não foi encontrado")
|
||||
return json.parseAs<ResultDto<MangaDto>>().results.toSManga()
|
||||
}
|
||||
|
||||
// ============================= Chapters =================================
|
||||
|
||||
override fun chapterListParse(response: Response): List<SChapter> {
|
||||
override fun getChapterUrl(chapter: SChapter) = when (contentOrigin) {
|
||||
ContentOrigin.Mobile -> "$baseUrl${chapter.url}"
|
||||
else -> super.getChapterUrl(chapter)
|
||||
}
|
||||
|
||||
override fun chapterListRequest(manga: SManga) =
|
||||
when (contentOrigin) {
|
||||
ContentOrigin.Mobile -> mangaDetailsRequest(manga)
|
||||
else -> super.chapterListRequest(manga)
|
||||
}
|
||||
|
||||
override fun chapterListParse(response: Response): List<SChapter> =
|
||||
when (contentOrigin) {
|
||||
ContentOrigin.Mobile -> chapterListParseMobile(response)
|
||||
else -> chapterListParseWeb(response)
|
||||
}.distinctBy(SChapter::url)
|
||||
|
||||
private fun chapterListParseMobile(response: Response): List<SChapter> =
|
||||
response.parseAs<ResultDto<WrapperChapterDto>>().toSChapterList()
|
||||
|
||||
private fun chapterListParseWeb(response: Response): List<SChapter> {
|
||||
val json = response.parseScriptToJson().let(DETAILS_CHAPTER_REGEX::find)
|
||||
?.groups?.get(0)?.value
|
||||
?.groups?.get(1)?.value
|
||||
?: return emptyList()
|
||||
return json.parseAs<ResultDto<WrapperChapterDto>>().toSChapterList()
|
||||
}
|
||||
@ -155,7 +187,50 @@ abstract class GreenShit(
|
||||
|
||||
private val pageUrlSelector = "img.chakra-image"
|
||||
|
||||
override fun pageListParse(response: Response): List<Page> {
|
||||
override fun pageListRequest(chapter: SChapter): Request =
|
||||
when (contentOrigin) {
|
||||
ContentOrigin.Mobile -> pageListRequestMobile(chapter)
|
||||
else -> super.pageListRequest(chapter)
|
||||
}
|
||||
|
||||
private fun pageListRequestMobile(chapter: SChapter): Request {
|
||||
val pathSegment = chapter.url.replace("capitulo", "capitulo-app-token")
|
||||
val newHeaders = headers.newBuilder()
|
||||
.set("x-client-hash", generateToken(scanId, SECRET_KEY))
|
||||
.set("authorization", "Bearer $token")
|
||||
.build()
|
||||
return GET("$apiUrl$pathSegment", newHeaders)
|
||||
}
|
||||
|
||||
private fun generateToken(scanId: Long, secretKey: String): String {
|
||||
val timestamp = System.currentTimeMillis() / 1000
|
||||
val expiration = timestamp + 3600
|
||||
|
||||
val payload = buildJsonObject {
|
||||
put("scan_id", scanId)
|
||||
put("timestamp", timestamp)
|
||||
put("exp", expiration)
|
||||
}.toJsonString()
|
||||
|
||||
val hmac = Mac.getInstance("HmacSHA256")
|
||||
val secretKeySpec = SecretKeySpec(secretKey.toByteArray(), "HmacSHA256")
|
||||
hmac.init(secretKeySpec)
|
||||
val signatureBytes = hmac.doFinal(payload.toByteArray())
|
||||
val signature = signatureBytes.joinToString("") { "%02x".format(it) }
|
||||
|
||||
return Base64.encodeToString("$payload.$signature".toByteArray(), Base64.NO_WRAP)
|
||||
}
|
||||
|
||||
override fun pageListParse(response: Response): List<Page> =
|
||||
when (contentOrigin) {
|
||||
ContentOrigin.Mobile -> pageListParseMobile(response)
|
||||
else -> pageListParseWeb(response)
|
||||
}
|
||||
|
||||
private fun pageListParseMobile(response: Response): List<Page> =
|
||||
response.parseAs<ResultDto<ChapterPageDto>>().toPageList()
|
||||
|
||||
private fun pageListParseWeb(response: Response): List<Page> {
|
||||
val document = response.asJsoup()
|
||||
|
||||
pageListParse(document).takeIf(List<Page>::isNotEmpty)?.let { return it }
|
||||
@ -200,6 +275,57 @@ abstract class GreenShit(
|
||||
return GET(page.url, imageHeaders)
|
||||
}
|
||||
|
||||
// ============================= Login ========================================
|
||||
|
||||
private val credential: Credential by lazy {
|
||||
Credential(
|
||||
email = preferences.getString(USERNAME_PREF, "") as String,
|
||||
password = preferences.getString(PASSWORD_PREF, "") as String,
|
||||
)
|
||||
}
|
||||
|
||||
private fun Token.save(): Token {
|
||||
return this.also {
|
||||
preferences.edit()
|
||||
.putString(TOKEN_PREF, it.toJsonString())
|
||||
.apply()
|
||||
}
|
||||
}
|
||||
|
||||
private var _cache: Token? = null
|
||||
private val token: Token
|
||||
get() {
|
||||
if (_cache != null && _cache!!.isValid()) {
|
||||
return _cache!!
|
||||
}
|
||||
|
||||
val tokenValue = preferences.getString(TOKEN_PREF, Token().toJsonString())?.parseAs<Token>()
|
||||
if (tokenValue != null && tokenValue.isValid()) {
|
||||
return tokenValue.also { _cache = it }
|
||||
}
|
||||
|
||||
return credential.takeIf(Credential::isNotEmpty)?.let(::doLogin)?.let { response ->
|
||||
if (response.isSuccessful.not()) {
|
||||
Token.empty().save()
|
||||
throw IOException("Falha ao realizar o login")
|
||||
}
|
||||
val tokenDto = response.parseAs<ResultDto<TokenDto>>().results
|
||||
Token(tokenDto.value).also {
|
||||
_cache = it.save()
|
||||
}
|
||||
} ?: throw IOException("Adicione suas credenciais em Extensões > $name > Configurações")
|
||||
}
|
||||
|
||||
val loginClient = network.cloudflareClient
|
||||
|
||||
fun doLogin(credential: Credential): Response {
|
||||
val payload = buildJsonObject {
|
||||
put("usr_email", credential.email)
|
||||
put("usr_senha", credential.password)
|
||||
}.toJsonString().toRequestBody("application/json".toMediaType())
|
||||
return loginClient.newCall(POST("$apiUrl/me/login", headers, payload)).execute()
|
||||
}
|
||||
|
||||
// ============================= Interceptors =================================
|
||||
|
||||
private fun imageLocation(chain: Interceptor.Chain): Response {
|
||||
@ -224,52 +350,45 @@ abstract class GreenShit(
|
||||
// ============================= Settings ====================================
|
||||
|
||||
override fun setupPreferenceScreen(screen: PreferenceScreen) {
|
||||
val fields = listOf(
|
||||
EditTextPreference(screen.context).apply {
|
||||
key = BASE_URL_PREF
|
||||
title = BASE_URL_PREF_TITLE
|
||||
summary = URL_PREF_SUMMARY
|
||||
if (contentOrigin != ContentOrigin.Mobile) {
|
||||
return
|
||||
}
|
||||
|
||||
dialogTitle = BASE_URL_PREF_TITLE
|
||||
dialogMessage = "URL padrão:\n$defaultBaseUrl"
|
||||
val warning = "⚠️ Os dados inseridos nessa seção serão usados somente para realizar o login na fonte"
|
||||
val message = "Insira %s para prosseguir com o acesso aos recursos disponíveis na fonte"
|
||||
|
||||
setDefaultValue(defaultBaseUrl)
|
||||
},
|
||||
EditTextPreference(screen.context).apply {
|
||||
key = API_BASE_URL_PREF
|
||||
title = API_BASE_URL_PREF_TITLE
|
||||
summary = buildString {
|
||||
append("Se não souber como verificar a URL da API, ")
|
||||
append("busque suporte no Discord do repositório de extensões.")
|
||||
appendLine(URL_PREF_SUMMARY)
|
||||
append("\n⚠ A fonte não oferece suporte para essa extensão.")
|
||||
}
|
||||
EditTextPreference(screen.context).apply {
|
||||
key = USERNAME_PREF
|
||||
title = "📧 Email"
|
||||
summary = "Email de acesso"
|
||||
dialogMessage = buildString {
|
||||
appendLine(message.format("seu email"))
|
||||
append("\n$warning")
|
||||
}
|
||||
|
||||
dialogTitle = BASE_URL_PREF_TITLE
|
||||
dialogMessage = "URL da API padrão:\n$defaultApiUrl"
|
||||
setDefaultValue("")
|
||||
|
||||
setDefaultValue(defaultApiUrl)
|
||||
},
|
||||
setOnPreferenceChangeListener { _, _ ->
|
||||
Toast.makeText(screen.context, RESTART_APP_MESSAGE, Toast.LENGTH_LONG).show()
|
||||
true
|
||||
}
|
||||
}.also(screen::addPreference)
|
||||
|
||||
SwitchPreferenceCompat(screen.context).apply {
|
||||
key = DEFAULT_PREF
|
||||
title = "Redefinir configurações"
|
||||
summary = buildString {
|
||||
append("Habilite para redefinir as configurações padrões no próximo reinicialização da aplicação.")
|
||||
appendLine("Você pode limpar os dados da extensão em Configurações > Avançado:")
|
||||
appendLine("\t - Limpar os cookies")
|
||||
appendLine("\t - Limpar os dados da WebView")
|
||||
appendLine("\t - Limpar o banco de dados (Procure a '$name' e remova os dados)")
|
||||
}
|
||||
setDefaultValue(false)
|
||||
setOnPreferenceChangeListener { _, _ ->
|
||||
Toast.makeText(screen.context, RESTART_APP_MESSAGE, Toast.LENGTH_LONG).show()
|
||||
true
|
||||
}
|
||||
},
|
||||
)
|
||||
EditTextPreference(screen.context).apply {
|
||||
key = PASSWORD_PREF
|
||||
title = "🔑 Senha"
|
||||
summary = "Senha de acesso"
|
||||
dialogMessage = buildString {
|
||||
appendLine(message.format("sua senha"))
|
||||
append("\n$warning")
|
||||
}
|
||||
setDefaultValue("")
|
||||
|
||||
fields.forEach(screen::addPreference)
|
||||
setOnPreferenceChangeListener { _, _ ->
|
||||
Toast.makeText(screen.context, RESTART_APP_MESSAGE, Toast.LENGTH_LONG).show()
|
||||
true
|
||||
}
|
||||
}.also(screen::addPreference)
|
||||
}
|
||||
|
||||
// ============================= Utilities ====================================
|
||||
@ -299,24 +418,33 @@ abstract class GreenShit(
|
||||
return this
|
||||
}
|
||||
|
||||
enum class TargetAudience(val value: Int) {
|
||||
All(1),
|
||||
Shoujo(4),
|
||||
Yaoi(7),
|
||||
;
|
||||
|
||||
override fun toString() = value.toString()
|
||||
}
|
||||
|
||||
enum class ContentOrigin {
|
||||
Mobile,
|
||||
Web,
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val CDN_URL = "https://cdn.sussytoons.site"
|
||||
|
||||
val pageRegex = """capituloInicial.{3}(.*?)(\}\]\})""".toRegex()
|
||||
val POPULAR_JSON_REGEX = """(?:"dataTop":)(\{.+totalPaginas":\d+\})(?:.+"dataF)""".toRegex()
|
||||
val DETAILS_CHAPTER_REGEX = """(\{\"resultado.+"\}{3})""".toRegex()
|
||||
val DETAILS_CHAPTER_REGEX = """\{"obra":(\{.+"\}{3})""".toRegex()
|
||||
|
||||
private const val URL_PREF_SUMMARY = "Para uso temporário, se a extensão for atualizada, a alteração será perdida."
|
||||
|
||||
private const val BASE_URL_PREF = "overrideBaseUrl"
|
||||
private const val BASE_URL_PREF_TITLE = "Editar URL da fonte"
|
||||
private const val DEFAULT_BASE_URL_PREF = "defaultBaseUrl"
|
||||
private const val RESTART_APP_MESSAGE = "Reinicie o aplicativo para aplicar as alterações"
|
||||
|
||||
private const val API_BASE_URL_PREF = "overrideApiUrl"
|
||||
private const val API_BASE_URL_PREF_TITLE = "Editar URL da API da fonte"
|
||||
private const val API_DEFAULT_BASE_URL_PREF = "defaultApiUrl"
|
||||
private const val TOKEN_PREF = "greenShitToken"
|
||||
private const val USERNAME_PREF = "usernamePref"
|
||||
private const val PASSWORD_PREF = "passwordPref"
|
||||
|
||||
private const val DEFAULT_PREF = "defaultPref"
|
||||
private const val SECRET_KEY = "sua_chave_secreta_aqui_32_caracteres"
|
||||
}
|
||||
}
|
||||
|
@ -13,8 +13,41 @@ import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import org.jsoup.Jsoup
|
||||
import java.text.Normalizer
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Calendar
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
@Serializable
|
||||
class Token(
|
||||
val value: String = "",
|
||||
val updateAt: Long = Date().time,
|
||||
) {
|
||||
fun isValid() = value.isNotEmpty() && isExpired().not()
|
||||
|
||||
fun isExpired(): Boolean {
|
||||
val updateAtDate = Date(updateAt)
|
||||
val expiration = Calendar.getInstance().apply {
|
||||
time = updateAtDate
|
||||
add(Calendar.HOUR, 1)
|
||||
}
|
||||
return Date().after(expiration.time)
|
||||
}
|
||||
|
||||
override fun toString() = value
|
||||
|
||||
companion object {
|
||||
fun empty() = Token()
|
||||
}
|
||||
}
|
||||
|
||||
class Credential(
|
||||
val email: String = "",
|
||||
val password: String = "",
|
||||
) {
|
||||
fun isEmpty() = listOf(email, password).any(String::isBlank)
|
||||
fun isNotEmpty() = isEmpty().not()
|
||||
}
|
||||
|
||||
@Serializable
|
||||
class ResultDto<T>(
|
||||
@SerialName("pagina")
|
||||
@ -84,6 +117,12 @@ class ResultDto<T>(
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
class TokenDto(
|
||||
@SerialName("token")
|
||||
val value: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class MangaDto(
|
||||
@SerialName("obr_id")
|
||||
|
@ -2,4 +2,4 @@ plugins {
|
||||
id("lib-multisrc")
|
||||
}
|
||||
|
||||
baseVersionCode = 9
|
||||
baseVersionCode = 10
|
||||
|
@ -67,7 +67,19 @@ abstract class Iken(
|
||||
return MangasPage(entries, false)
|
||||
}
|
||||
|
||||
override fun latestUpdatesRequest(page: Int) = searchMangaRequest(page, "", getFilterList())
|
||||
override fun latestUpdatesRequest(page: Int): Request {
|
||||
val url = "$apiUrl/api/posts".toHttpUrl().newBuilder().apply {
|
||||
addQueryParameter("page", page.toString())
|
||||
addQueryParameter("perPage", perPage.toString())
|
||||
if (apiUrl.startsWith("https://api.", true)) {
|
||||
addQueryParameter("tag", "latestUpdate")
|
||||
addQueryParameter("isNovel", "false")
|
||||
}
|
||||
}.build()
|
||||
|
||||
return GET(url, headers)
|
||||
}
|
||||
|
||||
override fun latestUpdatesParse(response: Response) = searchMangaParse(response)
|
||||
|
||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
||||
|
@ -2,4 +2,4 @@ plugins {
|
||||
id("lib-multisrc")
|
||||
}
|
||||
|
||||
baseVersionCode = 21
|
||||
baseVersionCode = 22
|
||||
|
@ -43,6 +43,8 @@ open class Kemono(
|
||||
|
||||
private val apiPath = "api/v1"
|
||||
|
||||
private val dataPath = "data"
|
||||
|
||||
private val imgCdnUrl = baseUrl.replace("//", "//img.")
|
||||
|
||||
private var mangasCache: List<KemonoCreatorDto> = emptyList()
|
||||
@ -231,7 +233,7 @@ open class Kemono(
|
||||
|
||||
override fun pageListParse(response: Response): List<Page> {
|
||||
val postData: KemonoPostDtoWrapped = response.parseAs()
|
||||
return postData.post.images.mapIndexed { i, path -> Page(i, imageUrl = baseUrl + path) }
|
||||
return postData.post.images.mapIndexed { i, path -> Page(i, imageUrl = "$baseUrl/$dataPath$path") }
|
||||
}
|
||||
|
||||
override fun imageRequest(page: Page): Request {
|
||||
@ -242,7 +244,7 @@ open class Kemono(
|
||||
val index = imageUrl.indexOf('/', 8)
|
||||
val url = buildString {
|
||||
append(imageUrl, 0, index)
|
||||
append("/thumbnail/data")
|
||||
append("/thumbnail")
|
||||
append(imageUrl.substring(index))
|
||||
}
|
||||
return GET(url, headers)
|
||||
|
@ -2,4 +2,4 @@ plugins {
|
||||
id("lib-multisrc")
|
||||
}
|
||||
|
||||
baseVersionCode = 4
|
||||
baseVersionCode = 1
|
BIN
lib-multisrc/lectormonline/res/mipmap-hdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 2.7 KiB |
BIN
lib-multisrc/lectormonline/res/mipmap-mdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 1.6 KiB |
BIN
lib-multisrc/lectormonline/res/mipmap-xhdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 3.7 KiB |
BIN
lib-multisrc/lectormonline/res/mipmap-xxhdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 6.4 KiB |
BIN
lib-multisrc/lectormonline/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
After Width: | Height: | Size: 9.0 KiB |
@ -0,0 +1,193 @@
|
||||
package eu.kanade.tachiyomi.multisrc.lectormonline
|
||||
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.source.model.Filter
|
||||
import eu.kanade.tachiyomi.source.model.FilterList
|
||||
import eu.kanade.tachiyomi.source.model.MangasPage
|
||||
import eu.kanade.tachiyomi.source.model.Page
|
||||
import eu.kanade.tachiyomi.source.model.SChapter
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||
import eu.kanade.tachiyomi.util.asJsoup
|
||||
import keiyoushi.utils.parseAs
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import org.jsoup.nodes.Document
|
||||
import kotlin.concurrent.thread
|
||||
|
||||
open class LectorMOnline(
|
||||
override val name: String,
|
||||
override val baseUrl: String,
|
||||
override val lang: String,
|
||||
) : HttpSource() {
|
||||
|
||||
override val supportsLatest = true
|
||||
|
||||
override fun popularMangaRequest(page: Int): Request {
|
||||
return GET("$baseUrl/comics?sort=views&page=$page", headers)
|
||||
}
|
||||
|
||||
override fun popularMangaParse(response: Response): MangasPage = searchMangaParse(response)
|
||||
|
||||
override fun latestUpdatesRequest(page: Int): Request {
|
||||
return GET("$baseUrl/comics?page=$page", headers)
|
||||
}
|
||||
|
||||
override fun latestUpdatesParse(response: Response): MangasPage = searchMangaParse(response)
|
||||
|
||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
||||
val url = baseUrl.toHttpUrl().newBuilder()
|
||||
.addPathSegment("comics")
|
||||
.addQueryParameter("q", query)
|
||||
.addQueryParameter("page", page.toString())
|
||||
|
||||
filters.forEach { filter ->
|
||||
when (filter) {
|
||||
is SortByFilter -> {
|
||||
if (filter.selected == "views") {
|
||||
url.addQueryParameter("sort", "views")
|
||||
}
|
||||
if (filter.state!!.ascending) {
|
||||
url.addQueryParameter("isDesc", "false")
|
||||
}
|
||||
}
|
||||
is GenreFilter -> {
|
||||
val selectedGenre = filter.toUriPart()
|
||||
if (selectedGenre.isNotEmpty()) {
|
||||
return GET("$baseUrl/genres/$selectedGenre?page=$page", headers)
|
||||
}
|
||||
}
|
||||
else -> { }
|
||||
}
|
||||
}
|
||||
|
||||
return GET(url.build(), headers)
|
||||
}
|
||||
|
||||
override fun searchMangaParse(response: Response): MangasPage {
|
||||
val document = response.asJsoup()
|
||||
if (response.request.url.pathSegments[0] == "genres") {
|
||||
return searchMangaGenreParse(document)
|
||||
}
|
||||
val script = document.select("script:containsData(self.__next_f.push)").joinToString { it.data() }
|
||||
val jsonData = COMICS_LIST_REGEX.find(script)?.groupValues?.get(1)?.unescape()
|
||||
?: throw Exception("No se pudo encontrar la lista de cómics")
|
||||
val data = jsonData.parseAs<ComicListDataDto>()
|
||||
return MangasPage(data.comics.map { it.toSManga() }, data.hasNextPage())
|
||||
}
|
||||
|
||||
private fun searchMangaGenreParse(document: Document): MangasPage {
|
||||
val mangas = document.select("div.grid.relative > a.group.relative").map { element ->
|
||||
SManga.create().apply {
|
||||
setUrlWithoutDomain(element.attr("href").substringAfter("/comics/").substringBefore("?"))
|
||||
title = element.selectFirst("h3")!!.text()
|
||||
thumbnail_url = element.selectFirst("img")?.attr("abs:src")
|
||||
}
|
||||
}
|
||||
val hasNextPage = document.selectFirst("div.flex.items-center > a:has(> svg):last-child:not(.pointer-events-none)") != null
|
||||
return MangasPage(mangas, hasNextPage)
|
||||
}
|
||||
|
||||
override fun getMangaUrl(manga: SManga) = "$baseUrl/comics/${manga.url}"
|
||||
|
||||
override fun mangaDetailsRequest(manga: SManga): Request {
|
||||
return GET("$baseUrl/api/app/comic/${manga.url}", headers)
|
||||
}
|
||||
|
||||
override fun mangaDetailsParse(response: Response): SManga {
|
||||
return response.parseAs<ComicDto>().toSMangaDetails()
|
||||
}
|
||||
|
||||
override fun getChapterUrl(chapter: SChapter): String {
|
||||
val mangaSlug = chapter.url.substringBefore("/")
|
||||
val chapterNumber = chapter.url.substringAfter("/")
|
||||
return "$baseUrl/comics/$mangaSlug/chapters/$chapterNumber"
|
||||
}
|
||||
|
||||
override fun chapterListRequest(manga: SManga) = mangaDetailsRequest(manga)
|
||||
|
||||
override fun chapterListParse(response: Response): List<SChapter> {
|
||||
return response.parseAs<ComicDto>().getChapters()
|
||||
}
|
||||
|
||||
override fun pageListRequest(chapter: SChapter): Request {
|
||||
val mangaSlug = chapter.url.substringBefore("/")
|
||||
val chapterNumber = chapter.url.substringAfter("/")
|
||||
return GET("$baseUrl/api/app/comic/$mangaSlug/chapter/$chapterNumber", headers)
|
||||
}
|
||||
|
||||
override fun pageListParse(response: Response): List<Page> {
|
||||
val data = response.parseAs<ChapterPagesDataDto>()
|
||||
return data.chapter.urlImagesChapter.mapIndexed { index, image ->
|
||||
Page(index, imageUrl = image)
|
||||
}
|
||||
}
|
||||
|
||||
private var genresList: List<Pair<String, String>> = emptyList()
|
||||
private var fetchFiltersAttempts = 0
|
||||
private var filtersState = FiltersState.NOT_FETCHED
|
||||
|
||||
private fun fetchFilters() {
|
||||
if (filtersState != FiltersState.NOT_FETCHED || fetchFiltersAttempts >= 3) return
|
||||
filtersState = FiltersState.FETCHING
|
||||
fetchFiltersAttempts++
|
||||
thread {
|
||||
try {
|
||||
val response = client.newCall(GET("$baseUrl/api/app/genres", headers)).execute()
|
||||
val filters = response.parseAs<GenreListDto>()
|
||||
|
||||
genresList = filters.genres.map { genre -> genre.name.lowercase().replaceFirstChar { it.uppercase() } to genre.name }
|
||||
|
||||
filtersState = FiltersState.FETCHED
|
||||
} catch (_: Throwable) {
|
||||
filtersState = FiltersState.NOT_FETCHED
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun getFilterList(): FilterList {
|
||||
fetchFilters()
|
||||
|
||||
val filters = mutableListOf<Filter<*>>(
|
||||
Filter.Header("El filtro por género no funciona con los demas filtros"),
|
||||
Filter.Separator(),
|
||||
SortByFilter(
|
||||
"Ordenar por",
|
||||
listOf(
|
||||
SortProperty("Más vistos", "views"),
|
||||
SortProperty("Más recientes", "created_at"),
|
||||
),
|
||||
1,
|
||||
),
|
||||
)
|
||||
|
||||
filters += if (filtersState == FiltersState.FETCHED) {
|
||||
listOf(
|
||||
Filter.Separator(),
|
||||
Filter.Header("Filtrar por género"),
|
||||
GenreFilter(genresList),
|
||||
)
|
||||
} else {
|
||||
listOf(
|
||||
Filter.Separator(),
|
||||
Filter.Header("Presione 'Reiniciar' para intentar cargar los filtros"),
|
||||
)
|
||||
}
|
||||
|
||||
return FilterList(filters)
|
||||
}
|
||||
|
||||
private enum class FiltersState { NOT_FETCHED, FETCHING, FETCHED }
|
||||
|
||||
override fun imageUrlParse(response: Response): String = throw UnsupportedOperationException()
|
||||
|
||||
private fun String.unescape(): String {
|
||||
return UNESCAPE_REGEX.replace(this, "$1")
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val UNESCAPE_REGEX = """\\(.)""".toRegex()
|
||||
private val COMICS_LIST_REGEX = """\\"comicsData\\":(\{.*?\}),\\"searchParams""".toRegex()
|
||||
}
|
||||
}
|
@ -0,0 +1,93 @@
|
||||
package eu.kanade.tachiyomi.multisrc.lectormonline
|
||||
|
||||
import eu.kanade.tachiyomi.source.model.SChapter
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import keiyoushi.utils.tryParse
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
|
||||
@Serializable
|
||||
class ComicListDataDto(
|
||||
val comics: List<ComicDto>,
|
||||
private val page: Int,
|
||||
private val totalPages: Int,
|
||||
) {
|
||||
fun hasNextPage() = page < totalPages
|
||||
}
|
||||
|
||||
@Serializable
|
||||
class ComicDto(
|
||||
private val slug: String,
|
||||
private val name: String,
|
||||
private val state: String?,
|
||||
private val urlCover: String,
|
||||
private val description: String?,
|
||||
private val author: String?,
|
||||
private val chapters: List<ChapterDto> = emptyList(),
|
||||
) {
|
||||
fun toSManga() = SManga.create().apply {
|
||||
url = slug
|
||||
title = name.substringBeforeLast("-").trim()
|
||||
thumbnail_url = urlCover
|
||||
status = state.parseStatus()
|
||||
}
|
||||
|
||||
fun toSMangaDetails() = SManga.create().apply {
|
||||
url = slug
|
||||
title = name.substringBeforeLast("-").trim()
|
||||
thumbnail_url = urlCover
|
||||
description = this@ComicDto.description
|
||||
status = state.parseStatus()
|
||||
author = this@ComicDto.author
|
||||
}
|
||||
|
||||
fun getChapters(): List<SChapter> {
|
||||
return chapters.map { it.toSChapter(slug) }
|
||||
}
|
||||
|
||||
private fun String?.parseStatus(): Int {
|
||||
return when (this?.lowercase()) {
|
||||
"ongoing" -> SManga.ONGOING
|
||||
else -> SManga.UNKNOWN
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.ROOT).apply {
|
||||
timeZone = TimeZone.getTimeZone("UTC")
|
||||
}
|
||||
|
||||
@Serializable
|
||||
class ChapterDto(
|
||||
private val number: JsonPrimitive,
|
||||
private val createdAt: String,
|
||||
) {
|
||||
fun toSChapter(mangaSlug: String) = SChapter.create().apply {
|
||||
url = "$mangaSlug/$number"
|
||||
name = "Capítulo $number"
|
||||
date_upload = dateFormat.tryParse(createdAt)
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
class ChapterPagesDataDto(
|
||||
val chapter: ChapterPagesDto,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class ChapterPagesDto(
|
||||
val urlImagesChapter: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class GenreListDto(
|
||||
val genres: List<GenreDto>,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class GenreDto(
|
||||
val name: String,
|
||||
)
|
@ -0,0 +1,29 @@
|
||||
package eu.kanade.tachiyomi.multisrc.lectormonline
|
||||
|
||||
import eu.kanade.tachiyomi.source.model.Filter
|
||||
|
||||
class SortByFilter(title: String, private val sortProperties: List<SortProperty>, defaultIndex: Int) : Filter.Sort(
|
||||
title,
|
||||
sortProperties.map { it.name }.toTypedArray(),
|
||||
Selection(defaultIndex, ascending = false),
|
||||
) {
|
||||
val selected: String
|
||||
get() = sortProperties[state!!.index].value
|
||||
}
|
||||
|
||||
class SortProperty(val name: String, val value: String) {
|
||||
override fun toString(): String = name
|
||||
}
|
||||
|
||||
class GenreFilter(genres: List<Pair<String, String>>) : UriPartFilter(
|
||||
"Género",
|
||||
arrayOf(
|
||||
Pair("Todos", ""),
|
||||
*genres.toTypedArray(),
|
||||
),
|
||||
)
|
||||
|
||||
open class UriPartFilter(displayName: String, private val vals: Array<Pair<String, String>>) :
|
||||
Filter.Select<String>(displayName, vals.map { it.first }.toTypedArray()) {
|
||||
fun toUriPart() = vals[state].second
|
||||
}
|
@ -2,4 +2,4 @@ plugins {
|
||||
id("lib-multisrc")
|
||||
}
|
||||
|
||||
baseVersionCode = 18
|
||||
baseVersionCode = 19
|
||||
|
@ -39,7 +39,22 @@ abstract class MadTheme(
|
||||
|
||||
override val client: OkHttpClient = network.cloudflareClient.newBuilder()
|
||||
.rateLimit(1, 1, TimeUnit.SECONDS)
|
||||
.build()
|
||||
.addInterceptor { chain ->
|
||||
val request = chain.request()
|
||||
val url = request.url
|
||||
val response = chain.proceed(request)
|
||||
if (!response.isSuccessful && url.fragment == "image-request") {
|
||||
response.close()
|
||||
val newUrl = url.newBuilder()
|
||||
.host("sb.mbcdn.xyz")
|
||||
.encodedPath(url.encodedPath.replaceFirst("/res/", "/"))
|
||||
.fragment(null)
|
||||
.build()
|
||||
|
||||
return@addInterceptor chain.proceed(request.newBuilder().url(newUrl).build())
|
||||
}
|
||||
response
|
||||
}.build()
|
||||
|
||||
protected open val useLegacyApi = false
|
||||
|
||||
@ -329,6 +344,10 @@ abstract class MadTheme(
|
||||
}
|
||||
}
|
||||
|
||||
override fun imageRequest(page: Page): Request {
|
||||
return GET("${page.imageUrl}#image-request", headers)
|
||||
}
|
||||
|
||||
override fun imageUrlParse(document: Document): String =
|
||||
throw UnsupportedOperationException()
|
||||
|
||||
|
@ -0,0 +1,77 @@
|
||||
package eu.kanade.tachiyomi.multisrc.mccms
|
||||
|
||||
object Intl {
|
||||
var lang = "zh"
|
||||
|
||||
val sort
|
||||
get() = when (lang) {
|
||||
"zh" -> "排序"
|
||||
else -> "Sort by"
|
||||
}
|
||||
|
||||
val popular
|
||||
get() = when (lang) {
|
||||
"zh" -> "热门人气"
|
||||
else -> "Popular"
|
||||
}
|
||||
|
||||
val latest
|
||||
get() = when (lang) {
|
||||
"zh" -> "更新时间"
|
||||
else -> "Latest"
|
||||
}
|
||||
|
||||
val score
|
||||
get() = when (lang) {
|
||||
"zh" -> "评分"
|
||||
else -> "Score"
|
||||
}
|
||||
|
||||
val status
|
||||
get() = when (lang) {
|
||||
"zh" -> "进度"
|
||||
else -> "Status"
|
||||
}
|
||||
|
||||
val all
|
||||
get() = when (lang) {
|
||||
"zh" -> "全部"
|
||||
else -> "All"
|
||||
}
|
||||
|
||||
val ongoing
|
||||
get() = when (lang) {
|
||||
"zh" -> "连载"
|
||||
else -> "Ongoing"
|
||||
}
|
||||
|
||||
val completed
|
||||
get() = when (lang) {
|
||||
"zh" -> "完结"
|
||||
else -> "Completed"
|
||||
}
|
||||
|
||||
val genreWeb
|
||||
get() = when (lang) {
|
||||
"zh" -> "标签"
|
||||
else -> "Genre"
|
||||
}
|
||||
|
||||
val genreApi
|
||||
get() = when (lang) {
|
||||
"zh" -> "标签(搜索文本时无效)"
|
||||
else -> "Genre (ignored for text search)"
|
||||
}
|
||||
|
||||
val categoryWeb
|
||||
get() = when (lang) {
|
||||
"zh" -> "分类筛选(搜索时无效)"
|
||||
else -> "Category filters (ignored for text search)"
|
||||
}
|
||||
|
||||
val tapReset
|
||||
get() = when (lang) {
|
||||
"zh" -> "点击“重置”尝试刷新标签分类"
|
||||
else -> "Tap 'Reset' to load genres"
|
||||
}
|
||||
}
|
@ -9,15 +9,13 @@ import eu.kanade.tachiyomi.source.model.Page
|
||||
import eu.kanade.tachiyomi.source.model.SChapter
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.decodeFromStream
|
||||
import okhttp3.Headers
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import rx.Observable
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.net.URLEncoder
|
||||
import keiyoushi.utils.parseAs as parseAsRaw
|
||||
|
||||
/**
|
||||
* 漫城CMS http://mccms.cn/
|
||||
@ -25,16 +23,26 @@ import java.net.URLEncoder
|
||||
open class MCCMS(
|
||||
override val name: String,
|
||||
override val baseUrl: String,
|
||||
override val lang: String = "zh",
|
||||
final override val lang: String = "zh",
|
||||
private val config: MCCMSConfig = MCCMSConfig(),
|
||||
) : HttpSource() {
|
||||
override val supportsLatest = true
|
||||
override val supportsLatest get() = true
|
||||
|
||||
private val json: Json by injectLazy()
|
||||
init {
|
||||
Intl.lang = lang
|
||||
}
|
||||
|
||||
override val client by lazy {
|
||||
network.cloudflareClient.newBuilder()
|
||||
.rateLimitHost(baseUrl.toHttpUrl(), 2)
|
||||
.addInterceptor { chain -> // for thumbnail requests
|
||||
var request = chain.request()
|
||||
val referer = request.header("Referer")
|
||||
if (referer != null && !request.url.toString().startsWith(referer)) {
|
||||
request = request.newBuilder().removeHeader("Referer").build()
|
||||
}
|
||||
chain.proceed(request)
|
||||
}
|
||||
.build()
|
||||
}
|
||||
|
||||
@ -42,12 +50,14 @@ open class MCCMS(
|
||||
.add("User-Agent", System.getProperty("http.agent")!!)
|
||||
.add("Referer", baseUrl)
|
||||
|
||||
protected open fun SManga.cleanup(): SManga = this
|
||||
|
||||
override fun popularMangaRequest(page: Int): Request =
|
||||
GET("$baseUrl/api/data/comic?page=$page&size=$PAGE_SIZE&order=hits", headers)
|
||||
|
||||
override fun popularMangaParse(response: Response): MangasPage {
|
||||
val list: List<MangaDto> = response.parseAs()
|
||||
return MangasPage(list.map { it.toSManga() }, list.size >= PAGE_SIZE)
|
||||
return MangasPage(list.map { it.toSManga().cleanup() }, list.size >= PAGE_SIZE)
|
||||
}
|
||||
|
||||
override fun latestUpdatesRequest(page: Int): Request =
|
||||
@ -86,7 +96,7 @@ open class MCCMS(
|
||||
return client.newCall(GET(url, headers))
|
||||
.asObservableSuccess().map { response ->
|
||||
val list = response.parseAs<List<MangaDto>>()
|
||||
list.first { it.cleanUrl == mangaUrl }.toSManga()
|
||||
list.first { it.cleanUrl == mangaUrl }.toSManga().cleanup()
|
||||
}
|
||||
}
|
||||
|
||||
@ -120,9 +130,7 @@ open class MCCMS(
|
||||
// Don't send referer
|
||||
override fun imageRequest(page: Page) = GET(page.imageUrl!!, pcHeaders)
|
||||
|
||||
private inline fun <reified T> Response.parseAs(): T = use {
|
||||
json.decodeFromStream<ResultDto<T>>(it.body.byteStream()).data
|
||||
}
|
||||
private inline fun <reified T> Response.parseAs(): T = parseAsRaw<ResultDto<T>>().data
|
||||
|
||||
override fun getFilterList(): FilterList {
|
||||
val genreData = config.genreData.also { it.fetchGenres(this) }
|
||||
|
@ -12,6 +12,8 @@ val pcHeaders = Headers.headersOf("User-Agent", "Mozilla/5.0 (Windows NT 10.0; W
|
||||
|
||||
fun String.removePathPrefix() = removePrefix("/index.php")
|
||||
|
||||
fun String.mobileUrl() = replace("//www.", "//m.")
|
||||
|
||||
open class MCCMSConfig(
|
||||
hasCategoryPage: Boolean = true,
|
||||
val textSearchOnlyPageOne: Boolean = false,
|
||||
|
@ -26,11 +26,11 @@ data class MangaDto(
|
||||
title = Entities.unescape(name)
|
||||
author = Entities.unescape(this@MangaDto.author)
|
||||
description = Entities.unescape(content)
|
||||
genre = tags.joinToString()
|
||||
status = when {
|
||||
'连' in serialize || isUpdating(addtime) -> SManga.ONGOING
|
||||
'完' in serialize -> SManga.COMPLETED
|
||||
else -> SManga.UNKNOWN
|
||||
genre = Entities.unescape(tags.joinToString())
|
||||
status = when (serialize) {
|
||||
"连载", "連載中", "En cours", "OnGoing" -> SManga.ONGOING
|
||||
"完结", "已完結", "Terminé", "Complete", "Complété" -> SManga.COMPLETED
|
||||
else -> if (isUpdating(addtime)) SManga.ONGOING else SManga.UNKNOWN
|
||||
}
|
||||
thumbnail_url = "$pic#$id"
|
||||
initialized = true
|
||||
|
@ -18,32 +18,31 @@ open class MCCMSFilter(
|
||||
val query get() = queries[state]
|
||||
}
|
||||
|
||||
class SortFilter : MCCMSFilter("排序", SORT_NAMES, SORT_QUERIES)
|
||||
class WebSortFilter : MCCMSFilter("排序", SORT_NAMES, SORT_QUERIES_WEB)
|
||||
class SortFilter : MCCMSFilter(Intl.sort, SORT_NAMES, SORT_QUERIES)
|
||||
class WebSortFilter : MCCMSFilter(Intl.sort, SORT_NAMES, SORT_QUERIES_WEB)
|
||||
|
||||
private val SORT_NAMES = arrayOf("热门人气", "更新时间", "评分")
|
||||
private val SORT_QUERIES = arrayOf("order=hits", "order=addtime", "order=score")
|
||||
private val SORT_QUERIES_WEB = arrayOf("order/hits", "order/addtime", "order/score")
|
||||
private val SORT_NAMES get() = arrayOf(Intl.popular, Intl.latest, Intl.score)
|
||||
private val SORT_QUERIES get() = arrayOf("order=hits", "order=addtime", "order=score")
|
||||
private val SORT_QUERIES_WEB get() = arrayOf("order/hits", "order/addtime", "order/score")
|
||||
|
||||
class StatusFilter : MCCMSFilter("进度", STATUS_NAMES, STATUS_QUERIES)
|
||||
class WebStatusFilter : MCCMSFilter("进度", STATUS_NAMES, STATUS_QUERIES_WEB)
|
||||
class StatusFilter : MCCMSFilter(Intl.status, STATUS_NAMES, STATUS_QUERIES)
|
||||
class WebStatusFilter : MCCMSFilter(Intl.status, STATUS_NAMES, STATUS_QUERIES_WEB)
|
||||
|
||||
private val STATUS_NAMES = arrayOf("全部", "连载", "完结")
|
||||
private val STATUS_QUERIES = arrayOf("", "serialize=连载", "serialize=完结")
|
||||
private val STATUS_QUERIES_WEB = arrayOf("", "finish/1", "finish/2")
|
||||
private val STATUS_NAMES get() = arrayOf(Intl.all, Intl.ongoing, Intl.completed)
|
||||
private val STATUS_QUERIES get() = arrayOf("", "serialize=连载", "serialize=完结")
|
||||
private val STATUS_QUERIES_WEB get() = arrayOf("", "finish/1", "finish/2")
|
||||
|
||||
class GenreFilter(private val values: Array<String>, private val queries: Array<String>) {
|
||||
|
||||
private val apiQueries get() = queries.run {
|
||||
Array(size) { i -> "type[tags]=" + this[i] }
|
||||
Array(size) { i -> "type[tags]=" + this[i] }.apply { this[0] = "" }
|
||||
}
|
||||
|
||||
private val webQueries get() = queries.run {
|
||||
Array(size) { i -> "tags/" + this[i] }
|
||||
Array(size) { i -> "tags/" + this[i] }.apply { this[0] = "" }
|
||||
}
|
||||
|
||||
val filter get() = MCCMSFilter("标签(搜索文本时无效)", values, apiQueries, isTypeQuery = true)
|
||||
val webFilter get() = MCCMSFilter("标签", values, webQueries, isTypeQuery = true)
|
||||
val filter get() = MCCMSFilter(Intl.genreApi, values, apiQueries, isTypeQuery = true)
|
||||
val webFilter get() = MCCMSFilter(Intl.genreWeb, values, webQueries, isTypeQuery = true)
|
||||
}
|
||||
|
||||
class GenreData(hasCategoryPage: Boolean) {
|
||||
@ -55,7 +54,12 @@ class GenreData(hasCategoryPage: Boolean) {
|
||||
status = FETCHING
|
||||
thread {
|
||||
try {
|
||||
val response = source.client.newCall(GET("${source.baseUrl}/category/", pcHeaders)).execute()
|
||||
val request = when (source) {
|
||||
// Web sources parse listings whenever possible. They call this function for mobile pages.
|
||||
is MCCMSWeb -> GET("${source.baseUrl.mobileUrl()}/category/", source.headers)
|
||||
else -> GET("${source.baseUrl}/category/", pcHeaders)
|
||||
}
|
||||
val response = source.client.newCall(request).execute()
|
||||
parseGenres(response.asJsoup(), this)
|
||||
} catch (e: Exception) {
|
||||
status = NOT_FETCHED
|
||||
@ -74,7 +78,7 @@ class GenreData(hasCategoryPage: Boolean) {
|
||||
|
||||
internal fun parseGenres(document: Document, genreData: GenreData) {
|
||||
if (genreData.status == GenreData.FETCHED || genreData.status == GenreData.NO_DATA) return
|
||||
val box = document.selectFirst(".cate-selector, .cy_list_l")
|
||||
val box = document.selectFirst(".cate-selector, .cy_list_l, .ticai, .stui-screen__list")
|
||||
if (box == null || "/tags/" in document.location()) {
|
||||
genreData.status = GenreData.NOT_FETCHED
|
||||
return
|
||||
@ -85,7 +89,7 @@ internal fun parseGenres(document: Document, genreData: GenreData) {
|
||||
return
|
||||
}
|
||||
val result = buildList(genres.size + 1) {
|
||||
add(Pair("全部", ""))
|
||||
add(Pair(Intl.all, ""))
|
||||
genres.mapTo(this) {
|
||||
val tagId = it.attr("href").substringAfterLast('/')
|
||||
Pair(it.text(), tagId)
|
||||
@ -100,14 +104,14 @@ internal fun parseGenres(document: Document, genreData: GenreData) {
|
||||
|
||||
internal fun getFilters(genreData: GenreData): FilterList {
|
||||
val list = buildList(4) {
|
||||
add(StatusFilter())
|
||||
if (Intl.lang == "zh") add(StatusFilter())
|
||||
add(SortFilter())
|
||||
if (genreData.status == GenreData.NO_DATA) return@buildList
|
||||
add(Filter.Separator())
|
||||
if (genreData.status == GenreData.FETCHED) {
|
||||
add(genreData.genreFilter.filter)
|
||||
} else {
|
||||
add(Filter.Header("点击“重置”尝试刷新标签分类"))
|
||||
add(Filter.Header(Intl.tapReset))
|
||||
}
|
||||
}
|
||||
return FilterList(list)
|
||||
@ -115,13 +119,13 @@ internal fun getFilters(genreData: GenreData): FilterList {
|
||||
|
||||
internal fun getWebFilters(genreData: GenreData): FilterList {
|
||||
val list = buildList(4) {
|
||||
add(Filter.Header("分类筛选(搜索时无效)"))
|
||||
add(Filter.Header(Intl.categoryWeb))
|
||||
add(WebStatusFilter())
|
||||
add(WebSortFilter())
|
||||
when (genreData.status) {
|
||||
GenreData.NO_DATA -> return@buildList
|
||||
GenreData.FETCHED -> add(genreData.genreFilter.webFilter)
|
||||
else -> add(Filter.Header("点击“重置”尝试刷新标签分类"))
|
||||
else -> add(Filter.Header(Intl.tapReset))
|
||||
}
|
||||
}
|
||||
return FilterList(list)
|
||||
|
@ -13,39 +13,45 @@ import okhttp3.Headers
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okio.IOException
|
||||
import org.jsoup.nodes.Document
|
||||
import org.jsoup.nodes.Element
|
||||
import org.jsoup.select.Evaluator
|
||||
import rx.Observable
|
||||
|
||||
open class MCCMSWeb(
|
||||
override val name: String,
|
||||
override val baseUrl: String,
|
||||
override val lang: String = "zh",
|
||||
private val config: MCCMSConfig = MCCMSConfig(),
|
||||
final override val lang: String = "zh",
|
||||
protected val config: MCCMSConfig = MCCMSConfig(),
|
||||
) : HttpSource() {
|
||||
override val supportsLatest get() = true
|
||||
|
||||
init {
|
||||
Intl.lang = lang
|
||||
}
|
||||
|
||||
override val client by lazy {
|
||||
network.cloudflareClient.newBuilder()
|
||||
.rateLimitHost(baseUrl.toHttpUrl(), 2)
|
||||
.addInterceptor { chain ->
|
||||
val response = chain.proceed(chain.request())
|
||||
if (response.request.url.encodedPath == "/err/comic") {
|
||||
throw IOException(response.body.string().substringBefore('\n'))
|
||||
}
|
||||
response
|
||||
}
|
||||
.build()
|
||||
}
|
||||
|
||||
override fun headersBuilder() = Headers.Builder()
|
||||
.add("User-Agent", System.getProperty("http.agent")!!)
|
||||
|
||||
private fun parseListing(document: Document): MangasPage {
|
||||
open fun parseListing(document: Document): MangasPage {
|
||||
parseGenres(document, config.genreData)
|
||||
val mangas = document.select(Evaluator.Class("common-comic-item")).map {
|
||||
SManga.create().apply {
|
||||
val titleElement = it.selectFirst(Evaluator.Class("comic__title"))!!.child(0)
|
||||
url = titleElement.attr("href").removePathPrefix()
|
||||
title = titleElement.ownText()
|
||||
thumbnail_url = it.selectFirst(Evaluator.Tag("img"))!!.attr("data-original")
|
||||
}
|
||||
}
|
||||
val mangas = document.select(simpleMangaSelector()).map(::simpleMangaFromElement)
|
||||
val hasNextPage = run { // default pagination
|
||||
val buttons = document.selectFirst(Evaluator.Id("Pagination"))!!.select(Evaluator.Tag("a"))
|
||||
val buttons = document.selectFirst("#Pagination, .NewPages")!!.select(Evaluator.Tag("a"))
|
||||
val count = buttons.size
|
||||
// Next page != Last page
|
||||
buttons[count - 1].attr("href") != buttons[count - 2].attr("href")
|
||||
@ -53,6 +59,15 @@ open class MCCMSWeb(
|
||||
return MangasPage(mangas, hasNextPage)
|
||||
}
|
||||
|
||||
open fun simpleMangaSelector() = ".common-comic-item"
|
||||
|
||||
open fun simpleMangaFromElement(element: Element) = SManga.create().apply {
|
||||
val titleElement = element.selectFirst(Evaluator.Class("comic__title"))!!.child(0)
|
||||
url = titleElement.attr("href").removePathPrefix()
|
||||
title = titleElement.ownText()
|
||||
thumbnail_url = element.selectFirst(Evaluator.Tag("img"))!!.attr("data-original")
|
||||
}
|
||||
|
||||
override fun popularMangaRequest(page: Int) = GET("$baseUrl/category/order/hits/page/$page", pcHeaders)
|
||||
|
||||
override fun popularMangaParse(response: Response) = parseListing(response.asJsoup())
|
||||
@ -104,6 +119,8 @@ open class MCCMSWeb(
|
||||
return super.fetchMangaDetails(manga)
|
||||
}
|
||||
|
||||
override fun getMangaUrl(manga: SManga) = baseUrl.mobileUrl() + manga.url
|
||||
|
||||
override fun mangaDetailsRequest(manga: SManga) = GET(baseUrl + manga.url, pcHeaders)
|
||||
|
||||
override fun mangaDetailsParse(response: Response): SManga {
|
||||
@ -127,17 +144,23 @@ open class MCCMSWeb(
|
||||
override fun chapterListRequest(manga: SManga) = GET(baseUrl + manga.url, pcHeaders)
|
||||
|
||||
override fun chapterListParse(response: Response): List<SChapter> {
|
||||
return run {
|
||||
response.asJsoup().selectFirst(Evaluator.Class("chapter__list-box"))!!.children().map {
|
||||
return getDescendingChapters(
|
||||
response.asJsoup().select(chapterListSelector()).map {
|
||||
val link = it.child(0)
|
||||
SChapter.create().apply {
|
||||
url = link.attr("href").removePathPrefix()
|
||||
name = link.ownText()
|
||||
name = link.text()
|
||||
}
|
||||
}.asReversed()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
open fun chapterListSelector() = ".chapter__list-box > li"
|
||||
|
||||
open fun getDescendingChapters(chapters: List<SChapter>) = chapters.asReversed()
|
||||
|
||||
override fun getChapterUrl(chapter: SChapter) = baseUrl.mobileUrl() + chapter.url
|
||||
|
||||
override fun pageListRequest(chapter: SChapter): Request =
|
||||
GET(baseUrl + chapter.url, if (config.useMobilePageList) headers else pcHeaders)
|
||||
|
||||
|
9
lib-multisrc/mmlook/build.gradle.kts
Normal file
@ -0,0 +1,9 @@
|
||||
plugins {
|
||||
id("lib-multisrc")
|
||||
}
|
||||
|
||||
baseVersionCode = 1
|
||||
|
||||
dependencies {
|
||||
implementation(project(":lib:unpacker"))
|
||||
}
|
@ -0,0 +1,18 @@
|
||||
package eu.kanade.tachiyomi.multisrc.mmlook
|
||||
|
||||
import eu.kanade.tachiyomi.source.model.SChapter
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
class ResponseDto(val data: List<ChapterDto>)
|
||||
|
||||
@Serializable
|
||||
class ChapterDto(
|
||||
private val chapterid: String,
|
||||
private val chaptername: String,
|
||||
) {
|
||||
fun toSChapter(mangaId: String) = SChapter.create().apply {
|
||||
url = "$mangaId/$chapterid"
|
||||
name = chaptername
|
||||
}
|
||||
}
|
@ -0,0 +1,44 @@
|
||||
package eu.kanade.tachiyomi.multisrc.mmlook
|
||||
|
||||
import eu.kanade.tachiyomi.source.model.Filter
|
||||
|
||||
class Option(val name: String, val value: String)
|
||||
|
||||
open class SelectFilter(name: String, val options: Array<Option>) :
|
||||
Filter.Select<String>(name, Array(options.size) { options[it].name })
|
||||
|
||||
class RankingFilter : SelectFilter(
|
||||
"排行榜",
|
||||
arrayOf(
|
||||
Option("不查看", ""),
|
||||
Option("精品榜", "1"),
|
||||
Option("人气榜", "2"),
|
||||
Option("推荐榜", "3"),
|
||||
Option("黑马榜", "4"),
|
||||
Option("最近更新", "5"),
|
||||
Option("新漫画", "6"),
|
||||
),
|
||||
)
|
||||
|
||||
class CategoryFilter : SelectFilter(
|
||||
"分类",
|
||||
arrayOf(
|
||||
Option("全部", ""),
|
||||
Option("冒险", "1"),
|
||||
Option("热血", "2"),
|
||||
Option("都市", "3"),
|
||||
Option("玄幻", "4"),
|
||||
Option("悬疑", "5"),
|
||||
Option("耽美", "6"),
|
||||
Option("恋爱", "7"),
|
||||
Option("生活", "8"),
|
||||
Option("搞笑", "9"),
|
||||
Option("穿越", "10"),
|
||||
Option("修真", "11"),
|
||||
Option("后宫", "12"),
|
||||
Option("女主", "13"),
|
||||
Option("古风", "14"),
|
||||
Option("连载", "15"),
|
||||
Option("完结", "16"),
|
||||
),
|
||||
)
|
@ -0,0 +1,200 @@
|
||||
package eu.kanade.tachiyomi.multisrc.mmlook
|
||||
|
||||
import eu.kanade.tachiyomi.lib.unpacker.Unpacker
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.network.POST
|
||||
import eu.kanade.tachiyomi.source.model.Filter
|
||||
import eu.kanade.tachiyomi.source.model.FilterList
|
||||
import eu.kanade.tachiyomi.source.model.MangasPage
|
||||
import eu.kanade.tachiyomi.source.model.Page
|
||||
import eu.kanade.tachiyomi.source.model.SChapter
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||
import eu.kanade.tachiyomi.util.asJsoup
|
||||
import keiyoushi.utils.parseAs
|
||||
import okhttp3.FormBody
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
|
||||
// Rumanhua legacy preference:
|
||||
// const val APP_CUSTOMIZATION_URL = "APP_CUSTOMIZATION_URL"
|
||||
|
||||
/** 漫漫看 */
|
||||
open class MMLook(
|
||||
override val name: String,
|
||||
override val baseUrl: String,
|
||||
private val desktopUrl: String,
|
||||
private val useLegacyMangaUrl: Boolean,
|
||||
) : HttpSource() {
|
||||
override val lang: String get() = "zh"
|
||||
override val supportsLatest: Boolean get() = true
|
||||
|
||||
override val client = network.cloudflareClient.newBuilder()
|
||||
.followRedirects(false)
|
||||
.hostnameVerifier { _, _ -> true }
|
||||
.build()
|
||||
|
||||
private fun String.certificateWorkaround() = replace("https:", "http:")
|
||||
|
||||
private fun SManga.formatUrl() = apply { if (useLegacyMangaUrl) url = "/$url/" }
|
||||
|
||||
private fun rankingRequest(id: String) = GET("$desktopUrl/rank/$id", headers)
|
||||
|
||||
override fun popularMangaRequest(page: Int) = rankingRequest("1")
|
||||
|
||||
override fun popularMangaParse(response: Response): MangasPage {
|
||||
val entries = response.asJsoup().select(".likedata").map { element ->
|
||||
SManga.create().apply {
|
||||
url = element.select("a").attr("href").mustRemoveSurrounding("/", "/")
|
||||
title = element.selectFirst(".le-t")!!.text()
|
||||
author = element.selectFirst(".likeinfo > p")!!.text()
|
||||
.mustRemoveSurrounding("作者:", "")
|
||||
description = element.selectFirst(".le-j")!!.text()
|
||||
thumbnail_url = element.selectFirst("img")!!.attr("data-src")
|
||||
}.formatUrl()
|
||||
}
|
||||
return MangasPage(entries, false)
|
||||
}
|
||||
|
||||
override fun latestUpdatesRequest(page: Int) = rankingRequest("5")
|
||||
|
||||
override fun latestUpdatesParse(response: Response) = popularMangaParse(response)
|
||||
|
||||
override fun getFilterList() = FilterList(
|
||||
RankingFilter(),
|
||||
Filter.Separator(),
|
||||
Filter.Header("分类(搜索文本、查看排行榜时无效)"),
|
||||
CategoryFilter(),
|
||||
)
|
||||
|
||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
||||
if (query.isNotBlank()) {
|
||||
return POST(
|
||||
"$desktopUrl/s",
|
||||
headers,
|
||||
FormBody.Builder().add("k", query.take(12)).build(),
|
||||
)
|
||||
}
|
||||
for (filter in filters) {
|
||||
when (filter) {
|
||||
is RankingFilter -> if (filter.state > 0) {
|
||||
return rankingRequest(filter.options[filter.state].value)
|
||||
}
|
||||
|
||||
is CategoryFilter -> if (filter.state > 0) {
|
||||
val id = filter.options[filter.state].value
|
||||
return GET("$desktopUrl/sort/$id", headers)
|
||||
}
|
||||
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
return popularMangaRequest(page)
|
||||
}
|
||||
|
||||
override fun searchMangaParse(response: Response): MangasPage {
|
||||
if (response.request.method == "GET") return popularMangaParse(response)
|
||||
|
||||
val entries = response.asJsoup().select(".col-auto").map { element ->
|
||||
SManga.create().apply {
|
||||
url = element.selectFirst("a")!!.attr("href").mustRemoveSurrounding("/", "/")
|
||||
title = element.selectFirst(".e-title")!!.text()
|
||||
author = element.selectFirst(".tip")!!.text()
|
||||
thumbnail_url = element.selectFirst("img")!!.attr("data-src")
|
||||
}.formatUrl()
|
||||
}
|
||||
return MangasPage(entries, false)
|
||||
}
|
||||
|
||||
override fun getMangaUrl(manga: SManga): String {
|
||||
val id = manga.url.removeSurrounding("/")
|
||||
return "$baseUrl/$id/".certificateWorkaround()
|
||||
}
|
||||
|
||||
// Desktop page has consistent template and more initial chapters
|
||||
override fun mangaDetailsRequest(manga: SManga): Request {
|
||||
val id = manga.url.removeSurrounding("/")
|
||||
return GET("$desktopUrl/$id/", headers)
|
||||
}
|
||||
|
||||
override fun mangaDetailsParse(response: Response) = SManga.create().apply {
|
||||
val comicInfo = response.asJsoup().selectFirst(".comicInfo")!!
|
||||
thumbnail_url = comicInfo.selectFirst("img")!!.attr("data-src")
|
||||
|
||||
val container = comicInfo.selectFirst(".detinfo")!!
|
||||
title = container.selectFirst("h1")!!.text()
|
||||
|
||||
var updated = ""
|
||||
for (span in container.select("span")) {
|
||||
val text = span.ownText()
|
||||
val value = text.substring(4).trimStart()
|
||||
when (val key = text.substring(0, 4)) {
|
||||
"作 者:" -> author = value
|
||||
"更新时间" -> updated = "$text\n\n"
|
||||
"标 签:" -> genre = value.replace(" ", ", ")
|
||||
"状 态:" -> status = when (value) {
|
||||
"连载中" -> SManga.ONGOING
|
||||
"已完结" -> SManga.COMPLETED
|
||||
else -> SManga.UNKNOWN
|
||||
}
|
||||
|
||||
else -> throw Exception("Unknown field: $key")
|
||||
}
|
||||
}
|
||||
|
||||
description = updated + container.selectFirst(".content")!!.text()
|
||||
}
|
||||
|
||||
// Desktop page contains more initial chapters
|
||||
// "more chapter" request must be sent to the same domain
|
||||
override fun chapterListRequest(manga: SManga) = mangaDetailsRequest(manga)
|
||||
|
||||
override fun chapterListParse(response: Response): List<SChapter> {
|
||||
val container = response.asJsoup().selectFirst(".chapterlistload")!!
|
||||
val chapters = container.child(0).children().mapTo(ArrayList()) { element ->
|
||||
SChapter.create().apply {
|
||||
url = element.attr("href").mustRemoveSurrounding("/", ".html")
|
||||
name = element.text()
|
||||
}
|
||||
}
|
||||
if (container.selectFirst(".chaplist-more") != null) {
|
||||
val mangaId = response.request.url.pathSegments[0]
|
||||
val request = POST(
|
||||
"$desktopUrl/morechapter",
|
||||
headers,
|
||||
FormBody.Builder().addEncoded("id", mangaId).build(),
|
||||
)
|
||||
client.newCall(request).execute().parseAs<ResponseDto>().data
|
||||
.mapTo(chapters) { it.toSChapter(mangaId) }
|
||||
}
|
||||
return chapters
|
||||
}
|
||||
|
||||
private fun SChapter.fullUrl(): String {
|
||||
val url = this.url
|
||||
if (url.startsWith('/')) throw Exception("请刷新章节列表")
|
||||
return "$baseUrl/$url.html"
|
||||
}
|
||||
|
||||
override fun getChapterUrl(chapter: SChapter) = chapter.fullUrl().certificateWorkaround()
|
||||
|
||||
override fun pageListRequest(chapter: SChapter): Request = GET(chapter.fullUrl(), headers)
|
||||
|
||||
override fun pageListParse(response: Response): List<Page> {
|
||||
val document = response.asJsoup()
|
||||
val id = document.selectFirst(".readerContainer")!!.attr("data-id").toInt()
|
||||
return document.selectFirst("script:containsData(eval)")!!.data()
|
||||
.let(Unpacker::unpack)
|
||||
.mustRemoveSurrounding("var __c0rst96=\"", "\"")
|
||||
.let { decrypt(it, id) }
|
||||
.parseAs<List<String>>()
|
||||
.mapIndexed { i, imageUrl -> Page(i, imageUrl = imageUrl) }
|
||||
}
|
||||
|
||||
override fun imageUrlParse(response: Response) = throw UnsupportedOperationException()
|
||||
}
|
||||
|
||||
private fun String.mustRemoveSurrounding(prefix: String, suffix: String): String {
|
||||
check(startsWith(prefix) && endsWith(suffix)) { "string doesn't match $prefix[...]$suffix" }
|
||||
return substring(prefix.length, length - suffix.length)
|
||||
}
|
@ -0,0 +1,27 @@
|
||||
package eu.kanade.tachiyomi.multisrc.mmlook
|
||||
|
||||
import android.util.Base64
|
||||
import kotlin.experimental.xor
|
||||
|
||||
// all2.js?v=2.3
|
||||
fun decrypt(data: String, index: Int): String {
|
||||
val key = when (index) {
|
||||
0 -> "smkhy258"
|
||||
1 -> "smkd95fv"
|
||||
2 -> "md496952"
|
||||
3 -> "cdcsdwq"
|
||||
4 -> "vbfsa256"
|
||||
5 -> "cawf151c"
|
||||
6 -> "cd56cvda"
|
||||
7 -> "8kihnt9"
|
||||
8 -> "dso15tlo"
|
||||
9 -> "5ko6plhy"
|
||||
else -> throw Exception("Unknown index: $index")
|
||||
}.encodeToByteArray()
|
||||
val keyLength = key.size
|
||||
val bytes = Base64.decode(data, Base64.DEFAULT)
|
||||
for (i in bytes.indices) {
|
||||
bytes[i] = bytes[i] xor key[i % keyLength]
|
||||
}
|
||||
return String(Base64.decode(bytes, Base64.DEFAULT))
|
||||
}
|
@ -2,4 +2,4 @@ plugins {
|
||||
id("lib-multisrc")
|
||||
}
|
||||
|
||||
baseVersionCode = 3
|
||||
baseVersionCode = 4
|
||||
|
@ -95,7 +95,7 @@ abstract class PizzaReader(
|
||||
artist = comic.artist
|
||||
description = comic.description
|
||||
genre = comic.genres.joinToString(", ") { it.name }
|
||||
status = comic.status.toStatus()
|
||||
status = comic.status?.toStatus() ?: SManga.UNKNOWN
|
||||
thumbnail_url = comic.thumbnail
|
||||
}
|
||||
|
||||
|
@ -27,7 +27,7 @@ data class PizzaComicDto(
|
||||
val description: String = "",
|
||||
val genres: List<PizzaGenreDto> = emptyList(),
|
||||
@SerialName("last_chapter") val lastChapter: PizzaChapterDto? = null,
|
||||
val status: String = "",
|
||||
val status: String? = null,
|
||||
val title: String = "",
|
||||
val thumbnail: String = "",
|
||||
val url: String = "",
|
||||
|
@ -2,4 +2,4 @@ plugins {
|
||||
id("lib-multisrc")
|
||||
}
|
||||
|
||||
baseVersionCode = 4
|
||||
baseVersionCode = 5
|
||||
|
@ -431,7 +431,7 @@ abstract class Senkuro(
|
||||
|
||||
companion object {
|
||||
private const val offsetCount = 20
|
||||
private const val API_URL = "https://api.senkuro.com/graphql"
|
||||
private const val API_URL = "https://api.senkuro.me/graphql"
|
||||
private val senkuroExcludeGenres = listOf("hentai", "yaoi", "yuri", "shoujo_ai", "shounen_ai")
|
||||
private val JSON_MEDIA_TYPE = "application/json; charset=utf-8".toMediaTypeOrNull()
|
||||
}
|
||||
|
Before Width: | Height: | Size: 3.7 KiB |
Before Width: | Height: | Size: 1.9 KiB |
Before Width: | Height: | Size: 4.7 KiB |
Before Width: | Height: | Size: 9.2 KiB |
Before Width: | Height: | Size: 12 KiB |
@ -1,273 +0,0 @@
|
||||
package eu.kanade.tachiyomi.multisrc.slimereadtheme
|
||||
|
||||
import app.cash.quickjs.QuickJs
|
||||
import eu.kanade.tachiyomi.extension.pt.slimeread.dto.ChapterDto
|
||||
import eu.kanade.tachiyomi.extension.pt.slimeread.dto.LatestResponseDto
|
||||
import eu.kanade.tachiyomi.extension.pt.slimeread.dto.MangaInfoDto
|
||||
import eu.kanade.tachiyomi.extension.pt.slimeread.dto.PageListDto
|
||||
import eu.kanade.tachiyomi.extension.pt.slimeread.dto.PopularMangaDto
|
||||
import eu.kanade.tachiyomi.extension.pt.slimeread.dto.toSMangaList
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.network.asObservableSuccess
|
||||
import eu.kanade.tachiyomi.source.model.FilterList
|
||||
import eu.kanade.tachiyomi.source.model.MangasPage
|
||||
import eu.kanade.tachiyomi.source.model.Page
|
||||
import eu.kanade.tachiyomi.source.model.SChapter
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||
import eu.kanade.tachiyomi.util.asJsoup
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.decodeFromStream
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import rx.Observable
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
import kotlin.math.min
|
||||
|
||||
abstract class SlimeReadTheme(
|
||||
override val name: String,
|
||||
override val baseUrl: String,
|
||||
override val lang: String,
|
||||
private val scanId: String = "",
|
||||
) : HttpSource() {
|
||||
|
||||
protected open val apiUrl: String by lazy { getApiUrlFromPage() }
|
||||
|
||||
override val supportsLatest = true
|
||||
|
||||
override val client = network.cloudflareClient
|
||||
|
||||
private val json: Json by injectLazy()
|
||||
|
||||
protected open val urlInfix: String = "slimeread.com"
|
||||
|
||||
protected open fun getApiUrlFromPage(): String {
|
||||
val initClient = network.cloudflareClient
|
||||
val response = initClient.newCall(GET(baseUrl, headers)).execute()
|
||||
if (!response.isSuccessful) throw Exception("HTTP error ${response.code}")
|
||||
val document = response.asJsoup()
|
||||
val scriptUrl = document.selectFirst("script[src*=pages/_app]")?.attr("abs:src")
|
||||
?: throw Exception("Could not find script URL")
|
||||
val scriptResponse = initClient.newCall(GET(scriptUrl, headers)).execute()
|
||||
if (!scriptResponse.isSuccessful) throw Exception("HTTP error ${scriptResponse.code}")
|
||||
val script = scriptResponse.body.string()
|
||||
val apiUrl = FUNCTION_REGEX.find(script)?.let { result ->
|
||||
val varBlock = result.groupValues[1]
|
||||
val varUrlInfix = result.groupValues[2]
|
||||
|
||||
val block = """${varBlock.replace(varUrlInfix, "\"$urlInfix\"")}.toString()"""
|
||||
|
||||
try {
|
||||
QuickJs.create().use { it.evaluate(block) as String }
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
return apiUrl?.let { "https://$it" } ?: throw Exception("Could not find API URL")
|
||||
}
|
||||
|
||||
// ============================== Popular ===============================
|
||||
private var popularMangeCache: MangasPage? = null
|
||||
|
||||
override fun popularMangaRequest(page: Int): Request {
|
||||
val url = "$apiUrl/book_search?order=1&status=0".toHttpUrl().newBuilder()
|
||||
.addIfNotBlank("scan_id", scanId)
|
||||
.build()
|
||||
return GET(url, headers)
|
||||
}
|
||||
|
||||
override fun fetchPopularManga(page: Int): Observable<MangasPage> {
|
||||
popularMangeCache = popularMangeCache?.takeIf { page != 1 }
|
||||
?: super.fetchPopularManga(page).toBlocking().last()
|
||||
return pageableOf(page, popularMangeCache!!)
|
||||
}
|
||||
|
||||
override fun popularMangaParse(response: Response): MangasPage {
|
||||
val items = response.parseAs<List<PopularMangaDto>>()
|
||||
val mangaList = items.toSMangaList()
|
||||
return MangasPage(mangaList, mangaList.isNotEmpty())
|
||||
}
|
||||
|
||||
// =============================== Latest ===============================
|
||||
|
||||
override fun latestUpdatesRequest(page: Int): Request {
|
||||
val url = "$apiUrl/books?page=$page".toHttpUrl().newBuilder()
|
||||
.addIfNotBlank("scan_id", scanId)
|
||||
.build()
|
||||
return GET(url, headers)
|
||||
}
|
||||
|
||||
override fun latestUpdatesParse(response: Response): MangasPage {
|
||||
val dto = response.parseAs<LatestResponseDto>()
|
||||
val mangaList = dto.data.toSMangaList()
|
||||
val hasNextPage = dto.page < dto.pages
|
||||
return MangasPage(mangaList, hasNextPage)
|
||||
}
|
||||
|
||||
// =============================== Search ===============================
|
||||
|
||||
private var searchMangaCache: MangasPage? = null
|
||||
|
||||
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
|
||||
return if (query.startsWith(PREFIX_SEARCH)) { // URL intent handler
|
||||
val id = query.removePrefix(PREFIX_SEARCH)
|
||||
client.newCall(GET("$apiUrl/book/$id", headers))
|
||||
.asObservableSuccess()
|
||||
.map(::searchMangaByIdParse)
|
||||
} else {
|
||||
searchMangaCache = searchMangaCache?.takeIf { page != 1 }
|
||||
?: super.fetchSearchManga(page, query, filters).toBlocking().last()
|
||||
pageableOf(page, searchMangaCache!!)
|
||||
}
|
||||
}
|
||||
|
||||
private fun searchMangaByIdParse(response: Response): MangasPage {
|
||||
val details = mangaDetailsParse(response)
|
||||
return MangasPage(listOf(details), false)
|
||||
}
|
||||
|
||||
override fun getFilterList() = SlimeReadThemeFilters.FILTER_LIST
|
||||
|
||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
||||
val params = SlimeReadThemeFilters.getSearchParameters(filters)
|
||||
|
||||
val url = "$apiUrl/book_search".toHttpUrl().newBuilder()
|
||||
.addIfNotBlank("query", query)
|
||||
.addIfNotBlank("genre[]", params.genre)
|
||||
.addIfNotBlank("status", params.status)
|
||||
.addIfNotBlank("searchMethod", params.searchMethod)
|
||||
.addIfNotBlank("scan_id", scanId)
|
||||
.apply {
|
||||
params.categories.forEach {
|
||||
addQueryParameter("categories[]", it)
|
||||
}
|
||||
}.build()
|
||||
|
||||
return GET(url, headers)
|
||||
}
|
||||
|
||||
override fun searchMangaParse(response: Response) = popularMangaParse(response)
|
||||
|
||||
// =========================== Manga Details ============================
|
||||
override fun getMangaUrl(manga: SManga) = baseUrl + manga.url.replace("/book/", "/manga/")
|
||||
|
||||
override fun mangaDetailsRequest(manga: SManga) = GET(apiUrl + manga.url, headers)
|
||||
|
||||
override fun mangaDetailsParse(response: Response) = SManga.create().apply {
|
||||
val info = response.parseAs<MangaInfoDto>()
|
||||
thumbnail_url = info.thumbnail_url
|
||||
title = info.name
|
||||
description = info.description
|
||||
genre = info.categories.joinToString()
|
||||
url = "/book/${info.id}"
|
||||
status = when (info.status) {
|
||||
1 -> SManga.ONGOING
|
||||
2 -> SManga.COMPLETED
|
||||
3, 4 -> SManga.CANCELLED
|
||||
5 -> SManga.ON_HIATUS
|
||||
else -> SManga.UNKNOWN
|
||||
}
|
||||
}
|
||||
|
||||
// ============================== Chapters ==============================
|
||||
override fun chapterListRequest(manga: SManga) =
|
||||
GET("$apiUrl/book_cap_units_all?manga_id=${manga.url.substringAfterLast("/")}", headers)
|
||||
|
||||
override fun chapterListParse(response: Response): List<SChapter> {
|
||||
val items = response.parseAs<List<ChapterDto>>()
|
||||
val mangaId = response.request.url.queryParameter("manga_id")!!
|
||||
return items.map {
|
||||
SChapter.create().apply {
|
||||
name = "Cap " + parseChapterNumber(it.number)
|
||||
date_upload = parseChapterDate(it.updated_at)
|
||||
chapter_number = it.number
|
||||
scanlator = it.scan?.scan_name
|
||||
url = "/book_cap_units?manga_id=$mangaId&cap=${it.number}"
|
||||
}
|
||||
}.reversed()
|
||||
}
|
||||
|
||||
private fun parseChapterNumber(number: Float): String {
|
||||
val cap = number + 1F
|
||||
return "%.2f".format(cap)
|
||||
.let { if (cap < 10F) "0$it" else it }
|
||||
.replace(",00", "")
|
||||
.replace(",", ".")
|
||||
}
|
||||
|
||||
private fun parseChapterDate(date: String): Long {
|
||||
return try { dateFormat.parse(date)!!.time } catch (_: Exception) { 0L }
|
||||
}
|
||||
|
||||
override fun getChapterUrl(chapter: SChapter): String {
|
||||
val url = "$baseUrl${chapter.url}".toHttpUrl()
|
||||
val id = url.queryParameter("manga_id")!!
|
||||
val cap = url.queryParameter("cap")!!.toFloat()
|
||||
val num = parseChapterNumber(cap)
|
||||
return "$baseUrl/ler/$id/cap-$num"
|
||||
}
|
||||
|
||||
// =============================== Pages ================================
|
||||
override fun pageListRequest(chapter: SChapter) = GET(apiUrl + chapter.url, headers)
|
||||
|
||||
override fun pageListParse(response: Response): List<Page> {
|
||||
val body = response.body.string()
|
||||
val pages = if (body.startsWith("{")) {
|
||||
json.decodeFromString<Map<String, PageListDto>>(body).values.flatMap { it.pages }
|
||||
} else {
|
||||
json.decodeFromString<List<PageListDto>>(body).flatMap { it.pages }
|
||||
}
|
||||
|
||||
return pages.mapIndexed { index, item ->
|
||||
Page(index, "", item.url)
|
||||
}
|
||||
}
|
||||
|
||||
override fun imageUrlParse(response: Response): String {
|
||||
throw UnsupportedOperationException()
|
||||
}
|
||||
|
||||
// ============================= Utilities ==============================
|
||||
|
||||
/**
|
||||
* Handles a large manga list and returns a paginated response.
|
||||
* The app can't handle the large JSON list without pagination.
|
||||
*
|
||||
* @param page The page number to retrieve.
|
||||
* @param cache The cached manga page containing the full list of mangas.
|
||||
*/
|
||||
private fun pageableOf(page: Int, cache: MangasPage) = Observable.just(cache).map { mangaPage ->
|
||||
val mangas = mangaPage.mangas
|
||||
val pageSize = 15
|
||||
|
||||
val currentSlice = (page - 1) * pageSize
|
||||
|
||||
val startIndex = min(mangas.size, currentSlice)
|
||||
val endIndex = min(mangas.size, currentSlice + pageSize)
|
||||
|
||||
val slice = mangas.subList(startIndex, endIndex)
|
||||
|
||||
MangasPage(slice, hasNextPage = endIndex < mangas.size)
|
||||
}
|
||||
|
||||
private inline fun <reified T> Response.parseAs(): T = use {
|
||||
json.decodeFromStream(it.body.byteStream())
|
||||
}
|
||||
|
||||
private fun HttpUrl.Builder.addIfNotBlank(query: String, value: String): HttpUrl.Builder {
|
||||
if (value.isNotBlank()) addQueryParameter(query, value)
|
||||
return this
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val PREFIX_SEARCH = "id:"
|
||||
val FUNCTION_REGEX = """(\[""\.concat\("[^,]+,"\."\)\.concat\(([^,]+),":\d+"\)\])""".toRegex(RegexOption.DOT_MATCHES_ALL)
|
||||
val dateFormat = SimpleDateFormat("yyyy-MM-dd", Locale.ROOT)
|
||||
}
|
||||
}
|
@ -1,141 +0,0 @@
|
||||
package eu.kanade.tachiyomi.multisrc.slimereadtheme
|
||||
|
||||
import eu.kanade.tachiyomi.source.model.Filter
|
||||
import eu.kanade.tachiyomi.source.model.FilterList
|
||||
|
||||
object SlimeReadThemeFilters {
|
||||
open class SelectFilter(
|
||||
displayName: String,
|
||||
val vals: Array<Pair<String, String>>,
|
||||
) : Filter.Select<String>(
|
||||
displayName,
|
||||
vals.map { it.first }.toTypedArray(),
|
||||
) {
|
||||
val selected get() = vals[state].second
|
||||
}
|
||||
|
||||
private inline fun <reified R> FilterList.getSelected(): String {
|
||||
return (first { it is R } as SelectFilter).selected
|
||||
}
|
||||
|
||||
open class CheckBoxFilterList(name: String, val pairs: Array<Pair<String, String>>) :
|
||||
Filter.Group<Filter.CheckBox>(name, pairs.map { CheckBoxVal(it.first) })
|
||||
|
||||
private class CheckBoxVal(name: String) : Filter.CheckBox(name, false)
|
||||
|
||||
private inline fun <reified R> FilterList.parseCheckbox(
|
||||
options: Array<Pair<String, String>>,
|
||||
): Sequence<String> {
|
||||
return (first { it is R } as CheckBoxFilterList).state
|
||||
.asSequence()
|
||||
.filter { it.state }
|
||||
.map { checkbox -> options.find { it.first == checkbox.name }!!.second }
|
||||
}
|
||||
|
||||
internal class CategoriesFilter : CheckBoxFilterList("Categorias", SlimeReadFiltersData.CATEGORIES)
|
||||
|
||||
internal class GenreFilter : SelectFilter("Gênero", SlimeReadFiltersData.GENRES)
|
||||
internal class SearchMethodFilter : SelectFilter("Método de busca", SlimeReadFiltersData.SEARCH_METHODS)
|
||||
internal class StatusFilter : SelectFilter("Status", SlimeReadFiltersData.STATUS)
|
||||
|
||||
val FILTER_LIST get() = FilterList(
|
||||
CategoriesFilter(),
|
||||
GenreFilter(),
|
||||
SearchMethodFilter(),
|
||||
StatusFilter(),
|
||||
)
|
||||
|
||||
data class FilterSearchParams(
|
||||
val categories: Sequence<String> = emptySequence(),
|
||||
val genre: String = "",
|
||||
val searchMethod: String = "",
|
||||
val status: String = "",
|
||||
)
|
||||
|
||||
internal fun getSearchParameters(filters: FilterList): FilterSearchParams {
|
||||
if (filters.isEmpty()) return FilterSearchParams()
|
||||
|
||||
return FilterSearchParams(
|
||||
filters.parseCheckbox<CategoriesFilter>(SlimeReadFiltersData.CATEGORIES),
|
||||
filters.getSelected<GenreFilter>(),
|
||||
filters.getSelected<SearchMethodFilter>(),
|
||||
filters.getSelected<StatusFilter>(),
|
||||
)
|
||||
}
|
||||
|
||||
private object SlimeReadFiltersData {
|
||||
val CATEGORIES = arrayOf(
|
||||
Pair("Adulto", "125"),
|
||||
Pair("Artes Marciais", "117"),
|
||||
Pair("Avant Garde", "154"),
|
||||
Pair("Aventura", "112"),
|
||||
Pair("Ação", "146"),
|
||||
Pair("Comédia", "147"),
|
||||
Pair("Culinária", "126"),
|
||||
Pair("Doujinshi", "113"),
|
||||
Pair("Drama", "148"),
|
||||
Pair("Ecchi", "127"),
|
||||
Pair("Erotico", "152"),
|
||||
Pair("Esporte", "135"),
|
||||
Pair("Fantasia", "114"),
|
||||
Pair("Ficção Científica", "120"),
|
||||
Pair("Filosofico", "150"),
|
||||
Pair("Harém", "128"),
|
||||
Pair("Histórico", "115"),
|
||||
Pair("Isekai", "129"),
|
||||
Pair("Josei", "116"),
|
||||
Pair("Mecha", "130"),
|
||||
Pair("Militar", "149"),
|
||||
Pair("Mistério", "142"),
|
||||
Pair("Médico", "118"),
|
||||
Pair("One-shot", "131"),
|
||||
Pair("Premiado", "155"),
|
||||
Pair("Psicológico", "119"),
|
||||
Pair("Romance", "141"),
|
||||
Pair("Seinen", "140"),
|
||||
Pair("Shoujo", "133"),
|
||||
Pair("Shoujo-ai", "121"),
|
||||
Pair("Shounen", "139"),
|
||||
Pair("Shounen-ai", "134"),
|
||||
Pair("Slice-of-life", "122"),
|
||||
Pair("Sobrenatural", "123"),
|
||||
Pair("Sugestivo", "153"),
|
||||
Pair("Terror", "144"),
|
||||
Pair("Thriller", "151"),
|
||||
Pair("Tragédia", "137"),
|
||||
Pair("Vida Escolar", "132"),
|
||||
Pair("Yaoi", "124"),
|
||||
Pair("Yuri", "136"),
|
||||
)
|
||||
|
||||
private val SELECT = Pair("Selecione", "")
|
||||
|
||||
val GENRES = arrayOf(
|
||||
SELECT,
|
||||
Pair("Manga", "29"),
|
||||
Pair("Light Novel", "34"),
|
||||
Pair("Manhua", "31"),
|
||||
Pair("Manhwa", "30"),
|
||||
Pair("Novel", "33"),
|
||||
Pair("Webcomic", "35"),
|
||||
Pair("Webnovel", "36"),
|
||||
Pair("Webtoon", "32"),
|
||||
Pair("4-Koma", "37"),
|
||||
)
|
||||
|
||||
val SEARCH_METHODS = arrayOf(
|
||||
SELECT,
|
||||
Pair("Preciso", "0"),
|
||||
Pair("Geral", "1"),
|
||||
)
|
||||
|
||||
val STATUS = arrayOf(
|
||||
SELECT,
|
||||
Pair("Em andamento", "1"),
|
||||
Pair("Completo", "2"),
|
||||
Pair("Dropado", "3"),
|
||||
Pair("Cancelado", "4"),
|
||||
Pair("Hiato", "5"),
|
||||
)
|
||||
}
|
||||
}
|
@ -1,74 +0,0 @@
|
||||
package eu.kanade.tachiyomi.extension.pt.slimeread.dto
|
||||
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class PopularMangaDto(
|
||||
@SerialName("book_image") val thumbnail_url: String?,
|
||||
@SerialName("book_id") val id: Int,
|
||||
@SerialName("book_name_original") val name: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class LatestResponseDto(
|
||||
val pages: Int,
|
||||
val page: Int,
|
||||
val data: List<PopularMangaDto>,
|
||||
)
|
||||
|
||||
fun List<PopularMangaDto>.toSMangaList(): List<SManga> = map { item ->
|
||||
SManga.create().apply {
|
||||
thumbnail_url = item.thumbnail_url
|
||||
title = item.name
|
||||
url = "/book/${item.id}"
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class MangaInfoDto(
|
||||
@SerialName("book_id") val id: Int,
|
||||
@SerialName("book_image") val thumbnail_url: String?,
|
||||
@SerialName("book_name_original") val name: String,
|
||||
@SerialName("book_status") val status: Int,
|
||||
@SerialName("book_synopsis") val description: String?,
|
||||
@SerialName("book_categories") private val _categories: List<CategoryDto>,
|
||||
) {
|
||||
@Serializable
|
||||
data class CategoryDto(val categories: CatDto)
|
||||
|
||||
@Serializable
|
||||
data class CatDto(@SerialName("cat_name_ptBR") val name: String)
|
||||
|
||||
val categories = _categories.map { it.categories.name }
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class ChapterDto(
|
||||
@SerialName("btc_cap") val number: Float,
|
||||
@SerialName("btc_date_updated") val updated_at: String,
|
||||
val scan: ScanDto?,
|
||||
) {
|
||||
@Serializable
|
||||
data class ScanDto(val scan_name: String?)
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class PageListDto(@SerialName("book_temp_cap_unit") val pages: List<PageDto>)
|
||||
|
||||
@Serializable
|
||||
data class PageDto(
|
||||
@SerialName("btcu_image") private val path: String,
|
||||
@SerialName("btcu_provider_host") private val hostId: Int?,
|
||||
) {
|
||||
val url by lazy {
|
||||
val baseUrl = when (hostId) {
|
||||
2 -> "https://cdn.slimeread.com/"
|
||||
5 -> "https://black.slimeread.com/"
|
||||
else -> "https://objects.slimeread.com/"
|
||||
}
|
||||
|
||||
baseUrl + path
|
||||
}
|
||||
}
|
@ -2,4 +2,4 @@ plugins {
|
||||
id("lib-multisrc")
|
||||
}
|
||||
|
||||
baseVersionCode = 1
|
||||
baseVersionCode = 2
|
||||
|
@ -118,8 +118,9 @@ abstract class VerComics(
|
||||
protected open val pageListSelector =
|
||||
"div.wp-content p > img:not(noscript img), " +
|
||||
"div.wp-content div#lector > img:not(noscript img), " +
|
||||
"div.wp-content > figure img:not(noscript img)," +
|
||||
"div.wp-content > img, div.wp-content > p img"
|
||||
"div.wp-content > figure img:not(noscript img), " +
|
||||
"div.wp-content > img, div.wp-content > p img, " +
|
||||
"div.post-imgs > img"
|
||||
|
||||
override fun pageListParse(document: Document): List<Page> = document.select(pageListSelector)
|
||||
.mapIndexed { i, img -> Page(i, imageUrl = img.imgAttr()) }
|
||||
|
@ -1,308 +0,0 @@
|
||||
package eu.kanade.tachiyomi.multisrc.webtoons
|
||||
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.source.model.Filter.Header
|
||||
import eu.kanade.tachiyomi.source.model.Filter.Select
|
||||
import eu.kanade.tachiyomi.source.model.Filter.Separator
|
||||
import eu.kanade.tachiyomi.source.model.FilterList
|
||||
import eu.kanade.tachiyomi.source.model.MangasPage
|
||||
import eu.kanade.tachiyomi.source.model.Page
|
||||
import eu.kanade.tachiyomi.source.model.SChapter
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import eu.kanade.tachiyomi.source.online.ParsedHttpSource
|
||||
import eu.kanade.tachiyomi.util.asJsoup
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import okhttp3.Cookie
|
||||
import okhttp3.CookieJar
|
||||
import okhttp3.Headers
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import org.jsoup.nodes.Document
|
||||
import org.jsoup.nodes.Element
|
||||
import rx.Observable
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.net.SocketException
|
||||
import java.text.ParseException
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Calendar
|
||||
import java.util.Locale
|
||||
|
||||
open class Webtoons(
|
||||
override val name: String,
|
||||
override val baseUrl: String,
|
||||
override val lang: String,
|
||||
open val langCode: String = lang,
|
||||
open val localeForCookie: String = lang,
|
||||
private val dateFormat: SimpleDateFormat = SimpleDateFormat("MMM d, yyyy", Locale.ENGLISH),
|
||||
) : ParsedHttpSource() {
|
||||
|
||||
override val supportsLatest = true
|
||||
|
||||
override val client: OkHttpClient = network.cloudflareClient.newBuilder()
|
||||
.cookieJar(
|
||||
object : CookieJar {
|
||||
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) {}
|
||||
override fun loadForRequest(url: HttpUrl): List<Cookie> {
|
||||
return listOf<Cookie>(
|
||||
Cookie.Builder()
|
||||
.domain("www.webtoons.com")
|
||||
.path("/")
|
||||
.name("ageGatePass")
|
||||
.value("true")
|
||||
.name("locale")
|
||||
.value(localeForCookie)
|
||||
.name("needGDPR")
|
||||
.value("false")
|
||||
.build(),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
.addInterceptor(::sslRetryInterceptor)
|
||||
.build()
|
||||
|
||||
// m.webtoons.com throws an SSL error that can be solved by a simple retry
|
||||
private fun sslRetryInterceptor(chain: Interceptor.Chain): Response {
|
||||
return try {
|
||||
chain.proceed(chain.request())
|
||||
} catch (e: SocketException) {
|
||||
chain.proceed(chain.request())
|
||||
}
|
||||
}
|
||||
|
||||
private val day: String
|
||||
get() {
|
||||
return when (Calendar.getInstance().get(Calendar.DAY_OF_WEEK)) {
|
||||
Calendar.SUNDAY -> "div._list_SUNDAY"
|
||||
Calendar.MONDAY -> "div._list_MONDAY"
|
||||
Calendar.TUESDAY -> "div._list_TUESDAY"
|
||||
Calendar.WEDNESDAY -> "div._list_WEDNESDAY"
|
||||
Calendar.THURSDAY -> "div._list_THURSDAY"
|
||||
Calendar.FRIDAY -> "div._list_FRIDAY"
|
||||
Calendar.SATURDAY -> "div._list_SATURDAY"
|
||||
else -> {
|
||||
"div"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val json: Json by injectLazy()
|
||||
|
||||
override fun popularMangaSelector() = "not using"
|
||||
|
||||
override fun latestUpdatesSelector() = "div#dailyList > $day li > a"
|
||||
|
||||
override fun headersBuilder(): Headers.Builder = super.headersBuilder()
|
||||
.add("Referer", "https://www.webtoons.com/$langCode/")
|
||||
|
||||
protected val mobileHeaders: Headers = super.headersBuilder()
|
||||
.add("Referer", "https://m.webtoons.com")
|
||||
.build()
|
||||
|
||||
override fun popularMangaRequest(page: Int) = GET("$baseUrl/$langCode/dailySchedule", headers)
|
||||
|
||||
override fun popularMangaParse(response: Response): MangasPage {
|
||||
val mangas = mutableListOf<SManga>()
|
||||
val document = response.asJsoup()
|
||||
var maxChild = 0
|
||||
|
||||
// For ongoing webtoons rows are ordered by descending popularity, count how many rows there are
|
||||
document.select("div#dailyList .daily_section").forEach { day ->
|
||||
day.select("li").count().let { rowCount ->
|
||||
if (rowCount > maxChild) maxChild = rowCount
|
||||
}
|
||||
}
|
||||
|
||||
// Process each row
|
||||
for (i in 1..maxChild) {
|
||||
document.select("div#dailyList .daily_section li:nth-child($i) a").map { mangas.add(popularMangaFromElement(it)) }
|
||||
}
|
||||
|
||||
// Add completed webtoons, no sorting needed
|
||||
document.select("div.daily_lst.comp li a").map { mangas.add(popularMangaFromElement(it)) }
|
||||
|
||||
return MangasPage(mangas.distinctBy { it.url }, false)
|
||||
}
|
||||
|
||||
override fun latestUpdatesRequest(page: Int) = GET("$baseUrl/$langCode/dailySchedule?sortOrder=UPDATE&webtoonCompleteType=ONGOING", headers)
|
||||
|
||||
override fun popularMangaFromElement(element: Element): SManga {
|
||||
val manga = SManga.create()
|
||||
|
||||
manga.setUrlWithoutDomain(element.attr("href"))
|
||||
manga.title = element.select("p.subj").text()
|
||||
manga.thumbnail_url = element.select("img").attr("abs:src")
|
||||
|
||||
return manga
|
||||
}
|
||||
|
||||
override fun latestUpdatesFromElement(element: Element): SManga = popularMangaFromElement(element)
|
||||
|
||||
override fun popularMangaNextPageSelector(): String? = null
|
||||
|
||||
override fun latestUpdatesNextPageSelector(): String? = null
|
||||
|
||||
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
|
||||
if (!query.startsWith(URL_SEARCH_PREFIX)) {
|
||||
return super.fetchSearchManga(page, query, filters)
|
||||
}
|
||||
|
||||
val emptyResult = Observable.just(MangasPage(emptyList(), false))
|
||||
|
||||
// given a url to either a webtoon or an episode, returns a url path to corresponding webtoon
|
||||
fun webtoonPath(u: HttpUrl) = when {
|
||||
langCode == u.pathSegments[0] -> "/${u.pathSegments[0]}/${u.pathSegments[1]}/${u.pathSegments[2]}/list"
|
||||
else -> "/${u.pathSegments[0]}/${u.pathSegments[1]}/list" // dongmanmanhua doesn't include langCode
|
||||
}
|
||||
|
||||
return query.substringAfter(URL_SEARCH_PREFIX).toHttpUrlOrNull()?.let { url ->
|
||||
val title_no = url.queryParameter("title_no")
|
||||
val couldBeWebtoonOrEpisode = title_no != null && (url.pathSegments.size >= 3 && url.pathSegments.last().isNotEmpty())
|
||||
val isThisLang = "$url".startsWith("$baseUrl/$langCode")
|
||||
if (!(couldBeWebtoonOrEpisode && isThisLang)) {
|
||||
emptyResult
|
||||
} else {
|
||||
val potentialUrl = "${webtoonPath(url)}?title_no=$title_no"
|
||||
fetchMangaDetails(SManga.create().apply { this.url = potentialUrl }).map {
|
||||
it.url = potentialUrl
|
||||
MangasPage(listOf(it), false)
|
||||
}
|
||||
}
|
||||
} ?: emptyResult
|
||||
}
|
||||
|
||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
||||
val url = "$baseUrl/$langCode/search?keyword=$query".toHttpUrl().newBuilder()
|
||||
val uriPart = (filters.find { it is SearchType } as? SearchType)?.toUriPart() ?: ""
|
||||
|
||||
url.addQueryParameter("searchType", uriPart)
|
||||
if (uriPart != "WEBTOON" && page > 1) url.addQueryParameter("page", page.toString())
|
||||
|
||||
return GET(url.build(), headers)
|
||||
}
|
||||
|
||||
override fun searchMangaSelector() = "#content > div.card_wrap.search ul:not(#filterLayer) li a"
|
||||
|
||||
override fun searchMangaFromElement(element: Element): SManga = popularMangaFromElement(element)
|
||||
|
||||
override fun searchMangaNextPageSelector() = "div.more_area, div.paginate a[onclick] + a"
|
||||
|
||||
open fun parseDetailsThumbnail(document: Document): String? {
|
||||
val picElement = document.select("#content > div.cont_box > div.detail_body")
|
||||
val discoverPic = document.select("#content > div.cont_box > div.detail_header > span.thmb")
|
||||
return picElement.attr("style").substringAfter("url(").substringBeforeLast(")").removeSurrounding("\"").removeSurrounding("'")
|
||||
.ifBlank { discoverPic.select("img").not("[alt='Representative image']").first()?.attr("src") }
|
||||
}
|
||||
|
||||
override fun mangaDetailsParse(document: Document): SManga {
|
||||
val detailElement = document.select("#content > div.cont_box > div.detail_header > div.info")
|
||||
val infoElement = document.select("#_asideDetail")
|
||||
|
||||
val manga = SManga.create()
|
||||
manga.title = document.selectFirst("h1.subj, h3.subj")!!.text()
|
||||
manga.author = detailElement.select(".author:nth-of-type(1)").first()?.ownText()
|
||||
?: detailElement.select(".author_area").first()?.ownText()
|
||||
manga.artist = detailElement.select(".author:nth-of-type(2)").first()?.ownText()
|
||||
?: detailElement.select(".author_area").first()?.ownText() ?: manga.author
|
||||
manga.genre = detailElement.select(".genre").joinToString(", ") { it.text() }
|
||||
manga.description = infoElement.select("p.summary").text()
|
||||
manga.status = infoElement.select("p.day_info").firstOrNull()?.text().orEmpty().toStatus()
|
||||
manga.thumbnail_url = parseDetailsThumbnail(document)
|
||||
return manga
|
||||
}
|
||||
|
||||
open fun String.toStatus(): Int = when {
|
||||
contains("UP") -> SManga.ONGOING
|
||||
contains("COMPLETED") -> SManga.COMPLETED
|
||||
else -> SManga.UNKNOWN
|
||||
}
|
||||
|
||||
override fun imageUrlParse(document: Document): String = document.select("img").first()!!.attr("src")
|
||||
|
||||
// Filters
|
||||
|
||||
override fun getFilterList(): FilterList {
|
||||
return FilterList(
|
||||
Header("Query can not be blank"),
|
||||
Separator(),
|
||||
SearchType(getOfficialList()),
|
||||
)
|
||||
}
|
||||
|
||||
override fun chapterListSelector() = "ul#_episodeList li[id*=episode]"
|
||||
|
||||
private class SearchType(vals: Array<Pair<String, String>>) : UriPartFilter("Official or Challenge", vals)
|
||||
|
||||
private fun getOfficialList() = arrayOf(
|
||||
Pair("Any", ""),
|
||||
Pair("Official only", "WEBTOON"),
|
||||
Pair("Challenge only", "CHALLENGE"),
|
||||
)
|
||||
|
||||
open class UriPartFilter(displayName: String, private val vals: Array<Pair<String, String>>) :
|
||||
Select<String>(displayName, vals.map { it.first }.toTypedArray()) {
|
||||
fun toUriPart() = vals[state].second
|
||||
}
|
||||
|
||||
override fun chapterFromElement(element: Element): SChapter {
|
||||
val urlElement = element.select("a")
|
||||
|
||||
val chapter = SChapter.create()
|
||||
chapter.setUrlWithoutDomain(urlElement.attr("href"))
|
||||
chapter.name = element.select("a > div.row > div.info > p.sub_title > span.ellipsis").text()
|
||||
val select = element.select("a > div.row > div.num")
|
||||
if (select.isNotEmpty()) {
|
||||
chapter.name += " Ch. " + select.text().substringAfter("#")
|
||||
}
|
||||
if (element.select(".ico_bgm").isNotEmpty()) {
|
||||
chapter.name += " ♫"
|
||||
}
|
||||
chapter.date_upload = element.select("a > div.row > div.col > div.sub_info > span.date").text().let { chapterParseDate(it) } ?: 0
|
||||
return chapter
|
||||
}
|
||||
|
||||
open fun chapterParseDate(date: String): Long {
|
||||
return try {
|
||||
dateFormat.parse(date)?.time ?: 0
|
||||
} catch (e: ParseException) {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
override fun chapterListRequest(manga: SManga) = GET("https://m.webtoons.com" + manga.url, mobileHeaders)
|
||||
|
||||
override fun pageListParse(document: Document): List<Page> {
|
||||
var pages = document.select("div#_imageList > img").mapIndexed { i, element -> Page(i, "", element.attr("data-url")) }
|
||||
|
||||
if (pages.isNotEmpty()) { return pages }
|
||||
|
||||
val docString = document.toString()
|
||||
|
||||
val docUrlRegex = Regex("documentURL:.*?'(.*?)'")
|
||||
val motiontoonPathRegex = Regex("jpg:.*?'(.*?)\\{")
|
||||
|
||||
val docUrl = docUrlRegex.find(docString)!!.destructured.toList()[0]
|
||||
val motiontoonPath = motiontoonPathRegex.find(docString)!!.destructured.toList()[0]
|
||||
val motiontoonResponse = client.newCall(GET(docUrl, headers)).execute()
|
||||
|
||||
val motiontoonJson = json.parseToJsonElement(motiontoonResponse.body.string()).jsonObject
|
||||
val motiontoonImages = motiontoonJson["assets"]!!.jsonObject["image"]!!.jsonObject
|
||||
|
||||
return motiontoonImages.entries
|
||||
.filter { it.key.contains("layer") }
|
||||
.mapIndexed { i, entry ->
|
||||
Page(i, "", motiontoonPath + entry.value.jsonPrimitive.content)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val URL_SEARCH_PREFIX = "url:"
|
||||
}
|
||||
}
|
@ -1,226 +0,0 @@
|
||||
package eu.kanade.tachiyomi.multisrc.webtoons
|
||||
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.network.asObservableSuccess
|
||||
import eu.kanade.tachiyomi.source.model.FilterList
|
||||
import eu.kanade.tachiyomi.source.model.MangasPage
|
||||
import eu.kanade.tachiyomi.source.model.Page
|
||||
import eu.kanade.tachiyomi.source.model.SChapter
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.boolean
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.int
|
||||
import kotlinx.serialization.json.intOrNull
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlinx.serialization.json.long
|
||||
import okhttp3.Headers
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import org.jsoup.nodes.Document
|
||||
import org.jsoup.nodes.Element
|
||||
import rx.Observable
|
||||
|
||||
open class WebtoonsTranslate(
|
||||
override val name: String,
|
||||
override val baseUrl: String,
|
||||
override val lang: String,
|
||||
private val translateLangCode: String,
|
||||
) : Webtoons(name, baseUrl, lang) {
|
||||
|
||||
// popularMangaRequest already returns manga sorted by latest update
|
||||
override val supportsLatest = false
|
||||
|
||||
private val apiBaseUrl = "https://global.apis.naver.com".toHttpUrl()
|
||||
private val mobileBaseUrl = "https://m.webtoons.com".toHttpUrl()
|
||||
private val thumbnailBaseUrl = "https://mwebtoon-phinf.pstatic.net"
|
||||
|
||||
private val pageSize = 24
|
||||
|
||||
override fun headersBuilder(): Headers.Builder = super.headersBuilder()
|
||||
.removeAll("Referer")
|
||||
.add("Referer", mobileBaseUrl.toString())
|
||||
|
||||
private fun mangaRequest(page: Int, requeztSize: Int): Request {
|
||||
val url = apiBaseUrl
|
||||
.resolve("/lineWebtoon/ctrans/translatedWebtoons_jsonp.json")!!
|
||||
.newBuilder()
|
||||
.addQueryParameter("orderType", "UPDATE")
|
||||
.addQueryParameter("offset", "${(page - 1) * requeztSize}")
|
||||
.addQueryParameter("size", "$requeztSize")
|
||||
.addQueryParameter("languageCode", translateLangCode)
|
||||
.build()
|
||||
return GET(url, headers)
|
||||
}
|
||||
|
||||
// Webtoons translations doesn't really have a "popular" sort; just "UPDATE", "TITLE_ASC",
|
||||
// and "TITLE_DESC". Pick UPDATE as the most useful sort.
|
||||
override fun popularMangaRequest(page: Int): Request = mangaRequest(page, pageSize)
|
||||
|
||||
override fun popularMangaParse(response: Response): MangasPage {
|
||||
val offset = response.request.url.queryParameter("offset")!!.toInt()
|
||||
val result = json.parseToJsonElement(response.body.string()).jsonObject
|
||||
val responseCode = result["code"]!!.jsonPrimitive.content
|
||||
|
||||
if (responseCode != "000") {
|
||||
throw Exception("Error getting popular manga: error code $responseCode")
|
||||
}
|
||||
|
||||
val titles = result["result"]!!.jsonObject
|
||||
val totalCount = titles["totalCount"]!!.jsonPrimitive.int
|
||||
|
||||
val mangaList = titles["titleList"]!!.jsonArray
|
||||
.map { mangaFromJson(it.jsonObject) }
|
||||
|
||||
return MangasPage(mangaList, hasNextPage = totalCount > pageSize + offset)
|
||||
}
|
||||
|
||||
private fun mangaFromJson(manga: JsonObject): SManga {
|
||||
val relativeThumnailURL = manga["thumbnailIPadUrl"]?.jsonPrimitive?.contentOrNull
|
||||
?: manga["thumbnailMobileUrl"]?.jsonPrimitive?.contentOrNull
|
||||
|
||||
return SManga.create().apply {
|
||||
title = manga["representTitle"]!!.jsonPrimitive.content
|
||||
author = manga["writeAuthorName"]!!.jsonPrimitive.content
|
||||
artist = manga["pictureAuthorName"]?.jsonPrimitive?.contentOrNull ?: author
|
||||
thumbnail_url = if (relativeThumnailURL != null) "$thumbnailBaseUrl$relativeThumnailURL" else null
|
||||
status = SManga.UNKNOWN
|
||||
url = mobileBaseUrl
|
||||
.resolve("/translate/episodeList")!!
|
||||
.newBuilder()
|
||||
.addQueryParameter("titleNo", manga["titleNo"]!!.jsonPrimitive.int.toString())
|
||||
.addQueryParameter("languageCode", translateLangCode)
|
||||
.addQueryParameter("teamVersion", (manga["teamVersion"]?.jsonPrimitive?.intOrNull ?: 0).toString())
|
||||
.build()
|
||||
.toString()
|
||||
}
|
||||
}
|
||||
|
||||
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
|
||||
return client.newCall(searchMangaRequest(page, query, filters))
|
||||
.asObservableSuccess()
|
||||
.map { response ->
|
||||
searchMangaParse(response, query)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Don't see a search function for Fan Translations, so let's do it client side.
|
||||
* There's 75 webtoons as of 2019/11/21, a hardcoded request of 200 should be a sufficient request
|
||||
* to get all titles, in 1 request, for quite a while
|
||||
*/
|
||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request = mangaRequest(page, 200)
|
||||
|
||||
private fun searchMangaParse(response: Response, query: String): MangasPage {
|
||||
val result = json.parseToJsonElement(response.body.string()).jsonObject
|
||||
val responseCode = result["code"]!!.jsonPrimitive.content
|
||||
|
||||
if (responseCode != "000") {
|
||||
throw Exception("Error getting manga: error code $responseCode")
|
||||
}
|
||||
|
||||
val mangaList = result["result"]!!.jsonObject["titleList"]!!.jsonArray
|
||||
.map { mangaFromJson(it.jsonObject) }
|
||||
.filter { it.title.contains(query, ignoreCase = true) }
|
||||
|
||||
return MangasPage(mangaList, false)
|
||||
}
|
||||
|
||||
override fun mangaDetailsRequest(manga: SManga): Request {
|
||||
return GET(manga.url, headers)
|
||||
}
|
||||
|
||||
override fun mangaDetailsParse(document: Document): SManga {
|
||||
val getMetaProp = fun(property: String): String =
|
||||
document.head().select("meta[property=\"$property\"]").attr("content")
|
||||
var parsedAuthor = getMetaProp("com-linewebtoon:webtoon:author")
|
||||
var parsedArtist = parsedAuthor
|
||||
val authorSplit = parsedAuthor.split(" / ", limit = 2)
|
||||
if (authorSplit.count() > 1) {
|
||||
parsedAuthor = authorSplit[0]
|
||||
parsedArtist = authorSplit[1]
|
||||
}
|
||||
|
||||
return SManga.create().apply {
|
||||
title = getMetaProp("og:title")
|
||||
artist = parsedArtist
|
||||
author = parsedAuthor
|
||||
description = getMetaProp("og:description")
|
||||
status = SManga.UNKNOWN
|
||||
thumbnail_url = getMetaProp("og:image")
|
||||
}
|
||||
}
|
||||
|
||||
override fun chapterListSelector(): String = throw UnsupportedOperationException()
|
||||
|
||||
override fun chapterFromElement(element: Element): SChapter = throw UnsupportedOperationException()
|
||||
|
||||
override fun pageListParse(document: Document): List<Page> = throw UnsupportedOperationException()
|
||||
|
||||
override fun chapterListRequest(manga: SManga): Request {
|
||||
val mangaUrl = manga.url.toHttpUrl()
|
||||
val titleNo = mangaUrl.queryParameter("titleNo")
|
||||
val teamVersion = mangaUrl.queryParameter("teamVersion")
|
||||
val chapterListUrl = apiBaseUrl
|
||||
.resolve("/lineWebtoon/ctrans/translatedEpisodes_jsonp.json")!!
|
||||
.newBuilder()
|
||||
.addQueryParameter("titleNo", titleNo)
|
||||
.addQueryParameter("languageCode", translateLangCode)
|
||||
.addQueryParameter("offset", "0")
|
||||
.addQueryParameter("limit", "10000")
|
||||
.addQueryParameter("teamVersion", teamVersion)
|
||||
.toString()
|
||||
return GET(chapterListUrl, mobileHeaders)
|
||||
}
|
||||
|
||||
override fun chapterListParse(response: Response): List<SChapter> {
|
||||
val result = json.parseToJsonElement(response.body.string()).jsonObject
|
||||
val responseCode = result["code"]!!.jsonPrimitive.content
|
||||
|
||||
if (responseCode != "000") {
|
||||
val message = result["message"]?.jsonPrimitive?.content ?: "error code $responseCode"
|
||||
throw Exception("Error getting chapter list: $message")
|
||||
}
|
||||
|
||||
return result["result"]!!.jsonObject["episodes"]!!.jsonArray
|
||||
.filter { it.jsonObject["translateCompleted"]!!.jsonPrimitive.boolean }
|
||||
.map { parseChapterJson(it.jsonObject) }
|
||||
.reversed()
|
||||
}
|
||||
|
||||
private fun parseChapterJson(obj: JsonObject): SChapter = SChapter.create().apply {
|
||||
name = obj["title"]!!.jsonPrimitive.content + " #" + obj["episodeSeq"]!!.jsonPrimitive.int
|
||||
chapter_number = obj["episodeSeq"]!!.jsonPrimitive.int.toFloat()
|
||||
date_upload = obj["updateYmdt"]!!.jsonPrimitive.long
|
||||
scanlator = obj["teamVersion"]!!.jsonPrimitive.int.takeIf { it != 0 }?.toString() ?: "(wiki)"
|
||||
|
||||
val chapterUrl = apiBaseUrl
|
||||
.resolve("/lineWebtoon/ctrans/translatedEpisodeDetail_jsonp.json")!!
|
||||
.newBuilder()
|
||||
.addQueryParameter("titleNo", obj["titleNo"]!!.jsonPrimitive.int.toString())
|
||||
.addQueryParameter("episodeNo", obj["episodeNo"]!!.jsonPrimitive.int.toString())
|
||||
.addQueryParameter("languageCode", obj["languageCode"]!!.jsonPrimitive.content)
|
||||
.addQueryParameter("teamVersion", obj["teamVersion"]!!.jsonPrimitive.int.toString())
|
||||
.toString()
|
||||
|
||||
setUrlWithoutDomain(chapterUrl)
|
||||
}
|
||||
|
||||
override fun pageListRequest(chapter: SChapter): Request {
|
||||
return GET(apiBaseUrl.resolve(chapter.url)!!, headers)
|
||||
}
|
||||
|
||||
override fun pageListParse(response: Response): List<Page> {
|
||||
val result = json.parseToJsonElement(response.body.string()).jsonObject
|
||||
|
||||
return result["result"]!!.jsonObject["imageInfo"]!!.jsonArray
|
||||
.mapIndexed { i, jsonEl ->
|
||||
Page(i, "", jsonEl.jsonObject["imageUrl"]!!.jsonPrimitive.content)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getFilterList(): FilterList = FilterList()
|
||||
}
|
@ -129,19 +129,22 @@ abstract class YuYu(
|
||||
genre = details.select(".genre-tag").joinToString { it.text() }
|
||||
description = details.selectFirst(".sinopse p")?.text()
|
||||
details.selectFirst(".manga-meta > div")?.ownText()?.let {
|
||||
status = when (it.lowercase()) {
|
||||
"em andamento" -> SManga.ONGOING
|
||||
"completo" -> SManga.COMPLETED
|
||||
"cancelado" -> SManga.CANCELLED
|
||||
"hiato" -> SManga.ON_HIATUS
|
||||
else -> SManga.UNKNOWN
|
||||
}
|
||||
status = it.toStatus()
|
||||
}
|
||||
setUrlWithoutDomain(document.location())
|
||||
}
|
||||
|
||||
private fun SManga.fetchMangaId(): String {
|
||||
val document = client.newCall(mangaDetailsRequest(this)).execute().asJsoup()
|
||||
protected fun String.toStatus(): Int {
|
||||
return when (lowercase()) {
|
||||
"em andamento" -> SManga.ONGOING
|
||||
"completo" -> SManga.COMPLETED
|
||||
"cancelado" -> SManga.CANCELLED
|
||||
"hiato" -> SManga.ON_HIATUS
|
||||
else -> SManga.UNKNOWN
|
||||
}
|
||||
}
|
||||
|
||||
protected open fun getMangaId(manga: SManga): String {
|
||||
val document = client.newCall(mangaDetailsRequest(manga)).execute().asJsoup()
|
||||
return document.select("script")
|
||||
.map(Element::data)
|
||||
.firstOrNull(MANGA_ID_REGEX::containsMatchIn)
|
||||
@ -159,11 +162,11 @@ abstract class YuYu(
|
||||
}
|
||||
|
||||
override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> {
|
||||
val mangaId = manga.fetchMangaId()
|
||||
val mangaId = getMangaId(manga)
|
||||
val chapters = mutableListOf<SChapter>()
|
||||
var page = 1
|
||||
do {
|
||||
val dto = fetchChapterListPage(mangaId, page++).parseAs<ChaptersDto>()
|
||||
val dto = fetchChapterListPage(mangaId, page++).parseAs<ChaptersDto<String>>()
|
||||
val document = Jsoup.parseBodyFragment(dto.chapters, baseUrl)
|
||||
chapters += document.select(chapterListSelector()).map(::chapterFromElement)
|
||||
} while (dto.hasNext())
|
||||
@ -194,7 +197,7 @@ abstract class YuYu(
|
||||
// ============================== Utilities ===========================
|
||||
|
||||
@Serializable
|
||||
class ChaptersDto(val chapters: String, private val remaining: Int) {
|
||||
class ChaptersDto<T>(val chapters: T, private val remaining: Int) {
|
||||
fun hasNext() = remaining > 0
|
||||
}
|
||||
|
||||
|
5
lib-multisrc/zerotheme/build.gradle.kts
Normal file
@ -0,0 +1,5 @@
|
||||
plugins {
|
||||
id("lib-multisrc")
|
||||
}
|
||||
|
||||
baseVersionCode = 1
|
@ -0,0 +1,83 @@
|
||||
package eu.kanade.tachiyomi.multisrc.zerotheme
|
||||
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.network.interceptor.rateLimit
|
||||
import eu.kanade.tachiyomi.source.model.FilterList
|
||||
import eu.kanade.tachiyomi.source.model.MangasPage
|
||||
import eu.kanade.tachiyomi.source.model.Page
|
||||
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||
import eu.kanade.tachiyomi.util.asJsoup
|
||||
import keiyoushi.utils.parseAs
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
|
||||
abstract class ZeroTheme(
|
||||
override val name: String,
|
||||
override val baseUrl: String,
|
||||
override val lang: String,
|
||||
) : HttpSource() {
|
||||
|
||||
override val supportsLatest: Boolean = true
|
||||
|
||||
override val client = network.cloudflareClient.newBuilder()
|
||||
.rateLimit(2)
|
||||
.build()
|
||||
|
||||
open val cdnUrl: String = "https://cdn.${baseUrl.substringAfterLast("/")}"
|
||||
|
||||
open val imageLocation: String = "images"
|
||||
|
||||
private val sourceLocation: String get() = "$cdnUrl/$imageLocation"
|
||||
|
||||
// =========================== Popular ================================
|
||||
|
||||
override fun popularMangaRequest(page: Int) = searchMangaRequest(page, "", FilterList())
|
||||
|
||||
override fun popularMangaParse(response: Response) = searchMangaParse(response)
|
||||
|
||||
// =========================== Latest ===================================
|
||||
|
||||
override fun latestUpdatesRequest(page: Int) = GET(baseUrl, headers)
|
||||
|
||||
override fun latestUpdatesParse(response: Response): MangasPage =
|
||||
MangasPage(response.toDto<LatestDto>().toSMangaList(sourceLocation), hasNextPage = false)
|
||||
|
||||
// =========================== Search =================================
|
||||
|
||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
||||
val url = "$baseUrl/api/search".toHttpUrl().newBuilder()
|
||||
.addQueryParameter("q", query)
|
||||
.addQueryParameter("page", page.toString())
|
||||
.build()
|
||||
return GET(url, headers)
|
||||
}
|
||||
|
||||
override fun searchMangaParse(response: Response): MangasPage {
|
||||
val dto = response.parseAs<SearchDto>()
|
||||
val mangas = dto.mangas.map { it.toSManga(sourceLocation) }
|
||||
return MangasPage(mangas, hasNextPage = dto.hasNextPage())
|
||||
}
|
||||
|
||||
// =========================== Details =================================
|
||||
|
||||
override fun mangaDetailsParse(response: Response) = response.toDto<MangaDetailsDto>().toSManga(sourceLocation)
|
||||
|
||||
// =========================== Chapter =================================
|
||||
|
||||
override fun chapterListParse(response: Response) = response.toDto<MangaDetailsDto>().toSChapterList()
|
||||
|
||||
// =========================== Pages ===================================
|
||||
|
||||
override fun pageListParse(response: Response): List<Page> =
|
||||
response.toDto<PageDto>().toPageList(sourceLocation)
|
||||
|
||||
override fun imageUrlParse(response: Response) = ""
|
||||
|
||||
// =========================== Utilities ===============================
|
||||
|
||||
inline fun <reified T> Response.toDto(): T {
|
||||
val jsonString = asJsoup().selectFirst("[data-page]")!!.attr("data-page")
|
||||
return jsonString.parseAs<T>()
|
||||
}
|
||||
}
|
@ -0,0 +1,139 @@
|
||||
package eu.kanade.tachiyomi.multisrc.zerotheme
|
||||
|
||||
import eu.kanade.tachiyomi.source.model.Page
|
||||
import eu.kanade.tachiyomi.source.model.SChapter
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import keiyoushi.utils.tryParse
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonNames
|
||||
import org.jsoup.Jsoup
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
|
||||
@Serializable
|
||||
class Props<T>(
|
||||
@JsonNames("comic_infos", "chapter", "new_chapters")
|
||||
val content: T,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class LatestDto(
|
||||
private val props: Props<List<Comic>>,
|
||||
) {
|
||||
fun toSMangaList(srcPath: String) = props.content.map { it.comic.toSManga(srcPath) }
|
||||
|
||||
@Serializable
|
||||
class Comic(
|
||||
val comic: MangaDto,
|
||||
)
|
||||
}
|
||||
|
||||
@Serializable
|
||||
class MangaDetailsDto(
|
||||
private val props: Props<MangaDto>,
|
||||
) {
|
||||
fun toSManga(srcPath: String) = props.content.toSManga(srcPath)
|
||||
fun toSChapterList() = props.content.chapters!!.map { it.toSChapter() }
|
||||
}
|
||||
|
||||
@Serializable
|
||||
class PageDto(
|
||||
val props: Props<ChapterWrapper>,
|
||||
) {
|
||||
fun toPageList(srcPath: String): List<Page> {
|
||||
return props.content.chapter.pages
|
||||
.filter { it.pathSegment.contains("xml").not() }
|
||||
.mapIndexed { index, path ->
|
||||
Page(index, imageUrl = "$srcPath/${path.pathSegment}")
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
class ChapterWrapper(
|
||||
val chapter: Chapter,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class Chapter(
|
||||
val pages: List<Image>,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class Image(
|
||||
@SerialName("page_path")
|
||||
val pathSegment: String,
|
||||
)
|
||||
}
|
||||
|
||||
@Serializable
|
||||
class SearchDto(
|
||||
@SerialName("comics")
|
||||
private val page: PageDto,
|
||||
) {
|
||||
|
||||
val mangas: List<MangaDto> get() = page.data
|
||||
|
||||
fun hasNextPage() = page.currentPage < page.lastPage
|
||||
|
||||
@Serializable
|
||||
class PageDto(
|
||||
val `data`: List<MangaDto>,
|
||||
@SerialName("last_page")
|
||||
val lastPage: Int = 0,
|
||||
@SerialName("current_page")
|
||||
val currentPage: Int = 0,
|
||||
)
|
||||
}
|
||||
|
||||
@Serializable
|
||||
class MangaDto(
|
||||
val title: String,
|
||||
val description: String?,
|
||||
@SerialName("cover")
|
||||
val thumbnailUrl: String?,
|
||||
val slug: String,
|
||||
val status: List<ValueDto>? = emptyList(),
|
||||
val genres: List<ValueDto>? = emptyList(),
|
||||
val chapters: List<ChapterDto>? = emptyList(),
|
||||
) {
|
||||
|
||||
fun toSManga(srcPath: String) = SManga.create().apply {
|
||||
title = this@MangaDto.title
|
||||
description = this@MangaDto.description?.let { Jsoup.parseBodyFragment(it).text() }
|
||||
this.thumbnail_url = thumbnailUrl?.let { "$srcPath/$it" }
|
||||
|
||||
status = when (this@MangaDto.status?.firstOrNull()?.name?.lowercase()) {
|
||||
"em andamento" -> SManga.ONGOING
|
||||
else -> SManga.UNKNOWN
|
||||
}
|
||||
genre = genres?.joinToString { it.name }
|
||||
url = "/comic/$slug"
|
||||
}
|
||||
|
||||
@Serializable
|
||||
class ValueDto(
|
||||
val name: String,
|
||||
)
|
||||
}
|
||||
|
||||
@Serializable
|
||||
class ChapterDto(
|
||||
@SerialName("chapter_number")
|
||||
val number: Float,
|
||||
@SerialName("chapter_path")
|
||||
val path: String,
|
||||
@SerialName("created_at")
|
||||
val createdAt: String,
|
||||
) {
|
||||
fun toSChapter() = SChapter.create().apply {
|
||||
name = number.toString()
|
||||
chapter_number = number
|
||||
date_upload = dateFormat.tryParse(createdAt)
|
||||
url = "/chapter/$path"
|
||||
}
|
||||
|
||||
companion object {
|
||||
val dateFormat = SimpleDateFormat("yyyy-MM-dd", Locale.ROOT)
|
||||
}
|
||||
}
|
@ -8,7 +8,6 @@ loadAllIndividualExtensions()
|
||||
* ===================================== COMMON CONFIGURATION ======================================
|
||||
*/
|
||||
include(":core")
|
||||
include(":utils")
|
||||
|
||||
// Load all modules under /lib
|
||||
File(rootDir, "lib").eachDir { include("lib:${it.name}") }
|
||||
|
@ -1,9 +1,8 @@
|
||||
ext {
|
||||
extName = 'Comic Growl'
|
||||
extClass = '.ComicGrowl'
|
||||
themePkg = 'gigaviewer'
|
||||
baseUrl = 'https://comic-growl.com'
|
||||
overrideVersionCode = 0
|
||||
extVersionCode = 7
|
||||
isNsfw = false
|
||||
}
|
||||
|
||||
|
@ -1,63 +1,212 @@
|
||||
package eu.kanade.tachiyomi.extension.all.comicgrowl
|
||||
|
||||
import eu.kanade.tachiyomi.multisrc.gigaviewer.GigaViewer
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.source.model.FilterList
|
||||
import eu.kanade.tachiyomi.source.model.Page
|
||||
import eu.kanade.tachiyomi.source.model.SChapter
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import eu.kanade.tachiyomi.source.online.ParsedHttpSource
|
||||
import eu.kanade.tachiyomi.util.asJsoup
|
||||
import keiyoushi.utils.parseAs
|
||||
import keiyoushi.utils.tryParse
|
||||
import okhttp3.Headers
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import org.jsoup.nodes.Document
|
||||
import org.jsoup.nodes.Element
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
|
||||
// TODO: get manga status
|
||||
// TODO: filter by status
|
||||
// TODO: change cdnUrl as a array(upstream)
|
||||
class ComicGrowl : GigaViewer(
|
||||
"コミックグロウル",
|
||||
"https://comic-growl.com",
|
||||
"all",
|
||||
"https://cdn-img.comic-growl.com/public/page",
|
||||
) {
|
||||
class ComicGrowl(
|
||||
override val lang: String = "all",
|
||||
override val baseUrl: String = "https://comic-growl.com",
|
||||
override val name: String = "コミックグロウル",
|
||||
override val supportsLatest: Boolean = true,
|
||||
) : ParsedHttpSource() {
|
||||
|
||||
override val publisher = "BUSHIROAD WORKS"
|
||||
override val client = super.client.newBuilder()
|
||||
.addNetworkInterceptor(ImageDescrambler::interceptor)
|
||||
.build()
|
||||
|
||||
override val chapterListMode = CHAPTER_LIST_LOCKED
|
||||
|
||||
override val supportsLatest: Boolean = true
|
||||
|
||||
override val client: OkHttpClient =
|
||||
super.client.newBuilder().addInterceptor(::imageIntercept).build()
|
||||
|
||||
override fun popularMangaRequest(page: Int): Request = GET(baseUrl, headers)
|
||||
|
||||
// Show only ongoing works
|
||||
override fun popularMangaSelector(): String = "ul[class=\"lineup-list ongoing\"] > li > div > a"
|
||||
|
||||
override fun popularMangaFromElement(element: Element) = SManga.create().apply {
|
||||
title = element.select("h5").text()
|
||||
thumbnail_url = element.select("div > img").attr("data-src")
|
||||
setUrlWithoutDomain(element.attr("href"))
|
||||
override fun headersBuilder(): Headers.Builder {
|
||||
return super.headersBuilder().set("Referer", "$baseUrl/")
|
||||
}
|
||||
|
||||
override fun latestUpdatesSelector() =
|
||||
"div[class=\"update latest\"] > div.card-board > " + "div[class~=card]:not([class~=ad]) > div > a"
|
||||
override fun popularMangaRequest(page: Int) = GET("$baseUrl/ranking/manga", headers)
|
||||
|
||||
override fun latestUpdatesFromElement(element: Element) = SManga.create().apply {
|
||||
title = element.select("div.data h3").text()
|
||||
thumbnail_url = element.select("div.thumb-container img").attr("data-src")
|
||||
setUrlWithoutDomain(element.attr("href"))
|
||||
override fun popularMangaNextPageSelector() = null
|
||||
|
||||
override fun popularMangaSelector() = ".ranking-item"
|
||||
|
||||
override fun popularMangaFromElement(element: Element): SManga {
|
||||
return SManga.create().apply {
|
||||
setUrlWithoutDomain(element.selectFirst("a")!!.absUrl("href"))
|
||||
title = element.selectFirst(".title-text")!!.text()
|
||||
setImageUrlFromElement(element)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getCollections(): List<Collection> = listOf(
|
||||
Collection("連載作品", ""),
|
||||
)
|
||||
override fun mangaDetailsParse(document: Document): SManga {
|
||||
val infoElement = document.selectFirst(".series-h-info")!!
|
||||
val authorElements = infoElement.select(".series-h-credit-user-item .article-text")
|
||||
val updateDateElement = infoElement.selectFirst(".series-h-tag-label")
|
||||
return SManga.create().apply {
|
||||
title = infoElement.selectFirst("h1 > span:not(.g-hidden)")!!.text()
|
||||
author = authorElements.joinToString { it.text() }
|
||||
description = infoElement.selectFirst(".series-h-credit-info-text-text p")?.wholeText()?.trim()
|
||||
setImageUrlFromElement(document.selectFirst(".series-h-img"))
|
||||
status = if (updateDateElement != null) SManga.ONGOING else SManga.COMPLETED
|
||||
}
|
||||
}
|
||||
|
||||
override fun chapterListRequest(manga: SManga) = GET(baseUrl + manga.url + "/list", headers)
|
||||
|
||||
override fun chapterListParse(response: Response): List<SChapter> {
|
||||
val document = response.asJsoup()
|
||||
return document.select(chapterListSelector()).mapIndexed { index, element ->
|
||||
chapterFromElement(element).apply {
|
||||
chapter_number = index.toFloat()
|
||||
if (url.isEmpty()) { // need login, set a dummy url and append lock icon for chapter name
|
||||
val hasLockElement = element.selectFirst(".g-payment-article.wait-free-enabled")
|
||||
url = response.request.url.newBuilder().fragment("$index-$DUMMY_URL_SUFFIX").build().toString()
|
||||
name = (if (hasLockElement != null) LOCK_ICON else PAY_ICON) + name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun chapterListSelector() = ".article-ep-list-item-img-link"
|
||||
|
||||
override fun chapterFromElement(element: Element): SChapter {
|
||||
return SChapter.create().apply {
|
||||
setUrlWithoutDomain(element.absUrl("data-href"))
|
||||
name = element.selectFirst(".series-ep-list-item-h-text")!!.text()
|
||||
setUploadDate(element.selectFirst(".series-ep-list-date-time"))
|
||||
scanlator = PUBLISHER
|
||||
}
|
||||
}
|
||||
|
||||
override fun pageListRequest(chapter: SChapter): Request {
|
||||
if (chapter.url.endsWith(DUMMY_URL_SUFFIX)) {
|
||||
throw Exception("Login required to see this chapter")
|
||||
}
|
||||
return super.pageListRequest(chapter)
|
||||
}
|
||||
|
||||
override fun pageListParse(document: Document): List<Page> {
|
||||
val pageList = mutableListOf<Page>()
|
||||
|
||||
// Get some essential info from document
|
||||
val viewer = document.selectFirst("#comici-viewer")!!
|
||||
val comiciViewerId = viewer.attr("comici-viewer-id")
|
||||
val memberJwt = viewer.attr("data-member-jwt")
|
||||
val requestUrl = "$baseUrl/book/contentsInfo".toHttpUrl().newBuilder()
|
||||
.addQueryParameter("comici-viewer-id", comiciViewerId)
|
||||
.addQueryParameter("user-id", memberJwt)
|
||||
.addQueryParameter("page-from", "0")
|
||||
|
||||
// Initial request to get total pages
|
||||
val initialRequest = GET(requestUrl.addQueryParameter("page-to", "1").build(), headers)
|
||||
client.newCall(initialRequest).execute().use { initialResponseRaw ->
|
||||
if (!initialResponseRaw.isSuccessful) {
|
||||
throw Exception("Failed to get page list")
|
||||
}
|
||||
|
||||
// Get all pages
|
||||
val pageTo = initialResponseRaw.parseAs<PageResponse>().totalPages.toString()
|
||||
val getAllPagesUrl = requestUrl.setQueryParameter("page-to", pageTo).build()
|
||||
val getAllPagesRequest = GET(getAllPagesUrl, headers)
|
||||
client.newCall(getAllPagesRequest).execute().use {
|
||||
if (!it.isSuccessful) {
|
||||
throw Exception("Failed to get page list")
|
||||
}
|
||||
|
||||
it.parseAs<PageResponse>().result.forEach { resultItem ->
|
||||
// Origin scramble string is something like [6, 9, 14, 15, 8, 3, 4, 12, 1, 5, 0, 7, 13, 2, 11, 10]
|
||||
val scramble = resultItem.scramble.drop(1).dropLast(1).replace(", ", "-")
|
||||
// Add fragment to let interceptor descramble the image
|
||||
val imageUrl = resultItem.imageUrl.toHttpUrl().newBuilder().fragment(scramble).build()
|
||||
|
||||
pageList.add(
|
||||
Page(index = resultItem.sort, imageUrl = imageUrl.toString()),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
return pageList
|
||||
}
|
||||
|
||||
override fun imageUrlParse(document: Document): String {
|
||||
throw UnsupportedOperationException()
|
||||
}
|
||||
|
||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
||||
if (query.isNotEmpty()) {
|
||||
val url = "$baseUrl/search".toHttpUrl().newBuilder().addQueryParameter("q", query)
|
||||
val searchUrl = "$baseUrl/search".toHttpUrl().newBuilder()
|
||||
.setQueryParameter("keyword", query)
|
||||
.setQueryParameter("page", page.toString())
|
||||
.build()
|
||||
return GET(searchUrl, headers)
|
||||
}
|
||||
|
||||
return GET(url.build(), headers)
|
||||
override fun searchMangaNextPageSelector() = null
|
||||
|
||||
override fun searchMangaSelector() = ".series-list a"
|
||||
|
||||
override fun searchMangaFromElement(element: Element) = SManga.create().apply {
|
||||
setUrlWithoutDomain(element.absUrl("href"))
|
||||
title = element.selectFirst(".manga-title")!!.text()
|
||||
setImageUrlFromElement(element)
|
||||
}
|
||||
|
||||
override fun latestUpdatesRequest(page: Int) = GET(baseUrl, headers)
|
||||
|
||||
override fun latestUpdatesNextPageSelector() = null
|
||||
|
||||
override fun latestUpdatesSelector() = "h2:contains(新連載) + .feature-list > .feature-item"
|
||||
|
||||
override fun latestUpdatesFromElement(element: Element) = SManga.create().apply {
|
||||
setUrlWithoutDomain(element.selectFirst("a")!!.absUrl("href"))
|
||||
title = element.selectFirst("h3")!!.text()
|
||||
setImageUrlFromElement(element)
|
||||
}
|
||||
|
||||
// ========================================= Helper Functions =====================================
|
||||
|
||||
companion object {
|
||||
private const val PUBLISHER = "BUSHIROAD WORKS"
|
||||
|
||||
private val imageUrlRegex by lazy { Regex("^.*?webp") }
|
||||
|
||||
private val DATE_PARSER by lazy { SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.ROOT) }
|
||||
|
||||
private const val DUMMY_URL_SUFFIX = "NeedLogin"
|
||||
|
||||
private const val PAY_ICON = "💴 "
|
||||
private const val LOCK_ICON = "🔒 "
|
||||
}
|
||||
|
||||
/**
|
||||
* Set cover image url from [element] for [SManga]
|
||||
*/
|
||||
private fun SManga.setImageUrlFromElement(element: Element?) {
|
||||
if (element == null) {
|
||||
return
|
||||
}
|
||||
return GET(baseUrl, headers) // Currently just get all ongoing works
|
||||
val match = imageUrlRegex.find(element.selectFirst("source")!!.attr("data-srcset"))
|
||||
// Add missing protocol
|
||||
if (match != null) {
|
||||
this.thumbnail_url = "https:${match.value}"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set date_upload to [SChapter], parsing from string like "3月31日" to UNIX Epoch time.
|
||||
*/
|
||||
private fun SChapter.setUploadDate(element: Element?) {
|
||||
if (element == null) {
|
||||
return
|
||||
}
|
||||
this.date_upload = DATE_PARSER.tryParse(element.attr("datetime"))
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,70 @@
|
||||
package eu.kanade.tachiyomi.extension.all.comicgrowl
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Rect
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.Response
|
||||
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||
import java.io.ByteArrayOutputStream
|
||||
|
||||
object ImageDescrambler {
|
||||
|
||||
// Left-top corner position
|
||||
private class TilePos(val x: Int, val y: Int)
|
||||
|
||||
/**
|
||||
* Interceptor to descramble the image.
|
||||
*/
|
||||
fun interceptor(chain: Interceptor.Chain): Response {
|
||||
val request = chain.request()
|
||||
val response = chain.proceed(request)
|
||||
|
||||
val scramble = request.url.fragment ?: return response // return if no scramble fragment
|
||||
val tiles = buildList {
|
||||
scramble.split("-").forEachIndexed { index, s ->
|
||||
val scrambleInt = s.toInt()
|
||||
add(index, TilePos(scrambleInt / 4, scrambleInt % 4))
|
||||
}
|
||||
}
|
||||
|
||||
val scrambledImg = BitmapFactory.decodeStream(response.body.byteStream())
|
||||
val descrambledImg = drawDescrambledImage(scrambledImg, scrambledImg.width, scrambledImg.height, tiles)
|
||||
|
||||
val output = ByteArrayOutputStream()
|
||||
descrambledImg.compress(Bitmap.CompressFormat.JPEG, 90, output)
|
||||
|
||||
val body = output.toByteArray().toResponseBody("image/jpeg".toMediaType())
|
||||
|
||||
return response.newBuilder().body(body).build()
|
||||
}
|
||||
|
||||
private fun drawDescrambledImage(rawImage: Bitmap, width: Int, height: Int, tiles: List<TilePos>): Bitmap {
|
||||
// Prepare canvas
|
||||
val descrambledImg = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
|
||||
val canvas = Canvas(descrambledImg)
|
||||
|
||||
// Tile width and height(4x4)
|
||||
val tileWidth = width / 4
|
||||
val tileHeight = height / 4
|
||||
|
||||
// Draw rect
|
||||
var count = 0
|
||||
for (x in 0..3) {
|
||||
for (y in 0..3) {
|
||||
val desRect = Rect(x * tileWidth, y * tileHeight, (x + 1) * tileWidth, (y + 1) * tileHeight)
|
||||
val srcRect = Rect(
|
||||
tiles[count].x * tileWidth,
|
||||
tiles[count].y * tileHeight,
|
||||
(tiles[count].x + 1) * tileWidth,
|
||||
(tiles[count].y + 1) * tileHeight,
|
||||
)
|
||||
canvas.drawBitmap(rawImage, srcRect, desRect, null)
|
||||
count++
|
||||
}
|
||||
}
|
||||
return descrambledImg
|
||||
}
|
||||
}
|
@ -0,0 +1,16 @@
|
||||
package eu.kanade.tachiyomi.extension.all.comicgrowl
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
class PageResponse(
|
||||
val totalPages: Int,
|
||||
val result: List<PageResponseResult>,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class PageResponseResult(
|
||||
val imageUrl: String,
|
||||
val scramble: String,
|
||||
val sort: Int,
|
||||
)
|
@ -22,3 +22,6 @@ score_position_top=Top
|
||||
score_position_middle=Middle
|
||||
score_position_bottom=Bottom
|
||||
score_position_none=Hide Score
|
||||
chapter_score_filtering_title=Automatically de-duplicate chapters
|
||||
chapter_score_filtering_on=For each chapter, only displays the scanlator with the highest score
|
||||
chapter_score_filtering_off=Does not filterout any chapters based on score (any other scanlator filtering will still apply)
|
||||
|
@ -1,7 +1,7 @@
|
||||
ext {
|
||||
extName = 'Comick'
|
||||
extClass = '.ComickFactory'
|
||||
extVersionCode = 56
|
||||
extVersionCode = 57
|
||||
isNsfw = true
|
||||
}
|
||||
|
||||
|
@ -178,6 +178,20 @@ abstract class Comick(
|
||||
.commit()
|
||||
}
|
||||
}.also(screen::addPreference)
|
||||
|
||||
SwitchPreferenceCompat(screen.context).apply {
|
||||
key = CHAPTER_SCORE_FILTERING_PREF
|
||||
title = intl["chapter_score_filtering_title"]
|
||||
summaryOff = intl["chapter_score_filtering_off"]
|
||||
summaryOn = intl["chapter_score_filtering_on"]
|
||||
setDefaultValue(CHAPTER_SCORE_FILTERING_DEFAULT)
|
||||
|
||||
setOnPreferenceChangeListener { _, newValue ->
|
||||
preferences.edit()
|
||||
.putBoolean(CHAPTER_SCORE_FILTERING_PREF, newValue as Boolean)
|
||||
.commit()
|
||||
}
|
||||
}.also(screen::addPreference)
|
||||
}
|
||||
|
||||
private val SharedPreferences.ignoredGroups: Set<String>
|
||||
@ -224,6 +238,9 @@ abstract class Comick(
|
||||
private val SharedPreferences.scorePosition: String
|
||||
get() = getString(SCORE_POSITION_PREF, SCORE_POSITION_DEFAULT) ?: SCORE_POSITION_DEFAULT
|
||||
|
||||
private val SharedPreferences.chapterScoreFiltering: Boolean
|
||||
get() = getBoolean(CHAPTER_SCORE_FILTERING_PREF, CHAPTER_SCORE_FILTERING_DEFAULT)
|
||||
|
||||
override fun headersBuilder() = Headers.Builder().apply {
|
||||
add("Referer", "$baseUrl/")
|
||||
add("User-Agent", "Tachiyomi ${System.getProperty("http.agent")}")
|
||||
@ -546,9 +563,19 @@ abstract class Comick(
|
||||
|
||||
publishedChapter && noGroupBlock
|
||||
}
|
||||
.filterOnScore(preferences.chapterScoreFiltering)
|
||||
.map { it.toSChapter(mangaUrl) }
|
||||
}
|
||||
|
||||
private fun List<Chapter>.filterOnScore(shouldFilter: Boolean): Collection<Chapter> {
|
||||
if (shouldFilter) {
|
||||
return groupBy { it.chap }
|
||||
.map { (_, chapters) -> chapters.maxBy { it.score } }
|
||||
} else {
|
||||
return this
|
||||
}
|
||||
}
|
||||
|
||||
private val publishedDateFormat =
|
||||
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.ENGLISH).apply {
|
||||
timeZone = TimeZone.getTimeZone("UTC")
|
||||
@ -626,6 +653,8 @@ abstract class Comick(
|
||||
const val SCORE_POSITION_DEFAULT = "top"
|
||||
private const val LOCAL_TITLE_PREF = "LocalTitle"
|
||||
private const val LOCAL_TITLE_DEFAULT = false
|
||||
private const val CHAPTER_SCORE_FILTERING_PREF = "ScoreAutoFiltering"
|
||||
private const val CHAPTER_SCORE_FILTERING_DEFAULT = false
|
||||
private const val LIMIT = 20
|
||||
private const val CHAPTERS_LIMIT = 99999
|
||||
}
|
||||
|
@ -199,10 +199,14 @@ class Chapter(
|
||||
private val title: String = "",
|
||||
@SerialName("created_at") private val createdAt: String = "",
|
||||
@SerialName("publish_at") val publishedAt: String = "",
|
||||
private val chap: String = "",
|
||||
val chap: String = "",
|
||||
private val vol: String = "",
|
||||
@SerialName("group_name") val groups: List<String> = emptyList(),
|
||||
@SerialName("up_count") private val upCount: Int,
|
||||
@SerialName("down_count") private val downCount: Int,
|
||||
) {
|
||||
val score get() = upCount - downCount
|
||||
|
||||
fun toSChapter(mangaUrl: String) = SChapter.create().apply {
|
||||
url = "$mangaUrl/$hid-chapter-$chap-$lang"
|
||||
name = beautifyChapterName(vol, chap, title)
|
||||
|
@ -1,7 +1,7 @@
|
||||
ext {
|
||||
extName = 'DeviantArt'
|
||||
extClass = '.DeviantArt'
|
||||
extVersionCode = 8
|
||||
extVersionCode = 9
|
||||
isNsfw = true
|
||||
}
|
||||
|
||||
|
@ -13,6 +13,7 @@ import eu.kanade.tachiyomi.source.model.SManga
|
||||
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||
import eu.kanade.tachiyomi.util.asJsoup
|
||||
import keiyoushi.utils.getPreferencesLazy
|
||||
import keiyoushi.utils.tryParse
|
||||
import okhttp3.Headers
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.Request
|
||||
@ -20,7 +21,6 @@ import okhttp3.Response
|
||||
import org.jsoup.Jsoup
|
||||
import org.jsoup.nodes.Document
|
||||
import org.jsoup.parser.Parser
|
||||
import java.text.ParseException
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
|
||||
@ -43,14 +43,6 @@ class DeviantArt : HttpSource(), ConfigurableSource {
|
||||
SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss zzz", Locale.ENGLISH)
|
||||
}
|
||||
|
||||
private fun parseDate(dateStr: String?): Long {
|
||||
return try {
|
||||
dateFormat.parse(dateStr ?: "")!!.time
|
||||
} catch (_: ParseException) {
|
||||
0L
|
||||
}
|
||||
}
|
||||
|
||||
override fun popularMangaRequest(page: Int): Request {
|
||||
throw UnsupportedOperationException(SEARCH_FORMAT_MSG)
|
||||
}
|
||||
@ -94,9 +86,9 @@ class DeviantArt : HttpSource(), ConfigurableSource {
|
||||
return SManga.create().apply {
|
||||
setUrlWithoutDomain(response.request.url.toString())
|
||||
author = document.title().substringBefore(" ")
|
||||
title = when (artistInTitle) {
|
||||
true -> "$author - $galleryName"
|
||||
false -> galleryName
|
||||
title = when {
|
||||
artistInTitle -> "$author - $galleryName"
|
||||
else -> galleryName
|
||||
}
|
||||
description = gallery?.selectFirst(".legacy-journal")?.wholeText()
|
||||
thumbnail_url = gallery?.selectFirst("img[property=contentUrl]")?.absUrl("src")
|
||||
@ -142,7 +134,7 @@ class DeviantArt : HttpSource(), ConfigurableSource {
|
||||
SChapter.create().apply {
|
||||
setUrlWithoutDomain(it.selectFirst("link")!!.text())
|
||||
name = it.selectFirst("title")!!.text()
|
||||
date_upload = parseDate(it.selectFirst("pubDate")?.text())
|
||||
date_upload = dateFormat.tryParse(it.selectFirst("pubDate")?.text())
|
||||
scanlator = it.selectFirst("media|credit")?.text()
|
||||
}
|
||||
}
|
||||
@ -162,16 +154,17 @@ class DeviantArt : HttpSource(), ConfigurableSource {
|
||||
|
||||
override fun pageListParse(response: Response): List<Page> {
|
||||
val document = response.asJsoup()
|
||||
val firstImageUrl = document.selectFirst("img[fetchpriority=high]")?.absUrl("src")
|
||||
return when (val buttons = document.selectFirst("[draggable=false]")?.children()) {
|
||||
null -> listOf(Page(0, imageUrl = firstImageUrl))
|
||||
else -> buttons.mapIndexed { i, button ->
|
||||
val buttons = document.selectFirst("[draggable=false]")?.children()
|
||||
return if (buttons == null) {
|
||||
val imageUrl = document.selectFirst("img[fetchpriority=high]")?.absUrl("src")
|
||||
listOf(Page(0, imageUrl = imageUrl))
|
||||
} else {
|
||||
buttons.mapIndexed { i, button ->
|
||||
// Remove everything past "/v1/" to get original instead of thumbnail
|
||||
val imageUrl = button.selectFirst("img")?.absUrl("src")?.substringBefore("/v1/")
|
||||
// But need to preserve the query parameter where the token is
|
||||
val imageUrl = button.selectFirst("img")?.absUrl("src")
|
||||
?.replaceFirst(Regex("""/v1(/.*)?(?=\?)"""), "")
|
||||
Page(i, imageUrl = imageUrl)
|
||||
}.also {
|
||||
// First image needs token to get original, which is included in firstImageUrl
|
||||
it[0].imageUrl = firstImageUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,168 +0,0 @@
|
||||
package eu.kanade.tachiyomi.extension.all.eternalmangas
|
||||
|
||||
import eu.kanade.tachiyomi.multisrc.mangaesp.MangaEsp
|
||||
import eu.kanade.tachiyomi.multisrc.mangaesp.SeriesDto
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.network.POST
|
||||
import eu.kanade.tachiyomi.source.model.Filter
|
||||
import eu.kanade.tachiyomi.source.model.FilterList
|
||||
import eu.kanade.tachiyomi.source.model.MangasPage
|
||||
import eu.kanade.tachiyomi.source.model.Page
|
||||
import eu.kanade.tachiyomi.source.model.SChapter
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import eu.kanade.tachiyomi.util.asJsoup
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import okhttp3.FormBody
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import org.jsoup.Jsoup
|
||||
import rx.Observable
|
||||
import java.text.ParseException
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
|
||||
open class EternalMangas(
|
||||
lang: String,
|
||||
private val internalLang: String,
|
||||
) : MangaEsp(
|
||||
"EternalMangas",
|
||||
"https://eternalmangas.com",
|
||||
lang,
|
||||
) {
|
||||
override val useApiSearch = true
|
||||
|
||||
override fun fetchPopularManga(page: Int): Observable<MangasPage> {
|
||||
return super.fetchSearchManga(page, "", createSortFilter("views", false))
|
||||
}
|
||||
|
||||
override fun fetchLatestUpdates(page: Int): Observable<MangasPage> {
|
||||
return super.fetchSearchManga(page, "", createSortFilter("updated_at", false))
|
||||
}
|
||||
|
||||
override fun List<SeriesDto>.additionalParse(): List<SeriesDto> {
|
||||
return this.filter { it.language == internalLang }.toMutableList()
|
||||
}
|
||||
|
||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
||||
return GET("$baseUrl/comics", headers)
|
||||
}
|
||||
|
||||
private val dataUrl = "https://raw.githubusercontent.com/bapeey/extensions-tools/refs/heads/main/keiyoushi/eternalmangas/values.txt"
|
||||
|
||||
override fun searchMangaParse(
|
||||
response: Response,
|
||||
page: Int,
|
||||
query: String,
|
||||
filters: FilterList,
|
||||
): MangasPage {
|
||||
val (apiComicsUrl, jsonHeaders, useApi, scriptSelector, comicsRegex) = client.newCall(GET(dataUrl)).execute().body.string().split("\n")
|
||||
val apiSearch = useApi == "1"
|
||||
comicsList = if (apiSearch) {
|
||||
val headersJson = json.parseToJsonElement(jsonHeaders).jsonObject
|
||||
val apiHeaders = headersBuilder()
|
||||
headersJson.forEach { (key, jsonElement) ->
|
||||
var value = jsonElement.jsonPrimitive.contentOrNull.orEmpty()
|
||||
if (value.startsWith("1-")) {
|
||||
val match = value.substringAfter("-").toRegex().find(response.body.string())
|
||||
value = match?.groupValues?.get(1).orEmpty()
|
||||
} else {
|
||||
value = value.substringAfter("-")
|
||||
}
|
||||
apiHeaders.add(key, value)
|
||||
}
|
||||
val apiResponse = client.newCall(GET(apiComicsUrl, apiHeaders.build())).execute()
|
||||
json.decodeFromString<List<SeriesDto>>(apiResponse.body.string()).toMutableList()
|
||||
} else {
|
||||
val script = response.asJsoup().select(scriptSelector).joinToString { it.data() }
|
||||
val jsonString = comicsRegex.toRegex().find(script)?.groupValues?.get(1)
|
||||
?: throw Exception(intl["comics_list_error"])
|
||||
val unescapedJson = jsonString.unescape()
|
||||
json.decodeFromString<List<SeriesDto>>(unescapedJson).toMutableList()
|
||||
}
|
||||
|
||||
return parseComicsList(page, query, filters)
|
||||
}
|
||||
|
||||
override fun mangaDetailsParse(response: Response) = SManga.create().apply {
|
||||
val body = jsRedirect(response)
|
||||
|
||||
MANGA_DETAILS_REGEX.find(body)?.groupValues?.get(1)?.let {
|
||||
val unescapedJson = it.unescape()
|
||||
return json.decodeFromString<SeriesDto>(unescapedJson).toSMangaDetails()
|
||||
}
|
||||
|
||||
val document = Jsoup.parse(body)
|
||||
with(document.selectFirst("div#info")!!) {
|
||||
title = select("div:has(p.font-bold:contains(Títuto)) > p.text-sm").text()
|
||||
author = select("div:has(p.font-bold:contains(Autor)) > p.text-sm").text()
|
||||
artist = select("div:has(p.font-bold:contains(Artista)) > p.text-sm").text()
|
||||
genre = select("div:has(p.font-bold:contains(Género)) > p.text-sm > span").joinToString { it.ownText() }
|
||||
}
|
||||
description = document.select("div#sinopsis p").text()
|
||||
thumbnail_url = document.selectFirst("div.contenedor img.object-cover")?.imgAttr()
|
||||
}
|
||||
|
||||
private val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US)
|
||||
|
||||
override fun chapterListParse(response: Response): List<SChapter> {
|
||||
val body = jsRedirect(response)
|
||||
|
||||
MANGA_DETAILS_REGEX.find(body)?.groupValues?.get(1)?.let {
|
||||
val unescapedJson = it.unescape()
|
||||
val series = json.decodeFromString<SeriesDto>(unescapedJson)
|
||||
return series.chapters.map { chapter -> chapter.toSChapter(seriesPath, series.slug) }
|
||||
}
|
||||
|
||||
val document = Jsoup.parse(body)
|
||||
return document.select("div.contenedor > div.grid > div > a").map {
|
||||
SChapter.create().apply {
|
||||
name = it.selectFirst("span.text-sm")!!.text()
|
||||
date_upload = try {
|
||||
it.selectFirst("span.chapter-date")?.attr("data-date")?.let { date ->
|
||||
dateFormat.parse(date)?.time
|
||||
} ?: 0
|
||||
} catch (e: ParseException) {
|
||||
0
|
||||
}
|
||||
setUrlWithoutDomain(it.selectFirst("a")!!.attr("href"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun pageListParse(response: Response): List<Page> {
|
||||
val doc = Jsoup.parse(jsRedirect(response))
|
||||
return doc.select("main > img").mapIndexed { i, img ->
|
||||
Page(i, imageUrl = img.imgAttr())
|
||||
}
|
||||
}
|
||||
|
||||
private fun jsRedirect(response: Response): String {
|
||||
var body = response.body.string()
|
||||
val document = Jsoup.parse(body)
|
||||
document.selectFirst("body > form[method=post], body > div[hidden] > form[method=post]")?.let {
|
||||
val action = it.attr("action")
|
||||
val inputs = it.select("input")
|
||||
|
||||
val form = FormBody.Builder()
|
||||
inputs.forEach { input ->
|
||||
form.add(input.attr("name"), input.attr("value"))
|
||||
}
|
||||
|
||||
body = client.newCall(POST(action, headers, form.build())).execute().body.string()
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
private fun createSortFilter(value: String, ascending: Boolean = false): FilterList {
|
||||
val sortProperties = getSortProperties()
|
||||
val index = sortProperties.indexOfFirst { it.value == value }.takeIf { it >= 0 } ?: 0
|
||||
return FilterList(
|
||||
SortByFilter("", sortProperties).apply {
|
||||
state = Filter.Sort.Selection(index, ascending)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
@ -1,15 +0,0 @@
|
||||
package eu.kanade.tachiyomi.extension.all.eternalmangas
|
||||
|
||||
import eu.kanade.tachiyomi.source.SourceFactory
|
||||
|
||||
class EternalMangasFactory : SourceFactory {
|
||||
override fun createSources() = listOf(
|
||||
EternalMangasES(),
|
||||
EternalMangasEN(),
|
||||
EternalMangasPTBR(),
|
||||
)
|
||||
}
|
||||
|
||||
class EternalMangasES : EternalMangas("es", "es")
|
||||
class EternalMangasEN : EternalMangas("en", "en")
|
||||
class EternalMangasPTBR : EternalMangas("pt-BR", "pt")
|
@ -1,7 +1,7 @@
|
||||
ext {
|
||||
extName = 'Hentai Cosplay'
|
||||
extClass = '.HentaiCosplay'
|
||||
extVersionCode = 4
|
||||
extVersionCode = 5
|
||||
isNsfw = true
|
||||
}
|
||||
|
||||
|
@ -198,7 +198,7 @@ class HentaiCosplay : HttpSource() {
|
||||
override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> = Observable.fromCallable {
|
||||
SChapter.create().apply {
|
||||
name = "Gallery"
|
||||
url = manga.url.removeSuffix("/").plus("/attachment/1/")
|
||||
url = manga.url.replace("/image/", "/story/")
|
||||
date_upload = runCatching {
|
||||
dateFormat.parse(dateCache[manga.url]!!)!!.time
|
||||
}.getOrDefault(0L)
|
||||
@ -209,21 +209,13 @@ class HentaiCosplay : HttpSource() {
|
||||
|
||||
override fun pageListParse(response: Response): List<Page> {
|
||||
val document = response.asJsoup()
|
||||
val pageUrl = document.location().substringBeforeLast("/1/")
|
||||
|
||||
val totalPages = document.selectFirst("#right_sidebar > h3, #title > h2")
|
||||
?.text()?.trim()
|
||||
?.run { pagesRegex.find(this)?.groupValues?.get(1) }
|
||||
?.toIntOrNull()
|
||||
?: return emptyList()
|
||||
|
||||
val pages = (1..totalPages).map {
|
||||
Page(it, "$pageUrl/$it/")
|
||||
}
|
||||
|
||||
pages[0].imageUrl = imageUrlParse(document)
|
||||
|
||||
return pages
|
||||
return document.select("amp-img[src*=upload]")
|
||||
.mapIndexed { index, element ->
|
||||
Page(
|
||||
index = index,
|
||||
imageUrl = element.attr("src"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun imageUrlParse(response: Response) = imageUrlParse(response.asJsoup())
|
||||
|
@ -1,7 +1,7 @@
|
||||
ext {
|
||||
extName = 'Hitomi'
|
||||
extClass = '.HitomiFactory'
|
||||
extVersionCode = 39
|
||||
extVersionCode = 41
|
||||
isNsfw = true
|
||||
}
|
||||
|
||||
|
@ -15,6 +15,7 @@ import keiyoushi.utils.tryParse
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
@ -24,12 +25,14 @@ import okhttp3.HttpUrl
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okhttp3.internal.http2.ErrorCode
|
||||
import okhttp3.internal.http2.StreamResetException
|
||||
import rx.Observable
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
import java.security.MessageDigest
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.LinkedHashSet
|
||||
import java.util.LinkedList
|
||||
import java.util.Locale
|
||||
import kotlin.math.min
|
||||
@ -53,9 +56,6 @@ class Hitomi(
|
||||
|
||||
override val client = network.cloudflareClient.newBuilder()
|
||||
.addInterceptor(::imageUrlInterceptor)
|
||||
.apply {
|
||||
interceptors().add(0, ::streamResetRetry)
|
||||
}
|
||||
.build()
|
||||
|
||||
override fun headersBuilder() = super.headersBuilder()
|
||||
@ -118,7 +118,22 @@ class Hitomi(
|
||||
}
|
||||
}
|
||||
|
||||
return client.newCall(request).awaitSuccess().use { it.body.bytes() }
|
||||
val tries = 5
|
||||
repeat(tries) { attempt ->
|
||||
try {
|
||||
return client.newCall(request).awaitSuccess().use { it.body.bytes() }
|
||||
} catch (e: StreamResetException) {
|
||||
if (e.errorCode == ErrorCode.INTERNAL_ERROR) {
|
||||
if (attempt == tries - 1) throw e // last attempt, rethrow
|
||||
Log.e(name, "Stream reset attempt ${attempt + 1}", e)
|
||||
delay((attempt + 1).seconds)
|
||||
} else {
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw Exception("Unreachable code")
|
||||
}
|
||||
|
||||
private suspend fun hitomiSearch(
|
||||
@ -294,8 +309,6 @@ class Hitomi(
|
||||
|
||||
val inbuf = getRangedResponse(url, offset.until(offset + length))
|
||||
|
||||
val galleryIDs = mutableSetOf<Int>()
|
||||
|
||||
val buffer =
|
||||
ByteBuffer
|
||||
.wrap(inbuf)
|
||||
@ -312,6 +325,9 @@ class Hitomi(
|
||||
"inbuf.byteLength ${inbuf.size} != expected_length $expectedLength"
|
||||
}
|
||||
|
||||
// we know total number so avoid internal resize overhead
|
||||
val galleryIDs = LinkedHashSet<Int>(numberOfGalleryIDs, 1.0f)
|
||||
|
||||
for (i in 0.until(numberOfGalleryIDs))
|
||||
galleryIDs.add(buffer.int)
|
||||
|
||||
@ -390,12 +406,16 @@ class Hitomi(
|
||||
}
|
||||
|
||||
val bytes = getRangedResponse(nozomiAddress, range)
|
||||
val nozomi = mutableSetOf<Int>()
|
||||
|
||||
val arrayBuffer = ByteBuffer
|
||||
.wrap(bytes)
|
||||
.order(ByteOrder.BIG_ENDIAN)
|
||||
|
||||
val size = arrayBuffer.remaining() / Int.SIZE_BYTES
|
||||
|
||||
// we know total number so avoid internal resize overhead
|
||||
val nozomi = LinkedHashSet<Int>(size, 1.0f)
|
||||
|
||||
while (arrayBuffer.hasRemaining())
|
||||
nozomi.add(arrayBuffer.int)
|
||||
|
||||
@ -667,20 +687,6 @@ class Hitomi(
|
||||
return hash.replace(Regex("""^.*(..)(.)$"""), "$2/$1")
|
||||
}
|
||||
|
||||
private fun streamResetRetry(chain: Interceptor.Chain): Response {
|
||||
return try {
|
||||
chain.proceed(chain.request())
|
||||
} catch (e: StreamResetException) {
|
||||
Log.e(name, "reset", e)
|
||||
if (e.message.orEmpty().contains("INTERNAL_ERROR")) {
|
||||
Thread.sleep(2.seconds.inWholeMilliseconds)
|
||||
chain.proceed(chain.request())
|
||||
} else {
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun imageUrlInterceptor(chain: Interceptor.Chain): Response {
|
||||
val request = chain.request()
|
||||
if (request.url.host != IMAGE_LOOPBACK_HOST) {
|
||||
|
@ -24,7 +24,7 @@ class ImageFile(
|
||||
val hash: String,
|
||||
private val name: String,
|
||||
) {
|
||||
val isGif get() = name.endsWith(".gif")
|
||||
val isGif get() = name.endsWith(".gif") || name.endsWith(".webp")
|
||||
}
|
||||
|
||||
@Serializable
|
||||
|
@ -1,7 +1,7 @@
|
||||
ext {
|
||||
extName = 'HOLONOMETRIA'
|
||||
extClass = '.HolonometriaFactory'
|
||||
extVersionCode = 2
|
||||
extVersionCode = 3
|
||||
isNsfw = false
|
||||
}
|
||||
|
||||
|
@ -8,12 +8,12 @@ import eu.kanade.tachiyomi.source.model.SChapter
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import eu.kanade.tachiyomi.source.online.ParsedHttpSource
|
||||
import eu.kanade.tachiyomi.util.asJsoup
|
||||
import keiyoushi.utils.tryParse
|
||||
import okhttp3.Response
|
||||
import org.jsoup.nodes.Document
|
||||
import org.jsoup.nodes.Element
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class Holonometria(
|
||||
override val lang: String,
|
||||
@ -22,31 +22,23 @@ class Holonometria(
|
||||
|
||||
override val name = "HOLONOMETRIA"
|
||||
|
||||
override val baseUrl = "https://alt.hololive.tv"
|
||||
override val baseUrl = "https://holoearth.com"
|
||||
|
||||
override val supportsLatest = false
|
||||
|
||||
override val client = network.cloudflareClient.newBuilder()
|
||||
.readTimeout(60, TimeUnit.SECONDS)
|
||||
.build()
|
||||
override fun popularMangaRequest(page: Int) = GET("$baseUrl/${langPath}alt/holonometria/manga/", headers)
|
||||
|
||||
override fun headersBuilder() = super.headersBuilder()
|
||||
.add("Referer", "$baseUrl/")
|
||||
|
||||
override fun popularMangaRequest(page: Int) =
|
||||
GET("$baseUrl/holonometria/$langPath", headers)
|
||||
|
||||
override fun popularMangaSelector() = "#Story article:has(a[href*=/manga/])"
|
||||
override fun popularMangaSelector() = ".manga__item"
|
||||
override fun popularMangaNextPageSelector() = null
|
||||
|
||||
override fun popularMangaFromElement(element: Element) = SManga.create().apply {
|
||||
setUrlWithoutDomain(element.selectFirst("a")!!.attr("href"))
|
||||
title = element.select(".ttl").text()
|
||||
title = element.select(".manga__title").text()
|
||||
thumbnail_url = element.selectFirst("img")?.attr("abs:src")
|
||||
}
|
||||
|
||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList) =
|
||||
GET("$baseUrl/holonometria/$langPath#${query.trim()}", headers)
|
||||
GET("$baseUrl/${langPath}alt/holonometria/manga/#${query.trim()}", headers)
|
||||
|
||||
override fun searchMangaParse(response: Response): MangasPage {
|
||||
val document = response.asJsoup()
|
||||
@ -64,10 +56,10 @@ class Holonometria(
|
||||
override fun searchMangaFromElement(element: Element) = popularMangaFromElement(element)
|
||||
|
||||
override fun mangaDetailsParse(document: Document) = SManga.create().apply {
|
||||
title = document.select(".md-ttl__pages").text()
|
||||
thumbnail_url = document.select(".mangainfo img").attr("abs:src")
|
||||
description = document.select(".mangainfo aside").text()
|
||||
val info = document.select(".mangainfo footer").html().split("<br>")
|
||||
title = document.select(".alt-nav__met-sub-link.is-current").text()
|
||||
thumbnail_url = document.select(".manga-detail__thumb img").attr("abs:src")
|
||||
description = document.select(".manga-detail__caption").text()
|
||||
val info = document.select(".manga-detail__person").html().split("<br>")
|
||||
author = info.firstOrNull { desc -> manga.any { desc.contains(it, true) } }
|
||||
?.substringAfter(":")
|
||||
?.substringAfter(":")
|
||||
@ -80,57 +72,23 @@ class Holonometria(
|
||||
?.replace("&", "&")
|
||||
}
|
||||
|
||||
override fun chapterListRequest(manga: SManga) =
|
||||
paginatedChapterListRequest(manga.url, 1)
|
||||
override fun chapterListRequest(manga: SManga) = GET("$baseUrl/${manga.url}", headers)
|
||||
|
||||
private fun paginatedChapterListRequest(mangaUrl: String, page: Int) =
|
||||
GET("$baseUrl$mangaUrl".removeSuffix("/") + if (page == 1) "/" else "/page/$page/", headers)
|
||||
override fun chapterListSelector() = ".manga-detail__list .manga-detail__list-item"
|
||||
|
||||
override fun chapterListParse(response: Response): List<SChapter> {
|
||||
val document = response.asJsoup()
|
||||
val mangaUrl = response.request.url.toString()
|
||||
.substringAfter(baseUrl)
|
||||
.substringBefore("page/")
|
||||
|
||||
val chapters = document.select(chapterListSelector())
|
||||
.map(::chapterFromElement)
|
||||
.toMutableList()
|
||||
|
||||
val lastPage = document.select(".pagenation-list a").last()
|
||||
?.text()?.toIntOrNull() ?: return chapters
|
||||
|
||||
for (page in 2..lastPage) {
|
||||
val request = paginatedChapterListRequest(mangaUrl, page)
|
||||
val newDocument = client.newCall(request).execute().asJsoup()
|
||||
|
||||
val moreChapters = newDocument.select(chapterListSelector())
|
||||
.map(::chapterFromElement)
|
||||
|
||||
chapters.addAll(moreChapters)
|
||||
}
|
||||
|
||||
return chapters
|
||||
}
|
||||
|
||||
override fun chapterListSelector() = "#Archive article"
|
||||
override fun chapterListParse(response: Response): List<SChapter> =
|
||||
super.chapterListParse(response).reversed()
|
||||
|
||||
override fun chapterFromElement(element: Element) = SChapter.create().apply {
|
||||
setUrlWithoutDomain(element.selectFirst("a")!!.attr("href"))
|
||||
name = element.select(".ttl").text()
|
||||
date_upload = element.selectFirst(".data--date")?.text().parseDate()
|
||||
scanlator = element.selectFirst(".data--category")?.text()
|
||||
}
|
||||
|
||||
private fun String?.parseDate(): Long {
|
||||
return runCatching {
|
||||
dateFormat.parse(this!!)!!.time
|
||||
}.getOrDefault(0L)
|
||||
name = element.select(".manga-detail__list-title").text()
|
||||
date_upload = dateFormat.tryParse(element.selectFirst(".manga-detail__list-date")?.text())
|
||||
}
|
||||
|
||||
override fun pageListParse(document: Document): List<Page> {
|
||||
return document.select("#js-mangaviewer img").mapIndexed { idx, img ->
|
||||
return document.select(".manga-detail__swiper-wrapper img").mapIndexed { idx, img ->
|
||||
Page(idx, "", img.attr("abs:src"))
|
||||
}
|
||||
}.reversed()
|
||||
}
|
||||
|
||||
companion object {
|
||||
@ -138,7 +96,7 @@ class Holonometria(
|
||||
private val script = listOf("script", "naskah", "脚本")
|
||||
|
||||
private val dateFormat by lazy {
|
||||
SimpleDateFormat("yy.MM.dd", Locale.ENGLISH)
|
||||
SimpleDateFormat("yyyy.MM.dd", Locale.ENGLISH)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1,10 +0,0 @@
|
||||
ext {
|
||||
extName = 'KDT Scans'
|
||||
extClass = '.KdtScans'
|
||||
themePkg = 'madara'
|
||||
baseUrl = 'https://kdtscans.com'
|
||||
overrideVersionCode = 2
|
||||
isNsfw = true
|
||||
}
|
||||
|
||||
apply from: "$rootDir/common.gradle"
|