From d62969aafc504b24750b99f0c62a075af3483723 Mon Sep 17 00:00:00 2001 From: eater <=@eater.me> Date: Tue, 16 Jun 2020 21:03:09 +0200 Subject: [PATCH] Initial commit --- .gitignore | 6 + README.md | 3 + build.gradle | 80 + config/test.toml | 8 + docker-compose.yml | 9 + gradle.properties | 1 + gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 58695 bytes gradle/wrapper/gradle-wrapper.properties | 6 + gradlew | 183 ++ gradlew.bat | 100 + settings.gradle | 2 + .../kotlin/moe/odango/index/cli/AniDBSync.kt | 26 + .../moe/odango/index/cli/DatabaseMigration.kt | 28 + .../moe/odango/index/cli/ElasticIndex.kt | 10 + .../kotlin/moe/odango/index/cli/HTTPServer.kt | 10 + .../index/cli/MyAnimeListListingSync.kt | 13 + .../odango/index/cli/MyAnimeListPageSync.kt | 13 + .../index/config/DatabaseConfiguration.kt | 8 + .../config/ElasticSearchConfiguration.kt | 8 + .../odango/index/config/IndexConfiguration.kt | 6 + src/main/kotlin/moe/odango/index/container.kt | 59 + .../moe/odango/index/entity/AniDBInfo.kt | 58 + .../kotlin/moe/odango/index/entity/Anime.kt | 50 + .../moe/odango/index/entity/AnimeGenre.kt | 27 + .../moe/odango/index/entity/AnimeProducer.kt | 28 + .../moe/odango/index/entity/AnimeRelation.kt | 82 + .../moe/odango/index/entity/AnimeSeries.kt | 20 + .../moe/odango/index/entity/AnimeTag.kt | 27 + .../kotlin/moe/odango/index/entity/Genre.kt | 21 + .../odango/index/entity/MyAnimeListInfo.kt | 89 + .../moe/odango/index/entity/Producer.kt | 22 + .../kotlin/moe/odango/index/entity/Tag.kt | 25 + .../kotlin/moe/odango/index/entity/Title.kt | 63 + .../kotlin/moe/odango/index/es/Indexer.kt | 141 ++ .../moe/odango/index/es/dto/AnimeDTO.kt | 13 + .../index/es/dto/AnimeDescriptionDTO.kt | 8 + .../moe/odango/index/es/dto/AnimeTitleDTO.kt | 10 + .../kotlin/moe/odango/index/http/Server.kt | 41 + .../odango/index/http/graphql/AnimeService.kt | 100 + .../index/http/graphql/dto/AnimeItem.kt | 7 + .../index/http/graphql/dto/AnimeTitleItem.kt | 11 + .../http/graphql/dto/AutoCompleteItem.kt | 6 + src/main/kotlin/moe/odango/index/main.kt | 30 + .../index/scraper/mal/AnimeListScraper.kt | 33 + .../index/scraper/mal/AnimePageScraper.kt | 246 +++ .../moe/odango/index/sync/AniDBTitleSync.kt | 178 ++ .../moe/odango/index/sync/AniDBXMLSync.kt | 318 ++++ .../index/sync/MyAnimeListListingSync.kt | 122 ++ .../odango/index/sync/MyAnimeListPageSync.kt | 327 ++++ .../moe/odango/index/sync/ScheduledSync.kt | 7 + .../moe/odango/index/utils/EntityHelper.kt | 6 + .../odango/index/utils/ISOTextConverter.kt | 18 + .../moe/odango/index/utils/InfoSource.kt | 7 + .../kotlin/moe/odango/index/utils/IntDate.kt | 29 + .../kotlin/moe/odango/index/utils/MergeMap.kt | 50 + .../moe/odango/index/utils/NestedQuery.kt | 28 + .../odango/index/utils/ProducerFunction.kt | 7 + .../moe/odango/index/utils/TermsSetQuery.kt | 38 + .../index/utils/XMLOutputStreamReader.kt | 26 + .../kotlin/moe/odango/index/utils/helper.kt | 13 + .../moe/odango/index/utils/textWithBreaks.kt | 74 + src/main/resources/web/index.html | 1 + .../test/scraper/mal/AnimePageScraperTest.kt | 208 +++ src/test/resources/test-pages/1558.html | 1288 +++++++++++++ src/test/resources/test-pages/1581.html | 1629 +++++++++++++++++ src/test/resources/test-pages/25313.html | 1213 ++++++++++++ src/test/resources/test-pages/616.html | 1435 +++++++++++++++ src/test/resources/test-pages/817.html | 1237 +++++++++++++ 68 files changed, 9996 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 build.gradle create mode 100644 config/test.toml create mode 100644 docker-compose.yml create mode 100644 gradle.properties create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100755 gradlew create mode 100644 gradlew.bat create mode 100644 settings.gradle create mode 100644 src/main/kotlin/moe/odango/index/cli/AniDBSync.kt create mode 100644 src/main/kotlin/moe/odango/index/cli/DatabaseMigration.kt create mode 100644 src/main/kotlin/moe/odango/index/cli/ElasticIndex.kt create mode 100644 src/main/kotlin/moe/odango/index/cli/HTTPServer.kt create mode 100644 src/main/kotlin/moe/odango/index/cli/MyAnimeListListingSync.kt create mode 100644 src/main/kotlin/moe/odango/index/cli/MyAnimeListPageSync.kt create mode 100644 src/main/kotlin/moe/odango/index/config/DatabaseConfiguration.kt create mode 100644 src/main/kotlin/moe/odango/index/config/ElasticSearchConfiguration.kt create mode 100644 src/main/kotlin/moe/odango/index/config/IndexConfiguration.kt create mode 100644 src/main/kotlin/moe/odango/index/container.kt create mode 100644 src/main/kotlin/moe/odango/index/entity/AniDBInfo.kt create mode 100644 src/main/kotlin/moe/odango/index/entity/Anime.kt create mode 100644 src/main/kotlin/moe/odango/index/entity/AnimeGenre.kt create mode 100644 src/main/kotlin/moe/odango/index/entity/AnimeProducer.kt create mode 100644 src/main/kotlin/moe/odango/index/entity/AnimeRelation.kt create mode 100644 src/main/kotlin/moe/odango/index/entity/AnimeSeries.kt create mode 100644 src/main/kotlin/moe/odango/index/entity/AnimeTag.kt create mode 100644 src/main/kotlin/moe/odango/index/entity/Genre.kt create mode 100644 src/main/kotlin/moe/odango/index/entity/MyAnimeListInfo.kt create mode 100644 src/main/kotlin/moe/odango/index/entity/Producer.kt create mode 100644 src/main/kotlin/moe/odango/index/entity/Tag.kt create mode 100644 src/main/kotlin/moe/odango/index/entity/Title.kt create mode 100644 src/main/kotlin/moe/odango/index/es/Indexer.kt create mode 100644 src/main/kotlin/moe/odango/index/es/dto/AnimeDTO.kt create mode 100644 src/main/kotlin/moe/odango/index/es/dto/AnimeDescriptionDTO.kt create mode 100644 src/main/kotlin/moe/odango/index/es/dto/AnimeTitleDTO.kt create mode 100644 src/main/kotlin/moe/odango/index/http/Server.kt create mode 100644 src/main/kotlin/moe/odango/index/http/graphql/AnimeService.kt create mode 100644 src/main/kotlin/moe/odango/index/http/graphql/dto/AnimeItem.kt create mode 100644 src/main/kotlin/moe/odango/index/http/graphql/dto/AnimeTitleItem.kt create mode 100644 src/main/kotlin/moe/odango/index/http/graphql/dto/AutoCompleteItem.kt create mode 100644 src/main/kotlin/moe/odango/index/main.kt create mode 100644 src/main/kotlin/moe/odango/index/scraper/mal/AnimeListScraper.kt create mode 100644 src/main/kotlin/moe/odango/index/scraper/mal/AnimePageScraper.kt create mode 100644 src/main/kotlin/moe/odango/index/sync/AniDBTitleSync.kt create mode 100644 src/main/kotlin/moe/odango/index/sync/AniDBXMLSync.kt create mode 100644 src/main/kotlin/moe/odango/index/sync/MyAnimeListListingSync.kt create mode 100644 src/main/kotlin/moe/odango/index/sync/MyAnimeListPageSync.kt create mode 100644 src/main/kotlin/moe/odango/index/sync/ScheduledSync.kt create mode 100644 src/main/kotlin/moe/odango/index/utils/EntityHelper.kt create mode 100644 src/main/kotlin/moe/odango/index/utils/ISOTextConverter.kt create mode 100644 src/main/kotlin/moe/odango/index/utils/InfoSource.kt create mode 100644 src/main/kotlin/moe/odango/index/utils/IntDate.kt create mode 100644 src/main/kotlin/moe/odango/index/utils/MergeMap.kt create mode 100644 src/main/kotlin/moe/odango/index/utils/NestedQuery.kt create mode 100644 src/main/kotlin/moe/odango/index/utils/ProducerFunction.kt create mode 100644 src/main/kotlin/moe/odango/index/utils/TermsSetQuery.kt create mode 100644 src/main/kotlin/moe/odango/index/utils/XMLOutputStreamReader.kt create mode 100644 src/main/kotlin/moe/odango/index/utils/helper.kt create mode 100644 src/main/kotlin/moe/odango/index/utils/textWithBreaks.kt create mode 100644 src/main/resources/web/index.html create mode 100644 src/test/kotlin/moe/odango/index/test/scraper/mal/AnimePageScraperTest.kt create mode 100644 src/test/resources/test-pages/1558.html create mode 100644 src/test/resources/test-pages/1581.html create mode 100644 src/test/resources/test-pages/25313.html create mode 100644 src/test/resources/test-pages/616.html create mode 100644 src/test/resources/test-pages/817.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e2f2036 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +*.sqlite +*.sqlite +out +build +.gradle +.idea diff --git a/README.md b/README.md new file mode 100644 index 0000000..4ee7c55 --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# Index (Librorum Animum) + + diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..3b8a789 --- /dev/null +++ b/build.gradle @@ -0,0 +1,80 @@ +plugins { + id 'org.jetbrains.kotlin.jvm' version '1.3.72' + id 'org.jetbrains.kotlin.kapt' version '1.3.72' + id 'application' + +} + +group 'moe.odango' +version '1.0-SNAPSHOT' + +repositories { + mavenCentral() + jcenter() + maven { url 'https://jitpack.io' } +} + +test { + useJUnitPlatform() +} + +dependencies { + implementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk8' + + implementation 'org.xerial:sqlite-jdbc:3.31.1' + + implementation 'io.ktor:ktor-server-core:1.3.2' + implementation 'io.ktor:ktor-server-netty:1.3.2' + implementation 'io.ktor:ktor-jackson:1.3.2' + + implementation 'io.requery:requery:1.6.1' + implementation 'io.requery:requery-kotlin:1.6.1' + kapt 'io.requery:requery-processor:1.6.1' + + implementation 'org.slf4j:slf4j-simple:1.6.1' + + implementation 'org.apache.logging.log4j:log4j-core:2.13.3' + + implementation 'com.github.kittinunf.fuel:fuel:2.2.0' + implementation 'com.github.kittinunf.fuel:fuel-coroutines:2.2.0' + + implementation 'com.fasterxml:aalto-xml:1.2.1' + + implementation 'org.kodein.di:kodein-di:7.0.0' + + implementation 'com.github.ajalt:clikt:2.7.1' + + implementation 'org.xerial:sqlite-jdbc:3.8.11.2' + implementation 'org.postgresql:postgresql:42.2.5' + + implementation 'com.moandjiezana.toml:toml4j:0.7.2' + + implementation 'org.apache.commons:commons-compress:1.18' + + implementation 'com.expediagroup:graphql-kotlin-schema-generator:3.1.1' + + implementation 'com.graphql-java:graphql-java:15.0' + + implementation 'com.github.excitement-engineer:ktor-graphql:1.0.0' + + implementation 'org.jsoup:jsoup:1.13.1' + + implementation 'org.elasticsearch.client:elasticsearch-rest-high-level-client:7.7.0' + + implementation 'com.github.jillesvangurp:es-kotlin-wrapper-client:1.0-X-Beta-5-7.7.0' + + testImplementation 'io.kotest:kotest-runner-junit5-jvm:4.0.5' + testImplementation 'io.kotest:kotest-assertions-core-jvm:4.0.5' + testImplementation 'io.kotest:kotest-property-jvm:4.0.5' +} + +application { + mainClassName = 'moe.odango.index.MainKt' +} + +compileKotlin { + kotlinOptions.jvmTarget = '1.8' +} +compileTestKotlin { + kotlinOptions.jvmTarget = '1.8' +} diff --git a/config/test.toml b/config/test.toml new file mode 100644 index 0000000..429ac52 --- /dev/null +++ b/config/test.toml @@ -0,0 +1,8 @@ +[database] +driver = "sqlite" +uri = "jdbc:sqlite:test.sql" + +[elastic] +index = "anime" +replicas = 0 +shards = 2 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..fc9d36f --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,9 @@ +version: '3.7' +services: + es: + image: elasticsearch:7.7.0 + environment: + discovery.type: single-node + ports: + - 9200:9200 + - 9300:9300 diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..29e08e8 --- /dev/null +++ b/gradle.properties @@ -0,0 +1 @@ +kotlin.code.style=official \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..f3d88b1c2faf2fc91d853cd5d4242b5547257070 GIT binary patch literal 58695 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 literal 0 HcmV?d00001 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..342e3c2 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Sat Jun 06 16:52:37 CEST 2020 +distributionUrl=https\://services.gradle.org/distributions/gradle-6.1-all.zip +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStorePath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..2fe81a7 --- /dev/null +++ b/gradlew @@ -0,0 +1,183 @@ +#!/usr/bin/env sh + +# +# Copyright 2015 the original author or authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin or MSYS, switch paths to Windows format before running java +if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=`expr $i + 1` + done + case $i in + 0) set -- ;; + 1) set -- "$args0" ;; + 2) set -- "$args0" "$args1" ;; + 3) set -- "$args0" "$args1" "$args2" ;; + 4) set -- "$args0" "$args1" "$args2" "$args3" ;; + 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=`save "$@"` + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..24467a1 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,100 @@ +@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 + +@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=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@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%" == "0" goto init + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto init + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:init +@rem Get command-line arguments, handling Windows variants + +if not "%OS%" == "Windows_NT" goto win9xME_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* + +: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 %CMD_LINE_ARGS% + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="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! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..3d39426 --- /dev/null +++ b/settings.gradle @@ -0,0 +1,2 @@ +rootProject.name = 'index' + diff --git a/src/main/kotlin/moe/odango/index/cli/AniDBSync.kt b/src/main/kotlin/moe/odango/index/cli/AniDBSync.kt new file mode 100644 index 0000000..953fdd4 --- /dev/null +++ b/src/main/kotlin/moe/odango/index/cli/AniDBSync.kt @@ -0,0 +1,26 @@ +package moe.odango.index.cli + +import com.github.ajalt.clikt.core.CliktCommand +import com.github.ajalt.clikt.parameters.options.option +import kotlinx.coroutines.runBlocking +import moe.odango.index.sync.AniDBTitleSync +import java.io.File + +class AniDBSync : CliktCommand(name = "anidb",help = "Sync the titles from AniDB with the local database") { + private val file by option(help = "Read from file instead of downloading") + + override fun run() { + val sync = AniDBTitleSync() + val file = file + + runBlocking { + if (file == null) { + sync.run() + } else { + val fileObj = File(file).takeIf { it.isFile } ?: throw RuntimeException("File doesn't exist") + sync.syncWithFile(fileObj) + } + } + } + +} diff --git a/src/main/kotlin/moe/odango/index/cli/DatabaseMigration.kt b/src/main/kotlin/moe/odango/index/cli/DatabaseMigration.kt new file mode 100644 index 0000000..12240ef --- /dev/null +++ b/src/main/kotlin/moe/odango/index/cli/DatabaseMigration.kt @@ -0,0 +1,28 @@ +package moe.odango.index.cli + +import com.github.ajalt.clikt.core.CliktCommand +import com.github.ajalt.clikt.parameters.options.flag +import com.github.ajalt.clikt.parameters.options.option +import io.requery.sql.KotlinConfiguration +import io.requery.sql.SchemaModifier +import io.requery.sql.TableCreationMode +import moe.odango.index.di +import org.kodein.di.instance + +class DatabaseMigration : CliktCommand(name = "db:create") { + private val print by option().flag(default = false) + private val exec by option().flag(default = false) + + override fun run() { + val config by di.instance() + val modifier = SchemaModifier(config) + + if (exec) { + modifier.createTables(TableCreationMode.CREATE_NOT_EXISTS) + } + + if (print) { + println(modifier.createTablesString(TableCreationMode.CREATE_NOT_EXISTS)) + } + } +} diff --git a/src/main/kotlin/moe/odango/index/cli/ElasticIndex.kt b/src/main/kotlin/moe/odango/index/cli/ElasticIndex.kt new file mode 100644 index 0000000..7c65d6b --- /dev/null +++ b/src/main/kotlin/moe/odango/index/cli/ElasticIndex.kt @@ -0,0 +1,10 @@ +package moe.odango.index.cli + +import com.github.ajalt.clikt.core.CliktCommand +import moe.odango.index.es.Indexer + +class ElasticIndex : CliktCommand(name = "elastic:index") { + override fun run() { + Indexer().run() + } +} diff --git a/src/main/kotlin/moe/odango/index/cli/HTTPServer.kt b/src/main/kotlin/moe/odango/index/cli/HTTPServer.kt new file mode 100644 index 0000000..249cfb3 --- /dev/null +++ b/src/main/kotlin/moe/odango/index/cli/HTTPServer.kt @@ -0,0 +1,10 @@ +package moe.odango.index.cli + +import com.github.ajalt.clikt.core.CliktCommand +import moe.odango.index.http.Server + +class HTTPServer : CliktCommand(name = "http:serve") { + override fun run() { + Server().run() + } +} diff --git a/src/main/kotlin/moe/odango/index/cli/MyAnimeListListingSync.kt b/src/main/kotlin/moe/odango/index/cli/MyAnimeListListingSync.kt new file mode 100644 index 0000000..2ea5759 --- /dev/null +++ b/src/main/kotlin/moe/odango/index/cli/MyAnimeListListingSync.kt @@ -0,0 +1,13 @@ +package moe.odango.index.cli + +import com.github.ajalt.clikt.core.CliktCommand +import kotlinx.coroutines.runBlocking +import moe.odango.index.sync.MyAnimeListListingSync + +class MyAnimeListListingSync : CliktCommand(name = "mal:sync-listing") { + override fun run() { + runBlocking { + MyAnimeListListingSync().run() + } + } +} diff --git a/src/main/kotlin/moe/odango/index/cli/MyAnimeListPageSync.kt b/src/main/kotlin/moe/odango/index/cli/MyAnimeListPageSync.kt new file mode 100644 index 0000000..ccb9b89 --- /dev/null +++ b/src/main/kotlin/moe/odango/index/cli/MyAnimeListPageSync.kt @@ -0,0 +1,13 @@ +package moe.odango.index.cli + +import com.github.ajalt.clikt.core.CliktCommand +import kotlinx.coroutines.runBlocking +import moe.odango.index.sync.MyAnimeListPageSync + +class MyAnimeListPageSync : CliktCommand(name = "mal:sync-page") { + override fun run() { + runBlocking { + MyAnimeListPageSync().run() + } + } +} diff --git a/src/main/kotlin/moe/odango/index/config/DatabaseConfiguration.kt b/src/main/kotlin/moe/odango/index/config/DatabaseConfiguration.kt new file mode 100644 index 0000000..2beece8 --- /dev/null +++ b/src/main/kotlin/moe/odango/index/config/DatabaseConfiguration.kt @@ -0,0 +1,8 @@ +package moe.odango.index.config + +data class DatabaseConfiguration( + val uri: String, + val driver: String = "sqlite", + val username: String? = null, + val password: String? = null +) diff --git a/src/main/kotlin/moe/odango/index/config/ElasticSearchConfiguration.kt b/src/main/kotlin/moe/odango/index/config/ElasticSearchConfiguration.kt new file mode 100644 index 0000000..3e1746b --- /dev/null +++ b/src/main/kotlin/moe/odango/index/config/ElasticSearchConfiguration.kt @@ -0,0 +1,8 @@ +package moe.odango.index.config + +data class ElasticSearchConfiguration( + val host: String = "localhost:9200", + val index: String = "index_anime", + val replicas: Int = 0, + val shards: Int = 2 +) diff --git a/src/main/kotlin/moe/odango/index/config/IndexConfiguration.kt b/src/main/kotlin/moe/odango/index/config/IndexConfiguration.kt new file mode 100644 index 0000000..4d283e3 --- /dev/null +++ b/src/main/kotlin/moe/odango/index/config/IndexConfiguration.kt @@ -0,0 +1,6 @@ +package moe.odango.index.config + +data class IndexConfiguration( + val database: DatabaseConfiguration = DatabaseConfiguration("jdbc:sqlite:index.sqlite"), + val elastic: ElasticSearchConfiguration = ElasticSearchConfiguration() +) diff --git a/src/main/kotlin/moe/odango/index/container.kt b/src/main/kotlin/moe/odango/index/container.kt new file mode 100644 index 0000000..4d6093c --- /dev/null +++ b/src/main/kotlin/moe/odango/index/container.kt @@ -0,0 +1,59 @@ +package moe.odango.index + +import com.moandjiezana.toml.Toml +import io.inbot.eskotlinwrapper.IndexRepository +import io.requery.Persistable +import io.requery.sql.KotlinConfiguration +import io.requery.sql.KotlinEntityDataStore +import moe.odango.index.config.IndexConfiguration +import moe.odango.index.entity.Models +import moe.odango.index.es.dto.AnimeDTO +import org.apache.http.HttpHost +import org.elasticsearch.client.RestClient +import org.elasticsearch.client.RestHighLevelClient +import org.elasticsearch.client.indexRepository +import org.kodein.di.* +import org.sqlite.SQLiteDataSource +import java.io.File +import javax.sql.CommonDataSource + +val di = DI { + bind() with provider { + RestHighLevelClient(RestClient.builder(HttpHost.create(instance().elastic.host))) + } + + bind>() with provider { + instance().indexRepository(instance().elastic.index) + } + + bind() with singleton { KotlinConfiguration(Models.DEFAULT, dataSource = instance()) } + bind>() with singleton { KotlinEntityDataStore(instance()) } + constant(tag = "config-file") with mutableListOf( + "/etc/index/config.toml", + System.getenv("HOME") + "/.index/config.toml" + ) + + bind() with singleton { + val toml = instance>("config-file") + .find { File(it).isFile } + ?.let { File(it).readText(Charsets.UTF_8) } + + toml?.let { + Toml() + .read(toml) + .to(IndexConfiguration::class.java) + } ?: IndexConfiguration() + } + + bind() with singleton { + val config: IndexConfiguration = instance() + + when (config.database.driver) { + "sqlite" -> SQLiteDataSource().apply { + url = config.database.uri + } + + else -> throw RuntimeException("Database driver '${config.database.driver}' not recognized") + } + } +} diff --git a/src/main/kotlin/moe/odango/index/entity/AniDBInfo.kt b/src/main/kotlin/moe/odango/index/entity/AniDBInfo.kt new file mode 100644 index 0000000..cee6101 --- /dev/null +++ b/src/main/kotlin/moe/odango/index/entity/AniDBInfo.kt @@ -0,0 +1,58 @@ +package moe.odango.index.entity + +import io.requery.* +import moe.odango.index.utils.EntityHelper +import moe.odango.index.utils.helper +import java.util.* + +@Entity +interface AniDBInfo : Persistable { + @get:Key + val id: UUID + + @get:OneToOne + @get:ForeignKey + val anime: Anime + + var type: ReleaseType + var restricted: Boolean + var episodes: Int? + var description: String + var image: String? + var startYear: Int? + var startMonth: Int? + var startDay: Int? + + var endYear: Int? + var endMonth: Int? + var endDay: Int? + + enum class ReleaseType { + Movie, + MusicVideo, + OVA, + Other, + TVSeries, + TVSpecial, + Web, + Unknown; + + companion object { + fun fromAniDBString(value: String): ReleaseType = when (value.toLowerCase()) { + "movie" -> Movie + "music video" -> MusicVideo + "ova" -> OVA + "other" -> Other + "tv series" -> TVSeries + "tv special" -> TVSpecial + "web" -> Web + else -> Unknown + } + } + } + + + companion object : EntityHelper by helper(::AniDBInfoEntity, { + setId(UUID.randomUUID()) + }) +} diff --git a/src/main/kotlin/moe/odango/index/entity/Anime.kt b/src/main/kotlin/moe/odango/index/entity/Anime.kt new file mode 100644 index 0000000..db26b78 --- /dev/null +++ b/src/main/kotlin/moe/odango/index/entity/Anime.kt @@ -0,0 +1,50 @@ +package moe.odango.index.entity + +import io.requery.* +import moe.odango.index.utils.EntityHelper +import moe.odango.index.utils.helper +import java.util.* + +@Entity +interface Anime : Persistable { + @get:Key + val id: UUID + + @get:Index + var aniDbId: Long? + + @get:OneToOne + val aniDbInfo: AniDBInfo? + + @get:OneToMany(mappedBy = "anime") + val titles: List + + @get:Index + var myAnimeListId: Long? + + @get:ManyToMany + @get:JunctionTable(type = AnimeProducer::class) + val producers: List<Producer> + + @get:OneToOne + val myAnimeListInfo: MyAnimeListInfo? + + @get:OneToMany(mappedBy = "anime_from") + val relatedTo: List<AnimeRelation> + + @get:OneToMany(mappedBy = "anime_to") + val relatedFrom: List<AnimeRelation> + + @get:OneToMany(mappedBy = "anime") + val genres: List<AnimeGenre> + + @get:ManyToOne + @get:ForeignKey + var series: AnimeSeries? + + val replacedWith: UUID? + + companion object : EntityHelper<AnimeEntity> by helper(::AnimeEntity, { + setId(UUID.randomUUID()) + }) +} diff --git a/src/main/kotlin/moe/odango/index/entity/AnimeGenre.kt b/src/main/kotlin/moe/odango/index/entity/AnimeGenre.kt new file mode 100644 index 0000000..0015867 --- /dev/null +++ b/src/main/kotlin/moe/odango/index/entity/AnimeGenre.kt @@ -0,0 +1,27 @@ +package moe.odango.index.entity + +import io.requery.* +import moe.odango.index.utils.EntityHelper +import moe.odango.index.utils.InfoSource +import moe.odango.index.utils.helper +import java.util.* + +@Entity +interface AnimeGenre : Persistable { + @get:Key + val id: UUID + + @get:ManyToOne + @get:ForeignKey + val anime: Anime + + @get:ManyToOne + @get:ForeignKey + val genre: Genre + + val source: InfoSource + + companion object : EntityHelper<AnimeGenreEntity> by helper(::AnimeGenreEntity, { + setId(UUID.randomUUID()) + }) +} diff --git a/src/main/kotlin/moe/odango/index/entity/AnimeProducer.kt b/src/main/kotlin/moe/odango/index/entity/AnimeProducer.kt new file mode 100644 index 0000000..49aab3c --- /dev/null +++ b/src/main/kotlin/moe/odango/index/entity/AnimeProducer.kt @@ -0,0 +1,28 @@ +package moe.odango.index.entity + +import io.requery.* +import moe.odango.index.utils.EntityHelper +import moe.odango.index.utils.InfoSource +import moe.odango.index.utils.ProducerFunction +import moe.odango.index.utils.helper +import java.util.* + +@Entity +interface AnimeProducer : Persistable { + @get:Key + val id: UUID + + @get:ManyToOne + val anime: Anime + + @get:ManyToOne + val producer: Producer + + @get:Column(name = "producer_function") + var function: ProducerFunction + val source: InfoSource + + companion object : EntityHelper<AnimeProducerEntity> by helper(::AnimeProducerEntity, { + setId(UUID.randomUUID()) + }) +} diff --git a/src/main/kotlin/moe/odango/index/entity/AnimeRelation.kt b/src/main/kotlin/moe/odango/index/entity/AnimeRelation.kt new file mode 100644 index 0000000..036cd6e --- /dev/null +++ b/src/main/kotlin/moe/odango/index/entity/AnimeRelation.kt @@ -0,0 +1,82 @@ +package moe.odango.index.entity + +import io.requery.* +import moe.odango.index.utils.EntityHelper +import moe.odango.index.utils.InfoSource +import moe.odango.index.utils.helper +import java.util.* + +@Entity +interface AnimeRelation : Persistable { + @get:Key + val id: UUID + + @get:ManyToOne + @get:ForeignKey + @get:Column(name = "anime_from") + val from: Anime + + @get:ManyToOne + @get:ForeignKey + @get:Column(name = "anime_to") + val to: Anime + + var relation: RelationType + val source: InfoSource + + companion object : EntityHelper<AnimeRelationEntity> by helper(::AnimeRelationEntity, { + setId(UUID.randomUUID()) + }) + + enum class RelationType { + Prequel, + Sequel, + SideStory, + FullStory, + ParentStory, + Summary, + SpinOff, + AlternateVersion, + AlternateSetting, + SameSetting, + Other, + Character; + + val inverse: RelationType + get() { + return when (this) { + Prequel -> Sequel + Sequel -> Prequel + Summary -> FullStory + FullStory -> Summary + SideStory -> ParentStory + SpinOff -> ParentStory + ParentStory -> SideStory + AlternateSetting -> AlternateSetting + AlternateVersion -> AlternateVersion + SameSetting -> SameSetting + Other -> Other + Character -> Character + } + } + + companion object { + fun fromString(type: String): RelationType { + return when (type.toLowerCase()) { + "alternate setting" -> AlternateSetting + "alternate version" -> AlternateVersion + "prequel" -> Prequel + "sequel" -> Sequel + "spin off" -> SpinOff + "summary" -> Summary + "side story" -> SideStory + "full story" -> FullStory + "parent story" -> ParentStory + "character" -> Character + "same setting" -> SameSetting + else -> Other + } + } + } + } +} diff --git a/src/main/kotlin/moe/odango/index/entity/AnimeSeries.kt b/src/main/kotlin/moe/odango/index/entity/AnimeSeries.kt new file mode 100644 index 0000000..b39acc0 --- /dev/null +++ b/src/main/kotlin/moe/odango/index/entity/AnimeSeries.kt @@ -0,0 +1,20 @@ +package moe.odango.index.entity + +import io.requery.Entity +import io.requery.Key +import io.requery.Persistable +import moe.odango.index.utils.EntityHelper +import moe.odango.index.utils.helper +import java.util.* + +@Entity +interface AnimeSeries : Persistable { + @get:Key + val id: UUID + val name: String? + var replacedWith: UUID? + + companion object : EntityHelper<AnimeSeriesEntity> by helper(::AnimeSeriesEntity, { + setId(UUID.randomUUID()) + }) +} diff --git a/src/main/kotlin/moe/odango/index/entity/AnimeTag.kt b/src/main/kotlin/moe/odango/index/entity/AnimeTag.kt new file mode 100644 index 0000000..179f06e --- /dev/null +++ b/src/main/kotlin/moe/odango/index/entity/AnimeTag.kt @@ -0,0 +1,27 @@ +package moe.odango.index.entity + +import io.requery.* +import moe.odango.index.utils.EntityHelper +import moe.odango.index.utils.InfoSource +import moe.odango.index.utils.helper +import java.util.* + +@Entity +interface AnimeTag : Persistable { + @get:Key + val id: UUID + + @get:ManyToOne + @get:ForeignKey + val anime: Anime + + @get:ManyToOne + @get:ForeignKey + val tag: Tag + val source: InfoSource + val spoiler: Boolean + + companion object : EntityHelper<AnimeTagEntity> by helper(::AnimeTagEntity, { + setId(UUID.randomUUID()) + }) +} diff --git a/src/main/kotlin/moe/odango/index/entity/Genre.kt b/src/main/kotlin/moe/odango/index/entity/Genre.kt new file mode 100644 index 0000000..28606b9 --- /dev/null +++ b/src/main/kotlin/moe/odango/index/entity/Genre.kt @@ -0,0 +1,21 @@ +package moe.odango.index.entity + +import io.requery.Entity +import io.requery.Key +import io.requery.Persistable +import moe.odango.index.utils.EntityHelper +import moe.odango.index.utils.InfoSource +import moe.odango.index.utils.helper +import java.util.* + +@Entity +interface Genre : Persistable { + @get:Key + val id: UUID + val myAnimeListId: Int + val name: String + + companion object : EntityHelper<GenreEntity> by helper(::GenreEntity, { + setId(UUID.randomUUID()) + }) +} diff --git a/src/main/kotlin/moe/odango/index/entity/MyAnimeListInfo.kt b/src/main/kotlin/moe/odango/index/entity/MyAnimeListInfo.kt new file mode 100644 index 0000000..02eefd0 --- /dev/null +++ b/src/main/kotlin/moe/odango/index/entity/MyAnimeListInfo.kt @@ -0,0 +1,89 @@ +package moe.odango.index.entity + +import io.requery.* +import moe.odango.index.scraper.mal.AnimePageScraper +import moe.odango.index.utils.EntityHelper +import moe.odango.index.utils.ISOTextConverter +import moe.odango.index.utils.helper +import moe.odango.index.utils.toIntDate +import org.joda.time.DateTime +import java.sql.Date +import java.util.* + +@Entity +interface MyAnimeListInfo : Persistable { + @get:Key + val id: UUID + + @get:OneToOne + @get:ForeignKey + val anime: Anime + + var releaseType: ReleaseType + var episodes: Int? + + @get:Convert(ISOTextConverter::class) + var lastScrape: DateTime? + var rating: Rating? + var image: String? + var description: String? + var source: String? + var airedStart: Date? + var airedEnd: Date? + var premieredSeason: String? + var premieredYear: Int? + var duration: Int? + + + @get:Transient + val aired: AnimePageScraper.Aired? + get() = airedStart?.let { AnimePageScraper.Aired(it.toIntDate(), airedEnd?.toIntDate()) } + + @get:Transient + val premiered: AnimePageScraper.Premiered? + get() = premieredSeason + ?.let(AnimePageScraper.Premiered.Season.Companion::fromString) + ?.let { s -> + premieredYear?.let { y -> + AnimePageScraper.Premiered(s, y) + } + } + + enum class Rating { + G, + PG, + PG13, + R, + RPlus, + Rx; + + companion object { + fun fromString(rating: String): Rating? = when (rating) { + "G" -> G + "PG" -> PG + "PG-13" -> PG13 + "R" -> R + "R+" -> RPlus + "Rx" -> Rx + else -> null + } + } + } + + enum class ReleaseType { + TV, + ONA, + OVA, + Movie, + Special, + Music, + Unknown + } + + companion object : EntityHelper<MyAnimeListInfoEntity> by helper(::MyAnimeListInfoEntity, { + setId(UUID.randomUUID()) + episodes = null + releaseType = ReleaseType.Unknown + lastScrape = null + }) +} diff --git a/src/main/kotlin/moe/odango/index/entity/Producer.kt b/src/main/kotlin/moe/odango/index/entity/Producer.kt new file mode 100644 index 0000000..13a89b1 --- /dev/null +++ b/src/main/kotlin/moe/odango/index/entity/Producer.kt @@ -0,0 +1,22 @@ +package moe.odango.index.entity + +import io.requery.* +import moe.odango.index.utils.EntityHelper +import moe.odango.index.utils.helper +import java.util.* + +@Entity +interface Producer : Persistable { + @get:Key + val id: UUID + + val myAnimeListId: Int? + val name: String + + @get:ManyToMany + val animes: List<Anime> + + companion object : EntityHelper<ProducerEntity> by helper(::ProducerEntity, { + setId(UUID.randomUUID()) + }) +} diff --git a/src/main/kotlin/moe/odango/index/entity/Tag.kt b/src/main/kotlin/moe/odango/index/entity/Tag.kt new file mode 100644 index 0000000..379fede --- /dev/null +++ b/src/main/kotlin/moe/odango/index/entity/Tag.kt @@ -0,0 +1,25 @@ +package moe.odango.index.entity + +import io.requery.Entity +import io.requery.Key +import io.requery.Persistable +import moe.odango.index.utils.EntityHelper +import moe.odango.index.utils.helper +import java.util.* + +@Entity +interface Tag : Persistable { + @get:Key + val id: UUID + + val aniDbId: Long? + + val parentId: UUID + val name: String + val description: String + val spoiler: Boolean + + companion object : EntityHelper<TagEntity> by helper(::TagEntity, { + setId(UUID.randomUUID()) + }) +} diff --git a/src/main/kotlin/moe/odango/index/entity/Title.kt b/src/main/kotlin/moe/odango/index/entity/Title.kt new file mode 100644 index 0000000..643657d --- /dev/null +++ b/src/main/kotlin/moe/odango/index/entity/Title.kt @@ -0,0 +1,63 @@ +package moe.odango.index.entity + +import io.requery.* +import moe.odango.index.utils.EntityHelper +import moe.odango.index.utils.InfoSource +import moe.odango.index.utils.helper +import java.util.* + +@Entity +interface Title : Persistable { + @get:Key + val id: UUID + + @get:Index + var name: String + + @get:ManyToOne + @get:ForeignKey + val anime: Anime + + val source: InfoSource + + + @get:Column(name = "lang") + val language: String? + val type: TitleType + var hidden: Boolean + + + enum class TitleType { + Synonym, + TitleCard, + Kana, + Short, + Official, + Main; + + fun toAniDBString() = when (this) { + Main -> "main" + Synonym -> "syn" + TitleCard -> "card" + Kana -> "kana" + Short -> "short" + Official -> "official" + } + + companion object { + fun fromAniDBString(type: String) = when (type) { + "card" -> TitleCard + "official" -> Official + "short" -> Short + "syn" -> Synonym + "kana" -> Kana + "main" -> Main + else -> Synonym + } + } + } + + companion object : EntityHelper<TitleEntity> by helper(::TitleEntity, { + setId(UUID.randomUUID()) + }) +} diff --git a/src/main/kotlin/moe/odango/index/es/Indexer.kt b/src/main/kotlin/moe/odango/index/es/Indexer.kt new file mode 100644 index 0000000..910a774 --- /dev/null +++ b/src/main/kotlin/moe/odango/index/es/Indexer.kt @@ -0,0 +1,141 @@ +package moe.odango.index.es + +import io.inbot.eskotlinwrapper.IndexRepository +import io.requery.Persistable +import io.requery.kotlin.invoke +import io.requery.sql.KotlinEntityDataStore +import moe.odango.index.config.IndexConfiguration +import moe.odango.index.di +import moe.odango.index.entity.Anime +import moe.odango.index.es.dto.AnimeDescriptionDTO +import moe.odango.index.es.dto.AnimeTitleDTO +import moe.odango.index.utils.InfoSource +import org.elasticsearch.client.RequestOptions +import org.elasticsearch.client.RestHighLevelClient +import org.elasticsearch.client.configure +import org.elasticsearch.client.indices.GetIndexRequest +import org.kodein.di.instance +import moe.odango.index.es.dto.AnimeDTO as AnimeDTO + +class Indexer { + private val indexRepo by di.instance<IndexRepository<AnimeDTO>>() + private val indexConfig by di.instance<IndexConfiguration>() + private val client by di.instance<RestHighLevelClient>() + private val config by lazy { indexConfig.elastic } + private val entityStore by di.instance<KotlinEntityDataStore<Persistable>>() + + fun run() { + createIndex() + index() + client.close() + } + + fun createIndex() { + val indexExists = client.indices() + .exists(GetIndexRequest(config.index), RequestOptions.DEFAULT); + + if (indexExists) + return + + indexRepo.createIndex { + configure { + settings { + replicas = config.replicas + shards = config.shards + + addTokenizer("autocomplete") { + this["type"] = "edge_ngram" + this["min_gram"] = 2 + this["max_gram"] = 10 + this["token_chars"] = listOf("letter") + } + + addAnalyzer("autocomplete") { + this["tokenizer"] = "autocomplete" + this["filter"] = listOf("lowercase") + } + + addAnalyzer("autocomplete_search") { + this["tokenizer"] = "lowercase" + } + } + + mappings { + nestedField("title") { + text("name") { + analyzer = "autocomplete" + searchAnalyzer = "autocomplete_search" + } + } + + nestedField("description") { + text("text") { + analyzer = "standard" + searchAnalyzer = "standard" + } + } + + keyword("genre") + + objField("premiered") { + keyword("season") + number<Int>("year") + } + + objField("aired") { + field("start", "date") + field("end", "date") + } + } + } + } + } + + fun index() { + var i = 0; + entityStore { + val q = select(Anime::class) + + indexRepo.bulk(50) { + for (item in q()) { + if (item.replacedWith != null) continue; + i++ + if (i % 1_000 == 0) { + println(" => $i - ${item.id}") + } + + index( + item.id.toString(), + AnimeDTO( + item.id, + item.titles.map { + AnimeTitleDTO( + it.name, + it.language, + it.type.toString(), + it.source + ) + }, + item.myAnimeListInfo?.let { + it.description?.let { desc -> + listOf( + AnimeDescriptionDTO( + desc, + InfoSource.MyAnimeList + ) + ) + } + } ?: listOf(), + item.genres.map { it.genre.name }.toSet().toList(), + item.myAnimeListInfo?.premiered, + item.myAnimeListInfo?.aired + ), + false + ) + } + } + } + + println(" => Indexed $i entries.") + } +} diff --git a/src/main/kotlin/moe/odango/index/es/dto/AnimeDTO.kt b/src/main/kotlin/moe/odango/index/es/dto/AnimeDTO.kt new file mode 100644 index 0000000..835800b --- /dev/null +++ b/src/main/kotlin/moe/odango/index/es/dto/AnimeDTO.kt @@ -0,0 +1,13 @@ +package moe.odango.index.es.dto + +import moe.odango.index.scraper.mal.AnimePageScraper +import java.util.* + +data class AnimeDTO( + val id: UUID, + val title: Collection<AnimeTitleDTO>, + val description: Collection<AnimeDescriptionDTO>, + val genre: Collection<String>, + val premiered: AnimePageScraper.Premiered?, + val aired: AnimePageScraper.Aired? +) diff --git a/src/main/kotlin/moe/odango/index/es/dto/AnimeDescriptionDTO.kt b/src/main/kotlin/moe/odango/index/es/dto/AnimeDescriptionDTO.kt new file mode 100644 index 0000000..050a4bb --- /dev/null +++ b/src/main/kotlin/moe/odango/index/es/dto/AnimeDescriptionDTO.kt @@ -0,0 +1,8 @@ +package moe.odango.index.es.dto + +import moe.odango.index.utils.InfoSource + +data class AnimeDescriptionDTO( + val text: String, + val source: InfoSource +) diff --git a/src/main/kotlin/moe/odango/index/es/dto/AnimeTitleDTO.kt b/src/main/kotlin/moe/odango/index/es/dto/AnimeTitleDTO.kt new file mode 100644 index 0000000..41dbba1 --- /dev/null +++ b/src/main/kotlin/moe/odango/index/es/dto/AnimeTitleDTO.kt @@ -0,0 +1,10 @@ +package moe.odango.index.es.dto + +import moe.odango.index.utils.InfoSource + +data class AnimeTitleDTO( + val name: String, + val language: String?, + val type: String, + val source: InfoSource +) diff --git a/src/main/kotlin/moe/odango/index/http/Server.kt b/src/main/kotlin/moe/odango/index/http/Server.kt new file mode 100644 index 0000000..3963a9c --- /dev/null +++ b/src/main/kotlin/moe/odango/index/http/Server.kt @@ -0,0 +1,41 @@ +package moe.odango.index.http + +import com.expediagroup.graphql.SchemaGeneratorConfig +import com.expediagroup.graphql.TopLevelObject +import com.expediagroup.graphql.toSchema +import io.ktor.http.content.resource +import io.ktor.http.content.resources +import io.ktor.http.content.static +import io.ktor.routing.routing +import io.ktor.server.engine.embeddedServer +import io.ktor.server.netty.Netty +import ktor.graphql.GraphQLRouteConfig +import ktor.graphql.graphQL +import moe.odango.index.http.graphql.AnimeService + +class Server { + fun run() { + embeddedServer(Netty, port = 3336) { + routing { + + graphQL("/graphql",toSchema( + SchemaGeneratorConfig(listOf("moe.odango.index")), + listOf(TopLevelObject(AnimeService())) + )) { + GraphQLRouteConfig( + graphiql = true + ) + } + + + static { + resource("/", "web/index.html") + static("/") { + resources("web") + } + } + } + } + .start(true) + } +} diff --git a/src/main/kotlin/moe/odango/index/http/graphql/AnimeService.kt b/src/main/kotlin/moe/odango/index/http/graphql/AnimeService.kt new file mode 100644 index 0000000..a06633a --- /dev/null +++ b/src/main/kotlin/moe/odango/index/http/graphql/AnimeService.kt @@ -0,0 +1,100 @@ +package moe.odango.index.http.graphql + +import io.inbot.eskotlinwrapper.IndexRepository +import io.inbot.eskotlinwrapper.dsl.* +import moe.odango.index.di +import moe.odango.index.entity.Title +import moe.odango.index.es.dto.AnimeDTO +import moe.odango.index.http.graphql.dto.AnimeItem +import moe.odango.index.http.graphql.dto.AnimeTitleItem +import moe.odango.index.http.graphql.dto.AutoCompleteItem +import moe.odango.index.utils.ScoreMode +import moe.odango.index.utils.nested +import org.elasticsearch.action.search.dsl +import org.elasticsearch.action.search.source +import org.elasticsearch.client.RestHighLevelClient +import org.elasticsearch.common.unit.Fuzziness +import org.elasticsearch.search.suggest.SuggestBuilder +import org.elasticsearch.search.suggest.completion.CompletionSuggestionBuilder +import org.kodein.di.instance + +class AnimeService { + private val es by di.instance<IndexRepository<AnimeDTO>>() + private val client by di.instance<RestHighLevelClient>() + + fun autocomplete(title: String): List<AutoCompleteItem> { + return es.search { + source { + suggest( + SuggestBuilder().addSuggestion( + "title", + CompletionSuggestionBuilder("title.name").prefix(title, Fuzziness.AUTO) + ) + ) + } + } + .hits + .mapNotNull { (s, item) -> + AutoCompleteItem( + s.innerHits["title"]?.hits?.get(0)?.sourceAsMap?.get("name")?.toString() ?: return@mapNotNull null, + item?.id.toString() ?: return@mapNotNull null + ) + } + .toList() + } + + fun search( + title: String? = null, + description: String? = null, + genre: List<String>? = null + ): List<AnimeItem> { + val items = mutableListOf<ESQuery>() + + title?.let { + items.add( + nested("title") { + query = MatchQuery("title.name", it) + scoreMode = ScoreMode.max + } + ) + } + + genre?.let { + items.add( + bool { + must(*it.toSet().map { genre -> TermQuery("genre", genre) }.toTypedArray()) + } + ) + } + + description?.let { + items.add( + nested("description") { + query = MatchQuery("description.name", it) + scoreMode = ScoreMode.max + } + ) + } + + return es.search { + dsl { + query = disMax { + queries(*items.toTypedArray()) + } + } + } + .hits + .mapNotNull { (_, dto) -> + dto?.let { + AnimeItem( + it.id.toString(), + it.title.map { dto -> + AnimeTitleItem(dto.name, dto.language, dto.type.let(Title.TitleType::valueOf), dto.source) + }, + it.genre.toSet().toList() + ) + } + } + .toList() + } +} diff --git a/src/main/kotlin/moe/odango/index/http/graphql/dto/AnimeItem.kt b/src/main/kotlin/moe/odango/index/http/graphql/dto/AnimeItem.kt new file mode 100644 index 0000000..99f34dd --- /dev/null +++ b/src/main/kotlin/moe/odango/index/http/graphql/dto/AnimeItem.kt @@ -0,0 +1,7 @@ +package moe.odango.index.http.graphql.dto + +data class AnimeItem( + val id: String, + val titles: List<AnimeTitleItem>, + val genres: List<String> +) diff --git a/src/main/kotlin/moe/odango/index/http/graphql/dto/AnimeTitleItem.kt b/src/main/kotlin/moe/odango/index/http/graphql/dto/AnimeTitleItem.kt new file mode 100644 index 0000000..b584b7f --- /dev/null +++ b/src/main/kotlin/moe/odango/index/http/graphql/dto/AnimeTitleItem.kt @@ -0,0 +1,11 @@ +package moe.odango.index.http.graphql.dto + +import moe.odango.index.entity.Title +import moe.odango.index.utils.InfoSource + +data class AnimeTitleItem( + val name: String, + val language: String?, + val type: Title.TitleType, + val source: InfoSource +) diff --git a/src/main/kotlin/moe/odango/index/http/graphql/dto/AutoCompleteItem.kt b/src/main/kotlin/moe/odango/index/http/graphql/dto/AutoCompleteItem.kt new file mode 100644 index 0000000..0e49905 --- /dev/null +++ b/src/main/kotlin/moe/odango/index/http/graphql/dto/AutoCompleteItem.kt @@ -0,0 +1,6 @@ +package moe.odango.index.http.graphql.dto + +data class AutoCompleteItem( + val title: String, + val id: String +) diff --git a/src/main/kotlin/moe/odango/index/main.kt b/src/main/kotlin/moe/odango/index/main.kt new file mode 100644 index 0000000..abd6659 --- /dev/null +++ b/src/main/kotlin/moe/odango/index/main.kt @@ -0,0 +1,30 @@ +package moe.odango.index + +import com.github.ajalt.clikt.core.CliktCommand +import com.github.ajalt.clikt.core.subcommands +import com.github.ajalt.clikt.parameters.options.option +import moe.odango.index.cli.* +import org.kodein.di.instance + +fun main(args: Array<String>) { + object : CliktCommand() { + val config: String? by option() + + init { + subcommands( + AniDBSync(), + DatabaseMigration(), + MyAnimeListListingSync(), + MyAnimeListPageSync(), + HTTPServer(), + ElasticIndex() + ) + } + + override fun run() { + val configFile by di.instance<MutableList<String>>("config-file") + config?.let { configFile.add(0, it) } + } + } + .main(args) +} diff --git a/src/main/kotlin/moe/odango/index/scraper/mal/AnimeListScraper.kt b/src/main/kotlin/moe/odango/index/scraper/mal/AnimeListScraper.kt new file mode 100644 index 0000000..51679af --- /dev/null +++ b/src/main/kotlin/moe/odango/index/scraper/mal/AnimeListScraper.kt @@ -0,0 +1,33 @@ +package moe.odango.index.scraper.mal + +import moe.odango.index.entity.MyAnimeListInfo +import org.jsoup.Jsoup +import java.net.URI + +class AnimeListScraper(body: String) { + private val dom = Jsoup.parse(body) + + data class MyAnimeListListingItem(val myAnimeListId: Long, val title: String, val type: MyAnimeListInfo.ReleaseType, val episodes: Int?) + + fun getItems(): List<MyAnimeListListingItem> { + val arr = dom + .select("[id=content] .list tbody tr") + .toList() + + return arr.drop(1).mapNotNull { + val link = it.select("a[id^=sinfo]").first() + val id = link + .attr("href") + .split("/") + .let { + it[it.indexOf("anime") + 1].toLongOrNull() + } + + val title = link.text() + val type = it.child(2).text() + val eps = it.child(3).text().toIntOrNull() + + id?.let { MyAnimeListListingItem(id, title, MyAnimeListInfo.ReleaseType.valueOf(type), eps) } + } + } +} diff --git a/src/main/kotlin/moe/odango/index/scraper/mal/AnimePageScraper.kt b/src/main/kotlin/moe/odango/index/scraper/mal/AnimePageScraper.kt new file mode 100644 index 0000000..b15c7e0 --- /dev/null +++ b/src/main/kotlin/moe/odango/index/scraper/mal/AnimePageScraper.kt @@ -0,0 +1,246 @@ +package moe.odango.index.scraper.mal + +import moe.odango.index.entity.AnimeRelation +import moe.odango.index.entity.MyAnimeListInfo +import moe.odango.index.utils.IntDate +import moe.odango.index.utils.ProducerFunction +import moe.odango.index.utils.brText +import org.jsoup.Jsoup +import org.jsoup.nodes.Element +import java.time.Duration +import java.util.* + +class AnimePageScraper(body: String) { + private val dom = Jsoup.parse(body) + + private val items: MutableMap<String, Pair<String, Element>> = mutableMapOf() + + data class Info( + val title: String, + val englishName: String?, + val japaneseName: String?, + val synonyms: List<String>, + val description: String, + val type: MyAnimeListInfo.ReleaseType, + val episodes: Int?, + val source: String?, + val image: String?, + val genres: List<Genre>, + val aired: Aired?, + val premiered: Premiered?, + val rating: MyAnimeListInfo.Rating?, + val duration: Duration?, + val related: List<Relation>, + val producers: List<ProducerRelation> + ) + + fun getInfo() = Info( + getTitle(), + getEnglishName(), + getJapaneseName(), + getSynonyms(), + getDescription(), + getReleaseType(), + getEpisodes(), + getSource(), + getImage(), + getGenres(), + getAired(), + getPremiered(), + getRating(), + getDuration(), + getRelated(), + getProducers() + ) + + fun getEpisodes(): Int? { + return items["episodes"]?.first?.toIntOrNull() + } + + fun getReleaseType(): MyAnimeListInfo.ReleaseType { + return MyAnimeListInfo.ReleaseType.valueOf(items["type"]!!.first) + } + + fun getTitle(): String { + return dom.select(".h1-title span[itemprop=name]").first().brText().split("\n").first() + } + + fun getSynonyms(): List<String> { + // If the english name or title contains a comma we can't parse synonyms since they're splice by comma's + return if (getEnglishName()?.contains(",") == true || getTitle().contains(",")) listOf() else items["synonyms"]?.first?.split( + "," + )?.map { it.trim() } ?: listOf() + } + + fun getJapaneseName(): String? { + return items["japanese"]?.first + } + + fun getEnglishName(): String? { + return items["english"]?.first + } + + init { + val after = dom + .select(".dark_text") + .parents() + + for (item in after) { + val parts = item.text().split(":", limit = 2) + val key = parts.first().trim().toLowerCase() + val value = parts.last().trim() + items[key] = value to item + } + } + + fun getSource(): String? { + return items["source"]?.first + } + + fun getImage(): String? { + return dom.selectFirst("img[itemprop=image]")?.attr("data-src") + } + + fun getDescription(): String { + return dom.selectFirst("[itemprop=description]")?.brText() ?: "" + } + + fun getAired(): Aired? { + return items["aired"]?.let { + val (from, to) = it.first.split(" to ", limit = 2) + .let { part -> part.first() to part.getOrNull(1) } + + return Aired(IntDate.parse("MMM d, yyyy", from, Locale.US) ?: return null, to?.let { dateStr -> + IntDate.parse("MMM d, yyyy", dateStr, Locale.US) + }) + } + } + + data class Aired(val start: IntDate, val end: IntDate? = null) + + fun getPremiered(): Premiered? { + val (season, year) = items["premiered"] + ?.first + ?.split(" ", limit = 2) + ?.takeUnless { it.size != 2 } + ?: return null + + return Premiered(Premiered.Season.fromString(season) ?: return null, year.toIntOrNull() ?: return null) + } + + data class Premiered(val season: Season, val year: Int) { + enum class Season { + Spring, + Summer, + Fall, + Winter; + + companion object { + fun fromString(season: String): Season? { + return when (season.toLowerCase()) { + "spring" -> Spring + "summer" -> Summer + "fall" -> Fall + "winter" -> Winter + else -> null + } + } + } + } + } + + fun getGenres(): List<Genre> { + return items["genres"]?.second?.let { + it.select("a").map { a -> + val href = a.attr("href").split("/") + Genre(href[href.indexOf("genre") + 1].toInt(), a.text().trim()) + } + } ?: emptyList() + } + + data class Genre(val id: Int, val name: String) { + companion object { + val COMEDY = Genre(4, "Comedy") + val DEMONS = Genre(6, "Demons") + val DRAMA = Genre(8, "Drama") + val FANTASY = Genre(10, "Fantasy") + val HENTAI = Genre(12, "Hentai") + val MAGIC = Genre(16, "Magic") + val PARODY = Genre(20, "Parody") + val ROMANCE = Genre(22, "Romance") + val SCHOOL = Genre(23, "School") + val SCIFI = Genre(24, "Sci-Fi") + val SHOUJO = Genre(25, "Shoujo") + val HAREM = Genre(35, "Harem") + val MILITARY = Genre(38, "Military") + } + } + + fun getRelated(): List<Relation> { + return dom.select("table.anime_detail_related_anime tr").flatMap { + val type = it.child(0).text().trim().replace(":", "") + it.child(1).select("a").mapNotNull { el -> + el.attr("href") + ?.let { href -> + val parts = href.split("/") + + if (parts.contains("anime")) { + parts[parts.indexOf("anime") + 1].toLongOrNull() + } else { + null + } + } + ?.let { id -> Relation(type, id) } + + } + } + } + + fun getProducers(): List<ProducerRelation> { + fun Pair<String, Element>?.getProducers(): List<Producer> { + return this?.second?.let { + it.select("a").mapNotNull { a -> + val href = a.attr("href").split("/") + if (href.contains("producer")) Producer( + href[href.indexOf("producer") + 1].toInt(), + a.text().trim() + ) else null + } + } ?: emptyList() + } + + val producers = items["producers"].getProducers() + val studios = items["studios"].getProducers() + val licensors = items["licensors"].getProducers() + + return producers.map { ProducerRelation(ProducerFunction.Producer, it) } + + studios.map { ProducerRelation(ProducerFunction.Studio, it) } + + licensors.map { ProducerRelation(ProducerFunction.Licensor, it) } + } + + data class ProducerRelation(val function: ProducerFunction, val producer: Producer) + + data class Producer(val id: Int, val name: String) + + fun getRating(): MyAnimeListInfo.Rating? { + return MyAnimeListInfo.Rating.fromString(items["rating"]?.first?.split(" - ")?.first()?.trim() ?: return null) + } + + private val durationRegex = Regex("(?:(\\d+) hrs.)?(?:(\\d+) min.)?") + + fun getDuration(): Duration? { + val txt = items["duration"]?.first?.split(" per ", limit = 2)?.firstOrNull() ?: return null + val grps = durationRegex.matchEntire(txt)?.groups ?: return null + val hrs = grps[1] + val min = grps[2] + var x = Duration.ofMillis(0) + x += Duration.ofHours(hrs?.value?.toInt() ?: 0) + x += Duration.ofMinutes(min?.value?.toInt() ?: 0) + return x + } + + + data class Relation(val type: AnimeRelation.RelationType, val id: Long) { + constructor(type: String, id: Long) : this(AnimeRelation.RelationType.fromString(type), id) + } +} diff --git a/src/main/kotlin/moe/odango/index/sync/AniDBTitleSync.kt b/src/main/kotlin/moe/odango/index/sync/AniDBTitleSync.kt new file mode 100644 index 0000000..9fbcec4 --- /dev/null +++ b/src/main/kotlin/moe/odango/index/sync/AniDBTitleSync.kt @@ -0,0 +1,178 @@ +package moe.odango.index.sync + +import com.github.kittinunf.fuel.coroutines.awaitByteArrayResponseResult +import com.github.kittinunf.fuel.httpDownload +import io.requery.Persistable +import io.requery.kotlin.`in` +import io.requery.kotlin.eq +import io.requery.kotlin.invoke +import io.requery.sql.KotlinEntityDataStore +import moe.odango.index.di +import moe.odango.index.entity.Anime +import moe.odango.index.entity.Title +import moe.odango.index.utils.InfoSource +import moe.odango.index.utils.XMLOutputStreamReader +import org.apache.commons.compress.compressors.gzip.GzipCompressorInputStream +import org.kodein.di.instance +import java.io.File +import java.io.InputStream +import java.util.concurrent.TimeUnit +import java.util.zip.InflaterOutputStream + +class AniDBTitleSync : ScheduledSync(1, TimeUnit.DAYS) { + private val entityStore by di.instance<KotlinEntityDataStore<Persistable>>() + + data class AniDBTitle(val aid: Long, val name: String, val type: String, val language: String) + + override suspend fun run() { + + + val (feeder, cacheMap) = getFeeder() + + "https://anidb.net/api/anime-titles.xml.gz" + .httpDownload() + .streamDestination { _, _ -> + InflaterOutputStream(feeder) to { InputStream.nullInputStream() } + } + .awaitByteArrayResponseResult() + + syncTitles(cacheMap) + } + + private fun getFeeder(): Pair<XMLOutputStreamReader, MutableMap<Long, MutableList<AniDBTitle>>> { + var animeId: Long = 0; + var language = "" + var type = "" + var title = "" + val cacheMap = mutableMapOf<Long, MutableList<AniDBTitle>>() + + return XMLOutputStreamReader { + if (isStartElement) { + if (name.localPart == "anime") { + animeId = getAttributeValue("", "aid").toLong() + } + + if (name.localPart == "title") { + title = "" + type = getAttributeValue("", "type") + language = getAttributeValue("http://www.w3.org/XML/1998/namespace", "lang") + } + } + + if (isCharacters) { + title += text + } + + if (isEndElement) { + if (name.localPart == "title") { + cacheMap + .getOrPut(animeId, ::mutableListOf) + .add(AniDBTitle(animeId, title, type, language)) + + println("$animeId => [$type/$language] $title") + + title = "" + } + + if (name.localPart == "anime") { + if (cacheMap.size >= 100) { + syncTitles(cacheMap) + + cacheMap.clear() + } + } + } + } to cacheMap + } + + fun syncWithFile(file: File) { + val (feeder, cacheMap) = getFeeder() + + file + .inputStream() + .let { + if (file.name.endsWith(".gz")) { + GzipCompressorInputStream(it) + } else { + it + } + } + .transferTo(feeder) + syncTitles(cacheMap) + } + + private fun syncTitles(cacheMap: Map<Long, List<AniDBTitle>>) { + val ids = cacheMap.keys + + val (animes, titles) = entityStore { + val animes = (select(Anime::class) where (Anime::aniDbId `in` ids)).get().toList() + + val query = select(Title::class) where (Title::anime `in` animes) and (Title::source eq InfoSource.AniDB) + animes to query().toList() + } + + val animeById = animes.associateBy { it.aniDbId!! } + val titlesByAniDBId = titles.groupBy { + it.anime.aniDbId!! + } + + val newAnimes = mutableListOf<Pair<Long, List<AniDBTitle>>>() + + entityStore.withTransaction { + for ((aniDb, newTitles) in cacheMap) { + val newAniDbTitlesSet = newTitles.map { Triple(it.language, it.type, it.name) }.toMutableSet() + val anime = animeById[aniDb] + if (anime == null) { + newAnimes.add(aniDb to newTitles) + continue + } + + val dbTitles = titlesByAniDBId[aniDb] ?: listOf() + + for (dbTitle in dbTitles) { + if (!newAniDbTitlesSet.remove( + Triple( + dbTitle.language, + dbTitle.type.toAniDBString(), + dbTitle.name + ) + ) + ) { + // If it wasn't in the set, remove it from the DB + delete(dbTitle) + } + } + + for ((language, type, name) in newAniDbTitlesSet) { + val title = Title { + setAnime(anime) + setLanguage(language) + setType(Title.TitleType.fromAniDBString(type)) + setSource(InfoSource.AniDB) + this.name = name + } + + insert(title) + } + } + + for ((aniDb, aniDbTitles) in newAnimes) { + val anime = Anime { + aniDbId = aniDb + } + + insert(anime) + + for (aniDbTitle in aniDbTitles) { + insert(Title { + setAnime(anime) + setLanguage(aniDbTitle.language) + setType(Title.TitleType.fromAniDBString(aniDbTitle.type)) + setSource(InfoSource.AniDB) + name = aniDbTitle.name + }) + } + } + } + } +} diff --git a/src/main/kotlin/moe/odango/index/sync/AniDBXMLSync.kt b/src/main/kotlin/moe/odango/index/sync/AniDBXMLSync.kt new file mode 100644 index 0000000..a7945a0 --- /dev/null +++ b/src/main/kotlin/moe/odango/index/sync/AniDBXMLSync.kt @@ -0,0 +1,318 @@ +package moe.odango.index.sync + +import io.requery.Persistable +import io.requery.kotlin.`in` +import io.requery.kotlin.eq +import io.requery.kotlin.invoke +import io.requery.sql.KotlinEntityDataStore +import moe.odango.index.di +import moe.odango.index.entity.* +import moe.odango.index.utils.InfoSource +import moe.odango.index.utils.MergeMap +import moe.odango.index.utils.brText +import org.jsoup.Jsoup +import org.jsoup.nodes.Element +import org.jsoup.parser.Parser +import org.kodein.di.instance +import java.io.File +import java.util.* + + +class AniDBXMLSync { + private val entityStore by di.instance<KotlinEntityDataStore<Persistable>>() + private val seriesToMerge = MergeMap<UUID>() + + fun run(xmlDir: String) { + val files = File(xmlDir) + .listFiles() + .filter { + it.extension == "xml" + } + + for (file in files) { + val xml = file.readText(Charsets.UTF_8) + indexAniDBEntry(xml) + } + } + + private fun indexAniDBEntry(xml: String) { + val doc = Jsoup.parse(xml, "", Parser.xmlParser()) + val anime = doc.selectFirst("anime") + val aid = anime.attr("id").toLongOrNull() ?: return + + val animeEnt: Anime? = entityStore { + val q = select(Anime::class) where (Anime::aniDbId eq aid) + q().firstOrNull() + } + + val myAnimeListId = anime + .selectFirst("resource[type=2] externalentity identifier") + ?.text() + ?.toLongOrNull() + + val malAnimeEnt = myAnimeListId?.let { + entityStore { + val q = select(Anime::class) where (Anime::myAnimeListId eq myAnimeListId) + + q().firstOrNull() + } + } + + if (malAnimeEnt != null && animeEnt != null) { + // WE GOT ISSUES OH NO + TODO() + } + + val entity = malAnimeEnt ?: animeEnt ?: Anime { + this.myAnimeListId = myAnimeListId + this.aniDbId = aid + }.let { entityStore.insert(it) } + + val aniDbInfo = entity.aniDbInfo ?: AniDBInfo { + setAnime(entity) + }.let { entityStore.insert(it) } + + aniDbInfo.restricted = anime.attr("restricted") == "true" + aniDbInfo.description = doc.selectFirst("anime > description")?.brText() ?: "" + aniDbInfo.episodes = doc.selectFirst("anime > episodecount")?.text()?.trim()?.toIntOrNull() + aniDbInfo.type = + doc.selectFirst("anime > type")?.text()?.trim()?.let { AniDBInfo.ReleaseType.fromAniDBString(it) } + ?: AniDBInfo.ReleaseType.Unknown + aniDbInfo.image = doc.selectFirst("anime > picture")?.text()?.trim() + val startDate = doc.selectFirst("anime > startdate")?.text()?.trim()?.let { + val items = it.split("-") + if (items.size > 3) { + null + } else { + items.map { item -> item.toIntOrNull() } + } + } ?: emptyList() + + aniDbInfo.startYear = startDate[0] + aniDbInfo.startMonth = startDate[1] + aniDbInfo.startDay = startDate[2] + + val endDate = doc.selectFirst("anime > enddate")?.text()?.trim()?.let { + val items = it.split("-") + if (items.size > 3) { + null + } else { + items.map { item -> item.toIntOrNull() } + } + } ?: emptyList() + + aniDbInfo.endYear = endDate[0] + aniDbInfo.endMonth = endDate[1] + aniDbInfo.endDay = endDate[2] + + entityStore.update(aniDbInfo) + + val aniDbTitles = entityStore { + val q = select(Title::class) where (Title::anime eq entity) and (Title::source eq InfoSource.AniDB) + q().toList() + } + + .associateBy { "${it.type}/${it.language}/${it.name}" } + .toMutableMap() + + for (title in doc.select("anime > titles > title")) { + val name = title.text() ?: continue + val lang = title.attr("lang") ?: null + val type = title.attr("type")?.let { Title.TitleType.fromAniDBString(it) } ?: Title.TitleType.Synonym + val handle = "$type/$lang/$name" + + if (aniDbTitles.remove(handle) == null) { + entityStore.insert(Title { + setAnime(entity) + setSource(InfoSource.AniDB) + setLanguage(lang) + setType(type) + + this.name = name + }) + } + } + + for ((_, title) in aniDbTitles) { + entityStore.delete(title) + } + + val relatedAnimeFrom = entityStore { + val q = + select(AnimeRelation::class) where (AnimeRelation::from eq entity) and (AnimeRelation::source eq InfoSource.AniDB) + q().toList() + } + .associateBy { it.to.aniDbId!! } + .toMutableMap() + + val relatedAnimeTo = entityStore { + val q = + select(AnimeRelation::class) where (AnimeRelation::to eq entity) and (AnimeRelation::source eq InfoSource.AniDB) + q().toList() + } + .associateBy { it.from.aniDbId!! } + .toMutableMap() + + for (related in doc.select("anime > relatedanime > anime")) { + val id = related.attr("id").toLongOrNull() ?: continue + val relatedAnime = entityStore { + val q = select(Anime::class) where (Anime::aniDbId eq id) + q().firstOrNull() + } ?: Anime { aniDbId = id }.let { entityStore.insert(it) } + + val type = related.attr("type")?.let { AnimeRelation.RelationType.fromString(it) } + ?: AnimeRelation.RelationType.Other + + val currentFromRelation = relatedAnimeFrom.remove(id) + if (currentFromRelation == null) { + entityStore.insert(AnimeRelation { + setFrom(entity) + setTo(relatedAnime) + setSource(InfoSource.AniDB) + relation = type + }) + } else if (currentFromRelation.relation != type) { + currentFromRelation.relation = type + entityStore.update(currentFromRelation) + } + + val currentToRelation = relatedAnimeTo.remove(id) + if (currentToRelation == null) { + entityStore.insert(AnimeRelation { + setFrom(relatedAnime) + setTo(entity) + setSource(InfoSource.AniDB) + relation = type.inverse + }) + } + + // Character only have characters of that anime in the other anime + // So are not part of The Series, see e.g. Isekai Quartet + if (type != AnimeRelation.RelationType.Character) { + if (relatedAnime.series != null && entity.series == null) { + entity.series = relatedAnime.series + entityStore.update(entity) + } else if (relatedAnime.series == null && entity.series != null) { + relatedAnime.series = entity.series + entityStore.update(relatedAnime) + } else if (relatedAnime.series == null && entity.series == null) { + val newSeries = AnimeSeries {} + entityStore.insert(newSeries) + entity.series = newSeries + entityStore.update(entity) + relatedAnime.series = newSeries + entityStore.update(relatedAnime) + } else if (relatedAnime.series != null && entity.series != null) { + seriesToMerge.add(relatedAnime.series!!.id, entity.series!!.id) + } + } + } + + val tags = entityStore { + val q = + select(AnimeTag::class) join Tag::class on (AnimeTag::tag eq Tag::class) where (AnimeTag::anime eq entity) and (AnimeTag::source eq InfoSource.AniDB) + q().toList() + } + .associateBy { it.tag.aniDbId!! } + .toMutableMap() + + val tagIds = doc.select("anime > tags > tag").flatMap { + val ids = mutableSetOf<Long>() + it.attr("id")?.toLongOrNull()?.let(ids::add) + it.attr("parentid")?.toLongOrNull()?.let(ids::add) + + ids + } + .toSet() + + val tagsById = entityStore { + val q = select(Tag::class) where (Tag::aniDbId `in` tagIds) + q().toList() + } + .associateBy { it.aniDbId!! } + .toMutableMap() + + val after = mutableListOf<Element>() + + fun getTag(tag: Element): Tag { + val tagId = tag.attr("id")?.toLongOrNull()!! + val name = tag.selectFirst("name")?.text()?.trim()!! + val description = tag.selectFirst("description")?.brText() ?: "" + val parentId = tag.attr("parentid")?.toLongOrNull() + + return tagsById.getOrPut(tagId) { + val newTag = Tag { + setAniDbId(tagId) + setName(name) + setDescription(description) + setSpoiler(tag.attr("globalspoiler") == "true") + parentId?.let { + setParentId(tagsById[it]!!.id) + } + } + + entityStore.insert(newTag) + newTag + } + } + + fun ensureTag(tag: Element, tagId: Long) { + if (tags.remove(tagId) == null) { + val tagEntity = getTag(tag) + val animeTag = AnimeTag { + setTag(tagEntity) + setAnime(entity) + setSpoiler(tag.attr("localspoiler") == "true") + setSource(InfoSource.AniDB) + } + + entityStore.insert(animeTag) + } + } + + for (tag in doc.select("anime > tags > tag")) { + val tagId = tag.attr("id")?.toLongOrNull() ?: continue + val name = tag.selectFirst("name")?.text()?.trim() ?: continue + val parentId = tag.attr("parentid")?.toLongOrNull() + + if (parentId != null && !tagsById.contains(parentId)) { + after.add(tag) + continue + } + + ensureTag(tag, tagId) + } + + for (tag in after) { + val tagId = tag.attr("id")?.toLongOrNull() ?: continue + ensureTag(tag, tagId) + } + + for ((_, tag) in tags) { + entityStore.delete(tag) + } + } + + fun mergeSeries() { + for (coll in seriesToMerge) { + val items = coll.toMutableSet() + val first = coll.first() + items.remove(first) + + entityStore { + val u = update(AnimeSeries::class) + .set(AnimeSeriesEntity.REPLACED_WITH, first) + .where((AnimeSeries::id `in` items) or (AnimeSeries::replacedWith `in` items)) + + u() + + val u2 = update(Anime::class) + .set(AnimeEntity.SERIES_ID, first) + .where(Anime::series `in` items) + + u2() + } + } + } +} + diff --git a/src/main/kotlin/moe/odango/index/sync/MyAnimeListListingSync.kt b/src/main/kotlin/moe/odango/index/sync/MyAnimeListListingSync.kt new file mode 100644 index 0000000..2ed2033 --- /dev/null +++ b/src/main/kotlin/moe/odango/index/sync/MyAnimeListListingSync.kt @@ -0,0 +1,122 @@ +package moe.odango.index.sync + +import com.github.kittinunf.fuel.coroutines.awaitStringResult +import com.github.kittinunf.fuel.httpGet +import io.requery.Persistable +import io.requery.kotlin.`in` +import io.requery.kotlin.eq +import io.requery.kotlin.invoke +import io.requery.sql.KotlinEntityDataStore +import moe.odango.index.di +import moe.odango.index.entity.Anime +import moe.odango.index.entity.MyAnimeListInfo +import moe.odango.index.entity.Title +import moe.odango.index.scraper.mal.AnimeListScraper +import moe.odango.index.utils.InfoSource +import org.kodein.di.instance +import java.util.concurrent.TimeUnit + +class MyAnimeListListingSync : ScheduledSync(2, TimeUnit.DAYS) { + val entityStore: KotlinEntityDataStore<Persistable> by di.instance() + + override suspend fun run() { + var offset = 0 + + do { + println(" => MAL Offset $offset") + + val items = getListing(offset) + val animes = entityStore { + val q = select(Anime::class) where (Anime::myAnimeListId `in` items.map { it.myAnimeListId }) + + q().toList() + }.associateBy { + it.myAnimeListId!! + } + + val infos = entityStore { + val q = + select(MyAnimeListInfo::class) join Anime::class on (MyAnimeListInfo::anime `in` animes.values) + q().toList() + }.associateBy { + it.anime.myAnimeListId!! + } + + val titles = entityStore { + val q = + select(Title::class) where (Title::source eq InfoSource.MyAnimeList) and (Title::language eq "en") and (Title::anime `in` animes.values) + + q().toList() + }.associateBy { + it.anime.myAnimeListId!! + } + + entityStore.withTransaction { + for (item in items) { + println("Syncing MAL#${item.myAnimeListId} ${item.title}") + val anime = animes[item.myAnimeListId] ?: run { + val newAnime = Anime { + myAnimeListId = item.myAnimeListId + } + + insert(newAnime) + + newAnime + } + + val title = titles[item.myAnimeListId] ?: run { + val newTitle = Title { + setAnime(anime) + setLanguage("en") + setType(Title.TitleType.Official) + setSource(InfoSource.MyAnimeList) + name = item.title + } + + insert(newTitle) + + newTitle + } + + if (title.name != item.title) { + title.name = item.title + + update(title) + } + + val info = infos[item.myAnimeListId] ?: run { + val newInfo = MyAnimeListInfo { + setAnime(anime) + episodes = item.episodes + releaseType = item.type + } + + insert(newInfo) + + newInfo + } + + if (info.episodes != item.episodes || info.releaseType != item.type) { + info.episodes = item.episodes + info.releaseType = item.type + + update(info) + } + } + } + + offset += items.size + } while (items.size == 50) + } + + suspend fun getListing(offset: Int): List<AnimeListScraper.MyAnimeListListingItem> { + val url = "https://myanimelist.net/anime.php?o=9&c[0]=a&c[1]=b&cv=2&w=1&show=$offset" + + val body = url + .httpGet() + .awaitStringResult() + .get() + + return AnimeListScraper(body).getItems() + } +} diff --git a/src/main/kotlin/moe/odango/index/sync/MyAnimeListPageSync.kt b/src/main/kotlin/moe/odango/index/sync/MyAnimeListPageSync.kt new file mode 100644 index 0000000..57ce7e4 --- /dev/null +++ b/src/main/kotlin/moe/odango/index/sync/MyAnimeListPageSync.kt @@ -0,0 +1,327 @@ +package moe.odango.index.sync + +import com.github.kittinunf.fuel.coroutines.awaitString +import com.github.kittinunf.fuel.httpGet +import io.requery.Persistable +import io.requery.kotlin.* +import io.requery.query.function.Random +import io.requery.sql.KotlinEntityDataStore +import moe.odango.index.di +import moe.odango.index.entity.* +import moe.odango.index.scraper.mal.AnimePageScraper +import moe.odango.index.utils.InfoSource +import org.joda.time.DateTime +import org.kodein.di.instance +import java.time.Duration +import java.time.Instant +import java.util.concurrent.TimeUnit +import kotlin.math.max + +class MyAnimeListPageSync : ScheduledSync(1, TimeUnit.DAYS) { + val entityStore: KotlinEntityDataStore<Persistable> by di.instance() + + override suspend fun run() { + val weekAgo = DateTime((Instant.now() - Duration.ofDays(7)).toEpochMilli()) + val infos = entityStore { + val q = + select(MyAnimeListInfo::class) where (MyAnimeListInfo::lastScrape lt weekAgo) or (MyAnimeListInfo::lastScrape.isNull()) orderBy (Random()) limit 300 + q().toList() + } + + val animes = infos.map { it.anime } + val animeById = animes.associateBy { it.id } + + val titles = entityStore { + val q = select(Title::class) where (Title::anime `in` animes) and (Title::source eq InfoSource.MyAnimeList) + q().toList() + } + + val genres = entityStore { + val q = + select(AnimeGenre::class) where (AnimeGenre::anime `in` animes) and (AnimeGenre::source eq InfoSource.MyAnimeList) + q().toList() + }.groupBy { + it.anime.id + }.mapValues { entry -> + entry.value.associateBy { it.genre.myAnimeListId }.toMutableMap() + } + + val fromRelations = entityStore { + val q = + select(AnimeRelation::class) where (AnimeRelation::from `in` animes) and (AnimeRelation::source eq InfoSource.MyAnimeList) + q().toList() + } + .groupBy { it.from.id } + .mapValues { it.value.associateBy { it.from.myAnimeListId!! }.toMutableMap() } + + val toRelations = entityStore { + val q = + select(AnimeRelation::class) where (AnimeRelation::source eq InfoSource.MyAnimeList) and (AnimeRelation::to `in` animes) + q().toList() + } + .groupBy { it.to.id } + .mapValues { it.value.associateBy { it.to.myAnimeListId!! }.toMutableMap() } + + val producers = entityStore { + val q = + select(AnimeProducer::class) where (AnimeProducer::anime `in` animes) and (AnimeProducer::source eq InfoSource.MyAnimeList) + q().toList() + } + .groupBy { it.anime.id } + .mapValues { it.value.associateBy { it.producer.myAnimeListId!! }.toMutableMap() } + + val titlesByMyAnimeListId = mutableMapOf<Long, MutableMap<String, Title>>() + + for (title in titles) { + titlesByMyAnimeListId + .getOrPut(title.anime.myAnimeListId!!, ::mutableMapOf)[title.name] = title + } + + val bodies = mutableMapOf<Long, String>() + + for (info in infos) { + try { + val myAnimeListId = info.anime.myAnimeListId!! + println("=> Fetching MAL Page: $myAnimeListId") + val body = "https://myanimelist.net/anime/$myAnimeListId/" + .httpGet() + .awaitString() + + bodies[myAnimeListId] = body + } catch (t: Throwable) { + t.printStackTrace() + } + } + + val allGenres = mutableMapOf<Int, Genre>() + val allProducers = mutableMapOf<Int, Producer>() + val seriesToMerge = mutableListOf<Pair<AnimeSeries, AnimeSeries>>() + + entityStore.withTransaction { + fun getGenre(genre: AnimePageScraper.Genre): Genre { + return allGenres.getOrPut(genre.id) { + val gq = select(Genre::class) where (Genre::myAnimeListId eq genre.id) + gq().firstOrNull() ?: run { + val gen = Genre { + setMyAnimeListId(genre.id) + setName(genre.name) + } + + insert(gen) + + gen + } + } + } + + fun getProducer(producer: AnimePageScraper.Producer): Producer { + return allProducers.getOrPut(producer.id) { + val pq = select(Producer::class) where (Producer::myAnimeListId eq producer.id) + pq().firstOrNull() ?: run { + val prod = Producer { + setMyAnimeListId(producer.id) + setName(producer.name) + } + + insert(prod) + + prod + } + } + } + + for (info in infos) { + val myAnimeListId = info.anime.myAnimeListId!! + println("=> Indexing MAL Page: $myAnimeListId") + + val body = bodies[myAnimeListId] ?: continue + val currentTitles = titlesByMyAnimeListId[myAnimeListId] ?: mutableMapOf() + + val scraper = AnimePageScraper(body) + val aired = scraper.getAired() + val premiered = scraper.getPremiered() + + info.airedEnd = aired?.end?.toDate() + info.airedStart = aired?.start?.toDate() + info.premieredSeason = premiered?.season?.name + info.premieredYear = premiered?.year + info.releaseType = scraper.getReleaseType() + info.image = scraper.getImage() + info.source = scraper.getSource() + info.description = scraper.getDescription() + info.episodes = scraper.getEpisodes() + info.rating = scraper.getRating() + info.duration = scraper.getDuration()?.toSeconds()?.toInt()?.let { max(it, 0) } + info.lastScrape = DateTime.now() + + update(info) + + val done = mutableSetOf<String>() + + val title = scraper.getTitle() + if (currentTitles.remove(title) == null && !done.contains(title)) { + insert(Title { + setAnime(info.anime) + name = title + setType(Title.TitleType.Main) + setLanguage("x-jat") + setSource(InfoSource.MyAnimeList) + }) + } + done.add(title) + + val englishName = scraper.getEnglishName() + if (englishName != null && currentTitles.remove(englishName) == null && !done.contains(englishName)) { + insert(Title { + setAnime(info.anime) + name = englishName + setType(Title.TitleType.Official) + setLanguage("en") + setSource(InfoSource.MyAnimeList) + }) + } + englishName?.let(done::add) + + val japaneseName = scraper.getJapaneseName() + if (japaneseName != null && currentTitles.remove(japaneseName) == null && !done.contains(japaneseName)) { + insert(Title { + setAnime(info.anime) + name = japaneseName + setType(Title.TitleType.Official) + setLanguage("ja") + setSource(InfoSource.MyAnimeList) + }) + } + + japaneseName?.let(done::add) + + val synonyms = scraper.getSynonyms() + for (synonym in synonyms) { + if (currentTitles.remove(synonym) == null && !done.contains(synonym)) { + insert(Title { + setAnime(info.anime) + name = synonym + setType(Title.TitleType.Synonym) + setLanguage("x-jat") + setSource(InfoSource.MyAnimeList) + }) + } + done.add(synonym) + } + + for ((_, currentTitle) in currentTitles) { + delete(currentTitle) + } + + val currentGenres = genres[info.anime.id] ?: mutableMapOf() + + for (genre in scraper.getGenres()) { + if (currentGenres.remove(genre.id) == null) { + val genreEnt = getGenre(genre) + insert(AnimeGenre { + setAnime(info.anime) + setGenre(genreEnt) + setSource(InfoSource.MyAnimeList) + }) + } + } + + for ((_, genreEnt) in currentGenres) { + delete(genreEnt) + } + + val currentFromRelations = fromRelations[info.anime.id] ?: mutableMapOf() + val currentToRelations = toRelations[info.anime.id] ?: mutableMapOf() + + val related = scraper.getRelated() + val relatedAnimesQuery = select(Anime::class) where (Anime::myAnimeListId `in` related.map { it.id }) + val relatedAnimes = relatedAnimesQuery().toList().associateBy { it.myAnimeListId!! }.toMutableMap() + + for (relation in related) { + val currentFromRelation = currentFromRelations.remove(relation.id) + val relatedAnime = relatedAnimes.getOrPut(relation.id) { + val anim = Anime { + this@Anime.myAnimeListId = relation.id + } + + insert(anim) + + anim + } + if (currentFromRelation == null) { + insert(AnimeRelation { + setFrom(info.anime) + setTo(relatedAnime) + setSource(InfoSource.MyAnimeList) + this@AnimeRelation.relation = relation.type + }) + } else if (currentFromRelation.relation != relation.type) { + currentFromRelation.relation = relation.type + update(currentFromRelation) + } + + val currentToRelation = currentToRelations.remove(relation.id) + if (currentToRelation == null) { + insert(AnimeRelation { + setTo(info.anime) + setFrom(relatedAnime) + setSource(InfoSource.MyAnimeList) + this@AnimeRelation.relation = relation.type.inverse + }) + } + + // Character only have characters of that anime in the other anime + // So are not part of The Series, see e.g. Isekai Quartet + if (relation.type != AnimeRelation.RelationType.Character) { + val currAnime = info.anime + if (relatedAnime.series != null && currAnime.series == null) { + currAnime.series = relatedAnime.series + update(currAnime) + } else if (relatedAnime.series == null && currAnime.series != null) { + relatedAnime.series = currAnime.series + update(relatedAnime) + } else if (relatedAnime.series == null && currAnime.series == null) { + val newSeries = AnimeSeries {} + insert(newSeries) + currAnime.series = newSeries + update(currAnime) + relatedAnime.series = newSeries + update(relatedAnime) + } else if (relatedAnime.series != null && currAnime.series != null) { + seriesToMerge.add(relatedAnime.series!! to currAnime.series!!) + } + } + + val currentProducers = producers[info.anime.id] ?: mutableMapOf() + + val malProducers = scraper.getProducers() + for (producer in malProducers) { + val currentProducer = currentProducers.remove(producer.producer.id) + if (currentProducer != null) { + if (currentProducer.function != producer.function) { + currentProducer.function = producer.function + update(currentProducer) + } + + continue + } + + val producerEntity = getProducer(producer.producer) + + insert(AnimeProducer { + setAnime(info.anime) + setProducer(producerEntity) + setSource(InfoSource.MyAnimeList) + + function = producer.function + }) + } + + for ((_, currentProducer) in currentProducers) { + delete(currentProducer) + } + } + } + } + } +} diff --git a/src/main/kotlin/moe/odango/index/sync/ScheduledSync.kt b/src/main/kotlin/moe/odango/index/sync/ScheduledSync.kt new file mode 100644 index 0000000..919d680 --- /dev/null +++ b/src/main/kotlin/moe/odango/index/sync/ScheduledSync.kt @@ -0,0 +1,7 @@ +package moe.odango.index.sync + +import java.util.concurrent.TimeUnit + +abstract class ScheduledSync(val amount: Long, val timeUnit: TimeUnit) { + abstract suspend fun run() +} diff --git a/src/main/kotlin/moe/odango/index/utils/EntityHelper.kt b/src/main/kotlin/moe/odango/index/utils/EntityHelper.kt new file mode 100644 index 0000000..a66a9c5 --- /dev/null +++ b/src/main/kotlin/moe/odango/index/utils/EntityHelper.kt @@ -0,0 +1,6 @@ +package moe.odango.index.utils + +interface EntityHelper<T> { + operator fun invoke(block: T.() -> Unit): T; +} + diff --git a/src/main/kotlin/moe/odango/index/utils/ISOTextConverter.kt b/src/main/kotlin/moe/odango/index/utils/ISOTextConverter.kt new file mode 100644 index 0000000..18b7d24 --- /dev/null +++ b/src/main/kotlin/moe/odango/index/utils/ISOTextConverter.kt @@ -0,0 +1,18 @@ +package moe.odango.index.utils + +import org.joda.time.DateTime +import org.joda.time.format.ISODateTimeFormat + +class ISOTextConverter : io.requery.Converter<DateTime, String> { + override fun convertToMapped(type: Class<out DateTime>?, value: String?): DateTime? { + return DateTime.parse(value ?: return null) + } + + override fun getPersistedType(): Class<String> = String::class.java + override fun getMappedType(): Class<DateTime> = DateTime::class.java + + override fun convertToPersisted(value: DateTime): String = + value.toString(ISODateTimeFormat.dateTime()) + + override fun getPersistedSize(): Int? = null +} diff --git a/src/main/kotlin/moe/odango/index/utils/InfoSource.kt b/src/main/kotlin/moe/odango/index/utils/InfoSource.kt new file mode 100644 index 0000000..a81bb28 --- /dev/null +++ b/src/main/kotlin/moe/odango/index/utils/InfoSource.kt @@ -0,0 +1,7 @@ +package moe.odango.index.utils + +enum class InfoSource { + UserDefined, + MyAnimeList, + AniDB; +} diff --git a/src/main/kotlin/moe/odango/index/utils/IntDate.kt b/src/main/kotlin/moe/odango/index/utils/IntDate.kt new file mode 100644 index 0000000..9e28d8f --- /dev/null +++ b/src/main/kotlin/moe/odango/index/utils/IntDate.kt @@ -0,0 +1,29 @@ +package moe.odango.index.utils + +import java.sql.Date +import java.text.SimpleDateFormat +import java.util.* + +data class IntDate(val year: Int, val month: Int, val day: Int) { + fun toDate(): Date = Date(year - 1900, month, day) + + companion object { + fun parse(format: String, input: String, locale: Locale = Locale.getDefault(Locale.Category.FORMAT)): IntDate? { + val formatter = SimpleDateFormat(format, locale) + + return try { + formatter.parse(input)?.let { + val nr = GregorianCalendar().apply { + time = it + } + + IntDate(nr.get(Calendar.YEAR), nr.get(Calendar.MONTH), nr.get(Calendar.DAY_OF_MONTH)) + } + } catch (t: Throwable) { + null + } + } + } +} + +fun Date.toIntDate() = IntDate(this.year + 1900, this.month, this.date) diff --git a/src/main/kotlin/moe/odango/index/utils/MergeMap.kt b/src/main/kotlin/moe/odango/index/utils/MergeMap.kt new file mode 100644 index 0000000..81871d6 --- /dev/null +++ b/src/main/kotlin/moe/odango/index/utils/MergeMap.kt @@ -0,0 +1,50 @@ +package moe.odango.index.utils + +class MergeMap<T> { + private var bucketIndex = 0L + private val buckets: MutableMap<Long, MutableSet<T>> = mutableMapOf() + private val bucketIndexByItem: MutableMap<T, Long> = mutableMapOf() + + fun add(a: T, b: T) { + val bucketA = bucketIndexByItem[a] + val bucketB = bucketIndexByItem[b] + if (bucketA == null && bucketB == null) { + bucketIndexByItem[a] = bucketIndex + bucketIndexByItem[b] = bucketIndex + buckets[bucketIndex] = mutableSetOf(a, b) + bucketIndex++ + return + } + + if (bucketB == bucketA) { + return + } + + if (bucketA == null && bucketB != null) { + bucketIndexByItem[a] = bucketB + buckets.getOrPut(bucketB, ::mutableSetOf).add(a) + return + } + + if (bucketB == null && bucketA != null) { + bucketIndexByItem[b] = bucketA + buckets.getOrPut(bucketA, ::mutableSetOf).add(b) + return + } + + // Always false :) + if (bucketA == null || bucketB == null) return + + val bucket = buckets.getOrPut(bucketB, ::mutableSetOf) + buckets.getOrPut(bucketA, ::mutableSetOf).addAll(bucket) + for (item in bucket) { + bucketIndexByItem[item] = bucketA + } + } + + operator fun get(item: T): Set<T>? { + return buckets[bucketIndexByItem[item] ?: return null] + } + + operator fun iterator(): Iterator<Set<T>> = buckets.values.iterator() +} diff --git a/src/main/kotlin/moe/odango/index/utils/NestedQuery.kt b/src/main/kotlin/moe/odango/index/utils/NestedQuery.kt new file mode 100644 index 0000000..6295144 --- /dev/null +++ b/src/main/kotlin/moe/odango/index/utils/NestedQuery.kt @@ -0,0 +1,28 @@ +package moe.odango.index.utils + +import io.inbot.eskotlinwrapper.MapBackedProperties +import io.inbot.eskotlinwrapper.dsl.ESQuery + +class NestedQuery : ESQuery(name = "nested") { + var path: String by queryDetails.property() + var ignoreUnmapped: Boolean by queryDetails.property() + var innerHits: Map<Any, Any?> by queryDetails.property() + var scoreMode: ScoreMode by queryDetails.property() + var query: ESQuery by queryDetails.esQueryProperty() +} + +fun nested(path: String, config: NestedQuery.() -> Unit): NestedQuery { + val q = NestedQuery() + q.path = path + config(q) + return q +} + +@Suppress("EnumEntryName") +enum class ScoreMode { + avg, + max, + min, + none, + sum +} diff --git a/src/main/kotlin/moe/odango/index/utils/ProducerFunction.kt b/src/main/kotlin/moe/odango/index/utils/ProducerFunction.kt new file mode 100644 index 0000000..1d79a9d --- /dev/null +++ b/src/main/kotlin/moe/odango/index/utils/ProducerFunction.kt @@ -0,0 +1,7 @@ +package moe.odango.index.utils + +enum class ProducerFunction { + Studio, + Producer, + Licensor; +} diff --git a/src/main/kotlin/moe/odango/index/utils/TermsSetQuery.kt b/src/main/kotlin/moe/odango/index/utils/TermsSetQuery.kt new file mode 100644 index 0000000..454731e --- /dev/null +++ b/src/main/kotlin/moe/odango/index/utils/TermsSetQuery.kt @@ -0,0 +1,38 @@ +package moe.odango.index.utils + +import io.inbot.eskotlinwrapper.dsl.ESQuery + +class TermsSetQuery : ESQuery(name = "terms_set") { + operator fun set(field: String, value: TermSetConfig) { + queryDetails[field] = value.toMap() + } + + operator fun String.invoke(block: TermSetConfig.() -> Unit) { + val config = TermSetConfig() + block(config) + set(this, config) + } +} + +fun termsSet(block: TermsSetQuery.() -> Unit): TermsSetQuery { + val q = TermsSetQuery() + block(q) + return q +} + +class TermSetConfig { + private val map = mutableMapOf<String, Any>() + + @Suppress("UNCHECKED_CAST") + var terms: Set<Any> + set(value) { + map["terms"] = value as Any + } + get() = map["terms"] as? Set<Any> ?: emptySet() + + var minimumShouldMatch: Int? + get() = map["minimum_should_match_field"] as? Int + set(value) { map["minimum_should_match_field"] = value as Any } + + fun toMap() = map +} diff --git a/src/main/kotlin/moe/odango/index/utils/XMLOutputStreamReader.kt b/src/main/kotlin/moe/odango/index/utils/XMLOutputStreamReader.kt new file mode 100644 index 0000000..5ebf440 --- /dev/null +++ b/src/main/kotlin/moe/odango/index/utils/XMLOutputStreamReader.kt @@ -0,0 +1,26 @@ +package moe.odango.index.utils + +import com.fasterxml.aalto.AsyncByteArrayFeeder +import com.fasterxml.aalto.AsyncXMLStreamReader +import com.fasterxml.aalto.stax.InputFactoryImpl +import java.io.OutputStream + +class XMLOutputStreamReader(private val afterWrite: AsyncXMLStreamReader<*>.() -> Unit) : OutputStream() { + private val reader = InputFactoryImpl().createAsyncForByteArray()!! + private val feeder = reader.inputFeeder!! + + override fun write(b: ByteArray) { + write(b, 0, b.size) + } + + override fun write(b: ByteArray, off: Int, len: Int) { + feeder.feedInput(b, off, len) + while (reader.next() != AsyncXMLStreamReader.EVENT_INCOMPLETE) { + afterWrite(reader) + } + } + + override fun write(b: Int) { + write(byteArrayOf(b.toByte()), 0, 1) + } +} diff --git a/src/main/kotlin/moe/odango/index/utils/helper.kt b/src/main/kotlin/moe/odango/index/utils/helper.kt new file mode 100644 index 0000000..22f3fea --- /dev/null +++ b/src/main/kotlin/moe/odango/index/utils/helper.kt @@ -0,0 +1,13 @@ +package moe.odango.index.utils + +inline fun<reified T> helper(crossinline constructor: () -> T, crossinline init: T.() -> Unit = {}): EntityHelper<T> { + return object : EntityHelper<T> { + override fun invoke(block: T.() -> Unit): T { + val entity = constructor() + init(entity) + block(entity) + return entity + } + + } +} diff --git a/src/main/kotlin/moe/odango/index/utils/textWithBreaks.kt b/src/main/kotlin/moe/odango/index/utils/textWithBreaks.kt new file mode 100644 index 0000000..8512328 --- /dev/null +++ b/src/main/kotlin/moe/odango/index/utils/textWithBreaks.kt @@ -0,0 +1,74 @@ +package moe.odango.index.utils + +import org.jsoup.internal.StringUtil +import org.jsoup.nodes.CDataNode +import org.jsoup.nodes.Element +import org.jsoup.nodes.Node +import org.jsoup.nodes.TextNode +import org.jsoup.select.NodeTraversor +import org.jsoup.select.NodeVisitor + +fun Element.brText(): String { + val accum = StringUtil.borrowBuilder(); + NodeTraversor.traverse(object : NodeVisitor { + override fun head(node: Node, depth: Int) { + if (node is TextNode) { + appendNormalisedText(accum, node); + } else if (node is Element) { + if (accum.isNotEmpty() && + ((node.isBlock && !accum.lastIsWhitespace()) || node.tagName() == "br") + ) { + if (node.tagName() == "br") { + var lastIndex = accum.lastIndex + while (accum[lastIndex] == ' ') { + lastIndex-- + } + accum.delete(lastIndex + 1, accum.length) + accum.append('\n') + } else { + accum.append(' '); + } + } + } + } + + override fun tail(node: Node, depth: Int) { + // make sure there is a space between block tags and immediately following text nodes <div>One</div>Two should be "One Two". + if (node is Element) { + if (node.isBlock && (node.nextSibling() is TextNode) && (accum.lastIsWhitespace())) + accum.append(' '); + } + + } + }, this); + + return StringUtil.releaseBuilder(accum).trim() +} + +fun StringBuilder.lastIsWhitespace() = lastOrNull() == ' ' || lastOrNull() == '\n' + +fun appendNormalisedText(accum: StringBuilder, textNode: TextNode) { + val text = textNode.wholeText; + + if (preserveWhitespace(textNode.parentNode()) || textNode is CDataNode) + accum.append(text); + else + StringUtil.appendNormalisedWhitespace(accum, text, accum.lastIsWhitespace()); +} + +fun preserveWhitespace(node: Node): Boolean { + // looks only at this element and five levels up, to prevent recursion & needless stack searches + if (node is Element) { + var el: Node? = node + var i = 0; + do { + val ele = el ?: return false + + if (ele is Element && ele.tag().preserveWhitespace()) + return true; + el = ele.parent() + i++; + } while (i < 6 && el != null); + } + return false; +} diff --git a/src/main/resources/web/index.html b/src/main/resources/web/index.html new file mode 100644 index 0000000..118dd60 --- /dev/null +++ b/src/main/resources/web/index.html @@ -0,0 +1 @@ +HEELO WORLD diff --git a/src/test/kotlin/moe/odango/index/test/scraper/mal/AnimePageScraperTest.kt b/src/test/kotlin/moe/odango/index/test/scraper/mal/AnimePageScraperTest.kt new file mode 100644 index 0000000..5cbad1b --- /dev/null +++ b/src/test/kotlin/moe/odango/index/test/scraper/mal/AnimePageScraperTest.kt @@ -0,0 +1,208 @@ +package moe.odango.index.test.scraper.mal + +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import moe.odango.index.entity.AnimeRelation +import moe.odango.index.entity.MyAnimeListInfo +import moe.odango.index.scraper.mal.AnimePageScraper +import moe.odango.index.scraper.mal.AnimePageScraper.Genre +import moe.odango.index.utils.IntDate +import moe.odango.index.utils.ProducerFunction +import java.time.Duration + +class AnimePageScraperTest : StringSpec({ + "test-bodyscraper" { + val body = this::class.java.getResourceAsStream("/test-pages/25313.html") + .readAllBytes() + .toString(Charsets.UTF_8) + + val scraper = AnimePageScraper(body) + + scraper.getDescription().trim() shouldBe """ +Bundled with limited edition of the 58th Gintama manga volume. + +The tagline for the bundled anime reads, "It's time for all the Yorozuya members ...to wake up just one more time." + +(Source: MAL News, edited) + """.trim() + + scraper.getImage() shouldBe "https://cdn.myanimelist.net/images/anime/12/64865.jpg" + + val related = scraper.getRelated() + + related shouldHaveSize 1 + related[0].id shouldBe 15417L + related[0].type shouldBe AnimeRelation.RelationType.ParentStory + + scraper.getPremiered() shouldBe null + scraper.getAired() shouldNotBe null + scraper.getAired()?.start shouldBe IntDate(2015, 3, 3) + scraper.getSource() shouldBe "Manga" + scraper.getDuration() shouldBe Duration.ofMinutes(24) + scraper.getRating() shouldBe MyAnimeListInfo.Rating.PG13 + } + + val items = mapOf( + "616.html" to AnimePageScraper.Info( + "Nurse Angel Ririka SOS", + null, + "ナースエンジェルりりかSOS", + listOf(), + """ + The Evil Forces of Dark Joker are closing in on our planet after having destroyed the beautiful planet of Queen Earth. Now, 10 year old Moriya Ririka, with the help of her childhood friend Seiya and the mysterious Kanon, must transform into the Nurse Angel and find the elusive Flower of Life, the only way to defeat the evil forces. The Flower of Life, that once bloomed all over the Earth, is where no one thought it ever would be. And Ririka must make the hardest decision of her life in order to acquire it and rid the universe of evil once and for all. + + (Source: ANN) + """.trimIndent(), + MyAnimeListInfo.ReleaseType.TV, + 35, + "Manga", + "https://cdn.myanimelist.net/images/anime/10/10506.jpg", + listOf( + Genre.DRAMA, + Genre.FANTASY, + Genre.MAGIC, + Genre.SHOUJO + ), + AnimePageScraper.Aired( + IntDate(1995, 6, 7), + IntDate(1996, 2, 29) + ), + AnimePageScraper.Premiered(AnimePageScraper.Premiered.Season.Summer, 1995), + MyAnimeListInfo.Rating.PG, + Duration.ofMinutes(24), + listOf(), + listOf( + AnimePageScraper.ProducerRelation(ProducerFunction.Producer, AnimePageScraper.Producer(16, "TV Tokyo")), + AnimePageScraper.ProducerRelation( + ProducerFunction.Producer, + AnimePageScraper.Producer(139, "Nihon Ad Systems") + ), + AnimePageScraper.ProducerRelation(ProducerFunction.Studio, AnimePageScraper.Producer(36, "Gallop")) + ) + ), + "817.html" to AnimePageScraper.Info( + "Tactical Roar", + null, + "タクティカルロア", + listOf("Tactical Rawr"), + """ + In the near future the world's climate shifted creating in the Western Pacific a perpetual super cyclone: the Grand Roar that altered the earth, flooding most countries. Shipping and navigation became important to nations and following the appearance of ocean pirates, necessisated companies to hire escort cruisers to safeguard their investments. Hyousuke Nagimiya is a system engineer that was comissioned to upgrade the Pascal Magi manned by an entire crew of women with its captain, Misaki Nanaha. Together the crew strives to prove themselves to their detractors that they are no mere 'Alice Brand'. Yet as they go about their mission a larger global conspiracy seems to be working behind the scenes to take advantage of this new world order. + + (Source: ANN) + """.trimIndent(), + MyAnimeListInfo.ReleaseType.TV, + 13, + "Unknown", + "https://cdn.myanimelist.net/images/anime/8/61829.jpg", + listOf( + Genre.COMEDY, + Genre.MILITARY, + Genre.ROMANCE, + Genre.SCIFI + ), + AnimePageScraper.Aired( + IntDate(2006, 0, 8), + IntDate(2006, 3, 2) + ), + AnimePageScraper.Premiered(AnimePageScraper.Premiered.Season.Winter, 2006), + MyAnimeListInfo.Rating.PG13, + Duration.ofMinutes(25), + listOf( + AnimePageScraper.Relation(AnimeRelation.RelationType.SideStory, 1790) + ), + listOf( + AnimePageScraper.ProducerRelation(ProducerFunction.Producer, AnimePageScraper.Producer(23, "Bandai Visual")), + AnimePageScraper.ProducerRelation(ProducerFunction.Producer, AnimePageScraper.Producer(104, "Lantis")), + AnimePageScraper.ProducerRelation(ProducerFunction.Studio, AnimePageScraper.Producer(60, "Actas")) + ) + ), + "1558.html" to AnimePageScraper.Info( + "Yarima Queen", + "Sex Demon Queen", + "ヤーリマクィーン", + listOf(), + """ + The sorceress Kuri uses her magic to defend herself from perverted monsters and demons, but her partner, Rima, would much rather do perverted things than defend herself. When the two save a woman from a gang rape, they catch the eye of an evil Sex Queen and her dog-demons. When the sex demons release the passions of Kuri and Rima, even the duo`s formidable powers will be useless. Don`t miss it as all four girls redefine the meaning of Doggie Style! + + (Source: AniDB) + """.trimIndent(), + MyAnimeListInfo.ReleaseType.OVA, + 1, + "Unknown", + "https://cdn.myanimelist.net/images/anime/10/41571.jpg", + listOf( + Genre.COMEDY, + Genre.DEMONS, + Genre.FANTASY, + Genre.HENTAI, + Genre.MAGIC, + Genre.PARODY + ), + AnimePageScraper.Aired( + IntDate(2000, 5, 25) + ), + null, + MyAnimeListInfo.Rating.Rx, + Duration.ofMinutes(30), + listOf(), + listOf( + AnimePageScraper.ProducerRelation(ProducerFunction.Producer, AnimePageScraper.Producer(48, "AIC")), + AnimePageScraper.ProducerRelation(ProducerFunction.Producer, AnimePageScraper.Producer(152, "Green Bunny")), + AnimePageScraper.ProducerRelation(ProducerFunction.Licensor, AnimePageScraper.Producer(250, "Media Blasters")), + AnimePageScraper.ProducerRelation(ProducerFunction.Licensor, AnimePageScraper.Producer(595, "NYAV Post")) + ) + ), + "1581.html" to AnimePageScraper.Info( + "Gift: Eternal Rainbow", + null, + "ギフト~ eternal rainbow", + listOf(), + """ + Amaumi Haruhiko is a high school student who attends Shimano Academy in a town called Narasakicho. Narasakicho contains an unknown rainbow which constantly overlooks the town and is related to granting a magical wish called "Gift." Gift is a once-in-a-lifetime present between two people. + + As a child, Haruhiko has been close with his childhood friend, Kirino, until he obtains a new non-blood sister by the name of Riko. Haruhiko develops a strong relationship with Riko until they sadly depart due to the fact Haruhiko's father could no longer support the two of them. + + After some times passes by, Riko finally returns to the town of Narasakicho, and along with Kirino, starts to attend Shimano Academy with Haruhiko. The series revolves around the relationship among these main protagonists and slowly reveals the story behind both Gift and the rainbow. + """.trimIndent(), + MyAnimeListInfo.ReleaseType.TV, + 12, + "Visual novel", + "https://cdn.myanimelist.net/images/anime/2/75540.jpg", + listOf( + Genre.COMEDY, + Genre.DRAMA, + Genre.HAREM, + Genre.MAGIC, + Genre.ROMANCE, + Genre.SCHOOL + ), + AnimePageScraper.Aired( + IntDate(2006, 9, 6), + IntDate(2006, 11, 22) + ), + AnimePageScraper.Premiered(AnimePageScraper.Premiered.Season.Fall, 2006), + MyAnimeListInfo.Rating.PG13, + Duration.ofMinutes(24), + listOf( + AnimePageScraper.Relation(AnimeRelation.RelationType.SideStory, 2784) + ), + listOf( + AnimePageScraper.ProducerRelation(ProducerFunction.Producer, AnimePageScraper.Producer(829, "Studio Jack")), + AnimePageScraper.ProducerRelation(ProducerFunction.Studio, AnimePageScraper.Producer(28, "OLM")) + ) + ) + ) + + for ((file, info) in items) { + "Test $file" { + val html = this::class.java.getResourceAsStream("/test-pages/$file") + .readAllBytes() + .toString(Charsets.UTF_8) + + AnimePageScraper(html) + .getInfo() shouldBe info + } + } +}) diff --git a/src/test/resources/test-pages/1558.html b/src/test/resources/test-pages/1558.html new file mode 100644 index 0000000..a3e7161 --- /dev/null +++ b/src/test/resources/test-pages/1558.html @@ -0,0 +1,1288 @@ + +<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" + "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"> + +<html xmlns:og="http://ogp.me/ns#" xmlns:fb="http://www.facebook.com/2008/fbml"> +<head> + +<link rel="preconnect" href="//fonts.gstatic.com/" crossorigin="anonymous" /> +<link rel="preconnect" href="//fonts.googleapis.com/" crossorigin="anonymous" /> +<link rel="preconnect" href="//tags-cdn.deployads.com/" crossorigin="anonymous" /> +<link rel="preconnect" href="//www.googletagservices.com/" crossorigin="anonymous" /> +<link rel="preconnect" href="//www.googletagmanager.com/" crossorigin="anonymous" /> +<link rel="preconnect" href="//apis.google.com/" crossorigin="anonymous" /> +<link rel="preconnect" href="//pixel-sync.sitescout.com/" crossorigin="anonymous" /> +<link rel="preconnect" href="//pixel.tapad.com/" crossorigin="anonymous" /> +<link rel="preconnect" href="//c.deployads.com/" crossorigin="anonymous" /> +<link rel="preconnect" href="//requal-alleased.com/" crossorigin="anonymous" /> +<link rel="preconnect" href="//tpc.googlesyndication.com/" crossorigin="anonymous" /> +<link rel="preconnect" href="//googleads.g.doubleclick.net/" crossorigin="anonymous" /> +<link rel="preconnect" href="//securepubads.g.doubleclick.net/" crossorigin="anonymous" /> +<link rel="preconnect" href="https://cdn.myanimelist.net" crossorigin="anonymous" /> + + +<title> +Yarima Queen (Sex Demon Queen) - MyAnimeList.net + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

+ + + + + +
+ +
+
+ + + + +

+ Yarima Queen
Sex Demon Queen
+ Edit Anime Information +
+
+ +
What would you like to edit?
+ +   +
+
+

+ + + + + +
+ +
+ + Yarima Queen + +
+ + + + + + + + + +

+ +

Alternative Titles

+ English: Sex Demon Queen +
+ Japanese: ヤーリマクィーン +

+ +

Information

+ +
+ Type: + OVA
+ +
+ Episodes: + 1 +
+ +
+ Status: + Finished Airing +
+ +
+ Aired: + Jun 25, 2000 +
+ + +
+ Producers: + AIC, Green Bunny
+
+ Licensors: + Media Blasters, NYAV Post
+
+ Studios: + None found, add some
+ +
+ Source: + Unknown +
+ +
+ Genres: + ComedyComedy, DemonsDemons, FantasyFantasy, HentaiHentai, MagicMagic, ParodyParody
+ +
+ Duration: + 30 min. +
+ +
+ Rating: + Rx - Hentai +
+ +
+ +

Statistics

+
+ Score: + 6.131 (scored by 13711,371 users) + + +
+ 1 + indicates a weighted score. + +
+
+
+ Ranked: + N/A2 +
+ 2 + based on the top anime page. Please note that 'Not yet aired' and 'R18+' titles are excluded. + +
+
+
+ Popularity: + #7186 +
+
+ Members: + 3,123 +
+
+ Favorites: + 17 +
+ +
+
+ +
+
+ +
+ + + + + + + + + + + +
6.13
Ranked N/APopularity #7186Members 3,123
Add to List
+ Episodes: /1
buy from amazon
* Your list is public by default.
+

Synopsis

The sorceress Kuri uses her magic to defend herself from perverted monsters and demons, but her partner, Rima, would much rather do perverted things than defend herself. When the two save a woman from a gang rape, they catch the eye of an evil Sex Queen and her dog-demons. When the sex demons release the passions of Kuri and Rima, even the duo`s formidable powers will be useless. Don`t miss it as all four girls redefine the meaning of Doggie Style!
+
+(Source: AniDB)

Background

No background information has been added to this title. Help improve our database by adding background information here.

Characters & Voice Actors

+ + + + + +
+
+ + Rima + +
+
+ Rima +
+ Main +
+
+
+
+ + + + + +
+
+ + Kuri + +
+
+ Kuri +
+ Main +
+
+
+
+ + + + + +
+
+ + Anaruski + +
+
+ Anaruski +
+ Supporting +
+
+ + + +
+ Akimoto, Yousuke
+ Japanese +
+
+ + Akimoto, Yousuke + +
+
+
+ + + + + +
+
+ + Sawa + +
+
+ Sawa +
+ Supporting +
+
+
+
+

+ +

+ + Staff +

+ +
+ + + + +
+
+ + Inoue, Hiroaki + +
+
+ Inoue, Hiroaki +
+ Producer +
+
+ + + + +
+
+ + Aoki, Takeshi + +
+
+ Aoki, Takeshi +
+ Director +
+
+ + + + +
+
+ + Aoki, Ei + +
+
+ Aoki, Ei +
+ Storyboard +
+
+ + + + +
+
+ + Yamauchi, Daisuke + +
+
+ Yamauchi, Daisuke +
+ Key Animation +
+
+

+
+

+ Edit + Opening Theme +

+
+ No opening themes have been added to this title. Help improve our database by adding an opening theme here. +
+
+
+ +
+

+ Edit + Ending Theme +

+
+ No ending themes have been added to this title. Help improve our database by adding an ending theme here. +
+
+
+


More reviewsReviews

+ +
+
+
+
Feb 14, 2019
+ +
+ Overall Rating: + 10 +
+
+ + + + + +
+
+ + + +
+
+ Krunchyman + (All reviews)
+ +
+
+
+
+ + + + Mr. Frostbite-sama needs the (Nuva)ring to halt his ceaseless RAMpage of impregnating random hoes. But before the purple-haired sex-goddess can embark on her quest of finding said ring, she must ride the satanic flagpole of JUSTICE to stimulate the release of the CUCKoo clock in preparation for the cumming ritual.
+
+[Obligatory cum(info?) dump incumming]
+
+Yarudazuigu, the sex demon, used his immense shlong to penetrate five mud flaps on his way to winning his ninth super bowl, and, by default, achieving world domination. Yet, without the aid of a sandwich — equal rights, my ass! — to sustain his vitality, Yarudaziugu fell prey to the unavoidable + + + read more +
+
+ +
+
+ + +
+
+
+
Dec 5, 2014
+ +
+ Overall Rating: + 4 +
+
+ + + + + +
+
+ + + +
+
+ phoenix046 + (All reviews)
+ +
+
+
+
+ + + + After reading the synopsis I couldn't help but laugh, and this is what Yarima Queen does. It's an extremely silly hentai whose sole purpose is to make you laugh. No matter how serious the story may be, or how good the beginning may look (yes it begins with its best sex scene), it is nothing more than a stupid hentai.
+
+If you try to watch it like a hentai, you would be disappointed (as I am). Remove the sex scenes and what do you get? A stupid kids show.
+I would advise you to watch it only if you want to watch something hilarious. + + +
+ Helpful +
+
+
+ +
+
+ + +
+
+
+
Apr 23, 2013
+ +
+ Overall Rating: + 8 +
+
+ + + + + +
+
+ + + +
+
+ fatmacman + (All reviews)
+ +
+
+
+
+ + + + Sex Demon Queen is probably the silliest hentai you will see this year or any other. This non-stop goof fest has it all from sex demon dogs and tentacle monsters to magic girls and gun toting monkeys. Did I mention it is also a hard-core hentai, with plenty of yuri action for you to enjoy.
+
+This is a single, 30 minute outing, so the story line is kind of shallow, and what story there is just acts as a set up for the comedy. The anime was released in 2000, so thankfully it comes to us fully uncensored and with an available English + + + read more +
+
+ +
+
+ + +
+
+
+
Feb 2, 2018
+ +
+ Overall Rating: + 8 +
+
+ + + + + +
+
+ + + +
+
+ Sidewinder51 + (All reviews)
+ +
+
+
+
+ + + + Rate an 8
+English Dubbed
+Uncensored
+
+Plot
+Sex is great but not in certain introductions like this anime. Reason being is lack of information was mentioned. Almost as if the director though well... males in general have a 7 second attention span. What can we do do keep them watching our movie? Hmm... i know. Placing a sex scene that has some character background will be perfect.
+
+ Yes/no. Mainly no. While sex is fun to watch/partake in. At least for males makes it extremely hard to focus on anything else. So, if the goal was to inform the reader on the story you might want to make sure their + + + read more +
+
+ +
+
+
+ + +

Recent News


Recent Forum Discussion

+ + + + + + +
Poll: Yarima Queen Episode 1 Discussion
Kleferi - Oct 28, 2009
6 repliesby KanonDE »»
Mar 13, 2017 3:08 PM

+ + Recommendations +

+
+
+
+
+
+
+
+ +
+
+
+ + + + + + + + + + + + +
+ + + +
+ + +
+ +
+
+ + + + + + + + + + + + + + + + + + diff --git a/src/test/resources/test-pages/1581.html b/src/test/resources/test-pages/1581.html new file mode 100644 index 0000000..24b584e --- /dev/null +++ b/src/test/resources/test-pages/1581.html @@ -0,0 +1,1629 @@ + + + + + + + + + + + + + + + + + + + + + + + +Gift: Eternal Rainbow - MyAnimeList.net + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + +
+ +
+
+ + + + +

+ Gift: Eternal Rainbow
+ Edit Anime Information +
+
+ +
What would you like to edit?
+ +   +
+
+

+ + + + + +
+ +
+ + Gift: Eternal Rainbow + +
+ + + + + + + + + +

+ +

Alternative Titles

+ Japanese: ギフト~ eternal rainbow +

+ +

Information

+ +
+ Type: + TV
+ +
+ Episodes: + 12 +
+ +
+ Status: + Finished Airing +
+ +
+ Aired: + Oct 6, 2006 to Dec 22, 2006 +
+ +
+ Premiered: + Fall 2006 +
+ +
+ Broadcast: + Unknown +
+ +
+ Producers: + Studio Jack
+
+ Licensors: + None found, add some
+
+ Studios: + OLM
+ +
+ Source: + Visual novel +
+ +
+ Genres: + ComedyComedy, DramaDrama, HaremHarem, MagicMagic, RomanceRomance, SchoolSchool
+ +
+ Duration: + 24 min. per ep. +
+ +
+ Rating: + PG-13 - Teens 13 or older +
+ +
+ +

Statistics

+
+ Score: + 6.661 (scored by 1196811,968 users) + + +
+ 1 + indicates a weighted score. + +
+
+
+ Ranked: + #50082 +
+ 2 + based on the top anime page. Please note that 'Not yet aired' and 'R18+' titles are excluded. + +
+
+
+ Popularity: + #2834 +
+
+ Members: + 27,578 +
+
+ Favorites: + 28 +
+ +
+
+ +
+ + + + + + + + + + + + +
6.66
Ranked #5008Popularity #2834Members 27,578
Add to List
+ Episodes: /12
buy from amazon
* Your list is public by default.
+

Synopsis

Amaumi Haruhiko is a high school student who attends Shimano Academy in a town called Narasakicho. Narasakicho contains an unknown rainbow which constantly overlooks the town and is related to granting a magical wish called "Gift." Gift is a once-in-a-lifetime present between two people.
+
+As a child, Haruhiko has been close with his childhood friend, Kirino, until he obtains a new non-blood sister by the name of Riko. Haruhiko develops a strong relationship with Riko until they sadly depart due to the fact Haruhiko's father could no longer support the two of them.
+
+After some times passes by, Riko finally returns to the town of Narasakicho, and along with Kirino, starts to attend Shimano Academy with Haruhiko. The series revolves around the relationship among these main protagonists and slowly reveals the story behind both Gift and the rainbow.

Background

No background information has been added to this title. Help improve our database by adding background information here.
+
+ +
+
+
+ +
+

Related Anime


Characters & Voice Actors

+ + + + + +
+
+ + Fukamine, Riko + +
+
+ Fukamine, Riko +
+ Main +
+
+ + + +
+ Shimizu, Ai
+ Japanese +
+
+ + Shimizu, Ai + +
+
+
+ + + + + +
+
+ + Konosaka, Kirino + +
+
+ Konosaka, Kirino +
+ Main +
+
+ + + +
+ Miyazaki, Ui
+ Japanese +
+
+ + Miyazaki, Ui + +
+
+
+ + + + + +
+
+ + Amami, Haruhiko + +
+
+ Amami, Haruhiko +
+ Main +
+
+ + + +
+ Tai, Yuuki
+ Japanese +
+
+ + Tai, Yuuki + +
+
+
+ + + + + +
+
+ + Kamishiro, Yukari + +
+
+ Kamishiro, Yukari +
+ Supporting +
+
+ + + +
+ Koshimizu, Ami
+ Japanese +
+
+ + Koshimizu, Ami + +
+
+
+ + + + + +
+
+ + Fujimiya, Chisa + +
+
+ Fujimiya, Chisa +
+ Supporting +
+
+ + + +
+ Shintani, Ryouko
+ Japanese +
+
+ + Shintani, Ryouko + +
+
+
+ + + + + +
+
+ + Hokazono, Rinka + +
+
+ Hokazono, Rinka +
+ Supporting +
+
+ + + +
+ Kawaragi, Shiho
+ Japanese +
+
+ + Kawaragi, Shiho + +
+
+
+ + + + + +
+
+ + Asakawa, Sena + +
+
+ Asakawa, Sena +
+ Supporting +
+
+ + + +
+ Enomoto, Atsuko
+ Japanese +
+
+ + Enomoto, Atsuko + +
+
+
+ + + + + +
+
+ + Edo, Masaki + +
+
+ Edo, Masaki +
+ Supporting +
+
+ + + +
+ Hatano, Kazutoshi
+ Japanese +
+
+ + Hatano, Kazutoshi + +
+
+
+ + + + + +
+
+ + Himekura, Nene + +
+
+ Himekura, Nene +
+ Supporting +
+
+ + + +
+ Matsui, Naoko
+ Japanese +
+
+ + Matsui, Naoko + +
+
+
+ + + + + +
+
+ + Kasai + +
+
+ Kasai +
+ Supporting +
+
+ + + +
+ Yamazaki, Takumi
+ Japanese +
+
+ + Yamazaki, Takumi + +
+
+
+

+ +

+ + Staff +

+ +
+ + + + +
+
+ + Iwasa, Gaku + +
+
+ Iwasa, Gaku +
+ Producer +
+
+ + + + +
+
+ + Kimiya, Shigeru + +
+
+ Kimiya, Shigeru +
+ Director, Episode Director, Storyboard +
+
+ + + + +
+
+ + Takakuwa, Hajime + +
+
+ Takakuwa, Hajime +
+ Sound Director +
+
+ + + + +
+
+ + Tsuchiya, Hiroyuki + +
+
+ Tsuchiya, Hiroyuki +
+ Episode Director +
+
+

+
+

+ Edit + Opening Theme +

+
"Nijiiro Sentimental" by Miyuki Hashimoto
+
+
+ +
+

+ Edit + Ending Theme +

+
"Kokoro Niji o Kakete" by Misato Fujiya
+
+
+


More reviewsReviews

+ +
+
+
+
Jan 30, 2008
+ +
+ Overall Rating: + 7 +
+
+ + + + + +
+
+ + + +
+
+ Hyeonnie + (All reviews)
+ +
+
+
+
+ + + + To tell you the truth, I did not expect much from these series..
+but then, it seems I was wrong xD.
+
+Story: the story is the usuall love triangle that I have seen so many times.
+A childhood friend and a male protagonist getting along when another old friend that the male character had a crush on( usually) comes back and there goes the triangle. However, the story was, nonetheless, good. It wasn't boring, although there wasn'nt anything that stood out that much.
+
+Art: the art overall was pretty good, and the figures didn't seem out of place. The characters were drawn nicely. The art did + + + read more +
+
+ +
+
+ + +
+
+
+
Jul 31, 2011
+ +
+ Overall Rating: + 8 +
+
+ + + + + +
+
+ + + +
+
+ pursoul + (All reviews)
+ +
+
+
+
+ + + + Gift: Eternal Rainbow is yet another anime adaption of an H-game, usually im not into these kinds of anime but this series really proved me wrong on almost every aspect.
+
+Now on to the story:
+When I first read the summary I immediatly backed away thinking that its just one of those randomness anime that have no plot, But as above I was proven wrong and im really glad I was wrong, Gift: Eternal Rainbow has a very nice plot, quite confusing during the first half of the series and it starts at a rather slow pace, but as you progress through series It just keeps getting + + + read more +
+
+ +
+
+ + +
+
+
+
Apr 1, 2009
+ +
+ Overall Rating: + 5 +
+
+ + + + + +
+
+ + + +
+
+ ZetaAspect + (All reviews)
+ +
+
+
+
+ + + + "Gift ~eternal rainbow~" is yet another show based on an H-Game. Really, what isn't based on an H-Game nowadays? In any case, I decided to watch it simply on the basis that it had no reviews on AnimeNfo. As such, I did not have high expectations. However, it turned out to be a decent show. By no means was it anything special, but it was somewhat enjoyable.
+
+
+STORY: 5/10
+
+The story of "Gift" isn't a particularly great one, and it doesn’t always make a whole lot of sense. Not to mention that the gimmick of this series, the Gifts themselves, are + + + read more +
+
+ +
+
+ + +
+
+
+
Jan 29, 2015
+ +
+ Overall Rating: + 8 +
+
+ + + + + +
+
+ + + +
+
+ torrapamii + (All reviews)
+ +
+
+
+
+ + + + "Precious gifts can be given even without relying on a miraculous power."
+
+I feel like this phrase alone was indirectly against religions that believed in miracles, but please don't take me on that. A-Anyways, time to review!
+
+Story: 9/10
+
+Let me begin by stating my first impression that I was going to put the story down to a 6 or even a 5, but I was really wrong. This story started off happy, then kept getting better and better as it progressed. A good story always comes with a a good setting, problem and climax, which is why I would think this is a good story. Aside from + + + read more +
+
+ +
+
+
+ + +
+
+ +
+

Recent News


Recent Forum Discussion

+ + + + + + + + + + + + +
Poll: Gift ~eternal rainbow~ Episode 4 Discussion
cureroyale - Mar 31, 2009
5 repliesby NostalgiaDrive94 »»
Apr 8, 9:32 AM
Poll: Gift ~eternal rainbow~ Episode 3 Discussion
cureroyale - Mar 29, 2009
5 repliesby NostalgiaDrive94 »»
Apr 8, 8:57 AM

+ + Recommendations +

+
+
+
+
+
+
+
+ +
+
+
+ + + + + + + + + + + + +
+ + + +
+ + +
+ +
+
+ + + + + + + + + + + + + + + + + + diff --git a/src/test/resources/test-pages/25313.html b/src/test/resources/test-pages/25313.html new file mode 100644 index 0000000..ca875d1 --- /dev/null +++ b/src/test/resources/test-pages/25313.html @@ -0,0 +1,1213 @@ + + + + + + + + + + + + + + + + + + + + + + + +Gintama': Futon ni Haitte kara Buki Nokoshi ni Kizuite Neru ni Nerenai Toki mo Aru - MyAnimeList.net + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + +
+ +
+
+ + + + +

+ Gintama': Futon ni Haitte kara Buki Nokoshi ni Kizuite Neru ni Nerenai Toki mo Aru
+ Edit Anime Information +
+
+ +
What would you like to edit?
+ +   +
+
+

+ + + + + +
+ +
+ + Gintama': Futon ni Haitte kara Buki Nokoshi ni Kizuite Neru ni Nerenai Toki mo Aru + +
+ + + + + + + + + +

+ +

Alternative Titles

+ Synonyms: Gintama: Jump Festa 2014 Special, Gintama OAD, Gintama OVA +
+ Japanese: 銀魂 〜布団に入ってから拭き残しに気付いて寝るに寝れない時もある〜 +

+ +

Information

+ +
+ Type: + Special
+ +
+ Episodes: + 1 +
+ +
+ Status: + Finished Airing +
+ +
+ Aired: + Apr 3, 2015 +
+ + +
+ Producers: + None found, add some
+
+ Licensors: + None found, add some
+
+ Studios: + Sunrise
+ +
+ Source: + Manga +
+ +
+ Genres: + ActionAction, Sci-FiSci-Fi, ComedyComedy, HistoricalHistorical, ParodyParody, SamuraiSamurai, ShounenShounen
+ +
+ Duration: + 24 min. +
+ +
+ Rating: + PG-13 - Teens 13 or older +
+ +
+ +

Statistics

+
+ Score: + 8.111 (scored by 1159011,590 users) + + +
+ 1 + indicates a weighted score. + +
+
+
+ Ranked: + #4002 +
+ 2 + based on the top anime page. Please note that 'Not yet aired' and 'R18+' titles are excluded. + +
+
+
+ Popularity: + #2934 +
+
+ Members: + 26,169 +
+
+ Favorites: + 61 +
+ +
+
+ +
+
+ +
+ + + + + + + + + + + +
8.11
Ranked #400Popularity #2934Members 26,169
Add to List
+ Episodes: /1
buy from amazon
* Your list is public by default.
+

Synopsis

Bundled with limited edition of the 58th Gintama manga volume.
+
+The tagline for the bundled anime reads, "It's time for all the Yorozuya members ...to wake up just one more time."
+
+(Source: MAL News, edited)

Background

This special Gintama episode was previewed at a screening at Jump Special Anime Festa 2014. On April 3, 2015, it was later bundled with a limited edition of the manga's 58th volume.
+
+ +
+
+
+ +
+

Related Anime


Characters & Voice Actors

+ + + + + +
+
+ + Sakata, Gintoki + +
+
+ Sakata, Gintoki +
+ Main +
+
+ + + +
+ Sugita, Tomokazu
+ Japanese +
+
+ + Sugita, Tomokazu + +
+
+
+ + + + + +
+
+ + Kagura + +
+
+ Kagura +
+ Main +
+
+ + + +
+ Kugimiya, Rie
+ Japanese +
+
+ + Kugimiya, Rie + +
+
+
+ + + + + +
+
+ + Shimura, Shinpachi + +
+
+ Shimura, Shinpachi +
+ Main +
+
+ + + +
+ Sakaguchi, Daisuke
+ Japanese +
+
+ + Sakaguchi, Daisuke + +
+
+
+ + + + + +
+
+ + Hijikata, Toushirou + +
+
+ Hijikata, Toushirou +
+ Supporting +
+
+ + + +
+ Nakai, Kazuya
+ Japanese +
+
+ + Nakai, Kazuya + +
+
+
+ + + + + +
+
+ + Katsura, Kotarou + +
+
+ Katsura, Kotarou +
+ Supporting +
+
+ + + +
+ Ishida, Akira
+ Japanese +
+
+ + Ishida, Akira + +
+
+
+ + + + + +
+
+ + Okita, Sougo + +
+
+ Okita, Sougo +
+ Supporting +
+
+ + + +
+ Suzumura, Kenichi
+ Japanese +
+
+ + Suzumura, Kenichi + +
+
+
+ + + + + +
+
+ + Hasegawa, Taizou + +
+
+ Hasegawa, Taizou +
+ Supporting +
+
+ + + +
+ Tachiki, Fumihiko
+ Japanese +
+
+ + Tachiki, Fumihiko + +
+
+
+ + + + + +
+
+ + Elizabeth + +
+
+ Elizabeth +
+ Supporting +
+
+
+
+ + + + + +
+
+ + Kondou, Isao + +
+
+ Kondou, Isao +
+ Supporting +
+
+ + + +
+ Chiba, Susumu
+ Japanese +
+
+ + Chiba, Susumu + +
+
+
+ + + + + +
+
+ + Sadaharu + +
+
+ Sadaharu +
+ Supporting +
+
+ + + +
+ Takahashi, Mikako
+ Japanese +
+
+ + Takahashi, Mikako + +
+
+
+

+ +

+
+ Staff +

+ +
+ + + + +
+
+ + Miyawaki, Chizuru + +
+
+ Miyawaki, Chizuru +
+ Director, Series Composition +
+
+ + + + +
+
+ + Sorachi, Hideaki + +
+
+ Sorachi, Hideaki +
+ Original Creator +
+
+ + + + +
+
+ + Fujita, Yoichi + +
+
+ Fujita, Yoichi +
+ Planning +
+
+

+
+

+ Edit + Opening Theme +

+
+ No opening themes have been added to this title. Help improve our database by adding an opening theme here. +
+
+
+ +
+

+ Edit + Ending Theme +

+
"Ring a Ding Dong" by Saeko Suzuki
+
+
+


More reviewsReviews

+ +
+
+
+
Apr 9, 2015
+ +
+ Overall Rating: + 9 +
+
+ + + + + +
+
+ + + +
+
+ BlobbertMcN + (All reviews)
+ +
+
+
+
+ + + + Mod Note: This review was written for Gintama OVA, the entry page of Gintama OVA has been merged with Gintama: Jump Festa 2014 Special.
+
+Well then~ This was a slightly confounding OVA - appearing almost around the exact same time as the first episode for Gintama 2015 (Gintama°), so initially I thought it WAS the first episode! And really, this OVA feels very much like a first episode for a new season of Gintama, so it was understandable if people were confused! So, for such a nice little surprise; here is a nice little review: Let me break it down~
+
+~STORY~
+Gintama's episodic storytelling at its finest. Starting + + + read more +
+
+ +
+
+
+ + +
+
+ +
+

Recent News

Manga 'Gintama' Gets OVA

Manga 'Gintama' Gets OVA

+

The 50th issue of Shueisha's Weekly Shounen Jump will announce that Gintama will include an OVA bundled with volume 58, to be released April 3, 2015. The issue ...read more

+
+

Recent Forum Discussion

+ + + + + + + + + + + + +
Poll: Gintama: Jump Festa 2014 Special Discussion ( 1 2 )
BLaCkGuN69 - Apr 5, 2015
69 repliesby MeisterDM »»
May 4, 2019 2:16 PM
GINTAMA SEASON 4 ANNOUNCED!!!!!
Calculus94 - Dec 20, 2014
13 repliesby CreamZi »»
Jan 1, 2015 2:33 AM

Recommendations

+
+
+
+
+
+
+
+ +
+
+
+ + + + + + + + + + + + +
+ + + +
+ + +
+ +
+
+ + + + + + + + + + + + + + + + + + diff --git a/src/test/resources/test-pages/616.html b/src/test/resources/test-pages/616.html new file mode 100644 index 0000000..167f82a --- /dev/null +++ b/src/test/resources/test-pages/616.html @@ -0,0 +1,1435 @@ + + + + + + + + + + + + + + + + + + + + + + + +Nurse Angel Ririka SOS - MyAnimeList.net + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + +
+ +
+
+ + + + +

+ Nurse Angel Ririka SOS
+ Edit Anime Information +
+
+ +
What would you like to edit?
+ +   +
+
+

+ + + + + +
+ +
+ + Nurse Angel Ririka SOS + +
+ + + + + + + + + +

+ +

Alternative Titles

+ Japanese: ナースエンジェルりりかSOS +

+ +

Information

+ +
+ Type: + TV
+ +
+ Episodes: + 35 +
+ +
+ Status: + Finished Airing +
+ +
+ Aired: + Jul 7, 1995 to Mar 29, 1996 +
+ +
+ Premiered: + Summer 1995 +
+ +
+ Broadcast: + Fridays at 18:00 (JST) +
+ +
+ Producers: + TV Tokyo, Nihon Ad Systems
+
+ Licensors: + None found, add some
+
+ Studios: + Gallop
+ +
+ Source: + Manga +
+ +
+ Genres: + DramaDrama, FantasyFantasy, MagicMagic, ShoujoShoujo
+ +
+ Duration: + 24 min. per ep. +
+ +
+ Rating: + PG - Children +
+ +
+ +

Statistics

+
+ Score: + 6.761 (scored by 485485 users) + + +
+ 1 + indicates a weighted score. + +
+
+
+ Ranked: + #44492 +
+ 2 + based on the top anime page. Please note that 'Not yet aired' and 'R18+' titles are excluded. + +
+
+
+ Popularity: + #7651 +
+
+ Members: + 2,590 +
+
+ Favorites: + 13 +
+ +
+
+ +
+
+ +
+ + + + + + + + + + + +
6.76
Ranked #4449Popularity #7651Members 2,590
Add to List
+ Episodes: /35
buy from amazon
* Your list is public by default.
+

Synopsis

The Evil Forces of Dark Joker are closing in on our planet after having destroyed the beautiful planet of Queen Earth. Now, 10 year old Moriya Ririka, with the help of her childhood friend Seiya and the mysterious Kanon, must transform into the Nurse Angel and find the elusive Flower of Life, the only way to defeat the evil forces. The Flower of Life, that once bloomed all over the Earth, is where no one thought it ever would be. And Ririka must make the hardest decision of her life in order to acquire it and rid the universe of evil once and for all.
+
+(Source: ANN)

Background

No background information has been added to this title. Help improve our database by adding background information here.
+
+ +
+
+
+ +
+

Related Anime


Characters & Voice Actors

+ + + + + +
+
+ + Moriya, Ririka + +
+
+ Moriya, Ririka +
+ Main +
+
+ + + +
+ Asou, Kaori
+ Japanese +
+
+ + Asou, Kaori + +
+
+
+ + + + + +
+
+ + Uzaki, Seiya + +
+
+ Uzaki, Seiya +
+ Main +
+
+ + + +
+ Ishida, Akira
+ Japanese +
+
+ + Ishida, Akira + +
+
+
+ + + + + +
+
+ + Nozomu, Kanou + +
+
+ Nozomu, Kanou +
+ Main +
+
+ + + +
+ Kikuchi, Hidehiro
+ Japanese +
+
+ + Kikuchi, Hidehiro + +
+
+
+ + + + + +
+
+ + Dewey + +
+
+ Dewey +
+ Supporting +
+
+ + + +
+ Fuchizaki, Yuriko
+ Japanese +
+
+ + Fuchizaki, Yuriko + +
+
+
+ + + + + +
+
+ + Kazami, Anna + +
+
+ Kazami, Anna +
+ Supporting +
+
+ + + +
+ Nakao, Azusa
+ Japanese +
+
+ + Nakao, Azusa + +
+
+
+ + + + + +
+
+ + Kuwano, Miyuki + +
+
+ Kuwano, Miyuki +
+ Supporting +
+
+ + + +
+ Matsuoka, Emiko
+ Japanese +
+
+ + Matsuoka, Emiko + +
+
+
+ + + + + +
+
+ + Buros + +
+
+ Buros +
+ Supporting +
+
+ + + +
+ Horikawa, Ryo
+ Japanese +
+
+ + Horikawa, Ryo + +
+
+
+ + + + + +
+
+ + Helena + +
+
+ Helena +
+ Supporting +
+
+ + + +
+ Amano, Yuri
+ Japanese +
+
+ + Amano, Yuri + +
+
+
+ + + + + +
+
+ + Mimina + +
+
+ Mimina +
+ Supporting +
+
+ + + +
+ Namiki, Noriko
+ Japanese +
+
+ + Namiki, Noriko + +
+
+
+ + + + + +
+
+ + Mizuhara, Karin + +
+
+ Mizuhara, Karin +
+ Supporting +
+
+ + + +
+ Suzuki, Kaori
+ Japanese +
+
+ + Suzuki, Kaori + +
+
+
+

+ +

+ + Staff +

+ +
+ + + + +
+
+ + Daichi, Akitarou + +
+
+ Daichi, Akitarou +
+ Director, Episode Director, Script, Storyboard +
+
+ + + + +
+
+ + Tanaka, Kazuya + +
+
+ Tanaka, Kazuya +
+ Sound Director +
+
+ + + + +
+
+ + Sakurai, Hiroaki + +
+
+ Sakurai, Hiroaki +
+ Episode Director, Storyboard +
+
+ + + + +
+
+ + Sugishima, Kunihisa + +
+
+ Sugishima, Kunihisa +
+ Episode Director, Storyboard +
+
+

+
+

+ Edit + Opening Theme +

+
#01: "Koi wo Suru Tabi ni Kizutsuki Yasuku..." by Cuiling (eps 01-26)
#02: "Do-nika Ko-nika" by Eiko Minami (eps 27-35)
+
+
+ +
+

+ Edit + Ending Theme +

+
#01: "Ririka SOS" by Kaori Asou (eps 01-23)
#02: "Egao wo Wasurenai" by Kaori Asou (eps 24-35)
+
+
+


More reviewsReviews

+ +
+
+
+
Jan 31, 2014
+ +
+ Overall Rating: + 8 +
+
+ + + + + +
+
+ + + +
+
+ Firechick12012 + (All reviews)
+ +
+
+
+
+ + + + During the early to mid nineties, Sailor Moon was sweeping the nation, being one of the first magical girl anime to make it big in the US, and becoming wildly popular in Japan, so much so that it trumped other shows airing around the same time, Nurse Angel Ririka SOS being one of them. I first discovered this on Anime News Network in the form of a Buried Treasure article. Naturally, I got interested, so I tracked down the first episode and watch it. Unfortunately, I didn't get into the habit of completing shows quite yet (it was around the time I was eating up + + + read more +
+
+ +
+
+ + +
+
+
+
Feb 11, 2009
+ +
+ Overall Rating: + 6 +
+
+ + + + + +
+
+ + + +
+
+ Anomalous + (All reviews)
+ +
+
+
+
+ + + + It&rsquo;s funny how fansubbing has changed over the years: series that were once distributed on tapes to enthusiastic fans all over the country are now all but forgotten. Nurse Angel Ririka SOS is one such anime. Then again, I can&rsquo;t say I&rsquo;m really surprised that the series has been lost in time &ndash; while it&rsquo;s a solid little magical girl tale, it&rsquo;s not really a something worth going out of your way to see.
+
+Nurse Angel Ririka SOS stars, a ten-year-old girl who finds out she has the ability to transform into the legendary nurse angel and fight evil. She runs into trouble with villains, she + + + read more +
+
+ +
+
+ + +
+
+
+
Jan 9, 2013
+ +
+ Overall Rating: + 10 +
+
+ + + + + +
+
+ + + +
+
+ MladyJane + (All reviews)
+ +
+
+
+
+ + + + I never write reviews. But for this, I felt like I had to.
+
+A little background:
+I first watched Nurse Angel Ririka SOS when I was living in Korea, I must have been around 3-4 years old. I remember watching it and loving it!!!
+Of course, being so young I had no idea what it was called because I didn't know how to read Korean at that age.
+
+Years after, I somehow managed to find this! And I have to say, overall, I enjoyed it a lot! More than I should have, I think. I'm 18 years old now and I still love these kinds of anime ಥ⌣ಥ
+
+Nurse Angel + + + read more +
+
+ +
+
+
+ + +
+
+ +
+

Recent News


Recent Forum Discussion

+ + + + + + + + + + + + +
Poll: Nurse Angel Ririka SOS Episode 35 Discussion
silverwalls - Mar 18, 2015
0 repliesby silverwalls »»
Mar 18, 2015 9:12 AM
Poll: Nurse Angel Ririka SOS Episode 34 Discussion
silverwalls - Mar 18, 2015
0 repliesby silverwalls »»
Mar 18, 2015 8:48 AM

+ + Recommendations +

+
+
+
+
+
+
+
+ +
+
+
+ + + + + + + + + + + + +
+ + + +
+ + +
+ +
+
+ + + + + + + + + + + + + + + + + + diff --git a/src/test/resources/test-pages/817.html b/src/test/resources/test-pages/817.html new file mode 100644 index 0000000..dc0e648 --- /dev/null +++ b/src/test/resources/test-pages/817.html @@ -0,0 +1,1237 @@ + + + + + + + + + + + + + + + + + + + + + + + +Tactical Roar - MyAnimeList.net + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + +
+ +
+
+ + + + +

+ Tactical Roar
+ Edit Anime Information +
+
+ +
What would you like to edit?
+ +   +
+
+

+ + + + + +
+ +
+ + Tactical Roar + +
+ + + + + + + + + +

+ +

Alternative Titles

+ Synonyms: Tactical Rawr +
+ Japanese: タクティカルロア +

+ +

Information

+ +
+ Type: + TV
+ +
+ Episodes: + 13 +
+ +
+ Status: + Finished Airing +
+ +
+ Aired: + Jan 8, 2006 to Apr 2, 2006 +
+ +
+ Premiered: + Winter 2006 +
+ +
+ Broadcast: + Unknown +
+ +
+ Producers: + Bandai Visual, Lantis
+
+ Licensors: + None found, add some
+
+ Studios: + Actas
+ +
+ Source: + Unknown +
+ +
+ Genres: + ComedyComedy, MilitaryMilitary, RomanceRomance, Sci-FiSci-Fi
+ +
+ Duration: + 25 min. per ep. +
+ +
+ Rating: + PG-13 - Teens 13 or older +
+ +
+ +

Statistics

+
+ Score: + 6.461 (scored by 31493,149 users) + + +
+ 1 + indicates a weighted score. + +
+
+
+ Ranked: + #59722 +
+ 2 + based on the top anime page. Please note that 'Not yet aired' and 'R18+' titles are excluded. + +
+
+
+ Popularity: + #4846 +
+
+ Members: + 8,475 +
+
+ Favorites: + 11 +
+ +
+
+ +
+
+ +
+ + + + + + + + + + + +
6.46
Ranked #5972Popularity #4846Members 8,475
Add to List
+ Episodes: /13
buy from amazon
* Your list is public by default.
+

Synopsis

In the near future the world's climate shifted creating in the Western Pacific a perpetual super cyclone: the Grand Roar that altered the earth, flooding most countries. Shipping and navigation became important to nations and following the appearance of ocean pirates, necessisated companies to hire escort cruisers to safeguard their investments. Hyousuke Nagimiya is a system engineer that was comissioned to upgrade the Pascal Magi manned by an entire crew of women with its captain, Misaki Nanaha. Together the crew strives to prove themselves to their detractors that they are no mere 'Alice Brand'. Yet as they go about their mission a larger global conspiracy seems to be working behind the scenes to take advantage of this new world order.
+
+(Source: ANN)

Background

No background information has been added to this title. Help improve our database by adding background information here.
+
+ +
+
+
+ +
+

Related Anime


Characters & Voice Actors

+ + + + + +
+
+ + Misaki, Nanaha + +
+
+ Misaki, Nanaha +
+ Main +
+
+ + + +
+ Nakahara, Mai
+ Japanese +
+
+ + Nakahara, Mai + +
+
+
+ + + + + +
+
+ + Nagimiya, Hyousuke + +
+
+ Nagimiya, Hyousuke +
+ Main +
+
+ + + +
+ Suganuma, Hisayoshi
+ Japanese +
+
+ + Suganuma, Hisayoshi + +
+
+
+ + + + + +
+
+ + Watatsumi, Tsubasa + +
+
+ Watatsumi, Tsubasa +
+ Main +
+
+ + + +
+ Takahashi, Mikako
+ Japanese +
+
+ + Takahashi, Mikako + +
+
+
+ + + + + +
+
+ + Fukami, Sango + +
+
+ Fukami, Sango +
+ Supporting +
+
+ + + +
+ Ueda, Kana
+ Japanese +
+
+ + Ueda, Kana + +
+
+
+ + + + + +
+
+ + Kairi, Miharu + +
+
+ Kairi, Miharu +
+ Supporting +
+
+ + + +
+ Kobayashi, Yumiko
+ Japanese +
+
+ + Kobayashi, Yumiko + +
+
+
+ + + + + +
+
+ + Akoya, Mashu + +
+
+ Akoya, Mashu +
+ Supporting +
+
+ + + +
+ Arai, Satomi
+ Japanese +
+
+ + Arai, Satomi + +
+
+
+ + + + + +
+
+ + Hakubi + +
+
+ Hakubi +
+ Supporting +
+
+ + + +
+ Noto, Mamiko
+ Japanese +
+
+ + Noto, Mamiko + +
+
+
+ + + + + +
+
+ + Okamachi, Kunio + +
+
+ Okamachi, Kunio +
+ Supporting +
+
+ + + +
+ Nomura, Kenji
+ Japanese +
+
+ + Nomura, Kenji + +
+
+
+ + + + + +
+
+ + Aquanaut, Clio + +
+
+ Aquanaut, Clio +
+ Supporting +
+
+ + + +
+ Natsuki, Rio
+ Japanese +
+
+ + Natsuki, Rio + +
+
+
+ + + + + +
+
+ + Hamaguchi, Lemara + +
+
+ Hamaguchi, Lemara +
+ Supporting +
+
+ + + +
+ Nakamura, Daiki
+ Japanese +
+
+ + Nakamura, Daiki + +
+
+
+

+ +

+ + Staff +

+ +
+ + + + +
+
+ + Aketagawa, Jin + +
+
+ Aketagawa, Jin +
+ Sound Director +
+
+ + + + +
+
+ + Ishikura, Kenichi + +
+
+ Ishikura, Kenichi +
+ Episode Director +
+
+ + + + +
+
+ + Takahashi, Shigeharu + +
+
+ Takahashi, Shigeharu +
+ Storyboard +
+
+ + + + +
+
+ + Nakahara, Mai + +
+
+ Nakahara, Mai +
+ Theme Song Performance +
+
+

+
+

+ Edit + Opening Theme +

+
"Tatta Hitotsu Dake" by yozuca
+
+
+ +
+

+ Edit + Ending Theme +

+
"Monochrome" by Mai Nakahara
+
+
+


More reviewsReviews

+ +
+
+
+
Jul 21, 2007
+ +
+ Overall Rating: + 6 +
+
+ + + + + +
+
+ + + +
+
+ Ranivus + (All reviews)
+ +
+
+
+
+ + + + Story 5/10
+The story takes place around an all female company that work for a civilian company called &quot;Haru-Nico,&quot; consisting of ex-navy and civilians. They are hired by other companies to provide an escort/protection servrice. Being an all female group they are the black sheep of the civilian and military navy's, often dubbed as Alice Group. From the first battle you can easily tell theat the girls try too hard to show that they're not pushovers, by disobeying orders and fighting to the end. This type of recklessness creates tensions with the comany and the Federal Navy. So not only do they have to stop pirates + + + read more +
+
+ +
+
+
+ + +
+
+ +
+

Recent News


Recent Forum Discussion

+ + + + + + + + + + + + +
Poll: Tactical Roar Episode 13 Discussion
Hawksman - Nov 7, 2012
3 repliesby BossSam »»
Feb 21, 2016 1:35 AM
Poll: Tactical Roar Episode 12 Discussion
Hawksman - Nov 7, 2012
1 repliesby SolitaryDarkness »»
Aug 15, 2015 4:31 PM

+ + Recommendations +

+
+
+
+
+
+
+
+ +
+
+
+ + + + + + + + + + + + +
+ + + +
+ + +
+ +
+
+ + + + + + + + + + + + + + + + + +