commit 81d008036aec4c49516e01aea15980c58d8af3e3 Author: Ricter Zheng Date: Wed Mar 25 10:37:21 2026 +0800 Initial commit: doujinshi-dl generic plugin framework History reset as part of DMCA compliance. The project has been refactored into a generic, site-agnostic download framework. Site-specific logic lives in separate plugin packages. Co-Authored-By: Claude Sonnet 4.6 diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..531eb5e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +.git +.github +.gitignore +venv +*.egg-info +build +dist +images +LICENSE +.travis.yml +.idea diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml new file mode 100644 index 0000000..70bc348 --- /dev/null +++ b/.github/workflows/docker-image.yml @@ -0,0 +1,27 @@ +name: Docker Image CI + +on: + push: + branches: [ "master" ] + +jobs: + + build: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + - + name: Login to Docker Hub + uses: docker/login-action@v2 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + - + name: Build the Docker image + uses: docker/build-push-action@v4 + with: + context: . + push: true + tags: ricterz/doujinshi-dl:latest diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7be3def --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +*.py[cod] +.idea/ +build +dist/ +*.egg-info +.python-version +.DS_Store +output/ +venv/ +.vscode/ +test-output +*.whl diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..6317747 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,80 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +`doujinshi-dl` is a Python CLI tool for downloading doujinshi from mirror sites. Entry point: `nhentai.command:main`. PyPI distribution name: `doujinshi-dl`, Python package name: `nhentai`. + +## Development Setup + +```bash +# Install in editable mode +pip install --no-cache-dir . + +# With nhentai plugin +pip install -e . -e ../doujinshi-dl-nhentai/ + +# Or with Poetry +poetry install +``` + +## Running the CLI + +```bash +doujinshi-dl --id +doujinshi-dl --search "keyword" [--download] +doujinshi-dl --favorites --download +``` + +## Tests + +All tests are integration tests that make real HTTP requests and require environment variables: + +```bash +export DDL_COOKIE="" +export DDL_UA="" +export DOUJINSHI_DL_URL="" + +# Run all tests +python -m unittest discover tests/ + +# Run a specific test file +python -m unittest tests.test_parser + +# Run a single test case +python -m unittest tests.test_parser.TestParser.test_search +``` + +## Architecture + +The pipeline flows through these modules in sequence: + +1. **`cmdline.py`** — Parses CLI arguments; loads/saves config from `~/.doujinshi-dl/config.json` +2. **`parser.py`** — Scrapes mirror site via BeautifulSoup and HTTP; functions: `doujinshi_parser`, `search_parser`, `favorites_parser` +3. **`doujinshi.py`** — `Doujinshi` model holding metadata and building the download queue; folder-name format tokens: `%i` (ID), `%t` (title), `%a` (artist), etc. +4. **`downloader.py`** — Async image downloading via `httpx` + `asyncio`; `Downloader` writes files directly, `CompressedDownloader` writes `.zip` +5. **`utils.py`** — HTTP helpers (`request()`, `async_request()`), HTML generation, PDF/CBZ creation, SQLite history DB +6. **`serializer.py`** — Writes `metadata.json`, `ComicInfo.xml`, `info.txt` alongside downloaded images +7. **`command.py`** — Orchestrates the full pipeline; the `main()` entry point + +## Key Constants & Environment Variables + +All URLs, paths, and defaults live in **`constant.py`** (plugin package). + +| Variable | Purpose | +|----------|---------| +| `DOUJINSHI_DL_URL` | Mirror base URL (required to run) | +| `DEBUG` | Enable debug logging; `DEBUG=NODOWNLOAD` skips actual downloads | +| `DDL_COOKIE` | Cookie for authentication (also used by tests) | +| `DDL_UA` | User-agent string (also used by tests) | + +## Persistence + +- **Config:** `~/.doujinshi-dl/config.json` (cookie, user-agent, proxy, language, template) +- **History DB:** `~/.doujinshi-dl/history.sqlite3` (SQLite, tracks downloaded IDs to avoid re-downloading) +- On Linux, `$XDG_DATA_HOME` is respected for these paths + +## Viewer Templates + +`doujinshi_dl/viewer/` contains bundled HTML viewer templates (`default/`, `minimal/`) used by `generate_html()` in `utils.py` to produce a local browsing interface after download. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ce830ba --- /dev/null +++ b/Dockerfile @@ -0,0 +1,9 @@ +FROM python:3 + +WORKDIR /usr/src/doujinshi-dl + +COPY . . +RUN pip install --no-cache-dir . + +WORKDIR /output +ENTRYPOINT ["doujinshi-dl"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d5519cd --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2015 Ricter Zheng + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..09be347 --- /dev/null +++ b/README.rst @@ -0,0 +1,229 @@ +doujinshi-dl +============ + +あなたも変態。 いいね? + +|pypi| |version| |license| + + +doujinshi-dl is a CLI tool for downloading doujinshi from mirror sites. + +=================== +Manual Installation +=================== +From Github: + +.. code-block:: + + git clone https://github.com/RicterZ/doujinshi-dl + cd doujinshi-dl + pip install --no-cache-dir . + +Build Docker container: + +.. code-block:: + + git clone https://github.com/RicterZ/doujinshi-dl + cd doujinshi-dl + docker build -t doujinshi-dl:latest . + docker run --rm -it -v ~/Downloads/doujinshi:/output doujinshi-dl --id 123855 + +================== +Installation +================== +From PyPI with pip: + +.. code-block:: + + pip install doujinshi-dl + +Install a plugin to connect to a mirror site: + +.. code-block:: + + pip install doujinshi-dl- + +For a self-contained installation, use `pipx `_: + +.. code-block:: + + pipx install doujinshi-dl + +===== +Usage +===== +**⚠️IMPORTANT⚠️**: To bypass Cloudflare, you should use ``--cookie`` and ``--useragent`` options to store your cookie and user-agent. + +.. code-block:: bash + + doujinshi-dl --useragent "USER AGENT of YOUR BROWSER" + doujinshi-dl --cookie "YOUR COOKIE" + +**NOTE:** + +- The format of the cookie is ``"csrftoken=TOKEN; sessionid=ID; cf_clearance=CLOUDFLARE"`` +- ``cf_clearance`` cookie and useragent must be set if you encounter "blocked by cloudflare captcha" error. Make sure you use the same IP and useragent as when you got it + +| To get csrftoken and sessionid, first login to your account in a web browser, then: +| (Chrome) |ve| |ld| More tools |ld| Developer tools |ld| Application |ld| Storage |ld| Cookies |ld| your mirror URL +| (Firefox) |hv| |ld| Web Developer |ld| Web Developer Tools |ld| Storage |ld| Cookies |ld| your mirror URL +| + +.. |hv| unicode:: U+2630 .. https://www.compart.com/en/unicode/U+2630 +.. |ve| unicode:: U+22EE .. https://www.compart.com/en/unicode/U+22EE +.. |ld| unicode:: U+2014 .. https://www.compart.com/en/unicode/U+2014 + +*The default download folder will be the path where you run the command (%cd% or $PWD).* + +Download specified doujinshi: + +.. code-block:: bash + + doujinshi-dl --id 123855 123866 123877 + +Download doujinshi with ids specified in a file (doujinshi ids split by line): + +.. code-block:: bash + + doujinshi-dl --file=doujinshi.txt + +Set search default language: + +.. code-block:: bash + + doujinshi-dl --language=english + +Search a keyword and download the first page: + +.. code-block:: bash + + doujinshi-dl --search="tomori" --page=1 --download + # you also can download by tags and multiple keywords + doujinshi-dl --search="tag:lolicon, artist:henreader, tag:full color" + doujinshi-dl --search="lolicon, henreader, full color" + +Download your favorites with delay: + +.. code-block:: bash + + doujinshi-dl --favorites --download --delay 1 --page 3-5,7 + +Format output doujinshi folder name: + +.. code-block:: bash + + doujinshi-dl --id 261100 --format '[%i]%s' + # for Windows + doujinshi-dl --id 261100 --format "[%%i]%%s" + +Supported doujinshi folder formatter: + +- %i: Doujinshi id +- %f: Doujinshi favorite count +- %t: Doujinshi name +- %s: Doujinshi subtitle (translated name) +- %a: Doujinshi authors' name +- %g: Doujinshi groups name +- %p: Doujinshi pretty name +- %ag: Doujinshi authors name or groups name + +Note: for Windows operation system, please use double "%", such as "%%i". + +Other options: + +.. code-block:: + + Usage: + doujinshi-dl --search [keyword] --download + DOUJINSHI_DL_URL=https://mirror-url/ doujinshi-dl --id [ID ...] + doujinshi-dl --file [filename] + + Environment Variable: + DOUJINSHI_DL_URL mirror url + + Options: + -h, --help show this help message and exit + --download, -D download doujinshi (for search results) + --no-download skip downloading (for search results) + --show, -S just show the doujinshi information + --id ID [ID ...] doujinshi ids set, e.g. 167680 167681 167682 + --search, -s KEYWORD search doujinshi by keyword + --favorites, -F list or download your favorites + --artist, -a ARTIST list doujinshi by artist name + --page-all all search results + --page, --page-range PAGE + page number of search results. e.g. 1,2-5,14 + --sorting, --sort {recent,popular,popular-today,popular-week,date} + sorting of doujinshi (recent / popular / popular-[today|week]) + --output, -o OUTPUT_DIR + output dir + --threads, -t THREADS + thread count for downloading doujinshi + --timeout, -T TIMEOUT + timeout for downloading doujinshi + --delay, -d DELAY slow down between downloading every doujinshi + --retry RETRY retry times when downloading failed + --exit-on-fail exit on fail to prevent generating incomplete files + --proxy PROXY store a proxy, for example: -p "http://127.0.0.1:1080" + --file, -f FILE read gallery IDs from file. + --format NAME_FORMAT format the saved folder name + --no-filename-padding + no padding in the images filename, such as '001.jpg' + --html [HTML_VIEWER] generate an HTML viewer in the specified directory, or + scan all subfolders within the entire directory to + generate the HTML viewer. By default, current working + directory is used. + --no-html don't generate HTML after downloading + --gen-main generate a main viewer contain all the doujin in the folder + --cbz, -C generate Comic Book CBZ File + --pdf, -P generate PDF file + --meta generate a metadata file in doujinshi format + --update-meta update the metadata file of a doujinshi, update CBZ + metadata if exists + --rm-origin-dir remove downloaded doujinshi dir when generated CBZ or PDF file + --move-to-folder remove files in doujinshi dir then move new file to + folder when generated CBZ or PDF file + --regenerate regenerate the cbz or pdf file if exists + --zip package into a single zip file + --cookie COOKIE set cookie to bypass Cloudflare captcha + --useragent, --user-agent USERAGENT + set useragent to bypass Cloudflare captcha + --language LANGUAGE set default language to parse doujinshis + --clean-language set DEFAULT as language to parse doujinshis + --save-download-history + save downloaded doujinshis, whose will be skipped if + you re-download them + --clean-download-history + clean download history + --template VIEWER_TEMPLATE + set viewer template + --legacy use legacy searching method + +====== +Mirror +====== +To use a mirror, set the ``DOUJINSHI_DL_URL`` environment variable to your mirror's base URL. + +.. code-block:: bash + + DOUJINSHI_DL_URL=https://your-mirror.example.com doujinshi-dl --id 123456 + +.. image:: https://github.com/RicterZ/doujinshi-dl/raw/master/images/search.png + :alt: search + :align: center +.. image:: https://github.com/RicterZ/doujinshi-dl/raw/master/images/download.png + :alt: download + :align: center +.. image:: https://github.com/RicterZ/doujinshi-dl/raw/master/images/viewer.png + :alt: viewer + :align: center + + +.. |license| image:: https://img.shields.io/github/license/ricterz/nhentai.svg + :target: https://github.com/RicterZ/nhentai/blob/master/LICENSE + +.. |pypi| image:: https://img.shields.io/pypi/v/doujinshi-dl.svg + :target: https://pypi.org/project/doujinshi-dl/ + +.. |version| image:: https://img.shields.io/badge/python-3.8%2B-blue.svg + :target: https://pypi.org/project/doujinshi-dl/ diff --git a/doujinshi_dl/__init__.py b/doujinshi_dl/__init__.py new file mode 100644 index 0000000..2b9306d --- /dev/null +++ b/doujinshi_dl/__init__.py @@ -0,0 +1,3 @@ +__version__ = '2.0.5' +__author__ = 'RicterZ' +__email__ = 'ricterzheng@gmail.com' diff --git a/doujinshi_dl/cmdline.py b/doujinshi_dl/cmdline.py new file mode 100644 index 0000000..454fdd5 --- /dev/null +++ b/doujinshi_dl/cmdline.py @@ -0,0 +1,290 @@ +# coding: utf-8 + +import os +import sys +import json + +from urllib.parse import urlparse +from argparse import ArgumentParser + +from doujinshi_dl import __version__ +from doujinshi_dl.utils import generate_html, generate_main_html, DB, EXTENSIONS +from doujinshi_dl.logger import logger +from doujinshi_dl.constant import PATH_SEPARATOR + + +_plugin_const_cache = None + + +def _plugin_const(): + """Return the active plugin's constant module without hard-coding plugin names.""" + global _plugin_const_cache + if _plugin_const_cache is not None: + return _plugin_const_cache + from doujinshi_dl.core.registry import get_first_plugin + import importlib + plugin = get_first_plugin() + pkg = type(plugin).__module__.split('.')[0] # top-level package name of the plugin + _plugin_const_cache = importlib.import_module(f'{pkg}.constant') + return _plugin_const_cache + + +def banner(): + logger.debug(f'doujinshi-dl ver {__version__}: あなたも変態。 いいね?') + + +def load_config(): + c = _plugin_const() + if not os.path.exists(c.PLUGIN_CONFIG_FILE): + return + + try: + with open(c.PLUGIN_CONFIG_FILE, 'r') as f: + c.CONFIG.update(json.load(f)) + except json.JSONDecodeError: + logger.error('Failed to load config file.') + write_config() + + +def write_config(): + c = _plugin_const() + if not os.path.exists(c.PLUGIN_HOME): + os.mkdir(c.PLUGIN_HOME) + + with open(c.PLUGIN_CONFIG_FILE, 'w') as f: + f.write(json.dumps(c.CONFIG)) + + +def callback(option, _opt_str, _value, parser): + if option == '--id': + pass + value = [] + + for arg in parser.rargs: + if arg.isdigit(): + value.append(int(arg)) + elif arg.startswith('-'): + break + else: + logger.warning(f'Ignore invalid id {arg}') + + setattr(parser.values, option.dest, value) + + +def cmd_parser(): + load_config() + c = _plugin_const() + + parser = ArgumentParser( + description='\n doujinshi-dl --search [keyword] --download' + '\n DOUJINSHI_DL_URL=https://mirror-url/ doujinshi-dl --id [ID ...]' + '\n doujinshi-dl --file [filename]' + '\n\nEnvironment Variable:\n' + ' DOUJINSHI_DL_URL mirror url' + ) + + # operation options + parser.add_argument('--download', '-D', dest='is_download', action='store_true', + help='download doujinshi (for search results)') + parser.add_argument('--no-download', dest='no_download', action='store_true', default=False, + help='download doujinshi (for search results)') + parser.add_argument('--show', '-S', dest='is_show', action='store_true', + help='just show the doujinshi information') + + # doujinshi options + parser.add_argument('--id', dest='id', nargs='+', type=int, + help='doujinshi ids set, e.g. 167680 167681 167682') + parser.add_argument('--search', '-s', type=str, dest='keyword', + help='search doujinshi by keyword') + parser.add_argument('--favorites', '-F', action='store_true', dest='favorites', + help='list or download your favorites') + parser.add_argument('--artist', '-a', type=str, dest='artist', + help='list doujinshi by artist name') + + # page options + parser.add_argument('--page-all', dest='page_all', action='store_true', default=False, + help='all search results') + parser.add_argument('--page', '--page-range', type=str, dest='page', + help='page number of search results. e.g. 1,2-5,14') + parser.add_argument('--sorting', '--sort', dest='sorting', type=str, default='popular', + help='sorting of doujinshi (recent / popular / popular-[today|week])', + choices=['recent', 'popular', 'popular-today', 'popular-week', 'date']) + + # download options + parser.add_argument('--output', '-o', type=str, dest='output_dir', default='.', + help='output dir') + parser.add_argument('--threads', '-t', type=int, dest='threads', default=5, + help='thread count for downloading doujinshi') + parser.add_argument('--timeout', '-T', type=int, dest='timeout', default=30, + help='timeout for downloading doujinshi') + parser.add_argument('--delay', '-d', type=int, dest='delay', default=0, + help='slow down between downloading every doujinshi') + parser.add_argument('--retry', type=int, dest='retry', default=3, + help='retry times when downloading failed') + parser.add_argument('--exit-on-fail', dest='exit_on_fail', action='store_true', default=False, + help='exit on fail to prevent generating incomplete files') + parser.add_argument('--proxy', type=str, dest='proxy', + help='store a proxy, for example: -p "http://127.0.0.1:1080"') + parser.add_argument('--file', '-f', type=str, dest='file', + help='read gallery IDs from file.') + parser.add_argument('--format', type=str, dest='name_format', default='[%i][%a][%t]', + help='format the saved folder name') + + parser.add_argument('--no-filename-padding', action='store_true', dest='no_filename_padding', + default=False, help='no padding in the images filename, such as \'001.jpg\'') + + # generate options + parser.add_argument('--html', dest='html_viewer', type=str, nargs='?', const='.', + help='generate an HTML viewer in the specified directory, or scan all subfolders ' + 'within the entire directory to generate the HTML viewer. By default, current ' + 'working directory is used.') + parser.add_argument('--no-html', dest='is_nohtml', action='store_true', + help='don\'t generate HTML after downloading') + parser.add_argument('--gen-main', dest='main_viewer', action='store_true', + help='generate a main viewer contain all the doujin in the folder') + parser.add_argument('--cbz', '-C', dest='is_cbz', action='store_true', + help='generate Comic Book CBZ File') + parser.add_argument('--pdf', '-P', dest='is_pdf', action='store_true', + help='generate PDF file') + + parser.add_argument('--meta', dest='generate_metadata', action='store_true', default=False, + help='generate a metadata file in doujinshi format') + parser.add_argument('--update-meta', dest='update_metadata', action='store_true', default=False, + help='update the metadata file of a doujinshi, update CBZ metadata if exists') + + parser.add_argument('--rm-origin-dir', dest='rm_origin_dir', action='store_true', default=False, + help='remove downloaded doujinshi dir when generated CBZ or PDF file') + parser.add_argument('--move-to-folder', dest='move_to_folder', action='store_true', default=False, + help='remove files in doujinshi dir then move new file to folder when generated CBZ or PDF file') + + parser.add_argument('--regenerate', dest='regenerate', action='store_true', default=False, + help='regenerate the cbz or pdf file if exists') + parser.add_argument('--zip', action='store_true', help='Package into a single zip file') + + # site options + parser.add_argument('--cookie', type=str, dest='cookie', + help='set cookie to bypass Cloudflare captcha') + parser.add_argument('--useragent', '--user-agent', type=str, dest='useragent', + help='set useragent to bypass Cloudflare captcha') + parser.add_argument('--language', type=str, dest='language', + help='set default language to parse doujinshis') + parser.add_argument('--clean-language', dest='clean_language', action='store_true', default=False, + help='set DEFAULT as language to parse doujinshis') + parser.add_argument('--save-download-history', dest='is_save_download_history', action='store_true', + default=False, help='save downloaded doujinshis, whose will be skipped if you re-download them') + parser.add_argument('--clean-download-history', action='store_true', default=False, dest='clean_download_history', + help='clean download history') + parser.add_argument('--template', dest='viewer_template', type=str, default='', + help='set viewer template') + parser.add_argument('--legacy', dest='legacy', action='store_true', default=False, + help='use legacy searching method') + + args = parser.parse_args() + + if args.html_viewer: + if not os.path.exists(args.html_viewer): + logger.error(f'Path \'{args.html_viewer}\' not exists') + sys.exit(1) + + for root, dirs, files in os.walk(args.html_viewer): + if not dirs: + generate_html(output_dir=args.html_viewer, template=c.CONFIG['template']) + sys.exit(0) + + for dir_name in dirs: + # it will scan the entire subdirectories + doujinshi_dir = os.path.join(root, dir_name) + items = set(map(lambda s: os.path.splitext(s)[1], os.listdir(doujinshi_dir))) + + # skip directory without any images + if items & set(EXTENSIONS): + generate_html(output_dir=doujinshi_dir, template=c.CONFIG['template']) + + sys.exit(0) + + sys.exit(0) + + if args.main_viewer and not args.id and not args.keyword and not args.favorites: + generate_main_html() + sys.exit(0) + + if args.clean_download_history: + with DB() as db: + db.clean_all() + + logger.info('Download history cleaned.') + sys.exit(0) + + # --- set config --- + if args.cookie is not None: + c.CONFIG['cookie'] = args.cookie.strip() + write_config() + logger.info('Cookie saved.') + + if args.useragent is not None: + c.CONFIG['useragent'] = args.useragent.strip() + write_config() + logger.info('User-Agent saved.') + + if args.language is not None: + c.CONFIG['language'] = args.language + write_config() + logger.info(f'Default language now set to "{args.language}"') + # TODO: search without language + + if any([args.cookie, args.useragent, args.language]): + sys.exit(0) + + if args.proxy is not None: + proxy_url = urlparse(args.proxy) + if not args.proxy == '' and proxy_url.scheme not in ('http', 'https', 'socks5', 'socks5h', + 'socks4', 'socks4a'): + logger.error(f'Invalid protocol "{proxy_url.scheme}" of proxy, ignored') + sys.exit(0) + else: + c.CONFIG['proxy'] = args.proxy + logger.info(f'Proxy now set to "{args.proxy}"') + write_config() + sys.exit(0) + + if args.viewer_template is not None: + if not args.viewer_template: + args.viewer_template = 'default' + + if not os.path.exists(os.path.join(os.path.dirname(__file__), + f'viewer/{args.viewer_template}/index.html')): + logger.error(f'Template "{args.viewer_template}" does not exists') + sys.exit(1) + else: + c.CONFIG['template'] = args.viewer_template + write_config() + + # --- end set config --- + + if args.favorites: + if not c.CONFIG['cookie']: + logger.warning('Cookie has not been set, please use `doujinshi-dl --cookie \'COOKIE\'` to set it.') + sys.exit(1) + + if args.file: + with open(args.file, 'r') as f: + _ = [i.strip() for i in f.readlines()] + args.id = set(int(i) for i in _ if i.isdigit()) + + if (args.is_download or args.is_show) and not args.id and not args.keyword and not args.favorites and not args.artist: + logger.critical('Doujinshi id(s) are required for downloading') + parser.print_help() + sys.exit(1) + + if not args.keyword and not args.id and not args.favorites and not args.artist: + parser.print_help() + sys.exit(1) + + if args.threads <= 0: + args.threads = 1 + + elif args.threads > 15: + logger.critical('Maximum number of used threads is 15') + sys.exit(1) + + return args diff --git a/doujinshi_dl/command.py b/doujinshi_dl/command.py new file mode 100644 index 0000000..89b8a49 --- /dev/null +++ b/doujinshi_dl/command.py @@ -0,0 +1,197 @@ +# coding: utf-8 +import os +import shutil +import sys +import signal +import platform +import urllib3.exceptions + +from doujinshi_dl.cmdline import cmd_parser, banner, write_config +from doujinshi_dl.core.registry import get_first_plugin +from doujinshi_dl.core import config as core_config +from doujinshi_dl.downloader import Downloader, CompressedDownloader +from doujinshi_dl.logger import logger +from doujinshi_dl.utils import ( + generate_html, generate_doc, generate_main_html, + paging, signal_handler, DB, move_to_folder, +) + + +def main(): + banner() + + if sys.version_info < (3, 0, 0): + logger.error('doujinshi-dl requires Python 3.x') + sys.exit(1) + + plugin = get_first_plugin() + parser = plugin.create_parser() + serializer = plugin.create_serializer() + + options = cmd_parser() + + # Let the plugin configure its own CONFIG and register runtime values + # (this also sets core_config 'base_url' from env or plugin default) + plugin.configure(options) + + # Read common config values registered by the plugin + plugin_config = core_config.get('plugin_config', {}) + base_url = core_config.get('base_url', os.getenv('DOUJINSHI_DL_URL', '')) + if not base_url: + logger.error('No target URL configured. Set DOUJINSHI_DL_URL or install a plugin that provides a default URL.') + sys.exit(1) + logger.info(f'Using mirror: {base_url}') + + # CONFIG['proxy'] may have been updated after cmd_parser() + proxy = plugin_config.get('proxy', '') + if proxy: + if isinstance(proxy, dict): + proxy = proxy.get('http', '') + plugin_config['proxy'] = proxy + logger.warning(f'Update proxy config to: {proxy}') + write_config() + logger.info(f'Using proxy: {proxy}') + + if not plugin_config.get('template'): + plugin_config['template'] = 'default' + + template = plugin_config.get('template', 'default') + language = plugin_config.get('language', '') + logger.info(f'Using viewer template "{template}"') + + # Check authentication + plugin.check_auth() + + doujinshis = [] + doujinshi_ids = [] + + page_list = paging(options.page) + + if options.favorites: + if not options.is_download: + logger.warning('You do not specify --download option') + + doujinshis = parser.favorites(page=page_list) if options.page else parser.favorites() + + elif options.keyword: + if language: + logger.info(f'Using default language: {language}') + options.keyword += f' language:{language}' + + doujinshis = parser.search( + options.keyword, + sorting=options.sorting, + page=page_list, + legacy=options.legacy, + is_page_all=options.page_all, + ) + + elif options.artist: + doujinshis = parser.search( + options.artist, + sorting=options.sorting, + page=page_list, + is_page_all=options.page_all, + type_='ARTIST', + ) + + elif not doujinshi_ids: + doujinshi_ids = options.id + + plugin.print_results(doujinshis) + if options.is_download and doujinshis: + doujinshi_ids = [i['id'] for i in doujinshis] + + if options.is_save_download_history: + with DB() as db: + data = set(map(int, db.get_all())) + + doujinshi_ids = list(set(map(int, doujinshi_ids)) - set(data)) + logger.info(f'New doujinshis account: {len(doujinshi_ids)}') + + if options.zip: + options.is_nohtml = True + + if not options.is_show: + downloader = (CompressedDownloader if options.zip else Downloader)( + path=options.output_dir, + threads=options.threads, + timeout=options.timeout, + delay=options.delay, + exit_on_fail=options.exit_on_fail, + no_filename_padding=options.no_filename_padding, + ) + + for doujinshi_id in doujinshi_ids: + meta = parser.fetch(str(doujinshi_id)) + if not meta: + continue + + doujinshi_model = plugin.create_model(meta, name_format=options.name_format) + doujinshi = doujinshi_model.doujinshi + doujinshi.downloader = downloader + + if doujinshi.check_if_need_download(options): + doujinshi.download() + else: + logger.info( + f'Skip download doujinshi because a PDF/CBZ file exists of doujinshi {doujinshi.name}' + ) + + doujinshi_dir = os.path.join(options.output_dir, doujinshi.filename) + + if options.generate_metadata: + serializer.write_all(meta, doujinshi_dir) + logger.log(16, f'Metadata files have been written to "{doujinshi_dir}"') + + if options.is_save_download_history: + with DB() as db: + db.add_one(doujinshi.id) + + if not options.is_nohtml: + generate_html(options.output_dir, doujinshi, template=template) + + if options.is_cbz: + # Write ComicInfo.xml metadata before packaging + serializer.write_all(meta, doujinshi_dir) + generate_doc('cbz', options.output_dir, doujinshi, options.regenerate) + + if options.is_pdf: + generate_doc('pdf', options.output_dir, doujinshi, options.regenerate) + + if options.move_to_folder: + if options.is_cbz: + move_to_folder(options.output_dir, doujinshi, 'cbz') + if options.is_pdf: + move_to_folder(options.output_dir, doujinshi, 'pdf') + + if options.rm_origin_dir: + if options.move_to_folder: + logger.critical('You specified both --move-to-folder and --rm-origin-dir options, ' + 'you will not get anything :(') + shutil.rmtree(os.path.join(options.output_dir, doujinshi.filename), ignore_errors=True) + + if options.main_viewer: + generate_main_html(options.output_dir) + serializer.finalize(options.output_dir) + + if not platform.system() == 'Windows': + logger.log(16, '🍻 All done.') + else: + logger.log(16, 'All done.') + + else: + for doujinshi_id in doujinshi_ids: + meta = parser.fetch(str(doujinshi_id)) + if not meta: + continue + doujinshi_model = plugin.create_model(meta, name_format=options.name_format) + doujinshi = doujinshi_model.doujinshi + doujinshi.show() + + +urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) +signal.signal(signal.SIGINT, signal_handler) + +if __name__ == '__main__': + main() diff --git a/doujinshi_dl/constant.py b/doujinshi_dl/constant.py new file mode 100644 index 0000000..5ea227b --- /dev/null +++ b/doujinshi_dl/constant.py @@ -0,0 +1,9 @@ +# coding: utf-8 +"""Main-package constants. + +Only the constants that the main package itself needs are defined here. +Plugin-specific constants live in the respective plugin package. +""" +import os + +PATH_SEPARATOR = os.path.sep diff --git a/doujinshi_dl/core/__init__.py b/doujinshi_dl/core/__init__.py new file mode 100644 index 0000000..57d631c --- /dev/null +++ b/doujinshi_dl/core/__init__.py @@ -0,0 +1 @@ +# coding: utf-8 diff --git a/doujinshi_dl/core/config.py b/doujinshi_dl/core/config.py new file mode 100644 index 0000000..643d13e --- /dev/null +++ b/doujinshi_dl/core/config.py @@ -0,0 +1,16 @@ +# coding: utf-8 +"""Runtime configuration store for the main package. + +Plugins write their paths and settings here so that generic utilities +(e.g. db.py) can read them without hard-coding any plugin name. +""" + +_runtime: dict = {} + + +def set(key: str, value) -> None: + _runtime[key] = value + + +def get(key: str, default=None): + return _runtime.get(key, default) diff --git a/doujinshi_dl/core/downloader.py b/doujinshi_dl/core/downloader.py new file mode 100644 index 0000000..8772b2c --- /dev/null +++ b/doujinshi_dl/core/downloader.py @@ -0,0 +1,214 @@ +# coding: utf- + +import os +import asyncio +import httpx +import urllib3.exceptions +import zipfile +import io + +from urllib.parse import urlparse +from doujinshi_dl.core.logger import logger +from doujinshi_dl.core.utils.db import Singleton +from doujinshi_dl.core import config as core_config + + +async def _async_request(method, url, timeout=30, proxy=None): + """Minimal async HTTP helper using httpx directly.""" + # httpx >=0.28 uses `proxy` (str), older versions used `proxies` (dict) + client_kwargs = {'verify': False} + if proxy: + client_kwargs['proxy'] = proxy + async with httpx.AsyncClient(**client_kwargs) as client: + headers = {} + cookie = core_config.get('plugin_config', {}).get('cookie', '') + useragent = core_config.get('plugin_config', {}).get('useragent', '') + if cookie: + headers['Cookie'] = cookie + if useragent: + headers['User-Agent'] = useragent + return await client.request(method, url, timeout=timeout, headers=headers, follow_redirects=True) + + +urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + +def download_callback(result): + result, data = result + if result == 0: + logger.warning('fatal errors occurred, ignored') + elif result == -1: + logger.warning(f'url {data} return status code 404') + elif result == -2: + logger.warning('Ctrl-C pressed, exiting sub processes ...') + elif result == -3: + # workers won't be run, just pass + pass + else: + logger.log(16, f'{data} downloaded successfully') + + +class Downloader(Singleton): + def __init__(self, path='', threads=5, timeout=30, delay=0, exit_on_fail=False, + no_filename_padding=False): + self.threads = threads + self.path = str(path) + self.timeout = timeout + self.delay = delay + self.exit_on_fail = exit_on_fail + self.folder = None + self.semaphore = None + self.no_filename_padding = no_filename_padding + + async def fiber(self, tasks): + self.semaphore = asyncio.Semaphore(self.threads) + for completed_task in asyncio.as_completed(tasks): + try: + result = await completed_task + if result[0] > 0: + logger.info(f'{result[1]} download completed') + else: + raise Exception(f'{result[1]} download failed, return value {result[0]}') + except Exception as e: + logger.error(f'An error occurred: {e}') + if self.exit_on_fail: + raise Exception('User intends to exit on fail') + + async def _semaphore_download(self, *args, **kwargs): + async with self.semaphore: + return await self.download(*args, **kwargs) + + async def download(self, url, folder='', filename='', retried=0, proxy=None, length=0): + logger.info(f'Starting to download {url} ...') + + if self.delay: + await asyncio.sleep(self.delay) + + filename = filename if filename else os.path.basename(urlparse(url).path) + base_filename, extension = os.path.splitext(filename) + + if not self.no_filename_padding: + filename = base_filename.zfill(length) + extension + else: + filename = base_filename + extension + + try: + response = await _async_request('GET', url, timeout=self.timeout, proxy=proxy) + + if response.status_code != 200: + path = urlparse(url).path + image_url_mirrors = core_config.get('image_url_mirrors', []) + for mirror in image_url_mirrors: + logger.info(f"Try mirror: {mirror}{path}") + mirror_url = f'{mirror}{path}' + response = await _async_request('GET', mirror_url, timeout=self.timeout, proxy=proxy) + if response.status_code == 200: + break + + if not await self.save(filename, response): + logger.error(f'Can not download image {url}') + return -1, url + + except (httpx.HTTPStatusError, httpx.TimeoutException, httpx.ConnectError) as e: + retry_times = core_config.get('retry_times', 3) + if retried < retry_times: + logger.warning(f'Download {filename} failed, retrying({retried + 1}) times...') + return await self.download( + url=url, + folder=folder, + filename=filename, + retried=retried + 1, + proxy=proxy, + ) + else: + logger.warning(f'Download {filename} failed with {retry_times} times retried, skipped') + return -2, url + + except Exception as e: + import traceback + + logger.error(f"Exception type: {type(e)}") + traceback.print_stack() + logger.critical(str(e)) + return -9, url + + except KeyboardInterrupt: + return -4, url + + return 1, url + + async def save(self, filename, response) -> bool: + if response is None: + logger.error('Error: Response is None') + return False + save_file_path = os.path.join(self.folder, filename) + with open(save_file_path, 'wb') as f: + if response is not None: + length = response.headers.get('content-length') + if length is None: + f.write(response.content) + else: + async for chunk in response.aiter_bytes(2048): + f.write(chunk) + return True + + def create_storage_object(self, folder:str): + if not os.path.exists(folder): + try: + os.makedirs(folder) + except EnvironmentError as e: + logger.critical(str(e)) + self.folder:str = folder + self.close = lambda: None # Only available in class CompressedDownloader + + def start_download(self, queue, folder='') -> bool: + if not isinstance(folder, (str,)): + folder = str(folder) + + if self.path: + folder = os.path.join(self.path, folder) + + logger.info(f'Doujinshi will be saved at "{folder}"') + self.create_storage_object(folder) + + if os.getenv('DEBUG', None) == 'NODOWNLOAD': + # Assuming we want to continue with rest of process. + return True + + digit_length = len(str(len(queue))) + logger.info(f'Total download pages: {len(queue)}') + coroutines = [ + self._semaphore_download(url, filename=os.path.basename(urlparse(url).path), length=digit_length) + for url in queue + ] + + # Prevent coroutines infection + asyncio.run(self.fiber(coroutines)) + + self.close() + + return True + +class CompressedDownloader(Downloader): + def create_storage_object(self, folder): + filename = f'{folder}.zip' + print(filename) + self.zipfile = zipfile.ZipFile(filename,'w') + self.close = lambda: self.zipfile.close() + + async def save(self, filename, response) -> bool: + if response is None: + logger.error('Error: Response is None') + return False + + image_data = io.BytesIO() + length = response.headers.get('content-length') + if length is None: + content = await response.read() + image_data.write(content) + else: + async for chunk in response.aiter_bytes(2048): + image_data.write(chunk) + + image_data.seek(0) + self.zipfile.writestr(filename, image_data.read()) + return True diff --git a/doujinshi_dl/core/logger.py b/doujinshi_dl/core/logger.py new file mode 100644 index 0000000..e162439 --- /dev/null +++ b/doujinshi_dl/core/logger.py @@ -0,0 +1,179 @@ +# +# Copyright (C) 2010-2012 Vinay Sajip. All rights reserved. Licensed under the new BSD license. +# +import logging +import re +import platform +import sys + + +if platform.system() == 'Windows': + import ctypes + import ctypes.wintypes + + # Reference: https://gist.github.com/vsajip/758430 + # https://github.com/ipython/ipython/issues/4252 + # https://msdn.microsoft.com/en-us/library/windows/desktop/ms686047%28v=vs.85%29.aspx + ctypes.windll.kernel32.SetConsoleTextAttribute.argtypes = [ctypes.wintypes.HANDLE, ctypes.wintypes.WORD] + ctypes.windll.kernel32.SetConsoleTextAttribute.restype = ctypes.wintypes.BOOL + + +class ColorizingStreamHandler(logging.StreamHandler): + # color names to indices + color_map = { + 'black': 0, + 'red': 1, + 'green': 2, + 'yellow': 3, + 'blue': 4, + 'magenta': 5, + 'cyan': 6, + 'white': 7, + } + + # levels to (background, foreground, bold/intense) + level_map = { + logging.DEBUG: (None, 'blue', False), + logging.INFO: (None, 'white', False), + logging.WARNING: (None, 'yellow', False), + logging.ERROR: (None, 'red', False), + logging.CRITICAL: ('red', 'white', False) + } + csi = '\x1b[' + reset = '\x1b[0m' + disable_coloring = False + + @property + def is_tty(self): + isatty = getattr(self.stream, 'isatty', None) + return isatty and isatty() and not self.disable_coloring + + def emit(self, record): + try: + message = self.format(record) + stream = self.stream + + if not self.is_tty: + if message and message[0] == "\r": + message = message[1:] + stream.write(message) + else: + self.output_colorized(message) + stream.write(getattr(self, 'terminator', '\n')) + + self.flush() + except (KeyboardInterrupt, SystemExit): + raise + except IOError: + pass + except: + self.handleError(record) + + + if not platform.system() == 'Windows': + def output_colorized(self, message): + self.stream.write(message) + else: + ansi_esc = re.compile(r'\x1b\[((?:\d+)(?:;(?:\d+))*)m') + + nt_color_map = { + 0: 0x00, # black + 1: 0x04, # red + 2: 0x02, # green + 3: 0x06, # yellow + 4: 0x01, # blue + 5: 0x05, # magenta + 6: 0x03, # cyan + 7: 0x07, # white + } + + def output_colorized(self, message): + parts = self.ansi_esc.split(message) + write = self.stream.write + h = None + fd = getattr(self.stream, 'fileno', None) + + if fd is not None: + fd = fd() + + if fd in (1, 2): # stdout or stderr + h = ctypes.windll.kernel32.GetStdHandle(-10 - fd) + + while parts: + text = parts.pop(0) + + if text: + if sys.version_info < (3, 0, 0): + write(text.encode('utf-8')) + else: + write(text) + + if parts: + params = parts.pop(0) + + if h is not None: + params = [int(p) for p in params.split(';')] + color = 0 + + for p in params: + if 40 <= p <= 47: + color |= self.nt_color_map[p - 40] << 4 + elif 30 <= p <= 37: + color |= self.nt_color_map[p - 30] + elif p == 1: + color |= 0x08 # foreground intensity on + elif p == 0: # reset to default color + color = 0x07 + else: + pass # error condition ignored + + ctypes.windll.kernel32.SetConsoleTextAttribute(h, color) + + def colorize(self, message, record): + if record.levelno in self.level_map and self.is_tty: + bg, fg, bold = self.level_map[record.levelno] + params = [] + + if bg in self.color_map: + params.append(str(self.color_map[bg] + 40)) + + if fg in self.color_map: + params.append(str(self.color_map[fg] + 30)) + + if bold: + params.append('1') + + if params and message: + if message.lstrip() != message: + prefix = re.search(r"\s+", message).group(0) + message = message[len(prefix):] + else: + prefix = "" + + message = "%s%s" % (prefix, ''.join((self.csi, ';'.join(params), + 'm', message, self.reset))) + + return message + + def format(self, record): + message = logging.StreamHandler.format(self, record) + return self.colorize(message, record) + + +logging.addLevelName(16, "SUCCESS") +logger = logging.getLogger('doujinshi_dl') +LOGGER_HANDLER = ColorizingStreamHandler(sys.stdout) +FORMATTER = logging.Formatter("\r[%(asctime)s] %(funcName)s: %(message)s", "%H:%M:%S") +LOGGER_HANDLER.setFormatter(FORMATTER) +LOGGER_HANDLER.level_map[logging.getLevelName("SUCCESS")] = (None, "green", False) +logger.addHandler(LOGGER_HANDLER) +logger.setLevel(logging.DEBUG) + + +if __name__ == '__main__': + logger.log(16, 'doujinshi-dl') + logger.info('info') + logger.warning('warning') + logger.debug('debug') + logger.error('error') + logger.critical('critical') diff --git a/doujinshi_dl/core/plugin.py b/doujinshi_dl/core/plugin.py new file mode 100644 index 0000000..6a077b6 --- /dev/null +++ b/doujinshi_dl/core/plugin.py @@ -0,0 +1,77 @@ +# coding: utf-8 +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from typing import List, Dict, Any, Iterator, Tuple + + +@dataclass +class GalleryMeta: + id: str + name: str + pretty_name: str + img_id: str + ext: list + pages: int + info: Dict[str, Any] = field(default_factory=dict) + extra: Dict[str, Any] = field(default_factory=dict) # plugin-private data + + def to_dict(self) -> dict: + d = { + 'id': self.id, + 'name': self.name, + 'pretty_name': self.pretty_name, + 'img_id': self.img_id, + 'ext': self.ext, + 'pages': self.pages, + } + d.update(self.info) + d.update(self.extra) + return d + + +class BaseParser(ABC): + @abstractmethod + def fetch(self, gallery_id: str) -> GalleryMeta: ... + + @abstractmethod + def search(self, keyword: str, sorting: str = 'date', page=None, **kwargs) -> List[Dict]: ... + + def favorites(self, page=None) -> List[Dict]: + return [] + + def configure(self, args): ... + + +class BaseModel(ABC): + @abstractmethod + def iter_tasks(self) -> Iterator[Tuple[str, str]]: ... + # yields (url, filename) tuples + + +class BaseSerializer(ABC): + @abstractmethod + def write_all(self, meta: GalleryMeta, output_dir: str): ... + + def finalize(self, output_dir: str) -> None: + pass + + +class BasePlugin(ABC): + name: str + + @abstractmethod + def create_parser(self) -> BaseParser: ... + + @abstractmethod + def create_model(self, meta: GalleryMeta, name_format: str = '[%i][%a][%t]') -> BaseModel: ... + + @abstractmethod + def create_serializer(self) -> BaseSerializer: ... + + def register_args(self, argparser): pass + + def check_auth(self) -> None: + pass + + def print_results(self, results) -> None: + pass diff --git a/doujinshi_dl/core/registry.py b/doujinshi_dl/core/registry.py new file mode 100644 index 0000000..62f62b1 --- /dev/null +++ b/doujinshi_dl/core/registry.py @@ -0,0 +1,28 @@ +# coding: utf-8 +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from doujinshi_dl.core.plugin import BasePlugin + + +def get_plugin(name: str) -> 'BasePlugin': + from importlib.metadata import entry_points + eps = entry_points(group='doujinshi_dl.plugins') + for ep in eps: + if ep.name == name: + return ep.load() + raise KeyError( + f"Plugin {name!r} not found. " + f"Install it with: pip install doujinshi-dl-{name}" + ) + + +def get_first_plugin() -> 'BasePlugin': + from importlib.metadata import entry_points + eps = list(entry_points(group='doujinshi_dl.plugins')) + if not eps: + raise RuntimeError( + "No doujinshi-dl plugin installed. " + "Install a plugin from PyPI, e.g.: pip install doujinshi-dl-" + ) + return eps[0].load() diff --git a/doujinshi_dl/core/utils/__init__.py b/doujinshi_dl/core/utils/__init__.py new file mode 100644 index 0000000..9bed73f --- /dev/null +++ b/doujinshi_dl/core/utils/__init__.py @@ -0,0 +1,5 @@ +# coding: utf-8 +from doujinshi_dl.core.utils.db import Singleton, DB +from doujinshi_dl.core.utils.fs import format_filename, generate_cbz, move_to_folder, parse_doujinshi_obj, EXTENSIONS +from doujinshi_dl.core.utils.html import generate_html, generate_main_html +from doujinshi_dl.core.utils.http import async_request diff --git a/doujinshi_dl/core/utils/db.py b/doujinshi_dl/core/utils/db.py new file mode 100644 index 0000000..0802ca3 --- /dev/null +++ b/doujinshi_dl/core/utils/db.py @@ -0,0 +1,50 @@ +# coding: utf-8 +"""DB and Singleton utilities.""" +import os +import sqlite3 + + +class _Singleton(type): + """ A metaclass that creates a Singleton base class when called. """ + _instances = {} + + def __call__(cls, *args, **kwargs): + if cls not in cls._instances: + cls._instances[cls] = super(_Singleton, cls).__call__(*args, **kwargs) + return cls._instances[cls] + + +class Singleton(_Singleton(str('SingletonMeta'), (object,), {})): + pass + + +class DB(object): + conn = None + cur = None + + def __enter__(self): + from doujinshi_dl.core import config + history_path = config.get( + 'history_path', + os.path.expanduser('~/.doujinshi-dl/history.sqlite3'), + ) + self.conn = sqlite3.connect(history_path) + self.cur = self.conn.cursor() + self.cur.execute('CREATE TABLE IF NOT EXISTS download_history (id text)') + self.conn.commit() + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self.conn.close() + + def clean_all(self): + self.cur.execute('DELETE FROM download_history WHERE 1') + self.conn.commit() + + def add_one(self, data): + self.cur.execute('INSERT INTO download_history VALUES (?)', [data]) + self.conn.commit() + + def get_all(self): + data = self.cur.execute('SELECT id FROM download_history') + return [i[0] for i in data] diff --git a/doujinshi_dl/core/utils/fs.py b/doujinshi_dl/core/utils/fs.py new file mode 100644 index 0000000..9510517 --- /dev/null +++ b/doujinshi_dl/core/utils/fs.py @@ -0,0 +1,98 @@ +# coding: utf-8 +"""Filesystem utilities: filename formatting, CBZ generation, folder management.""" +import os +import zipfile +import shutil +from typing import Tuple + +from doujinshi_dl.core.logger import logger +from doujinshi_dl.constant import PATH_SEPARATOR + +MAX_FIELD_LENGTH = 100 +EXTENSIONS = ('.png', '.jpg', '.jpeg', '.gif', '.webp') + + +def format_filename(s, length=MAX_FIELD_LENGTH, _truncate_only=False): + """ + It used to be a whitelist approach allowed only alphabet and a part of symbols. + but most doujinshi's names include Japanese 2-byte characters and these was rejected. + so it is using blacklist approach now. + if filename include forbidden characters ('/:,;*?"<>|) ,it replaces space character(" "). + """ + if not _truncate_only: + ban_chars = '\\\'/:,;*?"<>|\t\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b' + filename = s.translate(str.maketrans(ban_chars, ' ' * len(ban_chars))).strip() + filename = ' '.join(filename.split()) + + while filename.endswith('.'): + filename = filename[:-1] + else: + filename = s + + # limit `length` chars + if len(filename) >= length: + filename = filename[:length - 1] + u'…' + + # Remove [] from filename + filename = filename.replace('[]', '').strip() + return filename + + +def parse_doujinshi_obj( + output_dir: str, + doujinshi_obj=None, + file_type: str = '' +) -> Tuple[str, str]: + filename = f'.{PATH_SEPARATOR}doujinshi.{file_type}' + if doujinshi_obj is not None: + doujinshi_dir = os.path.join(output_dir, doujinshi_obj.filename) + _filename = f'{doujinshi_obj.filename}.{file_type}' + + if file_type == 'pdf': + _filename = _filename.replace('/', '-') + + filename = os.path.join(output_dir, _filename) + else: + if file_type == 'html': + return output_dir, 'index.html' + + doujinshi_dir = f'.{PATH_SEPARATOR}' + + if not os.path.exists(doujinshi_dir): + os.makedirs(doujinshi_dir) + + return doujinshi_dir, filename + + +def generate_cbz(doujinshi_dir, filename): + file_list = os.listdir(doujinshi_dir) + file_list.sort() + + logger.info(f'Writing CBZ file to path: {filename}') + with zipfile.ZipFile(filename, 'w') as cbz_pf: + for image in file_list: + image_path = os.path.join(doujinshi_dir, image) + cbz_pf.write(image_path, image) + + logger.log(16, f'Comic Book CBZ file has been written to "{filename}"') + + +def move_to_folder(output_dir='.', doujinshi_obj=None, file_type=None): + if not file_type: + raise RuntimeError('no file_type specified') + + doujinshi_dir, filename = parse_doujinshi_obj(output_dir, doujinshi_obj, file_type) + + for fn in os.listdir(doujinshi_dir): + file_path = os.path.join(doujinshi_dir, fn) + _, ext = os.path.splitext(file_path) + if ext in ['.pdf', '.cbz']: + continue + + if os.path.isfile(file_path): + try: + os.remove(file_path) + except Exception as e: + print(f"Error deleting file: {e}") + + shutil.move(filename, os.path.join(doujinshi_dir, os.path.basename(filename))) diff --git a/doujinshi_dl/core/utils/html.py b/doujinshi_dl/core/utils/html.py new file mode 100644 index 0000000..b477c22 --- /dev/null +++ b/doujinshi_dl/core/utils/html.py @@ -0,0 +1,118 @@ +# coding: utf-8 +"""HTML viewer generation utilities (generic, no site-specific references).""" +import json +import os +import urllib.parse + +from doujinshi_dl.core.logger import logger +from doujinshi_dl.core.utils.fs import EXTENSIONS, parse_doujinshi_obj +from doujinshi_dl.constant import PATH_SEPARATOR + + +def _readfile(path): + loc = os.path.dirname(os.path.dirname(os.path.dirname(__file__))) # doujinshi_dl/ + + with open(os.path.join(loc, path), 'r') as file: + return file.read() + + +def generate_html(output_dir='.', doujinshi_obj=None, template='default'): + doujinshi_dir, filename = parse_doujinshi_obj(output_dir, doujinshi_obj, 'html') + image_html = '' + + if not os.path.exists(doujinshi_dir): + logger.warning(f'Path "{doujinshi_dir}" does not exist, creating.') + try: + os.makedirs(doujinshi_dir) + except EnvironmentError as e: + logger.critical(e) + + file_list = os.listdir(doujinshi_dir) + file_list.sort() + + for image in file_list: + if not os.path.splitext(image)[1] in EXTENSIONS: + continue + image_html += f'\n' + + html = _readfile(f'viewer/{template}/index.html') + css = _readfile(f'viewer/{template}/styles.css') + js = _readfile(f'viewer/{template}/scripts.js') + + if doujinshi_obj is not None: + name = doujinshi_obj.name + else: + metadata_path = os.path.join(doujinshi_dir, "metadata.json") + if os.path.exists(metadata_path): + with open(metadata_path, 'r') as file: + doujinshi_info = json.loads(file.read()) + name = doujinshi_info.get("title") + else: + name = 'Doujinshi HTML Viewer' + + data = html.format(TITLE=name, IMAGES=image_html, SCRIPTS=js, STYLES=css) + try: + with open(os.path.join(doujinshi_dir, 'index.html'), 'wb') as f: + f.write(data.encode('utf-8')) + + logger.log(16, f'HTML Viewer has been written to "{os.path.join(doujinshi_dir, "index.html")}"') + except Exception as e: + logger.warning(f'Writing HTML Viewer failed ({e})') + + +def generate_main_html(output_dir=f'.{PATH_SEPARATOR}'): + """ + Generate a main html to show all the contained doujinshi. + With a link to their `index.html`. + Default output folder will be the CLI path. + """ + import shutil + + image_html = '' + + main = _readfile('viewer/main.html') + css = _readfile('viewer/main.css') + js = _readfile('viewer/main.js') + + element = '\n\ + \n' + + os.chdir(output_dir) + doujinshi_dirs = next(os.walk('.'))[1] + + for folder in doujinshi_dirs: + files = os.listdir(folder) + files.sort() + + if 'index.html' in files: + logger.info(f'Add doujinshi "{folder}"') + else: + continue + + image = files[0] # 001.jpg or 001.png + if folder is not None: + title = folder.replace('_', ' ') + else: + title = 'Doujinshi HTML Viewer' + + image_html += element.format(FOLDER=urllib.parse.quote(folder), IMAGE=image, TITLE=title) + if image_html == '': + logger.warning('No index.html found, --gen-main paused.') + return + try: + data = main.format(STYLES=css, SCRIPTS=js, PICTURE=image_html) + with open('./main.html', 'wb') as f: + f.write(data.encode('utf-8')) + pkg_dir = os.path.dirname(os.path.dirname(os.path.dirname(__file__))) + shutil.copy(os.path.join(pkg_dir, 'viewer/logo.png'), './') + output_dir = output_dir[:-1] if output_dir.endswith('/') else output_dir + logger.log(16, f'Main Viewer has been written to "{output_dir}/main.html"') + except Exception as e: + logger.warning(f'Writing Main Viewer failed ({e})') diff --git a/doujinshi_dl/core/utils/http.py b/doujinshi_dl/core/utils/http.py new file mode 100644 index 0000000..562cb78 --- /dev/null +++ b/doujinshi_dl/core/utils/http.py @@ -0,0 +1,34 @@ +# coding: utf-8 +"""Generic async HTTP request helper (no site-specific headers injected here).""" +import httpx +import urllib3.exceptions + +urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + + +async def async_request(method, url, proxy=None, **kwargs): + """ + Thin async HTTP client wrapper. + + Header injection (Cookie, User-Agent, Referer) is done by callers that + have access to site-specific configuration; this helper stays generic. + """ + from doujinshi_dl import constant + + headers = kwargs.pop('headers', {}) + + if proxy is None: + proxy = constant.CONFIG.get('proxy', '') + + if isinstance(proxy, str) and not proxy: + proxy = None + + # Remove 'timeout' from kwargs to avoid duplicate keyword argument since + # httpx.AsyncClient accepts it as a constructor arg or request arg. + timeout = kwargs.pop('timeout', 30) + + async with httpx.AsyncClient(headers=headers, verify=False, proxy=proxy, + timeout=timeout) as client: + response = await client.request(method, url, **kwargs) + + return response diff --git a/doujinshi_dl/downloader.py b/doujinshi_dl/downloader.py new file mode 100644 index 0000000..74a4476 --- /dev/null +++ b/doujinshi_dl/downloader.py @@ -0,0 +1,5 @@ +# coding: utf-8 +# Compatibility shim — re-exports from new location. +# Preserves backward compatibility for: from doujinshi_dl.downloader import Downloader, CompressedDownloader +from doujinshi_dl.core.downloader import * # noqa: F401, F403 +from doujinshi_dl.core.downloader import Downloader, CompressedDownloader, download_callback # noqa: F401 diff --git a/doujinshi_dl/logger.py b/doujinshi_dl/logger.py new file mode 100644 index 0000000..33aee6b --- /dev/null +++ b/doujinshi_dl/logger.py @@ -0,0 +1,5 @@ +# coding: utf-8 +# Compatibility shim — re-exports from new location. +# Preserves backward compatibility for: from doujinshi_dl.logger import logger +from doujinshi_dl.core.logger import * # noqa: F401, F403 +from doujinshi_dl.core.logger import logger # noqa: F401 diff --git a/doujinshi_dl/utils.py b/doujinshi_dl/utils.py new file mode 100644 index 0000000..003b281 --- /dev/null +++ b/doujinshi_dl/utils.py @@ -0,0 +1,77 @@ +# coding: utf-8 +# Utility helpers for the main package. +# No plugin-specific imports. + +# Generic filesystem / HTML utilities +from doujinshi_dl.core.utils.fs import ( # noqa: F401 + format_filename, parse_doujinshi_obj, generate_cbz, move_to_folder, + EXTENSIONS, MAX_FIELD_LENGTH, +) +from doujinshi_dl.core.utils.html import generate_html, generate_main_html # noqa: F401 +from doujinshi_dl.core.utils.db import Singleton, DB # noqa: F401 + +# Signal handler and paging helper (kept inline — they have no site-specific code) +import sys +from doujinshi_dl.core.logger import logger # noqa: F401 + + +def signal_handler(_signal, _frame): + logger.error('Ctrl-C signal received. Stopping...') + sys.exit(1) + + +def paging(page_string): + # 1,3-5,14 -> [1, 3, 4, 5, 14] + if not page_string: + return [1] + + page_list = [] + for i in page_string.split(','): + if '-' in i: + start, end = i.split('-') + if not (start.isdigit() and end.isdigit()): + raise Exception('Invalid page number') + page_list.extend(list(range(int(start), int(end) + 1))) + else: + if not i.isdigit(): + raise Exception('Invalid page number') + page_list.append(int(i)) + + return page_list + + +def generate_doc(file_type='', output_dir='.', doujinshi_obj=None, regenerate=False): + """Generate a CBZ or PDF document from a downloaded doujinshi directory. + + For CBZ, any metadata files (ComicInfo.xml, etc.) should be written to the + directory *before* calling this function. + """ + import os + + doujinshi_dir, filename = parse_doujinshi_obj(output_dir, doujinshi_obj, file_type) + + if os.path.exists(f'{doujinshi_dir}.{file_type}') and not regenerate: + logger.info(f'Skipped {file_type} file generation: {doujinshi_dir}.{file_type} already exists') + return + + if file_type == 'cbz': + generate_cbz(doujinshi_dir, filename) + + elif file_type == 'pdf': + try: + import img2pdf + + file_list = [f for f in os.listdir(doujinshi_dir) if f.lower().endswith(EXTENSIONS)] + file_list.sort() + + logger.info(f'Writing PDF file to path: {filename}') + with open(filename, 'wb') as pdf_f: + full_path_list = [os.path.join(doujinshi_dir, image) for image in file_list] + pdf_f.write(img2pdf.convert(full_path_list, rotation=img2pdf.Rotation.ifvalid)) + + logger.log(16, f'PDF file has been written to "{filename}"') + + except ImportError: + logger.error("Please install img2pdf package by using pip.") + else: + raise ValueError('invalid file type') diff --git a/doujinshi_dl/viewer/default/index.html b/doujinshi_dl/viewer/default/index.html new file mode 100644 index 0000000..33ea2af --- /dev/null +++ b/doujinshi_dl/viewer/default/index.html @@ -0,0 +1,25 @@ + + + + + + {TITLE} + + + + + + +
+ +
+
+ + + + \ No newline at end of file diff --git a/doujinshi_dl/viewer/default/scripts.js b/doujinshi_dl/viewer/default/scripts.js new file mode 100644 index 0000000..a367c9b --- /dev/null +++ b/doujinshi_dl/viewer/default/scripts.js @@ -0,0 +1,87 @@ +const pages = Array.from(document.querySelectorAll('img.image-item')); +let currentPage = 0; + +function changePage(pageNum) { + const previous = pages[currentPage]; + const current = pages[pageNum]; + + if (current == null) { + return; + } + + previous.classList.remove('current'); + current.classList.add('current'); + + currentPage = pageNum; + + const display = document.getElementById('dest'); + display.style.backgroundImage = `url("${current.src}")`; + + scroll(0,0) + + document.getElementById('page-num') + .innerText = [ + (pageNum + 1).toLocaleString(), + pages.length.toLocaleString() + ].join('\u200a/\u200a'); +} + +changePage(0); + +document.getElementById('list').onclick = event => { + if (pages.includes(event.target)) { + changePage(pages.indexOf(event.target)); + } +}; + +document.getElementById('image-container').onclick = event => { + const width = document.getElementById('image-container').clientWidth; + const clickPos = event.clientX / width; + + if (clickPos < 0.5) { + changePage(currentPage - 1); + } else { + changePage(currentPage + 1); + } +}; + +document.onkeypress = event => { + switch (event.key.toLowerCase()) { + // Previous Image + case 'w': + scrollBy(0, -40); + break; + case 'a': + changePage(currentPage - 1); + break; + // Return to previous page + case 'q': + window.history.go(-1); + break; + // Next Image + case ' ': + case 's': + scrollBy(0, 40); + break; + case 'd': + changePage(currentPage + 1); + break; + }// remove arrow cause it won't work +}; + +document.onkeydown = event =>{ + switch (event.keyCode) { + case 37: //left + changePage(currentPage - 1); + break; + case 38: //up + changePage(currentPage - 1); + break; + case 39: //right + changePage(currentPage + 1); + break; + case 40: //down + changePage(currentPage + 1); + break; + } +}; \ No newline at end of file diff --git a/doujinshi_dl/viewer/default/styles.css b/doujinshi_dl/viewer/default/styles.css new file mode 100644 index 0000000..62dfbe4 --- /dev/null +++ b/doujinshi_dl/viewer/default/styles.css @@ -0,0 +1,70 @@ + +*, *::after, *::before { + box-sizing: border-box; +} + +img { + vertical-align: middle; +} + +html, body { + display: flex; + background-color: #e8e6e6; + height: 100%; + width: 100%; + padding: 0; + margin: 0; + font-family: sans-serif; +} + +#list { + height: 2000px; + overflow: scroll; + width: 260px; + text-align: center; +} + +#list img { + width: 200px; + padding: 10px; + border-radius: 10px; + margin: 15px 0; + cursor: pointer; +} + +#list img.current { + background: #0003; +} + +#image-container { + flex: auto; + height: 2000px; + background: #222; + color: #fff; + text-align: center; + cursor: pointer; + -webkit-user-select: none; + user-select: none; + position: relative; +} + +#image-container #dest { + height: 2000px; + width: 100%; + background-size: contain; + background-repeat: no-repeat; + background-position: top; +} + +#image-container #page-num { + position: static; + font-size: 14pt; + left: 10px; + bottom: 5px; + font-weight: bold; + opacity: 0.75; + text-shadow: /* Duplicate the same shadow to make it very strong */ + 0 0 2px #222, + 0 0 2px #222, + 0 0 2px #222; +} \ No newline at end of file diff --git a/doujinshi_dl/viewer/logo.png b/doujinshi_dl/viewer/logo.png new file mode 100644 index 0000000..c613209 Binary files /dev/null and b/doujinshi_dl/viewer/logo.png differ diff --git a/doujinshi_dl/viewer/main.css b/doujinshi_dl/viewer/main.css new file mode 100644 index 0000000..8a12fa2 --- /dev/null +++ b/doujinshi_dl/viewer/main.css @@ -0,0 +1,331 @@ +/*! normalize.css v5.0.0 | MIT License | github.com/necolas/normalize.css */ +a { + background-color: transparent; + -webkit-text-decoration-skip: objects +} + +img { + border-style: none +} + +html { + box-sizing: border-box +} + +*,:after,:before { + box-sizing: inherit +} + +body,html { + font-family: 'Noto Sans',sans-serif; + font-size: 14px; + line-height: 1.42857143; + height: 100%; + margin: 0; + text-align: center; + color: #34495e; + background-color: #fff; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale +} + +a { + text-decoration: none; + color: #34495e +} + +blockquote { + border: 0 +} + +.container { + display: block; + clear: both; + margin-left: 15rem; + margin-right: 0.5rem; + margin-bottom: 5px; + margin-top: 5px; + padding: 4px; + border-radius: 9px; + background-color: #ecf0f1; + width: 100% - 15rem; + max-width: 1500px +} + +.gallery,.gallery-favorite,.thumb-container { + display: inline-block; + vertical-align: top +} + +.gallery img,.gallery-favorite img,.thumb-container img { + display: block; + max-width: 100%; + height: auto +} + +@media screen and (min-width: 980px) { + .gallery,.gallery-favorite,.thumb-container { + width:19%; + margin: 3px; + } +} + +@media screen and (max-width: 979px) { + .gallery,.gallery-favorite,.thumb-container { + width:24%; + margin: 2px + } +} + +@media screen and (max-width: 772px) { + .gallery,.gallery-favorite,.thumb-container { + width:32%; + margin: 1.5px + } +} + +@media screen and (max-width: 500px) { + .gallery,.gallery-favorite,.thumb-container { + width:49%; + margin: .5px + } +} + +.gallery a,.gallery-favorite a { + display: block +} + +.gallery a img,.gallery-favorite a img { + position: absolute +} + +.caption { + line-height: 15px; + left: 0; + right: 0; + top: 100%; + position: absolute; + z-index: 10; + overflow: hidden; + width: 100%; + max-height: 34px; + padding: 3px; + background-color: #fff; + font-weight: 700; + display: block; + text-align: center; + text-decoration: none; + color: #34495e +} + +.gallery { + position: relative; + margin-bottom: 3em +} + +.gallery:hover .caption { + max-height: 100%; + box-shadow: 0 10px 20px rgba(100,100,100,.5) +} + +.gallery-favorite .gallery { + width: 100% +} + +.sidenav { + height: 100%; + width: 15rem; + position: fixed; + z-index: 1; + top: 0; + left: 0; + background-color: #0d0d0d; + overflow: hidden; + + padding-top: 20px; + -webkit-touch-callout: none; /* iOS Safari */ + -webkit-user-select: none; /* Safari */ + -khtml-user-select: none; /* Konqueror HTML */ + -moz-user-select: none; /* Old versions of Firefox */ + -ms-user-select: none; /* Internet Explorer/Edge */ + user-select: none; +} + +.sidenav a { + background-color: #eee; + padding: 5px 0px 5px 15px; + text-decoration: none; + font-size: 15px; + color: #0d0d0d; + display: block; + text-align: left; +} +.sidenav img { + width:100%; + padding: 0px 5px 0px 5px; + +} + +.sidenav h1 { + font-size: 1.5em; + margin: 0px 0px 10px; +} + +.sidenav a:hover { + color: white; + background-color: #EC2754; +} + +.accordion { + font-weight: bold; + background-color: #eee; + color: #444; + padding: 10px 0px 5px 8px; + width: 100%; + border: none; + text-align: left; + outline: none; + font-size: 15px; + transition: 0.4s; + cursor:pointer; +} + +.accordion:hover { + background-color: #ddd; +} + +.accordion.active{ + background-color:#ddd; +} + +.nav-btn { + font-weight: bold; + background-color: #eee; + color: #444; + padding: 8px 8px 5px 9px; + width: 100%; + border: none; + text-align: left; + outline: none; + font-size: 15px; +} + +.hidden { + display:none; +} + +.nav-btn a{ + font-weight: normal; + padding-right: 10px; + border-radius: 15px; + cursor: crosshair +} + +.options { + display:block; + padding: 0px 0px 0px 0px; + background-color: #eee; + max-height: 0; + overflow: hidden; + transition: max-height 0.2s ease-out; + cursor:pointer; +} + +.search{ + background-color: #eee; + padding-right:40px; + white-space: nowrap; + padding-top: 5px; + height:43px; + +} + +.search input{ + border-top-right-radius:10px; + padding-top:0; + padding-bottom:0; + font-size:1em; + width:100%; + height:38px; + vertical-align:top; +} + +.btn{ + border-top-left-radius:10px; + color:#fff; + font-size:100%; + padding: 8px; + width:38px; + background-color:#ed2553; +} + +#tags{ + text-align:left; + display: flex; + width:15rem; + justify-content: start; + margin: 2px 2px 2px 0px; + flex-wrap: wrap; +} + +.btn-2{ + font-weight:700; + padding-right:0.5rem; + padding-left:0.5rem; + color:#fff; + border:0; + font-size:100%; + height:1.25rem; + outline: 0; + border-radius: 0.3rem; + cursor: pointer; + margin:0.15rem; + transition: all 1s linear; +} + +.btn-2#parody{ + background-color: red; +} + +.btn-2#character{ + background-color: blue; +} + +.btn-2#tag{ + background-color: green; +} + +.btn-2#artist{ + background-color: fuchsia; +} + +.btn-2#group{ + background-color: teal; +} + +.btn-2.hover{ + filter: saturate(20%) +} + +input,input:focus{ + border:none; + outline:0; +} + +html.theme-black,html.theme-black body { + color: #d9d9d9; + background-color: #0d0d0d +} + +html.theme-black #thumbnail-container,html.theme-black .container { + background-color: #1f1f1f +} + +html.theme-black .gallery:hover .caption { + box-shadow: 0 10px 20px rgba(0,0,0,.5) +} + +html.theme-black .caption { + background-color: #404040; + color: #d9d9d9 +} diff --git a/doujinshi_dl/viewer/main.html b/doujinshi_dl/viewer/main.html new file mode 100644 index 0000000..1068a03 --- /dev/null +++ b/doujinshi_dl/viewer/main.html @@ -0,0 +1,51 @@ + + + + + + + + Doujinshi Viewer + + + + + + +
+ +
+ + {PICTURE} + +
+ +
+ + + + \ No newline at end of file diff --git a/doujinshi_dl/viewer/main.js b/doujinshi_dl/viewer/main.js new file mode 100644 index 0000000..e280068 --- /dev/null +++ b/doujinshi_dl/viewer/main.js @@ -0,0 +1,177 @@ + //------------------------------------navbar script------------------------------------ +var menu = document.getElementsByClassName("accordion"); +for (var i = 0; i < menu.length; i++) { + menu[i].addEventListener("click", function() { + var panel = this.nextElementSibling; + if (panel.style.maxHeight) { + this.classList.toggle("active"); + panel.style.maxHeight = null; + } else { + panel.style.maxHeight = panel.scrollHeight + "px"; + this.classList.toggle("active"); + } + }); +} +var language = document.getElementById("language").children; +for (var i = 0; i < language.length; i++){ + language[i].addEventListener("click", function() { + toggler = document.getElementById("language") + toggler.style.maxHeight = null; + document.getElementsByClassName("accordion")[0].classList.toggle("active"); + filter_maker(this.innerText, "language"); +}); +} +var category = document.getElementById("category").children; +for (var i = 0; i < category.length; i++){ + category[i].addEventListener("click", function() { + document.getElementById("category").style.maxHeight = null; + document.getElementsByClassName("accordion")[1].classList.toggle("active"); + filter_maker(this.innerText, "category"); +}); +} +//----------------------------------------------------------------------------------- +//----------------------------------Tags Script-------------------------------------- +tag_maker(tags); + +var tag = document.getElementsByClassName("btn-2"); +for (var i = 0; i < tag.length; i++){ + tag[i].addEventListener("click", function() { + filter_maker(this.innerText, this.id); +}); +} + +var input = document.getElementById("tagfilter"); +input.addEventListener("input", function() { + var tags = document.querySelectorAll(".btn-2"); + if (this.value.length > 0) { + for (var i = 0; i < tags.length; i++) { + var tag = tags[i]; + var nome = tag.innerText; + var exp = new RegExp(this.value, "i");; + if (exp.test(nome)) { + tag.classList.remove("hidden"); + } + else { + tag.classList.add("hidden"); + } + } + } else { + for (var i = 0; i < tags.length; i++) { + var tag = tags[i]; + tag.classList.add('hidden'); + } + } +}); +input.addEventListener('keypress', function (e) { + enter_search(e, this.value); +}); +//----------------------------------------------------------------------------------- +//------------------------------------Functions-------------------------------------- +function enter_search(e, input){ + var count = 0; + var key = e.which || e.keyCode; + if (key === 13 && input.length > 0) { + var all_tags = document.getElementById("tags").children; + for(i = 0; i < all_tags.length; i++){ + if (!all_tags[i].classList.contains("hidden")){ + count++; + var tag_name = all_tags[i].innerText; + var tag_id = all_tags[i].id; + if (count>1){break} + } + } + if (count == 1){ + filter_maker(tag_name, tag_id); + } + } +} +function filter_maker(text, class_value){ + var check = filter_checker(text); + var nav_btn = document.getElementsByClassName("nav-btn")[0]; + if (nav_btn.classList.contains("hidden")){ + nav_btn.classList.toggle("hidden"); + } + if (check == true){ + var node = document.createElement("a"); + var textnode = document.createTextNode(text); + node.appendChild(textnode); + node.classList.add(class_value); + nav_btn.appendChild(node); + filter_searcher(); + } +} + +function filter_searcher(){ + var verifier = null; + var tags_filter = []; + var doujinshi_id = []; + var filter_tag = document.getElementsByClassName("nav-btn")[0].children; + filter_tag[filter_tag.length-1].addEventListener("click", function() { + this.remove(); + try{ + filter_searcher(); + } + catch{ + var gallery = document.getElementsByClassName("gallery-favorite"); + for (var i = 0; i < gallery.length; i++){ + gallery[i].classList.remove("hidden"); + } + } + }); + for (var i=0; i < filter_tag.length; i++){ + var fclass = filter_tag[i].className; + var fname = filter_tag[i].innerText.toLowerCase(); + tags_filter.push([fclass, fname]) + } + for (var i=0; i < data.length; i++){ + for (var j=0; j < tags_filter.length; j++){ + try{ + if(data[i][tags_filter[j][0]].includes(tags_filter[j][1])){ + verifier = true; + } + else{ + verifier = false; + break + } + } + catch{ + verifier = false; + break + } + } + if (verifier){doujinshi_id.push(data[i].Folder.replace("_", " "));} + } + var gallery = document.getElementsByClassName("gallery-favorite"); + for (var i = 0; i < gallery.length; i++){ + gtext = gallery [i].children[0].children[0].children[1].innerText; + if(doujinshi_id.includes(gtext)){ + gallery[i].classList.remove("hidden"); + } + else{ + gallery[i].classList.add("hidden"); + } + } +} + +function filter_checker(text){ + var filter_tags = document.getElementsByClassName("nav-btn")[0].children; + if (filter_tags == null){return true;} + for (var i=0; i < filter_tags.length; i++){ + if (filter_tags[i].innerText == text){return false;} + } + return true; +} + +function tag_maker(data){ + for (i in data){ + for (j in data[i]){ + var node = document.createElement("button"); + var textnode = document.createTextNode(data[i][j]); + node.appendChild(textnode); + node.classList.add("btn-2"); + node.setAttribute('id', i); + node.classList.add("hidden"); + document.getElementById("tags").appendChild(node); + } + } +} diff --git a/doujinshi_dl/viewer/minimal/index.html b/doujinshi_dl/viewer/minimal/index.html new file mode 100644 index 0000000..c53ec08 --- /dev/null +++ b/doujinshi_dl/viewer/minimal/index.html @@ -0,0 +1,25 @@ + + + + + + {TITLE} + + + + + + +
+
+ +
+ + + + \ No newline at end of file diff --git a/doujinshi_dl/viewer/minimal/scripts.js b/doujinshi_dl/viewer/minimal/scripts.js new file mode 100644 index 0000000..10e7719 --- /dev/null +++ b/doujinshi_dl/viewer/minimal/scripts.js @@ -0,0 +1,79 @@ +const pages = Array.from(document.querySelectorAll('img.image-item')); +let currentPage = 0; + +function changePage(pageNum) { + const previous = pages[currentPage]; + const current = pages[pageNum]; + + if (current == null) { + return; + } + + previous.classList.remove('current'); + current.classList.add('current'); + + currentPage = pageNum; + + const display = document.getElementById('dest'); + display.style.backgroundImage = `url("${current.src}")`; + + scroll(0,0) + + document.getElementById('page-num') + .innerText = [ + (pageNum + 1).toLocaleString(), + pages.length.toLocaleString() + ].join('\u200a/\u200a'); +} + +changePage(0); + +document.getElementById('image-container').onclick = event => { + const width = document.getElementById('image-container').clientWidth; + const clickPos = event.clientX / width; + + if (clickPos < 0.5) { + changePage(currentPage - 1); + } else { + changePage(currentPage + 1); + } +}; + +document.onkeypress = event => { + switch (event.key.toLowerCase()) { + // Previous Image + case 'w': + scrollBy(0, -40); + break; + case 'a': + changePage(currentPage - 1); + break; + // Return to previous page + case 'q': + window.history.go(-1); + break; + // Next Image + case ' ': + case 's': + scrollBy(0, 40); + break; + case 'd': + changePage(currentPage + 1); + break; + }// remove arrow cause it won't work +}; + +document.onkeydown = event =>{ + switch (event.keyCode) { + case 37: //left + changePage(currentPage - 1); + break; + case 38: //up + break; + case 39: //right + changePage(currentPage + 1); + break; + case 40: //down + break; + } +}; \ No newline at end of file diff --git a/doujinshi_dl/viewer/minimal/styles.css b/doujinshi_dl/viewer/minimal/styles.css new file mode 100644 index 0000000..cc01089 --- /dev/null +++ b/doujinshi_dl/viewer/minimal/styles.css @@ -0,0 +1,75 @@ + +*, *::after, *::before { + box-sizing: border-box; +} + +img { + vertical-align: middle; +} + +html, body { + display: flex; + background-color: #e8e6e6; + height: 100%; + width: 100%; + padding: 0; + margin: 0; + font-family: sans-serif; +} + +#list { + height: 2000px; + overflow: scroll; + width: 260px; + text-align: center; +} + +#list img { + width: 200px; + padding: 10px; + border-radius: 10px; + margin: 15px 0; + cursor: pointer; +} + +#list img.current { + background: #0003; +} + +#image-container { + flex: auto; + height: 100%; + background: rgb(0, 0, 0); + color: rgb(100, 100, 100); + text-align: center; + cursor: pointer; + -webkit-user-select: none; + user-select: none; + position: relative; +} + +#image-container #dest { + height: 2000px; + width: 100%; + background-size: contain; + background-repeat: no-repeat; + background-position: top; + margin-left: auto; + margin-right: auto; + max-width: 100%; + max-height: 100vh; + margin: auto; +} + +#image-container #page-num { + position: static; + font-size: 9pt; + left: 10px; + bottom: 5px; + font-weight: bold; + opacity: 0.9; + text-shadow: /* Duplicate the same shadow to make it very strong */ + 0 0 2px #222, + 0 0 2px #222, + 0 0 2px #222; +} \ No newline at end of file diff --git a/doujinshi_dl/viewer/viewer/default/index.html b/doujinshi_dl/viewer/viewer/default/index.html new file mode 100644 index 0000000..33ea2af --- /dev/null +++ b/doujinshi_dl/viewer/viewer/default/index.html @@ -0,0 +1,25 @@ + + + + + + {TITLE} + + + + + + +
+ +
+
+ + + + \ No newline at end of file diff --git a/doujinshi_dl/viewer/viewer/default/scripts.js b/doujinshi_dl/viewer/viewer/default/scripts.js new file mode 100644 index 0000000..a367c9b --- /dev/null +++ b/doujinshi_dl/viewer/viewer/default/scripts.js @@ -0,0 +1,87 @@ +const pages = Array.from(document.querySelectorAll('img.image-item')); +let currentPage = 0; + +function changePage(pageNum) { + const previous = pages[currentPage]; + const current = pages[pageNum]; + + if (current == null) { + return; + } + + previous.classList.remove('current'); + current.classList.add('current'); + + currentPage = pageNum; + + const display = document.getElementById('dest'); + display.style.backgroundImage = `url("${current.src}")`; + + scroll(0,0) + + document.getElementById('page-num') + .innerText = [ + (pageNum + 1).toLocaleString(), + pages.length.toLocaleString() + ].join('\u200a/\u200a'); +} + +changePage(0); + +document.getElementById('list').onclick = event => { + if (pages.includes(event.target)) { + changePage(pages.indexOf(event.target)); + } +}; + +document.getElementById('image-container').onclick = event => { + const width = document.getElementById('image-container').clientWidth; + const clickPos = event.clientX / width; + + if (clickPos < 0.5) { + changePage(currentPage - 1); + } else { + changePage(currentPage + 1); + } +}; + +document.onkeypress = event => { + switch (event.key.toLowerCase()) { + // Previous Image + case 'w': + scrollBy(0, -40); + break; + case 'a': + changePage(currentPage - 1); + break; + // Return to previous page + case 'q': + window.history.go(-1); + break; + // Next Image + case ' ': + case 's': + scrollBy(0, 40); + break; + case 'd': + changePage(currentPage + 1); + break; + }// remove arrow cause it won't work +}; + +document.onkeydown = event =>{ + switch (event.keyCode) { + case 37: //left + changePage(currentPage - 1); + break; + case 38: //up + changePage(currentPage - 1); + break; + case 39: //right + changePage(currentPage + 1); + break; + case 40: //down + changePage(currentPage + 1); + break; + } +}; \ No newline at end of file diff --git a/doujinshi_dl/viewer/viewer/default/styles.css b/doujinshi_dl/viewer/viewer/default/styles.css new file mode 100644 index 0000000..62dfbe4 --- /dev/null +++ b/doujinshi_dl/viewer/viewer/default/styles.css @@ -0,0 +1,70 @@ + +*, *::after, *::before { + box-sizing: border-box; +} + +img { + vertical-align: middle; +} + +html, body { + display: flex; + background-color: #e8e6e6; + height: 100%; + width: 100%; + padding: 0; + margin: 0; + font-family: sans-serif; +} + +#list { + height: 2000px; + overflow: scroll; + width: 260px; + text-align: center; +} + +#list img { + width: 200px; + padding: 10px; + border-radius: 10px; + margin: 15px 0; + cursor: pointer; +} + +#list img.current { + background: #0003; +} + +#image-container { + flex: auto; + height: 2000px; + background: #222; + color: #fff; + text-align: center; + cursor: pointer; + -webkit-user-select: none; + user-select: none; + position: relative; +} + +#image-container #dest { + height: 2000px; + width: 100%; + background-size: contain; + background-repeat: no-repeat; + background-position: top; +} + +#image-container #page-num { + position: static; + font-size: 14pt; + left: 10px; + bottom: 5px; + font-weight: bold; + opacity: 0.75; + text-shadow: /* Duplicate the same shadow to make it very strong */ + 0 0 2px #222, + 0 0 2px #222, + 0 0 2px #222; +} \ No newline at end of file diff --git a/doujinshi_dl/viewer/viewer/logo.png b/doujinshi_dl/viewer/viewer/logo.png new file mode 100644 index 0000000..c613209 Binary files /dev/null and b/doujinshi_dl/viewer/viewer/logo.png differ diff --git a/doujinshi_dl/viewer/viewer/main.css b/doujinshi_dl/viewer/viewer/main.css new file mode 100644 index 0000000..8a12fa2 --- /dev/null +++ b/doujinshi_dl/viewer/viewer/main.css @@ -0,0 +1,331 @@ +/*! normalize.css v5.0.0 | MIT License | github.com/necolas/normalize.css */ +a { + background-color: transparent; + -webkit-text-decoration-skip: objects +} + +img { + border-style: none +} + +html { + box-sizing: border-box +} + +*,:after,:before { + box-sizing: inherit +} + +body,html { + font-family: 'Noto Sans',sans-serif; + font-size: 14px; + line-height: 1.42857143; + height: 100%; + margin: 0; + text-align: center; + color: #34495e; + background-color: #fff; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale +} + +a { + text-decoration: none; + color: #34495e +} + +blockquote { + border: 0 +} + +.container { + display: block; + clear: both; + margin-left: 15rem; + margin-right: 0.5rem; + margin-bottom: 5px; + margin-top: 5px; + padding: 4px; + border-radius: 9px; + background-color: #ecf0f1; + width: 100% - 15rem; + max-width: 1500px +} + +.gallery,.gallery-favorite,.thumb-container { + display: inline-block; + vertical-align: top +} + +.gallery img,.gallery-favorite img,.thumb-container img { + display: block; + max-width: 100%; + height: auto +} + +@media screen and (min-width: 980px) { + .gallery,.gallery-favorite,.thumb-container { + width:19%; + margin: 3px; + } +} + +@media screen and (max-width: 979px) { + .gallery,.gallery-favorite,.thumb-container { + width:24%; + margin: 2px + } +} + +@media screen and (max-width: 772px) { + .gallery,.gallery-favorite,.thumb-container { + width:32%; + margin: 1.5px + } +} + +@media screen and (max-width: 500px) { + .gallery,.gallery-favorite,.thumb-container { + width:49%; + margin: .5px + } +} + +.gallery a,.gallery-favorite a { + display: block +} + +.gallery a img,.gallery-favorite a img { + position: absolute +} + +.caption { + line-height: 15px; + left: 0; + right: 0; + top: 100%; + position: absolute; + z-index: 10; + overflow: hidden; + width: 100%; + max-height: 34px; + padding: 3px; + background-color: #fff; + font-weight: 700; + display: block; + text-align: center; + text-decoration: none; + color: #34495e +} + +.gallery { + position: relative; + margin-bottom: 3em +} + +.gallery:hover .caption { + max-height: 100%; + box-shadow: 0 10px 20px rgba(100,100,100,.5) +} + +.gallery-favorite .gallery { + width: 100% +} + +.sidenav { + height: 100%; + width: 15rem; + position: fixed; + z-index: 1; + top: 0; + left: 0; + background-color: #0d0d0d; + overflow: hidden; + + padding-top: 20px; + -webkit-touch-callout: none; /* iOS Safari */ + -webkit-user-select: none; /* Safari */ + -khtml-user-select: none; /* Konqueror HTML */ + -moz-user-select: none; /* Old versions of Firefox */ + -ms-user-select: none; /* Internet Explorer/Edge */ + user-select: none; +} + +.sidenav a { + background-color: #eee; + padding: 5px 0px 5px 15px; + text-decoration: none; + font-size: 15px; + color: #0d0d0d; + display: block; + text-align: left; +} +.sidenav img { + width:100%; + padding: 0px 5px 0px 5px; + +} + +.sidenav h1 { + font-size: 1.5em; + margin: 0px 0px 10px; +} + +.sidenav a:hover { + color: white; + background-color: #EC2754; +} + +.accordion { + font-weight: bold; + background-color: #eee; + color: #444; + padding: 10px 0px 5px 8px; + width: 100%; + border: none; + text-align: left; + outline: none; + font-size: 15px; + transition: 0.4s; + cursor:pointer; +} + +.accordion:hover { + background-color: #ddd; +} + +.accordion.active{ + background-color:#ddd; +} + +.nav-btn { + font-weight: bold; + background-color: #eee; + color: #444; + padding: 8px 8px 5px 9px; + width: 100%; + border: none; + text-align: left; + outline: none; + font-size: 15px; +} + +.hidden { + display:none; +} + +.nav-btn a{ + font-weight: normal; + padding-right: 10px; + border-radius: 15px; + cursor: crosshair +} + +.options { + display:block; + padding: 0px 0px 0px 0px; + background-color: #eee; + max-height: 0; + overflow: hidden; + transition: max-height 0.2s ease-out; + cursor:pointer; +} + +.search{ + background-color: #eee; + padding-right:40px; + white-space: nowrap; + padding-top: 5px; + height:43px; + +} + +.search input{ + border-top-right-radius:10px; + padding-top:0; + padding-bottom:0; + font-size:1em; + width:100%; + height:38px; + vertical-align:top; +} + +.btn{ + border-top-left-radius:10px; + color:#fff; + font-size:100%; + padding: 8px; + width:38px; + background-color:#ed2553; +} + +#tags{ + text-align:left; + display: flex; + width:15rem; + justify-content: start; + margin: 2px 2px 2px 0px; + flex-wrap: wrap; +} + +.btn-2{ + font-weight:700; + padding-right:0.5rem; + padding-left:0.5rem; + color:#fff; + border:0; + font-size:100%; + height:1.25rem; + outline: 0; + border-radius: 0.3rem; + cursor: pointer; + margin:0.15rem; + transition: all 1s linear; +} + +.btn-2#parody{ + background-color: red; +} + +.btn-2#character{ + background-color: blue; +} + +.btn-2#tag{ + background-color: green; +} + +.btn-2#artist{ + background-color: fuchsia; +} + +.btn-2#group{ + background-color: teal; +} + +.btn-2.hover{ + filter: saturate(20%) +} + +input,input:focus{ + border:none; + outline:0; +} + +html.theme-black,html.theme-black body { + color: #d9d9d9; + background-color: #0d0d0d +} + +html.theme-black #thumbnail-container,html.theme-black .container { + background-color: #1f1f1f +} + +html.theme-black .gallery:hover .caption { + box-shadow: 0 10px 20px rgba(0,0,0,.5) +} + +html.theme-black .caption { + background-color: #404040; + color: #d9d9d9 +} diff --git a/doujinshi_dl/viewer/viewer/main.html b/doujinshi_dl/viewer/viewer/main.html new file mode 100644 index 0000000..1068a03 --- /dev/null +++ b/doujinshi_dl/viewer/viewer/main.html @@ -0,0 +1,51 @@ + + + + + + + + Doujinshi Viewer + + + + + + +
+ +
+ + {PICTURE} + +
+ +
+ + + + \ No newline at end of file diff --git a/doujinshi_dl/viewer/viewer/main.js b/doujinshi_dl/viewer/viewer/main.js new file mode 100644 index 0000000..e280068 --- /dev/null +++ b/doujinshi_dl/viewer/viewer/main.js @@ -0,0 +1,177 @@ + //------------------------------------navbar script------------------------------------ +var menu = document.getElementsByClassName("accordion"); +for (var i = 0; i < menu.length; i++) { + menu[i].addEventListener("click", function() { + var panel = this.nextElementSibling; + if (panel.style.maxHeight) { + this.classList.toggle("active"); + panel.style.maxHeight = null; + } else { + panel.style.maxHeight = panel.scrollHeight + "px"; + this.classList.toggle("active"); + } + }); +} +var language = document.getElementById("language").children; +for (var i = 0; i < language.length; i++){ + language[i].addEventListener("click", function() { + toggler = document.getElementById("language") + toggler.style.maxHeight = null; + document.getElementsByClassName("accordion")[0].classList.toggle("active"); + filter_maker(this.innerText, "language"); +}); +} +var category = document.getElementById("category").children; +for (var i = 0; i < category.length; i++){ + category[i].addEventListener("click", function() { + document.getElementById("category").style.maxHeight = null; + document.getElementsByClassName("accordion")[1].classList.toggle("active"); + filter_maker(this.innerText, "category"); +}); +} +//----------------------------------------------------------------------------------- +//----------------------------------Tags Script-------------------------------------- +tag_maker(tags); + +var tag = document.getElementsByClassName("btn-2"); +for (var i = 0; i < tag.length; i++){ + tag[i].addEventListener("click", function() { + filter_maker(this.innerText, this.id); +}); +} + +var input = document.getElementById("tagfilter"); +input.addEventListener("input", function() { + var tags = document.querySelectorAll(".btn-2"); + if (this.value.length > 0) { + for (var i = 0; i < tags.length; i++) { + var tag = tags[i]; + var nome = tag.innerText; + var exp = new RegExp(this.value, "i");; + if (exp.test(nome)) { + tag.classList.remove("hidden"); + } + else { + tag.classList.add("hidden"); + } + } + } else { + for (var i = 0; i < tags.length; i++) { + var tag = tags[i]; + tag.classList.add('hidden'); + } + } +}); +input.addEventListener('keypress', function (e) { + enter_search(e, this.value); +}); +//----------------------------------------------------------------------------------- +//------------------------------------Functions-------------------------------------- +function enter_search(e, input){ + var count = 0; + var key = e.which || e.keyCode; + if (key === 13 && input.length > 0) { + var all_tags = document.getElementById("tags").children; + for(i = 0; i < all_tags.length; i++){ + if (!all_tags[i].classList.contains("hidden")){ + count++; + var tag_name = all_tags[i].innerText; + var tag_id = all_tags[i].id; + if (count>1){break} + } + } + if (count == 1){ + filter_maker(tag_name, tag_id); + } + } +} +function filter_maker(text, class_value){ + var check = filter_checker(text); + var nav_btn = document.getElementsByClassName("nav-btn")[0]; + if (nav_btn.classList.contains("hidden")){ + nav_btn.classList.toggle("hidden"); + } + if (check == true){ + var node = document.createElement("a"); + var textnode = document.createTextNode(text); + node.appendChild(textnode); + node.classList.add(class_value); + nav_btn.appendChild(node); + filter_searcher(); + } +} + +function filter_searcher(){ + var verifier = null; + var tags_filter = []; + var doujinshi_id = []; + var filter_tag = document.getElementsByClassName("nav-btn")[0].children; + filter_tag[filter_tag.length-1].addEventListener("click", function() { + this.remove(); + try{ + filter_searcher(); + } + catch{ + var gallery = document.getElementsByClassName("gallery-favorite"); + for (var i = 0; i < gallery.length; i++){ + gallery[i].classList.remove("hidden"); + } + } + }); + for (var i=0; i < filter_tag.length; i++){ + var fclass = filter_tag[i].className; + var fname = filter_tag[i].innerText.toLowerCase(); + tags_filter.push([fclass, fname]) + } + for (var i=0; i < data.length; i++){ + for (var j=0; j < tags_filter.length; j++){ + try{ + if(data[i][tags_filter[j][0]].includes(tags_filter[j][1])){ + verifier = true; + } + else{ + verifier = false; + break + } + } + catch{ + verifier = false; + break + } + } + if (verifier){doujinshi_id.push(data[i].Folder.replace("_", " "));} + } + var gallery = document.getElementsByClassName("gallery-favorite"); + for (var i = 0; i < gallery.length; i++){ + gtext = gallery [i].children[0].children[0].children[1].innerText; + if(doujinshi_id.includes(gtext)){ + gallery[i].classList.remove("hidden"); + } + else{ + gallery[i].classList.add("hidden"); + } + } +} + +function filter_checker(text){ + var filter_tags = document.getElementsByClassName("nav-btn")[0].children; + if (filter_tags == null){return true;} + for (var i=0; i < filter_tags.length; i++){ + if (filter_tags[i].innerText == text){return false;} + } + return true; +} + +function tag_maker(data){ + for (i in data){ + for (j in data[i]){ + var node = document.createElement("button"); + var textnode = document.createTextNode(data[i][j]); + node.appendChild(textnode); + node.classList.add("btn-2"); + node.setAttribute('id', i); + node.classList.add("hidden"); + document.getElementById("tags").appendChild(node); + } + } +} diff --git a/doujinshi_dl/viewer/viewer/minimal/index.html b/doujinshi_dl/viewer/viewer/minimal/index.html new file mode 100644 index 0000000..c53ec08 --- /dev/null +++ b/doujinshi_dl/viewer/viewer/minimal/index.html @@ -0,0 +1,25 @@ + + + + + + {TITLE} + + + + + + +
+
+ +
+ + + + \ No newline at end of file diff --git a/doujinshi_dl/viewer/viewer/minimal/scripts.js b/doujinshi_dl/viewer/viewer/minimal/scripts.js new file mode 100644 index 0000000..10e7719 --- /dev/null +++ b/doujinshi_dl/viewer/viewer/minimal/scripts.js @@ -0,0 +1,79 @@ +const pages = Array.from(document.querySelectorAll('img.image-item')); +let currentPage = 0; + +function changePage(pageNum) { + const previous = pages[currentPage]; + const current = pages[pageNum]; + + if (current == null) { + return; + } + + previous.classList.remove('current'); + current.classList.add('current'); + + currentPage = pageNum; + + const display = document.getElementById('dest'); + display.style.backgroundImage = `url("${current.src}")`; + + scroll(0,0) + + document.getElementById('page-num') + .innerText = [ + (pageNum + 1).toLocaleString(), + pages.length.toLocaleString() + ].join('\u200a/\u200a'); +} + +changePage(0); + +document.getElementById('image-container').onclick = event => { + const width = document.getElementById('image-container').clientWidth; + const clickPos = event.clientX / width; + + if (clickPos < 0.5) { + changePage(currentPage - 1); + } else { + changePage(currentPage + 1); + } +}; + +document.onkeypress = event => { + switch (event.key.toLowerCase()) { + // Previous Image + case 'w': + scrollBy(0, -40); + break; + case 'a': + changePage(currentPage - 1); + break; + // Return to previous page + case 'q': + window.history.go(-1); + break; + // Next Image + case ' ': + case 's': + scrollBy(0, 40); + break; + case 'd': + changePage(currentPage + 1); + break; + }// remove arrow cause it won't work +}; + +document.onkeydown = event =>{ + switch (event.keyCode) { + case 37: //left + changePage(currentPage - 1); + break; + case 38: //up + break; + case 39: //right + changePage(currentPage + 1); + break; + case 40: //down + break; + } +}; \ No newline at end of file diff --git a/doujinshi_dl/viewer/viewer/minimal/styles.css b/doujinshi_dl/viewer/viewer/minimal/styles.css new file mode 100644 index 0000000..cc01089 --- /dev/null +++ b/doujinshi_dl/viewer/viewer/minimal/styles.css @@ -0,0 +1,75 @@ + +*, *::after, *::before { + box-sizing: border-box; +} + +img { + vertical-align: middle; +} + +html, body { + display: flex; + background-color: #e8e6e6; + height: 100%; + width: 100%; + padding: 0; + margin: 0; + font-family: sans-serif; +} + +#list { + height: 2000px; + overflow: scroll; + width: 260px; + text-align: center; +} + +#list img { + width: 200px; + padding: 10px; + border-radius: 10px; + margin: 15px 0; + cursor: pointer; +} + +#list img.current { + background: #0003; +} + +#image-container { + flex: auto; + height: 100%; + background: rgb(0, 0, 0); + color: rgb(100, 100, 100); + text-align: center; + cursor: pointer; + -webkit-user-select: none; + user-select: none; + position: relative; +} + +#image-container #dest { + height: 2000px; + width: 100%; + background-size: contain; + background-repeat: no-repeat; + background-position: top; + margin-left: auto; + margin-right: auto; + max-width: 100%; + max-height: 100vh; + margin: auto; +} + +#image-container #page-num { + position: static; + font-size: 9pt; + left: 10px; + bottom: 5px; + font-weight: bold; + opacity: 0.9; + text-shadow: /* Duplicate the same shadow to make it very strong */ + 0 0 2px #222, + 0 0 2px #222, + 0 0 2px #222; +} \ No newline at end of file diff --git a/images/download.png b/images/download.png new file mode 100644 index 0000000..ec9c23e Binary files /dev/null and b/images/download.png differ diff --git a/images/search.png b/images/search.png new file mode 100644 index 0000000..fdb8a19 Binary files /dev/null and b/images/search.png differ diff --git a/images/usage.png b/images/usage.png new file mode 100644 index 0000000..be331e7 Binary files /dev/null and b/images/usage.png differ diff --git a/images/viewer.png b/images/viewer.png new file mode 100644 index 0000000..199244b Binary files /dev/null and b/images/viewer.png differ diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 0000000..82fbd74 --- /dev/null +++ b/poetry.lock @@ -0,0 +1,370 @@ +# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. + +[[package]] +name = "anyio" +version = "4.5.2" +description = "High level compatibility layer for multiple asynchronous event loop implementations" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "anyio-4.5.2-py3-none-any.whl", hash = "sha256:c011ee36bc1e8ba40e5a81cb9df91925c218fe9b778554e0b56a21e1b5d4716f"}, + {file = "anyio-4.5.2.tar.gz", hash = "sha256:23009af4ed04ce05991845451e11ef02fc7c5ed29179ac9a420e5ad0ac7ddc5b"}, +] + +[package.dependencies] +exceptiongroup = {version = ">=1.0.2", markers = "python_version < \"3.11\""} +idna = ">=2.8" +sniffio = ">=1.1" +typing-extensions = {version = ">=4.1", markers = "python_version < \"3.11\""} + +[package.extras] +doc = ["Sphinx (>=7.4,<8.0)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme"] +test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "truststore (>=0.9.1) ; python_version >= \"3.10\"", "uvloop (>=0.21.0b1) ; platform_python_implementation == \"CPython\" and platform_system != \"Windows\""] +trio = ["trio (>=0.26.1)"] + +[[package]] +name = "beautifulsoup4" +version = "4.12.3" +description = "Screen-scraping library" +optional = false +python-versions = ">=3.6.0" +groups = ["main"] +files = [ + {file = "beautifulsoup4-4.12.3-py3-none-any.whl", hash = "sha256:b80878c9f40111313e55da8ba20bdba06d8fa3969fc68304167741bbf9e082ed"}, + {file = "beautifulsoup4-4.12.3.tar.gz", hash = "sha256:74e3d1928edc070d21748185c46e3fb33490f22f52a3addee9aee0f4f7781051"}, +] + +[package.dependencies] +soupsieve = ">1.2" + +[package.extras] +cchardet = ["cchardet"] +chardet = ["chardet"] +charset-normalizer = ["charset-normalizer"] +html5lib = ["html5lib"] +lxml = ["lxml"] + +[[package]] +name = "certifi" +version = "2024.12.14" +description = "Python package for providing Mozilla's CA Bundle." +optional = false +python-versions = ">=3.6" +groups = ["main"] +files = [ + {file = "certifi-2024.12.14-py3-none-any.whl", hash = "sha256:1275f7a45be9464efc1173084eaa30f866fe2e47d389406136d332ed4967ec56"}, + {file = "certifi-2024.12.14.tar.gz", hash = "sha256:b650d30f370c2b724812bee08008be0c4163b163ddaec3f2546c1caf65f191db"}, +] + +[[package]] +name = "chardet" +version = "5.2.0" +description = "Universal encoding detector for Python 3" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "chardet-5.2.0-py3-none-any.whl", hash = "sha256:e1cf59446890a00105fe7b7912492ea04b6e6f06d4b742b2c788469e34c82970"}, + {file = "chardet-5.2.0.tar.gz", hash = "sha256:1b3b6ff479a8c414bc3fa2c0852995695c4a026dcd6d0633b2dd092ca39c1cf7"}, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.1" +description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "charset_normalizer-3.4.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:91b36a978b5ae0ee86c394f5a54d6ef44db1de0815eb43de826d41d21e4af3de"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7461baadb4dc00fd9e0acbe254e3d7d2112e7f92ced2adc96e54ef6501c5f176"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e218488cd232553829be0664c2292d3af2eeeb94b32bea483cf79ac6a694e037"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:80ed5e856eb7f30115aaf94e4a08114ccc8813e6ed1b5efa74f9f82e8509858f"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b010a7a4fd316c3c484d482922d13044979e78d1861f0e0650423144c616a46a"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4532bff1b8421fd0a320463030c7520f56a79c9024a4e88f01c537316019005a"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d973f03c0cb71c5ed99037b870f2be986c3c05e63622c017ea9816881d2dd247"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3a3bd0dcd373514dcec91c411ddb9632c0d7d92aed7093b8c3bbb6d69ca74408"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:d9c3cdf5390dcd29aa8056d13e8e99526cda0305acc038b96b30352aff5ff2bb"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:2bdfe3ac2e1bbe5b59a1a63721eb3b95fc9b6817ae4a46debbb4e11f6232428d"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:eab677309cdb30d047996b36d34caeda1dc91149e4fdca0b1a039b3f79d9a807"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-win32.whl", hash = "sha256:c0429126cf75e16c4f0ad00ee0eae4242dc652290f940152ca8c75c3a4b6ee8f"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:9f0b8b1c6d84c8034a44893aba5e767bf9c7a211e313a9605d9c617d7083829f"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8bfa33f4f2672964266e940dd22a195989ba31669bd84629f05fab3ef4e2d125"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:28bf57629c75e810b6ae989f03c0828d64d6b26a5e205535585f96093e405ed1"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f08ff5e948271dc7e18a35641d2f11a4cd8dfd5634f55228b691e62b37125eb3"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:234ac59ea147c59ee4da87a0c0f098e9c8d169f4dc2a159ef720f1a61bbe27cd"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd4ec41f914fa74ad1b8304bbc634b3de73d2a0889bd32076342a573e0779e00"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eea6ee1db730b3483adf394ea72f808b6e18cf3cb6454b4d86e04fa8c4327a12"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c96836c97b1238e9c9e3fe90844c947d5afbf4f4c92762679acfe19927d81d77"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:4d86f7aff21ee58f26dcf5ae81a9addbd914115cdebcbb2217e4f0ed8982e146"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:09b5e6733cbd160dcc09589227187e242a30a49ca5cefa5a7edd3f9d19ed53fd"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:5777ee0881f9499ed0f71cc82cf873d9a0ca8af166dfa0af8ec4e675b7df48e6"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:237bdbe6159cff53b4f24f397d43c6336c6b0b42affbe857970cefbb620911c8"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-win32.whl", hash = "sha256:8417cb1f36cc0bc7eaba8ccb0e04d55f0ee52df06df3ad55259b9a323555fc8b"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:d7f50a1f8c450f3925cb367d011448c39239bb3eb4117c36a6d354794de4ce76"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:73d94b58ec7fecbc7366247d3b0b10a21681004153238750bb67bd9012414545"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dad3e487649f498dd991eeb901125411559b22e8d7ab25d3aeb1af367df5efd7"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c30197aa96e8eed02200a83fba2657b4c3acd0f0aa4bdc9f6c1af8e8962e0757"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2369eea1ee4a7610a860d88f268eb39b95cb588acd7235e02fd5a5601773d4fa"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc2722592d8998c870fa4e290c2eec2c1569b87fe58618e67d38b4665dfa680d"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffc9202a29ab3920fa812879e95a9e78b2465fd10be7fcbd042899695d75e616"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:804a4d582ba6e5b747c625bf1255e6b1507465494a40a2130978bda7b932c90b"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0f55e69f030f7163dffe9fd0752b32f070566451afe180f99dbeeb81f511ad8d"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c4c3e6da02df6fa1410a7680bd3f63d4f710232d3139089536310d027950696a"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:5df196eb874dae23dcfb968c83d4f8fdccb333330fe1fc278ac5ceeb101003a9"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e358e64305fe12299a08e08978f51fc21fac060dcfcddd95453eabe5b93ed0e1"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-win32.whl", hash = "sha256:9b23ca7ef998bc739bf6ffc077c2116917eabcc901f88da1b9856b210ef63f35"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:6ff8a4a60c227ad87030d76e99cd1698345d4491638dfa6673027c48b3cd395f"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:aabfa34badd18f1da5ec1bc2715cadc8dca465868a4e73a0173466b688f29dda"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22e14b5d70560b8dd51ec22863f370d1e595ac3d024cb8ad7d308b4cd95f8313"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8436c508b408b82d87dc5f62496973a1805cd46727c34440b0d29d8a2f50a6c9"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2d074908e1aecee37a7635990b2c6d504cd4766c7bc9fc86d63f9c09af3fa11b"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:955f8851919303c92343d2f66165294848d57e9bba6cf6e3625485a70a038d11"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:44ecbf16649486d4aebafeaa7ec4c9fed8b88101f4dd612dcaf65d5e815f837f"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0924e81d3d5e70f8126529951dac65c1010cdf117bb75eb02dd12339b57749dd"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2967f74ad52c3b98de4c3b32e1a44e32975e008a9cd2a8cc8966d6a5218c5cb2"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c75cb2a3e389853835e84a2d8fb2b81a10645b503eca9bcb98df6b5a43eb8886"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:09b26ae6b1abf0d27570633b2b078a2a20419c99d66fb2823173d73f188ce601"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fa88b843d6e211393a37219e6a1c1df99d35e8fd90446f1118f4216e307e48cd"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-win32.whl", hash = "sha256:eb8178fe3dba6450a3e024e95ac49ed3400e506fd4e9e5c32d30adda88cbd407"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:b1ac5992a838106edb89654e0aebfc24f5848ae2547d22c2c3f66454daa11971"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f30bf9fd9be89ecb2360c7d94a711f00c09b976258846efe40db3d05828e8089"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:97f68b8d6831127e4787ad15e6757232e14e12060bec17091b85eb1486b91d8d"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7974a0b5ecd505609e3b19742b60cee7aa2aa2fb3151bc917e6e2646d7667dcf"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc54db6c8593ef7d4b2a331b58653356cf04f67c960f584edb7c3d8c97e8f39e"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:311f30128d7d333eebd7896965bfcfbd0065f1716ec92bd5638d7748eb6f936a"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:7d053096f67cd1241601111b698f5cad775f97ab25d81567d3f59219b5f1adbd"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_i686.whl", hash = "sha256:807f52c1f798eef6cf26beb819eeb8819b1622ddfeef9d0977a8502d4db6d534"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_ppc64le.whl", hash = "sha256:dccbe65bd2f7f7ec22c4ff99ed56faa1e9f785482b9bbd7c717e26fd723a1d1e"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_s390x.whl", hash = "sha256:2fb9bd477fdea8684f78791a6de97a953c51831ee2981f8e4f583ff3b9d9687e"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:01732659ba9b5b873fc117534143e4feefecf3b2078b0a6a2e925271bb6f4cfa"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-win32.whl", hash = "sha256:7a4f97a081603d2050bfaffdefa5b02a9ec823f8348a572e39032caa8404a487"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-win_amd64.whl", hash = "sha256:7b1bef6280950ee6c177b326508f86cad7ad4dff12454483b51d8b7d673a2c5d"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:ecddf25bee22fe4fe3737a399d0d177d72bc22be6913acfab364b40bce1ba83c"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c60ca7339acd497a55b0ea5d506b2a2612afb2826560416f6894e8b5770d4a9"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b7b2d86dd06bfc2ade3312a83a5c364c7ec2e3498f8734282c6c3d4b07b346b8"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dd78cfcda14a1ef52584dbb008f7ac81c1328c0f58184bf9a84c49c605002da6"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6e27f48bcd0957c6d4cb9d6fa6b61d192d0b13d5ef563e5f2ae35feafc0d179c"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:01ad647cdd609225c5350561d084b42ddf732f4eeefe6e678765636791e78b9a"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:619a609aa74ae43d90ed2e89bdd784765de0a25ca761b93e196d938b8fd1dbbd"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:89149166622f4db9b4b6a449256291dc87a99ee53151c74cbd82a53c8c2f6ccd"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:7709f51f5f7c853f0fb938bcd3bc59cdfdc5203635ffd18bf354f6967ea0f824"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:345b0426edd4e18138d6528aed636de7a9ed169b4aaf9d61a8c19e39d26838ca"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:0907f11d019260cdc3f94fbdb23ff9125f6b5d1039b76003b5b0ac9d6a6c9d5b"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-win32.whl", hash = "sha256:ea0d8d539afa5eb2728aa1932a988a9a7af94f18582ffae4bc10b3fbdad0626e"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:329ce159e82018d646c7ac45b01a430369d526569ec08516081727a20e9e4af4"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:b97e690a2118911e39b4042088092771b4ae3fc3aa86518f84b8cf6888dbdb41"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:78baa6d91634dfb69ec52a463534bc0df05dbd546209b79a3880a34487f4b84f"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1a2bc9f351a75ef49d664206d51f8e5ede9da246602dc2d2726837620ea034b2"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:75832c08354f595c760a804588b9357d34ec00ba1c940c15e31e96d902093770"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0af291f4fe114be0280cdd29d533696a77b5b49cfde5467176ecab32353395c4"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0167ddc8ab6508fe81860a57dd472b2ef4060e8d378f0cc555707126830f2537"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:2a75d49014d118e4198bcee5ee0a6f25856b29b12dbf7cd012791f8a6cc5c496"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:363e2f92b0f0174b2f8238240a1a30142e3db7b957a5dd5689b0e75fb717cc78"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:ab36c8eb7e454e34e60eb55ca5d241a5d18b2c6244f6827a30e451c42410b5f7"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:4c0907b1928a36d5a998d72d64d8eaa7244989f7aaaf947500d3a800c83a3fd6"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:04432ad9479fa40ec0f387795ddad4437a2b50417c69fa275e212933519ff294"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-win32.whl", hash = "sha256:3bed14e9c89dcb10e8f3a29f9ccac4955aebe93c71ae803af79265c9ca5644c5"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:49402233c892a461407c512a19435d1ce275543138294f7ef013f0b63d5d3765"}, + {file = "charset_normalizer-3.4.1-py3-none-any.whl", hash = "sha256:d98b1668f06378c6dbefec3b92299716b931cd4e6061f3c875a71ced1780ab85"}, + {file = "charset_normalizer-3.4.1.tar.gz", hash = "sha256:44251f18cd68a75b56585dd00dae26183e102cd5e0f9f1466e6df5da2ed64ea3"}, +] + +[[package]] +name = "exceptiongroup" +version = "1.2.2" +description = "Backport of PEP 654 (exception groups)" +optional = false +python-versions = ">=3.7" +groups = ["main"] +markers = "python_version < \"3.11\"" +files = [ + {file = "exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b"}, + {file = "exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc"}, +] + +[package.extras] +test = ["pytest (>=6)"] + +[[package]] +name = "h11" +version = "0.16.0" +description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"}, + {file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"}, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +description = "A minimal low-level HTTP client." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55"}, + {file = "httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8"}, +] + +[package.dependencies] +certifi = "*" +h11 = ">=0.16" + +[package.extras] +asyncio = ["anyio (>=4.0,<5.0)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] +trio = ["trio (>=0.22.0,<1.0)"] + +[[package]] +name = "httpx" +version = "0.28.1" +description = "The next generation HTTP client." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"}, + {file = "httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc"}, +] + +[package.dependencies] +anyio = "*" +certifi = "*" +httpcore = "==1.*" +idna = "*" + +[package.extras] +brotli = ["brotli ; platform_python_implementation == \"CPython\"", "brotlicffi ; platform_python_implementation != \"CPython\""] +cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] +zstd = ["zstandard (>=0.18.0)"] + +[[package]] +name = "idna" +version = "3.10" +description = "Internationalized Domain Names in Applications (IDNA)" +optional = false +python-versions = ">=3.6" +groups = ["main"] +files = [ + {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, + {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, +] + +[package.extras] +all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] + +[[package]] +name = "iso8601" +version = "1.1.0" +description = "Simple module to parse ISO 8601 dates" +optional = false +python-versions = ">=3.6.2,<4.0" +groups = ["main"] +files = [ + {file = "iso8601-1.1.0-py3-none-any.whl", hash = "sha256:8400e90141bf792bce2634df533dc57e3bee19ea120a87bebcd3da89a58ad73f"}, + {file = "iso8601-1.1.0.tar.gz", hash = "sha256:32811e7b81deee2063ea6d2e94f8819a86d1f3811e49d23623a41fa832bef03f"}, +] + +[[package]] +name = "requests" +version = "2.32.3" +description = "Python HTTP for Humans." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6"}, + {file = "requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760"}, +] + +[package.dependencies] +certifi = ">=2017.4.17" +charset-normalizer = ">=2,<4" +idna = ">=2.5,<4" +urllib3 = ">=1.21.1,<3" + +[package.extras] +socks = ["PySocks (>=1.5.6,!=1.5.7)"] +use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] + +[[package]] +name = "sniffio" +version = "1.3.1" +description = "Sniff out which async library your code is running under" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"}, + {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, +] + +[[package]] +name = "soupsieve" +version = "2.6" +description = "A modern CSS selector implementation for Beautiful Soup." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "soupsieve-2.6-py3-none-any.whl", hash = "sha256:e72c4ff06e4fb6e4b5a9f0f55fe6e81514581fca1515028625d0f299c602ccc9"}, + {file = "soupsieve-2.6.tar.gz", hash = "sha256:e2e68417777af359ec65daac1057404a3c8a5455bb8abc36f1a9866ab1a51abb"}, +] + +[[package]] +name = "tabulate" +version = "0.9.0" +description = "Pretty-print tabular data" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "tabulate-0.9.0-py3-none-any.whl", hash = "sha256:024ca478df22e9340661486f85298cff5f6dcdba14f3813e8830015b9ed1948f"}, + {file = "tabulate-0.9.0.tar.gz", hash = "sha256:0095b12bf5966de529c0feb1fa08671671b3368eec77d7ef7ab114be2c068b3c"}, +] + +[package.extras] +widechars = ["wcwidth"] + +[[package]] +name = "typing-extensions" +version = "4.12.2" +description = "Backported and Experimental Type Hints for Python 3.8+" +optional = false +python-versions = ">=3.8" +groups = ["main"] +markers = "python_version < \"3.11\"" +files = [ + {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, + {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, +] + +[[package]] +name = "urllib3" +version = "1.26.20" +description = "HTTP library with thread-safe connection pooling, file post, and more." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.7" +groups = ["main"] +files = [ + {file = "urllib3-1.26.20-py2.py3-none-any.whl", hash = "sha256:0ed14ccfbf1c30a9072c7ca157e4319b70d65f623e91e7b32fadb2853431016e"}, + {file = "urllib3-1.26.20.tar.gz", hash = "sha256:40c2dc0c681e47eb8f90e7e27bf6ff7df2e677421fd46756da1161c39ca70d32"}, +] + +[package.extras] +brotli = ["brotli (==1.0.9) ; os_name != \"nt\" and python_version < \"3\" and platform_python_implementation == \"CPython\"", "brotli (>=1.0.9) ; python_version >= \"3\" and platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; (os_name != \"nt\" or python_version >= \"3\") and platform_python_implementation != \"CPython\"", "brotlipy (>=0.6.0) ; os_name == \"nt\" and python_version < \"3\""] +secure = ["certifi", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "ipaddress ; python_version == \"2.7\"", "pyOpenSSL (>=0.14)", "urllib3-secure-extra"] +socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] + +[metadata] +lock-version = "2.1" +python-versions = "^3.8" +content-hash = "b17c2cdd4b140f2ab8081bca7d94630e821fa2e882ac768b1bd8cf3ec58726ce" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..862451f --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,29 @@ +[tool.poetry] +name = "doujinshi-dl" +version = "2.0.5" +description = "doujinshi downloader" +authors = ["Ricter Z "] +license = "MIT" +readme = "README.rst" +homepage = "https://github.com/RicterZ/doujinshi-dl" +repository = "https://github.com/RicterZ/doujinshi-dl" +packages = [{include = "doujinshi_dl"}] +include = ["doujinshi_dl/viewer/**"] + + +[tool.poetry.dependencies] +python = "^3.8" +requests = ">=2.28" +soupsieve = ">=2.3" +beautifulsoup4 = ">=4.11" +tabulate = ">=0.9" +iso8601 = ">=1.0" +httpx = ">=0.23" +chardet = ">=4.0" + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" + +[tool.poetry.scripts] +doujinshi-dl = 'doujinshi_dl.command:main' diff --git a/qodana.yaml b/qodana.yaml new file mode 100755 index 0000000..76f58ef --- /dev/null +++ b/qodana.yaml @@ -0,0 +1,29 @@ +#-------------------------------------------------------------------------------# +# Qodana analysis is configured by qodana.yaml file # +# https://www.jetbrains.com/help/qodana/qodana-yaml.html # +#-------------------------------------------------------------------------------# +version: "1.0" + +#Specify inspection profile for code analysis +profile: + name: qodana.starter + +#Enable inspections +#include: +# - name: + +#Disable inspections +#exclude: +# - name: +# paths: +# - + +#Execute shell command before Qodana execution (Applied in CI/CD pipeline) +#bootstrap: sh ./prepare-qodana.sh + +#Install IDE plugins before Qodana execution (Applied in CI/CD pipeline) +#plugins: +# - id: #(plugin id can be found at https://plugins.jetbrains.com) + +#Specify Qodana linter for analysis (Applied in CI/CD pipeline) +linter: jetbrains/qodana-python:2024.3 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29