├── api ├── __init__.py ├── migrations │ └── __init__.py ├── models.py ├── admin.py ├── tests.py ├── apps.py ├── data_cache │ └── all_groups.json └── urls.py ├── guyamoe ├── __init__.py ├── settings │ ├── __init__.py │ ├── local.py │ ├── dev.py │ ├── prod.py │ └── base.py ├── wsgi.py ├── context_processors.py └── urls.py ├── homepage ├── __init__.py ├── migrations │ └── __init__.py ├── models.py ├── admin.py ├── tests.py ├── static │ └── img │ │ ├── ama.png │ │ ├── gp.png │ │ ├── shu.png │ │ ├── viz.png │ │ ├── yj.png │ │ ├── Guya.png │ │ ├── bg_box.png │ │ ├── quote1.png │ │ ├── quote2.png │ │ ├── quote3.png │ │ ├── quote4.png │ │ ├── quote5.png │ │ ├── quote6.png │ │ ├── quote7.png │ │ ├── quote8.png │ │ ├── quote9.png │ │ ├── thonk.png │ │ ├── yjbg.jpg │ │ ├── Guya-moe.png │ │ ├── bg_pink.gif │ │ ├── 4komacover.jpg │ │ ├── appuquote.png │ │ ├── bg_box_blur.jpg │ │ ├── doujincover.jpg │ │ ├── kaguyacover.jpg │ │ ├── logo_menu.png │ │ ├── novelcover.jpg │ │ ├── fanmade_media.png │ │ ├── how_cute_404.png │ │ ├── bg_box_blur_smol.jpg │ │ ├── bg_box_blur_smol.png │ │ ├── main_series_blur.png │ │ ├── novelcover_blur.jpg │ │ ├── fanmade_media_blur.png │ │ ├── img_comics-bottom.png │ │ ├── where-to-start-the-manga.png │ │ ├── send.svg │ │ ├── email-outline.svg │ │ ├── dots-vertical.svg │ │ ├── discord.svg │ │ └── reddit.svg ├── apps.py ├── urls.py ├── middleware.py ├── templates │ └── homepage │ │ ├── admin_home.html │ │ ├── how_cute_404.html │ │ ├── thonk_500.html │ │ └── about.html ├── sitemaps.py └── views.py ├── proxy ├── sources │ ├── __init__.py │ ├── hitomi.py │ ├── gist.py │ ├── nhentai.py │ ├── imgur.py │ ├── mangabox.py │ └── foolslide.py ├── models.py ├── tests.py ├── admin.py ├── views.py ├── apps.py ├── __init__.py ├── urls.py └── source │ ├── helpers.py │ ├── data.py │ └── __init__.py ├── reader ├── scraper │ ├── __init__.py │ ├── mappings.py │ └── scraper.py ├── static │ ├── css │ │ └── icons.css │ ├── fonts │ │ ├── guya.ttf │ │ └── guya.woff │ ├── img │ │ ├── cubare.png │ │ ├── cubari.png │ │ └── fujiload.png │ └── external │ │ └── css │ │ └── pickr.nano@1-8-1.css ├── migrations │ └── __init__.py ├── __init__.py ├── tests.py ├── apps.py ├── users_cache_lib.py ├── management │ └── commands │ │ ├── blur_images.py │ │ ├── chapter_sanity_check.py │ │ ├── chapter_checker.py │ │ └── chapter_indexer.py ├── middleware.py ├── forms.py ├── feed.py ├── urls.py ├── admin.py ├── templates │ └── reader │ │ └── reader.html ├── models.py ├── signals.py └── views.py ├── misc ├── __init__.py ├── tests.py ├── apps.py ├── templatetags │ └── page_tags.py ├── urls.py ├── signals.py ├── admin.py ├── templates │ └── misc │ │ ├── misc_pages.html │ │ └── misc.html ├── models.py └── views.py ├── config.json ├── static_global ├── logo.png ├── onk.jpg ├── favicon.ico ├── onk-blur.jpg ├── kgscreenshot.png ├── logo_small.png ├── safari-touch-128x128.png ├── safari-touch-256x256.png ├── safari-touch-512x512.png └── manifest.json ├── docker ├── Dockerfile ├── nginx │ ├── docker-compose.yml │ └── web.conf ├── settings.py ├── docker-compose.yml ├── README.md └── init.py ├── requirements.txt ├── templates ├── robots.txt ├── tracking.html └── layout.html ├── .gitignore ├── manage.py ├── README.md └── init.py /api/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /guyamoe/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /homepage/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /api/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /guyamoe/settings/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /proxy/sources/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /reader/scraper/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /reader/static/css/icons.css: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /homepage/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /reader/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /misc/__init__.py: -------------------------------------------------------------------------------- 1 | default_app_config = "misc.apps.MiscConfig" 2 | -------------------------------------------------------------------------------- /reader/__init__.py: -------------------------------------------------------------------------------- 1 | default_app_config = "reader.apps.ReaderConfig" 2 | -------------------------------------------------------------------------------- /api/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | # Create your models here. 4 | -------------------------------------------------------------------------------- /api/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | # Register your models here. 4 | -------------------------------------------------------------------------------- /api/tests.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | # Create your tests here. 4 | -------------------------------------------------------------------------------- /homepage/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | # Create your models here. 4 | -------------------------------------------------------------------------------- /misc/tests.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | # Create your tests here. 4 | -------------------------------------------------------------------------------- /proxy/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | # Create your models here. 4 | -------------------------------------------------------------------------------- /proxy/tests.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | # Create your tests here. 4 | -------------------------------------------------------------------------------- /reader/tests.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | # Create your tests here. 4 | -------------------------------------------------------------------------------- /homepage/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | # Register your models here. 4 | -------------------------------------------------------------------------------- /homepage/tests.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | # Create your tests here. 4 | -------------------------------------------------------------------------------- /proxy/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | # Register your models here. 4 | -------------------------------------------------------------------------------- /proxy/views.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import render 2 | 3 | # Create your views here. 4 | -------------------------------------------------------------------------------- /config.json: -------------------------------------------------------------------------------- 1 | { 2 | "secret_key": "", 3 | "db_user": "", 4 | "db_pass": "" 5 | } 6 | -------------------------------------------------------------------------------- /static_global/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subject-f/guyamoe/HEAD/static_global/logo.png -------------------------------------------------------------------------------- /static_global/onk.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subject-f/guyamoe/HEAD/static_global/onk.jpg -------------------------------------------------------------------------------- /static_global/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subject-f/guyamoe/HEAD/static_global/favicon.ico -------------------------------------------------------------------------------- /homepage/static/img/ama.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subject-f/guyamoe/HEAD/homepage/static/img/ama.png -------------------------------------------------------------------------------- /homepage/static/img/gp.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subject-f/guyamoe/HEAD/homepage/static/img/gp.png -------------------------------------------------------------------------------- /homepage/static/img/shu.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subject-f/guyamoe/HEAD/homepage/static/img/shu.png -------------------------------------------------------------------------------- /homepage/static/img/viz.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subject-f/guyamoe/HEAD/homepage/static/img/viz.png -------------------------------------------------------------------------------- /homepage/static/img/yj.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subject-f/guyamoe/HEAD/homepage/static/img/yj.png -------------------------------------------------------------------------------- /static_global/onk-blur.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subject-f/guyamoe/HEAD/static_global/onk-blur.jpg -------------------------------------------------------------------------------- /api/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class ApiConfig(AppConfig): 5 | name = "api" 6 | -------------------------------------------------------------------------------- /homepage/static/img/Guya.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subject-f/guyamoe/HEAD/homepage/static/img/Guya.png -------------------------------------------------------------------------------- /homepage/static/img/bg_box.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subject-f/guyamoe/HEAD/homepage/static/img/bg_box.png -------------------------------------------------------------------------------- /homepage/static/img/quote1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subject-f/guyamoe/HEAD/homepage/static/img/quote1.png -------------------------------------------------------------------------------- /homepage/static/img/quote2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subject-f/guyamoe/HEAD/homepage/static/img/quote2.png -------------------------------------------------------------------------------- /homepage/static/img/quote3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subject-f/guyamoe/HEAD/homepage/static/img/quote3.png -------------------------------------------------------------------------------- /homepage/static/img/quote4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subject-f/guyamoe/HEAD/homepage/static/img/quote4.png -------------------------------------------------------------------------------- /homepage/static/img/quote5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subject-f/guyamoe/HEAD/homepage/static/img/quote5.png -------------------------------------------------------------------------------- /homepage/static/img/quote6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subject-f/guyamoe/HEAD/homepage/static/img/quote6.png -------------------------------------------------------------------------------- /homepage/static/img/quote7.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subject-f/guyamoe/HEAD/homepage/static/img/quote7.png -------------------------------------------------------------------------------- /homepage/static/img/quote8.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subject-f/guyamoe/HEAD/homepage/static/img/quote8.png -------------------------------------------------------------------------------- /homepage/static/img/quote9.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subject-f/guyamoe/HEAD/homepage/static/img/quote9.png -------------------------------------------------------------------------------- /homepage/static/img/thonk.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subject-f/guyamoe/HEAD/homepage/static/img/thonk.png -------------------------------------------------------------------------------- /homepage/static/img/yjbg.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subject-f/guyamoe/HEAD/homepage/static/img/yjbg.jpg -------------------------------------------------------------------------------- /reader/static/fonts/guya.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subject-f/guyamoe/HEAD/reader/static/fonts/guya.ttf -------------------------------------------------------------------------------- /reader/static/fonts/guya.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subject-f/guyamoe/HEAD/reader/static/fonts/guya.woff -------------------------------------------------------------------------------- /reader/static/img/cubare.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subject-f/guyamoe/HEAD/reader/static/img/cubare.png -------------------------------------------------------------------------------- /reader/static/img/cubari.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subject-f/guyamoe/HEAD/reader/static/img/cubari.png -------------------------------------------------------------------------------- /reader/static/img/fujiload.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subject-f/guyamoe/HEAD/reader/static/img/fujiload.png -------------------------------------------------------------------------------- /static_global/kgscreenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subject-f/guyamoe/HEAD/static_global/kgscreenshot.png -------------------------------------------------------------------------------- /static_global/logo_small.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subject-f/guyamoe/HEAD/static_global/logo_small.png -------------------------------------------------------------------------------- /homepage/static/img/Guya-moe.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subject-f/guyamoe/HEAD/homepage/static/img/Guya-moe.png -------------------------------------------------------------------------------- /homepage/static/img/bg_pink.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subject-f/guyamoe/HEAD/homepage/static/img/bg_pink.gif -------------------------------------------------------------------------------- /proxy/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class ProxyConfig(AppConfig): 5 | name = "proxy" 6 | -------------------------------------------------------------------------------- /homepage/static/img/4komacover.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subject-f/guyamoe/HEAD/homepage/static/img/4komacover.jpg -------------------------------------------------------------------------------- /homepage/static/img/appuquote.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subject-f/guyamoe/HEAD/homepage/static/img/appuquote.png -------------------------------------------------------------------------------- /homepage/static/img/bg_box_blur.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subject-f/guyamoe/HEAD/homepage/static/img/bg_box_blur.jpg -------------------------------------------------------------------------------- /homepage/static/img/doujincover.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subject-f/guyamoe/HEAD/homepage/static/img/doujincover.jpg -------------------------------------------------------------------------------- /homepage/static/img/kaguyacover.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subject-f/guyamoe/HEAD/homepage/static/img/kaguyacover.jpg -------------------------------------------------------------------------------- /homepage/static/img/logo_menu.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subject-f/guyamoe/HEAD/homepage/static/img/logo_menu.png -------------------------------------------------------------------------------- /homepage/static/img/novelcover.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subject-f/guyamoe/HEAD/homepage/static/img/novelcover.jpg -------------------------------------------------------------------------------- /homepage/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class HomepageConfig(AppConfig): 5 | name = "homepage" 6 | -------------------------------------------------------------------------------- /homepage/static/img/fanmade_media.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subject-f/guyamoe/HEAD/homepage/static/img/fanmade_media.png -------------------------------------------------------------------------------- /homepage/static/img/how_cute_404.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subject-f/guyamoe/HEAD/homepage/static/img/how_cute_404.png -------------------------------------------------------------------------------- /homepage/static/img/bg_box_blur_smol.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subject-f/guyamoe/HEAD/homepage/static/img/bg_box_blur_smol.jpg -------------------------------------------------------------------------------- /homepage/static/img/bg_box_blur_smol.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subject-f/guyamoe/HEAD/homepage/static/img/bg_box_blur_smol.png -------------------------------------------------------------------------------- /homepage/static/img/main_series_blur.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subject-f/guyamoe/HEAD/homepage/static/img/main_series_blur.png -------------------------------------------------------------------------------- /homepage/static/img/novelcover_blur.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subject-f/guyamoe/HEAD/homepage/static/img/novelcover_blur.jpg -------------------------------------------------------------------------------- /static_global/safari-touch-128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subject-f/guyamoe/HEAD/static_global/safari-touch-128x128.png -------------------------------------------------------------------------------- /static_global/safari-touch-256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subject-f/guyamoe/HEAD/static_global/safari-touch-256x256.png -------------------------------------------------------------------------------- /static_global/safari-touch-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subject-f/guyamoe/HEAD/static_global/safari-touch-512x512.png -------------------------------------------------------------------------------- /homepage/static/img/fanmade_media_blur.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subject-f/guyamoe/HEAD/homepage/static/img/fanmade_media_blur.png -------------------------------------------------------------------------------- /homepage/static/img/img_comics-bottom.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subject-f/guyamoe/HEAD/homepage/static/img/img_comics-bottom.png -------------------------------------------------------------------------------- /homepage/static/img/where-to-start-the-manga.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/subject-f/guyamoe/HEAD/homepage/static/img/where-to-start-the-manga.png -------------------------------------------------------------------------------- /docker/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3 2 | 3 | # Creates the directory for us 4 | WORKDIR /guya 5 | COPY ./requirements.txt . 6 | RUN pip install -r requirements.txt 7 | -------------------------------------------------------------------------------- /reader/scraper/mappings.py: -------------------------------------------------------------------------------- 1 | from reader.models import MANGADEX 2 | from reader.scraper.sources import mangadex 3 | 4 | SCRAPER_MAPPINGS = {MANGADEX: mangadex.MangaDex} 5 | -------------------------------------------------------------------------------- /static_global/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Guya", 3 | "short_name": "Guya", 4 | "theme_color": "#282a36", 5 | "background_color": "#282a36", 6 | "display": "standalone" 7 | } 8 | -------------------------------------------------------------------------------- /api/data_cache/all_groups.json: -------------------------------------------------------------------------------- 1 | {"1": "Psylocke | Update your extension!", "2": "/a/ | Update your extension!", "3": "jb | Update your extension!", "4": "NotJag | Update your extension!", "5": "Ai | Update your extension!"} 2 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | aiohttp==3.7.4 2 | discord.py==1.6.0 3 | Django==3.1.8 4 | django-extensions==2.1.9 5 | django-ratelimit==2.0.0 6 | Markdown==3.1.1 7 | Pillow==8.1.1 8 | psycopg2==2.8.4 9 | python-memcached==1.59 10 | requests==2.22.0 11 | beautifulsoup4==4.8.2 12 | -------------------------------------------------------------------------------- /misc/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | from django.utils.translation import ugettext_lazy as _ 3 | 4 | 5 | class MiscConfig(AppConfig): 6 | name = "misc" 7 | verbose_name = _("misc") 8 | 9 | def ready(self): 10 | import misc.signals # noqa 11 | -------------------------------------------------------------------------------- /templates/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: /admin/* 3 | Disallow: /admin_home/ 4 | Disallow: /read/update_view_count/ 5 | Disallow: /proxy/* 6 | Disallow: /api/download_chapter/* 7 | Disallow: /api/upload_new_chapter/* 8 | 9 | Sitemap: https://aka.guya.moe/sitemap.xml 10 | -------------------------------------------------------------------------------- /reader/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | from django.utils.translation import ugettext_lazy as _ 3 | 4 | 5 | class ReaderConfig(AppConfig): 6 | name = "reader" 7 | verbose_name = _("reader") 8 | 9 | def ready(self): 10 | import reader.signals # noqa 11 | -------------------------------------------------------------------------------- /reader/users_cache_lib.py: -------------------------------------------------------------------------------- 1 | from django.core.cache import cache 2 | 3 | 4 | def get_user_ip(request): 5 | x_forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR") 6 | if x_forwarded_for: 7 | user_ip = x_forwarded_for.split(",")[0] 8 | else: 9 | user_ip = request.META.get("REMOTE_ADDR") 10 | return user_ip 11 | -------------------------------------------------------------------------------- /homepage/static/img/send.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /misc/templatetags/page_tags.py: -------------------------------------------------------------------------------- 1 | import markdown 2 | from django import template 3 | from django.template.defaultfilters import stringfilter 4 | 5 | from misc.models import Page 6 | 7 | register = template.Library() 8 | 9 | 10 | @register.filter() 11 | @stringfilter 12 | def convert_to_markdown(value): 13 | return markdown.markdown(value, extensions=["markdown.extensions.extra"]) 14 | -------------------------------------------------------------------------------- /misc/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.conf.urls.static import static 3 | from django.urls import path, re_path 4 | 5 | from . import views 6 | 7 | urlpatterns = [ 8 | path("", views.misc_pages, name="misc-all-pages"), 9 | re_path(r"^(?P[\w-]+)/$", views.content, name="misc-page"), 10 | path("api/update_view_count/", views.hit_count, name="page-view-count"), 11 | ] 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # pyenv 7 | .python-version 8 | 9 | # Environments 10 | .env 11 | .venv 12 | env/ 13 | guyaa/ 14 | venv/ 15 | ENV/ 16 | env.bak/ 17 | venv.bak/ 18 | 19 | # vscode 20 | .vscode 21 | 22 | #db 23 | db.sqlite3 24 | 25 | #django migrations 26 | migrations/ 27 | 28 | #django collectstatic location 29 | static/ 30 | 31 | #media root 32 | media/* 33 | -------------------------------------------------------------------------------- /homepage/static/img/email-outline.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /reader/management/commands/blur_images.py: -------------------------------------------------------------------------------- 1 | from django.core.management.base import BaseCommand 2 | 3 | from api.api import chapter_post_process 4 | from reader.models import Chapter 5 | 6 | 7 | class Command(BaseCommand): 8 | help = "Create shrunk and blurred versions of chapter pages" 9 | 10 | def handle(self, *args, **options): 11 | for chapter in Chapter.objects.all(): 12 | chapter_post_process(chapter, is_update=False) 13 | -------------------------------------------------------------------------------- /docker/nginx/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.7" 2 | 3 | # This file doesn't work on its own 4 | services: 5 | nginx: 6 | image: nginx 7 | depends_on: 8 | - web 9 | volumes: 10 | - type: bind 11 | source: ./nginx/web.conf 12 | target: /etc/nginx/conf.d/default.conf 13 | ports: 14 | - 80:80 # You might have to change this to just 80 if it doesn't bind properly 15 | networks: 16 | - guyamoe-intercontinental-highway 17 | -------------------------------------------------------------------------------- /docker/nginx/web.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 80; 3 | listen [::]:80; 4 | 5 | server_name _; 6 | 7 | access_log /var/log/nginx/access.log; 8 | error_log /var/log/nginx/error.log; 9 | 10 | location / { 11 | proxy_pass http://web:8000; 12 | 13 | proxy_set_header X-Real-IP $remote_addr; 14 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 15 | proxy_set_header X-Forwarded-Proto $scheme; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /proxy/__init__.py: -------------------------------------------------------------------------------- 1 | from .sources.foolslide import FoolSlide 2 | from .sources.imgur import Imgur 3 | from .sources.mangabox import MangaBox 4 | from .sources.mangadex import MangaDex 5 | from .sources.nhentai import NHentai 6 | from .sources.readmanhwa import ReadManhwa 7 | from .sources.hitomi import Hitomi 8 | from .sources.gist import Gist 9 | 10 | sources = [ 11 | MangaDex(), 12 | NHentai(), 13 | FoolSlide(), 14 | ReadManhwa(), 15 | Imgur(), 16 | MangaBox(), 17 | Hitomi(), 18 | Gist(), 19 | ] 20 | -------------------------------------------------------------------------------- /homepage/static/img/dots-vertical.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /proxy/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import include, path, re_path 2 | from django.views.decorators.http import condition 3 | 4 | from reader.models import Chapter 5 | 6 | from . import sources 7 | 8 | urlpatterns = [ 9 | path( 10 | "api/", 11 | include( 12 | [route for source in sources for route in source.register_api_routes()] 13 | ), 14 | ), 15 | path( 16 | "", 17 | include( 18 | [route for source in sources for route in source.register_frontend_routes()] 19 | ), 20 | ), 21 | ] 22 | -------------------------------------------------------------------------------- /guyamoe/settings/local.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from .base import * 4 | 5 | 6 | CANONICAL_ROOT_DOMAIN = "localhost:8000" 7 | 8 | DEBUG = True 9 | 10 | ALLOWED_HOSTS = [os.environ.get("ALLOWED_HOSTS", "localhost")] 11 | 12 | 13 | CACHES = { 14 | "default": { 15 | "BACKEND": "django.core.cache.backends.locmem.LocMemCache", 16 | "LOCATION": "unique-snowflake", 17 | } 18 | } 19 | 20 | DATABASES = { 21 | "default": { 22 | "ENGINE": "django.db.backends.sqlite3", 23 | "NAME": os.path.join(BASE_DIR, "db.sqlite3"), 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /docker/settings.py: -------------------------------------------------------------------------------- 1 | from guyamoe.settings.base import * 2 | 3 | ALLOWED_HOSTS = ["web", "localhost"] 4 | 5 | CACHES = { 6 | "default": { 7 | "BACKEND": "django.core.cache.backends.memcached.MemcachedCache", 8 | "LOCATION": "memcached:11211", 9 | } 10 | } 11 | 12 | DATABASES = { 13 | "default": { 14 | "ENGINE": "django.db.backends.postgresql_psycopg2", 15 | "NAME": "kaguyamoe", 16 | "USER": "POSTGRES_USER", 17 | "PASSWORD": "POSTGRES_PASSWORD", 18 | "HOST": "postgres", 19 | "PORT": "", 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /guyamoe/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for guyamoe project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/2.2/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | import sys 12 | from pathlib import Path 13 | 14 | from django.core.wsgi import get_wsgi_application 15 | 16 | ROOT_DIR = Path(__file__).resolve(strict=True).parent.parent 17 | sys.path.append(str(ROOT_DIR)) 18 | 19 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "guyamoe.settings.local") 20 | 21 | application = get_wsgi_application() 22 | -------------------------------------------------------------------------------- /misc/signals.py: -------------------------------------------------------------------------------- 1 | import os 2 | import shutil 3 | 4 | from django.conf import settings 5 | from django.db.models.signals import post_delete 6 | from django.dispatch import receiver 7 | 8 | from misc.models import Page, Static 9 | 10 | 11 | @receiver(post_delete, sender=Page) 12 | def delete_page(sender, instance, **kwargs): 13 | folder_path = os.path.join(settings.MEDIA_ROOT, "pages", instance.page_url) 14 | shutil.rmtree(folder_path, ignore_errors=True) 15 | 16 | 17 | @receiver(post_delete, sender=Static) 18 | def delete_static_file(sender, instance, **kwargs): 19 | if instance.static_file and os.path.isfile(instance.static_file.path): 20 | os.remove(instance.static_file.path) 21 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import sys 5 | 6 | 7 | def main(): 8 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "guyamoe.settings.local") 9 | try: 10 | from django.core.management import execute_from_command_line 11 | except ImportError as exc: 12 | raise ImportError( 13 | "Couldn't import Django. Are you sure it's installed and " 14 | "available on your PYTHONPATH environment variable? Did you " 15 | "forget to activate a virtual environment?" 16 | ) from exc 17 | execute_from_command_line(sys.argv) 18 | 19 | 20 | if __name__ == "__main__": 21 | main() 22 | -------------------------------------------------------------------------------- /misc/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from .models import Page, Static, Variable 4 | 5 | # Register your models here. 6 | 7 | 8 | class PageAdmin(admin.ModelAdmin): 9 | search_fields = ( 10 | "page_title", 11 | "page_url", 12 | ) 13 | ordering = ("date",) 14 | list_display = ( 15 | "page_title", 16 | "page_url", 17 | "cover_image_url", 18 | "date", 19 | ) 20 | filter_horizontal = ("variable",) 21 | 22 | 23 | class StaticAdmin(admin.ModelAdmin): 24 | search_fields = ("page__page_title",) 25 | list_display = ( 26 | "static_file", 27 | "page", 28 | ) 29 | 30 | 31 | admin.site.register(Page, PageAdmin) 32 | admin.site.register(Variable) 33 | admin.site.register(Static, StaticAdmin) 34 | -------------------------------------------------------------------------------- /homepage/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.conf.urls.static import static 3 | from django.urls import include, path, re_path 4 | 5 | from . import views 6 | 7 | urlpatterns = [ 8 | path("", views.home, name="site-home"), 9 | path("admin_home/", views.admin_home, name="admin_home"), 10 | path("about/", views.about, name="site-about"), 11 | re_path( 12 | r"^(?P[\d-]{1,9})/$", 13 | views.main_series_chapter, 14 | name="site-main-series-chapter", 15 | ), 16 | re_path( 17 | r"^(?P[\d-]{1,9})/(?P[\d]{1,9})/$", 18 | views.main_series_page, 19 | name="site-main-series-page", 20 | ), 21 | path("latest/", views.latest, name="site-main-series-latest"), 22 | path("random/", views.random, name="site-main-series-random"), 23 | ] 24 | -------------------------------------------------------------------------------- /guyamoe/context_processors.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | 3 | 4 | def branding(request): 5 | return { 6 | "brand": { 7 | "name": settings.BRANDING_NAME, 8 | "description": settings.BRANDING_DESCRIPTION, 9 | "image_url": settings.BRANDING_IMAGE_URL, 10 | } 11 | } 12 | 13 | 14 | def home_branding(request): 15 | return { 16 | "home_brand": { 17 | "name": settings.HOME_BRANDING_NAME, 18 | "description": settings.HOME_BRANDING_DESCRIPTION, 19 | "image_url": settings.HOME_BRANDING_IMAGE_URL, 20 | } 21 | } 22 | 23 | 24 | def urls(request): 25 | return { 26 | "root_domain": settings.CANONICAL_ROOT_DOMAIN, 27 | "uri_scheme": request.scheme, 28 | "absolute_url": f"https://{settings.CANONICAL_ROOT_DOMAIN}{request.path}", 29 | } 30 | -------------------------------------------------------------------------------- /templates/tracking.html: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 9 | 10 | 17 | 18 | 20 | 21 | -------------------------------------------------------------------------------- /reader/middleware.py: -------------------------------------------------------------------------------- 1 | from django.core.cache import cache 2 | from django.utils.deprecation import MiddlewareMixin 3 | 4 | from .users_cache_lib import get_user_ip 5 | 6 | 7 | class OnlineNowMiddleware(MiddlewareMixin): 8 | # def __init__(self, get_response): 9 | # self.get_response = get_response 10 | 11 | def process_response(self, request, response): 12 | user_ip = get_user_ip(request) 13 | online = cache.get("online_now") 14 | peak_traffic = cache.get("peak_traffic") 15 | if not peak_traffic: 16 | peak_traffic = 0 17 | if online: 18 | online = set([ip for ip in online if cache.get(ip)]) 19 | else: 20 | online = set([]) 21 | cache.set(user_ip, user_ip, 600) 22 | online.add(user_ip) 23 | if len(online) > peak_traffic: 24 | peak_traffic = len(online) 25 | cache.set("peak_traffic", peak_traffic, 3600 * 8) 26 | cache.set("online_now", online, 600) 27 | # response = self.get_response(request) 28 | return response 29 | -------------------------------------------------------------------------------- /homepage/middleware.py: -------------------------------------------------------------------------------- 1 | import requests 2 | from django.conf import settings 3 | from django.core.cache import cache 4 | from django.utils.deprecation import MiddlewareMixin 5 | 6 | from reader.users_cache_lib import get_user_ip 7 | 8 | 9 | class ForwardParametersMiddleware(MiddlewareMixin): 10 | def process_response(self, request, response): 11 | if request.GET.urlencode(): 12 | response["Location"] += f"?{request.GET.urlencode()}" 13 | return response 14 | 15 | 16 | class ReferralMiddleware(MiddlewareMixin): 17 | def process_response(self, request, response): 18 | ip = get_user_ip(request) 19 | if ( 20 | not cache.get(f"referral_{ip}") 21 | and request.method == "GET" 22 | and request.GET.get("rid") 23 | and response.status_code not in [301, 302] 24 | ): 25 | # Handle only one referral from an IP at a time 26 | cache.set( 27 | f"referral_{ip}", 28 | {"rid": request.GET.get("rid"), "consumed": False}, 29 | 120, 30 | ) 31 | return response 32 | -------------------------------------------------------------------------------- /homepage/static/img/discord.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /reader/scraper/scraper.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from typing import Any, Dict, Generator, List 3 | 4 | from ..models import Chapter 5 | 6 | 7 | class BaseScraper(ABC): 8 | @abstractmethod 9 | def __init__(self, *args, **kwargs): 10 | self.initialized = True 11 | 12 | @abstractmethod 13 | def get_source_chapter_data(self, *args, **kwargs) -> Any: 14 | raise NotImplementedError 15 | 16 | @abstractmethod 17 | def generate_source_chapter_hash( 18 | self, source_chapter_data: Any, *args, **kwargs 19 | ) -> str: 20 | raise NotImplementedError 21 | 22 | @abstractmethod 23 | def get_valid_source_chapters(self, *args, **kwargs) -> Generator[Any, None, None]: 24 | raise NotImplementedError 25 | 26 | @abstractmethod 27 | def download_source_chapter(self, *args, **kwargs) -> Chapter: 28 | raise NotImplementedError 29 | 30 | @abstractmethod 31 | def scrape_chapters( 32 | self, 33 | *, 34 | check_updates: bool = True, 35 | specific_chapters: Dict[ 36 | float, List[str] 37 | ] = [], # dict of {chapter_number: list of group names} 38 | **kwargs 39 | ) -> List[Chapter]: 40 | raise NotImplementedError 41 | -------------------------------------------------------------------------------- /misc/templates/misc/misc_pages.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | {% load static %} 3 | {% load page_tags %} 4 | {% block meta %} 5 | {{ block.super }} 6 | 7 | {% endblock %} 8 | 9 | {% block body %} 10 | 11 | 30 | 31 | {% endblock %} 32 | -------------------------------------------------------------------------------- /docker/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.7" 2 | 3 | services: 4 | web: 5 | build: 6 | context: ../ 7 | # Note that this is based on the context dir 8 | # (Mostly) everything else is based off the 9 | # compose file's dir 10 | dockerfile: ./docker/Dockerfile 11 | command: python -u docker/init.py 12 | volumes: 13 | - type: bind 14 | source: ../ 15 | target: /guya 16 | ports: 17 | - 8000:8000 18 | environment: 19 | - PYTHONUNBUFFERED=1 20 | - DJANGO_SETTINGS_MODULE=docker.settings 21 | depends_on: 22 | - memcached 23 | - postgres 24 | networks: 25 | - guyamoe-intercontinental-highway 26 | memcached: 27 | image: memcached:latest 28 | restart: always 29 | entrypoint: 30 | - memcached 31 | - -m 512 32 | networks: 33 | - guyamoe-intercontinental-highway 34 | postgres: 35 | image: postgres:latest 36 | restart: always 37 | environment: 38 | - POSTGRES_PASSWORD=POSTGRES_PASSWORD 39 | - POSTGRES_USER=POSTGRES_USER 40 | - POSTGRES_DB=kaguyamoe 41 | networks: 42 | - guyamoe-intercontinental-highway 43 | 44 | # Give the network a name so it's more deterministic 45 | # Otherwise it defaults to _default 46 | networks: 47 | guyamoe-intercontinental-highway: 48 | name: guyamoe-intercontinental-highway -------------------------------------------------------------------------------- /homepage/templates/homepage/admin_home.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | {% load static %} 3 | {% block head %} 4 | 24 | {% endblock %} 25 | {% block body %} 26 |
27 |
28 |
29 |

Online: {{ online }}

30 |
31 |
32 |

Peak traffic: {{ peak_traffic }}

33 |
34 | 35 | 36 |
37 | 38 |
39 | {% endblock %} 40 | -------------------------------------------------------------------------------- /homepage/static/img/reddit.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /homepage/sitemaps.py: -------------------------------------------------------------------------------- 1 | from django.contrib.sitemaps import Sitemap 2 | from django.shortcuts import reverse 3 | 4 | from misc.models import Page 5 | from reader.models import Chapter, Series 6 | 7 | 8 | class StaticViewSitemap(Sitemap): 9 | changefreq = "daily" 10 | priority = 0.7 11 | protocol = "https" 12 | 13 | def items(self): 14 | return ["site-home", "site-about"] 15 | 16 | def location(self, item): 17 | return reverse(item) 18 | 19 | 20 | class SeriesViewSitemap(Sitemap): 21 | changefreq = "daily" 22 | priority = 0.5 23 | protocol = "https" 24 | 25 | def items(self): 26 | return Series.objects.all() 27 | 28 | 29 | class ChapterViewSitemap(Sitemap): 30 | changefreq = "monthly" 31 | priority = 0.4 32 | protocol = "https" 33 | 34 | def items(self): 35 | return Chapter.objects.filter(series__isnull=False).order_by( 36 | "series__id", "-chapter_number" 37 | ) 38 | 39 | 40 | class PagesListViewSitemap(Sitemap): 41 | changefreq = "daily" 42 | priority = 0.5 43 | protocol = "https" 44 | 45 | def items(self): 46 | return [Page.objects.all()[0]] 47 | 48 | def location(self, item): 49 | return "/pages" 50 | 51 | 52 | class PageViewSitemap(Sitemap): 53 | changefreq = "daily" 54 | priority = 0.5 55 | protocol = "https" 56 | 57 | def items(self): 58 | return Page.objects.all().order_by("-date") 59 | -------------------------------------------------------------------------------- /misc/models.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from django.db import models 4 | 5 | 6 | # Create your models here. 7 | class Variable(models.Model): 8 | key = models.CharField(max_length=30) 9 | value = models.CharField(max_length=300) 10 | 11 | def __str__(self): 12 | return self.key 13 | 14 | 15 | def path_file_name(instance, filename): 16 | return os.path.join("pages", instance.page.page_url, "static/", filename) 17 | 18 | 19 | class Page(models.Model): 20 | content = models.TextField(blank=True, null=True) 21 | preview = models.TextField(blank=True, null=True) 22 | standalone = models.BooleanField(default=False) 23 | hidden = models.BooleanField(default=False) 24 | page_title = models.CharField(max_length=300, blank=False, null=False, unique=True) 25 | page_url = models.CharField(max_length=300, blank=False, null=False, unique=True) 26 | cover_image_url = models.CharField(max_length=512, blank=True, null=True) 27 | date = models.DateTimeField(default=None, blank=True, null=True, db_index=True) 28 | variable = models.ManyToManyField(Variable, blank=True) 29 | 30 | def __str__(self): 31 | return self.page_title 32 | 33 | def get_absolute_url(self): 34 | return f"/pages/{self.page_url}/" 35 | 36 | 37 | class Static(models.Model): 38 | static_file = models.FileField(upload_to=path_file_name) 39 | page = models.ForeignKey(Page, blank=False, null=False, on_delete=models.CASCADE) 40 | 41 | def __str__(self): 42 | return str(self.static_file) 43 | -------------------------------------------------------------------------------- /docker/README.md: -------------------------------------------------------------------------------- 1 | # Getting Started 2 | 3 | This Docker configuration does the absolute bare minimum for you to get started. 4 | 5 | Otherwise: 6 | ```bash 7 | docker-compose up 8 | ``` 9 | in this directory and you're done. 10 | 11 | ## nginx 12 | 13 | If you want to run with Nginx (which you really shouldn't unless you change a bunch of the configurations), use: 14 | ```bash 15 | docker-compose -f docker-compose.yml -f nginx/docker-compose.yml up 16 | ``` 17 | 18 | ## Superuser 19 | 20 | Use this to create a superuser with the username `root` and password as `password`: 21 | ```bash 22 | docker run -it --rm --network="guyamoe-intercontinental-highway" \ 23 | -v $PWD/..:/guya -w="/guya" \ 24 | -v $PWD/settings.py:/guya/guyamoe/settings.py \ 25 | $(docker build -q -f Dockerfile ..) \ 26 | sh -c "echo \"from django.contrib.auth import get_user_model; User = get_user_model(); User.objects.create_superuser('root', 'root@example.com', 'password')\" | python manage.py shell" 27 | ``` 28 | 29 | Or you can just shell into the web container and create one that way. Either way works. 30 | 31 | ## docker-compose 32 | 33 | If you can't be assed to install docker-compose, use this: 34 | ```bash 35 | echo alias docker-compose="'"'sudo docker run --rm -it \ 36 | -v /var/run/docker.sock:/var/run/docker.sock \ 37 | -v "$PWD/../:$PWD/../" \ 38 | -w="$PWD" \ 39 | docker/compose:1.25.4'"'" >> ~/.bashrc 40 | 41 | source ~/.bashrc 42 | ``` 43 | Note that this snippet isn't portable since the bind mount is specific for this purpose, so it may be a good idea to alias it differently. -------------------------------------------------------------------------------- /guyamoe/settings/dev.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from .base import * 4 | 5 | 6 | dev_domain = os.environ.get("DEV_CANONICAL_ROOT_DOMAIN", "localhost:8000") 7 | 8 | SECRET_KEY = os.environ.get("DEV_SECRET_KEY", "o kawaii koto") 9 | CANONICAL_ROOT_DOMAIN = dev_domain 10 | 11 | DEBUG = False 12 | 13 | ALLOWED_HOSTS = [dev_domain] 14 | 15 | CANONICAL_SITE_NAME = dev_domain 16 | 17 | LOGGING = { 18 | "version": 1, 19 | "disable_existing_loggers": True, 20 | "formatters": { 21 | "verbose": { 22 | "format": "%(asctime)s %(levelname)s [%(name)s:%(lineno)s] %(module)s %(process)d %(thread)d %(message)s" 23 | } 24 | }, 25 | "handlers": { 26 | "file": { 27 | "level": "WARNING", 28 | "class": "logging.handlers.RotatingFileHandler", 29 | "formatter": "verbose", 30 | "filename": os.path.join(PARENT_DIR, "guyamoe.log"), 31 | "maxBytes": 1024 * 1024 * 100, # 100 mb 32 | } 33 | }, 34 | "loggers": { 35 | "django": {"handlers": ["file"], "level": "WARNING", "propagate": True,}, 36 | }, 37 | } 38 | 39 | CACHES = {"default": {"BACKEND": "django.core.cache.backends.dummy.DummyCache",}} 40 | 41 | DATABASES = { 42 | "default": { 43 | "ENGINE": "django.db.backends.postgresql_psycopg2", 44 | "NAME": os.environ.get("DEV_DB_NAME"), 45 | "USER": os.environ.get("DEV_DB_USER"), 46 | "PASSWORD": os.environ.get("DEV_DB_PASS"), 47 | "HOST": "localhost", 48 | "PORT": "", 49 | } 50 | } 51 | 52 | OCR_SCRIPT_PATH = os.path.join(PARENT_DIR, "ocr_tool.sh") 53 | -------------------------------------------------------------------------------- /proxy/source/helpers.py: -------------------------------------------------------------------------------- 1 | import base64 2 | 3 | import requests 4 | from django.core.cache import cache 5 | 6 | ENCODE_STR_SLASH = "%FF-" 7 | ENCODE_STR_QUESTION = "%DE-" 8 | GLOBAL_HEADERS = { 9 | "User-Agent": "Mozilla Firefox Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:53.0) Gecko/20100101 Firefox/53.0.", 10 | } 11 | 12 | 13 | def naive_encode(url): 14 | return url.replace("/", ENCODE_STR_SLASH).replace("?", ENCODE_STR_QUESTION) 15 | 16 | 17 | def naive_decode(url): 18 | return url.replace(ENCODE_STR_SLASH, "/").replace(ENCODE_STR_QUESTION, "?") 19 | 20 | 21 | def decode(url: str): 22 | return str(base64.urlsafe_b64decode(url.encode()), "utf-8") 23 | 24 | 25 | def encode(url: str): 26 | return str(base64.urlsafe_b64encode(url.encode()), "utf-8") 27 | 28 | 29 | def get_wrapper(url, *, headers={}, **kwargs): 30 | return requests.get(url, headers={**GLOBAL_HEADERS, **headers}, **kwargs) 31 | 32 | 33 | def post_wrapper(url, headers={}, **kwargs): 34 | return requests.post(url, headers={**GLOBAL_HEADERS, **headers}, **kwargs) 35 | 36 | 37 | def api_cache(*, prefix, time): 38 | def wrapper(f): 39 | def inner(self, meta_id): 40 | data = cache.get(f"{prefix}_{meta_id}") 41 | if not data: 42 | data = f(self, meta_id) 43 | if not data: 44 | return None 45 | else: 46 | cache.set(f"{prefix}_{meta_id}", data, time) 47 | return data 48 | else: 49 | return data 50 | 51 | return inner 52 | 53 | return wrapper 54 | -------------------------------------------------------------------------------- /homepage/templates/homepage/how_cute_404.html: -------------------------------------------------------------------------------- 1 | {% load static %} 2 | 5 | 47 | 48 | 49 | 404 | {{ brand.name }} 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 |

You were trying to visit a page
that doesn't even exist? 59 |

How cute. 60 |

404 61 |

NOT FOUND 62 | Return to main page 63 | 64 | 65 | 66 | -------------------------------------------------------------------------------- /homepage/templates/homepage/thonk_500.html: -------------------------------------------------------------------------------- 1 | {% load static %} 2 | 5 | 47 | 48 | 49 | 500 | {{ brand.name }} 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 |

uwu we had a fucky wucky, the upstream source had a big oops 59 |

try again later 60 |

500 61 |

SERVER ERROR 62 | Return to main page 63 | 64 | 65 | 66 | -------------------------------------------------------------------------------- /proxy/source/data.py: -------------------------------------------------------------------------------- 1 | class SeriesAPI: 2 | def __init__(self, **kwargs): 3 | self.args = kwargs 4 | 5 | def objectify(self): 6 | return { 7 | "slug": self.args["slug"], 8 | "title": self.args["title"], 9 | "description": self.args["description"], 10 | "author": self.args["author"], 11 | "artist": self.args["artist"], 12 | "groups": self.args["groups"], 13 | "cover": self.args["cover"], 14 | "chapters": self.args["chapters"], 15 | "series_name": self.args["title"], 16 | } 17 | 18 | 19 | class SeriesPage: 20 | def __init__(self, **kwargs): 21 | self.args = kwargs 22 | 23 | def objectify(self): 24 | return { 25 | "series": self.args["series"], 26 | "alt_titles": self.args["alt_titles"], 27 | "alt_titles_str": self.args["alt_titles_str"], 28 | "slug": self.args["slug"], 29 | "cover_vol_url": self.args["cover_vol_url"], 30 | "metadata": self.args["metadata"], 31 | "synopsis": self.args["synopsis"], 32 | "author": self.args["author"], 33 | "chapter_list": self.args["chapter_list"], 34 | "original_url": self.args["original_url"], 35 | "available_features": ["detailed"], 36 | } 37 | 38 | 39 | class ChapterAPI: 40 | def __init__(self, **kwargs): 41 | self.args = kwargs 42 | 43 | def objectify(self): 44 | return { 45 | "series": self.args["series"], 46 | "pages": self.args["pages"], 47 | "chapter": self.args["chapter"], 48 | } 49 | -------------------------------------------------------------------------------- /reader/management/commands/chapter_sanity_check.py: -------------------------------------------------------------------------------- 1 | from django.core.management.base import BaseCommand 2 | 3 | from reader.models import Chapter, Volume 4 | from django.conf import settings 5 | 6 | import os 7 | 8 | def is_a_not_empty_folder(path): 9 | if not os.path.exists(path): 10 | raise RuntimeError(f"'{path}' does not exist") 11 | if not os.path.isdir(path): 12 | raise RuntimeError(f"'{path}' is not a dir") 13 | if len(os.listdir(path)) == 0: 14 | raise RuntimeError(f"'{path}' is empty.") 15 | 16 | class Command(BaseCommand): 17 | help = "Check that the file for every chapter do exist at the correct location" 18 | 19 | def add_arguments(self, parser): 20 | pass 21 | 22 | def handle(self, *args, **options): 23 | for chapter in Chapter.objects.select_related("series", "group"): 24 | series_folder = os.path.join(settings.MEDIA_ROOT, "manga", chapter.series.slug) 25 | chapter_folder = os.path.join(series_folder, "chapters", chapter.folder) 26 | group_folder = str(chapter.group.id) 27 | is_a_not_empty_folder(series_folder) 28 | is_a_not_empty_folder(chapter_folder) 29 | is_a_not_empty_folder(os.path.join(chapter_folder, f"{group_folder}")) 30 | is_a_not_empty_folder(os.path.join(chapter_folder, f"{group_folder}_shrunk")) 31 | is_a_not_empty_folder(os.path.join(chapter_folder, f"{group_folder}_shrunk_blur")) 32 | print(chapter_folder, "is ok.") 33 | 34 | for volume in Volume.objects.select_related("series"): 35 | series_folder = os.path.join(settings.MEDIA_ROOT, str(volume.volume_cover)) 36 | 37 | if not os.path.exists(series_folder): 38 | raise RuntimeError(f"'{series_folder}' does not exist") 39 | 40 | print("Success!") 41 | -------------------------------------------------------------------------------- /api/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path, re_path 2 | 3 | from api import views 4 | 5 | urlpatterns = [ 6 | re_path( 7 | r"^series/(?P[\w-]+)/$", 8 | views.get_series_data, 9 | name="api-series_data", 10 | ), 11 | re_path( 12 | r"^series_page_data/(?P[\w-]+)/$", 13 | views.get_series_page_data_req, 14 | name="api-series-page-data", 15 | ), 16 | re_path(r"^get_all_series/", views.get_all_series, name="api-get-all-series"), 17 | re_path( 18 | r"^get_groups/(?P[\w-]+)/", views.get_groups, name="api-groups" 19 | ), 20 | re_path(r"^get_all_groups/", views.get_all_groups, name="api-all-groups"), 21 | # re_path(r'^download_volume/(?P[\w-]+)/(?P[\d]{1,9})', views.download_volume, name='api-volume-chapters-download'), 22 | re_path( 23 | r"^download_chapter/(?P[\w-]+)/(?P[\d-]{1,9})/$", 24 | views.download_chapter, 25 | name="api-chapter-download", 26 | ), 27 | re_path( 28 | r"^upload_new_chapter/(?P[\w-]+)/", 29 | views.upload_new_chapter, 30 | name="api-chapter-upload", 31 | ), 32 | re_path( 33 | r"^get_volume_covers/(?P[\w-]+)/", 34 | views.get_volume_covers, 35 | name="api-get-volume-covers", 36 | ), 37 | re_path( 38 | r"^get_volume_cover/(?P[\w-]+)/(?P[\d-]{1,9})/$", 39 | views.get_volume_cover, 40 | name="api-get-volume-cover", 41 | ), 42 | re_path( 43 | r"^search_index/(?P[\w-]+)/", 44 | views.search_index, 45 | name="api-search-index", 46 | ), 47 | re_path(r"clear_cache/", views.clear_cache, name="api-clear-cache"), 48 | re_path(r"^black_hole_mail/", views.black_hole_mail, name="api-black-hole-mail"), 49 | ] 50 | -------------------------------------------------------------------------------- /reader/forms.py: -------------------------------------------------------------------------------- 1 | import json 2 | from ast import literal_eval 3 | 4 | from django import forms 5 | 6 | from reader.models import Chapter, Group, Series 7 | 8 | 9 | def preferred_sort_validity_check(preferred_sort): 10 | if not preferred_sort: 11 | return 12 | try: 13 | preferred_sort = literal_eval(preferred_sort) 14 | except Exception: 15 | raise forms.ValidationError("Invalid list for field 'Preferred sort'") 16 | if not ( 17 | all(isinstance(group, str) and group.isdigit() for group in preferred_sort) 18 | ): 19 | raise forms.ValidationError( 20 | "List for field 'Preferred sort' has invalid elements. Must be string of valid group ids." 21 | ) 22 | if Group.objects.filter( 23 | id__in=[int(group) for group in preferred_sort] 24 | ).count() != len(preferred_sort): 25 | raise forms.ValidationError( 26 | "One or more group ids specified in 'Preferred sort' is not associated with an actual group." 27 | ) 28 | 29 | 30 | class SeriesForm(forms.ModelForm): 31 | class Meta: 32 | model = Series 33 | exclude = [ 34 | id, 35 | ] 36 | 37 | def clean(self): 38 | preferred_sort_validity_check(self.cleaned_data.get("preferred_sort")) 39 | scraping_enabled = self.cleaned_data.get("scraping_enabled") 40 | if scraping_enabled: 41 | scraping_identifiers = self.cleaned_data.get("scraping_identifiers") 42 | try: 43 | scraping_identifiers = json.loads(scraping_identifiers) 44 | except Exception: 45 | raise forms.ValidationError( 46 | "Invalid json for field 'Scraping identifiers'" 47 | ) 48 | return self.cleaned_data 49 | 50 | 51 | class ChapterForm(forms.ModelForm): 52 | class Meta: 53 | model = Chapter 54 | exclude = [ 55 | id, 56 | ] 57 | 58 | def clean(self): 59 | preferred_sort_validity_check(self.cleaned_data.get("preferred_sort")) 60 | return self.cleaned_data 61 | -------------------------------------------------------------------------------- /reader/feed.py: -------------------------------------------------------------------------------- 1 | from django.contrib.syndication.views import Feed 2 | from django.core.cache import cache 3 | from django.shortcuts import reverse 4 | from django.utils.feedgenerator import DefaultFeed 5 | 6 | from reader.models import Chapter, Series 7 | 8 | 9 | class CorrectMimeTypeFeed(DefaultFeed): 10 | content_type = "application/xml; charset=utf-8" 11 | 12 | 13 | class AllChaptersFeed(Feed): 14 | feed_type = CorrectMimeTypeFeed 15 | link = "/all/" 16 | title = "All Chapter updates" 17 | description = "Latest chapter updates" 18 | 19 | def items(self): 20 | return Chapter.objects.order_by("-uploaded_on") 21 | 22 | def item_title(self, item): 23 | return f"{item.series.name} - Chapter {Chapter.clean_chapter_number(item)}" 24 | 25 | def item_link(self, item): 26 | return f"https://guya.moe/read/manga/{item.series.slug}/{Chapter.slug_chapter_number(item)}/1" 27 | 28 | def item_description(self, item): 29 | return f"Group: {item.group.name} - Title {item.title}" 30 | 31 | def item_pubdate(self, item): 32 | return item.uploaded_on 33 | 34 | 35 | class SeriesChaptersFeed(Feed): 36 | feed_type = CorrectMimeTypeFeed 37 | title = "Series Chapter updates" 38 | description = "Latest chapter updates" 39 | 40 | def get_object(self, request, series_slug): 41 | return Series.objects.get(slug=series_slug) 42 | 43 | def title(self, obj): 44 | return obj.name 45 | 46 | def link(self, obj): 47 | return f"https://guya.moe/read/manga/{obj.slug}/" 48 | 49 | def description(self, obj): 50 | return obj.synopsis 51 | 52 | def item_title(self, obj): 53 | return f"{obj.series.name} - Chapter {Chapter.clean_chapter_number(obj)}" 54 | 55 | def item_link(self, obj): 56 | return f"https://guya.moe/read/manga/{obj.series.slug}/{Chapter.slug_chapter_number(obj)}/1" 57 | 58 | def item_description(self, obj): 59 | return f"Group: {obj.group.name} - Title {obj.title}" 60 | 61 | def items(self, obj): 62 | return Chapter.objects.filter(series=obj).order_by("-uploaded_on") 63 | 64 | def item_pubdate(self, item): 65 | return item.uploaded_on 66 | -------------------------------------------------------------------------------- /reader/management/commands/chapter_checker.py: -------------------------------------------------------------------------------- 1 | from django.core.management.base import BaseCommand 2 | 3 | from reader.models import Series 4 | from reader.scraper.mappings import SCRAPER_MAPPINGS 5 | 6 | 7 | class Command(BaseCommand): 8 | help = "Import new chapters from JaiminisBox and Mangadex" 9 | 10 | def add_arguments(self, parser): 11 | # # Positional arguments 12 | parser.add_argument("--lookup", nargs="?", default="all") 13 | parser.add_argument("--update", action="store_true") 14 | parser.add_argument("--series", nargs="?") 15 | parser.add_argument("--chapters", nargs="+") 16 | 17 | def handle(self, *args, **options): 18 | if options["update"] and options["series"] and options["chapters"]: 19 | chapters = options["chapters"] 20 | chapters_and_groups = {} 21 | for chapter in chapters: 22 | chapter_number, group_name = chapter.split(" ", 1) 23 | if float(chapter_number) not in chapters_and_groups: 24 | chapters_and_groups[float(chapter_number)] = [group_name] 25 | else: 26 | chapters_and_groups[float(chapter_number)].append(group_name) 27 | series = Series.objects.get(slug=options["series"]) 28 | scraper_class = SCRAPER_MAPPINGS.get(series.scraping_source, None) 29 | if not scraper_class and options["lookup"]: 30 | scraper_class = SCRAPER_MAPPINGS.get(options["lookup"], None) 31 | if not scraper_class: 32 | print("Could not find specified scraping source.") 33 | scraper = scraper_class(series) 34 | if scraper.initialized: 35 | scraper.scrape_chapters( 36 | check_updates=True, specific_chapters=chapters_and_groups 37 | ) 38 | else: 39 | check_updates = options["update"] 40 | for series in Series.objects.filter(scraping_enabled=True): 41 | scraper_class = SCRAPER_MAPPINGS[series.scraping_source] 42 | scraper = scraper_class(series) 43 | if scraper.initialized: 44 | scraper.scrape_chapters(check_updates=check_updates) 45 | -------------------------------------------------------------------------------- /guyamoe/urls.py: -------------------------------------------------------------------------------- 1 | """guyamoe URL Configuration 2 | 3 | The `urlpatterns` list routes URLs to views. For more information please see: 4 | https://docs.djangoproject.com/en/2.2/topics/http/urls/ 5 | Examples: 6 | Function views 7 | 1. Add an import: from my_app import views 8 | 2. Add a URL to urlpatterns: path('', views.home, name='home') 9 | Class-based views 10 | 1. Add an import: from other_app.views import Home 11 | 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') 12 | Including another URLconf 13 | 1. Import the include() function: from django.urls import include, path 14 | 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 15 | """ 16 | from django.conf import settings 17 | 18 | # from django.conf.urls import url 19 | from django.conf.urls.static import static 20 | from django.contrib import admin 21 | from django.contrib.sitemaps.views import sitemap 22 | from django.urls import include, path 23 | 24 | from homepage.sitemaps import ( 25 | ChapterViewSitemap, 26 | PagesListViewSitemap, 27 | PageViewSitemap, 28 | SeriesViewSitemap, 29 | StaticViewSitemap, 30 | ) 31 | from proxy import sources 32 | 33 | sitemaps = { 34 | "static": StaticViewSitemap, 35 | "series": SeriesViewSitemap, 36 | "chapter": ChapterViewSitemap, 37 | "pageslist": PagesListViewSitemap, 38 | "page": PageViewSitemap, 39 | } 40 | 41 | urlpatterns = [ 42 | path("admin/", admin.site.urls), 43 | path("", include("homepage.urls")), 44 | path("sitemap.xml", sitemap, {"sitemaps": sitemaps}), 45 | path("reader/", include("reader.urls")), 46 | path("read/", include("reader.urls")), 47 | path("api/", include("api.urls")), 48 | path("pages/", include("misc.urls")), 49 | path( 50 | "", 51 | include( 52 | [route for source in sources for route in source.register_shortcut_routes()] 53 | ), 54 | ), 55 | path("proxy/", include("proxy.urls")), 56 | ] 57 | 58 | handler404 = "homepage.views.handle404" 59 | 60 | if settings.DEBUG: 61 | # import debug_toolbar 62 | # urlpatterns = [ 63 | # path('__debug__/', include(debug_toolbar.urls)), 64 | 65 | # # For django versions before 2.0: 66 | # # url(r'^__debug__/', include(debug_toolbar.urls)), 67 | 68 | # ] + urlpatterns 69 | urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) 70 | -------------------------------------------------------------------------------- /reader/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path, re_path 2 | from django.views.decorators.http import condition 3 | from django.views.decorators.cache import cache_control 4 | 5 | from api.api import all_chapter_data_etag, chapter_data_etag 6 | from reader import views 7 | from reader.feed import AllChaptersFeed, SeriesChaptersFeed 8 | from reader.models import Chapter 9 | 10 | urlpatterns = [ 11 | re_path( 12 | r"^manga/(?P[\w-]+)/$", views.series_info, name="reader-manga" 13 | ), 14 | re_path( 15 | r"^series/(?P[\w-]+)/$", views.series_info, name="reader-series" 16 | ), 17 | re_path( 18 | r"^manga/(?P[\w-]+)/admin$", 19 | views.series_info_admin, 20 | name="reader-manga-admin", 21 | ), 22 | re_path( 23 | r"^series/(?P[\w-]+)/admin$", 24 | views.series_info_admin, 25 | name="reader-series-admin", 26 | ), 27 | re_path( 28 | r"^manga/(?P[\w-]+)/(?P[\d-]{1,9})/$", 29 | views.reader, 30 | name="reader-manga-chapter-shortcut", 31 | ), 32 | re_path( 33 | r"^series/(?P[\w-]+)/(?P[\d-]{1,9})/$", 34 | views.reader, 35 | name="reader-series-chapter-shortcut", 36 | ), 37 | re_path( 38 | r"^manga/(?P[\w-]+)/(?P[\d-]{1,9})/(?P[\d]{1,9})/$", 39 | views.reader, 40 | name="reader-manga-chapter", 41 | ), 42 | re_path( 43 | r"^series/(?P[\w-]+)/(?P[\d-]{1,9})/(?P[\d]{1,9})/$", 44 | views.reader, 45 | name="reader-series-chapter", 46 | ), 47 | re_path( 48 | r"^manga/(?P[\w-]+)/(?P[\w-]+)/$", 49 | views.series_info_canonical, 50 | name="reader-manga-canonical", 51 | ), 52 | re_path(r"^update_view_count/", views.hit_count, name="reader-view-count"), 53 | re_path( 54 | r"^other/rss/all$", 55 | cache_control( 56 | public=True, max_age=600, s_maxage=600 57 | )( # Cache control for CF, etag for RSS readers 58 | condition(etag_func=all_chapter_data_etag)(AllChaptersFeed()) 59 | ), 60 | ), 61 | path( 62 | r"other/rss/", 63 | cache_control(public=True, max_age=600, s_maxage=600)( 64 | condition(etag_func=chapter_data_etag)(SeriesChaptersFeed()) 65 | ), 66 | ), 67 | ] 68 | -------------------------------------------------------------------------------- /guyamoe/settings/prod.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from .base import * 4 | 5 | 6 | CANONICAL_ROOT_DOMAIN = "aka.guya.moe" 7 | SECURE_HSTS_SECONDS = 60 8 | SECURE_HSTS_INCLUDE_SUBDOMAINS = True 9 | SECURE_HSTS_PRELOAD = True 10 | SECURE_CONTENT_TYPE_NOSNIFF = True 11 | SECURE_BROWSER_XSS_FILTER = True 12 | SECURE_SSL_REDIRECT = True 13 | SESSION_COOKIE_SECURE = True 14 | SECURE_HSTS_SECONDS = 3600 15 | SECURE_HSTS_INCLUDE_SUBDOMAINS = True 16 | # CSRF_COOKIE_SECURE = True 17 | X_FRAME_OPTIONS = "ALLOW" 18 | 19 | DEBUG = False 20 | SITE_ID = 2 21 | 22 | ALLOWED_HOSTS = [ 23 | "guya.moe", 24 | "www.guya.moe", 25 | "kaguya.guya.moe", 26 | "www.kaguya.guya.moe", 27 | "ka.guya.moe", 28 | "www.ka.guya.moe", 29 | "ice.guya.moe", 30 | "www.ice.guya.moe", 31 | "baka.guya.moe", 32 | "www.baka.guya.moe", 33 | "trash.guya.moe", 34 | "www.trash.guya.moe", 35 | "dog.guya.moe", 36 | "www.dog.guya.moe", 37 | "kuu.guya.moe", 38 | "www.kuu.guya.moe", 39 | "read.guya.moe", 40 | "manga.guya.moe", 41 | "aka.guya.moe", 42 | "guya.cubari.moe", 43 | "localhost", 44 | ] 45 | 46 | CANONICAL_SITE_NAME = CANONICAL_ROOT_DOMAIN 47 | 48 | LOGGING = { 49 | "version": 1, 50 | "disable_existing_loggers": True, 51 | "formatters": { 52 | "verbose": { 53 | "format": "%(asctime)s %(levelname)s [%(name)s:%(lineno)s] %(module)s %(process)d %(thread)d %(message)s" 54 | } 55 | }, 56 | "handlers": { 57 | "file": { 58 | "level": "ERROR", 59 | "class": "logging.handlers.RotatingFileHandler", 60 | "formatter": "verbose", 61 | "filename": os.path.join(PARENT_DIR, "guyamoe.log"), 62 | "maxBytes": 1024 * 1024 * 100, # 100 mb 63 | } 64 | }, 65 | "loggers": { 66 | "django": {"handlers": ["file"], "level": "WARNING", "propagate": True,}, 67 | }, 68 | } 69 | 70 | CACHES = { 71 | "default": { 72 | "BACKEND": "django.core.cache.backends.memcached.MemcachedCache", 73 | "LOCATION": "127.0.0.1:11211", 74 | } 75 | } 76 | 77 | DATABASES = { 78 | "default": { 79 | "ENGINE": "django.db.backends.postgresql_psycopg2", 80 | "NAME": os.environ.get("DB_NAME"), 81 | "USER": os.environ.get("DB_USER"), 82 | "PASSWORD": os.environ.get("DB_PASS"), 83 | "HOST": "localhost", 84 | "PORT": "", 85 | } 86 | } 87 | 88 | OCR_SCRIPT_PATH = os.path.join(PARENT_DIR, "ocr_tool.sh") 89 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Guya.moe 2 | Generalized manga reading framework. Adapted for Kaguya-sama manga, but can be used generically for any and all manga. 3 | 4 | Testing Supported By
5 | BrowserStack 6 | 7 | ⚠ **Note:** The install instructions below will not result in a general purpose CMS due to the amount of hardcoded assets in Guyamoe. 8 | 9 | ## Prerequisites 10 | - git 11 | - python 3.6.5+ 12 | - pip 13 | - virtualenv 14 | 15 | ## Install 16 | 1. Create a venv for Guyamoe in your home directory. 17 | ``` 18 | virtualenv ~/guyamoe 19 | ``` 20 | 21 | 2. Clone Guyamoe's source code into the venv. 22 | ``` 23 | git clone https://github.com/appu1232/guyamoe ~/guyamoe/app 24 | ``` 25 | 26 | 3. Activate the venv. 27 | ``` 28 | cd ~/guyamoe/app && source ../bin/activate 29 | ``` 30 | 31 | 4. Install Guyamoe's dependencies. 32 | ``` 33 | pip3 install -r requirements.txt 34 | ``` 35 | 36 | 5. Change the value of the `SECRET_KEY` variable to a randomly generated string. 37 | ``` 38 | sed -i "s|\"o kawaii koto\"|\"$(openssl rand -base64 32)\"|" guyamoe/settings/base.py 39 | ``` 40 | 41 | 6. Generate the default assets for Guyamoe. 42 | ``` 43 | python3 init.py 44 | ``` 45 | 46 | 7. Create an admin user for Guyamoe. 47 | ``` 48 | python3 manage.py createsuperuser 49 | ``` 50 | 51 | Before starting the server, create a `media` folder in the base directory. Add manga with the corresponding chapters and page images. Structure it like so: 52 | ``` 53 | media 54 | └───manga 55 | └─── 56 | └───001 57 | ├───001.jpg 58 | ├───002.jpg 59 | └───... 60 | ``` 61 | E.g. `Kaguya-Wants-To-Be-Confessed-To` for ``. 62 | 63 | **Note:** Zero pad chapter folder numbers like so: `001` for the Kaguya series (this is how the fixtures data for the series has it). It doesn't matter for pages though nor does it have to be .jpg. Only thing required for pages is that the ordering can be known from a simple numerical/alphabetical sort on the directory. 64 | 65 | ## Start the server 66 | - `python3 manage.py runserver` - keep this console active 67 | 68 | Now the site should be accessible on localhost:8000 69 | 70 | ## Other info 71 | Relevant URLs (as of now): 72 | 73 | - `/` - home page 74 | - `/about` - about page 75 | - `/admin` - admin view (login with created user above) 76 | - `/admin_home` - admin endpoint for clearing the site's cache 77 | - `/reader/series/` - series info and all chapter links 78 | - `/reader/series///` - url scheme for reader opened on specfied page of chapter of series. 79 | - `/api/series/` - all series data requested by reader frontend 80 | - `/media/manga///` - url scheme to used by reader to actual page as an image. 81 | -------------------------------------------------------------------------------- /misc/templates/misc/misc.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | {% load page_tags %} 3 | {% load static %} 4 | {% block meta %} 5 | {{ page_title }} | {{ brand.name }} 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | {% endblock %} 15 | 16 | {% block body %} 17 |

18 | {{page_title}} 19 |
20 | 21 |
22 | 23 |
24 |

{{page_title}}

25 | 31 | {{ content | convert_to_markdown | safe }} 32 |
33 | 55 | {% endblock %} 56 | -------------------------------------------------------------------------------- /misc/views.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from django.conf import settings 4 | from django.contrib.contenttypes.models import ContentType 5 | from django.core.cache import cache 6 | from django.db.models import F 7 | from django.http import Http404, HttpResponse 8 | from django.shortcuts import render 9 | from django.template import Context, Template 10 | from django.utils.decorators import decorator_from_middleware 11 | from django.views.decorators.cache import cache_control 12 | from django.views.decorators.csrf import csrf_exempt 13 | 14 | from reader.middleware import OnlineNowMiddleware 15 | from reader.models import HitCount 16 | from reader.users_cache_lib import get_user_ip 17 | 18 | from .models import Page 19 | 20 | 21 | @csrf_exempt 22 | @decorator_from_middleware(OnlineNowMiddleware) 23 | def hit_count(request): 24 | if request.method == "POST": 25 | user_ip = get_user_ip(request) 26 | page_id = f"url_{request.POST['page_url']}/{user_ip}" 27 | if not cache.get(page_id): 28 | cache.set(page_id, page_id, 60) 29 | page_url = request.POST["page_url"] 30 | page_id = Page.objects.get(page_url=page_url).id 31 | page = ContentType.objects.get(app_label="misc", model="page") 32 | hit, _ = HitCount.objects.get_or_create( 33 | content_type=page, object_id=page_id 34 | ) 35 | hit.hits = F("hits") + 1 36 | hit.save() 37 | 38 | return HttpResponse(json.dumps({}), content_type="application/json") 39 | 40 | 41 | @cache_control(public=True, max_age=3600, s_maxage=60) 42 | @decorator_from_middleware(OnlineNowMiddleware) 43 | def content(request, page_url): 44 | try: 45 | page = Page.objects.get(page_url=page_url) 46 | except Page.DoesNotExist: 47 | raise Http404("Page does not exist.") 48 | content = page.content 49 | for var in page.variable.all(): 50 | content = content.replace("{{%s}}" % var.key, var.value) 51 | template_tags = { 52 | "content": content, 53 | "page_description": page.preview, 54 | "page_url": page.page_url, 55 | "page_title": page.page_title, 56 | "date": int(page.date.timestamp()) if page.date else "", 57 | "cover_image_url": page.cover_image_url, 58 | "template": "misc_pages_list", 59 | "static_dir": f"/media/pages/{page.page_url}/static/", 60 | "relative_url": f"pages/{page_url}/", 61 | "version_query": settings.STATIC_VERSION, 62 | } 63 | if page.standalone: 64 | template = Template(page.content) 65 | return HttpResponse( 66 | template.render(Context(template_tags)), content_type="text/html" 67 | ) 68 | else: 69 | return render(request, "misc/misc.html", template_tags) 70 | 71 | 72 | @cache_control(public=True, max_age=300, s_maxage=60) 73 | @decorator_from_middleware(OnlineNowMiddleware) 74 | def misc_pages(request): 75 | pages = cache.get("misc_pages") 76 | if not pages: 77 | pages = Page.objects.filter(hidden=False).order_by("-date") 78 | cache.set("misc_pages", pages, 3600 * 8) 79 | for page in pages: 80 | page.date = int(page.date.timestamp()) if page.date else "" 81 | return render( 82 | request, 83 | "misc/misc_pages.html", 84 | { 85 | "pages": pages, 86 | "template": "misc_page", 87 | "relative_url": "pages/", 88 | "page_title": "Kaguya-sama news and articles", 89 | "page_description": "A collection of articles, interviews and news related to Kaguya-sama and Aka Akasaka.", 90 | "version_query": settings.STATIC_VERSION, 91 | }, 92 | ) 93 | -------------------------------------------------------------------------------- /reader/admin.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timezone 2 | 3 | from django.contrib import admin 4 | 5 | from .forms import ChapterForm, SeriesForm 6 | from .models import Chapter, Group, HitCount, Person, Series, Volume 7 | 8 | 9 | # Register your models here. 10 | class HitCountAdmin(admin.ModelAdmin): 11 | ordering = ("hits",) 12 | list_display = ( 13 | "hits", 14 | "content", 15 | "series", 16 | "content_type", 17 | ) 18 | 19 | def series(self, obj): 20 | if isinstance(obj.content, Series): 21 | return obj.content.name 22 | if isinstance(obj.content, Chapter): 23 | return obj.content.series.name 24 | else: 25 | return obj 26 | 27 | 28 | admin.site.register(HitCount, HitCountAdmin) 29 | admin.site.register(Person) 30 | 31 | 32 | class GroupAdmin(admin.ModelAdmin): 33 | list_display = ( 34 | "id", 35 | "name", 36 | ) 37 | 38 | 39 | admin.site.register(Group, GroupAdmin) 40 | 41 | 42 | class SeriesAdmin(admin.ModelAdmin): 43 | form = SeriesForm 44 | readonly_fields = ("slug",) 45 | list_display = ("name",) 46 | 47 | 48 | admin.site.register(Series, SeriesAdmin) 49 | 50 | 51 | class VolumeAdmin(admin.ModelAdmin): 52 | search_fields = ( 53 | "volume_number", 54 | "series__name", 55 | ) 56 | ordering = ("volume_number",) 57 | list_display = ( 58 | "volume_number", 59 | "series", 60 | "volume_cover", 61 | ) 62 | 63 | exclude = ("volume_cover ",) 64 | 65 | def get_readonly_fields(self, request, obj=None): 66 | if obj: 67 | return () 68 | else: 69 | return ("volume_cover",) 70 | 71 | 72 | admin.site.register(Volume, VolumeAdmin) 73 | 74 | 75 | class ChapterAdmin(admin.ModelAdmin): 76 | form = ChapterForm 77 | search_fields = ( 78 | "chapter_number", 79 | "title", 80 | "series__name", 81 | "volume", 82 | ) 83 | list_display = ( 84 | "chapter_number", 85 | "title", 86 | "series", 87 | "volume", 88 | "version", 89 | "time_since_last_update", 90 | "updated_on", 91 | "uploaded_on", 92 | ) 93 | 94 | def get_queryset(self, request): 95 | qs = super(ChapterAdmin, self).get_queryset(request) 96 | sort_sql = """SELECT 97 | CASE 98 | WHEN updated_on IS NOT NULL THEN updated_on 99 | ELSE uploaded_on END 100 | as time_since_change 101 | """ 102 | qs = qs.extra(select={"time_since_last_update": sort_sql}).order_by( 103 | "-time_since_last_update" 104 | ) 105 | return qs 106 | 107 | def time_since_last_update(self, obj): 108 | if obj.time_since_last_update is not None: 109 | if type(obj.time_since_last_update) is str: 110 | try: 111 | last_update = datetime.strptime( 112 | obj.time_since_last_update, "%Y-%m-%d %H:%M:%S.%f" 113 | ) 114 | except ValueError: 115 | last_update = datetime.strptime( 116 | obj.time_since_last_update, "%Y-%m-%d %H:%M:%S" 117 | ) 118 | last_update = last_update.replace(tzinfo=timezone.utc) 119 | else: 120 | last_update = obj.time_since_last_update.replace(tzinfo=timezone.utc) 121 | curr_time = datetime.utcnow().replace(tzinfo=timezone.utc) 122 | time_since_last_update = curr_time - last_update 123 | else: 124 | time_since_last_update = curr_time - obj.uploaded_on 125 | days = time_since_last_update.days 126 | seconds = time_since_last_update.seconds 127 | hours = seconds // 3600 128 | minutes = (seconds // 60) % 60 129 | 130 | return f"{days} days {hours} hours {minutes} mins" 131 | 132 | time_since_last_update.admin_order_field = "time_since_last_update" 133 | ordering = ("-uploaded_on", "-updated_on") 134 | 135 | 136 | admin.site.register(Chapter, ChapterAdmin) 137 | -------------------------------------------------------------------------------- /init.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import random 4 | import subprocess 5 | import time 6 | from multiprocessing import Pool 7 | 8 | from PIL import Image, ImageDraw, ImageFilter 9 | 10 | WIDTH = 850 11 | HEIGHT = 1250 12 | PAGES = 18 13 | PROCESSES = 15 14 | 15 | MEDIA_BASE = "media" 16 | FIXTURES_PATH = "./reader/fixtures/reader_fixtures.json" 17 | 18 | 19 | def real_path(path): 20 | return os.path.join(MEDIA_BASE, path) 21 | 22 | 23 | def generate_image(path): 24 | if not os.path.isfile(real_path(path)): 25 | print("Generating", path) 26 | width = WIDTH 27 | height = HEIGHT 28 | if "shrunk" in path: 29 | width = 85 30 | height = 125 31 | img = Image.new( 32 | "RGB", 33 | (width, height), 34 | color=( 35 | random.randint(0, 255), 36 | random.randint(0, 255), 37 | random.randint(0, 255), 38 | ), 39 | ) 40 | ImageDraw.Draw(img).text( 41 | (width / 2, height / 2), path.split("/")[-1], fill=(0, 0, 0) 42 | ) 43 | if "blur" in path: 44 | img.filter(ImageFilter.GaussianBlur(radius=10)) 45 | img.save(real_path(path)) 46 | return True 47 | 48 | 49 | def create_directories(directories): 50 | for directory in directories: 51 | os.makedirs(real_path(directory), exist_ok=True) 52 | 53 | 54 | def load_fixtures(path): 55 | with open(path, "r") as fixtures: 56 | raw = fixtures.read() 57 | data = json.loads(raw) 58 | 59 | series = {} 60 | for d in data: 61 | if d["model"] == "reader.series": 62 | series[d["pk"]] = d["fields"]["slug"] 63 | 64 | directories = [] 65 | volume_covers = [] 66 | chapters = [] 67 | for d in data: 68 | f = d["fields"] 69 | if d["model"] == "reader.chapter": 70 | directories.append( 71 | f"manga/{series[f['series']]}/chapters/{f['folder']}/{f['group']}" 72 | ) 73 | directories.append( 74 | f"manga/{series[f['series']]}/chapters/{f['folder']}/{f['group']}_shrunk" 75 | ) 76 | directories.append( 77 | f"manga/{series[f['series']]}/chapters/{f['folder']}/{f['group']}_shrunk_blur" 78 | ) 79 | for page_num in range(1, PAGES + 1): 80 | chapters.append( 81 | f"manga/{series[f['series']]}/chapters/{f['folder']}/{f['group']}/{page_num:03}.jpg" 82 | ) 83 | chapters.append( 84 | f"manga/{series[f['series']]}/chapters/{f['folder']}/{f['group']}_shrunk/{page_num:03}.jpg" 85 | ) 86 | chapters.append( 87 | f"manga/{series[f['series']]}/chapters/{f['folder']}/{f['group']}_shrunk_blur/{page_num:03}.jpg" 88 | ) 89 | 90 | elif d["model"] == "reader.volume": 91 | if f["volume_cover"]: 92 | directories.append("/".join(f["volume_cover"].split("/")[0:-1])) 93 | volume_covers.append(f["volume_cover"]) 94 | 95 | return { 96 | "volume_covers": volume_covers, 97 | "chapters": chapters, 98 | "directories": directories, 99 | } 100 | 101 | 102 | if __name__ == "__main__": 103 | fixtures = load_fixtures(FIXTURES_PATH) 104 | create_directories(fixtures["directories"]) 105 | with Pool(PROCESSES) as p: 106 | changed = [ 107 | status 108 | for status in p.map( 109 | generate_image, fixtures["chapters"] + fixtures["volume_covers"] 110 | ) 111 | if status 112 | ] 113 | 114 | os.system("python manage.py makemigrations") 115 | result = subprocess.check_output("python manage.py migrate", shell=True, text=True) 116 | 117 | if "OK" in result or changed: 118 | os.system(f"python manage.py loaddata {FIXTURES_PATH}") 119 | -------------------------------------------------------------------------------- /reader/management/commands/chapter_indexer.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | 4 | from django.conf import settings 5 | from django.core.management.base import BaseCommand, CommandError 6 | 7 | from reader.models import Chapter, ChapterIndex, Series 8 | 9 | 10 | class Command(BaseCommand): 11 | help = "Index pages and chapters" 12 | 13 | def add_arguments(self, parser): 14 | parser.add_argument("--series") 15 | parser.add_argument("--file") 16 | parser.add_argument("--update", nargs="?") 17 | parser.add_argument("--delete", nargs="?") 18 | parser.add_argument("--list", nargs="?") 19 | 20 | def handle(self, *args, **options): 21 | if options["update"]: 22 | print(options["series"]) 23 | chapter_number = float(options["update"]) 24 | series = Series.objects.get(slug=options["series"]) 25 | ch_obj = Chapter.objects.filter( 26 | chapter_number=chapter_number, series__slug=options["series"] 27 | ).first() 28 | if ch_obj: 29 | print("Adding chapter index to db.") 30 | with open(options["file"], encoding="utf-8-sig") as f: 31 | data = json.load(f)["MentionedWordChapterLocation"] 32 | for word in data: 33 | index = ChapterIndex.objects.filter( 34 | word=word, series=series 35 | ).first() 36 | if not index: 37 | index = ChapterIndex.objects.create( 38 | word=word, chapter_and_pages="{}", series=series 39 | ) 40 | word_dict = json.loads(index.chapter_and_pages) 41 | word_dict[ch_obj.slug_chapter_number()] = data[word] 42 | index.chapter_and_pages = json.dumps(word_dict) 43 | index.save() 44 | print("Finished adding chapter index to db.") 45 | else: 46 | print("Chapter does not exist.") 47 | elif options["delete"]: 48 | chapter_number = float(options["delete"]) 49 | series = Series.objects.get(slug=options["series"]) 50 | ch_slug = ( 51 | str(int(chapter_number)) 52 | if chapter_number % 1 == 0 53 | else str(chapter_number) 54 | ).replace(".", "-") 55 | print("Deleting chapter index from db.") 56 | for index in ChapterIndex.objects.filter(series=series): 57 | word_dict = json.loads(index.chapter_and_pages) 58 | if ch_slug in word_dict: 59 | print(index.word, word_dict[ch_slug]) 60 | del word_dict[ch_slug] 61 | index.chapter_and_pages = json.dumps(word_dict) 62 | index.save() 63 | print("Finished deleting chapter index from db.") 64 | 65 | elif options["list"]: 66 | chapter_number = float(options["list"]) 67 | series = Series.objects.get(slug=options["series"]) 68 | ch_obj = Chapter.objects.filter( 69 | chapter_number=chapter_number, 70 | series__slug=options["series"], 71 | series=series, 72 | ).first() 73 | if ch_obj: 74 | ch_slug = ch_obj.slug_chapter_number() 75 | print("Listing chapter index from db.") 76 | for index in ChapterIndex.objects.filter(series=series): 77 | word_dict = json.loads(index.chapter_and_pages) 78 | if ch_slug in word_dict: 79 | print(index.word, word_dict[ch_slug]) 80 | else: 81 | print("Chapter does not exist.") 82 | 83 | else: 84 | series = Series.objects.get(slug=options["series"]) 85 | with open(options["file"], encoding="utf-8-sig") as f: 86 | data = json.load(f)["MentionedWordLocation"] 87 | for word in data: 88 | ChapterIndex.objects.create( 89 | word=word, 90 | chapter_and_pages=json.dumps(data[word]), 91 | series=series, 92 | ) 93 | -------------------------------------------------------------------------------- /docker/init.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import random 4 | import subprocess 5 | import time 6 | from multiprocessing import Pool 7 | 8 | from PIL import Image, ImageDraw, ImageFilter 9 | 10 | WIDTH = 850 11 | HEIGHT = 1250 12 | PAGES = 18 13 | PROCESSES = 15 14 | 15 | MEDIA_BASE = "media" 16 | FIXTURES_PATH = "./reader/fixtures/reader_fixtures.json" 17 | 18 | 19 | def real_path(path): 20 | return os.path.join(MEDIA_BASE, path) 21 | 22 | 23 | def generate_image(path): 24 | if not os.path.isfile(real_path(path)): 25 | print("Generating", path) 26 | width = WIDTH 27 | height = HEIGHT 28 | if "shrunk" in path: 29 | width = 85 30 | height = 125 31 | img = Image.new( 32 | "RGB", 33 | (width, height), 34 | color=( 35 | random.randint(0, 255), 36 | random.randint(0, 255), 37 | random.randint(0, 255), 38 | ), 39 | ) 40 | ImageDraw.Draw(img).text( 41 | (width / 2, height / 2), path.split("/")[-1], fill=(0, 0, 0) 42 | ) 43 | if "blur" in path: 44 | img.filter(ImageFilter.GaussianBlur(radius=10)) 45 | img.save(real_path(path)) 46 | return True 47 | 48 | 49 | def create_directories(directories): 50 | for directory in directories: 51 | os.makedirs(real_path(directory), exist_ok=True) 52 | 53 | 54 | def load_fixtures(path): 55 | with open(path, "r") as fixtures: 56 | raw = fixtures.read() 57 | data = json.loads(raw) 58 | 59 | series = {} 60 | for d in data: 61 | if d["model"] == "reader.series": 62 | series[d["pk"]] = d["fields"]["slug"] 63 | 64 | directories = [] 65 | volume_covers = [] 66 | chapters = [] 67 | for d in data: 68 | f = d["fields"] 69 | if d["model"] == "reader.chapter": 70 | directories.append( 71 | f"manga/{series[f['series']]}/chapters/{f['folder']}/{f['group']}" 72 | ) 73 | directories.append( 74 | f"manga/{series[f['series']]}/chapters/{f['folder']}/{f['group']}_shrunk" 75 | ) 76 | directories.append( 77 | f"manga/{series[f['series']]}/chapters/{f['folder']}/{f['group']}_shrunk_blur" 78 | ) 79 | for page_num in range(1, PAGES + 1): 80 | chapters.append( 81 | f"manga/{series[f['series']]}/chapters/{f['folder']}/{f['group']}/{page_num:03}.jpg" 82 | ) 83 | chapters.append( 84 | f"manga/{series[f['series']]}/chapters/{f['folder']}/{f['group']}_shrunk/{page_num:03}.jpg" 85 | ) 86 | chapters.append( 87 | f"manga/{series[f['series']]}/chapters/{f['folder']}/{f['group']}_shrunk_blur/{page_num:03}.jpg" 88 | ) 89 | 90 | elif d["model"] == "reader.volume": 91 | if f["volume_cover"]: 92 | directories.append("/".join(f["volume_cover"].split("/")[0:-1])) 93 | volume_covers.append(f["volume_cover"]) 94 | 95 | return { 96 | "volume_covers": volume_covers, 97 | "chapters": chapters, 98 | "directories": directories, 99 | } 100 | 101 | 102 | if __name__ == "__main__": 103 | fixtures = load_fixtures(FIXTURES_PATH) 104 | create_directories(fixtures["directories"]) 105 | with Pool(PROCESSES) as p: 106 | changed = [ 107 | status 108 | for status in p.map( 109 | generate_image, fixtures["chapters"] + fixtures["volume_covers"] 110 | ) 111 | if status 112 | ] 113 | 114 | os.system("python manage.py makemigrations") 115 | result = subprocess.check_output("python manage.py migrate", shell=True, text=True) 116 | 117 | if "OK" in result or changed: 118 | os.system(f"python manage.py loaddata {FIXTURES_PATH}") 119 | 120 | os.system("python -u manage.py runserver 0.0.0.0:8000") 121 | -------------------------------------------------------------------------------- /homepage/views.py: -------------------------------------------------------------------------------- 1 | import random as r 2 | 3 | from django.conf import settings 4 | from django.contrib.admin.views.decorators import staff_member_required 5 | from django.core.cache import cache 6 | from django.shortcuts import redirect, render 7 | from django.utils.decorators import decorator_from_middleware 8 | from django.views.decorators.cache import cache_control 9 | 10 | from homepage.middleware import ForwardParametersMiddleware 11 | from reader.middleware import OnlineNowMiddleware 12 | from reader.models import Chapter 13 | 14 | 15 | @staff_member_required 16 | @cache_control(public=True, max_age=30, s_maxage=30) 17 | def admin_home(request): 18 | online = cache.get("online_now") 19 | peak_traffic = cache.get("peak_traffic") 20 | return render( 21 | request, 22 | "homepage/admin_home.html", 23 | { 24 | "online": len(online) if online else 0, 25 | "peak_traffic": peak_traffic, 26 | "template": "home", 27 | "version_query": settings.STATIC_VERSION, 28 | }, 29 | ) 30 | 31 | 32 | @cache_control(public=True, max_age=300, s_maxage=300) 33 | @decorator_from_middleware(OnlineNowMiddleware) 34 | def home(request): 35 | return render( 36 | request, 37 | "homepage/home.html", 38 | { 39 | "abs_url": request.build_absolute_uri(), 40 | "relative_url": "", 41 | "template": "home", 42 | "version_query": settings.STATIC_VERSION, 43 | }, 44 | ) 45 | 46 | 47 | @cache_control(public=True, max_age=3600, s_maxage=300) 48 | @decorator_from_middleware(OnlineNowMiddleware) 49 | def about(request): 50 | return render( 51 | request, 52 | "homepage/about.html", 53 | { 54 | "relative_url": "about/", 55 | "template": "about", 56 | "page_title": "About", 57 | "version_query": settings.STATIC_VERSION, 58 | }, 59 | ) 60 | 61 | 62 | @decorator_from_middleware(ForwardParametersMiddleware) 63 | def main_series_chapter(request, chapter): 64 | return redirect( 65 | "reader-manga-chapter", "Kaguya-Wants-To-Be-Confessed-To", chapter, "1" 66 | ) 67 | 68 | 69 | @decorator_from_middleware(ForwardParametersMiddleware) 70 | def main_series_page(request, chapter, page): 71 | return redirect( 72 | "reader-manga-chapter", "Kaguya-Wants-To-Be-Confessed-To", chapter, page 73 | ) 74 | 75 | 76 | @decorator_from_middleware(ForwardParametersMiddleware) 77 | def latest(request): 78 | latest_chap = cache.get("latest_chap") 79 | if not latest_chap: 80 | latest_chap = ( 81 | Chapter.objects.order_by("-chapter_number") 82 | .filter(series__slug="Kaguya-Wants-To-Be-Confessed-To")[0] 83 | .slug_chapter_number() 84 | ) 85 | cache.set("latest_chap", latest_chap, 3600 * 96) 86 | return redirect( 87 | "reader-manga-chapter", "Kaguya-Wants-To-Be-Confessed-To", latest_chap, "1" 88 | ) 89 | 90 | 91 | @decorator_from_middleware(ForwardParametersMiddleware) 92 | def random(request): 93 | random_opts = cache.get("random_opts") 94 | if not random_opts: 95 | random_opts = [ 96 | ch.slug_chapter_number() 97 | for ch in ( 98 | Chapter.objects.order_by("-chapter_number").filter( 99 | series__slug="Kaguya-Wants-To-Be-Confessed-To" 100 | ) 101 | ) 102 | ] 103 | cache.set("random_opts", random_opts, 3600 * 96) 104 | return redirect( 105 | "reader-manga-chapter", 106 | "Kaguya-Wants-To-Be-Confessed-To", 107 | r.choice(random_opts), 108 | "1", 109 | ) 110 | 111 | 112 | # def latest_releases(request): 113 | # latest_releases = cache.get("latest_releases") 114 | # if not latest_releases: 115 | # latest_releases = Chapter.objects.order_by('-uploaded_on') 116 | # latest_list = [] 117 | # #for _ in range(0, 10): 118 | 119 | # cache.set("latest_chap", latest_chap, 3600 * 96) 120 | # return redirect('reader-manga-chapter', "Kaguya-Wants-To-Be-Confessed-To", latest_chap, "1") 121 | 122 | 123 | def handle404(request, exception): 124 | return render(request, "homepage/how_cute_404.html", status=404) 125 | -------------------------------------------------------------------------------- /homepage/templates/homepage/about.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | {% load static %} 3 | {% block head %} 4 | 10 | {% endblock %} 11 | {% block body %} 12 |
13 |
14 |
15 |

What is guya.moe?

16 |

This is a site that exclusively hosts english scanlated (quality) chapters of the manga Kaguya-sama wa Kokurasetai: Tensai-tachi no Renai Zunousen. The official english title is Kaguya-sama: Love is War and the unofficial beloved english fan translated title is Kaguya Wants To Be Confessed To

17 |

Furthermore, this site aims to be the best in terms of speed and providing a great reader. We will also never sell ads or ask for money.

18 |

All chapters are updated automatically through the use of scrapers so this site should be up to date pretty much whenever you visit.

19 |
20 |
21 |

Why Did You Guys Make This?

22 |

We are big fans of the Kaguya-sama series and want people to enjoy reading this series as conveniently as possible. Currently, besides officially licensed releases (which aren't available for purchase everywhere anyway), the alternatives are sites that only have some of the chapters, sites that are littered with ads, and sites that that have a clunky/slow reader with no customizability options. We went about fixing all of those issues with this one site, but more than anything, we want to help people enjoy reading this manga.

23 |

This project was done for fun by members of the Kaguya-sama Discord (listed above) and will continue to stay up for the foreseeable future.

24 |
25 |
26 |

How Can I Support guya.moe?

27 |

Tell your friends to read Kaguya and tell them to read it here. :D If you run into issues or have suggestions, feel free to contact us on either Reddit or Discord (Appu or Algoinde, check the credits above) so we can try to make this site better.

28 |

If you feel like donating to us, please don't. We aren't doing this for money nor do we need it to support this site. If you have money to spare, please consider supporting the author and buying legal copies of the Kaguya-sama series. English volumes and JP volumes.

29 |
30 |
31 |

Some Handy Not-So-Obvious Features

32 |

- You can literally search for any word in the entirety of the main series. Yes, really. Hit the search icon (or ctrl/cmd + F) when viewing a chapter, input some text (e.g. how cute), hit enter, and you'll get results for every instance of the words in the manga! Pretty useful for finding that one line you loved but forgot what chapter it was in or just to experiment with it and find out cool callbacks and foreshadowing moments in this manga.

33 |

- There's shorturls to any chapter or page of the main series. Example: https://guya.moe/5 for chapter 5. https://guya.moe/14/16 for chapter 14 page 16.

34 |

- View the latest chapter with the shorturl: https://guya.moe/latest

35 |

- We have a load of customizability options so you can easily find the sweet spot view/look and feel when reading a chapter. There's a variety of options to let you fit the manga pages however you want on the screen, preload settings, layout direction (left to right, right to left, vertical), and more. Hover over the sidebar options to see tooltips for all the customization options. The sidebar is hideable as well. We spent considerable time planning out and fitting in a lot of customizabilty without falling behind on speed and snappiness of the reading experience.

36 |

- We have proxies for mangadex (replace `mangadex.org` in the url with `guya.moe` for whatever manga you're reading), nh (same way as md), and all sites that use the foolslide comic reader (jaiminisbox, helveticascans, a certain nsfw cafe, and more). For foolslide sites, add `guya.moe/fs/` before the entire link (e.g. https://guya.moe/fs/https://helveticascans.com/r/series/mousou-telepathy/).

37 |

If you would like to suggest any features or provide some feedback about the site, don't hesitate to drop by the Discord Server and ping Appu or Algoinde with your thoughts. We're always eager to make this site better!

38 |
39 |
40 |
41 | {% endblock %} 42 | -------------------------------------------------------------------------------- /guyamoe/settings/base.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for guyamoe project. 3 | 4 | Generated by 'django-admin startproject' using Django 2.2.2. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/2.2/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/2.2/ref/settings/ 11 | """ 12 | 13 | import os 14 | import subprocess 15 | from pathlib import Path 16 | 17 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 18 | BASE_DIR = Path(__file__).resolve(strict=True).parent.parent.parent 19 | PARENT_DIR = BASE_DIR.parent 20 | 21 | SECRET_KEY = os.environ.get("SECRET_KEY", "o kawaii koto") 22 | 23 | CANONICAL_ROOT_DOMAIN = "localhost:8000" 24 | 25 | DEBUG = True 26 | 27 | ALLOWED_HOSTS = ["localhost"] 28 | 29 | SITE_ID = 1 30 | 31 | # Application definition 32 | 33 | INSTALLED_APPS = [ 34 | "api.apps.ApiConfig", 35 | "reader.apps.ReaderConfig", 36 | "homepage.apps.HomepageConfig", 37 | "misc.apps.MiscConfig", 38 | "proxy.apps.ProxyConfig", 39 | "django.contrib.admin", 40 | "django.contrib.auth", 41 | "django.contrib.contenttypes", 42 | "django.contrib.sessions", 43 | "django.contrib.sites", 44 | "django.contrib.sitemaps", 45 | "django.contrib.messages", 46 | "django.contrib.staticfiles", 47 | "django_extensions", 48 | ] 49 | 50 | 51 | INTERNAL_IPS = ("127.0.0.1",) 52 | 53 | ROOT_URLCONF = "guyamoe.urls" 54 | 55 | CACHES = { 56 | "default": { 57 | "BACKEND": "django.core.cache.backends.locmem.LocMemCache", 58 | "LOCATION": "unique-snowflake", 59 | } 60 | } 61 | 62 | MIDDLEWARE = [ 63 | "django.middleware.csrf.CsrfViewMiddleware", 64 | "django.middleware.security.SecurityMiddleware", 65 | "django.contrib.sessions.middleware.SessionMiddleware", 66 | "django.middleware.common.CommonMiddleware", 67 | "django.middleware.csrf.CsrfViewMiddleware", 68 | "django.contrib.auth.middleware.AuthenticationMiddleware", 69 | "django.contrib.messages.middleware.MessageMiddleware", 70 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 71 | "homepage.middleware.ReferralMiddleware", 72 | ] 73 | 74 | # REFERRAL_SERVICE = 'http://127.0.0.1:8080' # Change this to where-ever Ai is hosted 75 | 76 | ROOT_URLCONF = "guyamoe.urls" 77 | 78 | TEMPLATES = [ 79 | { 80 | "BACKEND": "django.template.backends.django.DjangoTemplates", 81 | "DIRS": [os.path.join(BASE_DIR, "templates")], 82 | "APP_DIRS": True, 83 | "OPTIONS": { 84 | "context_processors": [ 85 | "django.template.context_processors.debug", 86 | "django.template.context_processors.request", 87 | "django.contrib.auth.context_processors.auth", 88 | "django.contrib.messages.context_processors.messages", 89 | "guyamoe.context_processors.branding", 90 | "guyamoe.context_processors.home_branding", 91 | "guyamoe.context_processors.urls", 92 | ], 93 | }, 94 | }, 95 | ] 96 | 97 | WSGI_APPLICATION = "guyamoe.wsgi.application" 98 | 99 | 100 | # Database 101 | # https://docs.djangoproject.com/en/2.2/ref/settings/#databases 102 | 103 | DATABASES = { 104 | "default": { 105 | "ENGINE": "django.db.backends.sqlite3", 106 | "NAME": os.path.join(BASE_DIR, "db.sqlite3"), 107 | } 108 | } 109 | 110 | 111 | # Password validation 112 | # https://docs.djangoproject.com/en/2.2/ref/settings/#auth-password-validators 113 | 114 | AUTH_PASSWORD_VALIDATORS = [ 115 | { 116 | "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", 117 | }, 118 | {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",}, 119 | {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",}, 120 | {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",}, 121 | ] 122 | 123 | 124 | # Internationalization 125 | # https://docs.djangoproject.com/en/2.2/topics/i18n/ 126 | 127 | LANGUAGE_CODE = "en-us" 128 | 129 | TIME_ZONE = "UTC" 130 | 131 | USE_I18N = True 132 | 133 | USE_L10N = True 134 | 135 | USE_TZ = True 136 | 137 | # Static files (CSS, JavaScript, Images) 138 | # https://docs.djangoproject.com/en/2.2/howto/static-files/ 139 | STATIC_URL = "/static/" 140 | STATIC_ROOT = os.path.join(BASE_DIR, "static") 141 | STATICFILES_DIRS = [ 142 | os.path.join(BASE_DIR, "static_global"), 143 | ] 144 | 145 | STATIC_VERSION = "?v=" + subprocess.check_output( 146 | ["git", "-C", str(BASE_DIR), "rev-parse", "--short", "HEAD"], text=True 147 | ) 148 | 149 | MEDIA_URL = "/media/" 150 | MEDIA_ROOT = os.path.join(BASE_DIR, "media") 151 | 152 | 153 | IMGUR_CLIENT_ID = os.environ.get("IMGUR_CLIENT_ID", "") 154 | MAIL_DISCORD_WEBHOOK_ID = int(os.environ.get("MAIL_DISCORD_WEBHOOK_ID", 1)) 155 | MAIL_DISCORD_WEBHOOK_TOKEN = os.environ.get("MAIL_DISCORD_WEBHOOK_TOKEN", "") 156 | 157 | BRANDING_NAME = "Guya.moe" 158 | BRANDING_DESCRIPTION = "A place to read the entirety of the Kaguya-sama: Love is War manga. No ads. No bad reader. All guya." 159 | BRANDING_IMAGE_URL = "https://i.imgur.com/jBhT5LV.png" 160 | 161 | HOME_BRANDING_NAME = "Read the Kaguya-sama manga series | Guya.moe" 162 | HOME_BRANDING_DESCRIPTION = "Read the Kaguya-sama: Love is War / Kaguya Wants to Be Confessed To manga and spin-off series. No ads. No bad reader. All guya." 163 | HOME_BRANDING_IMAGE_URL = "https://i.imgur.com/jBhT5LV.png" 164 | 165 | IMAGE_PROXY_URL = "https://proxy.f-ck.me" 166 | -------------------------------------------------------------------------------- /proxy/source/__init__.py: -------------------------------------------------------------------------------- 1 | import abc 2 | import json 3 | from typing import List 4 | 5 | from django.conf import settings 6 | from django.http import HttpResponse 7 | from django.http.response import HttpResponseRedirect 8 | from django.shortcuts import redirect, render 9 | from django.urls import path, re_path 10 | from django.views.decorators.cache import cache_control 11 | from django.utils.html import escape 12 | 13 | from .data import * 14 | from .helpers import * 15 | 16 | def proxy_redirect(request): 17 | if request.path.endswith("/"): 18 | request.path = request.path[:-1] 19 | return HttpResponseRedirect(f"https://cubari.moe{request.path}/#redirect") 20 | 21 | class ProxySource(metaclass=abc.ABCMeta): 22 | # /proxy/:reader_prefix/slug 23 | @abc.abstractmethod 24 | def get_reader_prefix(self) -> str: 25 | raise NotImplementedError 26 | 27 | @abc.abstractmethod 28 | def shortcut_instantiator(self) -> List[re_path]: 29 | raise NotImplementedError 30 | 31 | @abc.abstractmethod 32 | def series_api_handler(self, meta_id: str) -> SeriesAPI: 33 | raise NotImplementedError 34 | 35 | @abc.abstractmethod 36 | def chapter_api_handler(self, meta_id: str) -> ChapterAPI: 37 | raise NotImplementedError 38 | 39 | @abc.abstractmethod 40 | def series_page_handler(self, meta_id: str) -> SeriesPage: 41 | raise NotImplementedError 42 | 43 | def wrap_chapter_meta(self, meta_id): 44 | return f"/proxy/api/{self.get_reader_prefix()}/chapter/{meta_id}/" 45 | 46 | def process_description(self, desc): 47 | return escape(desc) 48 | 49 | @cache_control(public=True, max_age=60, s_maxage=60) 50 | def reader_view(self, request, meta_id, chapter, page=None): 51 | return proxy_redirect(request) 52 | if page: 53 | data = self.series_api_handler(meta_id) 54 | if data: 55 | data = data.objectify() 56 | if chapter.replace("-", ".") in data["chapters"]: 57 | data["version_query"] = settings.STATIC_VERSION 58 | data["relative_url"] = f"proxy/{self.get_reader_prefix()}/{meta_id}" 59 | data["api_path"] = f"/proxy/api/{self.get_reader_prefix()}/series/" 60 | data["image_proxy_url"] = settings.IMAGE_PROXY_URL 61 | data["reader_modifier"] = f"proxy/{self.get_reader_prefix()}" 62 | data["chapter_number"] = chapter.replace("-", ".") 63 | return render(request, "reader/reader.html", data) 64 | return render(request, "homepage/thonk_500.html", status=500) 65 | else: 66 | return redirect( 67 | f"reader-{self.get_reader_prefix()}-chapter-page", meta_id, chapter, "1" 68 | ) 69 | 70 | @cache_control(public=True, max_age=60, s_maxage=60) 71 | def series_view(self, request, meta_id): 72 | return proxy_redirect(request) 73 | data = self.series_page_handler(meta_id) 74 | if data: 75 | data = data.objectify() 76 | data["synopsis"] = self.process_description(data["synopsis"]) 77 | data["version_query"] = settings.STATIC_VERSION 78 | data["relative_url"] = f"proxy/{self.get_reader_prefix()}/{meta_id}" 79 | data["reader_modifier"] = f"proxy/{self.get_reader_prefix()}" 80 | return render(request, "reader/series.html", data) 81 | else: 82 | return render(request, "homepage/thonk_500.html", status=500) 83 | 84 | @cache_control(public=True, max_age=60, s_maxage=60) 85 | def series_api_view(self, request, meta_id): 86 | return proxy_redirect(request) 87 | data = self.series_api_handler(meta_id) 88 | if data: 89 | data = data.objectify() 90 | data["description"] = self.process_description(data["description"]) 91 | return HttpResponse(json.dumps(data), content_type="application/json") 92 | else: 93 | return render(request, "homepage/thonk_500.html", status=500) 94 | 95 | @cache_control(public=True, max_age=60, s_maxage=60) 96 | def chapter_api_view(self, request, meta_id): 97 | return proxy_redirect(request) 98 | data = self.chapter_api_handler(meta_id) 99 | 100 | if data: 101 | data = data.objectify() 102 | return HttpResponse( 103 | json.dumps(data["pages"]), content_type="application/json" 104 | ) 105 | else: 106 | return render(request, "homepage/thonk_500.html", status=500) 107 | 108 | def register_api_routes(self): 109 | """Routes will be under /proxy/api/""" 110 | return [ 111 | path( 112 | f"{self.get_reader_prefix()}/series//", 113 | self.series_api_view, 114 | name=f"api-{self.get_reader_prefix()}-series-data", 115 | ), 116 | path( 117 | f"{self.get_reader_prefix()}/chapter//", 118 | self.chapter_api_view, 119 | name=f"api-{self.get_reader_prefix()}-chapter-data", 120 | ), 121 | ] 122 | 123 | def register_shortcut_routes(self): 124 | return self.shortcut_instantiator() 125 | 126 | def register_frontend_routes(self): 127 | return [ 128 | path( 129 | f"{self.get_reader_prefix()}//", 130 | self.series_view, 131 | name=f"reader-{self.get_reader_prefix()}-series-page", 132 | ), 133 | path( 134 | f"{self.get_reader_prefix()}///", 135 | self.reader_view, 136 | name=f"reader-{self.get_reader_prefix()}-chapter", 137 | ), 138 | path( 139 | f"{self.get_reader_prefix()}////", 140 | self.reader_view, 141 | name=f"reader-{self.get_reader_prefix()}-chapter-page", 142 | ), 143 | ] 144 | -------------------------------------------------------------------------------- /proxy/sources/hitomi.py: -------------------------------------------------------------------------------- 1 | import json 2 | import re 3 | from datetime import datetime 4 | 5 | from django.conf import settings 6 | from django.shortcuts import redirect 7 | from django.urls import re_path 8 | 9 | from ..source import ProxySource 10 | from ..source.data import ChapterAPI, SeriesAPI, SeriesPage 11 | from ..source.helpers import api_cache, get_wrapper 12 | 13 | 14 | class Hitomi(ProxySource): 15 | def get_reader_prefix(self): 16 | return "hitomi" 17 | 18 | def shortcut_instantiator(self): 19 | def handler(request, raw_url): 20 | series_id = self.extract_hitomi_id(raw_url) 21 | if "/reader/" in raw_url: 22 | return redirect( 23 | f"reader-{self.get_reader_prefix()}-chapter-page", 24 | series_id, 25 | "1", 26 | "1", # TODO: parse page 27 | ) 28 | else: 29 | return redirect( 30 | f"reader-{self.get_reader_prefix()}-series-page", series_id, 31 | ) 32 | 33 | return [ 34 | re_path(r"^ht/(?P[\w\d\/:.-]+)", handler), 35 | ] 36 | 37 | @staticmethod 38 | def extract_hitomi_id(url: str): 39 | return url.split("/")[-1].split("-")[-1].split(".")[-2] 40 | 41 | @staticmethod 42 | def get_gallery_subdomain(segment: str, base: str): 43 | number_of_frontends = 3 44 | g = int(segment, 16) 45 | if g < 0x30: 46 | number_of_frontends = 2 47 | if g < 0x09: 48 | g = 1 49 | return chr(97 + g % number_of_frontends) + base 50 | 51 | @staticmethod 52 | def get_page_from_obj(gallery_id, obj: dict): 53 | hsh = obj["hash"] 54 | hash_path_1 = hsh[-1] 55 | hash_path_2 = hsh[-3:-1] 56 | ext = "webp" if obj["haswebp"] else obj["name"].split(".")[-1] 57 | path = "webp" if obj["haswebp"] else "images" 58 | base = "a" if obj["haswebp"] else "b" 59 | 60 | return f"https://{Hitomi.get_gallery_subdomain(hash_path_2, base)}.hitomi.la/{path}/{hash_path_1}/{hash_path_2}/{hsh}.{ext}" 61 | 62 | @staticmethod 63 | def wrap_image_url(url): 64 | return f"{settings.IMAGE_PROXY_URL}/{url}" 65 | 66 | def ht_api_common(self, meta_id): 67 | ht_series_api = f"https://ltn.hitomi.la/galleries/{meta_id}.js" 68 | resp = get_wrapper(ht_series_api) 69 | if resp.status_code == 200: 70 | data = resp.text.replace("var galleryinfo = ", "") 71 | api_data = json.loads(data) 72 | 73 | title = api_data["title"] 74 | 75 | pages_list = [ 76 | self.wrap_image_url(self.get_page_from_obj(meta_id, page)) 77 | for page in api_data["files"] 78 | ] 79 | chapter_dict = { 80 | "1": {"volume": "1", "title": title, "groups": {"1": pages_list}} 81 | } 82 | date = datetime.strptime(f"{api_data['date']}00", "%Y-%m-%d %H:%M:%S%z") 83 | chapter_list = [ 84 | [ 85 | "1", 86 | "1", 87 | title, 88 | "1", 89 | "hitomi.la", 90 | [ 91 | date.year, 92 | date.month - 1, 93 | date.day, 94 | date.hour, 95 | date.minute, 96 | date.second, 97 | ], 98 | "1", 99 | ] 100 | ] 101 | 102 | return { 103 | "slug": meta_id, 104 | "title": api_data["title"], 105 | "description": " - ".join( 106 | [d["tag"] for d in (api_data.get("tags", []) or [])] 107 | ), 108 | "group": "", 109 | "artist": "", 110 | "author": "", 111 | "groups": {"1": "hitomi.la"}, 112 | "series": api_data["title"], 113 | "alt_titles_str": None, 114 | "cover": pages_list[0], 115 | "metadata": [ 116 | ["Type", api_data.get("type", "Unknown")], 117 | ["Language", api_data.get("language", "Unknown")], 118 | ], 119 | "chapter_dict": chapter_dict, 120 | "chapter_list": chapter_list, 121 | "pages": pages_list, 122 | } 123 | else: 124 | return None 125 | 126 | @api_cache(prefix="ht_series_dt", time=600) 127 | def series_api_handler(self, meta_id): 128 | data = self.ht_api_common(meta_id) 129 | if data: 130 | return SeriesAPI( 131 | slug=data["slug"], 132 | title=data["title"], 133 | description=data["description"], 134 | author=data["author"], 135 | artist=data["artist"], 136 | groups=data["groups"], 137 | cover=data["cover"], 138 | chapters=data["chapter_dict"], 139 | ) 140 | else: 141 | return None 142 | 143 | @api_cache(prefix="ht_chapter_dt", time=3600) 144 | def chapter_api_handler(self, meta_id): 145 | data = self.ht_api_common(meta_id) 146 | if data: 147 | return ChapterAPI(pages=data["pages"], series=data["slug"], chapter="1",) 148 | else: 149 | return None 150 | 151 | @api_cache(prefix="ht_series_page_dt", time=600) 152 | def series_page_handler(self, meta_id): 153 | data = self.ht_api_common(meta_id) 154 | if data: 155 | return SeriesPage( 156 | series=data["title"], 157 | alt_titles=[], 158 | alt_titles_str=None, 159 | slug=data["slug"], 160 | cover_vol_url=data["cover"], 161 | metadata=[], 162 | synopsis=data["description"], 163 | author=data["artist"], 164 | chapter_list=data["chapter_list"], 165 | original_url=f"https://ltn.hitomi.la/galleries/{data['slug']}.js", 166 | ) 167 | else: 168 | return None 169 | -------------------------------------------------------------------------------- /proxy/sources/gist.py: -------------------------------------------------------------------------------- 1 | import json 2 | from datetime import datetime 3 | import random 4 | 5 | from django.shortcuts import redirect 6 | from django.urls import re_path 7 | 8 | from ..source import ProxySource 9 | from ..source.data import SeriesAPI, SeriesPage 10 | from ..source.helpers import api_cache, get_wrapper 11 | 12 | """ 13 | Expected format of the raw gists should be: 14 | { 15 | "title": "", 16 | "description": "", 17 | "artist": "", 18 | "author": "", 19 | "cover": "", 20 | "chapters": { 21 | "1": { 22 | "title": "", 23 | "volume": "", 24 | "groups": { 25 | "": "", 26 | OR 27 | "": [ 28 | "", 29 | "", 30 | ... 31 | ] 32 | }, 33 | "last_updated": "", 34 | }, 35 | "2": { 36 | ... 37 | }, 38 | ... 39 | } 40 | } 41 | Make sure you pass in the shortened key from git.io. 42 | """ 43 | 44 | 45 | class Gist(ProxySource): 46 | def get_reader_prefix(self): 47 | return "gist" 48 | 49 | def shortcut_instantiator(self): 50 | def handler(request, gist_hash): 51 | # While git.io allows vanity URLs with special characters, chances are 52 | # they won't parse properly by a regular browser. So we don't deal with it 53 | return redirect(f"reader-{self.get_reader_prefix()}-series-page", gist_hash) 54 | 55 | return [ 56 | re_path(r"^(?:gist|gh)/(?P[\d\w/]+)/$", handler), 57 | ] 58 | 59 | @staticmethod 60 | def date_parser(timestamp): 61 | timestamp = int(timestamp) 62 | date = datetime.utcfromtimestamp(timestamp) 63 | return [ 64 | date.year, 65 | date.month - 1, 66 | date.day, 67 | date.hour, 68 | date.minute, 69 | date.second, 70 | ] 71 | 72 | @api_cache(prefix="gist_common_dt", time=300) 73 | def gist_common(self, meta_id): 74 | resp = get_wrapper(f"https://git.io/{meta_id}", allow_redirects=False) 75 | if resp.status_code != 302 or not resp.headers["location"]: 76 | return None 77 | resp = get_wrapper(f"{resp.headers['location']}?{random.random()}") 78 | if resp.status_code == 200 and resp.headers["content-type"].startswith( 79 | "text/plain" 80 | ): 81 | api_data = json.loads(resp.text) 82 | title = api_data.get("title") 83 | description = api_data.get("description") 84 | if not title or not description: 85 | return None 86 | 87 | artist = api_data.get("artist", "") 88 | author = api_data.get("author", "") 89 | cover = api_data.get("cover", "") 90 | 91 | groups_set = { 92 | group 93 | for ch_data in api_data["chapters"].values() 94 | for group in ch_data["groups"].keys() 95 | } 96 | groups_dict = {str(key): value for key, value in enumerate(groups_set)} 97 | groups_map = {value: str(key) for key, value in enumerate(groups_set)} 98 | 99 | chapter_dict = { 100 | ch: { 101 | "volume": data.get("volume", "Uncategorized"), 102 | "title": data.get("title", ""), 103 | "groups": { 104 | groups_map[group]: metadata 105 | for group, metadata in data["groups"].items() 106 | }, 107 | "last_updated": data.get("last_updated", None), 108 | } 109 | for ch, data in api_data["chapters"].items() 110 | } 111 | 112 | chapter_list = [ 113 | [ 114 | ch[0], 115 | ch[0], 116 | ch[1]["title"], 117 | ch[0].replace(".", "-"), 118 | "Multiple Groups" 119 | if len(ch[1]["groups"]) > 1 120 | else groups_dict[list(ch[1]["groups"].keys())[0]], 121 | "No date." 122 | if not ch[1]["last_updated"] 123 | else self.date_parser(ch[1]["last_updated"]), 124 | ch[1]["volume"], 125 | ] 126 | for ch in sorted( 127 | chapter_dict.items(), key=lambda m: float(m[0]), reverse=True 128 | ) 129 | ] 130 | 131 | for md in chapter_dict.values(): 132 | del md["last_updated"] 133 | 134 | return { 135 | "slug": meta_id, 136 | "title": title, 137 | "description": description, 138 | "series": title, 139 | "cover_vol_url": cover, 140 | "metadata": [], 141 | "author": author, 142 | "artist": artist, 143 | "groups": groups_dict, 144 | "cover": cover, 145 | "chapter_dict": chapter_dict, 146 | "chapter_list": chapter_list, 147 | } 148 | else: 149 | return None 150 | 151 | @api_cache(prefix="gist_dt", time=300) 152 | def series_api_handler(self, meta_id): 153 | data = self.gist_common(meta_id) 154 | if data: 155 | return SeriesAPI( 156 | slug=data["slug"], 157 | title=data["title"], 158 | description=data["description"], 159 | author=data["author"], 160 | artist=data["artist"], 161 | groups=data["groups"], 162 | cover=data["cover"], 163 | chapters=data["chapter_dict"], 164 | ) 165 | else: 166 | return None 167 | 168 | def chapter_api_handler(self, meta_id): 169 | pass 170 | 171 | @api_cache(prefix="gist_page_dt", time=300) 172 | def series_page_handler(self, meta_id): 173 | data = self.gist_common(meta_id) 174 | if data: 175 | return SeriesPage( 176 | series=data["title"], 177 | alt_titles=[], 178 | alt_titles_str=None, 179 | slug=data["slug"], 180 | cover_vol_url=data["cover"], 181 | metadata=[], 182 | synopsis=data["description"], 183 | author=data["artist"], 184 | chapter_list=data["chapter_list"], 185 | original_url=f"https://git.io/{meta_id}", 186 | ) 187 | else: 188 | return None 189 | -------------------------------------------------------------------------------- /proxy/sources/nhentai.py: -------------------------------------------------------------------------------- 1 | import json 2 | from datetime import datetime 3 | 4 | from django.shortcuts import redirect 5 | from django.urls import re_path 6 | 7 | from ..source import ProxySource 8 | from ..source.data import ChapterAPI, SeriesAPI, SeriesPage 9 | from ..source.helpers import api_cache, get_wrapper 10 | 11 | 12 | class NHentai(ProxySource): 13 | def get_reader_prefix(self): 14 | return "nhentai" 15 | 16 | def shortcut_instantiator(self): 17 | def handler(request, series_id, page=None): 18 | if page: 19 | return redirect( 20 | f"reader-{self.get_reader_prefix()}-chapter-page", 21 | series_id, 22 | "1", 23 | page, 24 | ) 25 | else: 26 | return redirect( 27 | f"reader-{self.get_reader_prefix()}-series-page", series_id 28 | ) 29 | 30 | def series(request, series_id): 31 | return redirect(f"reader-{self.get_reader_prefix()}-series-page", series_id) 32 | 33 | def series_chapter(request, series_id, chapter, page="1"): 34 | return redirect( 35 | f"reader-{self.get_reader_prefix()}-chapter-page", 36 | series_id, 37 | chapter, 38 | page, 39 | ) 40 | 41 | return [ 42 | re_path(r"^g/(?P[\d]{1,9})/$", handler), 43 | re_path(r"^g/(?P[\d]{1,9})/(?P[\d]{1,9})/$", handler), 44 | re_path(r"^(?:read|reader)/nh_proxy/(?P[\w\d.%-]+)/$", series), 45 | re_path( 46 | r"^(?:read|reader)/nh_proxy/(?P[\w\d.%-]+)/(?P[\d]+)/$", 47 | series_chapter, 48 | ), 49 | re_path( 50 | r"^(?:read|reader)/nh_proxy/(?P[\w\d.%-]+)/(?P[\d]+)/(?P[\d]+)/$$", 51 | series_chapter, 52 | ), 53 | ] 54 | 55 | @api_cache(prefix="nh_series_common_dt", time=3600) 56 | def nh_api_common(self, meta_id): 57 | nh_series_api = f"https://nhentai.net/api/gallery/{meta_id}" 58 | resp = get_wrapper(nh_series_api) 59 | if resp.status_code == 200: 60 | data = resp.text 61 | api_data = json.loads(data) 62 | 63 | artist = api_data["scanlator"] 64 | group = api_data["scanlator"] 65 | lang_list = [] 66 | tag_list = [] 67 | for tag in api_data["tags"]: 68 | if tag["type"] == "artist": 69 | artist = tag["name"] 70 | elif tag["type"] == "group": 71 | group = tag["name"] 72 | elif tag["type"] == "language": 73 | lang_list.append(tag["name"]) 74 | elif tag["type"] == "tag": 75 | tag_list.append(tag["name"]) 76 | 77 | pages_list = [] 78 | for p, t in enumerate(api_data["images"]["pages"]): 79 | file_format = "jpg" 80 | if t["t"] == "p": 81 | file_format = "png" 82 | if t["t"] == "g": 83 | file_format = "gif" 84 | pages_list.append( 85 | f"https://i.nhentai.net/galleries/{api_data['media_id']}/{p + 1}.{file_format}" 86 | ) 87 | 88 | groups_dict = {"1": group or "N-Hentai"} 89 | chapters_dict = { 90 | "1": { 91 | "volume": "1", 92 | "title": api_data["title"]["pretty"] 93 | or api_data["title"]["english"], 94 | "groups": {"1": pages_list}, 95 | } 96 | } 97 | 98 | return { 99 | "slug": meta_id, 100 | "title": api_data["title"]["pretty"] or api_data["title"]["english"], 101 | "description": api_data["title"]["english"], 102 | "group": group, 103 | "artist": artist, 104 | "groups": groups_dict, 105 | "tags": tag_list, 106 | "lang": ", ".join(lang_list), 107 | "chapters": chapters_dict, 108 | "cover": f"https://t.nhentai.net/galleries/{api_data['media_id']}/cover.{'jpg' if api_data['images']['cover']['t'] == 'j' else 'png'}", 109 | "timestamp": api_data["upload_date"], 110 | } 111 | else: 112 | return None 113 | 114 | @api_cache(prefix="nh_series_dt", time=3600) 115 | def series_api_handler(self, meta_id): 116 | data = self.nh_api_common(meta_id) 117 | if data: 118 | return SeriesAPI( 119 | slug=meta_id, 120 | title=data["title"], 121 | description=data["description"], 122 | author=data["artist"], 123 | artist=data["artist"], 124 | groups=data["groups"], 125 | cover=data["cover"], 126 | chapters=data["chapters"], 127 | ) 128 | else: 129 | return None 130 | 131 | @api_cache(prefix="nh_pages_dt", time=3600) 132 | def chapter_api_handler(self, meta_id): 133 | data = self.nh_api_common(meta_id) 134 | if data: 135 | return ChapterAPI( 136 | pages=data["chapters"]["1"]["groups"]["1"], 137 | series=data["slug"], 138 | chapter="1", 139 | ) 140 | else: 141 | return None 142 | 143 | @api_cache(prefix="nh_series_page_dt", time=3600) 144 | def series_page_handler(self, meta_id): 145 | data = self.nh_api_common(meta_id) 146 | if data: 147 | date = datetime.utcfromtimestamp(data["timestamp"]) 148 | chapter_list = [ 149 | [ 150 | "1", 151 | "1", 152 | data["title"], 153 | "1", 154 | data["group"] or "NHentai", 155 | [ 156 | date.year, 157 | date.month - 1, 158 | date.day, 159 | date.hour, 160 | date.minute, 161 | date.second, 162 | ], 163 | "1", 164 | ] 165 | ] 166 | return SeriesPage( 167 | series=data["title"], 168 | alt_titles=[], 169 | alt_titles_str=None, 170 | slug=data["slug"], 171 | cover_vol_url=data["cover"], 172 | metadata=[["Author", data["artist"]], ["Artist", data["artist"]]], 173 | synopsis=f"{data['description']}\n\n{' - '.join(data['tags'])}", 174 | author=data["artist"], 175 | chapter_list=chapter_list, 176 | original_url=f"https://nhentai.net/g/{meta_id}/", 177 | ) 178 | else: 179 | return None 180 | -------------------------------------------------------------------------------- /proxy/sources/imgur.py: -------------------------------------------------------------------------------- 1 | import json 2 | import re 3 | import random 4 | from datetime import datetime 5 | 6 | from django.conf import settings 7 | from django.shortcuts import redirect 8 | from django.urls import re_path 9 | 10 | from ..source import ProxySource 11 | from ..source.data import ChapterAPI, SeriesAPI, SeriesPage 12 | from ..source.helpers import api_cache, get_wrapper 13 | 14 | 15 | class Imgur(ProxySource): 16 | def get_reader_prefix(self): 17 | return "imgur" 18 | 19 | def shortcut_instantiator(self): 20 | def handler(request, album_hash): 21 | return redirect( 22 | f"reader-{self.get_reader_prefix()}-chapter-page", 23 | album_hash, 24 | "1", 25 | "1", 26 | ) 27 | 28 | return [ 29 | re_path(r"^(?:a|gallery)/(?P[\d\w]+)/$", handler), 30 | ] 31 | 32 | @staticmethod 33 | def image_url_handler(metadata): 34 | return ( 35 | metadata["link"] + "?_w." 36 | if metadata.get("width", 0) > metadata.get("height", 0) 37 | else metadata["link"] 38 | ) 39 | 40 | def imgur_api_common(self, meta_id): 41 | """Backup handler using the API. It consumes the API key so be wary.""" 42 | resp = get_wrapper( 43 | f"https://api.imgur.com/3/album/{meta_id}", 44 | headers={"Authorization": f"Client-ID {settings.IMGUR_CLIENT_ID}"}, 45 | ) 46 | if resp.status_code == 200: 47 | api_data = resp.json()["data"] 48 | date = datetime.utcfromtimestamp(api_data["datetime"]) 49 | return { 50 | "slug": meta_id, 51 | "title": api_data["title"] or "Untitled", 52 | "description": api_data["description"] or "No description.", 53 | "author": api_data["account_id"] or "Unknown", 54 | "artist": api_data["account_id"] or "Unknown", 55 | "cover": api_data["images"][0]["link"], 56 | "groups": {"1": "Imgur"}, 57 | "chapter_dict": { 58 | "1": { 59 | "volume": "1", 60 | "title": api_data["title"] or "No title.", 61 | "groups": { 62 | "1": [ 63 | { 64 | "description": obj["description"] or "", 65 | "src": self.image_url_handler(obj), 66 | } 67 | for obj in api_data["images"] 68 | ] 69 | }, 70 | } 71 | }, 72 | "chapter_list": [ 73 | [ 74 | "1", 75 | "1", 76 | api_data["title"] or "Untitled", 77 | "1", 78 | api_data["account_id"] or "No group", 79 | [ 80 | date.year, 81 | date.month - 1, 82 | date.day, 83 | date.hour, 84 | date.minute, 85 | date.second, 86 | ], 87 | "1", 88 | ], 89 | ], 90 | "pages_list": [ 91 | self.image_url_handler(obj) for obj in api_data["images"] 92 | ], 93 | "original_url": api_data["link"], 94 | } 95 | else: 96 | return None 97 | 98 | def imgur_embed_common(self, meta_id): 99 | resp = get_wrapper( 100 | f"https://imgur.com/a/{meta_id}/embed?cache_buster={random.random()}" 101 | ) 102 | if resp.status_code == 200: 103 | data = re.search( 104 | r"(?:album[\s]+?: )([\s\S]+)(?:,[\s]+?images[\s]+?:)", resp.text 105 | ) 106 | api_data = json.loads(data.group(1)) 107 | try: 108 | date = datetime.strptime(api_data["datetime"], "%Y-%m-%d %H:%M:%S") 109 | except ValueError: 110 | date = datetime.now() 111 | images = api_data["album_images"]["images"] 112 | for image in images: 113 | image["link"] = f"https://i.imgur.com/{image['hash']}{image['ext']}" 114 | return { 115 | "slug": meta_id, 116 | "title": api_data["title"] or "Untitled", 117 | "description": api_data["description"] or "No description.", 118 | "author": "Unknown", 119 | "artist": "Unknown", 120 | "cover": images[0]["link"], 121 | "groups": {"1": "Imgur"}, 122 | "chapter_dict": { 123 | "1": { 124 | "volume": "1", 125 | "title": api_data["title"] or "No title.", 126 | "groups": { 127 | "1": [ 128 | { 129 | "description": obj["description"] or "", 130 | "src": self.image_url_handler(obj), 131 | } 132 | for obj in images 133 | ] 134 | }, 135 | } 136 | }, 137 | "chapter_list": [ 138 | [ 139 | "1", 140 | "1", 141 | api_data["title"] or "Untitled", 142 | "1", 143 | "No group", 144 | [ 145 | date.year, 146 | date.month - 1, 147 | date.day, 148 | date.hour, 149 | date.minute, 150 | date.second, 151 | ], 152 | "1", 153 | ], 154 | ], 155 | "pages_list": [self.image_url_handler(obj) for obj in images], 156 | "original_url": f"https://imgur.com/a/{api_data['id']}", 157 | } 158 | else: 159 | return None 160 | 161 | @api_cache(prefix="imgur_api_dt", time=300) 162 | def imgur_common(self, meta_id): 163 | return self.imgur_embed_common(meta_id) 164 | 165 | @api_cache(prefix="imgur_series_dt", time=300) 166 | def series_api_handler(self, meta_id): 167 | data = self.imgur_common(meta_id) 168 | return ( 169 | SeriesAPI( 170 | slug=data["slug"], 171 | title=data["title"], 172 | description=data["description"], 173 | author=data["author"], 174 | artist=data["artist"], 175 | groups=data["groups"], 176 | cover=data["cover"], 177 | chapters=data["chapter_dict"], 178 | ) 179 | if data 180 | else None 181 | ) 182 | 183 | @api_cache(prefix="imgur_pages_dt", time=300) 184 | def chapter_api_handler(self, meta_id): 185 | data = self.imgur_common(meta_id) 186 | return ( 187 | ChapterAPI( 188 | pages=data["pages_list"], series=data["slug"], chapter=data["slug"] 189 | ) 190 | if data 191 | else None 192 | ) 193 | 194 | @api_cache(prefix="imgur_series_page_dt", time=300) 195 | def series_page_handler(self, meta_id): 196 | data = self.imgur_common(meta_id) 197 | return ( 198 | SeriesPage( 199 | series=data["title"], 200 | alt_titles=[], 201 | alt_titles_str=None, 202 | slug=data["slug"], 203 | cover_vol_url=data["cover"], 204 | metadata=[], 205 | synopsis=data["description"], 206 | author=data["author"], 207 | chapter_list=data["chapter_list"], 208 | original_url=data["original_url"], 209 | ) 210 | if data 211 | else None 212 | ) 213 | -------------------------------------------------------------------------------- /reader/static/external/css/pickr.nano@1-8-1.css: -------------------------------------------------------------------------------- 1 | /*! Pickr 1.8.1 MIT | https://github.com/Simonwep/pickr */.pickr{position:relative;overflow:visible;transform:translateY(0)}.pickr *{box-sizing:border-box;outline:none;border:none;-webkit-appearance:none}.pickr .pcr-button{position:relative;height:2em;width:2em;padding:.5em;cursor:pointer;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,sans-serif;border-radius:.15em;background:url('data:image/svg+xml;utf8, ') no-repeat 50%;background-size:0;transition:all .3s}.pickr .pcr-button:before{background:url('data:image/svg+xml;utf8, ');background-size:.5em;z-index:-1;z-index:auto}.pickr .pcr-button:after,.pickr .pcr-button:before{position:absolute;content:"";top:0;left:0;width:100%;height:100%;border-radius:.15em}.pickr .pcr-button:after{transition:background .3s;background:var(--pcr-color)}.pickr .pcr-button.clear{background-size:70%}.pickr .pcr-button.clear:before{opacity:0}.pickr .pcr-button.clear:focus{box-shadow:0 0 0 1px hsla(0,0%,100%,.85),0 0 0 3px var(--pcr-color)}.pickr .pcr-button.disabled{cursor:not-allowed}.pcr-app *,.pickr *{box-sizing:border-box;outline:none;border:none;-webkit-appearance:none}.pcr-app button.pcr-active,.pcr-app button:focus,.pcr-app input.pcr-active,.pcr-app input:focus,.pickr button.pcr-active,.pickr button:focus,.pickr input.pcr-active,.pickr input:focus{box-shadow:0 0 0 1px hsla(0,0%,100%,.85),0 0 0 3px var(--pcr-color)}.pcr-app .pcr-palette,.pcr-app .pcr-slider,.pickr .pcr-palette,.pickr .pcr-slider{transition:box-shadow .3s}.pcr-app .pcr-palette:focus,.pcr-app .pcr-slider:focus,.pickr .pcr-palette:focus,.pickr .pcr-slider:focus{box-shadow:0 0 0 1px hsla(0,0%,100%,.85),0 0 0 3px rgba(0,0,0,.25)}.pcr-app{position:fixed;display:flex;flex-direction:column;z-index:10000;border-radius:.1em;background:#fff;opacity:0;visibility:hidden;transition:opacity .3s,visibility 0s .3s;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,sans-serif;box-shadow:0 .15em 1.5em 0 rgba(0,0,0,.1),0 0 1em 0 rgba(0,0,0,.03);left:0;top:0}.pcr-app.visible{transition:opacity .3s;visibility:visible;opacity:1}.pcr-app .pcr-swatches{display:flex;flex-wrap:wrap;margin-top:.75em}.pcr-app .pcr-swatches.pcr-last{margin:0}@supports (display:grid){.pcr-app .pcr-swatches{display:grid;align-items:center;grid-template-columns:repeat(auto-fit,1.75em)}}.pcr-app .pcr-swatches>button{font-size:1em;position:relative;width:calc(1.75em - 5px);height:calc(1.75em - 5px);border-radius:.15em;cursor:pointer;margin:2.5px;flex-shrink:0;justify-self:center;transition:all .15s;overflow:hidden;background:transparent;z-index:1}.pcr-app .pcr-swatches>button:before{position:absolute;content:"";top:0;left:0;width:100%;height:100%;background:url('data:image/svg+xml;utf8, ');background-size:6px;border-radius:.15em;z-index:-1}.pcr-app .pcr-swatches>button:after{content:"";position:absolute;top:0;left:0;width:100%;height:100%;background:var(--pcr-color);border:1px solid rgba(0,0,0,.05);border-radius:.15em;box-sizing:border-box}.pcr-app .pcr-swatches>button:hover{filter:brightness(1.05)}.pcr-app .pcr-swatches>button:not(.pcr-active){box-shadow:none}.pcr-app .pcr-interaction{display:flex;flex-wrap:wrap;align-items:center;margin:0 -.2em}.pcr-app .pcr-interaction>*{margin:0 .2em}.pcr-app .pcr-interaction input{letter-spacing:.07em;font-size:.75em;text-align:center;cursor:pointer;color:#75797e;background:#f1f3f4;border-radius:.15em;transition:all .15s;padding:.45em .5em;margin-top:.75em}.pcr-app .pcr-interaction input:hover{filter:brightness(.975)}.pcr-app .pcr-interaction input:focus{box-shadow:0 0 0 1px hsla(0,0%,100%,.85),0 0 0 3px rgba(66,133,244,.75)}.pcr-app .pcr-interaction .pcr-result{color:#75797e;text-align:left;flex:1 1 8em;min-width:8em;transition:all .2s;border-radius:.15em;background:#f1f3f4;cursor:text}.pcr-app .pcr-interaction .pcr-result::-moz-selection{background:#4285f4;color:#fff}.pcr-app .pcr-interaction .pcr-result::selection{background:#4285f4;color:#fff}.pcr-app .pcr-interaction .pcr-type.active{color:#fff;background:#4285f4}.pcr-app .pcr-interaction .pcr-cancel,.pcr-app .pcr-interaction .pcr-clear,.pcr-app .pcr-interaction .pcr-save{width:auto;color:#fff}.pcr-app .pcr-interaction .pcr-cancel:hover,.pcr-app .pcr-interaction .pcr-clear:hover,.pcr-app .pcr-interaction .pcr-save:hover{filter:brightness(.925)}.pcr-app .pcr-interaction .pcr-save{background:#4285f4}.pcr-app .pcr-interaction .pcr-cancel,.pcr-app .pcr-interaction .pcr-clear{background:#f44250}.pcr-app .pcr-interaction .pcr-cancel:focus,.pcr-app .pcr-interaction .pcr-clear:focus{box-shadow:0 0 0 1px hsla(0,0%,100%,.85),0 0 0 3px rgba(244,66,80,.75)}.pcr-app .pcr-selection .pcr-picker{position:absolute;height:18px;width:18px;border:2px solid #fff;border-radius:100%;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.pcr-app .pcr-selection .pcr-color-chooser,.pcr-app .pcr-selection .pcr-color-opacity,.pcr-app .pcr-selection .pcr-color-palette{position:relative;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;display:flex;flex-direction:column;cursor:grab;cursor:-webkit-grab}.pcr-app .pcr-selection .pcr-color-chooser:active,.pcr-app .pcr-selection .pcr-color-opacity:active,.pcr-app .pcr-selection .pcr-color-palette:active{cursor:grabbing;cursor:-webkit-grabbing}.pcr-app[data-theme=nano]{width:14.25em;max-width:95vw}.pcr-app[data-theme=nano] .pcr-swatches{margin-top:.6em;padding:0 .6em}.pcr-app[data-theme=nano] .pcr-interaction{padding:0 .6em .6em}.pcr-app[data-theme=nano] .pcr-selection{display:grid;grid-gap:.6em;grid-template-columns:1fr 4fr;grid-template-rows:5fr auto auto;align-items:center;height:10.5em;width:100%;align-self:flex-start}.pcr-app[data-theme=nano] .pcr-selection .pcr-color-preview{grid-area:2/1/4/1;height:100%;width:100%;display:flex;flex-direction:row;justify-content:center;margin-left:.6em}.pcr-app[data-theme=nano] .pcr-selection .pcr-color-preview .pcr-last-color{display:none}.pcr-app[data-theme=nano] .pcr-selection .pcr-color-preview .pcr-current-color{position:relative;background:var(--pcr-color);width:2em;height:2em;border-radius:50em;overflow:hidden}.pcr-app[data-theme=nano] .pcr-selection .pcr-color-preview .pcr-current-color:before{position:absolute;content:"";top:0;left:0;width:100%;height:100%;background:url('data:image/svg+xml;utf8, ');background-size:.5em;border-radius:.15em;z-index:-1}.pcr-app[data-theme=nano] .pcr-selection .pcr-color-palette{grid-area:1/1/2/3;width:100%;height:100%;z-index:1}.pcr-app[data-theme=nano] .pcr-selection .pcr-color-palette .pcr-palette{border-radius:.15em;width:100%;height:100%}.pcr-app[data-theme=nano] .pcr-selection .pcr-color-palette .pcr-palette:before{position:absolute;content:"";top:0;left:0;width:100%;height:100%;background:url('data:image/svg+xml;utf8, ');background-size:.5em;border-radius:.15em;z-index:-1}.pcr-app[data-theme=nano] .pcr-selection .pcr-color-chooser{grid-area:2/2/2/2}.pcr-app[data-theme=nano] .pcr-selection .pcr-color-opacity{grid-area:3/2/3/2}.pcr-app[data-theme=nano] .pcr-selection .pcr-color-chooser,.pcr-app[data-theme=nano] .pcr-selection .pcr-color-opacity{height:.5em;margin:0 .6em}.pcr-app[data-theme=nano] .pcr-selection .pcr-color-chooser .pcr-picker,.pcr-app[data-theme=nano] .pcr-selection .pcr-color-opacity .pcr-picker{top:50%;transform:translateY(-50%)}.pcr-app[data-theme=nano] .pcr-selection .pcr-color-chooser .pcr-slider,.pcr-app[data-theme=nano] .pcr-selection .pcr-color-opacity .pcr-slider{flex-grow:1;border-radius:50em}.pcr-app[data-theme=nano] .pcr-selection .pcr-color-chooser .pcr-slider{background:linear-gradient(90deg,red,#ff0,#0f0,#0ff,#00f,#f0f,red)}.pcr-app[data-theme=nano] .pcr-selection .pcr-color-opacity .pcr-slider{background:linear-gradient(90deg,transparent,#000),url('data:image/svg+xml;utf8, ');background-size:100%,.25em} -------------------------------------------------------------------------------- /proxy/sources/mangabox.py: -------------------------------------------------------------------------------- 1 | import json 2 | import re 3 | from datetime import datetime 4 | 5 | from bs4 import BeautifulSoup 6 | from django.conf import settings 7 | from django.shortcuts import redirect 8 | from django.urls import re_path 9 | 10 | from ..source import ProxySource 11 | from ..source.data import ChapterAPI, SeriesAPI, SeriesPage 12 | from ..source.helpers import api_cache, decode, encode, get_wrapper 13 | 14 | 15 | class MangaBox(ProxySource): 16 | def get_reader_prefix(self): 17 | return "mangabox" 18 | 19 | def shortcut_instantiator(self): 20 | def handler(request, raw_url): 21 | if "/chapter/" in raw_url: 22 | canonical_chapter = self.parse_chapter(raw_url) 23 | return redirect( 24 | f"reader-{self.get_reader_prefix()}-chapter-page", 25 | encode(self.normalize_slug(raw_url)), 26 | canonical_chapter, 27 | "1", 28 | ) 29 | else: 30 | return redirect( 31 | f"reader-{self.get_reader_prefix()}-series-page", 32 | encode(self.normalize_slug(raw_url)), 33 | ) 34 | 35 | return [ 36 | re_path(r"^mb/(?P[\w\d\/:.-]+)", handler), 37 | ] 38 | 39 | @staticmethod 40 | def wrap_image_url(url): 41 | return f"{settings.IMAGE_PROXY_URL}/{url}" 42 | 43 | @staticmethod 44 | def normalize_slug(denormal): 45 | if "/chapter/" in denormal: 46 | return "https://" + "/".join( 47 | [ 48 | part 49 | for part in denormal.replace("/chapter/", "/manga/").split("/") 50 | if part 51 | ][1 if denormal.startswith("http") else 0 : -1] 52 | ) 53 | else: 54 | return denormal 55 | 56 | @staticmethod 57 | def construct_url(raw): 58 | decoded = decode(raw) 59 | return ("" if decoded.startswith("http") else "https://") + decoded 60 | 61 | @staticmethod 62 | def parse_chapter(raw_url): 63 | return [ch for ch in raw_url.split("/") if ch][-1].replace("chapter_", "") 64 | 65 | def mb_scrape_common(self, meta_id): 66 | decoded_url = self.construct_url(meta_id) 67 | resp = get_wrapper(decoded_url) 68 | # There's a page redirect sometimes 69 | if resp.status_code == 200 and "window.location.assign" in resp.text: 70 | decoded_url = re.findall( 71 | r"(?:window.location.assign\(\")([\s\S]+)(?:\"\))", resp.text 72 | ) 73 | if decoded_url: 74 | resp = get_wrapper(decoded_url[0]) 75 | else: 76 | return None 77 | if resp.status_code == 200: 78 | data = resp.text 79 | soup = BeautifulSoup(data, "html.parser") 80 | elems = soup.select("div.manga-info-top, div.panel-story-info") 81 | if not elems: 82 | return None 83 | metadata = BeautifulSoup(str(elems), "html.parser") 84 | try: 85 | title = metadata.select_one("h1, h2").text 86 | except AttributeError: 87 | return None 88 | try: 89 | author = ( 90 | metadata.find( 91 | lambda element: element.name == "td" 92 | and "author" in element.text.lower() 93 | ) 94 | .findNext("td") 95 | .text 96 | ) 97 | except AttributeError: 98 | author = "None" 99 | try: 100 | description = soup.select_one( 101 | "div#noidungm, div#panel-story-info-description" 102 | ).text.strip() 103 | except AttributeError: 104 | description = "No description." 105 | try: 106 | cover = metadata.select_one( 107 | "div.manga-info-pic img, span.info-image img" 108 | )["src"] 109 | except AttributeError: 110 | cover = "" 111 | 112 | groups_dict = {"1": "MangaBox"} 113 | 114 | chapter_list = [] 115 | chapter_dict = {} 116 | chapters = list( 117 | map( 118 | lambda a: [ 119 | a.select_one("a")["title"], 120 | a.select_one("a")["href"], 121 | a.select("span")[2].text 122 | if len(a.select("span")) >= 3 123 | else "No date.", 124 | ], 125 | soup.select("div.chapter-list div.row, ul.row-content-chapter li"), 126 | ) 127 | ) 128 | for chapter in chapters: 129 | canonical_chapter = self.parse_chapter(chapter[1]) 130 | chapter_list.append( 131 | [ 132 | "", 133 | canonical_chapter, 134 | chapter[0], 135 | canonical_chapter.replace(".", "-"), 136 | "MangaBox", 137 | chapter[2], 138 | "", 139 | ] 140 | ) 141 | chapter_dict[canonical_chapter] = { 142 | "volume": "1", 143 | "title": chapter[0], 144 | "groups": {"1": self.wrap_chapter_meta(encode(chapter[1]))}, 145 | } 146 | 147 | return { 148 | "slug": meta_id, 149 | "title": title, 150 | "description": description, 151 | "series": title, 152 | "alt_titles_str": None, 153 | "cover_vol_url": cover, 154 | "metadata": [], 155 | "author": author, 156 | "artist": author, 157 | "groups": groups_dict, 158 | "cover": cover, 159 | "chapter_dict": chapter_dict, 160 | "chapter_list": chapter_list, 161 | } 162 | else: 163 | return None 164 | 165 | @api_cache(prefix="mb_series_dt", time=600) 166 | def series_api_handler(self, meta_id): 167 | data = self.mb_scrape_common(meta_id) 168 | if data: 169 | return SeriesAPI( 170 | slug=data["slug"], 171 | title=data["title"], 172 | description=data["description"], 173 | author=data["author"], 174 | artist=data["artist"], 175 | groups=data["groups"], 176 | cover=data["cover"], 177 | chapters=data["chapter_dict"], 178 | ) 179 | else: 180 | return None 181 | 182 | @api_cache(prefix="mb_chapter_dt", time=3600) 183 | def chapter_api_handler(self, meta_id): 184 | decoded_url = self.construct_url(meta_id) 185 | resp = get_wrapper(decoded_url) 186 | if resp.status_code == 200: 187 | data = resp.text 188 | soup = BeautifulSoup(data, "html.parser") 189 | pages = [ 190 | src 191 | for src in map( 192 | lambda a: self.wrap_image_url(a["src"]), 193 | soup.select("div#vungdoc img, div.container-chapter-reader img"), 194 | ) 195 | if not src.endswith("log") 196 | ] 197 | return ChapterAPI(pages=pages, series=meta_id, chapter="") 198 | else: 199 | return None 200 | 201 | @api_cache(prefix="mb_series_page_dt", time=600) 202 | def series_page_handler(self, meta_id): 203 | data = self.mb_scrape_common(meta_id) 204 | original_url = decode(meta_id) 205 | if not original_url.startswith("http"): 206 | original_url = "https://" + original_url 207 | if data: 208 | return SeriesPage( 209 | series=data["title"], 210 | alt_titles=[], 211 | alt_titles_str=None, 212 | slug=data["slug"], 213 | cover_vol_url=data["cover"], 214 | metadata=[], 215 | synopsis=data["description"], 216 | author=data["artist"], 217 | chapter_list=data["chapter_list"], 218 | original_url=original_url, 219 | ) 220 | else: 221 | return None 222 | -------------------------------------------------------------------------------- /templates/layout.html: -------------------------------------------------------------------------------- 1 | {% load static %} 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | {% if canonical_url %} 14 | 15 | {% else %} 16 | 17 | {% endif %} 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | {% block meta %} 30 | {% if page_title %} 31 | {{ page_title }} | {{ brand.name }} 32 | {% else %} 33 | {% if template == "home" %} 34 | {{ home_brand.name }} 35 | 36 | 37 | 38 | {% else %} 39 | {{ brand.name }} 40 | 41 | 42 | 43 | {% endif %} 44 | {% endif %} 45 | 46 | 47 | 48 | {% if embed_image %} 49 | 50 | 51 | {% else %} 52 | 53 | 54 | {% endif %} 55 | 56 | {% endblock %} 57 | {% block head %} {% endblock %} 58 | {% include "history.html" %} 59 | 60 | 61 | 62 | {% include "tracking.html" %} 63 | {% load cache %} {% cache 1 sidebar %} 64 |
65 | 127 |
128 | 158 | {% endcache %} {% block body %} {% endblock %} 159 | 175 | 176 | 177 | -------------------------------------------------------------------------------- /reader/templates/reader/reader.html: -------------------------------------------------------------------------------- 1 | {% load static %} 2 | 3 | 4 | 5 | 6 | 7 | {{ series_name }} | Chapter {{ chapter_number }} | {{ brand.name }} 8 | 9 | 10 | 11 | 12 | {% if first_party %} 13 | 15 | 17 | {% if embed_image %} 18 | 19 | 20 | {% else %} 21 | 22 | 23 | {% endif %} 24 | 25 | {% else %} 26 | 28 | 30 | 31 | 32 | {% endif %} 33 | 34 | 35 | 36 | 37 | 38 | 39 | {% include "history.html" %} 40 | 41 | 48 | 49 | 50 | {% include "tracking.html" %} 51 |
52 | 121 |
122 |
123 | 124 |
125 |
126 |
127 |
128 | 129 |
130 | 131 |
132 |
133 |
134 | 139 |
140 |
141 |
142 |
143 |
144 |
145 |
146 |
147 |
148 |
149 |
150 |
151 |
152 |
153 |
154 |
155 |
156 |
157 |
158 |
159 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 173 | 174 | -------------------------------------------------------------------------------- /reader/models.py: -------------------------------------------------------------------------------- 1 | import os 2 | from datetime import datetime, timezone 3 | from random import randint 4 | 5 | from django.conf import settings 6 | from django.contrib.contenttypes.fields import GenericForeignKey 7 | from django.contrib.contenttypes.models import ContentType 8 | from django.db import models 9 | 10 | MANGADEX = "MD" 11 | SCRAPING_SOURCES = ((MANGADEX, "MangaDex"),) 12 | 13 | 14 | class HitCount(models.Model): 15 | content = GenericForeignKey("content_type", "object_id") 16 | content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) 17 | object_id = models.PositiveIntegerField() 18 | hits = models.PositiveIntegerField(("Hits"), default=0) 19 | 20 | 21 | class Person(models.Model): 22 | name = models.CharField(max_length=200) 23 | 24 | def __str__(self): 25 | return self.name 26 | 27 | 28 | class Group(models.Model): 29 | name = models.CharField(max_length=200) 30 | 31 | def __str__(self): 32 | return self.name 33 | 34 | 35 | def embed_image_path(instance, filename): 36 | return os.path.join("manga", instance.slug, "static", str(filename)) 37 | 38 | 39 | def new_volume_folder(instance): 40 | return os.path.join( 41 | "manga", instance.series.slug, "volume_covers", str(instance.volume_number), 42 | ) 43 | 44 | 45 | def new_volume_path_file_name(instance, filename): 46 | _, ext = os.path.splitext(filename) 47 | new_filename = str(randint(10000, 99999)) + ext 48 | return os.path.join(new_volume_folder(instance), new_filename,) 49 | 50 | 51 | class Series(models.Model): 52 | name = models.CharField(max_length=200, db_index=True) 53 | slug = models.SlugField(unique=True, max_length=200) 54 | author = models.ForeignKey( 55 | Person, 56 | blank=True, 57 | null=True, 58 | on_delete=models.SET_NULL, 59 | related_name="series_author", 60 | ) 61 | artist = models.ForeignKey( 62 | Person, 63 | blank=True, 64 | null=True, 65 | on_delete=models.SET_NULL, 66 | related_name="series_artist", 67 | ) 68 | synopsis = models.TextField(blank=True, null=True) 69 | alternative_titles = models.TextField(blank=True, null=True) 70 | next_release_page = models.BooleanField(default=False) 71 | next_release_time = models.DateTimeField( 72 | default=None, blank=True, null=True, db_index=True 73 | ) 74 | next_release_html = models.TextField(blank=True, null=True) 75 | indexed = models.BooleanField(default=False) 76 | preferred_sort = models.CharField(max_length=200, blank=True, null=True) 77 | scraping_enabled = models.BooleanField(default=False) 78 | scraping_source = models.CharField( 79 | max_length=2, choices=SCRAPING_SOURCES, default=MANGADEX 80 | ) 81 | scraping_identifiers = models.TextField(blank=True, null=True) 82 | use_latest_vol_cover_for_embed = models.BooleanField(default=False) 83 | embed_image = models.ImageField(upload_to=embed_image_path, blank=True, null=True) 84 | canonical_series_url_filler = models.CharField( 85 | max_length=24, 86 | blank=True, 87 | null=True, 88 | help_text="Adds this text to the canonical url of the series' series page. Useful to avoid blacklists of... many varieties.", 89 | ) 90 | 91 | def __str__(self): 92 | return self.name 93 | 94 | def get_absolute_url(self): 95 | if self.canonical_series_url_filler: 96 | return f"/read/manga/{self.canonical_series_url_filler}/{self.slug}/" 97 | else: 98 | return f"/read/manga/{self.slug}/" 99 | 100 | def get_latest_volume_cover_path(self): 101 | vols = Volume.objects.filter(series=self).order_by("-volume_number") 102 | for vol in vols: 103 | if vol.volume_cover: 104 | cover_vol_url = f"/media/{vol.volume_cover}" 105 | return cover_vol_url, cover_vol_url.rsplit(".", 1)[0] + ".webp" 106 | else: 107 | return "", "" 108 | 109 | def get_embed_image_path(self): 110 | if self.use_latest_vol_cover_for_embed: 111 | embed_image, _ = self.get_latest_volume_cover_path() 112 | return embed_image 113 | elif self.embed_image: 114 | return f"/media/{self.embed_image}" 115 | else: 116 | return "" 117 | 118 | class Meta: 119 | ordering = ("name",) 120 | verbose_name_plural = "series" 121 | 122 | 123 | class Volume(models.Model): 124 | volume_number = models.PositiveIntegerField(blank=False, null=False, db_index=True) 125 | series = models.ForeignKey( 126 | Series, blank=False, null=False, on_delete=models.CASCADE 127 | ) 128 | volume_cover = models.ImageField(blank=True, upload_to=new_volume_path_file_name) 129 | 130 | class Meta: 131 | unique_together = ( 132 | "volume_number", 133 | "series", 134 | ) 135 | 136 | 137 | class Chapter(models.Model): 138 | series = models.ForeignKey(Series, on_delete=models.CASCADE) 139 | title = models.CharField(max_length=200, blank=True) 140 | chapter_number = models.FloatField(blank=False, null=False, db_index=True) 141 | folder = models.CharField(max_length=255, blank=True, null=True) 142 | volume = models.PositiveSmallIntegerField( 143 | blank=True, null=True, default=None, db_index=True 144 | ) 145 | group = models.ForeignKey(Group, null=True, on_delete=models.SET_NULL) 146 | uploaded_on = models.DateTimeField( 147 | default=None, blank=True, null=True, db_index=True 148 | ) 149 | updated_on = models.DateTimeField( 150 | default=None, blank=True, null=True, db_index=True 151 | ) 152 | version = models.PositiveSmallIntegerField(blank=True, null=True, default=None) 153 | preferred_sort = models.CharField(max_length=200, blank=True, null=True) 154 | scraper_hash = models.CharField(max_length=32, blank=True) 155 | reprocess_metadata = models.BooleanField( 156 | default=False, 157 | help_text="Check this and save to recreate/reprocess other chapter data (preview versions of the chapter and chapter index). This field will automatically uncheck on save. Chapter reindexing will be kicked off in the background.", 158 | ) 159 | 160 | def clean_chapter_number(self): 161 | return ( 162 | str(int(self.chapter_number)) 163 | if self.chapter_number % 1 == 0 164 | else str(self.chapter_number) 165 | ) 166 | 167 | def slug_chapter_number(self): 168 | return self.clean_chapter_number().replace(".", "-") 169 | 170 | def get_chapter_time(self): 171 | upload_date = self.uploaded_on 172 | upload_time = ( 173 | datetime.utcnow().replace(tzinfo=timezone.utc) - upload_date 174 | ).total_seconds() 175 | days = int(upload_time // (24 * 3600)) 176 | upload_time = upload_time % (24 * 3600) 177 | hours = int(upload_time // 3600) 178 | upload_time %= 3600 179 | minutes = int(upload_time // 60) 180 | upload_time %= 60 181 | seconds = int(upload_time) 182 | if days == 0 and hours == 0 and minutes == 0: 183 | upload_date = f"{seconds} second{'s' if seconds != 1 else ''} ago" 184 | elif days == 0 and hours == 0: 185 | upload_date = f"{minutes} min{'s' if minutes != 1 else ''} ago" 186 | elif days == 0: 187 | upload_date = f"{hours} hour{'s' if hours != 1 else ''} ago" 188 | elif days < 7: 189 | upload_date = f"{days} day{'s' if days != 1 else ''} ago" 190 | else: 191 | upload_date = upload_date.strftime("%Y-%m-%d") 192 | return upload_date 193 | 194 | def __str__(self): 195 | return f"{self.chapter_number} - {self.title} | {self.group}" 196 | 197 | def get_absolute_url(self): 198 | return f"/read/manga/{self.series.slug}/{Chapter.slug_chapter_number(self)}/1" 199 | 200 | class Meta: 201 | ordering = ("chapter_number",) 202 | unique_together = ( 203 | "chapter_number", 204 | "series", 205 | "group", 206 | ) 207 | 208 | 209 | class ChapterIndex(models.Model): 210 | word = models.CharField(max_length=128, db_index=True) 211 | chapter_and_pages = models.TextField() 212 | series = models.ForeignKey(Series, on_delete=models.CASCADE) 213 | 214 | def __str__(self): 215 | return self.word 216 | 217 | class Meta: 218 | unique_together = ( 219 | "word", 220 | "series", 221 | ) 222 | 223 | def path_file_name(instance, filename): 224 | return os.path.join( 225 | "manga", 226 | instance.series.slug, 227 | "volume_covers", 228 | str(instance.volume_number), 229 | filename, 230 | ) 231 | -------------------------------------------------------------------------------- /reader/signals.py: -------------------------------------------------------------------------------- 1 | import os 2 | import shutil 3 | from datetime import datetime, timedelta, timezone 4 | 5 | from django.conf import settings 6 | from django.contrib.contenttypes.models import ContentType 7 | from django.db.models.signals import post_delete, post_save, pre_save, post_init 8 | from django.dispatch import receiver 9 | from PIL import Image, ImageFilter 10 | 11 | from api.api import clear_pages_cache, chapter_post_process, delete_chapter_pages_if_exists 12 | from reader.models import Chapter, HitCount, Series, Volume, new_volume_folder 13 | 14 | 15 | @receiver(post_delete, sender=Series) 16 | def delete_series_hitcount(sender, instance, **kwargs): 17 | series = ContentType.objects.get(app_label="reader", model="series") 18 | hit_count_obj = HitCount.objects.filter( 19 | content_type=series, object_id=instance.id 20 | ).first() 21 | if hit_count_obj: 22 | hit_count_obj.delete() 23 | 24 | 25 | @receiver(post_delete, sender=Chapter) 26 | def delete_chapter_folder(sender, instance, **kwargs): 27 | if instance.folder and instance.series: 28 | clear_pages_cache() 29 | folder_path = os.path.join( 30 | settings.MEDIA_ROOT, 31 | "manga", 32 | instance.series.slug, 33 | "chapters", 34 | instance.folder, 35 | ) 36 | delete_chapter_pages_if_exists( 37 | folder_path, instance.clean_chapter_number(), str(instance.group.id) 38 | ) 39 | if os.path.exists(folder_path) and not os.listdir(folder_path): 40 | shutil.rmtree(folder_path, ignore_errors=True) 41 | chapter = ContentType.objects.get(app_label="reader", model="chapter") 42 | hit_count_obj = HitCount.objects.filter( 43 | content_type=chapter, object_id=instance.id 44 | ).first() 45 | if hit_count_obj: 46 | hit_count_obj.delete() 47 | clear_pages_cache() 48 | 49 | 50 | @receiver(post_delete, sender=Volume) 51 | def delete_volume_folder(sender, instance, **kwargs): 52 | if instance.volume_cover: 53 | clear_pages_cache() 54 | folder_path = os.path.join( 55 | settings.MEDIA_ROOT, 56 | "manga", 57 | instance.series.slug, 58 | "volume_covers", 59 | str(instance.volume_number), 60 | ) 61 | shutil.rmtree(folder_path, ignore_errors=True) 62 | 63 | 64 | @receiver(pre_save, sender=Chapter) 65 | def pre_save_chapter(sender, instance, **kwargs): 66 | if instance.reprocess_metadata: 67 | instance.reprocess_metadata = False 68 | instance.save() 69 | chapter_post_process(instance, is_update=False) 70 | 71 | if instance.series and instance.series.next_release_page: 72 | highest_chapter_number = Chapter.objects.filter(series=instance.series).latest( 73 | "chapter_number" 74 | ) 75 | if instance.chapter_number > highest_chapter_number.chapter_number: 76 | instance.series.next_release_time = datetime.utcnow().replace( 77 | tzinfo=timezone.utc 78 | ) + timedelta(days=7) 79 | instance.series.save() 80 | 81 | @receiver(post_init, sender=Chapter) 82 | def remember_original_series_of_chapter(sender, instance, **kwargs): 83 | instance.old_chapter_number = str(instance.slug_chapter_number()) if instance.chapter_number is not None else None 84 | instance.old_series_slug = str(instance.series.slug) if hasattr(instance, 'series') else None 85 | instance.old_group_id = str(instance.group.id) if hasattr(instance, 'group') else None 86 | 87 | 88 | @receiver(post_save, sender=Chapter) 89 | def post_save_chapter(sender, instance, **kwargs): 90 | # If the group or series or the chapter number has changed, move all chapter file 91 | if (instance.old_series_slug is not None and str(instance.series.slug) != instance.old_series_slug) or \ 92 | (instance.old_group_id is not None and str(instance.group.id) != instance.old_group_id) or \ 93 | (instance.old_chapter_number is not None and str(instance.slug_chapter_number()) != instance.old_chapter_number): 94 | 95 | new_group_id = str(instance.group.id) 96 | 97 | old_chapter_folder = os.path.join(settings.MEDIA_ROOT, "manga", instance.old_series_slug, "chapters", instance.folder, instance.old_group_id) 98 | new_chapter_folder = os.path.join(settings.MEDIA_ROOT, "manga", instance.series.slug, "chapters", instance.folder, new_group_id) 99 | 100 | os.makedirs(os.path.dirname(new_chapter_folder), exist_ok=True) 101 | if old_chapter_folder != new_chapter_folder or str(instance.slug_chapter_number()) != instance.old_chapter_number: 102 | shutil.move(f"{old_chapter_folder}_{instance.old_chapter_number}.zip", f"{new_chapter_folder}_{instance.slug_chapter_number()}.zip") 103 | 104 | if old_chapter_folder != new_chapter_folder: 105 | shutil.move(old_chapter_folder, new_chapter_folder) 106 | shutil.move(f"{old_chapter_folder}_shrunk", f"{new_chapter_folder}_shrunk") 107 | shutil.move(f"{old_chapter_folder}_shrunk_blur", f"{new_chapter_folder}_shrunk_blur") 108 | 109 | if instance.series: 110 | clear_pages_cache() 111 | 112 | 113 | @receiver(post_init, sender=Volume) 114 | def remember_original_series_of_volume(sender, instance, **kwargs): 115 | instance.old_series_slug = str(instance.series.slug) if hasattr(instance, 'series') else None 116 | instance.old_volume_number = int(instance.volume_number) if instance.volume_number else None 117 | instance.old_volume_cover = None if instance.volume_cover is None else str(instance.volume_cover) 118 | 119 | @receiver(post_save, sender=Volume) 120 | def save_volume(sender, instance, **kwargs): 121 | if instance.series: 122 | clear_pages_cache() 123 | if instance.volume_cover is None or instance.volume_cover == "": 124 | return 125 | # If series has been changed or the volume has been changed, move images 126 | # and a cover has been set in the past and a new cover has not been uploaded 127 | if instance.old_series_slug is not None and (instance.old_series_slug != str(instance.series.slug) or instance.old_volume_number != int(instance.volume_number)) \ 128 | and instance.old_volume_cover is not None and instance.old_volume_cover == instance.volume_cover: 129 | old_location = os.path.join(settings.MEDIA_ROOT, os.path.dirname(str(instance.old_volume_cover))) 130 | new_location = os.path.join(settings.MEDIA_ROOT, new_volume_folder(instance)) 131 | if os.path.normpath(old_location) != os.path.normpath(new_location): 132 | os.makedirs(new_location, exist_ok=True) 133 | for file in os.listdir(old_location): 134 | os.rename(os.path.join(old_location, file), os.path.join(new_location, file)) 135 | os.rmdir(old_location) 136 | original_filename = os.path.basename(str(instance.volume_cover)) 137 | instance.volume_cover = os.path.join(new_volume_folder(instance), original_filename) 138 | 139 | # setup it up to prevent a recursive loop of save call back 140 | instance.old_series_slug = str(instance.series.slug) 141 | instance.old_volume_number = int(instance.volume_number) 142 | instance.old_volume_cover = str(instance.volume_cover) 143 | instance.save() 144 | elif instance.volume_cover and (instance.old_volume_cover is None or instance.old_volume_cover != instance.volume_cover): 145 | save_dir = os.path.join(os.path.dirname(str(instance.volume_cover))) 146 | vol_cover = os.path.basename(str(instance.volume_cover)) 147 | for old_data in os.listdir(os.path.join(settings.MEDIA_ROOT, save_dir)): 148 | if old_data != vol_cover: 149 | os.remove(os.path.join(settings.MEDIA_ROOT, save_dir, old_data)) 150 | filename, ext = vol_cover.rsplit(".", 1) 151 | image = Image.open(os.path.join(settings.MEDIA_ROOT, save_dir, vol_cover)) 152 | image.save( 153 | os.path.join(settings.MEDIA_ROOT, save_dir, f"{filename}.webp"), 154 | lossless=False, 155 | quality=60, 156 | method=6, 157 | ) 158 | # This line crash on my server and this file does not seem be used at all. 159 | # image.save(os.path.join(settings.MEDIA_ROOT, save_dir, f"{filename}.jp2")) 160 | blur = Image.open(os.path.join(settings.MEDIA_ROOT, save_dir, vol_cover)) 161 | blur = blur.convert("RGB") 162 | blur.thumbnail((blur.width / 8, blur.height / 8), Image.ANTIALIAS) 163 | blur = blur.filter(ImageFilter.GaussianBlur(radius=4)) 164 | blur.save( 165 | os.path.join(settings.MEDIA_ROOT, save_dir, f"{filename}_blur.{ext}"), 166 | "JPEG", 167 | quality=100, 168 | optimize=True, 169 | progressive=True, 170 | ) 171 | -------------------------------------------------------------------------------- /proxy/sources/foolslide.py: -------------------------------------------------------------------------------- 1 | import json 2 | import re 3 | 4 | import requests 5 | from bs4 import BeautifulSoup 6 | from django.http import HttpResponse 7 | from django.shortcuts import redirect 8 | from django.urls import re_path 9 | 10 | from ..source import ProxySource 11 | from ..source.data import ChapterAPI, SeriesAPI, SeriesPage 12 | from ..source.helpers import ( 13 | api_cache, 14 | decode, 15 | encode, 16 | get_wrapper, 17 | naive_decode, 18 | post_wrapper, 19 | ) 20 | 21 | 22 | class FoolSlide(ProxySource): 23 | def get_reader_prefix(self): 24 | return "foolslide" 25 | 26 | def shortcut_instantiator(self): 27 | def handler(request, raw_url): 28 | if raw_url.endswith("/"): 29 | raw_url = raw_url[:-1] 30 | if "/read/" in raw_url: 31 | params = [n for n in raw_url.split("/") if n] 32 | # ~~Translator~~ Developer's note: "en" means only english FS sites work 33 | lang_idx = params.index("en") 34 | chapter = params[lang_idx + 2] 35 | if len(params) - 1 > lang_idx + 2 and params[lang_idx + 3] != "page": 36 | chapter += f"-{params[lang_idx + 3]}" 37 | page = "1" 38 | if "/page/" in raw_url: 39 | page_idx = params.index("page") 40 | page = params[page_idx + 1] 41 | return redirect( 42 | f"reader-{self.get_reader_prefix()}-chapter-page", 43 | self.encode_slug(raw_url), 44 | chapter, 45 | page, 46 | ) 47 | elif "/series/" in raw_url: 48 | return redirect( 49 | f"reader-{self.get_reader_prefix()}-series-page", 50 | self.encode_slug(raw_url), 51 | ) 52 | else: 53 | return HttpResponse(status=400) 54 | 55 | def series(request, series_id): 56 | # A hacky bandaid that shouldn't be here, but it'll otherwise redirect since we're 57 | # masking the canonnical route and it'll keep matching the regex path 58 | if "%" in series_id: 59 | series_id = self.encode_slug(naive_decode(series_id)) 60 | return redirect(f"reader-{self.get_reader_prefix()}-series-page", series_id) 61 | 62 | def series_chapter(request, series_id, chapter, page="1"): 63 | if "%" in series_id: 64 | series_id = self.encode_slug(naive_decode(series_id)) 65 | return redirect( 66 | f"reader-{self.get_reader_prefix()}-chapter-page", 67 | series_id, 68 | chapter, 69 | page, 70 | ) 71 | 72 | return [ 73 | re_path(r"^fs/(?P[\w\d\/:.-]+)", handler), 74 | re_path(r"^(?:read|reader)/fs_proxy/(?P[\w\d.%-]+)/$", series), 75 | re_path( 76 | r"^(?:read|reader)/fs_proxy/(?P[\w\d.%-]+)/(?P[\d]+)/$", 77 | series_chapter, 78 | ), 79 | re_path( 80 | r"^(?:read|reader)/fs_proxy/(?P[\w\d.%-]+)/(?P[\d]+)/(?P[\d]+)/$", 81 | series_chapter, 82 | ), 83 | re_path( 84 | r"^proxy/foolslide/(?P[\w\d.%-]*%[\w\d.%-]*)/$", series, 85 | ), 86 | re_path( 87 | r"^proxy/foolslide/(?P[\w\d.%-]*%[\w\d.%-]*)/(?P[\d-]+)/(?P[\d]+)/$", 88 | series_chapter, 89 | ), 90 | ] 91 | 92 | def encode_slug(self, url): 93 | url = ( 94 | url.replace("/read/", "/series/") 95 | .replace("https://", "") 96 | .replace("http://", "") 97 | ) 98 | split = url.split("/") 99 | url = "/".join(split[0 : split.index("series") + 2]) 100 | return encode(url) 101 | 102 | def fs_scrape_common(self, meta_id): 103 | try: 104 | resp = post_wrapper(f"https://{decode(meta_id)}/", data={"adult": "true"}) 105 | except requests.exceptions.ConnectionError: 106 | resp = post_wrapper(f"http://{decode(meta_id)}/", data={"adult": "true"}) 107 | if resp.status_code == 200: 108 | data = resp.text 109 | soup = BeautifulSoup(data, "html.parser") 110 | 111 | comic_info = soup.find("div", class_="large comic") 112 | 113 | title = ( 114 | comic_info.find("h1", class_="title") 115 | .get_text() 116 | .replace("\n", "") 117 | .strip() 118 | ) 119 | description = comic_info.find("div", class_="info").get_text().strip() 120 | groups_dict = {"1": decode(meta_id).split("/")[0]} 121 | cover_div = soup.find("div", class_="thumbnail") 122 | if cover_div and cover_div.find("img")["src"]: 123 | cover = cover_div.find("img")["src"] 124 | else: 125 | cover = "" 126 | 127 | chapter_dict = {} 128 | chapter_list = [] 129 | 130 | for a in soup.find_all("div", class_="element"): 131 | link = a.find("div", class_="title").find("a") 132 | chapter_regex = re.search(r"(Chapter |Ch.)([\d.]+)", link.get_text()) 133 | chapter_number = "0" 134 | if chapter_regex: 135 | chapter_number = chapter_regex.group(2) 136 | volume_regex = re.search(r"(Volume |Vol.)([\d.]+)", link.get_text()) 137 | volume_number = "1" 138 | if volume_regex: 139 | volume_number = volume_regex.group(2) 140 | 141 | chapter = { 142 | "volume": volume_number, 143 | "title": link.get_text(), 144 | "groups": {"1": None}, 145 | } 146 | chp = chapter_number.split(".") 147 | major_chapter = chp[0] 148 | minor_chapter = "0" 149 | if len(chp) > 1: 150 | minor_chapter = chp[1] 151 | deconstructed_url = link["href"].split("/read/") 152 | chapter_url = self.wrap_chapter_meta( 153 | encode( 154 | f"{deconstructed_url[0]}/api/reader/chapter?comic_stub={deconstructed_url[1].split('/')[0]}&chapter={major_chapter}&subchapter={minor_chapter}" 155 | ) 156 | ) 157 | chapter["groups"]["1"] = chapter_url 158 | chapter_dict[chapter_number] = chapter 159 | 160 | chapter_title = link.get_text() 161 | upload_info = list( 162 | map( 163 | lambda e: e.strip(), 164 | a.find("div", class_="meta_r") 165 | .get_text() 166 | .replace("by", "") 167 | .split(","), 168 | ) 169 | ) 170 | chapter_list.append( 171 | [ 172 | chapter_number, 173 | chapter_number, 174 | chapter_title, 175 | chapter_number.replace(".", "-"), 176 | upload_info[0], 177 | upload_info[1], 178 | "", 179 | ] 180 | ) 181 | 182 | return { 183 | "slug": meta_id, 184 | "title": title, 185 | "description": description, 186 | "series": title, 187 | "alt_titles_str": None, 188 | "cover_vol_url": cover, 189 | "metadata": [], 190 | "author": "", 191 | "artist": "", 192 | "groups": groups_dict, 193 | "cover": cover, 194 | "chapter_dict": chapter_dict, 195 | "chapter_list": chapter_list, 196 | } 197 | else: 198 | return None 199 | 200 | @api_cache(prefix="fs_series_dt", time=600) 201 | def series_api_handler(self, meta_id): 202 | data = self.fs_scrape_common(meta_id) 203 | if data: 204 | return SeriesAPI( 205 | slug=data["slug"], 206 | title=data["title"], 207 | description=data["description"], 208 | author=data["author"], 209 | artist=data["artist"], 210 | groups=data["groups"], 211 | cover=data["cover"], 212 | chapters=data["chapter_dict"], 213 | ) 214 | else: 215 | return None 216 | 217 | @api_cache(prefix="fs_chapter_dt", time=3600) 218 | def chapter_api_handler(self, meta_id): 219 | resp = get_wrapper(decode(meta_id)) 220 | if resp.status_code == 200: 221 | data = json.loads(resp.text) 222 | return ChapterAPI( 223 | pages=[e["url"] for e in data["pages"]], series=meta_id, chapter="" 224 | ) 225 | else: 226 | return None 227 | 228 | @api_cache(prefix="fs_series_page_dt", time=600) 229 | def series_page_handler(self, meta_id): 230 | data = self.fs_scrape_common(meta_id) 231 | if data: 232 | return SeriesPage( 233 | series=data["title"], 234 | alt_titles=[], 235 | alt_titles_str=None, 236 | slug=data["slug"], 237 | cover_vol_url=data["cover"], 238 | metadata=[], 239 | synopsis=data["description"], 240 | author=data["artist"], 241 | chapter_list=data["chapter_list"], 242 | original_url=f"https://{decode(meta_id)}/", 243 | ) 244 | else: 245 | return None 246 | -------------------------------------------------------------------------------- /reader/views.py: -------------------------------------------------------------------------------- 1 | import json 2 | from collections import OrderedDict, defaultdict 3 | from datetime import datetime 4 | 5 | from django.conf import settings 6 | from django.contrib.admin.views.decorators import staff_member_required 7 | from django.contrib.contenttypes.models import ContentType 8 | from django.core.cache import cache 9 | from django.db.models import F 10 | from django.http import HttpResponse 11 | from django.http.response import Http404 12 | from django.shortcuts import get_object_or_404, redirect, render 13 | from django.utils.decorators import decorator_from_middleware 14 | from django.views.decorators.cache import cache_control 15 | from django.views.decorators.csrf import csrf_exempt 16 | 17 | from .middleware import OnlineNowMiddleware 18 | from .models import Chapter, HitCount, Series, Volume 19 | from .users_cache_lib import get_user_ip 20 | 21 | 22 | @csrf_exempt 23 | @decorator_from_middleware(OnlineNowMiddleware) 24 | def hit_count(request): 25 | if request.method == "POST": 26 | user_ip = get_user_ip(request) 27 | page_id = f"url_{request.POST['series']}/{request.POST['chapter'] if 'chapter' in request.POST else ''}{user_ip}" 28 | if not cache.get(page_id): 29 | cache.set(page_id, page_id, 60) 30 | series_slug = request.POST["series"] 31 | series_id = Series.objects.get(slug=series_slug).id 32 | series = ContentType.objects.get(app_label="reader", model="series") 33 | hit, _ = HitCount.objects.get_or_create( 34 | content_type=series, object_id=series_id 35 | ) 36 | hit.hits = F("hits") + 1 37 | hit.save() 38 | if "chapter" in request.POST: 39 | chapter_number = request.POST["chapter"] 40 | group_id = request.POST["group"] 41 | chapter = ContentType.objects.get(app_label="reader", model="chapter") 42 | ch_obj = Chapter.objects.filter( 43 | chapter_number=float(chapter_number), 44 | group__id=group_id, 45 | series__id=series_id, 46 | ).first() 47 | if ch_obj: 48 | hit, _ = HitCount.objects.get_or_create( 49 | content_type=chapter, object_id=ch_obj.id, 50 | ) 51 | hit.hits = F("hits") + 1 52 | hit.save() 53 | 54 | return HttpResponse(json.dumps({}), content_type="application/json") 55 | 56 | 57 | def series_page_data(request, series_slug): 58 | series_page_dt = cache.get(f"series_page_dt_{series_slug}") 59 | if not series_page_dt: 60 | series = get_object_or_404(Series, slug=series_slug) 61 | chapters = Chapter.objects.filter(series=series).select_related( 62 | "series", "group" 63 | ) 64 | latest_chapter = chapters.latest("uploaded_on") if chapters else None 65 | cover_vol_url, cover_vol_url_webp = series.get_latest_volume_cover_path() 66 | content_series = ContentType.objects.get(app_label="reader", model="series") 67 | hit, _ = HitCount.objects.get_or_create( 68 | content_type=content_series, object_id=series.id 69 | ) 70 | chapter_list = [] 71 | volume_dict = defaultdict(list) 72 | chapter_dict = OrderedDict() 73 | for chapter in chapters: 74 | ch_clean = chapter.clean_chapter_number() 75 | if ch_clean in chapter_dict: 76 | if chapter.uploaded_on > chapter_dict[ch_clean][0].uploaded_on: 77 | chapter_dict[ch_clean] = [chapter, True] 78 | else: 79 | chapter_dict[ch_clean] = [chapter_dict[ch_clean][0], True] 80 | else: 81 | chapter_dict[ch_clean] = [chapter, False] 82 | for ch in chapter_dict: 83 | chapter, multiple_groups = chapter_dict[ch] 84 | u = chapter.uploaded_on 85 | chapter_list.append( 86 | [ 87 | chapter.clean_chapter_number(), 88 | chapter.clean_chapter_number(), 89 | chapter.title, 90 | chapter.slug_chapter_number(), 91 | chapter.group.name if not multiple_groups else "Multiple Groups", 92 | [u.year, u.month - 1, u.day, u.hour, u.minute, u.second], 93 | chapter.volume or "null", 94 | ] 95 | ) 96 | volume_dict[chapter.volume].append( 97 | [ 98 | chapter.clean_chapter_number(), 99 | chapter.slug_chapter_number(), 100 | chapter.group.name if not multiple_groups else "Multiple Groups", 101 | [u.year, u.month - 1, u.day, u.hour, u.minute, u.second], 102 | ] 103 | ) 104 | volume_list = [] 105 | for key, value in volume_dict.items(): 106 | volume_list.append( 107 | [key, sorted(value, key=lambda x: float(x[0]), reverse=True)] 108 | ) 109 | chapter_list.sort(key=lambda x: float(x[0]), reverse=True) 110 | 111 | series_page_dt = { 112 | "series": series.name, 113 | "alt_titles": series.alternative_titles.split(", ") 114 | if series.alternative_titles 115 | else [], 116 | "alt_titles_str": f" Alternative titles: {series.alternative_titles}." 117 | if series.alternative_titles 118 | else "", 119 | "series_id": series.id, 120 | "slug": series.slug, 121 | "cover_vol_url": cover_vol_url, 122 | "cover_vol_url_webp": cover_vol_url_webp, 123 | "metadata": [ 124 | ["Author", series.author.name], 125 | ["Artist", series.artist.name], 126 | ["Views", hit.hits + 1], 127 | [ 128 | "Last Updated", 129 | f"Ch. {latest_chapter.clean_chapter_number() if latest_chapter else ''} - {datetime.utcfromtimestamp(latest_chapter.uploaded_on.timestamp()).strftime('%Y-%m-%d') if latest_chapter else ''}", 130 | ], 131 | ], 132 | "synopsis": series.synopsis, 133 | "author": series.author.name, 134 | "chapter_list": chapter_list, 135 | "volume_list": sorted(volume_list, key=lambda m: m[0], reverse=True), 136 | "root_domain": settings.CANONICAL_ROOT_DOMAIN, 137 | "canonical_url": f"https://{settings.CANONICAL_ROOT_DOMAIN}{series.get_absolute_url()}", 138 | "relative_url": f"read/manga/{series.slug}/", 139 | "available_features": [ 140 | "detailed", 141 | "compact", 142 | "volumeCovers", 143 | "rss", 144 | "download", 145 | ], 146 | "reader_modifier": "read/manga", 147 | "embed_image": request.build_absolute_uri(series.get_embed_image_path()), 148 | } 149 | cache.set(f"series_page_dt_{series_slug}", series_page_dt, 3600 * 12) 150 | return series_page_dt 151 | 152 | 153 | @cache_control(public=True, max_age=60, s_maxage=60) 154 | @decorator_from_middleware(OnlineNowMiddleware) 155 | def series_info(request, series_slug): 156 | data = series_page_data(request, series_slug) 157 | data["version_query"] = settings.STATIC_VERSION 158 | return render(request, "reader/series.html", data) 159 | 160 | 161 | @cache_control(public=True, max_age=60, s_maxage=60) 162 | @decorator_from_middleware(OnlineNowMiddleware) 163 | def series_info_canonical(request, url_str, series_slug): 164 | series = get_object_or_404(Series, slug=series_slug) 165 | if series.canonical_series_url_filler != url_str: 166 | raise Http404() 167 | else: 168 | return series_info(request, series_slug) 169 | 170 | 171 | @staff_member_required 172 | @cache_control(public=True, max_age=60, s_maxage=60) 173 | @decorator_from_middleware(OnlineNowMiddleware) 174 | def series_info_admin(request, series_slug): 175 | data = series_page_data(request, series_slug) 176 | data["version_query"] = settings.STATIC_VERSION 177 | data["available_features"].append("admin") 178 | return render(request, "reader/series.html", data) 179 | 180 | 181 | def get_all_metadata(request, series_slug): 182 | series_metadata = cache.get(f"series_metadata_{series_slug}") 183 | if not series_metadata: 184 | series = Series.objects.filter(slug=series_slug).first() 185 | if not series: 186 | return None 187 | chapters = Chapter.objects.filter(series=series).select_related("series") 188 | series_metadata = {} 189 | series_metadata["indexed"] = series.indexed 190 | series_metadata["embed_image"] = request.build_absolute_uri( 191 | series.get_embed_image_path() 192 | ) 193 | for chapter in chapters: 194 | series_metadata[chapter.slug_chapter_number()] = { 195 | "series_name": chapter.series.name, 196 | "slug": chapter.series.slug, 197 | "author_name": series.author.name, 198 | "chapter_number": chapter.clean_chapter_number(), 199 | "chapter_title": chapter.title, 200 | } 201 | cache.set(f"series_metadata_{series_slug}", series_metadata, 3600 * 12) 202 | return series_metadata 203 | 204 | 205 | @cache_control(public=True, max_age=30, s_maxage=30) 206 | @decorator_from_middleware(OnlineNowMiddleware) 207 | def reader(request, series_slug, chapter, page=None): 208 | if page: 209 | data = get_all_metadata(request, series_slug) 210 | if data and chapter in data: 211 | data[chapter]["relative_url"] = f"read/manga/{series_slug}/{chapter}/1" 212 | data[chapter]["api_path"] = f"/api/series/" 213 | data[chapter]["image_proxy_url"] = settings.IMAGE_PROXY_URL 214 | data[chapter]["version_query"] = settings.STATIC_VERSION 215 | data[chapter]["first_party"] = True 216 | data[chapter]["indexed"] = data["indexed"] 217 | data[chapter]["embed_image"] = data["embed_image"] 218 | return render(request, "reader/reader.html", data[chapter]) 219 | else: 220 | return render(request, "homepage/how_cute_404.html", status=404) 221 | else: 222 | return redirect("reader-manga-chapter", series_slug, chapter, "1") 223 | --------------------------------------------------------------------------------