diff --git a/.coverage b/.coverage new file mode 100644 index 0000000..f8db8e0 Binary files /dev/null and b/.coverage differ diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..70321ac --- /dev/null +++ b/.dockerignore @@ -0,0 +1,13 @@ +.git +.github +.idea +.mypy_cache +.pytest_cache +.ruff_cache +.venv +build +capture +captures +tests +*.egg-info +.coverage diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml new file mode 100644 index 0000000..ae261fa --- /dev/null +++ b/.github/workflows/publish-pypi.yml @@ -0,0 +1,31 @@ +name: Publish to PyPI + +on: + workflow_dispatch: + push: + tags: + - "v*" + +jobs: + publish: + name: Build and publish package + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up uv + uses: astral-sh/setup-uv@v7 + with: + python-version: "3.14" + enable-cache: true + + - name: Build distribution artifacts + run: uv build + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.github/workflows/publish-to-pypi.yml b/.github/workflows/publish-to-pypi.yml deleted file mode 100644 index ac41a33..0000000 --- a/.github/workflows/publish-to-pypi.yml +++ /dev/null @@ -1,46 +0,0 @@ -name: Publish Python 🐍 distributions 📦 to PyPI -on: - push: - tags: - - "[0-9]+.[0-9]+.[0-9]+" - -jobs: - build-n-publish: - name: Build and publish Python 🐍 distributions 📦 to PyPI and TestPyPI - runs-on: ubuntu-22.04 - steps: - - uses: actions/checkout@master - - - name: Build Changelog - id: github_release - uses: mikepenz/release-changelog-builder-action@v4 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - failOnError: "true" - commitMode: "true" - - - name: Create Release - uses: actions/create-release@v1 - with: - tag_name: ${{ github.ref }} - release_name: ${{ github.ref }} - body: ${{steps.github_release.outputs.changelog}} - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - name: Set up Python 3.7 - uses: actions/setup-python@v1 - with: - python-version: 3.7 - - - name: Install wheel - run: python -m pip install wheel - - - name: Build a source tarball - run: python setup.py sdist bdist_wheel - - - name: Publish distribution 📦 to PyPI - uses: pypa/gh-action-pypi-publish@release/v1 - with: - user: __token__ - password: ${{ secrets.PYPI_API_TOKEN }} diff --git a/.github/workflows/readme-examples.yml b/.github/workflows/readme-examples.yml new file mode 100644 index 0000000..2f4b699 --- /dev/null +++ b/.github/workflows/readme-examples.yml @@ -0,0 +1,27 @@ +name: Verify README Examples + +on: + workflow_dispatch: + schedule: + - cron: "0 6 * * 1" + +jobs: + verify-readme-examples: + name: Verify README example targets + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up uv + uses: astral-sh/setup-uv@v7 + with: + python-version: "3.14" + enable-cache: true + + - name: Sync dependencies + run: uv sync --locked + + - name: Verify README examples + run: uv run python scripts/verify_readme_examples.py diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..710609f --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,133 @@ +name: Tests + +on: + push: + pull_request: + +jobs: + ty: + name: Ty type check + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.14"] + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up uv + uses: astral-sh/setup-uv@v7 + with: + python-version: ${{ matrix.python-version }} + enable-cache: true + + - name: Sync dependencies + run: uv sync --locked + + - name: Run ty + run: uv run ty check mloader scripts + + ruff: + name: Ruff lint and format + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.14"] + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up uv + uses: astral-sh/setup-uv@v7 + with: + python-version: ${{ matrix.python-version }} + enable-cache: true + + - name: Sync dependencies + run: uv sync --locked + + - name: Run ruff check + run: uv run ruff check . + + - name: Run ruff format + run: uv run ruff format --check . + + pytest: + name: Run pytest with coverage + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.14"] + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up uv + uses: astral-sh/setup-uv@v7 + with: + python-version: ${{ matrix.python-version }} + enable-cache: true + + - name: Sync dependencies + run: uv sync --locked + + - name: Run tests + run: uv run pytest --cov=mloader --cov-report=term-missing --cov-fail-under=100 + + capture-verify: + name: Verify capture schema fixtures + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.14"] + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up uv + uses: astral-sh/setup-uv@v7 + with: + python-version: ${{ matrix.python-version }} + enable-cache: true + + - name: Sync dependencies + run: uv sync --locked + + - name: Verify baseline capture schema + run: | + uv run mloader --verify-capture-schema tests/fixtures/api_captures/baseline \ + --verify-capture-baseline tests/fixtures/api_captures/baseline + + docs-lint: + name: Docs lint (README option sync) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.14"] + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up uv + uses: astral-sh/setup-uv@v7 + with: + python-version: ${{ matrix.python-version }} + enable-cache: true + + - name: Sync dependencies + run: uv sync --locked + + - name: Run docs lint tests + run: | + uv run python scripts/sync_readme_cli_reference.py --check + uv run pytest -q tests/test_readme_cli_options.py tests/test_cli_readme_reference.py diff --git a/.gitignore b/.gitignore index e539d90..2d2d3e0 100644 --- a/.gitignore +++ b/.gitignore @@ -52,7 +52,14 @@ log.txt ###################### extra/ venv/ +.venv/ # [date]full_name(id) report folders \[*\]*\(*\)/ *.egg-info -mloader_downloads/ \ No newline at end of file +mloader_downloads/ +build +capture/ +captures/ +.uv-cache/ +.ruff_cache/ +!tests/fixtures/**/*.json diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..6324d40 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.14 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..80157ec --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,68 @@ +# Contributing + +Thanks for contributing to `mloader`. + +## Local setup + +1. Use Python 3.14 or newer. +2. Create and activate a virtual environment. +3. Install package and development dependencies: + +```bash +pip install -e .[dev] +``` + +## Development workflow + +1. Create a feature branch. +2. Implement change with tests. +3. Run the test suite: + +```bash +pytest +``` + +4. Open a pull request with: +- problem statement +- implementation notes +- test evidence + +## Architecture overview + +Core areas: +- `mloader/cli/`: command-line interface and argument validation. +- `mloader/manga_loader/`: API integration, normalization, downloading, and decryption mixins. +- `mloader/exporters/`: output backends (`raw`, `cbz`, `pdf`) built on `ExporterBase`. + +## Extension points + +### Add a new exporter + +1. Create a new file in `mloader/exporters/`. +2. Subclass `ExporterBase`. +3. Define a unique `format` class attribute. +4. Implement: +- `add_image(self, image_data, index)` +- `skip_image(self, index)` +5. Export your class from `mloader/exporters/init.py`. +6. Add tests in `tests/test_exporters.py` (or a new test module). + +### Add CLI options + +1. Add a new click option in `mloader/cli/main.py`. +2. Thread option data into `MangaLoader` or exporter configuration. +3. Add CLI tests in `tests/test_cli_main.py`. + +### Add loader behavior + +1. Keep API/parsing logic in `mloader/manga_loader/api.py`. +2. Keep download orchestration in `mloader/manga_loader/downloader.py`. +3. Prefer pure helper functions for data transformations. +4. Cover behavior in tests with fakes/mocks, not network calls. + +## Quality bar + +- No untested behavior changes. +- No network in unit tests. +- Backward-compatible CLI flags unless intentionally deprecated. +- Prefer small, composable changes over broad rewrites. diff --git a/README.md b/README.md index 4c77ce7..55aa327 100644 --- a/README.md +++ b/README.md @@ -1,40 +1,148 @@ # Mangaplus Downloader -[![Latest Github release](https://img.shields.io/github/tag/hurlenko/mloader.svg)](https://github.com/hurlenko/mloader/releases/latest) -![Python](https://img.shields.io/badge/python-v3.6+-blue.svg) +![Version](https://img.shields.io/badge/version-v2.0.3-brightgreen.svg) +![Python](https://img.shields.io/badge/python-v3.14+-blue.svg) +![Coverage](https://img.shields.io/badge/coverage-100%25-brightgreen.svg) ![License](https://img.shields.io/badge/license-GPLv3-blue.svg) ## **mloader** - download manga from mangaplus.shueisha.co.jp ## 🚩 Table of Contents -- [Installation](#-installation) -- [Usage](#-usage) -- [Command line interface](#%EF%B8%8F-command-line-interface) +- [Installation](#-installation) +- [Development](#-development) +- [Testing](#-testing) +- [Usage](#-usage) +- [Command line interface](#%EF%B8%8F-command-line-interface) +- [Extending mloader](#-extending-mloader) ## 💾 Installation -The recommended installation method is using `pip`: +The recommended installation method is using `uv`: ```bash -pip install mloader +uv tool install mloader-ng ``` After installation, the `mloader` command will be available. Check the [command line](%EF%B8%8F-command-line-interface) section for supported commands. +If you prefer `pip`, `pip install mloader-ng` still works. + +## 🛠 Development + +```bash +git clone https://github.com/l0westbob/mloader.git +cd mloader +uv sync +``` + +This package is published as `mloader-ng` (temporary maintained rewrite fork). +The CLI command remains `mloader`. + +## ✅ Testing + +```bash +uv run pytest +``` + +Coverage is enforced at **100%** in CI: + +```bash +uv run pytest --cov=mloader --cov-report=term-missing --cov-fail-under=100 +``` + +Lint and format checks run through Ruff: + +```bash +uv run ruff check . +uv run ruff format --check . +``` + +Type checking runs through ty: + +```bash +uv run ty check mloader scripts +``` + +Verify README example targets against live MangaPlus API responses: + +```bash +uv run python scripts/verify_readme_examples.py +``` + ## 📙 Usage Copy the url of the chapter or title you want to download and pass it to `mloader`. -You can use `--title` and `--chapter` command line argument to download by title and chapter id. +Use `--title` with `--chapter` to target chapter numbers, or `--chapter-id` for direct API chapter IDs. You can download individual chapters or full title (but only available chapters). -Chapters can be saved as `CBZ` archives (default) or separate images by passing the `--raw` parameter. +Chapters can be saved in different formats (check the `--help` output for the available formats). + +Typical MangaPlus IDs are multi-digit integers (title IDs are commonly 6 digits), for example: + +```bash +mloader https://mangaplus.shueisha.co.jp/viewer/1024959 +mloader https://mangaplus.shueisha.co.jp/titles/100312 -f pdf +mloader --title 100312 --chapter 12 +mloader --chapter-id 1024959 +mloader --title 100312 --cover +``` + +For an exhaustive, option-complete command catalog (including discovery, capture, resume, and output modes): + +```bash +mloader --show-examples +``` + +When `--capture-api` is enabled, mloader stores every fetched API payload (raw protobuf + metadata + parsed JSON when possible). This is useful for regression fixture collection and for tracking upstream API changes over time. + +Every title directory now includes a resumable download manifest at `.mloader-manifest.json`. +Rerunning the same command skips chapters already marked as completed and retries chapters that previously failed or were interrupted. + +Use `--no-resume` to ignore manifest state for a run, or `--manifest-reset` to clear manifest state before downloading. + +Download all discoverable titles from MangaPlus list pages with one command: + +```bash +mloader --all --format pdf +``` + +As of February 25, 2026, this will download 24,944 chapters over a total of 637 titles with a size of around 220GB (English catalog). + +The bulk command uses protobuf API discovery first (`/api/title_list/allV2`), then falls back to +static page scraping and optional browser-rendered scraping (`--browser-fallback`, enabled by +default) when needed. + +Restrict bulk discovery to specific languages: + +```bash +mloader --all --language english --language spanish --list-only +``` + +Supported `--language` values: +- `english` +- `spanish` +- `french` +- `indonesian` +- `portuguese` +- `russian` +- `thai` +- `german` +- `vietnamese` + +As of February 24, 2026, all the languages above are present in the live `allV2` payload. + +Install browser runtime locally with: + +```bash +playwright install chromium +``` ## 🖥️ Command line interface -Currently `mloader` supports these commands +Currently `mloader` supports these options ``` Usage: mloader [OPTIONS] [URLS]... @@ -43,22 +151,193 @@ Usage: mloader [OPTIONS] [URLS]... Options: --version Show the version and exit. - -o, --out Save directory (not a file) [default: + --json Emit structured JSON output to stdout + --quiet Suppress non-error human-readable output + --verbose Increase logging verbosity (repeatable) + -o, --out Output directory for downloads [default: mloader_downloads] - -r, --raw Save raw images [default: False] + --verify-capture-schema + Verify captured API payloads + against required response schema fields and + exit + --verify-capture-baseline + Compare verified capture schema + signatures against a baseline capture + directory + --all Discover all available titles and + download them + --page TEXT MangaPlus list page to scrape for title + links (repeatable) + --title-index-endpoint TEXT MangaPlus web API endpoint used for + API-first title discovery + --id-length INTEGER RANGE If set, keep only title IDs with this + exact digit length + --language [english|spanish|french|indonesian|portuguese|russian|thai|german|vietnamese] + Restrict --all discovery to one or + more languages (repeatable) + --list-only Only print discovered title IDs for + --all and exit + --browser-fallback / --no-browser-fallback + Use Playwright-rendered scraping when + static page fetch yields no title IDs + -r, --raw Save raw images + -f, --format [cbz|pdf] Save as CBZ or PDF [default: cbz] + --capture-api Dump raw API payload captures (protobuf + + metadata) to this directory -q, --quality [super_high|high|low] Image quality [default: super_high] - -s, --split Split combined images [default: False] - -c, --chapter INTEGER Chapter id + -s, --split Split combined images + -c, --chapter INTEGER Chapter number + --chapter-id INTEGER Chapter API ID -t, --title INTEGER Title id -b, --begin INTEGER RANGE Minimal chapter to try to download [default: 0;x>=0] -e, --end INTEGER RANGE Maximal chapter to try to download [x>=1] -l, --last Download only the last chapter for title - [default: False] --chapter-title Include chapter titles in filenames - [default: False] - --chapter-subdir Save raw images in sub directory by chapter - [default: False] + --chapter-subdir Save raw images in subdirectories by chapter + -m, --meta Export additional metadata as JSON + --resume / --no-resume Use per-title manifest state to skip + already completed chapters + --manifest-reset Reset per-title manifest state before + downloading --help Show this message and exit. -``` \ No newline at end of file +``` + +Output mode behavior: + +- `--json`: emits machine-readable JSON payloads for successful command completion and controlled command failures. +- `--quiet`: suppresses intro and informational command output. +- `--verbose`: enables debug-level logging. + +Download run summaries include: +- downloaded chapter count +- manifest-skipped chapter count +- failed chapter count and failed chapter IDs + +### Parameter reference + +This section is generated from CLI metadata. Update it with `uv run python scripts/sync_readme_cli_reference.py`. + + +`URLS`: +- Positional MangaPlus URLs (`viewer/` and `titles/`). + +| Option | Description | Default | Env | +| --- | --- | --- | --- | +| `--version` | Show the version and exit. | `false` | `-` | +| `--json` | Emit structured JSON output to stdout | `false` | `-` | +| `--quiet` | Suppress non-error human-readable output | `false` | `-` | +| `--show-examples` | Print exhaustive command examples and exit | `false` | `-` | +| `--verbose`, `-v` | Increase logging verbosity (repeatable) | `0` | `-` | +| `--out`, `-o` | Output directory for downloads | `mloader_downloads` | `MLOADER_EXTRACT_OUT_DIR` | +| `--verify-capture-schema` | Verify captured API payloads against required response schema fields and exit | `-` | `-` | +| `--verify-capture-baseline` | Compare verified capture schema signatures against a baseline capture directory | `-` | `-` | +| `--all` | Discover all available titles and download them | `false` | `-` | +| `--page` | MangaPlus list page to scrape for title links (repeatable) | `https://mangaplus.shueisha.co.jp/manga_list/ongoing, https://mangaplus.shueisha.co.jp/manga_list/completed, https://mangaplus.shueisha.co.jp/manga_list/one_shot` | `-` | +| `--title-index-endpoint` | MangaPlus web API endpoint used for API-first title discovery | `https://jumpg-webapi.tokyo-cdn.com/api/title_list/allV2` | `MLOADER_TITLE_INDEX_ENDPOINT` | +| `--id-length` | If set, keep only title IDs with this exact digit length | `-` | `-` | +| `--language` | Restrict --all discovery to one or more languages (repeatable) | `-` | `-` | +| `--list-only` | Only print discovered title IDs for --all and exit | `false` | `-` | +| `--browser-fallback`, `--no-browser-fallback` | Use Playwright-rendered scraping when static page fetch yields no title IDs | `true` | `-` | +| `--raw`, `-r` | Save raw images | `false` | `MLOADER_RAW` | +| `--format`, `-f` | Save as CBZ or PDF | `cbz` | `MLOADER_OUTPUT_FORMAT` | +| `--capture-api` | Dump raw API payload captures (protobuf + metadata) to this directory | `-` | `MLOADER_CAPTURE_API_DIR` | +| `--quality`, `-q` | Image quality | `super_high` | `MLOADER_QUALITY` | +| `--split`, `-s` | Split combined images | `false` | `MLOADER_SPLIT` | +| `--chapter`, `-c` | Chapter number (integer, e.g. 1, 12) | `-` | `-` | +| `--chapter-id` | Chapter API ID (integer, e.g. 1024959) | `-` | `-` | +| `--title`, `-t` | Title ID (integer, usually 6 digits, e.g. 100312) | `-` | `-` | +| `--begin`, `-b` | Minimal chapter to download | `0` | `-` | +| `--end`, `-e` | Maximal chapter to download | `-` | `-` | +| `--last`, `-l` | Download only the last chapter for each title | `false` | `-` | +| `--chapter-title` | Include chapter titles in filenames | `false` | `-` | +| `--chapter-subdir` | Save raw images in subdirectories by chapter | `false` | `-` | +| `--meta`, `-m` | Export additional metadata as JSON | `false` | `-` | +| `--cover` | Download each title cover image as PNG | `false` | `-` | +| `--resume`, `--no-resume` | Use per-title manifest state to skip already completed chapters | `true` | `-` | +| `--manifest-reset` | Reset per-title manifest state before downloading | `false` | `-` | + + +Deterministic exit-code mapping: + +- `0`: success +- `2`: user input/usage error (Click argument parsing) +- `3`: validation error (invalid CLI option combinations, schema verification validation) +- `4`: external failure (upstream API/subscription/access failures) +- `5`: internal bug/unexpected runtime failure + +Runtime auth settings (`app_ver`, `os`, `os_ver`, `secret`) are resolved with this priority: + +1. CLI/runtime overrides (internal, reserved for programmatic usage) +2. Environment variables: `APP_VER`, `OS`, `OS_VER`, `SECRET` +3. Config file: `MLOADER_CONFIG_FILE` (or local `.mloader.toml`) +4. Built-in defaults + +Example TOML config: + +```toml +[auth] +app_ver = "97" +os = "ios" +os_ver = "18.1" +secret = "your-secret" +``` + +When `--meta` is enabled, `title_metadata.json` stores chapters keyed by chapter ID (`"chapters": {"": ...}`) and includes each chapter `sub_title` and `thumbnail_url`. + +Verify your recorded payload set: + +```bash +mloader --verify-capture-schema ./capture +``` + +Compare a new capture run against your committed baseline: + +```bash +mloader --verify-capture-schema ./capture --verify-capture-baseline ./tests/fixtures/api_captures/baseline +``` + +## 🐳 Docker + +`docker/Dockerfile` installs `mloader` from the local repository files. + +The default `compose.yaml` now runs a long-lived cron daemon inside the container and executes `mloader` weekly. + +Default schedule and arguments: + +```bash +MLOADER_CRON_SCHEDULE="0 3 * * 1" +MLOADER_CRON_ARGS="--all --language english --format pdf" +``` + +This means: every Monday at 03:00 container time. + +Useful runtime knobs in `compose.yaml`: + +- `MLOADER_CRON_SCHEDULE`: standard 5-field cron expression. +- `MLOADER_CRON_ARGS`: arguments passed to `mloader` for scheduled runs. +- `MLOADER_RUN_ON_START`: `"true"` to run one job immediately on container startup. + +Run in background: + +```bash +docker compose up -d --build +``` + +Check scheduler logs: + +```bash +docker compose logs -f mloader +``` + +## 🧩 Extending mloader + +`mloader` is designed around composable mixins and exporter classes. + +- Add a new exporter by subclassing `ExporterBase`. +- Set `format = ""` in your exporter. +- Implement `add_image` and `skip_image`. + +See `CONTRIBUTING.md` for architecture and extension details. +Detailed architecture notes are in `docs/ARCHITECTURE.md`. diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..dbce4e7 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,19 @@ +services: + mloader: + container_name: "mloader" + build: + context: . + dockerfile: docker/Dockerfile + environment: + APP_VER: 97 + OS: ios + OS_VER: 18.1 + SECRET: e75b23a6296a2575a49b5449119a72d8 + MLOADER_EXTRACT_OUT_DIR: /downloads + MLOADER_CRON_SCHEDULE: "0 3 * * 1" + MLOADER_CRON_ARGS: "--all --language english --format pdf" + MLOADER_RUN_ON_START: "false" + volumes: + - ./mloader_downloads:/downloads + entrypoint: ["/usr/local/bin/start-cron.sh"] + restart: unless-stopped diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 0000000..7fa27e6 --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,20 @@ +FROM python:3.14-slim +COPY --from=ghcr.io/astral-sh/uv:0.10.12 /uv /uvx /bin/ + +WORKDIR /app + +RUN apt-get update \ + && apt-get install --yes --no-install-recommends cron \ + && rm -rf /var/lib/apt/lists/* + +COPY pyproject.toml uv.lock README.md response.proto .python-version /app/ +COPY mloader /app/mloader +RUN uv sync --locked --no-dev \ + && uv run playwright install --with-deps chromium + +COPY docker/start-cron.sh /usr/local/bin/start-cron.sh +RUN chmod +x /usr/local/bin/start-cron.sh + +ENV PATH="/app/.venv/bin:${PATH}" + +ENTRYPOINT ["mloader"] diff --git a/docker/start-cron.sh b/docker/start-cron.sh new file mode 100755 index 0000000..356aee3 --- /dev/null +++ b/docker/start-cron.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env sh +set -eu + +CRON_SCHEDULE="${MLOADER_CRON_SCHEDULE:-0 3 * * 1}" +CRON_ARGS="${MLOADER_CRON_ARGS:---all --language english --format pdf}" +RUN_ON_START="${MLOADER_RUN_ON_START:-false}" +CRON_FILE="/etc/cron.d/mloader" +MLOADER_BIN="${MLOADER_BIN:-/app/.venv/bin/mloader}" +PATH="/app/.venv/bin:${PATH}" + +if [ ! -x "${MLOADER_BIN}" ]; then + if command -v mloader >/dev/null 2>&1; then + MLOADER_BIN="$(command -v mloader)" + else + echo "Error: mloader executable not found (checked ${MLOADER_BIN} and PATH)." >&2 + exit 127 + fi +fi + +echo "Configuring weekly mloader cron job..." +echo "Schedule: ${CRON_SCHEDULE}" +echo "Args: ${CRON_ARGS}" +echo "Binary: ${MLOADER_BIN}" + +cat > "${CRON_FILE}" <> /proc/1/fd/1 2>> /proc/1/fd/2 +EOF + +chmod 0644 "${CRON_FILE}" + +if [ "${RUN_ON_START}" = "true" ]; then + echo "Running initial mloader execution before cron starts..." + /bin/sh -lc "${MLOADER_BIN} ${CRON_ARGS}" +fi + +echo "Starting cron daemon in foreground..." +exec cron -f diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..df2db41 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,61 @@ +# Architecture + +`mloader` is intentionally split into small units that can be extended independently. + +## Runtime flow + +1. `mloader/cli/main.py` parses CLI options and translates them into immutable request models. +2. `mloader/cli/presenter.py` renders human/JSON outputs and keeps presentation concerns out of command orchestration. +3. `mloader/application/workflows.py` executes use-cases (`--all` discovery + download orchestration). +4. `mloader/cli/title_discovery.py` provides reusable discovery gateway helpers (API-first, optional language filters, static scrape, browser fallback). +5. `MangaLoader` facade composes runtime mixin behavior and coordinates downloads. +6. Exporters write chapter content to the chosen output format. + +## Layering + +- `mloader/cli`: argument parsing, command UX, terminal-facing errors. +- `mloader/application`: orchestration logic and use-case validation. +- `mloader/domain`: immutable request models and pure domain constants. +- `mloader/manga_loader` + `mloader/exporters`: infrastructure/runtime implementation. +- `mloader/config.py`: immutable layered auth settings (overrides > env > file > defaults). + +## Loader mixins + +- `api.py`: API URL/params and protobuf response parsing. +- `capture.py`: optional API payload capture (raw protobuf + metadata + parsed JSON). +- `capture_verify.py`: verification of capture payloads against required runtime fields and optional baseline drift checks. +- `normalization.py`: maps input title/chapter IDs into normalized work units. +- `downloader.py`: chapter/page orchestration and metadata export. +- `manifest.py`: persistent per-title chapter state tracking for resumable runs. +- `decryption.py`: image decryption helper. + +Keeping these responsibilities separate allows targeted tests and easier refactoring. + +## Exporter model + +All exporters inherit from `ExporterBase`: +- `add_image(image_data, index)` +- `skip_image(index)` +- `close()` + +Current implementations: +- `RawExporter` +- `CBZExporter` +- `PDFExporter` + +New formats should follow the same contract to remain compatible with `DownloadMixin`. + +Double-page naming note: +- DOUBLE spreads are represented with `range(start, stop)` indexes where `stop` is treated as an inclusive paired page marker for filename formatting (for example `p000-001`). + +## Testing strategy + +- Unit tests for deterministic logic (normalization, validators, naming, filtering). +- Behavior tests for CLI orchestration. +- Exporter tests with temporary directories and synthetic image bytes. +- No network calls in unit tests. + +## Future roadmap + +- Add integration tests that replay captured payload sets against download planning. +- Add optional schema drift checks for captured response envelopes. diff --git a/mloader/.env b/mloader/.env new file mode 100644 index 0000000..3af7908 --- /dev/null +++ b/mloader/.env @@ -0,0 +1,5 @@ +APP_VER=97 +OS=ios +OS_VER=18.1 +# will be invalid 1st march 2026 +SECRET=e75b23a6296a2575a49b5449119a72d8 \ No newline at end of file diff --git a/mloader/__init__.py b/mloader/__init__.py index e69de29..ad4bf8a 100644 --- a/mloader/__init__.py +++ b/mloader/__init__.py @@ -0,0 +1 @@ +"""Top-level package for mloader.""" diff --git a/mloader/__main__.py b/mloader/__main__.py index afcad18..4297685 100644 --- a/mloader/__main__.py +++ b/mloader/__main__.py @@ -1,231 +1,6 @@ -import logging -import re -import sys -from functools import partial -from typing import Optional, Set +"""Application entrypoint used by ``python -m mloader``.""" -import click +from mloader.cli.main import main -from mloader import __version__ as about -from mloader.exporter import RawExporter, CBZExporter -from mloader.loader import MangaLoader - -log = logging.getLogger() - - -def setup_logging(): - for logger in ("requests", "urllib3"): - logging.getLogger(logger).setLevel(logging.WARNING) - handlers = [logging.StreamHandler(sys.stdout)] - logging.basicConfig( - handlers=handlers, - format=( - "{asctime:^} | {levelname: ^8} | " - "{filename: ^14} {lineno: <4} | {message}" - ), - style="{", - datefmt="%d.%m.%Y %H:%M:%S", - level=logging.INFO, - ) - - -setup_logging() - - -def validate_urls(ctx: click.Context, param, value): - if not value: - return value - - res = {"viewer": set(), "titles": set()} - for url in value: - match = re.search(r"(\w+)/(\d+)", url) - if not match: - raise click.BadParameter(f"Invalid url: {url}") - try: - res[match.group(1)].add(int(match.group(2))) - except (ValueError, KeyError): - raise click.BadParameter(f"Invalid url: {url}") - - ctx.params.setdefault("titles", set()).update(res["titles"]) - ctx.params.setdefault("chapters", set()).update(res["viewer"]) - - -def validate_ids(ctx: click.Context, param, value): - if not value: - return value - - assert param.name in ("chapter", "title") - - ctx.params.setdefault(f"{param.name}s", set()).update(value) - - -EPILOG = f""" -Examples: - -{click.style('• download manga chapter 1 as CBZ archive', fg="green")} - - $ mloader https://mangaplus.shueisha.co.jp/viewer/1 - -{click.style('• download all chapters for manga title 2 and save ' -'to current directory', fg="green")} - - $ mloader https://mangaplus.shueisha.co.jp/titles/2 -o . - -{click.style('• download chapter 1 AND all available chapters from ' -'title 2 (can be two different manga) in low quality and save as ' -'separate images', fg="green")} - - $ mloader https://mangaplus.shueisha.co.jp/viewer/1 - https://mangaplus.shueisha.co.jp/titles/2 -r -q low -""" - - -@click.command( - help=about.__description__, - epilog=EPILOG, -) -@click.version_option( - about.__version__, - prog_name=about.__title__, - message="%(prog)s by Hurlenko, version %(version)s\n" - f"Check {about.__url__} for more info", -) -@click.option( - "--out", - "-o", - "out_dir", - type=click.Path(exists=False, writable=True), - metavar="", - default="mloader_downloads", - show_default=True, - help="Save directory (not a file)", - envvar="MLOADER_EXTRACT_OUT_DIR", -) -@click.option( - "--raw", - "-r", - is_flag=True, - default=False, - show_default=True, - help="Save raw images", - envvar="MLOADER_RAW", -) -@click.option( - "--quality", - "-q", - default="super_high", - type=click.Choice(["super_high", "high", "low"]), - show_default=True, - help="Image quality", - envvar="MLOADER_QUALITY", -) -@click.option( - "--split", - "-s", - is_flag=True, - default=False, - show_default=True, - help="Split combined images", - envvar="MLOADER_SPLIT", -) -@click.option( - "--chapter", - "-c", - type=click.INT, - multiple=True, - help="Chapter id", - expose_value=False, - callback=validate_ids, -) -@click.option( - "--title", - "-t", - type=click.INT, - multiple=True, - help="Title id", - expose_value=False, - callback=validate_ids, -) -@click.option( - "--begin", - "-b", - type=click.IntRange(min=0), - default=0, - show_default=True, - help="Minimal chapter to try to download", -) -@click.option( - "--end", - "-e", - type=click.IntRange(min=1), - help="Maximal chapter to try to download", -) -@click.option( - "--last", - "-l", - is_flag=True, - default=False, - show_default=True, - help="Download only the last chapter for title", -) -@click.option( - "--chapter-title", - is_flag=True, - default=False, - show_default=True, - help="Include chapter titles in filenames", -) -@click.option( - "--chapter-subdir", - is_flag=True, - default=False, - show_default=True, - help="Save raw images in sub directory by chapter", -) -@click.argument("urls", nargs=-1, callback=validate_urls, expose_value=False) -@click.pass_context -def main( - ctx: click.Context, - out_dir: str, - raw: bool, - quality: str, - split: bool, - begin: int, - end: int, - last: bool, - chapter_title: bool, - chapter_subdir: bool, - chapters: Optional[Set[int]] = None, - titles: Optional[Set[int]] = None, -): - click.echo(click.style(about.__doc__, fg="blue")) - if not any((chapters, titles)): - click.echo(ctx.get_help()) - return - end = end or float("inf") - log.info("Started export") - - exporter = RawExporter if raw else CBZExporter - exporter = partial( - exporter, - destination=out_dir, - add_chapter_title=chapter_title, - add_chapter_subdir=chapter_subdir, - ) - - loader = MangaLoader(exporter, quality, split) - try: - loader.download( - title_ids=titles, - chapter_ids=chapters, - min_chapter=begin, - max_chapter=end, - last_chapter=last, - ) - except Exception: - log.exception("Failed to download manga") - log.info("SUCCESS") - - -if __name__ == "__main__": - main(prog_name=about.__title__) +if __name__ == "__main__": # pragma: no cover + main() diff --git a/mloader/__version__.py b/mloader/__version__.py index 981c900..47746ab 100644 --- a/mloader/__version__.py +++ b/mloader/__version__.py @@ -1,13 +1,16 @@ -""" +"""Project metadata constants.""" + +__intro__ = r""" _ _ _ __ ___ | | ___ __ _ __| | ___ _ __ | '_ ` _ \| |/ _ \ / _` |/ _` |/ _ \ '__| | | | | | | | (_) | (_| | (_| | __/ | |_| |_| |_|_|\___/ \__,_|\__,_|\___|_| + """ __title__ = "mloader" __description__ = "Command-line tool to download manga from mangaplus" __url__ = "https://github.com/hurlenko/mloader" -__version__ = "1.1.12" +__version__ = "2.0.3" __license__ = "GPLv3" diff --git a/mloader/application/__init__.py b/mloader/application/__init__.py new file mode 100644 index 0000000..61d7b1b --- /dev/null +++ b/mloader/application/__init__.py @@ -0,0 +1 @@ +"""Application-layer use cases for CLI orchestration.""" diff --git a/mloader/application/workflows.py b/mloader/application/workflows.py new file mode 100644 index 0000000..bca5ef9 --- /dev/null +++ b/mloader/application/workflows.py @@ -0,0 +1,324 @@ +"""Application-layer workflows decoupled from CLI parsing details.""" + +from __future__ import annotations + +from functools import partial +from typing import Callable, Collection, Mapping, Protocol, Sequence, TypeAlias, cast + +import requests + +from mloader.domain.requests import ( + ApiOutputFormat, + DownloadSummary, + DownloadRequest, + DiscoveryRequest, + EffectiveOutputFormat, +) +from mloader.errors import APIResponseError +from mloader.manga_loader.downloader import DownloadInterruptedError +from mloader.manga_loader.init import MangaLoader +from mloader.types import ExporterFactoryLike + + +class WorkflowError(RuntimeError): + """Base class for workflow-level execution failures.""" + + +class DiscoveryError(WorkflowError): + """Raise when title discovery for ``--all`` cannot produce IDs.""" + + +class ExternalDependencyError(WorkflowError): + """Raise when external systems fail during download execution.""" + + +class DownloadInterrupted(ExternalDependencyError): + """Raise when user interrupts download while preserving partial summary.""" + + def __init__(self, summary: DownloadSummary) -> None: + """Store partial summary generated before interruption.""" + super().__init__("Download interrupted by user.") + self.summary = summary + + +class TitleDiscoveryGateway(Protocol): + """Protocol for title discovery backends used by ``--all`` workflow.""" + + def parse_language_filters(self, languages: Sequence[str]) -> set[int] | None: + """Map user-facing language names to API language codes.""" + + def collect_title_ids_from_api( + self, + title_index_endpoint: str, + *, + id_length: int | None, + allowed_languages: set[int] | None, + request_timeout: tuple[float, float] = (5.0, 30.0), + ) -> list[int]: + """Collect title IDs from the allV2 API endpoint.""" + + def collect_title_ids( + self, + pages: Sequence[str], + *, + id_length: int | None, + request_timeout: tuple[float, float] = (5.0, 30.0), + ) -> list[int]: + """Collect title IDs from static page HTML.""" + + def collect_title_ids_with_browser( + self, + pages: Sequence[str], + *, + id_length: int | None, + timeout_ms: int = 60000, + ) -> list[int]: + """Collect title IDs from browser-rendered list pages.""" + + +ExporterClass: TypeAlias = Callable[..., object] + + +def discover_title_ids( + request: DiscoveryRequest, + *, + gateway: TitleDiscoveryGateway, +) -> tuple[list[int], list[str]]: + """Discover title IDs and return ``(ids, notices)`` for CLI output.""" + notices: list[str] = [] + allowed_languages = gateway.parse_language_filters(request.languages) + title_ids: list[int] = [] + + try: + title_ids = gateway.collect_title_ids_from_api( + request.title_index_endpoint, + id_length=request.id_length, + allowed_languages=allowed_languages, + ) + except requests.RequestException as exc: + if allowed_languages is not None: + raise DiscoveryError( + "Language filtering requires API title-index access, but the API request failed: " + f"{exc}" + ) from exc + notices.append(f"API title-index fetch failed: {exc}") + + if not title_ids and allowed_languages is None: + try: + title_ids = gateway.collect_title_ids( + request.pages, + id_length=request.id_length, + ) + except requests.RequestException as exc: + if not request.browser_fallback: + raise DiscoveryError(f"Failed to fetch title pages: {exc}") from exc + notices.append(f"Static fetch failed: {exc}. Retrying with browser fallback.") + + if not title_ids and request.browser_fallback and allowed_languages is None: + try: + title_ids = gateway.collect_title_ids_with_browser( + request.pages, + id_length=request.id_length, + ) + except RuntimeError as exc: + raise DiscoveryError(str(exc)) from exc + except Exception as exc: # pragma: no cover - defensive fallback wrapper + raise DiscoveryError(f"Browser fallback failed: {exc}") from exc + + if not title_ids and allowed_languages is not None: + selected_languages = ", ".join(language.lower() for language in request.languages) + raise DiscoveryError( + f"No title IDs found for selected language filter(s): {selected_languages}." + ) + + if not title_ids: + raise DiscoveryError( + "No title IDs found on configured list pages. " + "Try enabling browser fallback or verify page access." + ) + + return title_ids, notices + + +def resolve_exporter( + request: DownloadRequest, + *, + raw_exporter: ExporterClass, + pdf_exporter: ExporterClass, + cbz_exporter: ExporterClass, +) -> tuple[ExporterClass, EffectiveOutputFormat]: + """Resolve exporter class and effective output format from request options.""" + if request.raw: + return raw_exporter, "raw" + if request.output_format == "pdf": + return pdf_exporter, "pdf" + return cbz_exporter, "cbz" + + +def execute_download( + request: DownloadRequest, + *, + loader_factory: type[MangaLoader], + raw_exporter: ExporterClass, + pdf_exporter: ExporterClass, + cbz_exporter: ExporterClass, +) -> DownloadSummary: + """Execute the configured download request via the provided factories.""" + exporter_class, effective_output_format = resolve_exporter( + request, + raw_exporter=raw_exporter, + pdf_exporter=pdf_exporter, + cbz_exporter=cbz_exporter, + ) + typed_exporter_factory = cast( + ExporterFactoryLike, + cast( + object, + partial( + exporter_class, + destination=request.out_dir, + add_chapter_title=request.chapter_title, + add_chapter_subdir=request.chapter_subdir, + ), + ), + ) + + loader = loader_factory( + typed_exporter_factory, + request.quality, + request.split, + request.meta, + request.cover, + destination=request.out_dir, + output_format=effective_output_format, + capture_api_dir=request.capture_api_dir, + resume=request.resume, + manifest_reset=request.manifest_reset, + ) + try: + summary = loader.download( + title_ids=request.titles or None, + chapter_numbers=request.chapters or None, + chapter_ids=request.chapter_ids or None, + min_chapter=request.begin, + max_chapter=request.max_chapter, + last_chapter=request.last, + ) + except DownloadInterruptedError as exc: + raise DownloadInterrupted(exc.summary) from exc + except (requests.RequestException, APIResponseError) as exc: + raise ExternalDependencyError(f"Download request failed: {exc}") from exc + + if isinstance(summary, DownloadSummary): + return summary + return DownloadSummary( + downloaded=0, + skipped_manifest=0, + failed=0, + failed_chapter_ids=(), + ) + + +def summarize_discovery(title_ids: Collection[int]) -> str: + """Return human-readable summary for discovered title IDs.""" + return f"Discovered {len(title_ids)} title ID(s)." + + +def format_discovered_ids(title_ids: Collection[int]) -> str: + """Return a space-separated title ID list for CLI printing.""" + return " ".join(str(title_id) for title_id in title_ids) + + +def verify_discovery_flags( + *, + download_all_titles: bool, + list_only: bool, + languages: Collection[str], +) -> str | None: + """Return validation error message for discovery-only flags, if any.""" + if list_only and not download_all_titles: + return "--list-only requires --all." + if languages and not download_all_titles: + return "--language requires --all." + return None + + +def build_download_request( + *, + out_dir: str, + raw: bool, + output_format: str, + capture_api_dir: str | None, + quality: str, + split: bool, + begin: int, + end: int | None, + last: bool, + chapter_title: bool, + chapter_subdir: bool, + meta: bool, + cover: bool, + resume: bool, + manifest_reset: bool, + chapters: Collection[int] | None, + chapter_ids: Collection[int] | None, + titles: Collection[int] | None, +) -> DownloadRequest: + """Create a typed download request from CLI-normalized values.""" + api_output_format: ApiOutputFormat = "pdf" if output_format == "pdf" else "cbz" + return DownloadRequest( + out_dir=out_dir, + raw=raw, + output_format=api_output_format, + capture_api_dir=capture_api_dir, + quality=quality, + split=split, + begin=begin, + end=end, + last=last, + chapter_title=chapter_title, + chapter_subdir=chapter_subdir, + meta=meta, + cover=cover, + resume=resume, + manifest_reset=manifest_reset, + chapters=frozenset(chapters or set()), + chapter_ids=frozenset(chapter_ids or set()), + titles=frozenset(titles or set()), + ) + + +def build_discovery_request( + *, + pages: tuple[str, ...], + title_index_endpoint: str, + id_length: int | None, + languages: tuple[str, ...], + browser_fallback: bool, +) -> DiscoveryRequest: + """Create a typed discovery request from CLI-normalized values.""" + return DiscoveryRequest( + pages=pages, + title_index_endpoint=title_index_endpoint, + id_length=id_length, + languages=languages, + browser_fallback=browser_fallback, + ) + + +def to_chapter_id_debug_map( + request: DownloadRequest, +) -> Mapping[str, int | bool | str | None]: + """Return minimal structured fields useful for debug logging.""" + return { + "target_titles": len(request.titles), + "target_chapters": len(request.chapters), + "target_chapter_ids": len(request.chapter_ids), + "begin": request.begin, + "end": request.end, + "raw": request.raw, + "format": request.output_format, + "cover": request.cover, + "resume": request.resume, + "manifest_reset": request.manifest_reset, + } diff --git a/mloader/cli/__init__.py b/mloader/cli/__init__.py new file mode 100644 index 0000000..a786c49 --- /dev/null +++ b/mloader/cli/__init__.py @@ -0,0 +1 @@ +"""CLI package exports.""" diff --git a/mloader/cli/config.py b/mloader/cli/config.py new file mode 100644 index 0000000..7e9da4f --- /dev/null +++ b/mloader/cli/config.py @@ -0,0 +1,32 @@ +"""Logging configuration helpers for CLI execution.""" + +from __future__ import annotations + +import logging +import sys +from typing import TextIO + + +def setup_logging( + *, + level: int = logging.INFO, + stream: TextIO | None = None, +) -> None: + """Configure application logging for console output.""" + for logger_name in ("requests", "urllib3"): + logging.getLogger(logger_name).setLevel(logging.WARNING) + + stream_handler = logging.StreamHandler(stream or sys.stderr) + logging.basicConfig( + handlers=[stream_handler], + format=("{asctime:^} | {levelname: ^8} | {filename: ^14} {lineno: <4} | {message}"), + style="{", + datefmt="%d.%m.%Y %H:%M:%S", + level=level, + force=True, + ) + + +def get_logger(name: str | None = None) -> logging.Logger: + """Return a logger for ``name`` or the root logger when omitted.""" + return logging.getLogger(name) diff --git a/mloader/cli/examples.py b/mloader/cli/examples.py new file mode 100644 index 0000000..8611ebf --- /dev/null +++ b/mloader/cli/examples.py @@ -0,0 +1,170 @@ +"""Curated CLI example catalog used by ``--show-examples`` output mode.""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True, slots=True) +class CliExample: + """One runnable CLI example with short context.""" + + title: str + command: str + description: str + + +_EXAMPLE_SPECS: tuple[tuple[str, str, str], ...] = ( + ( + "Show this complete example catalog", + "{prog} --show-examples", + "Print all curated examples and exit.", + ), + ( + "Print CLI version", + "{prog} --version", + "Check installed mloader version.", + ), + ( + "Download from a chapter viewer URL", + "{prog} https://mangaplus.shueisha.co.jp/viewer/1024959", + "Default output format is CBZ.", + ), + ( + "Download all chapters from a title URL as PDF to custom directory", + "{prog} https://mangaplus.shueisha.co.jp/titles/100312 --out ./downloads --format pdf", + "Uses positional URL target plus output format and destination overrides.", + ), + ( + "Target by title and chapter number", + "{prog} --title 100312 --chapter 12", + "Resolve chapter number to API chapter ID within selected titles.", + ), + ( + "Target by explicit chapter API ID", + "{prog} --chapter-id 1024959", + "Useful when a viewer URL or known API chapter ID is available.", + ), + ( + "Download multiple titles with chapter range bounds", + "{prog} --title 100312 --title 100315 --begin 3 --end 25", + "Restrict downloads to chapter numbers within the selected interval.", + ), + ( + "Download only the latest chapter per title", + "{prog} --title 100312 --last", + "Use latest-only mode for quick update checks.", + ), + ( + "Save raw images with chapter title and chapter subdirectories", + "{prog} --title 100312 --raw --chapter-title --chapter-subdir", + "Useful when post-processing images externally.", + ), + ( + "Tune quality and request split pages", + "{prog} --title 100312 --quality low --split", + "Lower quality and split behavior can reduce transfer size.", + ), + ( + "Export title metadata JSON", + "{prog} --title 100312 --meta", + "Writes title metadata and per-chapter metadata into output directory.", + ), + ( + "Download title cover image as PNG", + "{prog} --title 100312 --cover", + "Stores one cover image per title as cover.png.", + ), + ( + "Capture API payloads for regression analysis", + "{prog} --title 100312 --capture-api ./capture/new-run", + "Stores protobuf payloads and metadata for future schema checks.", + ), + ( + "Verify capture schema compatibility", + "{prog} --verify-capture-schema ./capture/new-run", + "Checks captured payloads against required decode fields and exits.", + ), + ( + "Compare capture schema against baseline fixtures", + "{prog} --verify-capture-schema ./capture/new-run --verify-capture-baseline ./tests/fixtures/api_captures/baseline", + "Detects schema drift between current captures and baseline captures.", + ), + ( + "Discover and download all titles", + "{prog} --all --format pdf", + "Bulk mode starts with API-first discovery and downloads all found titles.", + ), + ( + "List discovered titles only (no download)", + "{prog} --all --list-only", + "Print title IDs from discovery and exit.", + ), + ( + "Restrict bulk discovery to selected languages", + "{prog} --all --language english --language spanish", + "Language filtering applies to API-first discovery payloads.", + ), + ( + "Restrict bulk discovery by title ID length", + "{prog} --all --id-length 6", + "Keeps only IDs with exact digit length.", + ), + ( + "Use custom list pages for fallback scraping", + "{prog} --all --page https://mangaplus.shueisha.co.jp/manga_list/ongoing --page https://mangaplus.shueisha.co.jp/manga_list/completed", + "Useful if you want to scope fallback page crawling.", + ), + ( + "Use custom API title-index endpoint", + "{prog} --all --title-index-endpoint https://jumpg-webapi.tokyo-cdn.com/api/title_list/allV2", + "Overrides API endpoint used for title discovery.", + ), + ( + "Disable browser fallback in bulk mode", + "{prog} --all --no-browser-fallback", + "Fail fast when API/static fetch produces no IDs.", + ), + ( + "Enable browser fallback explicitly", + "{prog} --all --browser-fallback", + "For clarity in scripts where explicit fallback behavior is preferred.", + ), + ( + "Emit machine-readable JSON output", + "{prog} --json --chapter-id 1024959", + "Structured output mode for automation and scripting.", + ), + ( + "Suppress non-error human output", + "{prog} --quiet --chapter-id 1024959", + "Keeps stdout quieter in interactive shells.", + ), + ( + "Enable debug logging", + "{prog} --verbose --chapter-id 1024959", + "Repeat --verbose for additional detail (for example: -vv).", + ), + ( + "Retry failed chapters from manifest while skipping completed", + "{prog} --title 100312 --resume", + "Explicitly enables manifest-based resume behavior.", + ), + ( + "Disable resume and reset manifest state before download", + "{prog} --title 100312 --no-resume --manifest-reset", + "Forces fresh run behavior regardless of prior manifest state.", + ), +) + + +def build_cli_examples(*, prog_name: str) -> tuple[CliExample, ...]: + """Return full example catalog with ``prog_name`` substituted into commands.""" + return tuple( + CliExample( + title=title, + command=command.format(prog=prog_name), + description=description, + ) + for title, command, description in _EXAMPLE_SPECS + ) diff --git a/mloader/cli/exit_codes.py b/mloader/cli/exit_codes.py new file mode 100644 index 0000000..d8c2e80 --- /dev/null +++ b/mloader/cli/exit_codes.py @@ -0,0 +1,7 @@ +"""Deterministic process exit-code mapping for the CLI.""" + +SUCCESS = 0 +USER_ERROR = 2 +VALIDATION_ERROR = 3 +EXTERNAL_FAILURE = 4 +INTERNAL_BUG = 5 diff --git a/mloader/cli/main.py b/mloader/cli/main.py new file mode 100644 index 0000000..dc809ee --- /dev/null +++ b/mloader/cli/main.py @@ -0,0 +1,594 @@ +"""Command-line interface definition for mloader.""" + +from __future__ import annotations + +import logging +from typing import NoReturn, cast + +import click + +from mloader import __version__ as about +from mloader.application import workflows +from mloader.cli import examples as cli_examples +from mloader.cli import title_discovery +from mloader.cli.config import setup_logging +from mloader.cli.exit_codes import EXTERNAL_FAILURE, INTERNAL_BUG, SUCCESS, VALIDATION_ERROR +from mloader.cli.presenter import CliPresenter +from mloader.cli.validators import validate_ids, validate_urls +from mloader.config import AUTH_SETTINGS +from mloader.domain.requests import DownloadRequest, DownloadSummary +from mloader.errors import SubscriptionRequiredError +from mloader.exporters.init import CBZExporter, PDFExporter, RawExporter +from mloader.manga_loader.capture_verify import ( + CaptureVerificationError, + CaptureVerificationSummary, + verify_capture_schema, + verify_capture_schema_against_baseline, +) +from mloader.manga_loader.init import MangaLoader + +log = logging.getLogger(__name__) +SUPPORTED_AUTH_OS_VALUES: frozenset[str] = frozenset({"ios", "android"}) + + +class MloaderCliError(click.ClickException): + """Click exception that carries deterministic exit code mapping.""" + + def __init__(self, message: str, *, exit_code: int) -> None: + """Store message and deterministic process exit code.""" + super().__init__(message) + self.exit_code = exit_code + + +@click.command( + help=about.__description__, +) +@click.version_option( + about.__version__, + prog_name=about.__title__, + message="%(prog)s by Hurlenko, version %(version)s\nCheck {url} for more info".format( + url=about.__url__ + ), +) +@click.option( + "--json", + "json_output", + is_flag=True, + default=False, + show_default=True, + help="Emit structured JSON output to stdout", +) +@click.option( + "--quiet", + is_flag=True, + default=False, + show_default=True, + help="Suppress non-error human-readable output", +) +@click.option( + "--show-examples", + is_flag=True, + default=False, + show_default=True, + help="Print exhaustive command examples and exit", +) +@click.option( + "--verbose", + "-v", + count=True, + help="Increase logging verbosity (repeatable)", +) +@click.option( + "--out", + "-o", + "out_dir", + type=click.Path(exists=False, writable=True), + metavar="", + default="mloader_downloads", + show_default=True, + help="Output directory for downloads", + envvar="MLOADER_EXTRACT_OUT_DIR", +) +@click.option( + "--verify-capture-schema", + "verify_capture_schema_dir", + type=click.Path(exists=True, file_okay=False, readable=True), + metavar="", + help="Verify captured API payloads against required response schema fields and exit", +) +@click.option( + "--verify-capture-baseline", + "verify_capture_baseline_dir", + type=click.Path(exists=True, file_okay=False, readable=True), + metavar="", + help="Compare verified capture schema signatures against a baseline capture directory", +) +@click.option( + "--all", + "download_all_titles", + is_flag=True, + default=False, + show_default=True, + help="Discover all available titles and download them", +) +@click.option( + "--page", + "pages", + multiple=True, + default=title_discovery.DEFAULT_LIST_PAGES, + show_default=True, + help="MangaPlus list page to scrape for title links (repeatable)", +) +@click.option( + "--title-index-endpoint", + type=str, + default=title_discovery.DEFAULT_TITLE_INDEX_ENDPOINT, + show_default=True, + help="MangaPlus web API endpoint used for API-first title discovery", + envvar="MLOADER_TITLE_INDEX_ENDPOINT", +) +@click.option( + "--id-length", + type=click.IntRange(min=1), + default=None, + help="If set, keep only title IDs with this exact digit length", +) +@click.option( + "--language", + "languages", + multiple=True, + type=click.Choice(title_discovery.LANGUAGE_FILTER_CHOICES, case_sensitive=False), + help="Restrict --all discovery to one or more languages (repeatable)", +) +@click.option( + "--list-only", + is_flag=True, + default=False, + show_default=True, + help="Only print discovered title IDs for --all and exit", +) +@click.option( + "--browser-fallback/--no-browser-fallback", + default=True, + show_default=True, + help="Use Playwright-rendered scraping when static page fetch yields no title IDs", +) +@click.option( + "--raw", + "-r", + is_flag=True, + default=False, + show_default=True, + help="Save raw images", + envvar="MLOADER_RAW", +) +@click.option( + "--format", + "-f", + "output_format", + type=click.Choice(["cbz", "pdf"], case_sensitive=False), + default="cbz", + show_default=True, + help="Save as CBZ or PDF", + envvar="MLOADER_OUTPUT_FORMAT", +) +@click.option( + "--capture-api", + "capture_api_dir", + type=click.Path(file_okay=False, writable=True), + metavar="", + help="Dump raw API payload captures (protobuf + metadata) to this directory", + envvar="MLOADER_CAPTURE_API_DIR", +) +@click.option( + "--quality", + "-q", + default="super_high", + type=click.Choice(["super_high", "high", "low"]), + show_default=True, + help="Image quality", + envvar="MLOADER_QUALITY", +) +@click.option( + "--split", + "-s", + is_flag=True, + default=False, + show_default=True, + help="Split combined images", + envvar="MLOADER_SPLIT", +) +@click.option( + "--chapter", + "-c", + type=click.INT, + multiple=True, + help="Chapter number (integer, e.g. 1, 12)", + expose_value=False, + callback=validate_ids, +) +@click.option( + "--chapter-id", + type=click.INT, + multiple=True, + help="Chapter API ID (integer, e.g. 1024959)", + expose_value=False, + callback=validate_ids, +) +@click.option( + "--title", + "-t", + type=click.INT, + multiple=True, + help="Title ID (integer, usually 6 digits, e.g. 100312)", + expose_value=False, + callback=validate_ids, +) +@click.option( + "--begin", + "-b", + type=click.IntRange(min=0), + default=0, + show_default=True, + help="Minimal chapter to download", +) +@click.option( + "--end", + "-e", + type=click.IntRange(min=1), + help="Maximal chapter to download", +) +@click.option( + "--last", + "-l", + is_flag=True, + default=False, + show_default=True, + help="Download only the last chapter for each title", +) +@click.option( + "--chapter-title", + is_flag=True, + default=False, + show_default=True, + help="Include chapter titles in filenames", +) +@click.option( + "--chapter-subdir", + is_flag=True, + default=False, + show_default=True, + help="Save raw images in subdirectories by chapter", +) +@click.option( + "--meta", + "-m", + is_flag=True, + default=False, + help="Export additional metadata as JSON", +) +@click.option( + "--cover", + is_flag=True, + default=False, + show_default=True, + help="Download each title cover image as PNG", +) +@click.option( + "--resume/--no-resume", + default=True, + show_default=True, + help="Use per-title manifest state to skip already completed chapters", +) +@click.option( + "--manifest-reset", + is_flag=True, + default=False, + show_default=True, + help="Reset per-title manifest state before downloading", +) +@click.argument("urls", nargs=-1, callback=validate_urls, expose_value=False) +@click.pass_context +def main( + ctx: click.Context, + json_output: bool, + quiet: bool, + show_examples: bool, + verbose: int, + out_dir: str, + verify_capture_schema_dir: str | None, + verify_capture_baseline_dir: str | None, + download_all_titles: bool, + pages: tuple[str, ...], + title_index_endpoint: str, + id_length: int | None, + languages: tuple[str, ...], + list_only: bool, + browser_fallback: bool, + raw: bool, + output_format: str, + capture_api_dir: str | None, + quality: str, + split: bool, + begin: int, + end: int | None, + last: bool, + chapter_title: bool, + chapter_subdir: bool, + meta: bool, + cover: bool, + resume: bool, + manifest_reset: bool, + chapters: set[int] | None = None, + chapter_ids: set[int] | None = None, + titles: set[int] | None = None, +) -> None: + """Run the CLI command and start the configured download flow.""" + setup_logging(level=_resolve_log_level(quiet=quiet, verbose=verbose, json_output=json_output)) + presenter = CliPresenter(json_output=json_output, quiet=quiet) + presenter.emit_intro(about.__intro__) + if AUTH_SETTINGS.os.lower() not in SUPPORTED_AUTH_OS_VALUES: + _fail( + "Warning: Unsupported API auth OS value configured via environment/config: " + f"'{AUTH_SETTINGS.os}'. Supported values are: ios, android.", + presenter=presenter, + exit_code=VALIDATION_ERROR, + ) + + if show_examples: + examples = cli_examples.build_cli_examples(prog_name=ctx.info_name or about.__title__) + presenter.emit_examples(examples) + return + + if verify_capture_baseline_dir and not verify_capture_schema_dir: + _fail( + "--verify-capture-baseline requires --verify-capture-schema.", + presenter=presenter, + exit_code=VALIDATION_ERROR, + ) + + if verify_capture_schema_dir: + capture_summary = _run_capture_verification_mode( + verify_capture_schema_dir=verify_capture_schema_dir, + verify_capture_baseline_dir=verify_capture_baseline_dir, + presenter=presenter, + ) + presenter.emit_capture_verification( + summary=capture_summary, + capture_dir=verify_capture_schema_dir, + baseline_dir=verify_capture_baseline_dir, + ) + return + + discovery_flag_error = workflows.verify_discovery_flags( + download_all_titles=download_all_titles, + list_only=list_only, + languages=languages, + ) + if discovery_flag_error is not None: + _fail( + discovery_flag_error, + presenter=presenter, + exit_code=VALIDATION_ERROR, + ) + + request = workflows.build_download_request( + out_dir=out_dir, + raw=raw, + output_format=output_format, + capture_api_dir=capture_api_dir, + quality=quality, + split=split, + begin=begin, + end=end, + last=last, + chapter_title=chapter_title, + chapter_subdir=chapter_subdir, + meta=meta, + cover=cover, + resume=resume, + manifest_reset=manifest_reset, + chapters=chapters, + chapter_ids=chapter_ids, + titles=titles, + ) + + discovery_metadata: dict[str, int] | None = None + if download_all_titles: + all_mode_request, discovery_metadata = _resolve_all_mode_targets( + request=request, + pages=pages, + title_index_endpoint=title_index_endpoint, + id_length=id_length, + languages=languages, + browser_fallback=browser_fallback, + list_only=list_only, + presenter=presenter, + ) + if all_mode_request is None: + return + request = all_mode_request + + if not request.has_targets: + click.echo(ctx.get_help()) + raise click.exceptions.Exit(SUCCESS) + + log.info("Started export") + log.debug("Download request: %s", workflows.to_chapter_id_debug_map(request)) + + try: + download_summary = workflows.execute_download( + request, + loader_factory=MangaLoader, + raw_exporter=RawExporter, + pdf_exporter=PDFExporter, + cbz_exporter=CBZExporter, + ) + except workflows.DownloadInterrupted as exc: + presenter.emit_download_summary(exc.summary) + _fail( + "Download interrupted by user.", + presenter=presenter, + exit_code=EXTERNAL_FAILURE, + details={"summary": _summary_payload(exc.summary)}, + ) + except SubscriptionRequiredError as exc: + _fail(str(exc), presenter=presenter, exit_code=EXTERNAL_FAILURE) + except workflows.ExternalDependencyError as exc: + _fail(str(exc), presenter=presenter, exit_code=EXTERNAL_FAILURE) + except Exception: + if not presenter.json_output: + log.exception("Failed to download manga") + _fail("Download failed", presenter=presenter, exit_code=INTERNAL_BUG) + + presenter.emit_download_summary(download_summary) + summary_payload = _summary_payload(download_summary) + if download_summary.has_failures: + _fail( + f"Download completed with {download_summary.failed} failed chapter(s).", + presenter=presenter, + exit_code=EXTERNAL_FAILURE, + details={"summary": summary_payload}, + ) + + log.info("SUCCESS") + if presenter.json_output: + presenter.emit_json( + { + "status": "ok", + "mode": "download", + "exit_code": SUCCESS, + "targets": { + "titles": len(request.titles), + "chapters": len(request.chapters), + "chapter_ids": len(request.chapter_ids), + }, + "discovery": discovery_metadata, + "summary": summary_payload, + } + ) + + +def _resolve_log_level(*, quiet: bool, verbose: int, json_output: bool) -> int: + """Resolve runtime logging level from output and verbosity flags.""" + if quiet: + return logging.WARNING + if verbose >= 1: + return logging.DEBUG + if json_output: + return logging.WARNING + return logging.INFO + + +def _summary_payload(summary: DownloadSummary) -> dict[str, object]: + """Build JSON-serializable summary payload from immutable summary model.""" + return { + "downloaded": summary.downloaded, + "skipped_manifest": summary.skipped_manifest, + "failed": summary.failed, + "failed_chapter_ids": list(summary.failed_chapter_ids), + } + + +def _run_capture_verification_mode( + *, + verify_capture_schema_dir: str, + verify_capture_baseline_dir: str | None, + presenter: CliPresenter, +) -> CaptureVerificationSummary: + """Run capture schema verification command mode and return summary.""" + try: + if verify_capture_baseline_dir: + summary = verify_capture_schema_against_baseline( + verify_capture_schema_dir, + verify_capture_baseline_dir, + ) + else: + summary = verify_capture_schema(verify_capture_schema_dir) + except CaptureVerificationError as exc: + _fail(str(exc), presenter=presenter, exit_code=VALIDATION_ERROR) + + return summary + + +def _resolve_all_mode_targets( + *, + request: DownloadRequest, + pages: tuple[str, ...], + title_index_endpoint: str, + id_length: int | None, + languages: tuple[str, ...], + browser_fallback: bool, + list_only: bool, + presenter: CliPresenter, +) -> tuple[DownloadRequest | None, dict[str, int] | None]: + """Resolve title targets for ``--all`` mode and optionally print-only IDs.""" + discovery_request = workflows.build_discovery_request( + pages=pages, + title_index_endpoint=title_index_endpoint, + id_length=id_length, + languages=languages, + browser_fallback=browser_fallback, + ) + try: + discovered_title_ids, notices = workflows.discover_title_ids( + discovery_request, + gateway=cast(workflows.TitleDiscoveryGateway, title_discovery), + ) + except workflows.DiscoveryError as exc: + _fail(str(exc), presenter=presenter, exit_code=EXTERNAL_FAILURE) + + presenter.emit_notices(notices) + + if presenter.json_output and list_only: + presenter.emit_json( + { + "status": "ok", + "mode": "all_list_only", + "exit_code": SUCCESS, + "count": len(discovered_title_ids), + "title_ids": discovered_title_ids, + } + ) + return None, None + + if presenter.emits_human_output: + presenter.emit_discovery_summary(discovered_title_ids) + if list_only: + presenter.emit_discovery_ids(discovered_title_ids) + return None, None + + if presenter.quiet and list_only: + return None, None + + updated_request = request.with_additional_titles(set(discovered_title_ids)) + metadata = { + "discovered_titles": len(discovered_title_ids), + } + return updated_request, metadata + + +def _fail( + message: str, + *, + presenter: CliPresenter, + exit_code: int, + details: dict[str, object] | None = None, +) -> NoReturn: + """Abort command execution with deterministic exit code and optional JSON error.""" + if presenter.json_output: + payload: dict[str, object] = { + "status": "error", + "exit_code": exit_code, + "message": message, + } + if details: + payload.update(details) + presenter.emit_json(payload) + raise click.exceptions.Exit(exit_code) + + raise MloaderCliError(message, exit_code=exit_code) + + +if __name__ == "__main__": # pragma: no cover + main(prog_name=about.__title__) diff --git a/mloader/cli/presenter.py b/mloader/cli/presenter.py new file mode 100644 index 0000000..17e7f1b --- /dev/null +++ b/mloader/cli/presenter.py @@ -0,0 +1,137 @@ +"""CLI presentation helpers for human and JSON output modes.""" + +from __future__ import annotations + +import json +from typing import Any, Iterable, Mapping, Sequence + +import click + +from mloader.application import workflows +from mloader.cli.examples import CliExample +from mloader.domain.requests import DownloadSummary +from mloader.manga_loader.capture_verify import CaptureVerificationSummary + + +class CliPresenter: + """Render command outputs for human and machine-readable modes.""" + + def __init__(self, *, json_output: bool, quiet: bool) -> None: + """Store output-mode flags for rendering decisions.""" + self.json_output = json_output + self.quiet = quiet + + @property + def emits_human_output(self) -> bool: + """Return whether human-readable output should be emitted.""" + return not self.json_output and not self.quiet + + def emit_intro(self, intro: str) -> None: + """Emit a styled intro banner when human output is enabled.""" + if self.emits_human_output: + click.echo(click.style(intro, fg="blue")) + + def emit_notice(self, message: str) -> None: + """Emit one human-readable informational message.""" + if self.emits_human_output: + click.echo(message) + + def emit_notices(self, messages: Iterable[str]) -> None: + """Emit multiple human-readable informational messages.""" + for message in messages: + self.emit_notice(message) + + def emit_capture_verification( + self, + *, + summary: CaptureVerificationSummary, + capture_dir: str, + baseline_dir: str | None, + ) -> None: + """Emit capture-verification output in current render mode.""" + if self.json_output: + self.emit_json( + { + "status": "ok", + "mode": "verify_capture", + "exit_code": 0, + "capture_dir": capture_dir, + "baseline_dir": baseline_dir, + "total_records": summary.total_records, + "endpoint_counts": dict(sorted(summary.endpoint_counts.items())), + } + ) + return + + if not self.emits_human_output: + return + + endpoint_overview = ", ".join( + f"{name}={count}" for name, count in sorted(summary.endpoint_counts.items()) + ) + if baseline_dir: + click.echo( + f"Verified {summary.total_records} capture payload(s) in {capture_dir} " + f"against baseline {baseline_dir} ({endpoint_overview})" + ) + else: + click.echo( + f"Verified {summary.total_records} capture payload(s) in " + f"{capture_dir} ({endpoint_overview})" + ) + + def emit_discovery_summary(self, title_ids: list[int]) -> None: + """Emit discovered title count in human mode.""" + self.emit_notice(workflows.summarize_discovery(title_ids)) + + def emit_discovery_ids(self, title_ids: list[int]) -> None: + """Emit discovered title IDs in human mode.""" + self.emit_notice(workflows.format_discovered_ids(title_ids)) + + def emit_download_summary(self, summary: DownloadSummary) -> None: + """Emit human-readable download result counters.""" + if not self.emits_human_output: + return + click.echo( + "Download summary: " + f"downloaded={summary.downloaded}, " + f"skipped_manifest={summary.skipped_manifest}, " + f"failed={summary.failed}" + ) + if summary.failed_chapter_ids: + failed_ids = " ".join(str(chapter_id) for chapter_id in summary.failed_chapter_ids) + click.echo(f"Failed chapter IDs: {failed_ids}") + + def emit_examples(self, examples: Sequence[CliExample]) -> None: + """Emit CLI example catalog for human and JSON output modes.""" + if self.json_output: + self.emit_json( + { + "status": "ok", + "mode": "show_examples", + "exit_code": 0, + "count": len(examples), + "examples": [ + { + "title": example.title, + "command": example.command, + "description": example.description, + } + for example in examples + ], + } + ) + return + + click.echo("mloader example catalog") + click.echo("These examples are intentionally exhaustive and option-complete.") + click.echo() + for index, example in enumerate(examples, 1): + click.echo(f"{index}. {example.title}") + click.echo(f" $ {example.command}") + click.echo(f" {example.description}") + click.echo() + + def emit_json(self, payload: Mapping[str, Any]) -> None: + """Emit one machine-readable JSON object to stdout.""" + click.echo(json.dumps(payload, sort_keys=True)) diff --git a/mloader/cli/readme_reference.py b/mloader/cli/readme_reference.py new file mode 100644 index 0000000..54e8b7e --- /dev/null +++ b/mloader/cli/readme_reference.py @@ -0,0 +1,118 @@ +"""Helpers to generate and sync README CLI parameter reference content.""" + +from __future__ import annotations + +from dataclasses import dataclass + +import click + +README_CLI_REFERENCE_START = "" +README_CLI_REFERENCE_END = "" + + +@dataclass(frozen=True, slots=True) +class CliOptionDoc: + """Render-ready documentation row for one CLI option.""" + + names: str + description: str + default: str + envvar: str + + +def _normalize_whitespace(text: str) -> str: + """Collapse multiline help/default strings into single-spaced text.""" + return " ".join(text.split()) + + +def _escape_markdown_cell(text: str) -> str: + """Escape markdown table metacharacters used inside one cell.""" + return text.replace("|", r"\|") + + +def _format_default(option: click.Option) -> str: + """Return normalized default display text for one option.""" + if option.show_default is False: + return "-" + default = option.default + if str(default).startswith("Sentinel."): + return "-" + if default in (None, (), []): + return "-" + if isinstance(default, tuple): + rendered = ", ".join(str(value) for value in default) + return rendered or "-" + if isinstance(default, bool): + return "true" if default else "false" + return str(default) + + +def _format_envvar(option: click.Option) -> str: + """Return normalized envvar display text for one option.""" + envvar = option.envvar + if envvar is None: + return "-" + if isinstance(envvar, tuple): + rendered = ", ".join(str(value) for value in envvar) + return rendered or "-" + return str(envvar) + + +def _format_option_names(option: click.Option) -> str: + """Return display string for primary and secondary option names.""" + names = [*option.opts, *option.secondary_opts] + return ", ".join(f"`{name}`" for name in names) + + +def _iter_option_docs(command: click.Command) -> list[CliOptionDoc]: + """Build docs rows for all click options in command declaration order.""" + rows: list[CliOptionDoc] = [] + for parameter in command.params: + if not isinstance(parameter, click.Option): + continue + rows.append( + CliOptionDoc( + names=_format_option_names(parameter), + description=_escape_markdown_cell(_normalize_whitespace(parameter.help or "")), + default=_escape_markdown_cell(_normalize_whitespace(_format_default(parameter))), + envvar=_escape_markdown_cell(_normalize_whitespace(_format_envvar(parameter))), + ) + ) + return rows + + +def render_cli_parameter_reference(command: click.Command) -> str: + """Render deterministic markdown table for all CLI parameters.""" + rows = _iter_option_docs(command) + lines = [ + "`URLS`:", + "- Positional MangaPlus URLs (`viewer/` and `titles/`).", + "", + "| Option | Description | Default | Env |", + "| --- | --- | --- | --- |", + ] + lines.extend( + f"| {row.names} | {row.description or '-'} | `{row.default}` | `{row.envvar}` |" + for row in rows + ) + return "\n".join(lines) + + +def replace_readme_cli_reference(readme_text: str, *, command: click.Command) -> str: + """Replace README auto-generated CLI reference section with rendered markdown.""" + rendered_reference = render_cli_parameter_reference(command).rstrip() + generated_block = ( + f"{README_CLI_REFERENCE_START}\n{rendered_reference}\n{README_CLI_REFERENCE_END}" + ) + + start_index = readme_text.find(README_CLI_REFERENCE_START) + end_index = readme_text.find(README_CLI_REFERENCE_END) + if start_index == -1 or end_index == -1 or end_index < start_index: + msg = ( + "README CLI reference markers not found. " + f"Expected markers: {README_CLI_REFERENCE_START} / {README_CLI_REFERENCE_END}" + ) + raise ValueError(msg) + + end_offset = end_index + len(README_CLI_REFERENCE_END) + return f"{readme_text[:start_index]}{generated_block}{readme_text[end_offset:]}" diff --git a/mloader/cli/title_discovery.py b/mloader/cli/title_discovery.py new file mode 100644 index 0000000..134ba43 --- /dev/null +++ b/mloader/cli/title_discovery.py @@ -0,0 +1,142 @@ +"""Title-discovery helpers for ``mloader --all`` mode.""" + +from __future__ import annotations + +import re +from typing import Sequence + +import requests + +from mloader.constants import Language +from mloader.response_pb2 import Response # type: ignore + +DEFAULT_LIST_PAGES: tuple[str, str, str] = ( + "https://mangaplus.shueisha.co.jp/manga_list/ongoing", + "https://mangaplus.shueisha.co.jp/manga_list/completed", + "https://mangaplus.shueisha.co.jp/manga_list/one_shot", +) +DEFAULT_TITLE_INDEX_ENDPOINT = "https://jumpg-webapi.tokyo-cdn.com/api/title_list/allV2" +# Match both '/titles/123' and escaped '\/titles\/123' shapes. +TITLE_ID_PATTERN = re.compile(r"\\?/titles\\?/(?P\d+)(?:\\?/|$|[?#\"'])") +LANGUAGE_FILTER_CODES: dict[str, set[int]] = { + language.name.lower(): {language.value} for language in Language +} +LANGUAGE_FILTER_CODES["vietnamese"].add(8) +LANGUAGE_FILTER_CHOICES = tuple(LANGUAGE_FILTER_CODES) + + +def extract_title_ids(html: str, id_length: int | None = 6) -> set[int]: + """Extract unique MangaPlus title IDs from HTML content.""" + title_ids: set[int] = set() + for match in TITLE_ID_PATTERN.finditer(html): + title_id = match.group("title_id") + if id_length is not None and len(title_id) != id_length: + continue + title_ids.add(int(title_id)) + return title_ids + + +def extract_title_ids_from_api_payload(payload: bytes, id_length: int | None = 6) -> set[int]: + """Extract unique MangaPlus title IDs from protobuf all-titles payload bytes.""" + return extract_title_ids_from_api_payload_with_language_filter( + payload, + id_length=id_length, + allowed_languages=None, + ) + + +def extract_title_ids_from_api_payload_with_language_filter( + payload: bytes, + *, + id_length: int | None, + allowed_languages: set[int] | None, +) -> set[int]: + """Extract unique title IDs with an optional language-code filter.""" + parsed = Response.FromString(payload) + title_ids: set[int] = set() + for title_group in parsed.success.all_titles_view.title_groups: + for title in title_group.titles: + title_id = title.title_id + if allowed_languages is not None and title.language not in allowed_languages: + continue + if title_id <= 0: + continue + if id_length is not None and len(str(title_id)) != id_length: + continue + title_ids.add(int(title_id)) + return title_ids + + +def collect_title_ids_from_api( + title_index_endpoint: str, + *, + id_length: int | None, + allowed_languages: set[int] | None, + request_timeout: tuple[float, float] = (5.0, 30.0), +) -> list[int]: + """Fetch web title-index payload and return sorted unique title IDs.""" + with requests.Session() as session: + response = session.get(title_index_endpoint, timeout=request_timeout) + response.raise_for_status() + title_ids = extract_title_ids_from_api_payload_with_language_filter( + response.content, + id_length=id_length, + allowed_languages=allowed_languages, + ) + return sorted(title_ids) + + +def parse_language_filters(languages: Sequence[str]) -> set[int] | None: + """Convert language filter strings into a set of numeric API language codes.""" + if not languages: + return None + + language_codes: set[int] = set() + for language in languages: + language_codes.update(LANGUAGE_FILTER_CODES[language.lower()]) + return language_codes + + +def collect_title_ids( + pages: Sequence[str], + *, + id_length: int | None, + request_timeout: tuple[float, float] = (5.0, 30.0), +) -> list[int]: + """Fetch configured list pages and return sorted unique title IDs.""" + title_ids: set[int] = set() + with requests.Session() as session: + for page_url in pages: + response = session.get(page_url, timeout=request_timeout) + response.raise_for_status() + title_ids.update(extract_title_ids(response.text, id_length=id_length)) + return sorted(title_ids) + + +def collect_title_ids_with_browser( + pages: Sequence[str], + *, + id_length: int | None, + timeout_ms: int = 60000, +) -> list[int]: + """Render list pages in a browser and extract title IDs from DOM links.""" + try: + from playwright.sync_api import sync_playwright + except ImportError as exc: # pragma: no cover - import path is covered by CLI tests + raise RuntimeError( + "Playwright is not installed. Install project dependencies with 'uv sync' and run " + "'playwright install chromium'." + ) from exc + + title_ids: set[int] = set() + with sync_playwright() as playwright: + browser = playwright.chromium.launch(headless=True) + page = browser.new_page() + for page_url in pages: + page.goto(page_url, wait_until="networkidle", timeout=timeout_ms) + for link in page.query_selector_all("a[href]"): + href = link.get_attribute("href") + if href: + title_ids.update(extract_title_ids(href, id_length=id_length)) + browser.close() + return sorted(title_ids) diff --git a/mloader/cli/validators.py b/mloader/cli/validators.py new file mode 100644 index 0000000..facb797 --- /dev/null +++ b/mloader/cli/validators.py @@ -0,0 +1,69 @@ +"""Click callback validators used by CLI options and arguments.""" + +from __future__ import annotations + +from urllib.parse import urlparse + +import click + +ALLOWED_HOSTS = { + "mangaplus.shueisha.co.jp", + "www.mangaplus.shueisha.co.jp", +} + + +def _parse_target(url: str) -> tuple[str, int]: + """Parse and return ``(target_kind, id)`` from supported URL/path formats.""" + is_absolute_url = "://" in url + parsed = urlparse(url if is_absolute_url else f"https://placeholder/{url.lstrip('/')}") + if is_absolute_url and parsed.hostname not in ALLOWED_HOSTS: + raise click.BadParameter(f"Invalid url host: {url}") + segments = [segment for segment in parsed.path.split("/") if segment] + if len(segments) != 2 or not segments[1].isdigit(): + raise click.BadParameter(f"Invalid url: {url}") + if segments[0] not in {"viewer", "titles"}: + raise click.BadParameter(f"Invalid url: {url}") + return segments[0], int(segments[1]) + + +def validate_urls( + ctx: click.Context, + param: click.Parameter | None, + value: tuple[str, ...], +) -> tuple[str, ...]: + """Validate URL arguments and collect chapter/title IDs into click context.""" + _ = param + if not value: + return value + + results: dict[str, set[int]] = {"viewer": set(), "titles": set()} + for url in value: + key, id_value = _parse_target(url) + results[key].add(id_value) + + ctx.params.setdefault("titles", set()).update(results["titles"]) + ctx.params.setdefault("chapter_ids", set()).update(results["viewer"]) + return value + + +def validate_ids( + ctx: click.Context, + param: click.Parameter | None, + value: tuple[int, ...], +) -> tuple[int, ...]: + """Validate numeric IDs and add them to the matching context set.""" + if not value: + return value + if param is None: + raise click.BadParameter("Unexpected missing parameter metadata") + + if param.name == "chapter": + ctx.params.setdefault("chapters", set()).update(value) + return value + if param.name == "chapter_id": + ctx.params.setdefault("chapter_ids", set()).update(value) + return value + if param.name == "title": + ctx.params.setdefault("titles", set()).update(value) + return value + raise click.BadParameter(f"Unexpected parameter: {param.name}") diff --git a/mloader/config.py b/mloader/config.py new file mode 100644 index 0000000..aaa89ac --- /dev/null +++ b/mloader/config.py @@ -0,0 +1,126 @@ +"""Typed immutable runtime settings with layered configuration resolution.""" + +from __future__ import annotations + +from dataclasses import dataclass +import os +from pathlib import Path +import tomllib +from typing import Mapping + +from dotenv import load_dotenv + +load_dotenv() + +_DEFAULT_AUTH_SETTINGS: dict[str, str] = { + "app_ver": "97", + "os": "ios", + "os_ver": "18.1", + "secret": "f40080bcb01a9a963912f46688d411a3", +} +_ENV_TO_FIELD_MAP: dict[str, str] = { + "APP_VER": "app_ver", + "OS": "os", + "OS_VER": "os_ver", + "SECRET": "secret", +} +_DEFAULT_CONFIG_FILE = ".mloader.toml" + + +@dataclass(frozen=True, slots=True) +class AuthSettings: + """Immutable auth-related settings required for MangaPlus API requests.""" + + app_ver: str + os: str + os_ver: str + secret: str + + def as_query_params(self) -> dict[str, str]: + """Return settings as API query parameter mapping.""" + return { + "app_ver": self.app_ver, + "os": self.os, + "os_ver": self.os_ver, + "secret": self.secret, + } + + +def _load_auth_from_file(config_file: str | Path | None) -> dict[str, str]: + """Load auth settings from TOML file, returning empty mapping when unavailable.""" + if config_file is None: + return {} + + config_path = Path(config_file) + if not config_path.exists() or not config_path.is_file(): + return {} + + parsed_config = tomllib.loads(config_path.read_text(encoding="utf-8")) + auth_section = parsed_config.get("auth") + if auth_section is None: + return {} + if not isinstance(auth_section, dict): + raise ValueError("Invalid config file: [auth] section must be a table") + + resolved: dict[str, str] = {} + for key in _DEFAULT_AUTH_SETTINGS: + value = auth_section.get(key) + if value is None: + continue + resolved[key] = str(value) + return resolved + + +def _resolve_config_file( + environ: Mapping[str, str], config_file: str | Path | None +) -> str | Path | None: + """Resolve config file location from explicit argument, env var, or default path.""" + if config_file is not None: + return config_file + + env_config_file = environ.get("MLOADER_CONFIG_FILE") + if env_config_file: + return env_config_file + + default_path = Path(_DEFAULT_CONFIG_FILE) + if default_path.exists() and default_path.is_file(): + return default_path + return None + + +def load_auth_settings( + *, + overrides: Mapping[str, str] | None = None, + environ: Mapping[str, str] | None = None, + config_file: str | Path | None = None, +) -> AuthSettings: + """Load immutable auth settings with layered priority resolution. + + Resolution order (highest to lowest): + 1. ``overrides`` (CLI/runtime injected values) + 2. environment variables + 3. TOML config file (explicit path, env-selected path, or default) + 4. built-in defaults + """ + resolved_environ = environ if environ is not None else os.environ + + merged: dict[str, str] = dict(_DEFAULT_AUTH_SETTINGS) + resolved_config_file = _resolve_config_file(resolved_environ, config_file) + merged.update(_load_auth_from_file(resolved_config_file)) + + for env_key, field_name in _ENV_TO_FIELD_MAP.items(): + env_value = resolved_environ.get(env_key) + if env_value is not None: + merged[field_name] = env_value + + if overrides: + for key, value in overrides.items(): + if key not in _DEFAULT_AUTH_SETTINGS: + raise ValueError(f"Unsupported auth override key: {key}") + merged[key] = str(value) + + return AuthSettings(**merged) + + +AUTH_SETTINGS = load_auth_settings() +AUTH_PARAMS = AUTH_SETTINGS.as_query_params() diff --git a/mloader/constants.py b/mloader/constants.py index 2ef3f9e..b01b2bb 100644 --- a/mloader/constants.py +++ b/mloader/constants.py @@ -1,26 +1,34 @@ -from enum import Enum - - -class Language(Enum): - eng = 0 - spa = 1 - fre = 2 - ind = 3 - por = 4 - rus = 5 - tha = 6 - deu = 7 - vie = 9 - - -class ChapterType(Enum): - latest = 0 - sequence = 1 - nosequence = 2 - - -class PageType(Enum): - single = 0 - left = 1 - right = 2 - double = 3 +"""Domain enums used across loader and exporters.""" + +from enum import Enum + + +class Language(Enum): + """Represent supported manga languages.""" + + ENGLISH = 0 + SPANISH = 1 + FRENCH = 2 + INDONESIAN = 3 + PORTUGUESE = 4 + RUSSIAN = 5 + THAI = 6 + GERMAN = 7 + VIETNAMESE = 9 + + +class ChapterType(Enum): + """Represent chapter ordering categories returned by the API.""" + + LATEST = 0 + SEQUENCE = 1 + NO_SEQUENCE = 2 + + +class PageType(Enum): + """Represent page layout types in manga viewer responses.""" + + SINGLE = 0 + LEFT = 1 + RIGHT = 2 + DOUBLE = 3 diff --git a/mloader/domain/__init__.py b/mloader/domain/__init__.py new file mode 100644 index 0000000..cf83f39 --- /dev/null +++ b/mloader/domain/__init__.py @@ -0,0 +1 @@ +"""Domain models for immutable CLI/application inputs.""" diff --git a/mloader/domain/requests.py b/mloader/domain/requests.py new file mode 100644 index 0000000..7136314 --- /dev/null +++ b/mloader/domain/requests.py @@ -0,0 +1,75 @@ +"""Immutable request models shared between CLI and application layers.""" + +from __future__ import annotations + +from dataclasses import dataclass, replace +from typing import Literal + +ApiOutputFormat = Literal["cbz", "pdf"] +EffectiveOutputFormat = Literal["raw", "cbz", "pdf"] +MAX_CHAPTER_ID = 2_147_483_647 + + +@dataclass(frozen=True, slots=True) +class DiscoveryRequest: + """All inputs required to discover title IDs for ``--all`` mode.""" + + pages: tuple[str, ...] + title_index_endpoint: str + id_length: int | None + languages: tuple[str, ...] + browser_fallback: bool + + +@dataclass(frozen=True, slots=True) +class DownloadRequest: + """Inputs required to execute one download run.""" + + out_dir: str + raw: bool + output_format: ApiOutputFormat + capture_api_dir: str | None + quality: str + split: bool + begin: int + end: int | None + last: bool + chapter_title: bool + chapter_subdir: bool + meta: bool + cover: bool + resume: bool + manifest_reset: bool + chapters: frozenset[int] + chapter_ids: frozenset[int] + titles: frozenset[int] + + @property + def max_chapter(self) -> int: + """Return the inclusive upper chapter bound for the run.""" + return self.end if self.end is not None else MAX_CHAPTER_ID + + def with_additional_titles(self, title_ids: set[int] | frozenset[int]) -> DownloadRequest: + """Return a new request with additional title IDs merged in.""" + merged_titles = self.titles.union(title_ids) + return replace(self, titles=frozenset(merged_titles)) + + @property + def has_targets(self) -> bool: + """Return whether at least one title/chapter target is configured.""" + return bool(self.chapters or self.chapter_ids or self.titles) + + +@dataclass(frozen=True, slots=True) +class DownloadSummary: + """Summary counters reported for one completed download run.""" + + downloaded: int + skipped_manifest: int + failed: int + failed_chapter_ids: tuple[int, ...] + + @property + def has_failures(self) -> bool: + """Return whether the run encountered at least one failed chapter.""" + return self.failed > 0 diff --git a/mloader/errors.py b/mloader/errors.py new file mode 100644 index 0000000..e57ef45 --- /dev/null +++ b/mloader/errors.py @@ -0,0 +1,15 @@ +"""Domain-specific exceptions raised by mloader runtime components.""" + +from __future__ import annotations + + +class MLoaderError(Exception): + """Base exception for mloader-specific runtime failures.""" + + +class SubscriptionRequiredError(MLoaderError): + """Raised when a chapter requires a subscription unavailable to the caller.""" + + +class APIResponseError(MLoaderError): + """Raised when MangaPlus API returns an invalid or non-success payload.""" diff --git a/mloader/exporter.py b/mloader/exporter.py deleted file mode 100644 index bf119a0..0000000 --- a/mloader/exporter.py +++ /dev/null @@ -1,162 +0,0 @@ -import zipfile -from abc import ABCMeta, abstractmethod -from itertools import chain -from pathlib import Path -from typing import Union, Optional - -from mloader.constants import Language -from mloader.response_pb2 import Title, Chapter -from mloader.utils import ( - escape_path, - is_oneshot, - chapter_name_to_int, - is_windows, -) - - -class ExporterBase(metaclass=ABCMeta): - def __init__( - self, - destination: str, - title: Title, - chapter: Chapter, - next_chapter: Optional[Chapter] = None, - add_chapter_title: bool = False, - add_chapter_subdir: bool = False, - ): - self.destination = destination - - if is_windows(): - destination = Path(self.destination).resolve().as_posix() - self.destination = f"\\\\?\\{destination}" - - self.add_chapter_title = add_chapter_title - self.add_chapter_subdir = add_chapter_subdir - self.title_name = escape_path(title.name).title() - self.is_oneshot = is_oneshot(chapter.name, chapter.sub_title) - self.is_extra = self._is_extra(chapter.name) - - self._extra_info = [] - - if self.is_oneshot: - self._extra_info.append("[Oneshot]") - - if self.add_chapter_title: - self._extra_info.append(f"[{escape_path(chapter.sub_title)}]") - - self._chapter_prefix = self._format_chapter_prefix( - self.title_name, - chapter.name, - title.language, - next_chapter and next_chapter.name, - ) - self._chapter_suffix = self._format_chapter_suffix() - self.chapter_name = " ".join( - (self._chapter_prefix, self._chapter_suffix) - ) - - def _is_extra(self, chapter_name: str) -> bool: - return chapter_name.strip("#") == "ex" - - def _format_chapter_prefix( - self, - title_name: str, - chapter_name: str, - language: int, - next_chapter_name: Optional[str] = None, - ) -> str: - # https://github.com/Daiz/manga-naming-scheme - components = [title_name] - if Language(language) != Language.eng: - components.append(f"[{Language(language).name}]") - components.append("-") - suffix = "" - prefix = "" - if self.is_oneshot: - chapter_num = 0 - elif self.is_extra and next_chapter_name: - suffix = "x1" - chapter_num = chapter_name_to_int(next_chapter_name) - if chapter_num is not None: - chapter_num -= 1 - prefix = "c" if chapter_num < 1000 else "d" - else: - chapter_num = chapter_name_to_int(chapter_name) - if chapter_num is not None: - prefix = "c" if chapter_num < 1000 else "d" - - if chapter_num is None: - chapter_num = escape_path(chapter_name) - - components.append(f"{prefix}{chapter_num:0>3}{suffix}") - components.append("(web)") - return " ".join(components) - - def _format_chapter_suffix(self) -> str: - return " ".join(chain(self._extra_info, ["[Unknown]"])) - - def format_page_name(self, page: Union[int, range], ext=".jpg") -> str: - if isinstance(page, range): - page = f"p{page.start:0>3}-{page.stop:0>3}" - else: - page = f"p{page:0>3}" - - ext = ext.lstrip(".") - - return f"{self._chapter_prefix} - {page} {self._chapter_suffix}.{ext}" - - def close(self): - pass - - @abstractmethod - def add_image(self, image_data: bytes, index: Union[int, range]): - pass - - @abstractmethod - def skip_image(self, index: Union[int, range]) -> bool: - pass - - -class RawExporter(ExporterBase): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.path = Path(self.destination, self.title_name) - self.path.mkdir(parents=True, exist_ok=True) - if self.add_chapter_subdir: - self.path = self.path.joinpath(self.chapter_name) - self.path.mkdir(parents=True, exist_ok=True) - - def add_image(self, image_data: bytes, index: Union[int, range]): - filename = Path(self.format_page_name(index)) - self.path.joinpath(filename).write_bytes(image_data) - - def skip_image(self, index: Union[int, range]) -> bool: - filename = Path(self.format_page_name(index)) - return self.path.joinpath(filename).exists() - - -class CBZExporter(ExporterBase): - def __init__(self, compression=zipfile.ZIP_DEFLATED, *args, **kwargs): - super().__init__(*args, **kwargs) - self.path = Path(self.destination, self.title_name) - self.path.mkdir(parents=True, exist_ok=True) - self.path = self.path.joinpath(self.chapter_name).with_suffix(".cbz") - self.skip_all_images = self.path.exists() - if not self.skip_all_images: - self.archive = zipfile.ZipFile( - self.path, mode="w", compression=compression - ) - - def add_image(self, image_data: bytes, index: Union[int, range]): - if self.skip_all_images: - return - path = Path(self.chapter_name, self.format_page_name(index)) - self.archive.writestr(path.as_posix(), image_data) - - def skip_image(self, index: Union[int, range]) -> bool: - return self.skip_all_images - - def close(self): - if self.skip_all_images: - return - self.archive.close() diff --git a/mloader/exporters/__init__.py b/mloader/exporters/__init__.py new file mode 100644 index 0000000..6f8279d --- /dev/null +++ b/mloader/exporters/__init__.py @@ -0,0 +1 @@ +"""Exporter package.""" diff --git a/mloader/exporters/cbz_exporter.py b/mloader/exporters/cbz_exporter.py new file mode 100644 index 0000000..7edfdf0 --- /dev/null +++ b/mloader/exporters/cbz_exporter.py @@ -0,0 +1,114 @@ +"""CBZ archive exporter implementation.""" + +from __future__ import annotations + +from contextlib import suppress +from html import escape +import zipfile +from io import BytesIO +from pathlib import Path +from tempfile import NamedTemporaryFile +from typing import Optional, Union + +from mloader.exporters.exporter_base import ExporterBase +from mloader.response_pb2 import Chapter, Title # type: ignore + + +class CBZExporter(ExporterBase): + """Export manga pages into a CBZ archive.""" + + format = "cbz" + COMICINFO_XML_TEMPLATE = """ + + {series} + {number} + {title} + {writer} + {language_iso} + YesAndRightToLeft + Shueisha + Manga + +""" + + def __init__( + self, + destination: str, + title: Title, + chapter: Chapter, + next_chapter: Optional[Chapter] = None, + add_chapter_title: bool = False, + add_chapter_subdir: bool = False, + compression: int = zipfile.ZIP_DEFLATED, + ) -> None: + """Initialize archive path and optional in-memory zip buffer.""" + super().__init__( + destination=destination, + title=title, + chapter=chapter, + next_chapter=next_chapter, + add_chapter_title=add_chapter_title, + add_chapter_subdir=add_chapter_subdir, + ) + base_path = Path(self.destination, self.title_name) + base_path.mkdir(parents=True, exist_ok=True) + self.path = base_path.joinpath(self.chapter_name).with_suffix(".cbz") + + self.skip_all_images = self.path.exists() + if not self.skip_all_images: + self.archive_buffer = BytesIO() + self.archive = zipfile.ZipFile( + self.archive_buffer, + mode="w", + compression=compression, + ) + + def add_image(self, image_data: bytes, index: Union[int, range]) -> None: + """Write one image into the CBZ archive.""" + if self.skip_all_images: + return + image_path = Path(self.chapter_name, self.format_page_name(index)) + self.archive.writestr(image_path.as_posix(), image_data) + + def skip_image(self, index: Union[int, range]) -> bool: + """Return whether image writes should be skipped.""" + _ = index + return self.skip_all_images + + def _generate_comicinfo_xml(self) -> str: + """Generate a ComicInfo.xml payload for the current chapter export.""" + return self.COMICINFO_XML_TEMPLATE.format( + series=escape(self.series_name or ""), + number=escape(str(self.chapter_number or "")), + title=escape(self.chapter_title or ""), + writer=escape(self.author or ""), + language_iso=escape(self._iso_language()), + ) + + def _write_comicinfo_xml_entry(self) -> None: + """Write ComicInfo.xml into the chapter directory inside the CBZ archive.""" + xml_path = Path(self.chapter_name, "ComicInfo.xml").as_posix() + with suppress(Exception): + if xml_path in self.archive.namelist(): + return + + self.archive.writestr(xml_path, self._generate_comicinfo_xml()) + + def close(self) -> None: + """Persist the in-memory archive to disk with ComicInfo metadata.""" + if self.skip_all_images: + return + + try: + self._write_comicinfo_xml_entry() + finally: + with suppress(Exception): + self.archive.close() + + data = self.archive_buffer.getvalue() + self.path.parent.mkdir(parents=True, exist_ok=True) + with NamedTemporaryFile("wb", delete=False, dir=self.path.parent) as tmp: + tmp.write(data) + temp_path = Path(tmp.name) + + temp_path.replace(self.path) diff --git a/mloader/exporters/exporter_base.py b/mloader/exporters/exporter_base.py new file mode 100644 index 0000000..8f813f6 --- /dev/null +++ b/mloader/exporters/exporter_base.py @@ -0,0 +1,146 @@ +"""Base exporter contract and shared naming logic.""" + +from __future__ import annotations + +from abc import ABCMeta, abstractmethod +from pathlib import Path +from typing import ClassVar, Optional, Union + +from mloader.constants import Language +from mloader.response_pb2 import Chapter, Title # type: ignore +from mloader.utils import escape_path, is_oneshot, is_windows + + +def _is_extra(chapter_name: str) -> bool: + """Return ``True`` when a chapter name represents an extra chapter.""" + return chapter_name.strip("#").lower() == "ex" + + +def _format_language_tag(language: int) -> str: + """Build a stable language tag for chapter names, including unknown codes.""" + if language == 8: # Legacy Vietnamese code observed in older payloads. + return " [VIETNAMESE]" + + try: + parsed_language = Language(language) + except ValueError: + return f" [LANG-{language}]" + + if parsed_language == Language.ENGLISH: + return "" + + return f" [{parsed_language.name}]" + + +def _iso_language_code(language: int) -> str: + """Convert internal language codes to ISO 639-1 values.""" + language_map: dict[int, str] = { + Language.ENGLISH.value: "en", + Language.SPANISH.value: "es", + Language.FRENCH.value: "fr", + Language.INDONESIAN.value: "id", + Language.PORTUGUESE.value: "pt", + Language.RUSSIAN.value: "ru", + Language.THAI.value: "th", + Language.GERMAN.value: "de", + Language.VIETNAMESE.value: "vi", + 8: "vi", # Legacy Vietnamese code observed in historical payloads. + } + return language_map.get(language, "en") + + +class ExporterBase(metaclass=ABCMeta): + """Define the interface and shared behavior for all exporters.""" + + FORMAT_REGISTRY: dict[str, type["ExporterBase"]] = {} + format: ClassVar[str] + + def __init__( + self, + destination: str, + title: Title, + chapter: Chapter, + next_chapter: Optional[Chapter] = None, + add_chapter_title: bool = False, + add_chapter_subdir: bool = False, + ) -> None: + """Initialize exporter state and derive chapter naming parts.""" + self.destination = destination + + if is_windows(): + resolved_path = Path(self.destination).resolve().as_posix() + self.destination = f"\\\\?\\{resolved_path}" + + self.add_chapter_title = add_chapter_title + self.add_chapter_subdir = add_chapter_subdir + self.series_name = title.name + self.title_name = escape_path(title.name).title() + self.chapter = chapter + self.next_chapter = next_chapter + self.language = title.language + self.author = title.author + self.chapter_title = chapter.sub_title + self.chapter_number = chapter.name + self.is_oneshot = is_oneshot(chapter.name, chapter.sub_title) + self.is_extra = _is_extra(chapter.name) + + self._chapter_prefix = self._format_chapter_prefix( + self.title_name, + chapter.name, + title.language, + ) + self._chapter_suffix = self._format_chapter_suffix() + self.chapter_name = f"{self._chapter_prefix} {self._chapter_suffix}" + + def _format_chapter_prefix( + self, + title_name: str, + chapter_name: str, + language: int, + next_chapter_name: Optional[str] = None, + ) -> str: + """Build the filename prefix used by chapter and page outputs.""" + _ = next_chapter_name + safe_chapter_name = escape_path(chapter_name) + lang = _format_language_tag(language) + return f"{title_name}{lang} - {safe_chapter_name}" + + def _format_chapter_suffix(self) -> str: + """Build the filename suffix based on chapter subtitle.""" + safe_subtitle = ( + escape_path(self.chapter.sub_title) + if self.chapter.sub_title and self.chapter.sub_title.strip() + else "Unknown" + ) + return f"- {safe_subtitle}" + + def _iso_language(self) -> str: + """Return the chapter language as an ISO 639-1 code.""" + return _iso_language_code(self.language) + + def format_page_name(self, page: Union[int, range], ext: str = ".jpg") -> str: + """Return the canonical page filename for ``page``.""" + if isinstance(page, range): + page_str = f"p{page.start:0>3}-{page.stop:0>3}" + else: + page_str = f"p{page:0>3}" + return f"{self._chapter_prefix} - {page_str} {self._chapter_suffix}.{ext.lstrip('.')}" + + def close(self) -> None: + """Finalize exporter resources if needed.""" + + def __init_subclass__(cls, **kwargs: object) -> None: + """Register subclasses by their declared ``format`` key.""" + format_name = getattr(cls, "format", "") + if not isinstance(format_name, str) or not format_name: + raise TypeError("Exporter subclasses must define a non-empty string 'format'.") + cls.FORMAT_REGISTRY[format_name] = cls + super().__init_subclass__(**kwargs) + + @abstractmethod + def add_image(self, image_data: bytes, index: Union[int, range]) -> None: + """Persist a single page image.""" + + @abstractmethod + def skip_image(self, index: Union[int, range]) -> bool: + """Return whether writing ``index`` can be skipped.""" diff --git a/mloader/exporters/init.py b/mloader/exporters/init.py new file mode 100644 index 0000000..5ddd858 --- /dev/null +++ b/mloader/exporters/init.py @@ -0,0 +1,13 @@ +"""Re-export exporter implementations used by the CLI.""" + +from .exporter_base import ExporterBase +from .raw_exporter import RawExporter +from .cbz_exporter import CBZExporter +from .pdf_exporter import PDFExporter + +__all__ = [ + "ExporterBase", + "RawExporter", + "CBZExporter", + "PDFExporter", +] diff --git a/mloader/exporters/pdf_exporter.py b/mloader/exporters/pdf_exporter.py new file mode 100644 index 0000000..6ba113e --- /dev/null +++ b/mloader/exporters/pdf_exporter.py @@ -0,0 +1,100 @@ +"""PDF exporter implementation.""" + +from __future__ import annotations + +from pathlib import Path +from tempfile import TemporaryDirectory +from typing import Optional, Union + +from PIL import Image + +from mloader.__version__ import __title__, __version__ +from mloader.exporters.exporter_base import ExporterBase +from mloader.response_pb2 import Chapter, Title # type: ignore + + +class PDFExporter(ExporterBase): + """Export manga pages into a PDF file.""" + + format = "pdf" + + def __init__( + self, + destination: str, + title: Title, + chapter: Chapter, + next_chapter: Optional[Chapter] = None, + add_chapter_title: bool = False, + add_chapter_subdir: bool = False, + ) -> None: + """Initialize PDF path and disk-backed page buffering state.""" + super().__init__( + destination=destination, + title=title, + chapter=chapter, + next_chapter=next_chapter, + add_chapter_title=add_chapter_title, + add_chapter_subdir=add_chapter_subdir, + ) + base_path = Path(self.destination, self.title_name) + base_path.mkdir(parents=True, exist_ok=True) + self.path = base_path.joinpath(self.chapter_name).with_suffix(".pdf") + self.skip_all_images = self.path.exists() + self._temp_dir: TemporaryDirectory[str] | None = None + self._page_paths: list[Path] = [] + if not self.skip_all_images: + self._temp_dir = TemporaryDirectory(prefix="mloader-pdf-", dir=base_path) + + def add_image(self, image_data: bytes, index: Union[int, range]) -> None: + """Persist one image payload into a temporary page buffer file.""" + if self.skip_all_images: + return + if self._temp_dir is None: + return + + if isinstance(index, range): + sort_key = index.start + else: + sort_key = index + page_path = Path(self._temp_dir.name) / f"{sort_key:08d}.img" + page_path.write_bytes(image_data) + self._page_paths.append(page_path) + + def skip_image(self, index: Union[int, range]) -> bool: + """Return whether the chapter PDF already exists.""" + _ = index + return self.skip_all_images + + def close(self) -> None: + """Write all collected images to the destination PDF file.""" + if self.skip_all_images or not self._page_paths: + return + + app_info = f"{__title__} - {__version__}" + opened_images: list[Image.Image] = [] + try: + for page_path in sorted(self._page_paths): + opened_image: Image.Image = Image.open(page_path) + if opened_image.mode != "RGB": + converted = opened_image.convert("RGB") + opened_image.close() + opened_image = converted + opened_images.append(opened_image) + + opened_images[0].save( + self.path, + "PDF", + resolution=100.0, + save_all=True, + append_images=opened_images[1:], + title=self.chapter_name, + producer=app_info, + creator=app_info, + ) + finally: + for image in opened_images: + image.close() + if self._temp_dir is not None: + self._temp_dir.cleanup() + self._temp_dir = None + self._page_paths.clear() diff --git a/mloader/exporters/raw_exporter.py b/mloader/exporters/raw_exporter.py new file mode 100644 index 0000000..9ccf396 --- /dev/null +++ b/mloader/exporters/raw_exporter.py @@ -0,0 +1,50 @@ +"""Raw image exporter implementation.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Optional, Union + +from mloader.exporters.exporter_base import ExporterBase +from mloader.response_pb2 import Chapter, Title # type: ignore + + +class RawExporter(ExporterBase): + """Export manga pages as standalone image files.""" + + format = "raw" + + def __init__( + self, + destination: str, + title: Title, + chapter: Chapter, + next_chapter: Optional[Chapter] = None, + add_chapter_title: bool = False, + add_chapter_subdir: bool = False, + ) -> None: + """Initialize output directories for raw image export.""" + super().__init__( + destination=destination, + title=title, + chapter=chapter, + next_chapter=next_chapter, + add_chapter_title=add_chapter_title, + add_chapter_subdir=add_chapter_subdir, + ) + self.path = Path(self.destination, self.title_name) + self.path.mkdir(parents=True, exist_ok=True) + + if self.add_chapter_subdir: + self.path = self.path.joinpath(self.chapter_name) + self.path.mkdir(parents=True, exist_ok=True) + + def add_image(self, image_data: bytes, index: Union[int, range]) -> None: + """Write one page image file to disk.""" + filename = self.format_page_name(index) + self.path.joinpath(filename).write_bytes(image_data) + + def skip_image(self, index: Union[int, range]) -> bool: + """Return whether the target image file already exists.""" + filename = self.format_page_name(index) + return self.path.joinpath(filename).exists() diff --git a/mloader/loader.py b/mloader/loader.py deleted file mode 100644 index 25041d7..0000000 --- a/mloader/loader.py +++ /dev/null @@ -1,191 +0,0 @@ -import logging -from collections import namedtuple -from functools import lru_cache -from itertools import chain, count -from typing import Union, Dict, Set, Collection, Optional, Callable - -import click -from requests import Session - -from mloader.constants import PageType -from mloader.exporter import ExporterBase -from mloader.response_pb2 import ( - Response, - MangaViewer, - TitleDetailView, - Chapter, - Title, -) -from mloader.utils import chapter_name_to_int - -log = logging.getLogger() - -MangaList = Dict[int, Set[int]] # Title ID: Set[Chapter ID] - - -class MangaLoader: - def __init__( - self, - exporter: Callable[[Title, Chapter, Optional[Chapter]], ExporterBase], - quality: str = "super_high", - split: bool = False, - ): - self.exporter = exporter - self.quality = quality - self.split = split - self._api_url = "https://jumpg-webapi.tokyo-cdn.com" - self.session = Session() - self.session.headers.update( - { - "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; " - "rv:72.0) Gecko/20100101 Firefox/72.0" - } - ) - - def _decrypt_image(self, url: str, encryption_hex: str) -> bytearray: - resp = self.session.get(url) - data = bytearray(resp.content) - key = bytes.fromhex(encryption_hex) - a = len(key) - for s in range(len(data)): - data[s] ^= key[s % a] - return data - - @lru_cache(None) - def _load_pages(self, chapter_id: Union[str, int]) -> MangaViewer: - resp = self.session.get( - f"{self._api_url}/api/manga_viewer", - params={ - "chapter_id": chapter_id, - "split": "yes" if self.split else "no", - "img_quality": self.quality, - }, - ) - return Response.FromString(resp.content).success.manga_viewer - - @lru_cache(None) - def _get_title_details(self, title_id: Union[str, int]) -> TitleDetailView: - resp = self.session.get( - f"{self._api_url}/api/title_detailV3", params={"title_id": title_id} - ) - return Response.FromString(resp.content).success.title_detail_view - - def _normalize_ids( - self, - title_ids: Collection[int], - chapter_ids: Collection[int], - min_chapter: int, - max_chapter: int, - last_chapter: bool = False, - ) -> MangaList: - # mloader allows you to mix chapters and titles(collections of chapters) - # This method tries to merge them while trying to avoid unnecessary - # http requests - if not any((title_ids, chapter_ids)): - raise ValueError("Expected at least one title or chapter id") - title_ids = set(title_ids or []) - chapter_ids = set(chapter_ids or []) - mangas = {} - chapter_meta = namedtuple("ChapterMeta", "id name") - for cid in chapter_ids: - viewer = self._load_pages(cid) - title_id = viewer.title_id - # Fetching details for this chapter also downloads all other - # visible chapters for the same title. - if title_id in title_ids: - title_ids.remove(title_id) - mangas.setdefault(title_id, []).extend( - chapter_meta(c.chapter_id, c.name) for c in viewer.chapters - ) - else: - mangas.setdefault(title_id, []).append( - chapter_meta(viewer.chapter_id, viewer.chapter_name) - ) - - for tid in title_ids: - details = self._get_title_details(tid) - mangas[tid] = [ - chapter_meta(chapter.chapter_id, chapter.name) - # Skipping mid_chapter_list, since it contains unavailable chapters - for chapter in chain.from_iterable( - chain(group.first_chapter_list, group.last_chapter_list) - for group in details.chapter_list_group - ) - ] - - for tid in mangas: - if last_chapter: - chapters = mangas[tid][-1:] - else: - chapters = [ - c - for c in mangas[tid] - if min_chapter - <= (chapter_name_to_int(c.name) or 0) - <= max_chapter - ] - - mangas[tid] = set(c.id for c in chapters) - - return mangas - - def _download(self, manga_list: MangaList): - manga_num = len(manga_list) - for title_index, (title_id, chapters) in enumerate( - manga_list.items(), 1 - ): - title = self._get_title_details(title_id).title - - title_name = title.name - log.info(f"{title_index}/{manga_num}) Manga: {title_name}") - log.info(" Author: %s", title.author) - - chapter_num = len(chapters) - for chapter_index, chapter_id in enumerate(sorted(chapters), 1): - viewer = self._load_pages(chapter_id) - chapter = viewer.pages[-1].last_page.current_chapter - next_chapter = viewer.pages[-1].last_page.next_chapter - next_chapter = ( - next_chapter if next_chapter.chapter_id != 0 else None - ) - chapter_name = viewer.chapter_name - log.info( - f" {chapter_index}/{chapter_num}) " - f"Chapter {chapter_name}: {chapter.sub_title}" - ) - exporter = self.exporter( - title=title, chapter=chapter, next_chapter=next_chapter - ) - pages = [ - p.manga_page for p in viewer.pages if p.manga_page.image_url - ] - - with click.progressbar( - pages, label=chapter_name, show_pos=True - ) as pbar: - page_counter = count() - for page_index, page in zip(page_counter, pbar): - if PageType(page.type) == PageType.double: - page_index = range(page_index, next(page_counter)) - if not exporter.skip_image(page_index): - # Todo use asyncio + async requests 3 - image_blob = self._decrypt_image( - page.image_url, page.encryption_key - ) - exporter.add_image(image_blob, page_index) - - exporter.close() - - def download( - self, - *, - title_ids: Optional[Collection[int]] = None, - chapter_ids: Optional[Collection[int]] = None, - min_chapter: int, - max_chapter: int, - last_chapter: bool = False, - ): - manga_list = self._normalize_ids( - title_ids, chapter_ids, min_chapter, max_chapter, last_chapter - ) - self._download(manga_list) diff --git a/mloader/manga_loader/__init__.py b/mloader/manga_loader/__init__.py new file mode 100644 index 0000000..a73a492 --- /dev/null +++ b/mloader/manga_loader/__init__.py @@ -0,0 +1 @@ +"""Loader package containing API, normalization, decryption, and download logic.""" diff --git a/mloader/manga_loader/api.py b/mloader/manga_loader/api.py new file mode 100644 index 0000000..1f7c9a4 --- /dev/null +++ b/mloader/manga_loader/api.py @@ -0,0 +1,231 @@ +"""API helpers and cached fetch methods for MangaPlus endpoints.""" + +from __future__ import annotations + +from collections import OrderedDict +from typing import Collection, Mapping, Union, cast + +from mloader.config import AUTH_PARAMS +from mloader.errors import APIResponseError +from mloader.response_pb2 import Response # type: ignore +from mloader.types import MangaViewerLike, PayloadCaptureLike, SessionLike, TitleDumpLike + + +def _has_message_field(message: object, field_name: str) -> bool: + """Return whether protobuf ``message`` has explicit ``field_name`` set.""" + has_field = getattr(message, "HasField", None) + if not callable(has_field): + return True + try: + return bool(has_field(field_name)) + except ValueError: + return False + + +def _parse_manga_viewer_response(content: bytes) -> MangaViewerLike: + """Parse and validate the API response to extract ``MangaViewer`` payload.""" + parsed = Response.FromString(content) + success = parsed.success + if not _has_message_field(success, "manga_viewer"): + raise APIResponseError("MangaPlus API returned no manga_viewer payload.") + viewer = success.manga_viewer + if viewer.title_id == 0 or viewer.chapter_id == 0: + raise APIResponseError( + "MangaPlus API returned manga_viewer payload without title/chapter IDs." + ) + if len(viewer.pages) == 0: + raise APIResponseError("MangaPlus API returned manga_viewer payload without pages.") + return cast(MangaViewerLike, viewer) + + +def _build_title_detail_params(title_id: Union[str, int]) -> dict[str, str | int]: + """Assemble the query parameters for the title details API request.""" + return {**AUTH_PARAMS, "title_id": title_id} + + +def _parse_title_detail_response(content: bytes) -> TitleDumpLike: + """Parse and validate the API response to extract ``TitleDetailView`` payload.""" + parsed = Response.FromString(content) + success = parsed.success + if not _has_message_field(success, "title_detail_view"): + raise APIResponseError("MangaPlus API returned no title_detail_view payload.") + title_detail = success.title_detail_view + title = title_detail.title + if title.title_id == 0 or not title.name: + raise APIResponseError("MangaPlus API returned title_detail_view without title identity.") + chapter_groups = title_detail.chapter_list_group + if len(chapter_groups) == 0: + raise APIResponseError("MangaPlus API returned title_detail_view without chapter groups.") + has_chapters = any( + group.first_chapter_list or group.mid_chapter_list or group.last_chapter_list + for group in chapter_groups + ) + if not has_chapters: + raise APIResponseError("MangaPlus API returned title_detail_view without chapter entries.") + return cast(TitleDumpLike, title_detail) + + +class APILoaderMixin: + """Provide cached API calls for chapter viewer and title details.""" + + session: SessionLike + _api_url: str + quality: str + split: bool + request_timeout: tuple[float, float] + payload_capture: PayloadCaptureLike | None + _viewer_cache_max_size: int = 512 + _title_cache_max_size: int = 256 + + def _load_pages(self, chapter_id: Union[str, int]) -> MangaViewerLike: + """ + Retrieve and cache manga viewer data for a given chapter. + """ + chapter_cache_key = self._cache_key(chapter_id) + cached_viewer = self._viewer_cache_get(chapter_cache_key) + if cached_viewer is not None: + return cached_viewer + + url = self._build_manga_viewer_url() + params = self._build_manga_viewer_params(chapter_id) + response = self.session.get(url, params=params, timeout=self.request_timeout) + response.raise_for_status() + self._capture_payload( + endpoint="manga_viewer", + identifier=chapter_id, + url=url, + params=params, + response_content=response.content, + ) + parsed_viewer = _parse_manga_viewer_response(response.content) + self._viewer_cache_set(chapter_cache_key, parsed_viewer) + return parsed_viewer + + def _build_manga_viewer_url(self) -> str: + """Construct the full URL for the manga viewer API endpoint.""" + return f"{self._api_url}/api/manga_viewer" + + def _build_manga_viewer_params(self, chapter_id: Union[str, int]) -> dict[str, str | int]: + """Assemble the query parameters for the manga viewer API request.""" + split_value = "yes" if self.split else "no" + return { + **AUTH_PARAMS, + "chapter_id": chapter_id, + "split": split_value, + "img_quality": self.quality, + } + + def _capture_payload( + self, + *, + endpoint: str, + identifier: str | int, + url: str, + params: Mapping[str, object], + response_content: bytes, + ) -> None: + """Write API payload capture data when capture mode is enabled.""" + if self.payload_capture is None: + return + + self.payload_capture.capture( + endpoint=endpoint, + identifier=identifier, + url=url, + params=params, + response_content=response_content, + ) + + def _get_title_details(self, title_id: Union[str, int]) -> TitleDumpLike: + """ + Retrieve and cache detailed information for a given manga title. + """ + title_cache_key = self._cache_key(title_id) + cached_title = self._title_cache_get(title_cache_key) + if cached_title is not None: + return cached_title + + url = self._build_title_detail_url() + params = _build_title_detail_params(title_id) + response = self.session.get(url, params=params, timeout=self.request_timeout) + response.raise_for_status() + self._capture_payload( + endpoint="title_detailV3", + identifier=title_id, + url=url, + params=params, + response_content=response.content, + ) + parsed_title = _parse_title_detail_response(response.content) + self._title_cache_set(title_cache_key, parsed_title) + return parsed_title + + def _build_title_detail_url(self) -> str: + """Construct the full URL for the title details API endpoint.""" + return f"{self._api_url}/api/title_detailV3" + + def _clear_api_caches_for_run(self) -> None: + """Clear all API response caches for the active loader instance.""" + self._get_viewer_cache().clear() + self._get_title_cache().clear() + + def _clear_api_caches_for_title( + self, + title_id: int | str, + chapter_ids: Collection[int] | None = None, + ) -> None: + """Clear title-scoped API cache entries after one title is processed.""" + self._get_title_cache().pop(self._cache_key(title_id), None) + if not chapter_ids: + return + viewer_cache = self._get_viewer_cache() + for chapter_id in chapter_ids: + viewer_cache.pop(self._cache_key(chapter_id), None) + + def _cache_key(self, identifier: int | str) -> str: + """Return a normalized cache key for API identifiers.""" + return str(identifier) + + def _get_viewer_cache(self) -> OrderedDict[str, MangaViewerLike]: + """Return the per-instance chapter-viewer cache.""" + if not hasattr(self, "_viewer_cache"): + self._viewer_cache: OrderedDict[str, MangaViewerLike] = OrderedDict() + return self._viewer_cache + + def _get_title_cache(self) -> OrderedDict[str, TitleDumpLike]: + """Return the per-instance title-detail cache.""" + if not hasattr(self, "_title_cache"): + self._title_cache: OrderedDict[str, TitleDumpLike] = OrderedDict() + return self._title_cache + + def _viewer_cache_get(self, key: str) -> MangaViewerLike | None: + """Return cached viewer payload by key and refresh LRU order.""" + cache = self._get_viewer_cache() + if key not in cache: + return None + cache.move_to_end(key) + return cache[key] + + def _viewer_cache_set(self, key: str, value: MangaViewerLike) -> None: + """Store viewer payload and evict oldest entries beyond max size.""" + cache = self._get_viewer_cache() + cache[key] = value + cache.move_to_end(key) + while len(cache) > self._viewer_cache_max_size: + cache.popitem(last=False) + + def _title_cache_get(self, key: str) -> TitleDumpLike | None: + """Return cached title payload by key and refresh LRU order.""" + cache = self._get_title_cache() + if key not in cache: + return None + cache.move_to_end(key) + return cache[key] + + def _title_cache_set(self, key: str, value: TitleDumpLike) -> None: + """Store title payload and evict oldest entries beyond max size.""" + cache = self._get_title_cache() + cache[key] = value + cache.move_to_end(key) + while len(cache) > self._title_cache_max_size: + cache.popitem(last=False) diff --git a/mloader/manga_loader/capture.py b/mloader/manga_loader/capture.py new file mode 100644 index 0000000..1f0b3ea --- /dev/null +++ b/mloader/manga_loader/capture.py @@ -0,0 +1,103 @@ +"""API payload capture helpers for reproducible fixture generation.""" + +from __future__ import annotations + +import json +import re +from datetime import datetime, timezone +from hashlib import sha256 +from itertools import count +from pathlib import Path +from typing import Mapping + +from google.protobuf.json_format import MessageToDict + +from mloader.response_pb2 import Response # type: ignore + +_FILENAME_SANITIZER = re.compile(r"[^a-zA-Z0-9_.-]+") +_REDACTED_PARAM_KEYS = frozenset( + { + "secret", + "authorization", + "auth", + "token", + "cookie", + "session", + } +) + + +def _sanitize_filename(value: str) -> str: + """Return a filesystem-safe slug for capture file names.""" + sanitized = _FILENAME_SANITIZER.sub("_", value.strip()) + return sanitized.strip("._") or "capture" + + +def _redact_params(params: Mapping[str, object]) -> dict[str, object]: + """Return a sorted parameter mapping with sensitive values redacted.""" + redacted: dict[str, object] = {} + for key, value in sorted(params.items(), key=lambda item: item[0]): + redacted[key] = "***REDACTED***" if key.lower() in _REDACTED_PARAM_KEYS else value + return redacted + + +class APIPayloadCapture: + """Persist raw and parsed API payloads for later regression analysis.""" + + def __init__(self, capture_dir: str | Path) -> None: + """Initialize capture directory and sequence counter.""" + self.capture_dir = Path(capture_dir) + self.capture_dir.mkdir(parents=True, exist_ok=True) + self._sequence = count(1) + + def capture( + self, + *, + endpoint: str, + identifier: str | int, + url: str, + params: Mapping[str, object], + response_content: bytes, + ) -> None: + """Write capture metadata, raw protobuf payload, and optional parsed JSON.""" + sequence = next(self._sequence) + capture_stem = ( + f"{sequence:04d}_{_sanitize_filename(endpoint)}_{_sanitize_filename(str(identifier))}" + ) + raw_payload_path = self.capture_dir / f"{capture_stem}.pb" + metadata_path = self.capture_dir / f"{capture_stem}.meta.json" + parsed_response_path = self.capture_dir / f"{capture_stem}.response.json" + + raw_payload_path.write_bytes(response_content) + + metadata: dict[str, object] = { + "captured_at_utc": datetime.now(timezone.utc).isoformat(), + "endpoint": endpoint, + "identifier": str(identifier), + "url": url, + "params": _redact_params(params), + "payload_sha256": sha256(response_content).hexdigest(), + "payload_size_bytes": len(response_content), + "raw_payload_file": raw_payload_path.name, + } + + try: + parsed_response = Response.FromString(response_content) + parsed_dict = MessageToDict( + parsed_response, + preserving_proto_field_name=True, + use_integers_for_enums=True, + ) + except Exception as exc: + metadata["parsed_payload_error"] = str(exc) + else: + parsed_response_path.write_text( + json.dumps(parsed_dict, ensure_ascii=False, indent=2, sort_keys=True), + encoding="utf-8", + ) + metadata["parsed_payload_file"] = parsed_response_path.name + + metadata_path.write_text( + json.dumps(metadata, ensure_ascii=False, indent=2, sort_keys=True), + encoding="utf-8", + ) diff --git a/mloader/manga_loader/capture_verify.py b/mloader/manga_loader/capture_verify.py new file mode 100644 index 0000000..63f6714 --- /dev/null +++ b/mloader/manga_loader/capture_verify.py @@ -0,0 +1,288 @@ +"""Verification helpers for recorded API capture payloads.""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from hashlib import sha256 +from pathlib import Path +from typing import Any, cast +from urllib.parse import urlparse + +from google.protobuf.json_format import MessageToDict +from mloader.response_pb2 import Response # type: ignore + + +class CaptureVerificationError(ValueError): + """Raised when a capture set fails schema verification.""" + + +@dataclass(frozen=True) +class CaptureVerificationSummary: + """Summary produced after capture schema verification.""" + + total_records: int + endpoint_counts: dict[str, int] + + +@dataclass(frozen=True) +class _CaptureRecord: + """Internal verified capture record used for baseline comparison.""" + + stem: str + endpoint: str + signature_json: str + + +def _load_metadata(meta_path: Path) -> dict[str, Any]: + """Load and return metadata JSON as a dictionary.""" + metadata = json.loads(meta_path.read_text(encoding="utf-8")) + if not isinstance(metadata, dict): + raise CaptureVerificationError(f"Metadata file is not an object: {meta_path}") + return cast(dict[str, Any], metadata) + + +def _as_dict(value: object, context: str) -> dict[str, Any]: + """Return ``value`` as dictionary or raise descriptive verification error.""" + if not isinstance(value, dict): + raise CaptureVerificationError(f"Expected object at {context}") + return cast(dict[str, Any], value) + + +def _as_list(value: object, context: str) -> list[Any]: + """Return ``value`` as list or raise descriptive verification error.""" + if not isinstance(value, list): + raise CaptureVerificationError(f"Expected list at {context}") + return cast(list[Any], value) + + +def _build_schema_signature( + *, + endpoint: str, + metadata: dict[str, Any], + parsed: Response, +) -> str: + """Build normalized schema signature JSON for baseline drift checks.""" + response_data = MessageToDict( + parsed, + preserving_proto_field_name=True, + use_integers_for_enums=True, + ) + success = _as_dict(response_data.get("success"), "response.success") + params = _as_dict(metadata.get("params"), "metadata.params") + + signature: dict[str, object] = { + "endpoint": endpoint, + "url_path": urlparse(str(metadata.get("url", ""))).path, + "meta_keys": sorted(metadata.keys()), + "param_keys": sorted(params.keys()), + "success_keys": sorted(success.keys()), + } + + if endpoint == "manga_viewer": + viewer = _as_dict(success.get("manga_viewer"), "response.success.manga_viewer") + signature["payload_keys"] = sorted(viewer.keys()) + + pages = _as_list(viewer.get("pages"), "response.success.manga_viewer.pages") + if not pages: + raise CaptureVerificationError( + "Expected at least one page in response.success.manga_viewer.pages" + ) + first_page = _as_dict(pages[0], "response.success.manga_viewer.pages[0]") + last_page = _as_dict(pages[-1], "response.success.manga_viewer.pages[-1]") + signature["first_page_keys"] = sorted(first_page.keys()) + signature["last_page_keys"] = sorted(last_page.keys()) + signature["manga_page_keys"] = sorted( + _as_dict( + first_page.get("manga_page"), "response.success.manga_viewer.pages[0].manga_page" + ).keys() + ) + signature["last_page_payload_keys"] = sorted( + _as_dict( + last_page.get("last_page"), "response.success.manga_viewer.pages[-1].last_page" + ).keys() + ) + return json.dumps(signature, sort_keys=True) + + if endpoint == "title_detailV3": + title_detail = _as_dict( + success.get("title_detail_view"), "response.success.title_detail_view" + ) + signature["payload_keys"] = sorted(title_detail.keys()) + signature["title_keys"] = sorted( + _as_dict(title_detail.get("title"), "response.success.title_detail_view.title").keys() + ) + + chapter_groups = _as_list( + title_detail.get("chapter_list_group"), + "response.success.title_detail_view.chapter_list_group", + ) + if not chapter_groups: + raise CaptureVerificationError( + "Expected at least one group in response.success.title_detail_view.chapter_list_group" + ) + first_group = _as_dict( + chapter_groups[0], + "response.success.title_detail_view.chapter_list_group[0]", + ) + signature["chapter_group_keys"] = sorted(first_group.keys()) + + first_chapter_list = _as_list( + first_group.get("first_chapter_list"), + "response.success.title_detail_view.chapter_list_group[0].first_chapter_list", + ) + if not first_chapter_list: + raise CaptureVerificationError( + "Expected at least one chapter in first_chapter_list for response.success.title_detail_view" + ) + first_chapter = _as_dict( + first_chapter_list[0], + "response.success.title_detail_view.chapter_list_group[0].first_chapter_list[0]", + ) + signature["chapter_keys"] = sorted(first_chapter.keys()) + return json.dumps(signature, sort_keys=True) + + raise CaptureVerificationError(f"Unsupported endpoint '{endpoint}'") + + +def _verify_title_detail_payload(parsed: Response, stem: str) -> None: + """Validate required title-detail fields used by planner/download flow.""" + if not parsed.success.HasField("title_detail_view"): + raise CaptureVerificationError(f"Missing success.title_detail_view in {stem}.pb") + + title_detail = parsed.success.title_detail_view + if title_detail.title.title_id == 0 or not title_detail.title.name: + raise CaptureVerificationError(f"Missing required title identity fields in {stem}.pb") + + if not title_detail.chapter_list_group: + raise CaptureVerificationError(f"No chapter_list_group records in {stem}.pb") + + has_any_chapter = any( + group.first_chapter_list or group.mid_chapter_list or group.last_chapter_list + for group in title_detail.chapter_list_group + ) + if not has_any_chapter: + raise CaptureVerificationError( + f"No chapter entries found in chapter_list_group for {stem}.pb" + ) + + +def _verify_manga_viewer_payload(parsed: Response, stem: str) -> None: + """Validate required manga-viewer fields used by download flow.""" + if not parsed.success.HasField("manga_viewer"): + raise CaptureVerificationError(f"Missing success.manga_viewer in {stem}.pb") + + viewer = parsed.success.manga_viewer + if viewer.title_id == 0 or viewer.chapter_id == 0: + raise CaptureVerificationError(f"Missing viewer title_id/chapter_id fields in {stem}.pb") + + if not viewer.pages: + raise CaptureVerificationError(f"No pages found in manga_viewer payload {stem}.pb") + + if not any(page.manga_page.image_url for page in viewer.pages): + raise CaptureVerificationError(f"No manga_page.image_url found in pages for {stem}.pb") + + last_page = viewer.pages[-1].last_page + if last_page.current_chapter.chapter_id == 0: + raise CaptureVerificationError(f"Missing last_page.current_chapter in {stem}.pb") + + +def _verify_capture_schema_records( + capture_dir_path: Path, +) -> tuple[CaptureVerificationSummary, list[_CaptureRecord]]: + """Verify capture directory and return summary with per-record signatures.""" + if not capture_dir_path.exists() or not capture_dir_path.is_dir(): + raise CaptureVerificationError(f"Capture directory not found: {capture_dir_path}") + + meta_paths = sorted(capture_dir_path.glob("*.meta.json")) + if not meta_paths: + raise CaptureVerificationError(f"No '*.meta.json' files found in: {capture_dir_path}") + + endpoint_counts: dict[str, int] = {} + records: list[_CaptureRecord] = [] + for meta_path in meta_paths: + metadata = _load_metadata(meta_path) + stem = meta_path.name.removesuffix(".meta.json") + + endpoint = str(metadata.get("endpoint", "")) + if not endpoint: + raise CaptureVerificationError(f"Missing endpoint in metadata file: {meta_path.name}") + + raw_payload_file = str(metadata.get("raw_payload_file", f"{stem}.pb")) + raw_payload_path = capture_dir_path / raw_payload_file + if not raw_payload_path.exists(): + raise CaptureVerificationError( + f"Missing raw payload file referenced by metadata: {raw_payload_file}" + ) + + payload = raw_payload_path.read_bytes() + payload_size = metadata.get("payload_size_bytes") + if isinstance(payload_size, int) and payload_size != len(payload): + raise CaptureVerificationError( + f"Payload size mismatch for {raw_payload_file}: metadata={payload_size}, actual={len(payload)}" + ) + + payload_sha = metadata.get("payload_sha256") + if isinstance(payload_sha, str) and payload_sha != sha256(payload).hexdigest(): + raise CaptureVerificationError(f"Payload sha256 mismatch for {raw_payload_file}") + + parsed = Response.FromString(payload) + if not parsed.HasField("success"): + raise CaptureVerificationError(f"Missing success envelope in {raw_payload_file}") + + if endpoint == "title_detailV3": + _verify_title_detail_payload(parsed, stem) + elif endpoint == "manga_viewer": + _verify_manga_viewer_payload(parsed, stem) + else: + raise CaptureVerificationError( + f"Unsupported endpoint '{endpoint}' in metadata {meta_path.name}" + ) + + records.append( + _CaptureRecord( + stem=stem, + endpoint=endpoint, + signature_json=_build_schema_signature( + endpoint=endpoint, metadata=metadata, parsed=parsed + ), + ) + ) + endpoint_counts[endpoint] = endpoint_counts.get(endpoint, 0) + 1 + + summary = CaptureVerificationSummary( + total_records=sum(endpoint_counts.values()), + endpoint_counts=endpoint_counts, + ) + return summary, records + + +def verify_capture_schema(capture_dir: str | Path) -> CaptureVerificationSummary: + """Verify capture metadata + protobuf payloads against required runtime fields.""" + summary, _records = _verify_capture_schema_records(Path(capture_dir)) + return summary + + +def verify_capture_schema_against_baseline( + capture_dir: str | Path, + baseline_dir: str | Path, +) -> CaptureVerificationSummary: + """Verify captures and compare structural signatures against a baseline set.""" + capture_summary, capture_records = _verify_capture_schema_records(Path(capture_dir)) + _baseline_summary, baseline_records = _verify_capture_schema_records(Path(baseline_dir)) + + baseline_by_endpoint: dict[str, set[str]] = {} + for record in baseline_records: + baseline_by_endpoint.setdefault(record.endpoint, set()).add(record.signature_json) + + for record in capture_records: + if record.endpoint not in baseline_by_endpoint: + raise CaptureVerificationError( + f"Unknown endpoint '{record.endpoint}' in capture '{record.stem}' compared to baseline" + ) + if record.signature_json not in baseline_by_endpoint[record.endpoint]: + raise CaptureVerificationError( + f"Schema drift detected for capture '{record.stem}' endpoint '{record.endpoint}' " + f"when compared to baseline '{baseline_dir}'" + ) + return capture_summary diff --git a/mloader/manga_loader/decryption.py b/mloader/manga_loader/decryption.py new file mode 100644 index 0000000..7b5c99e --- /dev/null +++ b/mloader/manga_loader/decryption.py @@ -0,0 +1,45 @@ +"""Image decryption helpers used for encrypted page payloads.""" + +from __future__ import annotations + +from mloader.types import SessionLike + + +def _convert_hex_to_bytes(hex_str: str) -> bytes: + """ + Convert a hexadecimal string to bytes. + """ + return bytes.fromhex(hex_str) + + +def _xor_decrypt(data: bytearray, key: bytes) -> bytearray: + """ + Decrypt data using XOR with a repeating key. + """ + key_length = len(key) + for index in range(len(data)): + data[index] ^= key[index % key_length] + return data + + +class DecryptionMixin: + """Provide image retrieval and XOR decryption behavior.""" + + session: SessionLike + request_timeout: tuple[float, float] + + def _decrypt_image(self, url: str, encryption_hex: str) -> bytearray: + """ + Retrieve and decrypt an image using XOR decryption with a repeating key. + """ + encrypted_data = self._fetch_encrypted_data(url) + encryption_key = _convert_hex_to_bytes(encryption_hex) + return _xor_decrypt(encrypted_data, encryption_key) + + def _fetch_encrypted_data(self, url: str) -> bytearray: + """ + Fetch encrypted image data from the provided URL. + """ + response = self.session.get(url, timeout=self.request_timeout) + response.raise_for_status() + return bytearray(response.content) diff --git a/mloader/manga_loader/download_services.py b/mloader/manga_loader/download_services.py new file mode 100644 index 0000000..9fd11aa --- /dev/null +++ b/mloader/manga_loader/download_services.py @@ -0,0 +1,32 @@ +"""Explicitly composed runtime services used by downloader orchestration.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from mloader.manga_loader.services import ( + ChapterPlanner, + MetadataWriter, + PageExportService, + PageImageService, +) + + +@dataclass(frozen=True, slots=True) +class DownloadServices: + """Container for runtime collaborators used by ``DownloadMixin``.""" + + chapter_planner: type[ChapterPlanner] + metadata_writer: type[MetadataWriter] + page_export_service: type[PageExportService] + page_image_service: type[PageImageService] + + @staticmethod + def defaults() -> "DownloadServices": + """Return default concrete service bindings.""" + return DownloadServices( + chapter_planner=ChapterPlanner, + metadata_writer=MetadataWriter, + page_export_service=PageExportService, + page_image_service=PageImageService, + ) diff --git a/mloader/manga_loader/downloader.py b/mloader/manga_loader/downloader.py new file mode 100644 index 0000000..3701fb6 --- /dev/null +++ b/mloader/manga_loader/downloader.py @@ -0,0 +1,458 @@ +"""Download orchestration logic for titles, chapters, and page assets.""" + +from __future__ import annotations + +from io import BytesIO +import logging +from pathlib import Path +from typing import Collection, Literal, Mapping, cast + +from PIL import Image + +from mloader.domain.requests import DownloadSummary +from mloader.errors import SubscriptionRequiredError +from mloader.manga_loader.download_services import DownloadServices +from mloader.manga_loader.manifest import TitleDownloadManifest +from mloader.manga_loader.run_report import RunReport +from mloader.manga_loader.services import ChapterMetadata +from mloader.types import ( + ChapterLike, + ExporterFactoryLike, + ExporterLike, + MangaPageLike, + MangaViewerLike, + SessionLike, + TitleDumpLike, + TitleLike, +) +from mloader.utils import escape_path + +log = logging.getLogger(__name__) + + +class DownloadInterruptedError(RuntimeError): + """Raised when a download run is interrupted by user signal.""" + + def __init__(self, summary: DownloadSummary) -> None: + """Store partial run summary for CLI reporting.""" + super().__init__("Download interrupted by user.") + self.summary = summary + + +class DownloadMixin: + """Provide download and export orchestration for manga content.""" + + meta: bool + cover: bool + destination: str + output_format: Literal["raw", "cbz", "pdf"] + exporter: ExporterFactoryLike + session: SessionLike + request_timeout: tuple[float, float] + resume: bool + manifest_reset: bool + services: DownloadServices + + def _prepare_normalized_manga_list( + self, + title_ids: Collection[int] | None, + chapter_numbers: Collection[int] | None, + chapter_ids: Collection[int] | None, + min_chapter: int, + max_chapter: int, + last_chapter: bool, + ) -> Mapping[int, Collection[int]]: + """Normalize title/chapter filters into a concrete download mapping.""" + raise NotImplementedError + + def _get_title_details(self, title_id: str | int) -> TitleDumpLike: + """Load title details for ``title_id``.""" + raise NotImplementedError + + def _load_pages(self, chapter_id: str | int) -> MangaViewerLike: + """Load chapter viewer payload for ``chapter_id``.""" + raise NotImplementedError + + def _decrypt_image(self, url: str, encryption_hex: str) -> bytearray: + """Download and decrypt one image payload.""" + raise NotImplementedError + + def _clear_api_caches_for_run(self) -> None: + """Clear all API cache entries before/after one download run.""" + + def _clear_api_caches_for_title( + self, + title_id: int, + chapter_ids: Collection[int], + ) -> None: + """Clear per-title API cache entries after title processing.""" + + def download( + self, + *, + title_ids: Collection[int] | None = None, + chapter_numbers: Collection[int] | None = None, + chapter_ids: Collection[int] | None = None, + min_chapter: int, + max_chapter: int, + last_chapter: bool = False, + ) -> DownloadSummary: + """Start a download run using already validated filters.""" + report = RunReport() + self._clear_api_caches_for_run() + try: + normalized_mapping = self._prepare_normalized_manga_list( + title_ids, + chapter_numbers, + chapter_ids, + min_chapter, + max_chapter, + last_chapter, + ) + self._download(normalized_mapping, report) + except KeyboardInterrupt as interrupted: + raise DownloadInterruptedError(report.as_summary()) from interrupted + finally: + self._clear_api_caches_for_run() + return report.as_summary() + + def _download( + self, + manga_mapping: Mapping[int, Collection[int]], + report: RunReport, + ) -> None: + """Iterate through normalized titles and process them one by one.""" + total_titles = len(manga_mapping) + for title_index, (title_id, chapter_ids) in enumerate(manga_mapping.items(), 1): + self._process_title(title_index, total_titles, title_id, chapter_ids, report=report) + + def _process_title( + self, + title_index: int, + total_titles: int, + title_id: int, + chapter_ids: Collection[int], + *, + report: RunReport, + ) -> None: + """Download and export all selected chapters for one title.""" + manifest: TitleDownloadManifest | None = None + try: + title_dump = self._get_title_details(title_id) + title_detail = title_dump.title + + log.info(f"{title_index}/{total_titles}) Manga: {title_detail.name}") + log.info(f" Author: {title_detail.author}") + + export_path = Path(self.destination) / escape_path(title_detail.name).title() + if self.cover: + try: + self._dump_title_cover(title_dump, export_path) + except Exception as error: + log.warning(" Cover export failed for '%s': %s", title_detail.name, error) + if self.resume or self.manifest_reset: + manifest = TitleDownloadManifest(export_path, autosave=False) + if self.manifest_reset: + manifest.reset() + + chapter_data = self._extract_chapter_data(title_dump) + + if self.meta: + self._dump_title_metadata(title_dump, chapter_data, export_path) + + existing_files = self._get_existing_files(export_path) + chapters_to_download = self._filter_chapters_to_download( + chapter_data, + title_dump, + title_detail, + existing_files, + chapter_ids, + ) + if self.resume and manifest is not None: + chapters_to_download, skipped_manifest = self._exclude_manifest_completed_chapters( + chapters_to_download, + manifest, + ) + report.mark_manifest_skipped(skipped_manifest) + + if not chapters_to_download: + log.info(f" All chapters for '{title_detail.name}' are already downloaded.") + return + + total_chapters = len(chapters_to_download) + log.info(f" {total_chapters} chapter(s) to download for '{title_detail.name}'.") + for chapter_index, chapter_id in enumerate(sorted(chapters_to_download), 1): + try: + self._process_chapter( + title_detail, + chapter_index, + total_chapters, + chapter_id, + manifest=manifest if self.resume else None, + ) + report.mark_downloaded() + except KeyboardInterrupt: + if self.resume and manifest is not None: + manifest.mark_failed(chapter_id, error="Interrupted by user.") + manifest.flush() + report.mark_failed(chapter_id) + log.warning(" Interrupted while downloading chapter %s.", chapter_id) + raise + except Exception as error: + if self.resume and manifest is not None: + manifest.mark_failed(chapter_id, error=str(error)) + manifest.flush() + report.mark_failed(chapter_id) + log.error(" Failed chapter %s: %s", chapter_id, error) + finally: + if self.resume and manifest is not None: + manifest.flush() + self._clear_api_caches_for_title(title_id, chapter_ids) + + def _process_chapter( + self, + title_detail: TitleLike, + chapter_index: int, + total_chapters: int, + chapter_id: int, + *, + manifest: TitleDownloadManifest | None = None, + ) -> None: + """Download and export a single chapter.""" + viewer = self._load_pages(chapter_id) + if not self._has_last_page(viewer): + raise SubscriptionRequiredError( + "A MAX subscription is required to download this chapter." + ) + + last_page = viewer.pages[-1].last_page + current_chapter = last_page.current_chapter + next_chapter = last_page.next_chapter if last_page.next_chapter.chapter_id != 0 else None + + current_chapter.sub_title = self._prepare_filename(current_chapter.sub_title) + log.info( + f" {chapter_index}/{total_chapters}) Chapter " + f"{viewer.chapter_name}: {current_chapter.sub_title}" + ) + if manifest is not None: + manifest.mark_started( + chapter_id, + chapter_name=viewer.chapter_name, + sub_title=current_chapter.sub_title, + output_format=self.output_format, + ) + + exporter = self.exporter( + title=title_detail, + chapter=current_chapter, + next_chapter=next_chapter, + ) + pages = [page.manga_page for page in viewer.pages if page.manga_page.image_url] + if not pages: + raise RuntimeError( + f"MangaPlus API returned no downloadable pages for chapter {chapter_id}." + ) + self._process_chapter_pages(pages, viewer.chapter_name, exporter) + exporter.close() + + if manifest is not None: + exporter_path = getattr(exporter, "path", None) + output_path = str(exporter_path) if exporter_path is not None else None + manifest.mark_completed(chapter_id, output_path=output_path) + + def _process_chapter_pages( + self, + pages: Collection[MangaPageLike], + chapter_name: str, + exporter: ExporterLike, + ) -> None: + """Download all chapter pages and pass them to the exporter.""" + self._services().page_export_service.export_pages( + pages, + chapter_name, + exporter, + fetch_page_image=self._fetch_page_image, + ) + + def _download_image(self, url: str) -> bytes: + """Download an image blob from ``url``.""" + return self._services().page_image_service.download_image( + self.session, + self.request_timeout, + url, + ) + + def _fetch_page_image(self, page: MangaPageLike) -> bytes: + """Return raw or decrypted image bytes for one manga page.""" + return self._services().page_image_service.fetch_page_image( + page, + download_image=self._download_image, + decrypt_image=self._decrypt_image, + ) + + def _dump_title_metadata( + self, + title_dump: TitleDumpLike, + chapter_data_or_export_dir: Mapping[int, ChapterMetadata | Mapping[str, object]] + | str + | Path, + export_dir: str | Path | None = None, + ) -> None: + """Write title-level metadata JSON into ``export_dir``.""" + resolved_chapter_data: Mapping[int, ChapterMetadata | Mapping[str, object]] + resolved_export_dir: str | Path + if export_dir is None: + if not isinstance(chapter_data_or_export_dir, (str, Path)): + raise TypeError("Expected export directory when chapter metadata is omitted.") + resolved_chapter_data = self._extract_chapter_data(title_dump) + resolved_export_dir = chapter_data_or_export_dir + else: + if not isinstance(chapter_data_or_export_dir, Mapping): + raise TypeError("Expected chapter metadata mapping when export_dir is provided.") + resolved_chapter_data = cast( + Mapping[int, ChapterMetadata | Mapping[str, object]], + chapter_data_or_export_dir, + ) + resolved_export_dir = export_dir + + self._services().metadata_writer.dump_title_metadata( + title_dump, + resolved_chapter_data, + resolved_export_dir, + ) + log.info(f" Metadata for title '{title_dump.title.name}' exported") + + def _resolve_cover_image_url(self, title_dump: TitleDumpLike) -> str | None: + """Resolve the best available cover URL from title-detail payload data.""" + portrait_cover_url = str(getattr(title_dump.title, "portrait_image_url", "")).strip() + if portrait_cover_url: + return portrait_cover_url + primary_cover_url = str(getattr(title_dump, "title_image_url", "")).strip() + if primary_cover_url: + return primary_cover_url + landscape_cover_url = str(getattr(title_dump.title, "landscape_image_url", "")).strip() + if landscape_cover_url: + return landscape_cover_url + return None + + def _dump_title_cover( + self, + title_dump: TitleDumpLike, + export_dir: str | Path, + ) -> None: + """Download and store one title cover image as ``cover.png``.""" + cover_url = self._resolve_cover_image_url(title_dump) + if cover_url is None: + log.warning( + " Cover export skipped for '%s': no cover URL found.", title_dump.title.name + ) + return + + export_dir_path = Path(export_dir) + export_dir_path.mkdir(parents=True, exist_ok=True) + cover_path = export_dir_path / "cover.png" + if cover_path.exists(): + log.info(" Cover for title '%s' already exists.", title_dump.title.name) + return + + image_blob = self._download_image(cover_url) + with Image.open(BytesIO(image_blob)) as image: + converted = image.convert("RGBA") + converted.save(cover_path, format="PNG") + log.info(" Cover for title '%s' exported.", title_dump.title.name) + + def _extract_chapter_data(self, title_dump: TitleDumpLike) -> dict[int, ChapterMetadata]: + """Collect chapter metadata from all chapter groups into one mapping.""" + return self._services().chapter_planner.extract_chapter_data( + title_dump, + self._prepare_filename, + ) + + def _get_existing_files(self, export_path: Path) -> list[str]: + """Return existing chapter stems for single-file output formats.""" + if not export_path.exists(): + return [] + + extension = self._chapter_output_extension() + if extension is None: + return [] + + existing_files = [file.stem for file in export_path.glob(f"*.{extension}")] + log.info(f" Found {len(existing_files)} existing chapter files in '{export_path}'.") + log.debug(f" Existing files: {existing_files}") + return existing_files + + def _chapter_output_extension(self) -> str | None: + """Return chapter-level output extension, or ``None`` for raw image mode.""" + if self.output_format in {"pdf", "cbz"}: + return self.output_format + return None + + def _filter_chapters_to_download( + self, + chapter_data: Mapping[int, ChapterMetadata], + title_dump: TitleDumpLike, + title_detail: TitleLike, + existing_files: Collection[str], + requested_chapter_ids: Collection[int], + ) -> list[int]: + """Return chapter IDs that are requested and not already exported.""" + return self._services().chapter_planner.filter_chapters_to_download( + chapter_data, + title_dump, + title_detail, + existing_files, + requested_chapter_ids, + ) + + def _exclude_manifest_completed_chapters( + self, + chapter_ids: Collection[int], + manifest: TitleDownloadManifest, + ) -> tuple[list[int], int]: + """Exclude chapter IDs already marked completed in the title manifest.""" + pending = [ + chapter_id for chapter_id in chapter_ids if not manifest.is_completed(chapter_id) + ] + skipped_count = len(chapter_ids) - len(pending) + if skipped_count: + log.info( + f" Skipping {skipped_count} chapter(s) already marked completed in manifest." + ) + return pending, skipped_count + + def _build_expected_filename( + self, + title_name: str, + chapter_obj: ChapterLike, + sub_title: str, + ) -> str: + """Build normalized filename stem expected for chapter-level outputs.""" + return self._services().chapter_planner.build_expected_filename( + title_name, + chapter_obj, + sub_title, + ) + + def _find_chapter_by_id(self, title_dump: TitleDumpLike, chapter_id: int) -> ChapterLike | None: + """Find and return a chapter object by ``chapter_id`` if available.""" + return self._services().chapter_planner.find_chapter_by_id(title_dump, chapter_id) + + def _has_last_page(self, viewer: MangaViewerLike) -> bool: + """Return whether ``viewer`` includes a valid terminal page payload.""" + return bool(viewer.pages and viewer.pages[-1] and hasattr(viewer.pages[-1], "last_page")) + + def _prepare_filename(self, text: str) -> str: + """Fix common encoding glitches and sanitize text for filesystem use.""" + fixed_text = text + try: + fixed_text = text.encode("latin1").decode("utf8") + except UnicodeEncodeError, UnicodeDecodeError: + log.warning(f" Encoding fix skipped for: {text}") + return escape_path(fixed_text) + + def _services(self) -> DownloadServices: + """Return configured downloader services, defaulting to concrete bindings.""" + if not hasattr(self, "services"): + self.services = DownloadServices.defaults() + return self.services diff --git a/mloader/manga_loader/init.py b/mloader/manga_loader/init.py new file mode 100644 index 0000000..471fafa --- /dev/null +++ b/mloader/manga_loader/init.py @@ -0,0 +1,175 @@ +"""Compose loader runtime services into the concrete ``MangaLoader`` facade.""" + +from __future__ import annotations + +from typing import Literal, cast + +from requests import Session +from requests.adapters import HTTPAdapter +from urllib3.util.retry import Retry + +from mloader.domain.requests import DownloadSummary +from mloader.types import ExporterFactoryLike, PayloadCaptureLike, SessionLike + +from .api import APILoaderMixin +from .capture import APIPayloadCapture +from .decryption import DecryptionMixin +from .download_services import DownloadServices +from .downloader import DownloadMixin +from .normalization import NormalizationMixin + + +class _LoaderRuntime(APILoaderMixin, NormalizationMixin, DownloadMixin, DecryptionMixin): + """Internal runtime implementation composed from existing focused mixins.""" + + def __init__( + self, + exporter: ExporterFactoryLike, + quality: str, + split: bool, + meta: bool, + cover: bool, + destination: str, + output_format: Literal["raw", "cbz", "pdf"], + session: SessionLike | None, + api_url: str, + request_timeout: tuple[float, float], + retries: int, + capture_api_dir: str | None, + resume: bool, + manifest_reset: bool, + services: DownloadServices, + ) -> None: + """Initialize runtime dependencies and transport settings.""" + self.meta = meta + self.cover = cover + self.exporter = exporter + self.destination = destination + self.output_format = output_format + self.quality = quality + self.split = split + self.request_timeout = request_timeout + self.resume = resume + self.manifest_reset = manifest_reset + self.services = services + self.payload_capture = APIPayloadCapture(capture_api_dir) if capture_api_dir else None + self.session = session if session is not None else cast(SessionLike, Session()) + self._configure_transport(self.session, retries) + self.session.headers.update( + { + "User-Agent": "JumpPlus/1 CFNetwork/1333.0.4 Darwin/21.5.0", + } + ) + self._api_url = api_url + + @staticmethod + def _configure_transport(session: SessionLike, retries: int) -> None: + """Configure HTTP retry policy for transient API failures.""" + configure_transport(session, retries) + + +def configure_transport(session: SessionLike, retries: int) -> None: + """Configure HTTPS retry policy for transient API failures.""" + retry_policy = Retry( + total=retries, + connect=retries, + read=retries, + status=retries, + backoff_factor=0.5, + status_forcelist=(429, 500, 502, 503, 504), + allowed_methods=frozenset({"GET"}), + raise_on_status=False, + ) + adapter = HTTPAdapter(max_retries=retry_policy) + session.mount("https://", adapter) + + +class MangaLoader: + """Facade object exposing the download API while composing internal runtime behavior.""" + + def __init__( + self, + exporter: ExporterFactoryLike, + quality: str, + split: bool, + meta: bool, + cover: bool = False, + destination: str = "mloader_downloads", + output_format: Literal["raw", "cbz", "pdf"] = "cbz", + session: SessionLike | None = None, + api_url: str = "https://jumpg-api.tokyo-cdn.com", + request_timeout: tuple[float, float] = (5.0, 30.0), + retries: int = 3, + capture_api_dir: str | None = None, + resume: bool = True, + manifest_reset: bool = False, + services: DownloadServices | None = None, + ) -> None: + """Initialize the composed runtime and preserve public constructor contract.""" + self._runtime = _LoaderRuntime( + exporter=exporter, + quality=quality, + split=split, + meta=meta, + cover=cover, + destination=destination, + output_format=output_format, + session=session, + api_url=api_url, + request_timeout=request_timeout, + retries=retries, + capture_api_dir=capture_api_dir, + resume=resume, + manifest_reset=manifest_reset, + services=services or DownloadServices.defaults(), + ) + + @property + def session(self) -> SessionLike: + """Expose active HTTP session for tests and runtime introspection.""" + return self._runtime.session + + @property + def destination(self) -> str: + """Expose configured destination directory.""" + return self._runtime.destination + + @property + def output_format(self) -> Literal["raw", "cbz", "pdf"]: + """Expose configured chapter output format.""" + return self._runtime.output_format + + @property + def request_timeout(self) -> tuple[float, float]: + """Expose configured request timeout tuple.""" + return self._runtime.request_timeout + + @property + def payload_capture(self) -> PayloadCaptureLike | None: + """Expose payload capture backend when capture mode is enabled.""" + return self._runtime.payload_capture + + @staticmethod + def _configure_transport(session: SessionLike, retries: int) -> None: + """Proxy transport configuration for compatibility with existing tests/usages.""" + configure_transport(session, retries) + + def download( + self, + *, + title_ids: set[int] | frozenset[int] | None = None, + chapter_numbers: set[int] | frozenset[int] | None = None, + chapter_ids: set[int] | frozenset[int] | None = None, + min_chapter: int, + max_chapter: int, + last_chapter: bool = False, + ) -> DownloadSummary: + """Delegate download orchestration to the composed runtime.""" + return self._runtime.download( + title_ids=title_ids, + chapter_numbers=chapter_numbers, + chapter_ids=chapter_ids, + min_chapter=min_chapter, + max_chapter=max_chapter, + last_chapter=last_chapter, + ) diff --git a/mloader/manga_loader/manifest.py b/mloader/manga_loader/manifest.py new file mode 100644 index 0000000..8e01f85 --- /dev/null +++ b/mloader/manga_loader/manifest.py @@ -0,0 +1,246 @@ +"""Persistent chapter download manifest used for resumable runs.""" + +from __future__ import annotations + +import json +from datetime import UTC, datetime +from pathlib import Path +from tempfile import NamedTemporaryFile +from collections.abc import Callable +from typing import Any + +from filelock import FileLock + +MANIFEST_FILENAME = ".mloader-manifest.json" +MANIFEST_SCHEMA = "mloader.title_download_manifest" +MANIFEST_VERSION = 2 + +type ManifestEntry = dict[str, Any] +type ManifestChapters = dict[str, ManifestEntry] +type ManifestPayload = dict[str, Any] + + +def _utc_timestamp() -> str: + """Return a stable UTC timestamp string for manifest updates.""" + return datetime.now(UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z") + + +def _coerce_chapter_entries(raw_chapters: object) -> ManifestChapters: + """Return chapter-entry mapping containing only dict chapter payload values.""" + if not isinstance(raw_chapters, dict): + return {} + entries: ManifestChapters = {} + for chapter_id, entry in raw_chapters.items(): + if isinstance(entry, dict): + entries[str(chapter_id)] = {str(key): value for key, value in entry.items()} + return entries + + +def _migrate_v0_to_v1(payload: ManifestPayload) -> ManifestPayload: + """Migrate legacy/unversioned payloads into version-1 structure.""" + chapters = _coerce_chapter_entries(payload.get("chapters")) + if not chapters: + chapters = _coerce_chapter_entries(payload) + return { + "version": 1, + "chapters": chapters, + } + + +def _migrate_v1_to_v2(payload: ManifestPayload) -> ManifestPayload: + """Migrate version-1 payloads by adding explicit schema metadata.""" + return { + "version": 2, + "schema": MANIFEST_SCHEMA, + "chapters": _coerce_chapter_entries(payload.get("chapters")), + } + + +MANIFEST_MIGRATIONS: dict[int, Callable[[ManifestPayload], ManifestPayload]] = { + 0: _migrate_v0_to_v1, + 1: _migrate_v1_to_v2, +} + + +def _normalize_payload(payload: ManifestPayload) -> tuple[ManifestChapters, bool]: + """Normalize and migrate payload to current schema, returning ``(chapters, migrated)``.""" + raw_version = payload.get("version") + version = raw_version if isinstance(raw_version, int) and raw_version >= 0 else 0 + normalized: ManifestPayload = dict(payload) + + if version > MANIFEST_VERSION: + return _coerce_chapter_entries(normalized.get("chapters")), False + + migrated = False + while version < MANIFEST_VERSION: + migrator = MANIFEST_MIGRATIONS.get(version) + if migrator is None: + return {}, False + normalized = migrator(normalized) + version += 1 + migrated = True + + return _coerce_chapter_entries(normalized.get("chapters")), migrated + + +class TitleDownloadManifest: + """Manage chapter download progress for a single title directory.""" + + def __init__( + self, + title_dir: Path, + *, + autosave: bool = True, + lock_timeout: float = 30.0, + ) -> None: + """Load an existing manifest from ``title_dir`` when available.""" + self.path = title_dir / MANIFEST_FILENAME + self.lock_path = title_dir / f"{MANIFEST_FILENAME}.lock" + self._lock = FileLock(str(self.lock_path), timeout=lock_timeout) + self._autosave = autosave + self._chapters: dict[str, dict[str, Any]] = {} + self._dirty = False + self._load() + + def _load(self) -> None: + """Load chapter status entries from disk if the manifest exists.""" + with self._lock: + self._load_unlocked() + + def _load_unlocked(self) -> None: + """Load chapter status entries without acquiring the lock.""" + if not self.path.exists(): + self._chapters = {} + self._dirty = False + return + + try: + payload = json.loads(self.path.read_text(encoding="utf-8")) + except OSError, json.JSONDecodeError: + self._chapters = {} + self._dirty = False + return + + if not isinstance(payload, dict): + self._chapters = {} + self._dirty = False + return + + self._chapters, migrated = _normalize_payload(payload) + self._dirty = migrated + if migrated and self._autosave: + self._save_unlocked() + + def _save_unlocked(self) -> None: + """Persist current manifest content to disk atomically without locking.""" + self.path.parent.mkdir(parents=True, exist_ok=True) + payload = { + "version": MANIFEST_VERSION, + "schema": MANIFEST_SCHEMA, + "chapters": self._chapters, + } + with NamedTemporaryFile("w", encoding="utf-8", delete=False, dir=self.path.parent) as tmp: + json.dump(payload, tmp, ensure_ascii=False, indent=2, sort_keys=True) + temp_path = Path(tmp.name) + temp_path.replace(self.path) + self._dirty = False + + def save(self) -> None: + """Persist current manifest content to disk atomically.""" + with self._lock: + self._save_unlocked() + + def flush(self) -> None: + """Persist pending in-memory changes when autosave is disabled.""" + if not self._dirty: + return + with self._lock: + if not self._dirty: + return + self._save_unlocked() + + def reset(self) -> None: + """Clear manifest state and remove persisted manifest file if present.""" + with self._lock: + self._chapters = {} + self._dirty = False + if self.path.exists(): + self.path.unlink() + + def is_completed(self, chapter_id: int) -> bool: + """Return ``True`` when chapter ``chapter_id`` is marked completed.""" + entry = self._chapters.get(str(chapter_id)) + if entry is None: + return False + return entry.get("status") == "completed" + + def _mark_entry(self, chapter_id: int, *, updates: dict[str, Any]) -> None: + """Update one chapter entry and persist according to autosave mode.""" + key = str(chapter_id) + if self._autosave: + with self._lock: + self._load_unlocked() + entry = dict(self._chapters.get(key, {"chapter_id": chapter_id})) + entry.update(updates) + if self._chapters.get(key) == entry: + return + self._chapters[key] = entry + self._dirty = True + self._save_unlocked() + return + + entry = dict(self._chapters.get(key, {"chapter_id": chapter_id})) + entry.update(updates) + if self._chapters.get(key) == entry: + return + self._chapters[key] = entry + self._dirty = True + + def mark_started( + self, + chapter_id: int, + *, + chapter_name: str, + sub_title: str, + output_format: str, + ) -> None: + """Mark chapter as in progress and persist metadata for resume tracking.""" + self._mark_entry( + chapter_id, + updates={ + "chapter_id": chapter_id, + "chapter_name": chapter_name, + "sub_title": sub_title, + "output_format": output_format, + "status": "in_progress", + "started_at": _utc_timestamp(), + "completed_at": None, + "failed_at": None, + "error": None, + }, + ) + + def mark_completed(self, chapter_id: int, *, output_path: str | None = None) -> None: + """Mark chapter as completed and optionally store final output path.""" + updates: dict[str, Any] = { + "chapter_id": chapter_id, + "status": "completed", + "completed_at": _utc_timestamp(), + "failed_at": None, + "error": None, + } + if output_path: + updates["output_path"] = output_path + self._mark_entry(chapter_id, updates=updates) + + def mark_failed(self, chapter_id: int, *, error: str) -> None: + """Mark chapter as failed with an error description.""" + self._mark_entry( + chapter_id, + updates={ + "chapter_id": chapter_id, + "status": "failed", + "failed_at": _utc_timestamp(), + "error": error, + }, + ) diff --git a/mloader/manga_loader/normalization.py b/mloader/manga_loader/normalization.py new file mode 100644 index 0000000..3bf8f7a --- /dev/null +++ b/mloader/manga_loader/normalization.py @@ -0,0 +1,130 @@ +"""Normalization helpers that map input IDs to downloadable chapter sets.""" + +from dataclasses import dataclass +from itertools import chain +from typing import Collection + +from mloader.types import MangaViewerLike, TitleDumpLike +from mloader.utils import chapter_name_to_int + +MangaList = dict[int, set[int]] + + +@dataclass(frozen=True) +class ChapterMetadata: + """Temporary chapter metadata used during normalization filtering.""" + + id: int + name: str + + +class NormalizationMixin: + """Provide chapter/title normalization logic for loader input.""" + + def _load_pages(self, chapter_id: str | int) -> MangaViewerLike: + """Load chapter viewer payload for ``chapter_id``.""" + raise NotImplementedError + + def _get_title_details(self, title_id: str | int) -> TitleDumpLike: + """Load title detail payload for ``title_id``.""" + raise NotImplementedError + + def _normalize_ids( + self, + title_ids: Collection[int] | None, + chapter_numbers: Collection[int] | None, + chapter_ids: Collection[int] | None, + min_chapter: int, + max_chapter: int, + last_chapter: bool = False, + ) -> MangaList: + """ + Normalize manga title and chapter IDs into a mapping. + + Merges provided title and chapter IDs into a single mapping where each title ID maps + to a set of chapter IDs that pass filtering based on the chapter range or last-chapter flag. + """ + if not any((title_ids, chapter_numbers, chapter_ids)): + raise ValueError("Expected at least one title or chapter id") + + remaining_title_ids = set(title_ids or []) + provided_chapter_numbers = set(chapter_numbers or []) + provided_chapter_ids = set(chapter_ids or []) + direct_chapter_ids_by_title: dict[int, set[int]] = {} + manga_mapping: dict[int, list[ChapterMetadata]] = {} + + if provided_chapter_numbers and not remaining_title_ids and not provided_chapter_ids: + raise ValueError("Chapter numbers require at least one title ID, --all, or viewer URL.") + + for chapter_id in provided_chapter_ids: + viewer = self._load_pages(chapter_id) + current_title_id = viewer.title_id + direct_chapter_ids_by_title.setdefault(current_title_id, set()).add(viewer.chapter_id) + if current_title_id in remaining_title_ids: + remaining_title_ids.remove(current_title_id) + manga_mapping.setdefault(current_title_id, []).extend( + ChapterMetadata(ch.chapter_id, ch.name) for ch in viewer.chapters + ) + else: + manga_mapping.setdefault(current_title_id, []).append( + ChapterMetadata(viewer.chapter_id, viewer.chapter_name) + ) + + titles_requiring_full_details = set(remaining_title_ids) + if provided_chapter_numbers: + titles_requiring_full_details.update(manga_mapping.keys()) + + for title_id in titles_requiring_full_details: + title_details = self._get_title_details(title_id) + manga_mapping[title_id] = [ + ChapterMetadata(ch.chapter_id, ch.name) + for group in title_details.chapter_list_group + for ch in chain( + group.first_chapter_list, group.mid_chapter_list, group.last_chapter_list + ) + ] + + normalized_mapping: MangaList = {} + for title_id, chapters in manga_mapping.items(): + if last_chapter: + filtered_chapters = chapters[-1:] + elif provided_chapter_numbers: + filtered_chapters = [ + ch + for ch in chapters + if (chapter_number := chapter_name_to_int(ch.name)) is not None + and chapter_number in provided_chapter_numbers + and min_chapter <= chapter_number <= max_chapter + ] + else: + filtered_chapters = [ + ch + for ch in chapters + if min_chapter <= (chapter_name_to_int(ch.name) or 0) <= max_chapter + ] + resolved_ids = {ch.id for ch in filtered_chapters} + resolved_ids.update(direct_chapter_ids_by_title.get(title_id, set())) + normalized_mapping[title_id] = resolved_ids + + return normalized_mapping + + def _prepare_normalized_manga_list( + self, + title_ids: Collection[int] | None, + chapter_numbers: Collection[int] | None, + chapter_ids: Collection[int] | None, + min_chapter: int, + max_chapter: int, + last_chapter: bool, + ) -> MangaList: + """ + Prepare the normalized manga mapping from title and chapter IDs. + """ + return self._normalize_ids( + title_ids, + chapter_numbers, + chapter_ids, + min_chapter, + max_chapter, + last_chapter, + ) diff --git a/mloader/manga_loader/run_report.py b/mloader/manga_loader/run_report.py new file mode 100644 index 0000000..9d4a137 --- /dev/null +++ b/mloader/manga_loader/run_report.py @@ -0,0 +1,39 @@ +"""Run-level download reporting helpers.""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +from mloader.domain.requests import DownloadSummary + + +@dataclass(slots=True) +class RunReport: + """Accumulate run counters and expose immutable download summaries.""" + + downloaded: int = 0 + skipped_manifest: int = 0 + failed: int = 0 + failed_chapter_ids: list[int] = field(default_factory=list) + + def mark_downloaded(self) -> None: + """Increment downloaded chapter count.""" + self.downloaded += 1 + + def mark_manifest_skipped(self, skipped_count: int) -> None: + """Increment manifest-skip counter by ``skipped_count``.""" + self.skipped_manifest += skipped_count + + def mark_failed(self, chapter_id: int) -> None: + """Increment failure counters and record the failed chapter ID.""" + self.failed += 1 + self.failed_chapter_ids.append(chapter_id) + + def as_summary(self) -> DownloadSummary: + """Build immutable summary payload for CLI and workflow boundaries.""" + return DownloadSummary( + downloaded=self.downloaded, + skipped_manifest=self.skipped_manifest, + failed=self.failed, + failed_chapter_ids=tuple(self.failed_chapter_ids), + ) diff --git a/mloader/manga_loader/services.py b/mloader/manga_loader/services.py new file mode 100644 index 0000000..7047ee8 --- /dev/null +++ b/mloader/manga_loader/services.py @@ -0,0 +1,229 @@ +"""Small focused services used by downloader orchestration.""" + +from __future__ import annotations + +import json +import logging +from dataclasses import dataclass +from itertools import count +from pathlib import Path +from typing import Callable, Collection, Iterator, Mapping + +import click + +from mloader.constants import PageType +from mloader.types import ChapterLike, TitleDumpLike, TitleLike +from mloader.types import ExporterLike, MangaPageLike, SessionLike +from mloader.utils import escape_path + +log = logging.getLogger(__name__) + + +def _coerce_chapter_metadata(data: ChapterMetadata | Mapping[str, object]) -> ChapterMetadata: + """Normalize chapter metadata from dataclass or mapping payloads.""" + if isinstance(data, ChapterMetadata): + return data + + chapter_id_raw = data.get("chapter_id", 0) + chapter_id = chapter_id_raw if isinstance(chapter_id_raw, int) else int(str(chapter_id_raw)) + thumbnail_url_raw = data.get("thumbnail_url", "") + sub_title_raw = data.get("sub_title", "") + return ChapterMetadata( + thumbnail_url=str(thumbnail_url_raw), + chapter_id=chapter_id, + sub_title=str(sub_title_raw), + ) + + +@dataclass(frozen=True) +class ChapterMetadata: + """Normalized chapter metadata used during planning.""" + + thumbnail_url: str + chapter_id: int + sub_title: str + + def __getitem__(self, key: str) -> str | int: + """Provide mapping-like access for compatibility with dict-shaped tests.""" + if key == "thumbnail_url": + return self.thumbnail_url + if key == "chapter_id": + return self.chapter_id + if key == "sub_title": + return self.sub_title + raise KeyError(key) + + +class ChapterPlanner: + """Compute chapter extraction and download planning decisions.""" + + @staticmethod + def extract_chapter_data( + title_dump: TitleDumpLike, + prepare_filename: Callable[[str], str], + ) -> dict[int, ChapterMetadata]: + """Collect chapter metadata keyed by chapter ID from all chapter groups.""" + chapter_data: dict[int, ChapterMetadata] = {} + for group in title_dump.chapter_list_group: + for chapter_list in ( + group.first_chapter_list, + group.mid_chapter_list, + group.last_chapter_list, + ): + for chapter in chapter_list: + chapter_data[chapter.chapter_id] = ChapterMetadata( + thumbnail_url=chapter.thumbnail_url, + chapter_id=chapter.chapter_id, + sub_title=prepare_filename(chapter.sub_title), + ) + return chapter_data + + @staticmethod + def find_chapter_by_id(title_dump: TitleDumpLike, chapter_id: int) -> ChapterLike | None: + """Find and return a chapter object by ``chapter_id`` if available.""" + for group in title_dump.chapter_list_group: + for chapter_list in ( + group.first_chapter_list, + group.mid_chapter_list, + group.last_chapter_list, + ): + for chapter in chapter_list: + if chapter.chapter_id == chapter_id: + return chapter + return None + + @staticmethod + def build_expected_filename(title_name: str, chapter_obj: ChapterLike, sub_title: str) -> str: + """Build normalized filename stem expected for chapter-level outputs.""" + sanitized_title = escape_path(title_name) + sanitized_chapter_name = escape_path(chapter_obj.name.lstrip("#").strip()) + sanitized_sub_title = escape_path(sub_title) + return f"{sanitized_title} - {sanitized_chapter_name} - {sanitized_sub_title}" + + @staticmethod + def filter_chapters_to_download( + chapter_data: Mapping[int, ChapterMetadata | Mapping[str, object]], + title_dump: TitleDumpLike, + title_detail: TitleLike, + existing_files: Collection[str], + requested_chapter_ids: Collection[int], + ) -> list[int]: + """Return requested chapter IDs that do not yet exist on disk.""" + chapters_to_download: list[int] = [] + for _chapter_id, data in chapter_data.items(): + metadata = _coerce_chapter_metadata(data) + chapter_obj = ChapterPlanner.find_chapter_by_id(title_dump, metadata.chapter_id) + if chapter_obj is None: + # Keep warning for visibility when upstream chapter metadata is inconsistent. + log.warning("Chapter id %s not found in title dump; skipping", metadata.chapter_id) + continue + expected_filename = ChapterPlanner.build_expected_filename( + escape_path(title_detail.name).title(), + chapter_obj, + metadata.sub_title, + ) + if expected_filename not in existing_files: + chapters_to_download.append(metadata.chapter_id) + return [ + chapter_id for chapter_id in chapters_to_download if chapter_id in requested_chapter_ids + ] + + +class MetadataWriter: + """Write metadata outputs derived from title details.""" + + @staticmethod + def dump_title_metadata( + title_dump: TitleDumpLike, + chapter_data: Mapping[int, ChapterMetadata | Mapping[str, object]], + export_dir: str | Path, + ) -> None: + """Write title-level metadata JSON into ``export_dir``.""" + normalized_chapter_data = { + str(chapter_id): { + "thumbnail_url": metadata.thumbnail_url, + "chapter_id": metadata.chapter_id, + "sub_title": escape_path(metadata.sub_title).title(), + } + for chapter_id, value in sorted(chapter_data.items()) + for metadata in [_coerce_chapter_metadata(value)] + } + export_dir_path = Path(export_dir) + export_dir_path.mkdir(parents=True, exist_ok=True) + + title_data = { + "non_appearance_info": title_dump.non_appearance_info, + "number_of_views": title_dump.number_of_views, + "overview": title_dump.overview, + "name": title_dump.title.name, + "author": title_dump.title.author, + "portrait_image_url": title_dump.title.portrait_image_url, + "chapters": normalized_chapter_data, + } + + metadata_file = export_dir_path / "title_metadata.json" + with metadata_file.open("w", encoding="utf-8") as file_obj: + json.dump(title_data, file_obj, ensure_ascii=False, indent=4) + + +class PageImageService: + """Download and decrypt chapter image payloads.""" + + @staticmethod + def download_image( + session: SessionLike, + request_timeout: tuple[float, float], + url: str, + ) -> bytes: + """Download one image blob from ``url`` using configured session settings.""" + response = session.get(url, timeout=request_timeout) + response.raise_for_status() + return response.content + + @staticmethod + def fetch_page_image( + page: MangaPageLike, + *, + download_image: Callable[[str], bytes], + decrypt_image: Callable[[str, str], bytearray], + ) -> bytes: + """Return raw or decrypted page bytes depending on encryption key presence.""" + encryption_key = str(getattr(page, "encryption_key", "")) + if encryption_key: + return bytes(decrypt_image(page.image_url, encryption_key)) + return download_image(page.image_url) + + +class PageExportService: + """Export chapter pages with stable page-index handling.""" + + @staticmethod + def _double_page_index(page_index: int, page_counter: Iterator[int]) -> range: + """Build DOUBLE-page index marker with inclusive ``stop`` naming semantics.""" + paired_page_index = next(page_counter) + return range(page_index, paired_page_index) + + @staticmethod + def export_pages( + pages: Collection[MangaPageLike], + chapter_name: str, + exporter: ExporterLike, + *, + fetch_page_image: Callable[[MangaPageLike], bytes], + ) -> None: + """Stream chapter pages through exporter with DOUBLE-page index mapping.""" + with click.progressbar(pages, label=chapter_name, show_pos=True) as progress_bar: + page_counter = count() + for page_index, page in zip(page_counter, progress_bar): + output_index: int | range = page_index + if PageType(page.type) == PageType.DOUBLE: + output_index = PageExportService._double_page_index( + page_index, + page_counter, + ) + + if exporter.skip_image(output_index): + continue + + image_blob = fetch_page_image(page) + exporter.add_image(image_blob, output_index) diff --git a/mloader/response_pb2.py b/mloader/response_pb2.py index 3133ed8..3e820c8 100644 --- a/mloader/response_pb2.py +++ b/mloader/response_pb2.py @@ -1,11 +1,22 @@ # -*- coding: utf-8 -*- # Generated by the protocol buffer compiler. DO NOT EDIT! +# NO CHECKED-IN PROTOBUF GENCODE # source: response.proto - +# Protobuf Python Version: 6.33.4 +"""Generated protocol buffer code.""" from google.protobuf import descriptor as _descriptor -from google.protobuf import message as _message -from google.protobuf import reflection as _reflection +from google.protobuf import descriptor_pool as _descriptor_pool +from google.protobuf import runtime_version as _runtime_version from google.protobuf import symbol_database as _symbol_database +from google.protobuf.internal import builder as _builder +_runtime_version.ValidateProtobufRuntimeVersion( + _runtime_version.Domain.PUBLIC, + 6, + 33, + 4, + '', + 'response.proto' +) # @@protoc_insertion_point(imports) _sym_db = _symbol_database.Default() @@ -13,1749 +24,70 @@ -DESCRIPTOR = _descriptor.FileDescriptor( - name='response.proto', - package='manga', - syntax='proto3', - serialized_options=None, - create_key=_descriptor._internal_create_key, - serialized_pb=b'\n\x0eresponse.proto\x12\x05manga\"P\n\x06\x42\x61nner\x12\x11\n\timage_url\x18\x01 \x01(\t\x12\'\n\x06\x61\x63tion\x18\x02 \x01(\x0b\x32\x17.manga.TransitionAction\x12\n\n\x02id\x18\x03 \x01(\r\"B\n\nBannerList\x12\x14\n\x0c\x62\x61nner_title\x18\x01 \x01(\t\x12\x1e\n\x07\x62\x61nners\x18\x02 \x03(\x0b\x32\r.manga.Banner\"/\n\x10TransitionAction\x12\x0e\n\x06method\x18\x01 \x01(\x05\x12\x0b\n\x03url\x18\x02 \x01(\t\"\xc9\x01\n\x07\x43hapter\x12\x10\n\x08title_id\x18\x01 \x01(\r\x12\x12\n\nchapter_id\x18\x02 \x01(\r\x12\x0c\n\x04name\x18\x03 \x01(\t\x12\x11\n\tsub_title\x18\x04 \x01(\t\x12\x15\n\rthumbnail_url\x18\x05 \x01(\t\x12\x17\n\x0fstart_timestamp\x18\x06 \x01(\r\x12\x15\n\rend_timestamp\x18\x07 \x01(\r\x12\x16\n\x0e\x61lready_viewed\x18\x08 \x01(\x08\x12\x18\n\x10is_vertical_only\x18\t \x01(\x08\"\xa8\x01\n\x0c\x43hapterGroup\x12\x17\n\x0f\x63hapter_numbers\x18\x01 \x01(\t\x12*\n\x12\x66irst_chapter_list\x18\x02 \x03(\x0b\x32\x0e.manga.Chapter\x12(\n\x10mid_chapter_list\x18\x03 \x03(\x0b\x32\x0e.manga.Chapter\x12)\n\x11last_chapter_list\x18\x04 \x03(\x0b\x32\x0e.manga.Chapter\"\xaf\x01\n\x07\x43omment\x12\n\n\x02id\x18\x01 \x01(\r\x12\r\n\x05index\x18\x02 \x01(\r\x12\x11\n\tuser_name\x18\x03 \x01(\t\x12\x10\n\x08icon_url\x18\x04 \x01(\t\x12\x15\n\ris_my_comment\x18\x06 \x01(\x08\x12\x15\n\ralready_liked\x18\x07 \x01(\x08\x12\x17\n\x0fnumber_of_likes\x18\t \x01(\r\x12\x0c\n\x04\x62ody\x18\n \x01(\t\x12\x0f\n\x07\x63reated\x18\x0b \x01(\r\"\xfa\x03\n\rAdNetworkList\x12\x33\n\x0b\x61\x64_networks\x18\x01 \x01(\x0b\x32\x1e.manga.AdNetworkList.AdNetwork\x1a\xb3\x03\n\tAdNetwork\x12\x39\n\x08\x66\x61\x63\x65\x62ook\x18\x01 \x01(\x0b\x32\'.manga.AdNetworkList.AdNetwork.Facebook\x12\x33\n\x05\x61\x64mob\x18\x02 \x01(\x0b\x32$.manga.AdNetworkList.AdNetwork.Admob\x12\x33\n\x05mopub\x18\x03 \x01(\x0b\x32$.manga.AdNetworkList.AdNetwork.Mopub\x12\x37\n\x07\x61\x64sense\x18\x04 \x01(\x0b\x32&.manga.AdNetworkList.AdNetwork.Adsense\x12\x39\n\x08\x61pplovin\x18\x05 \x01(\x0b\x32\'.manga.AdNetworkList.AdNetwork.Applovin\x1a \n\x08\x46\x61\x63\x65\x62ook\x12\x14\n\x0cplacement_id\x18\x01 \x01(\t\x1a\x18\n\x05\x41\x64mob\x12\x0f\n\x07unit_id\x18\x01 \x01(\t\x1a\x18\n\x05Mopub\x12\x0f\n\x07unit_id\x18\x01 \x01(\t\x1a\x1a\n\x07\x41\x64sense\x12\x0f\n\x07unit_id\x18\x01 \x01(\t\x1a\x1b\n\x08\x41pplovin\x12\x0f\n\x07unit_id\x18\x01 \x01(\t\"\xb8\x04\n\x05Popup\x12*\n\nos_default\x18\x01 \x01(\x0b\x32\x16.manga.Popup.OSDefault\x12,\n\x0b\x61pp_default\x18\x02 \x01(\x0b\x32\x17.manga.Popup.AppDefault\x12.\n\x0cmovie_reward\x18\x03 \x01(\x0b\x32\x18.manga.Popup.MovieReward\x1a?\n\x06\x42utton\x12\x0c\n\x04text\x18\x01 \x01(\t\x12\'\n\x06\x61\x63tion\x18\x02 \x01(\x0b\x32\x17.manga.TransitionAction\x1a\xab\x01\n\tOSDefault\x12\x0f\n\x07subject\x18\x01 \x01(\t\x12\x0c\n\x04\x62ody\x18\x02 \x01(\t\x12&\n\tok_button\x18\x03 \x01(\x0b\x32\x13.manga.Popup.Button\x12+\n\x0eneutral_button\x18\x04 \x01(\x0b\x32\x13.manga.Popup.Button\x12*\n\rcancel_button\x18\x05 \x01(\x0b\x32\x13.manga.Popup.Button\x1ag\n\nAppDefault\x12\x0f\n\x07subject\x18\x01 \x01(\t\x12\x0c\n\x04\x62ody\x18\x02 \x01(\t\x12\'\n\x06\x61\x63tion\x18\x03 \x01(\x0b\x32\x17.manga.TransitionAction\x12\x11\n\timage_url\x18\x04 \x01(\t\x1aM\n\x0bMovieReward\x12\x11\n\timage_url\x18\x01 \x01(\t\x12+\n\radvertisement\x18\x02 \x01(\x0b\x32\x14.manga.AdNetworkList\"\x95\x02\n\x08LastPage\x12\'\n\x0f\x63urrent_chapter\x18\x01 \x01(\x0b\x32\x0e.manga.Chapter\x12$\n\x0cnext_chapter\x18\x02 \x01(\x0b\x32\x0e.manga.Chapter\x12$\n\x0ctop_comments\x18\x03 \x03(\x0b\x32\x0e.manga.Comment\x12\x15\n\ris_subscribed\x18\x04 \x01(\x08\x12\x16\n\x0enext_timestamp\x18\x05 \x01(\r\x12\x14\n\x0c\x63hapter_type\x18\x06 \x01(\x05\x12+\n\radvertisement\x18\x07 \x01(\x0b\x32\x14.manga.AdNetworkList\x12\"\n\x0cmovie_reward\x18\x08 \x01(\x0b\x32\x0c.manga.Popup\"c\n\tMangaPage\x12\x11\n\timage_url\x18\x01 \x01(\t\x12\r\n\x05width\x18\x02 \x01(\r\x12\x0e\n\x06height\x18\x03 \x01(\r\x12\x0c\n\x04type\x18\x04 \x01(\x05\x12\x16\n\x0e\x65ncryption_key\x18\x05 \x01(\t\"\xa5\x01\n\x04Page\x12$\n\nmanga_page\x18\x01 \x01(\x0b\x32\x10.manga.MangaPage\x12&\n\x0b\x62\x61nner_list\x18\x02 \x01(\x0b\x32\x11.manga.BannerList\x12\"\n\tlast_page\x18\x03 \x01(\x0b\x32\x0f.manga.LastPage\x12+\n\radvertisement\x18\x04 \x01(\x0b\x32\x14.manga.AdNetworkList\" \n\x03Sns\x12\x0c\n\x04\x62ody\x18\x01 \x01(\t\x12\x0b\n\x03url\x18\x02 \x01(\t\"\x84\x02\n\x0bMangaViewer\x12\x1a\n\x05pages\x18\x01 \x03(\x0b\x32\x0b.manga.Page\x12\x12\n\nchapter_id\x18\x02 \x01(\r\x12 \n\x08\x63hapters\x18\x03 \x03(\x0b\x32\x0e.manga.Chapter\x12\x17\n\x03sns\x18\x04 \x01(\x0b\x32\n.manga.Sns\x12\x12\n\ntitle_name\x18\x05 \x01(\t\x12\x14\n\x0c\x63hapter_name\x18\x06 \x01(\t\x12\x1a\n\x12number_of_comments\x18\x07 \x01(\r\x12\x18\n\x10is_vertical_only\x18\x08 \x01(\x08\x12\x10\n\x08title_id\x18\t \x01(\r\x12\x18\n\x10start_from_right\x18\n \x01(\x08\"\x96\x01\n\x05Title\x12\x10\n\x08title_id\x18\x01 \x01(\r\x12\x0c\n\x04name\x18\x02 \x01(\t\x12\x0e\n\x06\x61uthor\x18\x03 \x01(\t\x12\x1a\n\x12portrait_image_url\x18\x04 \x01(\t\x12\x1b\n\x13landscape_image_url\x18\x05 \x01(\t\x12\x12\n\nview_count\x18\x06 \x01(\r\x12\x10\n\x08language\x18\x07 \x01(\x05\"\xce\x04\n\x0fTitleDetailView\x12\x1b\n\x05title\x18\x01 \x01(\x0b\x32\x0c.manga.Title\x12\x17\n\x0ftitle_image_url\x18\x02 \x01(\t\x12\x10\n\x08overview\x18\x03 \x01(\t\x12\x1c\n\x14\x62\x61\x63kground_image_url\x18\x04 \x01(\t\x12\x16\n\x0enext_timestamp\x18\x05 \x01(\r\x12\x15\n\rupdate_timing\x18\x06 \x01(\x05\x12\"\n\x1aviewing_period_description\x18\x07 \x01(\t\x12\x1b\n\x13non_appearance_info\x18\x08 \x01(\t\x12*\n\x12\x66irst_chapter_list\x18\t \x03(\x0b\x32\x0e.manga.Chapter\x12)\n\x11last_chapter_list\x18\n \x03(\x0b\x32\x0e.manga.Chapter\x12\x1e\n\x07\x62\x61nners\x18\x0b \x03(\x0b\x32\r.manga.Banner\x12,\n\x16recommended_title_list\x18\x0c \x03(\x0b\x32\x0c.manga.Title\x12\x17\n\x03sns\x18\r \x01(\x0b\x32\n.manga.Sns\x12\x19\n\x11is_simul_released\x18\x0e \x01(\x08\x12\x15\n\ris_subscribed\x18\x0f \x01(\x08\x12\x0e\n\x06rating\x18\x10 \x01(\x05\x12\x1b\n\x13\x63hapters_descending\x18\x11 \x01(\x08\x12\x17\n\x0fnumber_of_views\x18\x12 \x01(\r\x12/\n\x12\x63hapter_list_group\x18\x1c \x03(\x0b\x32\x13.manga.ChapterGroup\"l\n\rSuccessResult\x12\x31\n\x11title_detail_view\x18\x08 \x01(\x0b\x32\x16.manga.TitleDetailView\x12(\n\x0cmanga_viewer\x18\n \x01(\x0b\x32\x12.manga.MangaViewer\"1\n\x08Response\x12%\n\x07success\x18\x01 \x01(\x0b\x32\x14.manga.SuccessResultb\x06proto3' -) - - - - -_BANNER = _descriptor.Descriptor( - name='Banner', - full_name='manga.Banner', - filename=None, - file=DESCRIPTOR, - containing_type=None, - create_key=_descriptor._internal_create_key, - fields=[ - _descriptor.FieldDescriptor( - name='image_url', full_name='manga.Banner.image_url', index=0, - number=1, type=9, cpp_type=9, label=1, - has_default_value=False, default_value=b"".decode('utf-8'), - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='action', full_name='manga.Banner.action', index=1, - number=2, type=11, cpp_type=10, label=1, - has_default_value=False, default_value=None, - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='id', full_name='manga.Banner.id', index=2, - number=3, type=13, cpp_type=3, label=1, - has_default_value=False, default_value=0, - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - ], - extensions=[ - ], - nested_types=[], - enum_types=[ - ], - serialized_options=None, - is_extendable=False, - syntax='proto3', - extension_ranges=[], - oneofs=[ - ], - serialized_start=25, - serialized_end=105, -) - - -_BANNERLIST = _descriptor.Descriptor( - name='BannerList', - full_name='manga.BannerList', - filename=None, - file=DESCRIPTOR, - containing_type=None, - create_key=_descriptor._internal_create_key, - fields=[ - _descriptor.FieldDescriptor( - name='banner_title', full_name='manga.BannerList.banner_title', index=0, - number=1, type=9, cpp_type=9, label=1, - has_default_value=False, default_value=b"".decode('utf-8'), - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='banners', full_name='manga.BannerList.banners', index=1, - number=2, type=11, cpp_type=10, label=3, - has_default_value=False, default_value=[], - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - ], - extensions=[ - ], - nested_types=[], - enum_types=[ - ], - serialized_options=None, - is_extendable=False, - syntax='proto3', - extension_ranges=[], - oneofs=[ - ], - serialized_start=107, - serialized_end=173, -) - - -_TRANSITIONACTION = _descriptor.Descriptor( - name='TransitionAction', - full_name='manga.TransitionAction', - filename=None, - file=DESCRIPTOR, - containing_type=None, - create_key=_descriptor._internal_create_key, - fields=[ - _descriptor.FieldDescriptor( - name='method', full_name='manga.TransitionAction.method', index=0, - number=1, type=5, cpp_type=1, label=1, - has_default_value=False, default_value=0, - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='url', full_name='manga.TransitionAction.url', index=1, - number=2, type=9, cpp_type=9, label=1, - has_default_value=False, default_value=b"".decode('utf-8'), - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - ], - extensions=[ - ], - nested_types=[], - enum_types=[ - ], - serialized_options=None, - is_extendable=False, - syntax='proto3', - extension_ranges=[], - oneofs=[ - ], - serialized_start=175, - serialized_end=222, -) - - -_CHAPTER = _descriptor.Descriptor( - name='Chapter', - full_name='manga.Chapter', - filename=None, - file=DESCRIPTOR, - containing_type=None, - create_key=_descriptor._internal_create_key, - fields=[ - _descriptor.FieldDescriptor( - name='title_id', full_name='manga.Chapter.title_id', index=0, - number=1, type=13, cpp_type=3, label=1, - has_default_value=False, default_value=0, - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='chapter_id', full_name='manga.Chapter.chapter_id', index=1, - number=2, type=13, cpp_type=3, label=1, - has_default_value=False, default_value=0, - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='name', full_name='manga.Chapter.name', index=2, - number=3, type=9, cpp_type=9, label=1, - has_default_value=False, default_value=b"".decode('utf-8'), - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='sub_title', full_name='manga.Chapter.sub_title', index=3, - number=4, type=9, cpp_type=9, label=1, - has_default_value=False, default_value=b"".decode('utf-8'), - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='thumbnail_url', full_name='manga.Chapter.thumbnail_url', index=4, - number=5, type=9, cpp_type=9, label=1, - has_default_value=False, default_value=b"".decode('utf-8'), - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='start_timestamp', full_name='manga.Chapter.start_timestamp', index=5, - number=6, type=13, cpp_type=3, label=1, - has_default_value=False, default_value=0, - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='end_timestamp', full_name='manga.Chapter.end_timestamp', index=6, - number=7, type=13, cpp_type=3, label=1, - has_default_value=False, default_value=0, - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='already_viewed', full_name='manga.Chapter.already_viewed', index=7, - number=8, type=8, cpp_type=7, label=1, - has_default_value=False, default_value=False, - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='is_vertical_only', full_name='manga.Chapter.is_vertical_only', index=8, - number=9, type=8, cpp_type=7, label=1, - has_default_value=False, default_value=False, - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - ], - extensions=[ - ], - nested_types=[], - enum_types=[ - ], - serialized_options=None, - is_extendable=False, - syntax='proto3', - extension_ranges=[], - oneofs=[ - ], - serialized_start=225, - serialized_end=426, -) - - -_CHAPTERGROUP = _descriptor.Descriptor( - name='ChapterGroup', - full_name='manga.ChapterGroup', - filename=None, - file=DESCRIPTOR, - containing_type=None, - create_key=_descriptor._internal_create_key, - fields=[ - _descriptor.FieldDescriptor( - name='chapter_numbers', full_name='manga.ChapterGroup.chapter_numbers', index=0, - number=1, type=9, cpp_type=9, label=1, - has_default_value=False, default_value=b"".decode('utf-8'), - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='first_chapter_list', full_name='manga.ChapterGroup.first_chapter_list', index=1, - number=2, type=11, cpp_type=10, label=3, - has_default_value=False, default_value=[], - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='mid_chapter_list', full_name='manga.ChapterGroup.mid_chapter_list', index=2, - number=3, type=11, cpp_type=10, label=3, - has_default_value=False, default_value=[], - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='last_chapter_list', full_name='manga.ChapterGroup.last_chapter_list', index=3, - number=4, type=11, cpp_type=10, label=3, - has_default_value=False, default_value=[], - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - ], - extensions=[ - ], - nested_types=[], - enum_types=[ - ], - serialized_options=None, - is_extendable=False, - syntax='proto3', - extension_ranges=[], - oneofs=[ - ], - serialized_start=429, - serialized_end=597, -) - - -_COMMENT = _descriptor.Descriptor( - name='Comment', - full_name='manga.Comment', - filename=None, - file=DESCRIPTOR, - containing_type=None, - create_key=_descriptor._internal_create_key, - fields=[ - _descriptor.FieldDescriptor( - name='id', full_name='manga.Comment.id', index=0, - number=1, type=13, cpp_type=3, label=1, - has_default_value=False, default_value=0, - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='index', full_name='manga.Comment.index', index=1, - number=2, type=13, cpp_type=3, label=1, - has_default_value=False, default_value=0, - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='user_name', full_name='manga.Comment.user_name', index=2, - number=3, type=9, cpp_type=9, label=1, - has_default_value=False, default_value=b"".decode('utf-8'), - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='icon_url', full_name='manga.Comment.icon_url', index=3, - number=4, type=9, cpp_type=9, label=1, - has_default_value=False, default_value=b"".decode('utf-8'), - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='is_my_comment', full_name='manga.Comment.is_my_comment', index=4, - number=6, type=8, cpp_type=7, label=1, - has_default_value=False, default_value=False, - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='already_liked', full_name='manga.Comment.already_liked', index=5, - number=7, type=8, cpp_type=7, label=1, - has_default_value=False, default_value=False, - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='number_of_likes', full_name='manga.Comment.number_of_likes', index=6, - number=9, type=13, cpp_type=3, label=1, - has_default_value=False, default_value=0, - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='body', full_name='manga.Comment.body', index=7, - number=10, type=9, cpp_type=9, label=1, - has_default_value=False, default_value=b"".decode('utf-8'), - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='created', full_name='manga.Comment.created', index=8, - number=11, type=13, cpp_type=3, label=1, - has_default_value=False, default_value=0, - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - ], - extensions=[ - ], - nested_types=[], - enum_types=[ - ], - serialized_options=None, - is_extendable=False, - syntax='proto3', - extension_ranges=[], - oneofs=[ - ], - serialized_start=600, - serialized_end=775, -) - - -_ADNETWORKLIST_ADNETWORK_FACEBOOK = _descriptor.Descriptor( - name='Facebook', - full_name='manga.AdNetworkList.AdNetwork.Facebook', - filename=None, - file=DESCRIPTOR, - containing_type=None, - create_key=_descriptor._internal_create_key, - fields=[ - _descriptor.FieldDescriptor( - name='placement_id', full_name='manga.AdNetworkList.AdNetwork.Facebook.placement_id', index=0, - number=1, type=9, cpp_type=9, label=1, - has_default_value=False, default_value=b"".decode('utf-8'), - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - ], - extensions=[ - ], - nested_types=[], - enum_types=[ - ], - serialized_options=None, - is_extendable=False, - syntax='proto3', - extension_ranges=[], - oneofs=[ - ], - serialized_start=1143, - serialized_end=1175, -) - -_ADNETWORKLIST_ADNETWORK_ADMOB = _descriptor.Descriptor( - name='Admob', - full_name='manga.AdNetworkList.AdNetwork.Admob', - filename=None, - file=DESCRIPTOR, - containing_type=None, - create_key=_descriptor._internal_create_key, - fields=[ - _descriptor.FieldDescriptor( - name='unit_id', full_name='manga.AdNetworkList.AdNetwork.Admob.unit_id', index=0, - number=1, type=9, cpp_type=9, label=1, - has_default_value=False, default_value=b"".decode('utf-8'), - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - ], - extensions=[ - ], - nested_types=[], - enum_types=[ - ], - serialized_options=None, - is_extendable=False, - syntax='proto3', - extension_ranges=[], - oneofs=[ - ], - serialized_start=1177, - serialized_end=1201, -) - -_ADNETWORKLIST_ADNETWORK_MOPUB = _descriptor.Descriptor( - name='Mopub', - full_name='manga.AdNetworkList.AdNetwork.Mopub', - filename=None, - file=DESCRIPTOR, - containing_type=None, - create_key=_descriptor._internal_create_key, - fields=[ - _descriptor.FieldDescriptor( - name='unit_id', full_name='manga.AdNetworkList.AdNetwork.Mopub.unit_id', index=0, - number=1, type=9, cpp_type=9, label=1, - has_default_value=False, default_value=b"".decode('utf-8'), - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - ], - extensions=[ - ], - nested_types=[], - enum_types=[ - ], - serialized_options=None, - is_extendable=False, - syntax='proto3', - extension_ranges=[], - oneofs=[ - ], - serialized_start=1203, - serialized_end=1227, -) - -_ADNETWORKLIST_ADNETWORK_ADSENSE = _descriptor.Descriptor( - name='Adsense', - full_name='manga.AdNetworkList.AdNetwork.Adsense', - filename=None, - file=DESCRIPTOR, - containing_type=None, - create_key=_descriptor._internal_create_key, - fields=[ - _descriptor.FieldDescriptor( - name='unit_id', full_name='manga.AdNetworkList.AdNetwork.Adsense.unit_id', index=0, - number=1, type=9, cpp_type=9, label=1, - has_default_value=False, default_value=b"".decode('utf-8'), - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - ], - extensions=[ - ], - nested_types=[], - enum_types=[ - ], - serialized_options=None, - is_extendable=False, - syntax='proto3', - extension_ranges=[], - oneofs=[ - ], - serialized_start=1229, - serialized_end=1255, -) - -_ADNETWORKLIST_ADNETWORK_APPLOVIN = _descriptor.Descriptor( - name='Applovin', - full_name='manga.AdNetworkList.AdNetwork.Applovin', - filename=None, - file=DESCRIPTOR, - containing_type=None, - create_key=_descriptor._internal_create_key, - fields=[ - _descriptor.FieldDescriptor( - name='unit_id', full_name='manga.AdNetworkList.AdNetwork.Applovin.unit_id', index=0, - number=1, type=9, cpp_type=9, label=1, - has_default_value=False, default_value=b"".decode('utf-8'), - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - ], - extensions=[ - ], - nested_types=[], - enum_types=[ - ], - serialized_options=None, - is_extendable=False, - syntax='proto3', - extension_ranges=[], - oneofs=[ - ], - serialized_start=1257, - serialized_end=1284, -) - -_ADNETWORKLIST_ADNETWORK = _descriptor.Descriptor( - name='AdNetwork', - full_name='manga.AdNetworkList.AdNetwork', - filename=None, - file=DESCRIPTOR, - containing_type=None, - create_key=_descriptor._internal_create_key, - fields=[ - _descriptor.FieldDescriptor( - name='facebook', full_name='manga.AdNetworkList.AdNetwork.facebook', index=0, - number=1, type=11, cpp_type=10, label=1, - has_default_value=False, default_value=None, - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='admob', full_name='manga.AdNetworkList.AdNetwork.admob', index=1, - number=2, type=11, cpp_type=10, label=1, - has_default_value=False, default_value=None, - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='mopub', full_name='manga.AdNetworkList.AdNetwork.mopub', index=2, - number=3, type=11, cpp_type=10, label=1, - has_default_value=False, default_value=None, - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='adsense', full_name='manga.AdNetworkList.AdNetwork.adsense', index=3, - number=4, type=11, cpp_type=10, label=1, - has_default_value=False, default_value=None, - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='applovin', full_name='manga.AdNetworkList.AdNetwork.applovin', index=4, - number=5, type=11, cpp_type=10, label=1, - has_default_value=False, default_value=None, - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - ], - extensions=[ - ], - nested_types=[_ADNETWORKLIST_ADNETWORK_FACEBOOK, _ADNETWORKLIST_ADNETWORK_ADMOB, _ADNETWORKLIST_ADNETWORK_MOPUB, _ADNETWORKLIST_ADNETWORK_ADSENSE, _ADNETWORKLIST_ADNETWORK_APPLOVIN, ], - enum_types=[ - ], - serialized_options=None, - is_extendable=False, - syntax='proto3', - extension_ranges=[], - oneofs=[ - ], - serialized_start=849, - serialized_end=1284, -) - -_ADNETWORKLIST = _descriptor.Descriptor( - name='AdNetworkList', - full_name='manga.AdNetworkList', - filename=None, - file=DESCRIPTOR, - containing_type=None, - create_key=_descriptor._internal_create_key, - fields=[ - _descriptor.FieldDescriptor( - name='ad_networks', full_name='manga.AdNetworkList.ad_networks', index=0, - number=1, type=11, cpp_type=10, label=1, - has_default_value=False, default_value=None, - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - ], - extensions=[ - ], - nested_types=[_ADNETWORKLIST_ADNETWORK, ], - enum_types=[ - ], - serialized_options=None, - is_extendable=False, - syntax='proto3', - extension_ranges=[], - oneofs=[ - ], - serialized_start=778, - serialized_end=1284, -) - - -_POPUP_BUTTON = _descriptor.Descriptor( - name='Button', - full_name='manga.Popup.Button', - filename=None, - file=DESCRIPTOR, - containing_type=None, - create_key=_descriptor._internal_create_key, - fields=[ - _descriptor.FieldDescriptor( - name='text', full_name='manga.Popup.Button.text', index=0, - number=1, type=9, cpp_type=9, label=1, - has_default_value=False, default_value=b"".decode('utf-8'), - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='action', full_name='manga.Popup.Button.action', index=1, - number=2, type=11, cpp_type=10, label=1, - has_default_value=False, default_value=None, - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - ], - extensions=[ - ], - nested_types=[], - enum_types=[ - ], - serialized_options=None, - is_extendable=False, - syntax='proto3', - extension_ranges=[], - oneofs=[ - ], - serialized_start=1434, - serialized_end=1497, -) - -_POPUP_OSDEFAULT = _descriptor.Descriptor( - name='OSDefault', - full_name='manga.Popup.OSDefault', - filename=None, - file=DESCRIPTOR, - containing_type=None, - create_key=_descriptor._internal_create_key, - fields=[ - _descriptor.FieldDescriptor( - name='subject', full_name='manga.Popup.OSDefault.subject', index=0, - number=1, type=9, cpp_type=9, label=1, - has_default_value=False, default_value=b"".decode('utf-8'), - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='body', full_name='manga.Popup.OSDefault.body', index=1, - number=2, type=9, cpp_type=9, label=1, - has_default_value=False, default_value=b"".decode('utf-8'), - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='ok_button', full_name='manga.Popup.OSDefault.ok_button', index=2, - number=3, type=11, cpp_type=10, label=1, - has_default_value=False, default_value=None, - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='neutral_button', full_name='manga.Popup.OSDefault.neutral_button', index=3, - number=4, type=11, cpp_type=10, label=1, - has_default_value=False, default_value=None, - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='cancel_button', full_name='manga.Popup.OSDefault.cancel_button', index=4, - number=5, type=11, cpp_type=10, label=1, - has_default_value=False, default_value=None, - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - ], - extensions=[ - ], - nested_types=[], - enum_types=[ - ], - serialized_options=None, - is_extendable=False, - syntax='proto3', - extension_ranges=[], - oneofs=[ - ], - serialized_start=1500, - serialized_end=1671, -) - -_POPUP_APPDEFAULT = _descriptor.Descriptor( - name='AppDefault', - full_name='manga.Popup.AppDefault', - filename=None, - file=DESCRIPTOR, - containing_type=None, - create_key=_descriptor._internal_create_key, - fields=[ - _descriptor.FieldDescriptor( - name='subject', full_name='manga.Popup.AppDefault.subject', index=0, - number=1, type=9, cpp_type=9, label=1, - has_default_value=False, default_value=b"".decode('utf-8'), - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='body', full_name='manga.Popup.AppDefault.body', index=1, - number=2, type=9, cpp_type=9, label=1, - has_default_value=False, default_value=b"".decode('utf-8'), - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='action', full_name='manga.Popup.AppDefault.action', index=2, - number=3, type=11, cpp_type=10, label=1, - has_default_value=False, default_value=None, - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='image_url', full_name='manga.Popup.AppDefault.image_url', index=3, - number=4, type=9, cpp_type=9, label=1, - has_default_value=False, default_value=b"".decode('utf-8'), - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - ], - extensions=[ - ], - nested_types=[], - enum_types=[ - ], - serialized_options=None, - is_extendable=False, - syntax='proto3', - extension_ranges=[], - oneofs=[ - ], - serialized_start=1673, - serialized_end=1776, -) - -_POPUP_MOVIEREWARD = _descriptor.Descriptor( - name='MovieReward', - full_name='manga.Popup.MovieReward', - filename=None, - file=DESCRIPTOR, - containing_type=None, - create_key=_descriptor._internal_create_key, - fields=[ - _descriptor.FieldDescriptor( - name='image_url', full_name='manga.Popup.MovieReward.image_url', index=0, - number=1, type=9, cpp_type=9, label=1, - has_default_value=False, default_value=b"".decode('utf-8'), - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='advertisement', full_name='manga.Popup.MovieReward.advertisement', index=1, - number=2, type=11, cpp_type=10, label=1, - has_default_value=False, default_value=None, - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - ], - extensions=[ - ], - nested_types=[], - enum_types=[ - ], - serialized_options=None, - is_extendable=False, - syntax='proto3', - extension_ranges=[], - oneofs=[ - ], - serialized_start=1778, - serialized_end=1855, -) - -_POPUP = _descriptor.Descriptor( - name='Popup', - full_name='manga.Popup', - filename=None, - file=DESCRIPTOR, - containing_type=None, - create_key=_descriptor._internal_create_key, - fields=[ - _descriptor.FieldDescriptor( - name='os_default', full_name='manga.Popup.os_default', index=0, - number=1, type=11, cpp_type=10, label=1, - has_default_value=False, default_value=None, - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='app_default', full_name='manga.Popup.app_default', index=1, - number=2, type=11, cpp_type=10, label=1, - has_default_value=False, default_value=None, - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='movie_reward', full_name='manga.Popup.movie_reward', index=2, - number=3, type=11, cpp_type=10, label=1, - has_default_value=False, default_value=None, - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - ], - extensions=[ - ], - nested_types=[_POPUP_BUTTON, _POPUP_OSDEFAULT, _POPUP_APPDEFAULT, _POPUP_MOVIEREWARD, ], - enum_types=[ - ], - serialized_options=None, - is_extendable=False, - syntax='proto3', - extension_ranges=[], - oneofs=[ - ], - serialized_start=1287, - serialized_end=1855, -) - - -_LASTPAGE = _descriptor.Descriptor( - name='LastPage', - full_name='manga.LastPage', - filename=None, - file=DESCRIPTOR, - containing_type=None, - create_key=_descriptor._internal_create_key, - fields=[ - _descriptor.FieldDescriptor( - name='current_chapter', full_name='manga.LastPage.current_chapter', index=0, - number=1, type=11, cpp_type=10, label=1, - has_default_value=False, default_value=None, - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='next_chapter', full_name='manga.LastPage.next_chapter', index=1, - number=2, type=11, cpp_type=10, label=1, - has_default_value=False, default_value=None, - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='top_comments', full_name='manga.LastPage.top_comments', index=2, - number=3, type=11, cpp_type=10, label=3, - has_default_value=False, default_value=[], - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='is_subscribed', full_name='manga.LastPage.is_subscribed', index=3, - number=4, type=8, cpp_type=7, label=1, - has_default_value=False, default_value=False, - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='next_timestamp', full_name='manga.LastPage.next_timestamp', index=4, - number=5, type=13, cpp_type=3, label=1, - has_default_value=False, default_value=0, - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='chapter_type', full_name='manga.LastPage.chapter_type', index=5, - number=6, type=5, cpp_type=1, label=1, - has_default_value=False, default_value=0, - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='advertisement', full_name='manga.LastPage.advertisement', index=6, - number=7, type=11, cpp_type=10, label=1, - has_default_value=False, default_value=None, - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='movie_reward', full_name='manga.LastPage.movie_reward', index=7, - number=8, type=11, cpp_type=10, label=1, - has_default_value=False, default_value=None, - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - ], - extensions=[ - ], - nested_types=[], - enum_types=[ - ], - serialized_options=None, - is_extendable=False, - syntax='proto3', - extension_ranges=[], - oneofs=[ - ], - serialized_start=1858, - serialized_end=2135, -) - - -_MANGAPAGE = _descriptor.Descriptor( - name='MangaPage', - full_name='manga.MangaPage', - filename=None, - file=DESCRIPTOR, - containing_type=None, - create_key=_descriptor._internal_create_key, - fields=[ - _descriptor.FieldDescriptor( - name='image_url', full_name='manga.MangaPage.image_url', index=0, - number=1, type=9, cpp_type=9, label=1, - has_default_value=False, default_value=b"".decode('utf-8'), - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='width', full_name='manga.MangaPage.width', index=1, - number=2, type=13, cpp_type=3, label=1, - has_default_value=False, default_value=0, - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='height', full_name='manga.MangaPage.height', index=2, - number=3, type=13, cpp_type=3, label=1, - has_default_value=False, default_value=0, - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='type', full_name='manga.MangaPage.type', index=3, - number=4, type=5, cpp_type=1, label=1, - has_default_value=False, default_value=0, - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='encryption_key', full_name='manga.MangaPage.encryption_key', index=4, - number=5, type=9, cpp_type=9, label=1, - has_default_value=False, default_value=b"".decode('utf-8'), - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - ], - extensions=[ - ], - nested_types=[], - enum_types=[ - ], - serialized_options=None, - is_extendable=False, - syntax='proto3', - extension_ranges=[], - oneofs=[ - ], - serialized_start=2137, - serialized_end=2236, -) - - -_PAGE = _descriptor.Descriptor( - name='Page', - full_name='manga.Page', - filename=None, - file=DESCRIPTOR, - containing_type=None, - create_key=_descriptor._internal_create_key, - fields=[ - _descriptor.FieldDescriptor( - name='manga_page', full_name='manga.Page.manga_page', index=0, - number=1, type=11, cpp_type=10, label=1, - has_default_value=False, default_value=None, - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='banner_list', full_name='manga.Page.banner_list', index=1, - number=2, type=11, cpp_type=10, label=1, - has_default_value=False, default_value=None, - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='last_page', full_name='manga.Page.last_page', index=2, - number=3, type=11, cpp_type=10, label=1, - has_default_value=False, default_value=None, - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='advertisement', full_name='manga.Page.advertisement', index=3, - number=4, type=11, cpp_type=10, label=1, - has_default_value=False, default_value=None, - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - ], - extensions=[ - ], - nested_types=[], - enum_types=[ - ], - serialized_options=None, - is_extendable=False, - syntax='proto3', - extension_ranges=[], - oneofs=[ - ], - serialized_start=2239, - serialized_end=2404, -) - - -_SNS = _descriptor.Descriptor( - name='Sns', - full_name='manga.Sns', - filename=None, - file=DESCRIPTOR, - containing_type=None, - create_key=_descriptor._internal_create_key, - fields=[ - _descriptor.FieldDescriptor( - name='body', full_name='manga.Sns.body', index=0, - number=1, type=9, cpp_type=9, label=1, - has_default_value=False, default_value=b"".decode('utf-8'), - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='url', full_name='manga.Sns.url', index=1, - number=2, type=9, cpp_type=9, label=1, - has_default_value=False, default_value=b"".decode('utf-8'), - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - ], - extensions=[ - ], - nested_types=[], - enum_types=[ - ], - serialized_options=None, - is_extendable=False, - syntax='proto3', - extension_ranges=[], - oneofs=[ - ], - serialized_start=2406, - serialized_end=2438, -) - - -_MANGAVIEWER = _descriptor.Descriptor( - name='MangaViewer', - full_name='manga.MangaViewer', - filename=None, - file=DESCRIPTOR, - containing_type=None, - create_key=_descriptor._internal_create_key, - fields=[ - _descriptor.FieldDescriptor( - name='pages', full_name='manga.MangaViewer.pages', index=0, - number=1, type=11, cpp_type=10, label=3, - has_default_value=False, default_value=[], - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='chapter_id', full_name='manga.MangaViewer.chapter_id', index=1, - number=2, type=13, cpp_type=3, label=1, - has_default_value=False, default_value=0, - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='chapters', full_name='manga.MangaViewer.chapters', index=2, - number=3, type=11, cpp_type=10, label=3, - has_default_value=False, default_value=[], - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='sns', full_name='manga.MangaViewer.sns', index=3, - number=4, type=11, cpp_type=10, label=1, - has_default_value=False, default_value=None, - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='title_name', full_name='manga.MangaViewer.title_name', index=4, - number=5, type=9, cpp_type=9, label=1, - has_default_value=False, default_value=b"".decode('utf-8'), - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='chapter_name', full_name='manga.MangaViewer.chapter_name', index=5, - number=6, type=9, cpp_type=9, label=1, - has_default_value=False, default_value=b"".decode('utf-8'), - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='number_of_comments', full_name='manga.MangaViewer.number_of_comments', index=6, - number=7, type=13, cpp_type=3, label=1, - has_default_value=False, default_value=0, - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='is_vertical_only', full_name='manga.MangaViewer.is_vertical_only', index=7, - number=8, type=8, cpp_type=7, label=1, - has_default_value=False, default_value=False, - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='title_id', full_name='manga.MangaViewer.title_id', index=8, - number=9, type=13, cpp_type=3, label=1, - has_default_value=False, default_value=0, - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='start_from_right', full_name='manga.MangaViewer.start_from_right', index=9, - number=10, type=8, cpp_type=7, label=1, - has_default_value=False, default_value=False, - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - ], - extensions=[ - ], - nested_types=[], - enum_types=[ - ], - serialized_options=None, - is_extendable=False, - syntax='proto3', - extension_ranges=[], - oneofs=[ - ], - serialized_start=2441, - serialized_end=2701, -) - - -_TITLE = _descriptor.Descriptor( - name='Title', - full_name='manga.Title', - filename=None, - file=DESCRIPTOR, - containing_type=None, - create_key=_descriptor._internal_create_key, - fields=[ - _descriptor.FieldDescriptor( - name='title_id', full_name='manga.Title.title_id', index=0, - number=1, type=13, cpp_type=3, label=1, - has_default_value=False, default_value=0, - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='name', full_name='manga.Title.name', index=1, - number=2, type=9, cpp_type=9, label=1, - has_default_value=False, default_value=b"".decode('utf-8'), - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='author', full_name='manga.Title.author', index=2, - number=3, type=9, cpp_type=9, label=1, - has_default_value=False, default_value=b"".decode('utf-8'), - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='portrait_image_url', full_name='manga.Title.portrait_image_url', index=3, - number=4, type=9, cpp_type=9, label=1, - has_default_value=False, default_value=b"".decode('utf-8'), - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='landscape_image_url', full_name='manga.Title.landscape_image_url', index=4, - number=5, type=9, cpp_type=9, label=1, - has_default_value=False, default_value=b"".decode('utf-8'), - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='view_count', full_name='manga.Title.view_count', index=5, - number=6, type=13, cpp_type=3, label=1, - has_default_value=False, default_value=0, - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='language', full_name='manga.Title.language', index=6, - number=7, type=5, cpp_type=1, label=1, - has_default_value=False, default_value=0, - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - ], - extensions=[ - ], - nested_types=[], - enum_types=[ - ], - serialized_options=None, - is_extendable=False, - syntax='proto3', - extension_ranges=[], - oneofs=[ - ], - serialized_start=2704, - serialized_end=2854, -) - - -_TITLEDETAILVIEW = _descriptor.Descriptor( - name='TitleDetailView', - full_name='manga.TitleDetailView', - filename=None, - file=DESCRIPTOR, - containing_type=None, - create_key=_descriptor._internal_create_key, - fields=[ - _descriptor.FieldDescriptor( - name='title', full_name='manga.TitleDetailView.title', index=0, - number=1, type=11, cpp_type=10, label=1, - has_default_value=False, default_value=None, - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='title_image_url', full_name='manga.TitleDetailView.title_image_url', index=1, - number=2, type=9, cpp_type=9, label=1, - has_default_value=False, default_value=b"".decode('utf-8'), - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='overview', full_name='manga.TitleDetailView.overview', index=2, - number=3, type=9, cpp_type=9, label=1, - has_default_value=False, default_value=b"".decode('utf-8'), - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='background_image_url', full_name='manga.TitleDetailView.background_image_url', index=3, - number=4, type=9, cpp_type=9, label=1, - has_default_value=False, default_value=b"".decode('utf-8'), - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='next_timestamp', full_name='manga.TitleDetailView.next_timestamp', index=4, - number=5, type=13, cpp_type=3, label=1, - has_default_value=False, default_value=0, - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='update_timing', full_name='manga.TitleDetailView.update_timing', index=5, - number=6, type=5, cpp_type=1, label=1, - has_default_value=False, default_value=0, - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='viewing_period_description', full_name='manga.TitleDetailView.viewing_period_description', index=6, - number=7, type=9, cpp_type=9, label=1, - has_default_value=False, default_value=b"".decode('utf-8'), - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='non_appearance_info', full_name='manga.TitleDetailView.non_appearance_info', index=7, - number=8, type=9, cpp_type=9, label=1, - has_default_value=False, default_value=b"".decode('utf-8'), - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='first_chapter_list', full_name='manga.TitleDetailView.first_chapter_list', index=8, - number=9, type=11, cpp_type=10, label=3, - has_default_value=False, default_value=[], - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='last_chapter_list', full_name='manga.TitleDetailView.last_chapter_list', index=9, - number=10, type=11, cpp_type=10, label=3, - has_default_value=False, default_value=[], - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='banners', full_name='manga.TitleDetailView.banners', index=10, - number=11, type=11, cpp_type=10, label=3, - has_default_value=False, default_value=[], - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='recommended_title_list', full_name='manga.TitleDetailView.recommended_title_list', index=11, - number=12, type=11, cpp_type=10, label=3, - has_default_value=False, default_value=[], - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='sns', full_name='manga.TitleDetailView.sns', index=12, - number=13, type=11, cpp_type=10, label=1, - has_default_value=False, default_value=None, - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='is_simul_released', full_name='manga.TitleDetailView.is_simul_released', index=13, - number=14, type=8, cpp_type=7, label=1, - has_default_value=False, default_value=False, - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='is_subscribed', full_name='manga.TitleDetailView.is_subscribed', index=14, - number=15, type=8, cpp_type=7, label=1, - has_default_value=False, default_value=False, - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='rating', full_name='manga.TitleDetailView.rating', index=15, - number=16, type=5, cpp_type=1, label=1, - has_default_value=False, default_value=0, - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='chapters_descending', full_name='manga.TitleDetailView.chapters_descending', index=16, - number=17, type=8, cpp_type=7, label=1, - has_default_value=False, default_value=False, - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='number_of_views', full_name='manga.TitleDetailView.number_of_views', index=17, - number=18, type=13, cpp_type=3, label=1, - has_default_value=False, default_value=0, - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='chapter_list_group', full_name='manga.TitleDetailView.chapter_list_group', index=18, - number=28, type=11, cpp_type=10, label=3, - has_default_value=False, default_value=[], - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - ], - extensions=[ - ], - nested_types=[], - enum_types=[ - ], - serialized_options=None, - is_extendable=False, - syntax='proto3', - extension_ranges=[], - oneofs=[ - ], - serialized_start=2857, - serialized_end=3447, -) - - -_SUCCESSRESULT = _descriptor.Descriptor( - name='SuccessResult', - full_name='manga.SuccessResult', - filename=None, - file=DESCRIPTOR, - containing_type=None, - create_key=_descriptor._internal_create_key, - fields=[ - _descriptor.FieldDescriptor( - name='title_detail_view', full_name='manga.SuccessResult.title_detail_view', index=0, - number=8, type=11, cpp_type=10, label=1, - has_default_value=False, default_value=None, - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - _descriptor.FieldDescriptor( - name='manga_viewer', full_name='manga.SuccessResult.manga_viewer', index=1, - number=10, type=11, cpp_type=10, label=1, - has_default_value=False, default_value=None, - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - ], - extensions=[ - ], - nested_types=[], - enum_types=[ - ], - serialized_options=None, - is_extendable=False, - syntax='proto3', - extension_ranges=[], - oneofs=[ - ], - serialized_start=3449, - serialized_end=3557, -) - - -_RESPONSE = _descriptor.Descriptor( - name='Response', - full_name='manga.Response', - filename=None, - file=DESCRIPTOR, - containing_type=None, - create_key=_descriptor._internal_create_key, - fields=[ - _descriptor.FieldDescriptor( - name='success', full_name='manga.Response.success', index=0, - number=1, type=11, cpp_type=10, label=1, - has_default_value=False, default_value=None, - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), - ], - extensions=[ - ], - nested_types=[], - enum_types=[ - ], - serialized_options=None, - is_extendable=False, - syntax='proto3', - extension_ranges=[], - oneofs=[ - ], - serialized_start=3559, - serialized_end=3608, -) - -_BANNER.fields_by_name['action'].message_type = _TRANSITIONACTION -_BANNERLIST.fields_by_name['banners'].message_type = _BANNER -_CHAPTERGROUP.fields_by_name['first_chapter_list'].message_type = _CHAPTER -_CHAPTERGROUP.fields_by_name['mid_chapter_list'].message_type = _CHAPTER -_CHAPTERGROUP.fields_by_name['last_chapter_list'].message_type = _CHAPTER -_ADNETWORKLIST_ADNETWORK_FACEBOOK.containing_type = _ADNETWORKLIST_ADNETWORK -_ADNETWORKLIST_ADNETWORK_ADMOB.containing_type = _ADNETWORKLIST_ADNETWORK -_ADNETWORKLIST_ADNETWORK_MOPUB.containing_type = _ADNETWORKLIST_ADNETWORK -_ADNETWORKLIST_ADNETWORK_ADSENSE.containing_type = _ADNETWORKLIST_ADNETWORK -_ADNETWORKLIST_ADNETWORK_APPLOVIN.containing_type = _ADNETWORKLIST_ADNETWORK -_ADNETWORKLIST_ADNETWORK.fields_by_name['facebook'].message_type = _ADNETWORKLIST_ADNETWORK_FACEBOOK -_ADNETWORKLIST_ADNETWORK.fields_by_name['admob'].message_type = _ADNETWORKLIST_ADNETWORK_ADMOB -_ADNETWORKLIST_ADNETWORK.fields_by_name['mopub'].message_type = _ADNETWORKLIST_ADNETWORK_MOPUB -_ADNETWORKLIST_ADNETWORK.fields_by_name['adsense'].message_type = _ADNETWORKLIST_ADNETWORK_ADSENSE -_ADNETWORKLIST_ADNETWORK.fields_by_name['applovin'].message_type = _ADNETWORKLIST_ADNETWORK_APPLOVIN -_ADNETWORKLIST_ADNETWORK.containing_type = _ADNETWORKLIST -_ADNETWORKLIST.fields_by_name['ad_networks'].message_type = _ADNETWORKLIST_ADNETWORK -_POPUP_BUTTON.fields_by_name['action'].message_type = _TRANSITIONACTION -_POPUP_BUTTON.containing_type = _POPUP -_POPUP_OSDEFAULT.fields_by_name['ok_button'].message_type = _POPUP_BUTTON -_POPUP_OSDEFAULT.fields_by_name['neutral_button'].message_type = _POPUP_BUTTON -_POPUP_OSDEFAULT.fields_by_name['cancel_button'].message_type = _POPUP_BUTTON -_POPUP_OSDEFAULT.containing_type = _POPUP -_POPUP_APPDEFAULT.fields_by_name['action'].message_type = _TRANSITIONACTION -_POPUP_APPDEFAULT.containing_type = _POPUP -_POPUP_MOVIEREWARD.fields_by_name['advertisement'].message_type = _ADNETWORKLIST -_POPUP_MOVIEREWARD.containing_type = _POPUP -_POPUP.fields_by_name['os_default'].message_type = _POPUP_OSDEFAULT -_POPUP.fields_by_name['app_default'].message_type = _POPUP_APPDEFAULT -_POPUP.fields_by_name['movie_reward'].message_type = _POPUP_MOVIEREWARD -_LASTPAGE.fields_by_name['current_chapter'].message_type = _CHAPTER -_LASTPAGE.fields_by_name['next_chapter'].message_type = _CHAPTER -_LASTPAGE.fields_by_name['top_comments'].message_type = _COMMENT -_LASTPAGE.fields_by_name['advertisement'].message_type = _ADNETWORKLIST -_LASTPAGE.fields_by_name['movie_reward'].message_type = _POPUP -_PAGE.fields_by_name['manga_page'].message_type = _MANGAPAGE -_PAGE.fields_by_name['banner_list'].message_type = _BANNERLIST -_PAGE.fields_by_name['last_page'].message_type = _LASTPAGE -_PAGE.fields_by_name['advertisement'].message_type = _ADNETWORKLIST -_MANGAVIEWER.fields_by_name['pages'].message_type = _PAGE -_MANGAVIEWER.fields_by_name['chapters'].message_type = _CHAPTER -_MANGAVIEWER.fields_by_name['sns'].message_type = _SNS -_TITLEDETAILVIEW.fields_by_name['title'].message_type = _TITLE -_TITLEDETAILVIEW.fields_by_name['first_chapter_list'].message_type = _CHAPTER -_TITLEDETAILVIEW.fields_by_name['last_chapter_list'].message_type = _CHAPTER -_TITLEDETAILVIEW.fields_by_name['banners'].message_type = _BANNER -_TITLEDETAILVIEW.fields_by_name['recommended_title_list'].message_type = _TITLE -_TITLEDETAILVIEW.fields_by_name['sns'].message_type = _SNS -_TITLEDETAILVIEW.fields_by_name['chapter_list_group'].message_type = _CHAPTERGROUP -_SUCCESSRESULT.fields_by_name['title_detail_view'].message_type = _TITLEDETAILVIEW -_SUCCESSRESULT.fields_by_name['manga_viewer'].message_type = _MANGAVIEWER -_RESPONSE.fields_by_name['success'].message_type = _SUCCESSRESULT -DESCRIPTOR.message_types_by_name['Banner'] = _BANNER -DESCRIPTOR.message_types_by_name['BannerList'] = _BANNERLIST -DESCRIPTOR.message_types_by_name['TransitionAction'] = _TRANSITIONACTION -DESCRIPTOR.message_types_by_name['Chapter'] = _CHAPTER -DESCRIPTOR.message_types_by_name['ChapterGroup'] = _CHAPTERGROUP -DESCRIPTOR.message_types_by_name['Comment'] = _COMMENT -DESCRIPTOR.message_types_by_name['AdNetworkList'] = _ADNETWORKLIST -DESCRIPTOR.message_types_by_name['Popup'] = _POPUP -DESCRIPTOR.message_types_by_name['LastPage'] = _LASTPAGE -DESCRIPTOR.message_types_by_name['MangaPage'] = _MANGAPAGE -DESCRIPTOR.message_types_by_name['Page'] = _PAGE -DESCRIPTOR.message_types_by_name['Sns'] = _SNS -DESCRIPTOR.message_types_by_name['MangaViewer'] = _MANGAVIEWER -DESCRIPTOR.message_types_by_name['Title'] = _TITLE -DESCRIPTOR.message_types_by_name['TitleDetailView'] = _TITLEDETAILVIEW -DESCRIPTOR.message_types_by_name['SuccessResult'] = _SUCCESSRESULT -DESCRIPTOR.message_types_by_name['Response'] = _RESPONSE -_sym_db.RegisterFileDescriptor(DESCRIPTOR) - -Banner = _reflection.GeneratedProtocolMessageType('Banner', (_message.Message,), { - 'DESCRIPTOR' : _BANNER, - '__module__' : 'response_pb2' - # @@protoc_insertion_point(class_scope:manga.Banner) - }) -_sym_db.RegisterMessage(Banner) - -BannerList = _reflection.GeneratedProtocolMessageType('BannerList', (_message.Message,), { - 'DESCRIPTOR' : _BANNERLIST, - '__module__' : 'response_pb2' - # @@protoc_insertion_point(class_scope:manga.BannerList) - }) -_sym_db.RegisterMessage(BannerList) - -TransitionAction = _reflection.GeneratedProtocolMessageType('TransitionAction', (_message.Message,), { - 'DESCRIPTOR' : _TRANSITIONACTION, - '__module__' : 'response_pb2' - # @@protoc_insertion_point(class_scope:manga.TransitionAction) - }) -_sym_db.RegisterMessage(TransitionAction) - -Chapter = _reflection.GeneratedProtocolMessageType('Chapter', (_message.Message,), { - 'DESCRIPTOR' : _CHAPTER, - '__module__' : 'response_pb2' - # @@protoc_insertion_point(class_scope:manga.Chapter) - }) -_sym_db.RegisterMessage(Chapter) - -ChapterGroup = _reflection.GeneratedProtocolMessageType('ChapterGroup', (_message.Message,), { - 'DESCRIPTOR' : _CHAPTERGROUP, - '__module__' : 'response_pb2' - # @@protoc_insertion_point(class_scope:manga.ChapterGroup) - }) -_sym_db.RegisterMessage(ChapterGroup) - -Comment = _reflection.GeneratedProtocolMessageType('Comment', (_message.Message,), { - 'DESCRIPTOR' : _COMMENT, - '__module__' : 'response_pb2' - # @@protoc_insertion_point(class_scope:manga.Comment) - }) -_sym_db.RegisterMessage(Comment) - -AdNetworkList = _reflection.GeneratedProtocolMessageType('AdNetworkList', (_message.Message,), { - - 'AdNetwork' : _reflection.GeneratedProtocolMessageType('AdNetwork', (_message.Message,), { - - 'Facebook' : _reflection.GeneratedProtocolMessageType('Facebook', (_message.Message,), { - 'DESCRIPTOR' : _ADNETWORKLIST_ADNETWORK_FACEBOOK, - '__module__' : 'response_pb2' - # @@protoc_insertion_point(class_scope:manga.AdNetworkList.AdNetwork.Facebook) - }) - , - - 'Admob' : _reflection.GeneratedProtocolMessageType('Admob', (_message.Message,), { - 'DESCRIPTOR' : _ADNETWORKLIST_ADNETWORK_ADMOB, - '__module__' : 'response_pb2' - # @@protoc_insertion_point(class_scope:manga.AdNetworkList.AdNetwork.Admob) - }) - , - - 'Mopub' : _reflection.GeneratedProtocolMessageType('Mopub', (_message.Message,), { - 'DESCRIPTOR' : _ADNETWORKLIST_ADNETWORK_MOPUB, - '__module__' : 'response_pb2' - # @@protoc_insertion_point(class_scope:manga.AdNetworkList.AdNetwork.Mopub) - }) - , - - 'Adsense' : _reflection.GeneratedProtocolMessageType('Adsense', (_message.Message,), { - 'DESCRIPTOR' : _ADNETWORKLIST_ADNETWORK_ADSENSE, - '__module__' : 'response_pb2' - # @@protoc_insertion_point(class_scope:manga.AdNetworkList.AdNetwork.Adsense) - }) - , - - 'Applovin' : _reflection.GeneratedProtocolMessageType('Applovin', (_message.Message,), { - 'DESCRIPTOR' : _ADNETWORKLIST_ADNETWORK_APPLOVIN, - '__module__' : 'response_pb2' - # @@protoc_insertion_point(class_scope:manga.AdNetworkList.AdNetwork.Applovin) - }) - , - 'DESCRIPTOR' : _ADNETWORKLIST_ADNETWORK, - '__module__' : 'response_pb2' - # @@protoc_insertion_point(class_scope:manga.AdNetworkList.AdNetwork) - }) - , - 'DESCRIPTOR' : _ADNETWORKLIST, - '__module__' : 'response_pb2' - # @@protoc_insertion_point(class_scope:manga.AdNetworkList) - }) -_sym_db.RegisterMessage(AdNetworkList) -_sym_db.RegisterMessage(AdNetworkList.AdNetwork) -_sym_db.RegisterMessage(AdNetworkList.AdNetwork.Facebook) -_sym_db.RegisterMessage(AdNetworkList.AdNetwork.Admob) -_sym_db.RegisterMessage(AdNetworkList.AdNetwork.Mopub) -_sym_db.RegisterMessage(AdNetworkList.AdNetwork.Adsense) -_sym_db.RegisterMessage(AdNetworkList.AdNetwork.Applovin) - -Popup = _reflection.GeneratedProtocolMessageType('Popup', (_message.Message,), { - - 'Button' : _reflection.GeneratedProtocolMessageType('Button', (_message.Message,), { - 'DESCRIPTOR' : _POPUP_BUTTON, - '__module__' : 'response_pb2' - # @@protoc_insertion_point(class_scope:manga.Popup.Button) - }) - , - - 'OSDefault' : _reflection.GeneratedProtocolMessageType('OSDefault', (_message.Message,), { - 'DESCRIPTOR' : _POPUP_OSDEFAULT, - '__module__' : 'response_pb2' - # @@protoc_insertion_point(class_scope:manga.Popup.OSDefault) - }) - , - - 'AppDefault' : _reflection.GeneratedProtocolMessageType('AppDefault', (_message.Message,), { - 'DESCRIPTOR' : _POPUP_APPDEFAULT, - '__module__' : 'response_pb2' - # @@protoc_insertion_point(class_scope:manga.Popup.AppDefault) - }) - , - - 'MovieReward' : _reflection.GeneratedProtocolMessageType('MovieReward', (_message.Message,), { - 'DESCRIPTOR' : _POPUP_MOVIEREWARD, - '__module__' : 'response_pb2' - # @@protoc_insertion_point(class_scope:manga.Popup.MovieReward) - }) - , - 'DESCRIPTOR' : _POPUP, - '__module__' : 'response_pb2' - # @@protoc_insertion_point(class_scope:manga.Popup) - }) -_sym_db.RegisterMessage(Popup) -_sym_db.RegisterMessage(Popup.Button) -_sym_db.RegisterMessage(Popup.OSDefault) -_sym_db.RegisterMessage(Popup.AppDefault) -_sym_db.RegisterMessage(Popup.MovieReward) - -LastPage = _reflection.GeneratedProtocolMessageType('LastPage', (_message.Message,), { - 'DESCRIPTOR' : _LASTPAGE, - '__module__' : 'response_pb2' - # @@protoc_insertion_point(class_scope:manga.LastPage) - }) -_sym_db.RegisterMessage(LastPage) - -MangaPage = _reflection.GeneratedProtocolMessageType('MangaPage', (_message.Message,), { - 'DESCRIPTOR' : _MANGAPAGE, - '__module__' : 'response_pb2' - # @@protoc_insertion_point(class_scope:manga.MangaPage) - }) -_sym_db.RegisterMessage(MangaPage) - -Page = _reflection.GeneratedProtocolMessageType('Page', (_message.Message,), { - 'DESCRIPTOR' : _PAGE, - '__module__' : 'response_pb2' - # @@protoc_insertion_point(class_scope:manga.Page) - }) -_sym_db.RegisterMessage(Page) - -Sns = _reflection.GeneratedProtocolMessageType('Sns', (_message.Message,), { - 'DESCRIPTOR' : _SNS, - '__module__' : 'response_pb2' - # @@protoc_insertion_point(class_scope:manga.Sns) - }) -_sym_db.RegisterMessage(Sns) - -MangaViewer = _reflection.GeneratedProtocolMessageType('MangaViewer', (_message.Message,), { - 'DESCRIPTOR' : _MANGAVIEWER, - '__module__' : 'response_pb2' - # @@protoc_insertion_point(class_scope:manga.MangaViewer) - }) -_sym_db.RegisterMessage(MangaViewer) - -Title = _reflection.GeneratedProtocolMessageType('Title', (_message.Message,), { - 'DESCRIPTOR' : _TITLE, - '__module__' : 'response_pb2' - # @@protoc_insertion_point(class_scope:manga.Title) - }) -_sym_db.RegisterMessage(Title) - -TitleDetailView = _reflection.GeneratedProtocolMessageType('TitleDetailView', (_message.Message,), { - 'DESCRIPTOR' : _TITLEDETAILVIEW, - '__module__' : 'response_pb2' - # @@protoc_insertion_point(class_scope:manga.TitleDetailView) - }) -_sym_db.RegisterMessage(TitleDetailView) - -SuccessResult = _reflection.GeneratedProtocolMessageType('SuccessResult', (_message.Message,), { - 'DESCRIPTOR' : _SUCCESSRESULT, - '__module__' : 'response_pb2' - # @@protoc_insertion_point(class_scope:manga.SuccessResult) - }) -_sym_db.RegisterMessage(SuccessResult) - -Response = _reflection.GeneratedProtocolMessageType('Response', (_message.Message,), { - 'DESCRIPTOR' : _RESPONSE, - '__module__' : 'response_pb2' - # @@protoc_insertion_point(class_scope:manga.Response) - }) -_sym_db.RegisterMessage(Response) - - +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x0eresponse.proto\x12\x05manga\"P\n\x06\x42\x61nner\x12\x11\n\timage_url\x18\x01 \x01(\t\x12\'\n\x06\x61\x63tion\x18\x02 \x01(\x0b\x32\x17.manga.TransitionAction\x12\n\n\x02id\x18\x03 \x01(\r\"B\n\nBannerList\x12\x14\n\x0c\x62\x61nner_title\x18\x01 \x01(\t\x12\x1e\n\x07\x62\x61nners\x18\x02 \x03(\x0b\x32\r.manga.Banner\"/\n\x10TransitionAction\x12\x0e\n\x06method\x18\x01 \x01(\x05\x12\x0b\n\x03url\x18\x02 \x01(\t\"\xc9\x01\n\x07\x43hapter\x12\x10\n\x08title_id\x18\x01 \x01(\r\x12\x12\n\nchapter_id\x18\x02 \x01(\r\x12\x0c\n\x04name\x18\x03 \x01(\t\x12\x11\n\tsub_title\x18\x04 \x01(\t\x12\x15\n\rthumbnail_url\x18\x05 \x01(\t\x12\x17\n\x0fstart_timestamp\x18\x06 \x01(\r\x12\x15\n\rend_timestamp\x18\x07 \x01(\r\x12\x16\n\x0e\x61lready_viewed\x18\x08 \x01(\x08\x12\x18\n\x10is_vertical_only\x18\t \x01(\x08\"\xa8\x01\n\x0c\x43hapterGroup\x12\x17\n\x0f\x63hapter_numbers\x18\x01 \x01(\t\x12*\n\x12\x66irst_chapter_list\x18\x02 \x03(\x0b\x32\x0e.manga.Chapter\x12(\n\x10mid_chapter_list\x18\x03 \x03(\x0b\x32\x0e.manga.Chapter\x12)\n\x11last_chapter_list\x18\x04 \x03(\x0b\x32\x0e.manga.Chapter\"\xdd\x01\n\x07\x43omment\x12\n\n\x02id\x18\x01 \x01(\r\x12\r\n\x05index\x18\x02 \x01(\r\x12\x11\n\tuser_name\x18\x03 \x01(\t\x12\x10\n\x08icon_url\x18\x04 \x01(\t\x12\x1a\n\ris_my_comment\x18\x06 \x01(\x08H\x00\x88\x01\x01\x12\x1a\n\ralready_liked\x18\x07 \x01(\x08H\x01\x88\x01\x01\x12\x17\n\x0fnumber_of_likes\x18\t \x01(\r\x12\x0c\n\x04\x62ody\x18\n \x01(\t\x12\x0f\n\x07\x63reated\x18\x0b \x01(\rB\x10\n\x0e_is_my_commentB\x10\n\x0e_already_liked\"\xfa\x03\n\rAdNetworkList\x12\x33\n\x0b\x61\x64_networks\x18\x01 \x01(\x0b\x32\x1e.manga.AdNetworkList.AdNetwork\x1a\xb3\x03\n\tAdNetwork\x12\x39\n\x08\x66\x61\x63\x65\x62ook\x18\x01 \x01(\x0b\x32\'.manga.AdNetworkList.AdNetwork.Facebook\x12\x33\n\x05\x61\x64mob\x18\x02 \x01(\x0b\x32$.manga.AdNetworkList.AdNetwork.Admob\x12\x33\n\x05mopub\x18\x03 \x01(\x0b\x32$.manga.AdNetworkList.AdNetwork.Mopub\x12\x37\n\x07\x61\x64sense\x18\x04 \x01(\x0b\x32&.manga.AdNetworkList.AdNetwork.Adsense\x12\x39\n\x08\x61pplovin\x18\x05 \x01(\x0b\x32\'.manga.AdNetworkList.AdNetwork.Applovin\x1a \n\x08\x46\x61\x63\x65\x62ook\x12\x14\n\x0cplacement_id\x18\x01 \x01(\t\x1a\x18\n\x05\x41\x64mob\x12\x0f\n\x07unit_id\x18\x01 \x01(\t\x1a\x18\n\x05Mopub\x12\x0f\n\x07unit_id\x18\x01 \x01(\t\x1a\x1a\n\x07\x41\x64sense\x12\x0f\n\x07unit_id\x18\x01 \x01(\t\x1a\x1b\n\x08\x41pplovin\x12\x0f\n\x07unit_id\x18\x01 \x01(\t\"\xb8\x04\n\x05Popup\x12*\n\nos_default\x18\x01 \x01(\x0b\x32\x16.manga.Popup.OSDefault\x12,\n\x0b\x61pp_default\x18\x02 \x01(\x0b\x32\x17.manga.Popup.AppDefault\x12.\n\x0cmovie_reward\x18\x03 \x01(\x0b\x32\x18.manga.Popup.MovieReward\x1a?\n\x06\x42utton\x12\x0c\n\x04text\x18\x01 \x01(\t\x12\'\n\x06\x61\x63tion\x18\x02 \x01(\x0b\x32\x17.manga.TransitionAction\x1a\xab\x01\n\tOSDefault\x12\x0f\n\x07subject\x18\x01 \x01(\t\x12\x0c\n\x04\x62ody\x18\x02 \x01(\t\x12&\n\tok_button\x18\x03 \x01(\x0b\x32\x13.manga.Popup.Button\x12+\n\x0eneutral_button\x18\x04 \x01(\x0b\x32\x13.manga.Popup.Button\x12*\n\rcancel_button\x18\x05 \x01(\x0b\x32\x13.manga.Popup.Button\x1ag\n\nAppDefault\x12\x0f\n\x07subject\x18\x01 \x01(\t\x12\x0c\n\x04\x62ody\x18\x02 \x01(\t\x12\'\n\x06\x61\x63tion\x18\x03 \x01(\x0b\x32\x17.manga.TransitionAction\x12\x11\n\timage_url\x18\x04 \x01(\t\x1aM\n\x0bMovieReward\x12\x11\n\timage_url\x18\x01 \x01(\t\x12+\n\radvertisement\x18\x02 \x01(\x0b\x32\x14.manga.AdNetworkList\"\x95\x02\n\x08LastPage\x12\'\n\x0f\x63urrent_chapter\x18\x01 \x01(\x0b\x32\x0e.manga.Chapter\x12$\n\x0cnext_chapter\x18\x02 \x01(\x0b\x32\x0e.manga.Chapter\x12$\n\x0ctop_comments\x18\x03 \x03(\x0b\x32\x0e.manga.Comment\x12\x15\n\ris_subscribed\x18\x04 \x01(\x08\x12\x16\n\x0enext_timestamp\x18\x05 \x01(\r\x12\x14\n\x0c\x63hapter_type\x18\x06 \x01(\x05\x12+\n\radvertisement\x18\x07 \x01(\x0b\x32\x14.manga.AdNetworkList\x12\"\n\x0cmovie_reward\x18\x08 \x01(\x0b\x32\x0c.manga.Popup\"c\n\tMangaPage\x12\x11\n\timage_url\x18\x01 \x01(\t\x12\r\n\x05width\x18\x02 \x01(\r\x12\x0e\n\x06height\x18\x03 \x01(\r\x12\x0c\n\x04type\x18\x04 \x01(\x05\x12\x16\n\x0e\x65ncryption_key\x18\x05 \x01(\t\"\xa5\x01\n\x04Page\x12$\n\nmanga_page\x18\x01 \x01(\x0b\x32\x10.manga.MangaPage\x12&\n\x0b\x62\x61nner_list\x18\x02 \x01(\x0b\x32\x11.manga.BannerList\x12\"\n\tlast_page\x18\x03 \x01(\x0b\x32\x0f.manga.LastPage\x12+\n\radvertisement\x18\x04 \x01(\x0b\x32\x14.manga.AdNetworkList\" \n\x03Sns\x12\x0c\n\x04\x62ody\x18\x01 \x01(\t\x12\x0b\n\x03url\x18\x02 \x01(\t\"\x84\x02\n\x0bMangaViewer\x12\x1a\n\x05pages\x18\x01 \x03(\x0b\x32\x0b.manga.Page\x12\x12\n\nchapter_id\x18\x02 \x01(\r\x12 \n\x08\x63hapters\x18\x03 \x03(\x0b\x32\x0e.manga.Chapter\x12\x17\n\x03sns\x18\x04 \x01(\x0b\x32\n.manga.Sns\x12\x12\n\ntitle_name\x18\x05 \x01(\t\x12\x14\n\x0c\x63hapter_name\x18\x06 \x01(\t\x12\x1a\n\x12number_of_comments\x18\x07 \x01(\r\x12\x18\n\x10is_vertical_only\x18\x08 \x01(\x08\x12\x10\n\x08title_id\x18\t \x01(\r\x12\x18\n\x10start_from_right\x18\n \x01(\x08\"\x96\x01\n\x05Title\x12\x10\n\x08title_id\x18\x01 \x01(\r\x12\x0c\n\x04name\x18\x02 \x01(\t\x12\x0e\n\x06\x61uthor\x18\x03 \x01(\t\x12\x1a\n\x12portrait_image_url\x18\x04 \x01(\t\x12\x1b\n\x13landscape_image_url\x18\x05 \x01(\t\x12\x12\n\nview_count\x18\x06 \x01(\r\x12\x10\n\x08language\x18\x07 \x01(\x05\"\xce\x04\n\x0fTitleDetailView\x12\x1b\n\x05title\x18\x01 \x01(\x0b\x32\x0c.manga.Title\x12\x17\n\x0ftitle_image_url\x18\x02 \x01(\t\x12\x10\n\x08overview\x18\x03 \x01(\t\x12\x1c\n\x14\x62\x61\x63kground_image_url\x18\x04 \x01(\t\x12\x16\n\x0enext_timestamp\x18\x05 \x01(\r\x12\x15\n\rupdate_timing\x18\x06 \x01(\x05\x12\"\n\x1aviewing_period_description\x18\x07 \x01(\t\x12\x1b\n\x13non_appearance_info\x18\x08 \x01(\t\x12*\n\x12\x66irst_chapter_list\x18\t \x03(\x0b\x32\x0e.manga.Chapter\x12)\n\x11last_chapter_list\x18\n \x03(\x0b\x32\x0e.manga.Chapter\x12\x1e\n\x07\x62\x61nners\x18\x0b \x03(\x0b\x32\r.manga.Banner\x12,\n\x16recommended_title_list\x18\x0c \x03(\x0b\x32\x0c.manga.Title\x12\x17\n\x03sns\x18\r \x01(\x0b\x32\n.manga.Sns\x12\x19\n\x11is_simul_released\x18\x0e \x01(\x08\x12\x15\n\ris_subscribed\x18\x0f \x01(\x08\x12\x0e\n\x06rating\x18\x10 \x01(\x05\x12\x1b\n\x13\x63hapters_descending\x18\x11 \x01(\x08\x12\x17\n\x0fnumber_of_views\x18\x12 \x01(\r\x12/\n\x12\x63hapter_list_group\x18\x1c \x03(\x0b\x32\x13.manga.ChapterGroup\"B\n\x0e\x41llTitlesGroup\x12\x12\n\ngroup_name\x18\x01 \x01(\t\x12\x1c\n\x06titles\x18\x02 \x03(\x0b\x32\x0c.manga.Title\"<\n\rAllTitlesView\x12+\n\x0ctitle_groups\x18\x01 \x03(\x0b\x32\x15.manga.AllTitlesGroup\"\x9b\x01\n\rSuccessResult\x12\x31\n\x11title_detail_view\x18\x08 \x01(\x0b\x32\x16.manga.TitleDetailView\x12(\n\x0cmanga_viewer\x18\n \x01(\x0b\x32\x12.manga.MangaViewer\x12-\n\x0f\x61ll_titles_view\x18\x19 \x01(\x0b\x32\x14.manga.AllTitlesView\"1\n\x08Response\x12%\n\x07success\x18\x01 \x01(\x0b\x32\x14.manga.SuccessResultB\x0fH\x01Z\x0bmanga/protob\x06proto3') + +_globals = globals() +_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) +_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'response_pb2', _globals) +if not _descriptor._USE_C_DESCRIPTORS: + _globals['DESCRIPTOR']._loaded_options = None + _globals['DESCRIPTOR']._serialized_options = b'H\001Z\013manga/proto' + _globals['_BANNER']._serialized_start=25 + _globals['_BANNER']._serialized_end=105 + _globals['_BANNERLIST']._serialized_start=107 + _globals['_BANNERLIST']._serialized_end=173 + _globals['_TRANSITIONACTION']._serialized_start=175 + _globals['_TRANSITIONACTION']._serialized_end=222 + _globals['_CHAPTER']._serialized_start=225 + _globals['_CHAPTER']._serialized_end=426 + _globals['_CHAPTERGROUP']._serialized_start=429 + _globals['_CHAPTERGROUP']._serialized_end=597 + _globals['_COMMENT']._serialized_start=600 + _globals['_COMMENT']._serialized_end=821 + _globals['_ADNETWORKLIST']._serialized_start=824 + _globals['_ADNETWORKLIST']._serialized_end=1330 + _globals['_ADNETWORKLIST_ADNETWORK']._serialized_start=895 + _globals['_ADNETWORKLIST_ADNETWORK']._serialized_end=1330 + _globals['_ADNETWORKLIST_ADNETWORK_FACEBOOK']._serialized_start=1189 + _globals['_ADNETWORKLIST_ADNETWORK_FACEBOOK']._serialized_end=1221 + _globals['_ADNETWORKLIST_ADNETWORK_ADMOB']._serialized_start=1223 + _globals['_ADNETWORKLIST_ADNETWORK_ADMOB']._serialized_end=1247 + _globals['_ADNETWORKLIST_ADNETWORK_MOPUB']._serialized_start=1249 + _globals['_ADNETWORKLIST_ADNETWORK_MOPUB']._serialized_end=1273 + _globals['_ADNETWORKLIST_ADNETWORK_ADSENSE']._serialized_start=1275 + _globals['_ADNETWORKLIST_ADNETWORK_ADSENSE']._serialized_end=1301 + _globals['_ADNETWORKLIST_ADNETWORK_APPLOVIN']._serialized_start=1303 + _globals['_ADNETWORKLIST_ADNETWORK_APPLOVIN']._serialized_end=1330 + _globals['_POPUP']._serialized_start=1333 + _globals['_POPUP']._serialized_end=1901 + _globals['_POPUP_BUTTON']._serialized_start=1480 + _globals['_POPUP_BUTTON']._serialized_end=1543 + _globals['_POPUP_OSDEFAULT']._serialized_start=1546 + _globals['_POPUP_OSDEFAULT']._serialized_end=1717 + _globals['_POPUP_APPDEFAULT']._serialized_start=1719 + _globals['_POPUP_APPDEFAULT']._serialized_end=1822 + _globals['_POPUP_MOVIEREWARD']._serialized_start=1824 + _globals['_POPUP_MOVIEREWARD']._serialized_end=1901 + _globals['_LASTPAGE']._serialized_start=1904 + _globals['_LASTPAGE']._serialized_end=2181 + _globals['_MANGAPAGE']._serialized_start=2183 + _globals['_MANGAPAGE']._serialized_end=2282 + _globals['_PAGE']._serialized_start=2285 + _globals['_PAGE']._serialized_end=2450 + _globals['_SNS']._serialized_start=2452 + _globals['_SNS']._serialized_end=2484 + _globals['_MANGAVIEWER']._serialized_start=2487 + _globals['_MANGAVIEWER']._serialized_end=2747 + _globals['_TITLE']._serialized_start=2750 + _globals['_TITLE']._serialized_end=2900 + _globals['_TITLEDETAILVIEW']._serialized_start=2903 + _globals['_TITLEDETAILVIEW']._serialized_end=3493 + _globals['_ALLTITLESGROUP']._serialized_start=3495 + _globals['_ALLTITLESGROUP']._serialized_end=3561 + _globals['_ALLTITLESVIEW']._serialized_start=3563 + _globals['_ALLTITLESVIEW']._serialized_end=3623 + _globals['_SUCCESSRESULT']._serialized_start=3626 + _globals['_SUCCESSRESULT']._serialized_end=3781 + _globals['_RESPONSE']._serialized_start=3783 + _globals['_RESPONSE']._serialized_end=3832 # @@protoc_insertion_point(module_scope) diff --git a/mloader/types.py b/mloader/types.py new file mode 100644 index 0000000..915e4ad --- /dev/null +++ b/mloader/types.py @@ -0,0 +1,141 @@ +"""Typed protocol contracts shared across runtime components.""" + +from __future__ import annotations + +from typing import Mapping, MutableMapping, Protocol, Sequence + + +class ChapterLike(Protocol): + """Minimal chapter shape used by loader and exporter code.""" + + chapter_id: int + name: str + sub_title: str + thumbnail_url: str + + +class TitleLike(Protocol): + """Minimal title shape used by loader and exporter code.""" + + name: str + author: str + portrait_image_url: str + landscape_image_url: str + language: int + + +class ChapterGroupLike(Protocol): + """Shape for grouped chapter lists in title details payloads.""" + + first_chapter_list: Sequence[ChapterLike] + mid_chapter_list: Sequence[ChapterLike] + last_chapter_list: Sequence[ChapterLike] + + +class TitleDumpLike(Protocol): + """Shape for title details payload used by downloader.""" + + title: TitleLike + title_image_url: str + chapter_list_group: Sequence[ChapterGroupLike] + non_appearance_info: str + number_of_views: int + overview: str + + +class MangaPageLike(Protocol): + """Shape for chapter page image payload.""" + + image_url: str + type: int + + +class LastPageLike(Protocol): + """Shape for final page metadata payload.""" + + current_chapter: ChapterLike + next_chapter: ChapterLike + + +class ViewerPageLike(Protocol): + """Shape for a viewer page entry.""" + + manga_page: MangaPageLike + last_page: LastPageLike + + +class MangaViewerLike(Protocol): + """Shape for manga viewer payload used in downloads.""" + + title_id: int + chapter_id: int + chapter_name: str + chapters: Sequence[ChapterLike] + pages: Sequence[ViewerPageLike] + + +class ResponseLike(Protocol): + """Minimal HTTP response contract used by loader transport code.""" + + content: bytes + + def raise_for_status(self) -> None: + """Raise for non-successful HTTP responses.""" + + +class SessionLike(Protocol): + """Minimal HTTP session contract used by loader mixins.""" + + headers: MutableMapping[str, str] + + def get( + self, + url: str, + params: Mapping[str, object] | None = None, + timeout: tuple[float, float] | None = None, + ) -> ResponseLike: + """Perform an HTTP GET request and return a response object.""" + + def mount(self, prefix: str, adapter: object) -> None: + """Attach a transport adapter for matching URL prefixes.""" + + +class ExporterLike(Protocol): + """Minimal exporter contract used by downloader orchestration.""" + + def add_image(self, image_data: bytes, index: int | range) -> None: + """Persist one image payload.""" + + def skip_image(self, index: int | range) -> bool: + """Return whether a page index should be skipped.""" + + def close(self) -> None: + """Finalize exporter output.""" + + +class ExporterFactoryLike(Protocol): + """Factory contract used by loader to construct exporters per chapter.""" + + def __call__( + self, + *, + title: TitleLike, + chapter: ChapterLike, + next_chapter: ChapterLike | None, + ) -> ExporterLike: + """Create and return an exporter instance.""" + + +class PayloadCaptureLike(Protocol): + """Contract for persisting API payload captures.""" + + def capture( + self, + *, + endpoint: str, + identifier: str | int, + url: str, + params: Mapping[str, object], + response_content: bytes, + ) -> None: + """Persist payload capture artifacts.""" diff --git a/mloader/utils.py b/mloader/utils.py index 33baf43..759d865 100644 --- a/mloader/utils.py +++ b/mloader/utils.py @@ -1,32 +1,45 @@ -import re -import string -import sys -from typing import Optional - - -def is_oneshot(chapter_name: str, chapter_subtitle: str) -> bool: - chapter_number = chapter_name_to_int(chapter_name) - - if chapter_number is not None: - return False - - for name in (chapter_name, chapter_subtitle): - name = name.lower() - if "one" in name and "shot" in name: - return True - return False - - -def chapter_name_to_int(name: str) -> Optional[int]: - try: - return int(name.lstrip("#")) - except ValueError: - return None - - -def escape_path(path: str) -> str: - return re.sub(r"[^\w]+", " ", path).strip(string.punctuation + " ") - - -def is_windows() -> bool: - return sys.platform == "win32" +"""Generic utility helpers for chapter parsing and filename sanitization.""" + +from __future__ import annotations + +import re +import string +import sys +from typing import Collection + + +def _contains_keywords(text: str, keywords: Collection[str]) -> bool: + """Return whether ``text`` contains all ``keywords`` case-insensitively.""" + lower_text = text.lower() + return all(keyword.lower() in lower_text for keyword in keywords) + + +def is_oneshot(chapter_name: str, chapter_subtitle: str) -> bool: + """Return whether chapter metadata indicates one-shot content.""" + chapter_number = chapter_name_to_int(chapter_name) + if chapter_number is not None: + return False + + return _contains_keywords(chapter_name, ["one", "shot"]) or _contains_keywords( + chapter_subtitle, + ["one", "shot"], + ) + + +def chapter_name_to_int(name: str) -> int | None: + """Parse chapter numeric value from ``name``, returning ``None`` if invalid.""" + try: + return int(name.lstrip("#")) + except ValueError: + return None + + +def escape_path(path: str) -> str: + """Normalize path string for safe filename usage.""" + normalized = re.sub(r"\W+", " ", path) + return normalized.strip(string.punctuation + " ") + + +def is_windows() -> bool: + """Return whether current platform is Windows.""" + return sys.platform == "win32" diff --git a/pyproject.toml b/pyproject.toml index 729c302..3d6d0ff 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,89 @@ -[tool.black] -target_version = ['py36'] -line-length = 80 \ No newline at end of file +[build-system] +requires = ["hatchling>=1.27.0"] +build-backend = "hatchling.build" + +[project] +name = "mloader-ng" +version = "2.0.3" +description = "Command-line tool to download manga from mangaplus" +readme = "README.md" +requires-python = ">=3.14" +license = { text = "GPL-3.0-or-later" } +authors = [ + { name = "l0westbob" }, + { name = "Hurlenko" }, +] +dependencies = [ + "Click>=8.3.1", + "protobuf>=6.33.5,<7", + "requests>=2.32.5", + "urllib3>=2.5.0", + "Pillow>=12.0.0", + "python-dotenv>=1.2.1", + "filelock>=3.20.0", + "playwright>=1.55.0", +] + +[project.urls] +Source = "https://github.com/l0westbob/mloader" +Upstream = "https://github.com/hurlenko/mloader" + +[project.scripts] +mloader = "mloader.__main__:main" + +[dependency-groups] +dev = [ + "pytest>=8.4.0", + "pytest-cov>=6.2.0", + "ruff>=0.13.0", + "ty>=0.0.24", +] + +[tool.uv] +default-groups = ["dev"] + +[tool.hatch.build.targets.wheel] +packages = ["mloader"] + +[tool.pytest.ini_options] +addopts = "-q" +testpaths = ["tests"] + +[tool.ruff] +target-version = "py314" +line-length = 100 +extend-exclude = ["mloader/response_pb2.py"] + +[tool.ruff.lint] +select = [ + "F", + "D100", + "D101", + "D102", + "D103", + "D104", + "D107", + "ANN001", + "ANN002", + "ANN003", + "ANN201", + "ANN202", + "ANN204", + "ANN205", +] + +[tool.ruff.lint.per-file-ignores] +"mloader/__main__.py" = ["E402"] + +[tool.ty.environment] +python-version = "3.14" + +[tool.coverage.run] +source = ["mloader"] +omit = [ + "mloader/response_pb2.py", + "mloader/**/__init__.py", +] + +[tool.coverage.report] +show_missing = true diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 7fe08c5..0000000 --- a/requirements.txt +++ /dev/null @@ -1,3 +0,0 @@ -Click>=6.2 -protobuf~=3.6 -requests>=2 diff --git a/response.proto b/response.proto index 4951017..7730766 100644 --- a/response.proto +++ b/response.proto @@ -2,6 +2,9 @@ syntax = "proto3"; package manga; +option optimize_for = SPEED; +option go_package = "manga/proto"; // If using Go + message Banner { string image_url = 1; TransitionAction action = 2; @@ -42,8 +45,8 @@ message Comment { uint32 index = 2; string user_name = 3; string icon_url = 4; - bool is_my_comment = 6; - bool already_liked = 7; + optional bool is_my_comment = 6; + optional bool already_liked = 7; uint32 number_of_likes = 9; string body = 10; uint32 created = 11; @@ -54,7 +57,6 @@ message AdNetworkList { message Facebook { string placement_id = 1; } - message Admob { string unit_id = 1; } @@ -78,7 +80,6 @@ message AdNetworkList { AdNetwork ad_networks = 1; } - message Popup { message Button { string text = 1; @@ -91,7 +92,6 @@ message Popup { Button ok_button = 3; Button neutral_button = 4; Button cancel_button = 5; - } message AppDefault { @@ -111,7 +111,6 @@ message Popup { MovieReward movie_reward = 3; } - message LastPage { Chapter current_chapter = 1; Chapter next_chapter = 2; @@ -123,7 +122,6 @@ message LastPage { Popup movie_reward = 8; } -// MangaPage message MangaPage { string image_url = 1; uint32 width = 2; @@ -132,7 +130,6 @@ message MangaPage { string encryption_key = 5; } -// Page message Page { MangaPage manga_page = 1; BannerList banner_list = 2; @@ -145,7 +142,6 @@ message Sns { string url = 2; } -// MangaViewer message MangaViewer { repeated Page pages = 1; uint32 chapter_id = 2; @@ -191,9 +187,19 @@ message TitleDetailView { repeated ChapterGroup chapter_list_group = 28; } +message AllTitlesGroup { + string group_name = 1; + repeated Title titles = 2; +} + +message AllTitlesView { + repeated AllTitlesGroup title_groups = 1; +} + message SuccessResult { TitleDetailView title_detail_view = 8; MangaViewer manga_viewer = 10; + AllTitlesView all_titles_view = 25; } message Response { diff --git a/scripts/sync_readme_cli_reference.py b/scripts/sync_readme_cli_reference.py new file mode 100644 index 0000000..132772d --- /dev/null +++ b/scripts/sync_readme_cli_reference.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python3 +"""Sync README CLI reference section with Click command metadata.""" + +from __future__ import annotations + +import argparse +from pathlib import Path + +from mloader.cli.main import main as cli_main +from mloader.cli.readme_reference import replace_readme_cli_reference + + +def _parse_args() -> argparse.Namespace: + """Parse script command-line flags.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--check", + action="store_true", + help="Exit with non-zero status when README is out of sync.", + ) + parser.add_argument( + "--readme", + type=Path, + default=Path("README.md"), + help="Path to README file to update.", + ) + return parser.parse_args() + + +def main() -> int: + """Run README sync in update or check mode.""" + args = _parse_args() + readme_path: Path = args.readme + original = readme_path.read_text(encoding="utf-8") + updated = replace_readme_cli_reference(original, command=cli_main) + + if args.check: + if updated != original: + print("README CLI reference is out of sync. Run scripts/sync_readme_cli_reference.py.") + return 1 + print("README CLI reference is up to date.") + return 0 + + readme_path.write_text(updated, encoding="utf-8") + print(f"Updated {readme_path}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/verify_readme_examples.py b/scripts/verify_readme_examples.py new file mode 100644 index 0000000..9f7e092 --- /dev/null +++ b/scripts/verify_readme_examples.py @@ -0,0 +1,354 @@ +#!/usr/bin/env python3 +"""Validate README mloader examples against live MangaPlus API endpoints.""" + +from __future__ import annotations + +import argparse +from dataclasses import dataclass +import re +import shlex +from pathlib import Path +from typing import Iterable + +import requests + +from mloader.config import AUTH_PARAMS +from mloader.cli.examples import build_cli_examples +from mloader.errors import APIResponseError +from mloader.manga_loader.api import _parse_manga_viewer_response, _parse_title_detail_response +from mloader.types import TitleDumpLike +from mloader.utils import chapter_name_to_int + +MANGA_PLUS_HOST = "mangaplus.shueisha.co.jp" +VIEWER_URL_PATTERN = re.compile(rf"^https://{re.escape(MANGA_PLUS_HOST)}/viewer/(\d+)$") +TITLE_URL_PATTERN = re.compile(rf"^https://{re.escape(MANGA_PLUS_HOST)}/titles/(\d+)$") +BASH_BLOCK_PATTERN = re.compile(r"```bash\s*\n(.*?)```", re.DOTALL) +MANGA_VIEWER_ENDPOINT = "https://jumpg-api.tokyo-cdn.com/api/manga_viewer" +TITLE_DETAIL_ENDPOINT = "https://jumpg-api.tokyo-cdn.com/api/title_detailV3" + + +@dataclass(slots=True) +class ParsedCommand: + """Normalized target values extracted from one README command.""" + + source: str + command: str + title_ids: set[int] + chapter_ids: set[int] + chapter_numbers: set[int] + + +@dataclass(slots=True) +class ValidationIssue: + """One validation issue found for a README command target.""" + + command: str + message: str + + +@dataclass(slots=True) +class SkippedCommand: + """One command skipped from live validation with explicit reason.""" + + source: str + command: str + reason: str + + +def _extract_commands(readme_text: str) -> list[str]: + """Return all README bash codeblock lines that begin with ``mloader ``.""" + commands: list[str] = [] + for block_match in BASH_BLOCK_PATTERN.finditer(readme_text): + block = block_match.group(1) + for raw_line in block.splitlines(): + line = raw_line.strip() + if line.startswith("mloader "): + commands.append(line) + return commands + + +def _parse_command(command: str, *, source: str) -> ParsedCommand: + """Parse one command line and return discovered title/chapter targets.""" + tokens = shlex.split(command) + args = tokens[1:] + title_ids: set[int] = set() + chapter_ids: set[int] = set() + chapter_numbers: set[int] = set() + index = 0 + + while index < len(args): + token = args[index] + + if token in {"--title", "-t"} and index + 1 < len(args): + title_ids.add(int(args[index + 1])) + index += 2 + continue + + if token == "--chapter-id" and index + 1 < len(args): + chapter_ids.add(int(args[index + 1])) + index += 2 + continue + + if token in {"--chapter", "-c"} and index + 1 < len(args): + chapter_numbers.add(int(args[index + 1])) + index += 2 + continue + + viewer_match = VIEWER_URL_PATTERN.match(token) + if viewer_match: + chapter_ids.add(int(viewer_match.group(1))) + index += 1 + continue + + title_match = TITLE_URL_PATTERN.match(token) + if title_match: + title_ids.add(int(title_match.group(1))) + index += 1 + continue + + index += 1 + + return ParsedCommand( + source=source, + command=command, + title_ids=title_ids, + chapter_ids=chapter_ids, + chapter_numbers=chapter_numbers, + ) + + +def _unique_commands(commands: Iterable[str]) -> list[str]: + """Return de-duplicated commands while preserving first-seen order.""" + seen: set[str] = set() + result: list[str] = [] + for command in commands: + if command in seen: + continue + seen.add(command) + result.append(command) + return result + + +def _build_parsed_commands( + *, + readme_text: str, + include_cli_examples: bool, +) -> list[ParsedCommand]: + """Build parsed command list from README and optional CLI example catalog.""" + readme_commands = _unique_commands(_extract_commands(readme_text)) + parsed_commands = [_parse_command(command, source="README") for command in readme_commands] + + if include_cli_examples: + cli_commands = _unique_commands( + example.command for example in build_cli_examples(prog_name="mloader") + ) + parsed_commands.extend( + _parse_command(command, source="CLI_EXAMPLES") for command in cli_commands + ) + + return parsed_commands + + +def _split_validatable_commands( + parsed_commands: Iterable[ParsedCommand], +) -> tuple[list[ParsedCommand], list[SkippedCommand]]: + """Split commands into live-validatable and skipped categories.""" + validatable: list[ParsedCommand] = [] + skipped: list[SkippedCommand] = [] + + for command in parsed_commands: + has_explicit_targets = bool(command.title_ids or command.chapter_ids) + if has_explicit_targets: + validatable.append(command) + continue + + if command.chapter_numbers and not command.title_ids: + skipped.append( + SkippedCommand( + source=command.source, + command=command.command, + reason="chapter numbers provided without title IDs", + ) + ) + continue + + skipped.append( + SkippedCommand( + source=command.source, + command=command.command, + reason="no resolvable title/chapter targets in command", + ) + ) + + return validatable, skipped + + +def _all_chapter_numbers_for_title(title_dump: TitleDumpLike) -> set[int]: + """Extract numeric chapter numbers from all chapter groups in a title payload.""" + chapter_numbers: set[int] = set() + for group in title_dump.chapter_list_group: + for chapter_list in ( + group.first_chapter_list, + group.mid_chapter_list, + group.last_chapter_list, + ): + for chapter in chapter_list: + parsed_number = chapter_name_to_int(chapter.name) + if parsed_number is not None: + chapter_numbers.add(parsed_number) + return chapter_numbers + + +def _validate_targets( + commands: Iterable[ParsedCommand], + *, + timeout: tuple[float, float], +) -> list[ValidationIssue]: + """Validate README targets against live API and return all discovered issues.""" + session = requests.Session() + issues: list[ValidationIssue] = [] + title_cache: dict[int, TitleDumpLike] = {} + + def _fetch_title(title_id: int, command: str) -> TitleDumpLike | None: + if title_id in title_cache: + return title_cache[title_id] + params = {**AUTH_PARAMS, "title_id": title_id} + try: + response = session.get(TITLE_DETAIL_ENDPOINT, params=params, timeout=timeout) + response.raise_for_status() + parsed = _parse_title_detail_response(response.content) + except (requests.RequestException, APIResponseError) as error: + issues.append( + ValidationIssue( + command=command, + message=f"title {title_id} failed: {error}", + ) + ) + return None + title_cache[title_id] = parsed + return parsed + + for parsed_command in commands: + for chapter_id in sorted(parsed_command.chapter_ids): + params = { + **AUTH_PARAMS, + "chapter_id": chapter_id, + "split": "no", + "img_quality": "low", + } + try: + response = session.get(MANGA_VIEWER_ENDPOINT, params=params, timeout=timeout) + response.raise_for_status() + _parse_manga_viewer_response(response.content) + except (requests.RequestException, APIResponseError) as error: + issues.append( + ValidationIssue( + command=parsed_command.command, + message=f"chapter_id {chapter_id} failed: {error}", + ) + ) + + for title_id in sorted(parsed_command.title_ids): + _fetch_title(title_id, parsed_command.command) + + if parsed_command.chapter_numbers and parsed_command.title_ids: + for title_id in sorted(parsed_command.title_ids): + title_dump = _fetch_title(title_id, parsed_command.command) + if title_dump is None: + continue + available_numbers = _all_chapter_numbers_for_title(title_dump) + missing_numbers = sorted(parsed_command.chapter_numbers - available_numbers) + for missing_number in missing_numbers: + issues.append( + ValidationIssue( + command=parsed_command.command, + message=( + f"title {title_id} does not contain chapter number {missing_number}" + ), + ) + ) + + return issues + + +def _build_parser() -> argparse.ArgumentParser: + """Create CLI argument parser.""" + parser = argparse.ArgumentParser( + description="Validate README mloader command examples against live MangaPlus endpoints.", + ) + parser.add_argument( + "--readme", + type=Path, + default=Path("README.md"), + help="Path to README file containing examples.", + ) + parser.add_argument( + "--connect-timeout", + type=float, + default=5.0, + help="HTTP connect timeout in seconds.", + ) + parser.add_argument( + "--read-timeout", + type=float, + default=30.0, + help="HTTP read timeout in seconds.", + ) + parser.add_argument( + "--include-cli-examples", + action=argparse.BooleanOptionalAction, + default=True, + help="Also validate commands from `mloader --show-examples` catalog.", + ) + return parser + + +def main() -> int: + """Run README example verification and return process exit code.""" + parser = _build_parser() + args = parser.parse_args() + + readme_text = args.readme.read_text(encoding="utf-8") + parsed_commands = _build_parsed_commands( + readme_text=readme_text, + include_cli_examples=args.include_cli_examples, + ) + validatable_commands, skipped_commands = _split_validatable_commands(parsed_commands) + + if not validatable_commands: + print("No README examples with resolvable title/chapter targets were found.") + return 0 + + timeout = (args.connect_timeout, args.read_timeout) + issues = _validate_targets(validatable_commands, timeout=timeout) + + if issues: + print( + "README example validation failed: " + f"{len(issues)} issue(s), {len(validatable_commands)} validated, " + f"{len(skipped_commands)} skipped, {len(parsed_commands)} total." + ) + if skipped_commands: + print("Skipped commands:") + for skipped in skipped_commands: + print(f"- [{skipped.source}] {skipped.reason}") + print(f" command: {skipped.command}") + for issue in issues: + print(f"- {issue.message}") + print(f" command: [{issue.command}]") + return 1 + + print("README example validation succeeded.") + print(f"- total commands scanned: {len(parsed_commands)}") + print(f"- commands live-validated: {len(validatable_commands)}") + print(f"- commands skipped: {len(skipped_commands)}") + if skipped_commands: + print("Skipped commands:") + for skipped in skipped_commands: + print(f"- [{skipped.source}] {skipped.reason}") + print(f" command: {skipped.command}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/setup.py b/setup.py deleted file mode 100644 index 20a4993..0000000 --- a/setup.py +++ /dev/null @@ -1,51 +0,0 @@ -import os -from codecs import open - -from setuptools import setup, find_packages - -here = os.path.abspath(os.path.dirname(__file__)) - -package_name = "mloader" - -about = {} -with open( - os.path.join(here, package_name, "__version__.py"), "r", "utf-8" -) as f: - exec(f.read(), about) - -with open("README.md", "r", "utf-8") as f: - readme = f.read() - - -setup( - name=about["__title__"], - version=about["__version__"], - description=about["__description__"], - long_description=readme, - long_description_content_type="text/markdown", - url=about["__url__"], - packages=find_packages(), - python_requires=">=3.6", - install_requires=[ - "Click>=6.2", - "protobuf~=3.6", - "requests>=2" - ], - license=about["__license__"], - zip_safe=False, - classifiers=[ - "License :: OSI Approved :: GNU General Public License v3 (GPLv3)", - "Programming Language :: Python", - "Programming Language :: Python :: 3.5", - "Programming Language :: Python :: 3.6", - "Programming Language :: Python :: 3.7", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", - "Programming Language :: Python :: 3.10", - "Programming Language :: Python :: Implementation :: CPython", - ], - project_urls={"Source": about["__url__"]}, - entry_points={ - "console_scripts": [f"{about['__title__']} = mloader.__main__:main"] - }, -) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..804e4ee --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,10 @@ +"""Pytest configuration shared by all test modules.""" + +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) diff --git a/tests/fixtures/api_captures/baseline/0001_title_detailV3_100010.meta.json b/tests/fixtures/api_captures/baseline/0001_title_detailV3_100010.meta.json new file mode 100644 index 0000000..b449fe3 --- /dev/null +++ b/tests/fixtures/api_captures/baseline/0001_title_detailV3_100010.meta.json @@ -0,0 +1,17 @@ +{ + "captured_at_utc": "2026-02-13T22:09:00.680623+00:00", + "endpoint": "title_detailV3", + "identifier": "100010", + "params": { + "app_ver": "97", + "os": "ios", + "os_ver": "18.1", + "secret": "***REDACTED***", + "title_id": 100010 + }, + "parsed_payload_file": "0001_title_detailV3_100010.response.json", + "payload_sha256": "9d73849e8172c3bce4309f11cb540f531805d61023651744872af6ddef35608b", + "payload_size_bytes": 50999, + "raw_payload_file": "0001_title_detailV3_100010.pb", + "url": "https://jumpg-api.tokyo-cdn.com/api/title_detailV3" +} \ No newline at end of file diff --git a/tests/fixtures/api_captures/baseline/0001_title_detailV3_100010.pb b/tests/fixtures/api_captures/baseline/0001_title_detailV3_100010.pb new file mode 100644 index 0000000..b5b8c70 Binary files /dev/null and b/tests/fixtures/api_captures/baseline/0001_title_detailV3_100010.pb differ diff --git a/tests/fixtures/api_captures/baseline/0001_title_detailV3_100010.response.json b/tests/fixtures/api_captures/baseline/0001_title_detailV3_100010.response.json new file mode 100644 index 0000000..4550f6a --- /dev/null +++ b/tests/fixtures/api_captures/baseline/0001_title_detailV3_100010.response.json @@ -0,0 +1,2187 @@ +{ + "success": { + "title_detail_view": { + "chapter_list_group": [ + { + "chapter_numbers": "50", + "first_chapter_list": [ + { + "already_viewed": true, + "chapter_id": 1000310, + "end_timestamp": 2145884400, + "name": "#001", + "start_timestamp": 1547996400, + "sub_title": "Z=1: Stone World", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1000310/chapter_thumbnail/1684.webp?hash=fe6O3XCjdfpoXhVqDxIWig&expires=1771027200", + "title_id": 100010 + }, + { + "already_viewed": true, + "chapter_id": 1000311, + "end_timestamp": 2145884400, + "name": "#002", + "start_timestamp": 1547996400, + "sub_title": "Z=2: Fantasy vs. Science", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1000311/chapter_thumbnail/1687.webp?hash=EJshh-GXRNlpObwvngdhUQ&expires=1771027200", + "title_id": 100010 + }, + { + "already_viewed": true, + "chapter_id": 1000312, + "end_timestamp": 2145884400, + "name": "#003", + "start_timestamp": 1547996400, + "sub_title": "Z=3: King of the Stone World", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1000312/chapter_thumbnail/1690.webp?hash=BuOcAlWOqonXQB9MTojnNg&expires=1771027200", + "title_id": 100010 + } + ], + "mid_chapter_list": [ + { + "already_viewed": true, + "chapter_id": 1000313, + "end_timestamp": 2145884400, + "name": "#004", + "start_timestamp": 1547996400, + "sub_title": "Z=4: Pure White Seashells", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1000313/chapter_thumbnail/1693.webp?hash=l0MeH1w8JRGi_3c-58R5EQ&expires=1771027200", + "title_id": 100010 + }, + { + "already_viewed": true, + "chapter_id": 1000314, + "end_timestamp": 2145884400, + "name": "#005", + "start_timestamp": 1547996400, + "sub_title": "Z=5: Yuzuriha", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1000314/chapter_thumbnail/1696.webp?hash=snyg3Xasnralvi4WqK2bjw&expires=1771027200", + "title_id": 100010 + }, + { + "already_viewed": true, + "chapter_id": 1000315, + "end_timestamp": 2145884400, + "name": "#006", + "start_timestamp": 1547996400, + "sub_title": "Z=6: Taiju vs. Tsukasa", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1000315/chapter_thumbnail/1699.webp?hash=zT4Tnjjmr_ukPo7p5Nserg&expires=1771027200", + "title_id": 100010 + }, + { + "already_viewed": true, + "chapter_id": 1000316, + "end_timestamp": 2145884400, + "name": "#007", + "start_timestamp": 1640962800, + "sub_title": "Z=7: The Gunpowder Adventure", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1000316/chapter_thumbnail/1702.webp?hash=xXVp2f_vzU28UY3GWIMUiA&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012263, + "end_timestamp": 2145884400, + "name": "#008", + "start_timestamp": 1640962800, + "sub_title": "Z=8: Raise the Smoke Signal", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012263/chapter_thumbnail/201808.webp?hash=zttiXU1fZ7tg7erIhExpAg&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012264, + "end_timestamp": 2145884400, + "name": "#009", + "start_timestamp": 1640962800, + "sub_title": "Z=9: Senku Vs. Tsukasa", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012264/chapter_thumbnail/201811.webp?hash=npf9EWyqOGjopInMFnhJ1g&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012265, + "end_timestamp": 2145884400, + "name": "#010", + "start_timestamp": 1640962800, + "sub_title": "Z=10: Student Of Science", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012265/chapter_thumbnail/201814.webp?hash=6CZzFncusSiRzArdUv7T7w&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012266, + "end_timestamp": 2145884400, + "name": "#011", + "start_timestamp": 1640962800, + "sub_title": "Z=11: Weapon Of Science", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012266/chapter_thumbnail/201817.webp?hash=ijZbAeSWddBCSsU1noADoA&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012267, + "end_timestamp": 2145884400, + "name": "#012", + "start_timestamp": 1640962800, + "sub_title": "Z=12: Epilogue Of Prologue (End of Part 0)", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012267/chapter_thumbnail/201820.webp?hash=1QT9hCZ87lFmYO5oVfZlZw&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012268, + "end_timestamp": 2145884400, + "name": "#013", + "start_timestamp": 1640962800, + "sub_title": "Z=13: Part 1: Stone World-The Beginning", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012268/chapter_thumbnail/201823.webp?hash=s4346hVbLbZgtoJXhfmQqg&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012269, + "end_timestamp": 2145884400, + "name": "#014", + "start_timestamp": 1640962800, + "sub_title": "Z=14: Those Who Have Faith", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012269/chapter_thumbnail/201826.webp?hash=sSqahMCL0QzZY2teIIsIQg&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012270, + "end_timestamp": 2145884400, + "name": "#015", + "start_timestamp": 1640962800, + "sub_title": "Z=15: Two Kingdoms Of The Stone World", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012270/chapter_thumbnail/201829.webp?hash=WjT6eA5ASPntjxU7n3buBg&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012271, + "end_timestamp": 2145884400, + "name": "#016", + "start_timestamp": 1640962800, + "sub_title": "Z=16: Kohaku", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012271/chapter_thumbnail/201832.webp?hash=CpLmHACVngEP21smaXk7nQ&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012272, + "end_timestamp": 2145884400, + "name": "#017", + "start_timestamp": 1640962800, + "sub_title": "Z=17: Nasty Looks", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012272/chapter_thumbnail/201835.webp?hash=sh7_pYx7HVnNyuyw-XOvDA&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012273, + "end_timestamp": 2145884400, + "name": "#018", + "start_timestamp": 1640962800, + "sub_title": "Z=18: Sorcery Showdown", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012273/chapter_thumbnail/201838.webp?hash=8TNQSxfwBhAi2tQzGrul7g&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012274, + "end_timestamp": 2145884400, + "name": "#019", + "start_timestamp": 1640962800, + "sub_title": "Z=19: Two Million Years Of Being", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012274/chapter_thumbnail/201841.webp?hash=orDvfmkoHneRUeQCASfNMA&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012275, + "end_timestamp": 2145884400, + "name": "#020", + "start_timestamp": 1640962800, + "sub_title": "Z=20: Stone Road", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012275/chapter_thumbnail/201844.webp?hash=WKnCIXNZaBm_hRWNt9yo3g&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012276, + "end_timestamp": 2145884400, + "name": "#021", + "start_timestamp": 1640962800, + "sub_title": "Z=21: Dawn of Iron", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012276/chapter_thumbnail/201847.webp?hash=v9kuKnMKfSTGmSoC4Oh7_Q&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012277, + "end_timestamp": 2145884400, + "name": "#022", + "start_timestamp": 1640962800, + "sub_title": "Z=22: Survival Gourmet", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012277/chapter_thumbnail/201850.webp?hash=TK2RNXFaMIz4S9V-T-w0kg&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012278, + "end_timestamp": 2145884400, + "name": "#023", + "start_timestamp": 1640962800, + "sub_title": "Z=23: The Smooth Talker", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012278/chapter_thumbnail/201853.webp?hash=K1x2L9ZmpHmkAWGGBB8HUg&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012279, + "end_timestamp": 2145884400, + "name": "#024", + "start_timestamp": 1640962800, + "sub_title": "Z=24: Lightning Speed!!", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012279/chapter_thumbnail/201856.webp?hash=2KE2phCOLFBoXkRn3O-azQ&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012280, + "end_timestamp": 2145884400, + "name": "#025", + "start_timestamp": 1640962800, + "sub_title": "Z=25: By These Hands, The Light Of Science", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012280/chapter_thumbnail/201859.webp?hash=EJC23_vAHOfFXxoE_8xMSA&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012281, + "end_timestamp": 2145884400, + "name": "#026", + "start_timestamp": 1640962800, + "sub_title": "Z=26: A Shallow Alliance", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012281/chapter_thumbnail/201862.webp?hash=yyxKO8Zjt5W3JWMHpNUg5w&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012282, + "end_timestamp": 2145884400, + "name": "#027", + "start_timestamp": 1640962800, + "sub_title": "Z=27: A Certain Scientist's Wish", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012282/chapter_thumbnail/201865.webp?hash=MdRmcfccZyZoSdKkMIomcA&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012283, + "end_timestamp": 2145884400, + "name": "#028", + "start_timestamp": 1640962800, + "sub_title": "Z=28: Clear World", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012283/chapter_thumbnail/201868.webp?hash=paVUp29ppUUeKBxKxNtH6A&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012284, + "end_timestamp": 2145884400, + "name": "#029", + "start_timestamp": 1640962800, + "sub_title": "Z=29: Senku's Lab", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012284/chapter_thumbnail/201871.webp?hash=X7ZipXRw3a9Sz_aOFjM7BQ&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012285, + "end_timestamp": 2145884400, + "name": "#030", + "start_timestamp": 1640962800, + "sub_title": "Z=30: Death Green", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012285/chapter_thumbnail/201874.webp?hash=RH9UOm5XStbQ8Zo5M9TMrg&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012286, + "end_timestamp": 2145884400, + "name": "#031", + "start_timestamp": 1640962800, + "sub_title": "Z=31: Friends Have Each Other's Backs", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012286/chapter_thumbnail/201877.webp?hash=hUIXpe6YdDfXwbPLHKr-ug&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012287, + "end_timestamp": 2145884400, + "name": "#032", + "start_timestamp": 1640962800, + "sub_title": "Z=32: Brains & Heart", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012287/chapter_thumbnail/201880.webp?hash=hzyMRERQpA_0AyF1xgm9YA&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012288, + "end_timestamp": 2145884400, + "name": "#033", + "start_timestamp": 1640962800, + "sub_title": "Z=33: Baaad Chemicals", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012288/chapter_thumbnail/201883.webp?hash=b4e2A2KdQSpNl_JTeNjwCg&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012289, + "end_timestamp": 2145884400, + "name": "#034", + "start_timestamp": 1640962800, + "sub_title": "Z=34: Sneaky Grand Bout Strategy", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012289/chapter_thumbnail/201886.webp?hash=Mok_ZJtvH7CYR4kZU59gbQ&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012290, + "end_timestamp": 2145884400, + "name": "#035", + "start_timestamp": 1640962800, + "sub_title": "Z=35: The Masked Warrior", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012290/chapter_thumbnail/201889.webp?hash=3W6ZpWLxvNZ7H2E7S_42Uw&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012291, + "end_timestamp": 2145884400, + "name": "#036", + "start_timestamp": 1640962800, + "sub_title": "Z=36: Kinro And Ginro", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012291/chapter_thumbnail/201892.webp?hash=mEzUiizwNru882Ikcp4p9Q&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012292, + "end_timestamp": 2145884400, + "name": "#037", + "start_timestamp": 1640962800, + "sub_title": "Z=37: Science-User Chrome", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012292/chapter_thumbnail/201895.webp?hash=L6OJ5f_Qt9S3LRlBKzMNqA&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012293, + "end_timestamp": 2145884400, + "name": "#038", + "start_timestamp": 1640962800, + "sub_title": "Z=38: Master Of Flame", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012293/chapter_thumbnail/201898.webp?hash=dkWbMBVCzracvD8iu1a72A&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012294, + "end_timestamp": 2145884400, + "name": "#039", + "start_timestamp": 1640962800, + "sub_title": "Z=39: And The Winner Is...", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012294/chapter_thumbnail/201901.webp?hash=oFE65a9KlBpFl0DN8PGS7A&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012295, + "end_timestamp": 2145884400, + "name": "#040", + "start_timestamp": 1640962800, + "sub_title": "Z=40: Two Million Years In The Making", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012295/chapter_thumbnail/201904.webp?hash=I_5pApl8wB0_6EpBzby8rA&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012296, + "end_timestamp": 2145884400, + "name": "#041", + "start_timestamp": 1640962800, + "sub_title": "Z=41: Doctor Stone", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012296/chapter_thumbnail/201907.webp?hash=7eaqtV3IwXOw5vDUc-oWew&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012297, + "end_timestamp": 2145884400, + "name": "#042", + "start_timestamp": 1640962800, + "sub_title": "Z=42: Tale For The Ages", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012297/chapter_thumbnail/201910.webp?hash=auXaCozKyPxPb61azRvcNw&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012298, + "end_timestamp": 2145884400, + "name": "#043", + "start_timestamp": 1640962800, + "sub_title": "Z=43: Humanity's Final Six", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012298/chapter_thumbnail/201913.webp?hash=MbWRqyFvogAAVrhsCX82IQ&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012299, + "end_timestamp": 2145884400, + "name": "#044", + "start_timestamp": 1640962800, + "sub_title": "Z=44: One Hundred Nights, One Thousand Skies", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012299/chapter_thumbnail/201916.webp?hash=eWHkso7FUn2JOkjiYSaytA&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012300, + "end_timestamp": 2145884400, + "name": "#045", + "start_timestamp": 1640962800, + "sub_title": "Z=45: Epilogue Of Part 1 (End Of Part 1)", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012300/chapter_thumbnail/201919.webp?hash=cAeDPeVgJQIJSXHWxMALAw&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012301, + "end_timestamp": 2145884400, + "name": "#046", + "start_timestamp": 1640962800, + "sub_title": "Z=46: Stone Wars", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012301/chapter_thumbnail/201922.webp?hash=1xE9sxvdM30G32GWocR3Ag&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012302, + "end_timestamp": 2145884400, + "name": "#047", + "start_timestamp": 1640962800, + "sub_title": "Z=47: Science Vs. Power", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012302/chapter_thumbnail/201925.webp?hash=WQUWB3miiCTCqEg3_QhcTQ&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012303, + "end_timestamp": 2145884400, + "name": "#048", + "start_timestamp": 1640962800, + "sub_title": "Z=48: Blades Of Science", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012303/chapter_thumbnail/201928.webp?hash=ywvuUgCrChAHHW3E2c9n7A&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012304, + "end_timestamp": 2145884400, + "name": "#049", + "start_timestamp": 1640962800, + "sub_title": "Z=49: To The Present", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012304/chapter_thumbnail/201931.webp?hash=7U0HoBXN1bihVYop5OWM3Q&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012305, + "end_timestamp": 2145884400, + "name": "#050", + "start_timestamp": 1640962800, + "sub_title": "Z=50: Humanity's Greatest Weapon", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012305/chapter_thumbnail/201934.webp?hash=vFwz1urY0PNezwlZqu7nPA&expires=1771027200", + "title_id": 100010 + } + ] + }, + { + "chapter_numbers": "100", + "mid_chapter_list": [ + { + "chapter_id": 1012306, + "end_timestamp": 2145884400, + "name": "#051", + "start_timestamp": 1640962800, + "sub_title": "Z=51: Sweets For The Stone World", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012306/chapter_thumbnail/201937.webp?hash=kk-_5wWmMWgAGFvpapNkaQ&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012307, + "end_timestamp": 2145884400, + "name": "#052", + "start_timestamp": 1640962800, + "sub_title": "Z=52: Age Of Energy", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012307/chapter_thumbnail/201940.webp?hash=kFGantaUp9Hh6nKMNd5QeA&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012308, + "end_timestamp": 2145884400, + "name": "#053", + "start_timestamp": 1640962800, + "sub_title": "Z=53: Hard Knocks Crafting Club", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012308/chapter_thumbnail/201943.webp?hash=L0zjXkLHeP1ZIJc_JVMCuw&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012309, + "end_timestamp": 2145884400, + "name": "#054", + "start_timestamp": 1640962800, + "sub_title": "Z=54: Flickering Blue Jewel", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012309/chapter_thumbnail/201946.webp?hash=MwGgsxmzIZMiNYFrjzCLag&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012310, + "end_timestamp": 2145884400, + "name": "#055", + "start_timestamp": 1640962800, + "sub_title": "Z=55: Treasure Dungeon", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012310/chapter_thumbnail/201949.webp?hash=T0DOY1HduVaYJgOHkRLlMQ&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012311, + "end_timestamp": 2145884400, + "name": "#056", + "start_timestamp": 1640962800, + "sub_title": "Z=56: The Treasure", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012311/chapter_thumbnail/201952.webp?hash=W5y4XiNWhFLo1oPho-tHfw&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012312, + "end_timestamp": 2145884400, + "name": "#057", + "start_timestamp": 1640962800, + "sub_title": "Z=57: Heat Heart", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012312/chapter_thumbnail/201955.webp?hash=wMRcd0i9PQoWBJpae6NJrw&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012313, + "end_timestamp": 2145884400, + "name": "#058", + "start_timestamp": 1640962800, + "sub_title": "Z=58: Wave Of Science", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012313/chapter_thumbnail/201958.webp?hash=Gu3wSWazqWAfGdhseg_e3Q&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012314, + "end_timestamp": 2145884400, + "name": "#059", + "start_timestamp": 1640962800, + "sub_title": "Z=59: Voices from Here To Infinity", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012314/chapter_thumbnail/201961.webp?hash=Xx8QRMLpxEmhG8_PTQFiIQ&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012315, + "end_timestamp": 2145884400, + "name": "#060", + "start_timestamp": 1640962800, + "sub_title": "Z=60: Angel's Song, Devil's Whisper", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012315/chapter_thumbnail/201964.webp?hash=bCb_5RYQtB2WvJW1pVruQA&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012316, + "end_timestamp": 2145884400, + "name": "#061", + "start_timestamp": 1640962800, + "sub_title": "Z=61: Stone Wars Begin", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012316/chapter_thumbnail/201967.webp?hash=Ykl0AMTFUJCHUdwauJMNQw&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012317, + "end_timestamp": 2145884400, + "name": "#062", + "start_timestamp": 1640962800, + "sub_title": "Z=62: Double Chase", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012317/chapter_thumbnail/201970.webp?hash=9b3XnfO9nqpyWu_1IpwYTg&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012318, + "end_timestamp": 2145884400, + "name": "#063", + "start_timestamp": 1640962800, + "sub_title": "Z=63: Information Warfare", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012318/chapter_thumbnail/201973.webp?hash=x0ZLgEGhVOQofLzMTyzhXQ&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012319, + "end_timestamp": 2145884400, + "name": "#064", + "start_timestamp": 1640962800, + "sub_title": "Z=64: Hotline", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012319/chapter_thumbnail/201976.webp?hash=TomITaHUTDv-C13li0OpXQ&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012320, + "end_timestamp": 2145884400, + "name": "#065", + "start_timestamp": 1640962800, + "sub_title": "Z=65: Call From The Dead", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012320/chapter_thumbnail/201979.webp?hash=iKLbYkXKiNeJCaixalxjOw&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012321, + "end_timestamp": 2145884400, + "name": "#066", + "start_timestamp": 1640962800, + "sub_title": "Z=66: Liars And Truth-Tellers", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012321/chapter_thumbnail/201982.webp?hash=bIpfeaIFsh9LsMpPt70N_g&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012322, + "end_timestamp": 2145884400, + "name": "#067", + "start_timestamp": 1640962800, + "sub_title": "Z=67: Full Mobilization", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012322/chapter_thumbnail/201985.webp?hash=RRQh0wAgOMoyeJ1Hv15btQ&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012323, + "end_timestamp": 2145884400, + "name": "#068", + "start_timestamp": 1640962800, + "sub_title": "Z=68: Flames Of Revolution", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012323/chapter_thumbnail/201988.webp?hash=jMCMAeyHfVKexlHbiKL6cg&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012324, + "end_timestamp": 2145884400, + "name": "#069", + "start_timestamp": 1640962800, + "sub_title": "Z=69: Steam Gorilla", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012324/chapter_thumbnail/201991.webp?hash=-Xs-bUQK7ASdjnRc7aJiwQ&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012325, + "end_timestamp": 2145884400, + "name": "#070", + "start_timestamp": 1640962800, + "sub_title": "Z=70: Paper Shield", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012325/chapter_thumbnail/201994.webp?hash=YeaVuHG-IJUmqsE6N6oTAQ&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012326, + "end_timestamp": 2145884400, + "name": "#071", + "start_timestamp": 1640962800, + "sub_title": "Z=71: Prison Break", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012326/chapter_thumbnail/201997.webp?hash=b0_wn--zDV5QjqgzahgG8Q&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012327, + "end_timestamp": 2145884400, + "name": "#072", + "start_timestamp": 1640962800, + "sub_title": "Z=72: Experience Points", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012327/chapter_thumbnail/202000.webp?hash=JildIlYAVAjDfBZHonqy-A&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012328, + "end_timestamp": 2145884400, + "name": "#073", + "start_timestamp": 1640962800, + "sub_title": "Z=73: Top-Secret Mission", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012328/chapter_thumbnail/202003.webp?hash=p5UifDyCDOQ1dxRXn2ApXA&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012329, + "end_timestamp": 2145884400, + "name": "#074", + "start_timestamp": 1640962800, + "sub_title": "Z=74: Fateful 20 Seconds", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012329/chapter_thumbnail/202006.webp?hash=lESOj-9g1VSSh86vW8a5qQ&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012330, + "end_timestamp": 2145884400, + "name": "#075", + "start_timestamp": 1640962800, + "sub_title": "Z=75: 20-Second Countdown", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012330/chapter_thumbnail/202009.webp?hash=Y23TqpsDKwX2xy1lKAoDlA&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012331, + "end_timestamp": 2145884400, + "name": "#076", + "start_timestamp": 1640962800, + "sub_title": "Z=76: Final Battle", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012331/chapter_thumbnail/202012.webp?hash=qNIN_X--fLZz8EhpgGr3jg&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012332, + "end_timestamp": 2145884400, + "name": "#077", + "start_timestamp": 1640962800, + "sub_title": "Z=77: The Power Of Science", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012332/chapter_thumbnail/202015.webp?hash=L9YTYGRdTxRZUOa32oRudw&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012333, + "end_timestamp": 2145884400, + "name": "#078", + "start_timestamp": 1640962800, + "sub_title": "Z=78: That Which Destroys Or Saves", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012333/chapter_thumbnail/202018.webp?hash=ZG2g4eSNYXar88JbbcRyTQ&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012334, + "end_timestamp": 2145884400, + "name": "#079", + "start_timestamp": 1640962800, + "sub_title": "Z=79: For This Very Moment", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012334/chapter_thumbnail/202021.webp?hash=xN_0e7WT_KYPvJhv2yvVGg&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012335, + "end_timestamp": 2145884400, + "name": "#080", + "start_timestamp": 1640962800, + "sub_title": "Z=80: Humanity's Strongest Tag Team", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012335/chapter_thumbnail/202024.webp?hash=spBBVjJgbmNdEtZZJNksmQ&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012336, + "end_timestamp": 2145884400, + "name": "#081", + "start_timestamp": 1640962800, + "sub_title": "Z=81: Fingertip", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012336/chapter_thumbnail/202027.webp?hash=8jwiKem9oJq2bJIdSdDWlQ&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012337, + "end_timestamp": 2145884400, + "name": "#082", + "start_timestamp": 1640962800, + "sub_title": "Z=82: Epilogue of Stone Wars (End Of Part 2)", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012337/chapter_thumbnail/202030.webp?hash=GJB1fpuADj_439UW7AR7CQ&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012338, + "end_timestamp": 2145884400, + "name": "#083", + "start_timestamp": 1640962800, + "sub_title": "Z=83: Dr. Stone", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012338/chapter_thumbnail/202033.webp?hash=KPE3gCW3MiAQVkYZOCNMqQ&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012339, + "end_timestamp": 2145884400, + "name": "#084", + "start_timestamp": 1640962800, + "sub_title": "Z=84: People Power", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012339/chapter_thumbnail/202036.webp?hash=ZsDIH3LeUOBywmIyVM_s9A&expires=1771027200", + "title_id": 100010 + }, + { + "already_viewed": true, + "chapter_id": 1001174, + "end_timestamp": 2145884400, + "name": "#085", + "start_timestamp": 1547996400, + "sub_title": "Z=85: Ultimate Resource", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1001174/chapter_thumbnail/7153.webp?hash=5leStJ8t-KtjOAWePzUYVg&expires=1771027200", + "title_id": 100010 + }, + { + "already_viewed": true, + "chapter_id": 1001175, + "end_timestamp": 2145884400, + "name": "#086", + "start_timestamp": 1547996400, + "sub_title": "Z=86: Money", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1001175/chapter_thumbnail/7156.webp?hash=km59KviyWF_R6bsSZ22KEA&expires=1771027200", + "title_id": 100010 + }, + { + "already_viewed": true, + "chapter_id": 1001176, + "end_timestamp": 2145884400, + "name": "#087", + "start_timestamp": 1547996400, + "sub_title": "Z=87: Senku's Department Store", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1001176/chapter_thumbnail/7159.webp?hash=yDayCqHw1Ya9qMovxnOYKA&expires=1771027200", + "title_id": 100010 + }, + { + "already_viewed": true, + "chapter_id": 1001177, + "end_timestamp": 2145884400, + "name": "#088", + "start_timestamp": 1547996400, + "sub_title": "Z=88: Wings of Humanity", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1001177/chapter_thumbnail/7162.webp?hash=SxBMtTGq_5MtJIgwWwYMqA&expires=1771027200", + "title_id": 100010 + }, + { + "already_viewed": true, + "chapter_id": 1001178, + "end_timestamp": 2145884400, + "name": "#089", + "start_timestamp": 1547996400, + "sub_title": "Z=89: Adventurers", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1001178/chapter_thumbnail/7165.webp?hash=hzZn2SyeCuucwQ5_r4jwfg&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1001179, + "end_timestamp": 2145884400, + "name": "#090", + "start_timestamp": 1548014400, + "sub_title": "Z=90: New World Map", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1001179/chapter_thumbnail/8710.webp?hash=l1hFY7QTzaGma21jdT8sTQ&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1001180, + "end_timestamp": 2145884400, + "name": "#091", + "start_timestamp": 1548619200, + "sub_title": "Z=91: Need Bread? Start with Wheat", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1001180/chapter_thumbnail/11296.webp?hash=g-ser8a-1LVTU6VafDejjQ&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1001181, + "end_timestamp": 2145884400, + "name": "#092", + "start_timestamp": 1549224000, + "sub_title": "Z=92: Desire Is Noble", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1001181/chapter_thumbnail/11575.webp?hash=dpV9-X66oGFH7DAdxnBi2Q&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1001182, + "end_timestamp": 2145884400, + "name": "#093", + "start_timestamp": 1549656000, + "sub_title": "Z=93: The First Shot Is Yours", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1001182/chapter_thumbnail/11926.webp?hash=MaTrdW985w20avwcHn84Sw&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1001183, + "end_timestamp": 2145884400, + "name": "#094", + "start_timestamp": 1550433600, + "sub_title": "Z=94: The Scent of Black Gold", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1001183/chapter_thumbnail/12154.webp?hash=ihlB3HFrfHm1cQm1RJq6QA&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1001184, + "end_timestamp": 2145884400, + "name": "#095", + "start_timestamp": 1551038400, + "sub_title": "Z=95: First Contact", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1001184/chapter_thumbnail/15229.webp?hash=MBx1Zr37PuxVV8wfBZ0NxA&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1001622, + "end_timestamp": 2145884400, + "name": "#096", + "start_timestamp": 1551643200, + "sub_title": "Z=96: Eye of Science", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1001622/chapter_thumbnail/15541.webp?hash=SF27o7qY6G8UmcDN_82F3A&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1001623, + "end_timestamp": 2145884400, + "name": "#097", + "start_timestamp": 1552248000, + "sub_title": "Z=97: The Joy of Leadership", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1001623/chapter_thumbnail/16819.webp?hash=tCsc7h4-AAsXah1EO0k6xQ&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1001624, + "end_timestamp": 2145884400, + "name": "#098", + "start_timestamp": 1552852800, + "sub_title": "Z=98: Ryusui", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1001624/chapter_thumbnail/17041.webp?hash=nlXyLE2ivL8S5XPbJISlxA&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1001625, + "end_timestamp": 2145884400, + "name": "#099", + "start_timestamp": 1553457600, + "sub_title": "Z=99: Kingdom of Science Photo Journal", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1001625/chapter_thumbnail/19561.webp?hash=5TG3Wwdrf6nRGCKosKeCdw&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1001759, + "end_timestamp": 2145884400, + "name": "#100", + "start_timestamp": 1554667200, + "sub_title": "Z=100: Origin of the 100 Tales", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1001759/chapter_thumbnail/20542.webp?hash=Gx91y2sMOOcmADqupab2bA&expires=1771027200", + "title_id": 100010 + } + ] + }, + { + "chapter_numbers": "150", + "mid_chapter_list": [ + { + "chapter_id": 1001760, + "end_timestamp": 2145884400, + "name": "#101", + "start_timestamp": 1555272000, + "sub_title": "Z=101: Treasure Chest", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1001760/chapter_thumbnail/23479.webp?hash=7ACiQ_XM_3iigEqmAN83Vg&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1001761, + "end_timestamp": 2145884400, + "name": "#102", + "start_timestamp": 1555876800, + "sub_title": "Z=102: Perseus, Ship of Science", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1001761/chapter_thumbnail/25188.webp?hash=sRcTiTsipye5jzHBIH9ggA&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1001762, + "end_timestamp": 2145884400, + "name": "#103", + "start_timestamp": 1556308800, + "sub_title": "Z=103: Light of Hope and Despair", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1001762/chapter_thumbnail/26955.webp?hash=TpyHAai5sAXNikUwCyAz9Q&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1001763, + "end_timestamp": 2145884400, + "name": "#104", + "start_timestamp": 1557691200, + "sub_title": "Z=104: Men of Forensics", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1001763/chapter_thumbnail/27243.webp?hash=o_n9aRs6HhrLV0GFKk70yA&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1001935, + "end_timestamp": 2145884400, + "name": "#105", + "start_timestamp": 1558296000, + "sub_title": "Z=105: The Island's Greatest Beauty", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1001935/chapter_thumbnail/28524.webp?hash=Sfz2diOzS_mBJp3I0d7fPA&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1001936, + "end_timestamp": 2145884400, + "name": "#106", + "start_timestamp": 1558900800, + "sub_title": "Z=106: The Secret of Petrification", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1001936/chapter_thumbnail/28647.webp?hash=1IW0HlLoceqOnZqDw4xm8Q&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1001937, + "end_timestamp": 2145884400, + "name": "#107", + "start_timestamp": 1559505600, + "sub_title": "Z=107: Ace in the Hole on the Ship of Science", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1001937/chapter_thumbnail/33180.webp?hash=qgYw8j182p9ThyelCIhwIQ&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1002366, + "end_timestamp": 2145884400, + "name": "#108", + "start_timestamp": 1560110400, + "sub_title": "Z=108: Double Ace in the Hole", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1002366/chapter_thumbnail/33507.webp?hash=oG_F4TS_Hi8VuulCi8fmJg&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1002367, + "end_timestamp": 2145884400, + "name": "#109", + "start_timestamp": 1560715200, + "sub_title": "Z=109: Great Escape", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1002367/chapter_thumbnail/33774.webp?hash=zGmuYM-S1YEWu9iEH6DvgA&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1002368, + "end_timestamp": 2145884400, + "name": "#110", + "start_timestamp": 1561320000, + "sub_title": "Z=110: Beauty Science", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1002368/chapter_thumbnail/34014.webp?hash=9BNXFsIoK2MnlNxCKt8J5w&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1002369, + "end_timestamp": 2145884400, + "name": "#111", + "start_timestamp": 1561924800, + "sub_title": "Z=111: Science Wars", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1002369/chapter_thumbnail/35478.webp?hash=MYQoNeBnly0b6EHJUh7O5w&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1003161, + "end_timestamp": 2145884400, + "name": "#112", + "start_timestamp": 1562529600, + "sub_title": "Z=112: King of Three Dimensions", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1003161/chapter_thumbnail/36969.webp?hash=IlBVZUF94jtoFMjuin9WZw&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1003162, + "end_timestamp": 2145884400, + "name": "#113", + "start_timestamp": 1562961600, + "sub_title": "Z=113: Cryptography Science", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1003162/chapter_thumbnail/37269.webp?hash=M2YSk_J5ReByKaUCF_3neA&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1003163, + "end_timestamp": 2145884400, + "name": "#114", + "start_timestamp": 1563739200, + "sub_title": "Z=114: Silently, Science Pierces the Stone", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1003163/chapter_thumbnail/37581.webp?hash=L-NuybAcFa_WP1umOrtIFQ&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1003164, + "end_timestamp": 2145884400, + "name": "#115", + "start_timestamp": 1564344000, + "sub_title": "Z=115: One Second, One Grain", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1003164/chapter_thumbnail/38205.webp?hash=siDTwSZELMa-9LFWGu7kVA&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1003686, + "end_timestamp": 2145884400, + "name": "#116", + "start_timestamp": 1564948800, + "sub_title": "Z=116: Miracle in Hand", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1003686/chapter_thumbnail/38505.webp?hash=A6cThTDIitI1SCmOJ1zlIA&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1003687, + "end_timestamp": 2145884400, + "name": "#117", + "start_timestamp": 1566158400, + "sub_title": "Z=117: The Kingdom of Science Strikes Back", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1003687/chapter_thumbnail/39078.webp?hash=0BlghZxTJKhwLWukMTgbpw&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1003688, + "end_timestamp": 2145884400, + "name": "#118", + "start_timestamp": 1566763200, + "sub_title": "Z=118: Silent Soldiers", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1003688/chapter_thumbnail/39447.webp?hash=BOmfpYlXy2R0WZY9NaPQqQ&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1003689, + "end_timestamp": 2145884400, + "name": "#119", + "start_timestamp": 1567368000, + "sub_title": "Z=119: Science Soldiers", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1003689/chapter_thumbnail/39783.webp?hash=AS7xfKB6pSGwCFgWFAYl8w&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1003831, + "end_timestamp": 2145884400, + "name": "#120", + "start_timestamp": 1567972800, + "sub_title": "Z=120: Top Seacret", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1003831/chapter_thumbnail/40152.webp?hash=PVktDWjyQk8mwbjIvEE-Qw&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1003832, + "end_timestamp": 2145884400, + "name": "#121", + "start_timestamp": 1568404800, + "sub_title": "Z=121: Medusa's True Face", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1003832/chapter_thumbnail/40827.webp?hash=cPruke5o4u_-k1eRRpddlQ&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1003833, + "end_timestamp": 2145884400, + "name": "#122", + "start_timestamp": 1569009600, + "sub_title": "Z=122: Brain-Battle Puzzle Pieces", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1003833/chapter_thumbnail/43131.webp?hash=DGbtAyr22Q411z88sl5rYg&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1003834, + "end_timestamp": 2145884400, + "name": "#123", + "start_timestamp": 1569787200, + "sub_title": "Z=123: Brain-Battle Gambit", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1003834/chapter_thumbnail/44607.webp?hash=-zjNfxoemuVFhw49911FSA&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1003835, + "end_timestamp": 2145884400, + "name": "#124", + "start_timestamp": 1570392000, + "sub_title": "Z=124: Invention of Gods and Devils", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1003835/chapter_thumbnail/45504.webp?hash=WmHY2BoMe5uPyI4vFE-oPQ&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1005646, + "end_timestamp": 2145884400, + "name": "#125", + "start_timestamp": 1570824000, + "sub_title": "Z=125: Decisive Three-Dimensional Battle", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1005646/chapter_thumbnail/46446.webp?hash=bDQeVuxhfu0T9q-nYdHI4w&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1005647, + "end_timestamp": 2145884400, + "name": "#126", + "start_timestamp": 1571601600, + "sub_title": "Z=126: Three-Dimensional Stratagem", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1005647/chapter_thumbnail/47829.webp?hash=fr3lpZXbm2eu0b5XxPjLrA&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1005648, + "end_timestamp": 2145884400, + "name": "#127", + "start_timestamp": 1572206400, + "sub_title": "Z=127: Medusa & Perseus", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1005648/chapter_thumbnail/49944.webp?hash=PjCvW8IHUM4gZyja21G5og&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1005834, + "end_timestamp": 2145884400, + "name": "#128", + "start_timestamp": 1572638400, + "sub_title": "Z=128: Island-Wide Battle Royale", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1005834/chapter_thumbnail/50871.webp?hash=rueKC0ask_CM_fUvLiZD9g&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1005925, + "end_timestamp": 2145884400, + "name": "#129", + "start_timestamp": 1573416000, + "sub_title": "Z=129: Joker", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1005925/chapter_thumbnail/51642.webp?hash=dUZdorXYQVlwTcGbnWk5VA&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1005926, + "end_timestamp": 2145884400, + "name": "#130", + "start_timestamp": 1574020800, + "sub_title": "Z=130: Devil's Choice", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1005926/chapter_thumbnail/53163.webp?hash=ewBT-lggD1ePGjKT9pHJ4w&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1005927, + "end_timestamp": 2145884400, + "name": "#131", + "start_timestamp": 1575230400, + "sub_title": "Z=131: Nasty Crimes", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1005927/chapter_thumbnail/54945.webp?hash=NbWgxvImfl6JPQA6qI3w2g&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1006041, + "end_timestamp": 2145884400, + "name": "#132", + "start_timestamp": 1575835200, + "sub_title": "Z=132: The Strongest Weapon Is...", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1006041/chapter_thumbnail/56670.webp?hash=K18kmmaBjlmmP-Cc5iXzUw&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1006042, + "end_timestamp": 2145884400, + "name": "#133", + "start_timestamp": 1576440000, + "sub_title": "Z=133: Flash of Destruction", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1006042/chapter_thumbnail/57015.webp?hash=QyEosL_gjg9Xjyi856FGdw&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1006043, + "end_timestamp": 2145884400, + "name": "#134", + "start_timestamp": 1577044800, + "sub_title": "Z=134: Commander Faceoff", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1006043/chapter_thumbnail/57471.webp?hash=o83Z5kC_l7mJXpswrJUFkg&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1006044, + "end_timestamp": 2145884400, + "name": "#135", + "start_timestamp": 1579446000, + "sub_title": "Z=135: Counting", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1006044/chapter_thumbnail/58992.webp?hash=yOHttqjc2MHf6PlawQ8XwA&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1006191, + "end_timestamp": 2145884400, + "name": "#136", + "start_timestamp": 1580050800, + "sub_title": "Z=136: Medusa vs. Science", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1006191/chapter_thumbnail/59622.webp?hash=6DhOq-TOcJhUCu_bWs0qbA&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1006277, + "end_timestamp": 2145884400, + "name": "#137", + "start_timestamp": 1580655600, + "sub_title": "Z=137: Last Man Standing", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1006277/chapter_thumbnail/61398.webp?hash=DC895nUMwc6QetrCNmnzdQ&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1006278, + "end_timestamp": 2145884400, + "name": "#138", + "start_timestamp": 1581264000, + "sub_title": "Z=138: End of Part 3", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1006278/chapter_thumbnail/61797.webp?hash=Mz4gIirvswI89yhXtJM4uQ&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1006279, + "end_timestamp": 2145884400, + "name": "#139", + "start_timestamp": 1581868800, + "sub_title": "Z=139: First Dream", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1006279/chapter_thumbnail/62850.webp?hash=cTxE6gy2wT_IdBdFbtR1sA&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1006280, + "end_timestamp": 2145884400, + "name": "#140", + "start_timestamp": 1582300800, + "sub_title": "Z=140: New-World Pilots", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1006280/chapter_thumbnail/63723.webp?hash=o1ImmhAtYwYw45P1bOSl6A&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1006412, + "end_timestamp": 2145884400, + "name": "#141", + "start_timestamp": 1583078400, + "sub_title": "Z=141: First Team", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1006412/chapter_thumbnail/64818.webp?hash=WF6eLOvIhjc8jArqXfVSnQ&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1006413, + "end_timestamp": 2145884400, + "name": "#142", + "start_timestamp": 1583683200, + "sub_title": "Z=142: World Power", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1006413/chapter_thumbnail/65253.webp?hash=qrDgKUv8Q0l1HO0lALMLyg&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1006414, + "end_timestamp": 2145884400, + "name": "#143", + "start_timestamp": 1584288000, + "sub_title": "Z=143: Ryusui vs. Senku", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1006414/chapter_thumbnail/93558.webp?hash=2Qo5G2mRT0rgIoPEyot-2Q&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1006415, + "end_timestamp": 2145884400, + "name": "#144", + "start_timestamp": 1584892800, + "sub_title": "Z=144: Ryusui & Gen vs. Senku & Kohaku", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1006415/chapter_thumbnail/94215.webp?hash=leZQGK0LHyh5XpVoKOXS2w&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1006416, + "end_timestamp": 2145884400, + "name": "#145", + "start_timestamp": 1585497600, + "sub_title": "Z=145: Bar Francois", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1006416/chapter_thumbnail/94623.webp?hash=YqH1uEsCD2jkdahJ9loeIQ&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1006560, + "end_timestamp": 2145884400, + "name": "#146", + "start_timestamp": 1586102400, + "sub_title": "Z=146: Bar Francois: Bitters", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1006560/chapter_thumbnail/95598.webp?hash=81thI0zq5U7DZCbqMLDtng&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1006561, + "end_timestamp": 2145884400, + "name": "#147", + "start_timestamp": 1586707200, + "sub_title": "Z=147: Science Journey", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1006561/chapter_thumbnail/95982.webp?hash=CPN5XxopGOPr7ajxd6voXA&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1006562, + "end_timestamp": 2145884400, + "name": "#148", + "start_timestamp": 1587916800, + "sub_title": "Z=148: Pioneers of Earth", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1006562/chapter_thumbnail/96612.webp?hash=-Q-a7xRsBfO5wdGfPNWo3g&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1006563, + "end_timestamp": 2145884400, + "name": "#149", + "start_timestamp": 1589126400, + "sub_title": "Z=149: Light Lure in Darkness", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1006563/chapter_thumbnail/98184.webp?hash=hNAWuVI_yG3CR6Lx-rFYcA&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1006671, + "end_timestamp": 2145884400, + "name": "#150", + "start_timestamp": 1589731200, + "sub_title": "Z=150: Righteous Science-User", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1006671/chapter_thumbnail/98889.webp?hash=-MBgU_z4VlrTb9_DXEnbSw&expires=1771027200", + "title_id": 100010 + } + ] + }, + { + "chapter_numbers": "200", + "mid_chapter_list": [ + { + "chapter_id": 1006672, + "end_timestamp": 2145884400, + "name": "#151", + "start_timestamp": 1590336000, + "sub_title": "Z=151: Dr. X", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1006672/chapter_thumbnail/99852.webp?hash=F0a2bGJqosDZi22OdReUDw&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1006958, + "end_timestamp": 2145884400, + "name": "#152", + "start_timestamp": 1590940800, + "sub_title": "Z=152: Doctor Vs. Doctor", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1006958/chapter_thumbnail/100470.webp?hash=3k7VNHmBWXz2l2311FEGpg&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1007021, + "end_timestamp": 2145884400, + "name": "#153", + "start_timestamp": 1591545600, + "sub_title": "Z=153: War Game", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1007021/chapter_thumbnail/132180.webp?hash=MPrCwvsagdhV-2s3qgY5BQ&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1007022, + "end_timestamp": 2145884400, + "name": "#154", + "start_timestamp": 1592150400, + "sub_title": "Z=154: Spy vs. Spy", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1007022/chapter_thumbnail/132756.webp?hash=VCoApZMbM2P1vv3oSVEE2g&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1007023, + "end_timestamp": 2145884400, + "name": "#155", + "start_timestamp": 1592755200, + "sub_title": "Z=155: Science is Elegant", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1007023/chapter_thumbnail/133545.webp?hash=KZF3ZYCbL85n5-Ub4rpFWA&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1007024, + "end_timestamp": 2145884400, + "name": "#156", + "start_timestamp": 1593187200, + "sub_title": "Z=156: Two Scientists", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1007024/chapter_thumbnail/133941.webp?hash=3IpAqFpgqSp8Ks01Lth2Dw&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1007322, + "end_timestamp": 2145884400, + "name": "#157", + "start_timestamp": 1593964800, + "sub_title": "Z=157: Same Time, Same Place", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1007322/chapter_thumbnail/134883.webp?hash=OfpX7zIt5JfOtoBgwRPcpg&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1007323, + "end_timestamp": 2145884400, + "name": "#158", + "start_timestamp": 1594569600, + "sub_title": "Z=158: Who's the Scientist?", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1007323/chapter_thumbnail/135444.webp?hash=G9OClUAe3lanmsF0trzkoQ&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1007324, + "end_timestamp": 2145884400, + "name": "#159", + "start_timestamp": 1595174400, + "sub_title": "Z=159: Lock On", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1007324/chapter_thumbnail/135786.webp?hash=QlmJ_0ss76jD70pNVBITcA&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1007499, + "end_timestamp": 2145884400, + "name": "#160", + "start_timestamp": 1596384000, + "sub_title": "Z=160: Torch of Science", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1007499/chapter_thumbnail/136368.webp?hash=5erX7iz_yQcu0Si6mOo97g&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1007500, + "end_timestamp": 2145884400, + "name": "#161", + "start_timestamp": 1597075200, + "sub_title": "Z=161: Craft Wars", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1007500/chapter_thumbnail/136821.webp?hash=1e5xxguqEvosdKfluj6SdA&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1007501, + "end_timestamp": 2145884400, + "name": "#162", + "start_timestamp": 1598198400, + "sub_title": "Z=162: Down the Earth-Stained Path", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1007501/chapter_thumbnail/137424.webp?hash=DNaDiDkckyAlFV8aorLE2A&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1007502, + "end_timestamp": 2145884400, + "name": "#163", + "start_timestamp": 1598803200, + "sub_title": "Z=163: Multifront Final Battle", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1007502/chapter_thumbnail/137847.webp?hash=VmOKrBX2-_oraOgDyEzTMQ&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1007636, + "end_timestamp": 2145884400, + "name": "#164", + "start_timestamp": 1599408000, + "sub_title": "Z=164: Re-Lock On", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1007636/chapter_thumbnail/138441.webp?hash=COhR5SDvImRhklQm1lUG9Q&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1007671, + "end_timestamp": 2145884400, + "name": "#165", + "start_timestamp": 1600012800, + "sub_title": "Z=165: Know the Rules, Make the Rules", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1007671/chapter_thumbnail/138942.webp?hash=8ES6YF0FiYajyNYfEY4s8g&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1007672, + "end_timestamp": 2145884400, + "name": "#166", + "start_timestamp": 1600444800, + "sub_title": "Z=166: Ultimate Knight", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1007672/chapter_thumbnail/139599.webp?hash=QJhqvRUk9ZJhAJmbwS60xA&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1007673, + "end_timestamp": 2145884400, + "name": "#167", + "start_timestamp": 1601222400, + "sub_title": "Z=167: Different Strokes", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1007673/chapter_thumbnail/139947.webp?hash=wi8LsA5khlwqjuspKuQZdg&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1007793, + "end_timestamp": 2145884400, + "name": "#168", + "start_timestamp": 1601827200, + "sub_title": "Z=168: Corn City: Population One Million", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1007793/chapter_thumbnail/140328.webp?hash=WaOd-zjI39tLLomHDwStHQ&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1007794, + "end_timestamp": 2145884400, + "name": "#169", + "start_timestamp": 1602432000, + "sub_title": "Z=169: RISK or HEART", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1007794/chapter_thumbnail/141249.webp?hash=mczulOLelmzOBJk0SnKKMQ&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1007795, + "end_timestamp": 2145884400, + "name": "#170", + "start_timestamp": 1602864000, + "sub_title": "Z=170: Staring Up at the Same Moon", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1007795/chapter_thumbnail/141816.webp?hash=HxPrQYQ6T4wmH_40da0KXg&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1007796, + "end_timestamp": 2145884400, + "name": "#171", + "start_timestamp": 1603641600, + "sub_title": "Z=171: Staring at the Same Light", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1007796/chapter_thumbnail/142398.webp?hash=hZ1cwOPygz18fc8JCM5PtA&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1007942, + "end_timestamp": 2145884400, + "name": "#172", + "start_timestamp": 1604246400, + "sub_title": "Z=172: Marked with an \"X\" of Wisdom", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1007942/chapter_thumbnail/142857.webp?hash=N1yA6aU7yc3DcVBl3C9B2w&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1007943, + "end_timestamp": 2145884400, + "name": "#173", + "start_timestamp": 1604851200, + "sub_title": "Z=173: Earth Race", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1007943/chapter_thumbnail/143526.webp?hash=WRDgI9iFHsOzwAuPz72YGw&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1007944, + "end_timestamp": 2145884400, + "name": "#174", + "start_timestamp": 1605456000, + "sub_title": "Z=174: The Specter of the Panama Canal", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1007944/chapter_thumbnail/143907.webp?hash=uQ7h0A7HJcd_oRxfMAlSIA&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1007945, + "end_timestamp": 2145884400, + "name": "#175", + "start_timestamp": 1605888000, + "sub_title": "Z=175: Ultra Race Across South America", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1007945/chapter_thumbnail/144726.webp?hash=Y11V-Rh4u6n9JE9-GX4i5w&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1007946, + "end_timestamp": 2145884400, + "name": "#176", + "start_timestamp": 1606665600, + "sub_title": "Z=176: Net-Breaking Battle Plan", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1007946/chapter_thumbnail/145248.webp?hash=fznkTgH3QYlrCUcZwYSupA&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1008144, + "end_timestamp": 2145884400, + "name": "#177", + "start_timestamp": 1607270400, + "sub_title": "Z=177: Medusa Mechanism", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1008144/chapter_thumbnail/145770.webp?hash=_NNpTBXicFviARtqwyU3eQ&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1008145, + "end_timestamp": 2145884400, + "name": "#178", + "start_timestamp": 1607875200, + "sub_title": "Z=178: Science Scales Mountains", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1008145/chapter_thumbnail/146346.webp?hash=5kh2zyEGa0kitKvGxCk9eQ&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1008146, + "end_timestamp": 2145884400, + "name": "#179", + "start_timestamp": 1608480000, + "sub_title": "Z=179: Bonds on the High-Wire", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1008146/chapter_thumbnail/146958.webp?hash=Zhg8eBi6x173AjdsSPho7A&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1008147, + "end_timestamp": 2145884400, + "name": "#180", + "start_timestamp": 1609689600, + "sub_title": "Z=180: Sickening Yet Beautiful", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1008147/chapter_thumbnail/148024.webp?hash=AND0zy_Wlm1SkIB_OScPLA&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1008373, + "end_timestamp": 2145884400, + "name": "#181", + "start_timestamp": 1610899200, + "sub_title": "Z=181: New World Science", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1008373/chapter_thumbnail/148825.webp?hash=H9UuM-GsoNvGXetiUF3ICA&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1008374, + "end_timestamp": 2145884400, + "name": "#182", + "start_timestamp": 1611504000, + "sub_title": "Z=182: Diamond Heart", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1008374/chapter_thumbnail/149326.webp?hash=4tvsa1dx3PAVezbzqYBVfA&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1008375, + "end_timestamp": 2145884400, + "name": "#183", + "start_timestamp": 1612108800, + "sub_title": "Z=183: Stone Sanctuary", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1008375/chapter_thumbnail/150367.webp?hash=jN4h0hNzv7gFSbTUuu-msQ&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1008530, + "end_timestamp": 2145884400, + "name": "#184", + "start_timestamp": 1612713600, + "sub_title": "Z=184: Fort Medusa", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1008530/chapter_thumbnail/150865.webp?hash=gr31hLWgLJEmAaA59HZ7qw&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1008531, + "end_timestamp": 2145884400, + "name": "#185", + "start_timestamp": 1613318400, + "sub_title": "Z=185: Lovely Cleavage Plane", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1008531/chapter_thumbnail/151399.webp?hash=fnR0M5JT8HTq3sGoWTKrqA&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1008532, + "end_timestamp": 2145884400, + "name": "#186", + "start_timestamp": 1613923200, + "sub_title": "Z=186: To Each Their Own Blade", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1008532/chapter_thumbnail/151993.webp?hash=Yru_NHjRX4sggp0BnxOp_A&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1008937, + "end_timestamp": 2145884400, + "name": "#187", + "start_timestamp": 1614528000, + "sub_title": "Z=187: Cyber Guerilla", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1008937/chapter_thumbnail/152443.webp?hash=rh1n10fQSPGnD6syyjQAxg&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1008938, + "end_timestamp": 2145884400, + "name": "#188", + "start_timestamp": 1615132800, + "sub_title": "Z=188: What I Once Sought to Destroy", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1008938/chapter_thumbnail/154504.webp?hash=dKnzMwSwCrBJovpITT7uzg&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1008939, + "end_timestamp": 2145884400, + "name": "#189", + "start_timestamp": 1615737600, + "sub_title": "Z=189: Our Dr. Stone", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1008939/chapter_thumbnail/156214.webp?hash=4nADYBXysQe3D7CTiu91ww&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1008940, + "end_timestamp": 2145884400, + "name": "#190", + "start_timestamp": 1616342400, + "sub_title": "Z=190: Science Transcends Life", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1008940/chapter_thumbnail/160108.webp?hash=VfPC7bYzyVjYZNKHhix2RQ&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1008941, + "end_timestamp": 2145884400, + "name": "#191", + "start_timestamp": 1617552000, + "sub_title": "Z=191: Divine Scream, Down to Earth", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1008941/chapter_thumbnail/161434.webp?hash=y420Wi4cudUli9F1fln8ng&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1009095, + "end_timestamp": 2145884400, + "name": "#192", + "start_timestamp": 1618156800, + "sub_title": "Z=192: Until We Meet Again", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1009095/chapter_thumbnail/163309.webp?hash=TNQMnFakkhn9SvmTzQlTpw&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1009096, + "end_timestamp": 2145884400, + "name": "#193", + "start_timestamp": 1618761600, + "sub_title": "Z=193: Our Stone World", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1009096/chapter_thumbnail/164767.webp?hash=Ke8beacJFROVoC9NuWlw-g&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1009097, + "end_timestamp": 2145884400, + "name": "#194", + "start_timestamp": 1619366400, + "sub_title": "Z=194: Homo Sapiens, All Alone", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1009097/chapter_thumbnail/165969.webp?hash=0J0h_3oCLrOY0jErgSMe_A&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1009231, + "end_timestamp": 2145884400, + "name": "#195", + "start_timestamp": 1620576000, + "sub_title": "Z=195: Treasure Hunter, All Alone", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1009231/chapter_thumbnail/167259.webp?hash=_XysckPLY_XtUI81CuiQ1Q&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1009232, + "end_timestamp": 2145884400, + "name": "#196", + "start_timestamp": 1621180800, + "sub_title": "Z=196: Scientist, All Alone", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1009232/chapter_thumbnail/168208.webp?hash=lryTizGi8PsrFDnGFDdpkw&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1009233, + "end_timestamp": 2145884400, + "name": "#197", + "start_timestamp": 1621785600, + "sub_title": "Z=197: A Stony Eden and Its Forbidden Fruit", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1009233/chapter_thumbnail/169013.webp?hash=IyB0J-aHcfUTMIwckMS2ng&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1009396, + "end_timestamp": 2145884400, + "name": "#198", + "start_timestamp": 1622390400, + "sub_title": "Z=198: Whole New World", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1009396/chapter_thumbnail/170043.webp?hash=RVnV3gYO0j2FogT_0uR2tw&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1009397, + "end_timestamp": 2145884400, + "name": "#199", + "start_timestamp": 1622995200, + "sub_title": "Z=199: Superalloys", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1009397/chapter_thumbnail/171418.webp?hash=PScSL18xjBf_Cz2UmTH1lg&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1009398, + "end_timestamp": 2145884400, + "name": "#200", + "start_timestamp": 1623600000, + "sub_title": "Z=200: Future Engine", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1009398/chapter_thumbnail/172501.webp?hash=7SV9ixq491wzY9fEU4EDXQ&expires=1771027200", + "title_id": 100010 + } + ] + }, + { + "chapter_numbers": "250", + "mid_chapter_list": [ + { + "chapter_id": 1009399, + "end_timestamp": 2145884400, + "name": "#201", + "start_timestamp": 1624204800, + "sub_title": "Z=201: Morse Talk", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1009399/chapter_thumbnail/173074.webp?hash=zrycmlHjyDq9mR5_QGYlag&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1009400, + "end_timestamp": 2145884400, + "name": "#202", + "start_timestamp": 1624809600, + "sub_title": "Z=202: Ryusui Corp.", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1009400/chapter_thumbnail/173518.webp?hash=Ztz9Z6VHuL4vcot1gep1kA&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1009560, + "end_timestamp": 2145884400, + "name": "#203", + "start_timestamp": 1625414400, + "sub_title": "Z=203: Missile Heart", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1009560/chapter_thumbnail/174097.webp?hash=bdQvq90fWJ34p2fU8VRUeQ&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1009561, + "end_timestamp": 2145884400, + "name": "#204", + "start_timestamp": 1626019200, + "sub_title": "Z=204: The Universe is Written in the Language of Mathematics", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1009561/chapter_thumbnail/174466.webp?hash=VWBzL9dfcn2_YrvZgzOBUw&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1009562, + "end_timestamp": 2145884400, + "name": "#205", + "start_timestamp": 1626624000, + "sub_title": "Z=205: Universe of Zeroes and Ones", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1009562/chapter_thumbnail/174862.webp?hash=D5U0kLsAIMqNfxrsm-ZyJw&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1009667, + "end_timestamp": 2145884400, + "name": "#206", + "start_timestamp": 1628521200, + "sub_title": "Z=206: Dawn of the Computer", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1009667/chapter_thumbnail/176716.webp?hash=usdbXDYoDfh9Yclpmb9qsA&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1009668, + "end_timestamp": 2145884400, + "name": "#207", + "start_timestamp": 1629644400, + "sub_title": "Z=207: Linking the Circuit Diagram", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1009668/chapter_thumbnail/178006.webp?hash=CFtjUQ6GFHxCMt0r5kiHrg&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1009669, + "end_timestamp": 2145884400, + "name": "#208", + "start_timestamp": 1630249200, + "sub_title": "Z=208: Science Transcends Humanity", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1009669/chapter_thumbnail/178759.webp?hash=Q9kZIaBIQg9KqPlAB7Z2Ug&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1009823, + "end_timestamp": 2145884400, + "name": "#209", + "start_timestamp": 1630854000, + "sub_title": "Z=209: The Rocket's Hard Truth", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1009823/chapter_thumbnail/180847.webp?hash=HHoRvrDO495Sz4_XpiCRMw&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1009824, + "end_timestamp": 2145884400, + "name": "#210", + "start_timestamp": 1631458800, + "sub_title": "Z=210: Not One-Way", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1009824/chapter_thumbnail/181405.webp?hash=eXCeLW_Tv0pIgkIV2wgnQg&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1009825, + "end_timestamp": 2145884400, + "name": "#211", + "start_timestamp": 1631890800, + "sub_title": "Z=211: World Tour for Resources", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1009825/chapter_thumbnail/182512.webp?hash=ZjZyOwvx4t2Tu4IDz9GW-w&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1009826, + "end_timestamp": 2145884400, + "name": "#212", + "start_timestamp": 1632668400, + "sub_title": "Z=212: Final Part: Stone to Space", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1009826/chapter_thumbnail/183277.webp?hash=r_9epfICJxl0Q9wNzJUBbQ&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1010013, + "end_timestamp": 2145884400, + "name": "#213", + "start_timestamp": 1633878000, + "sub_title": "Z=213: Unknown Known", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1010013/chapter_thumbnail/185056.webp?hash=IyaQjVXFHvm8_YHrV27MCw&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1010014, + "end_timestamp": 2145884400, + "name": "#214", + "start_timestamp": 1634482800, + "sub_title": "Z=214: Stone World's Earth Defense Force", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1010014/chapter_thumbnail/185821.webp?hash=lxOkrCvKBlEYKSL0BWfGVg&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1010015, + "end_timestamp": 2145884400, + "name": "#215", + "start_timestamp": 1635087600, + "sub_title": "Z=215: Long, Long Road", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1010015/chapter_thumbnail/186373.webp?hash=1PCW4vjbCFDR96xT-sQgtw&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1010484, + "end_timestamp": 2145884400, + "name": "#216", + "start_timestamp": 1635692400, + "sub_title": "Z=216: Hello, World", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1010484/chapter_thumbnail/186871.webp?hash=kGUYhMJAyH20S5KK7s4PpQ&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1010485, + "end_timestamp": 2145884400, + "name": "#217", + "start_timestamp": 1636297200, + "sub_title": "Z=217: Science Underdogs", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1010485/chapter_thumbnail/187417.webp?hash=bjgqkbb3DLjL2kMEveYDBw&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1010486, + "end_timestamp": 2145884400, + "name": "#218", + "start_timestamp": 1636902000, + "sub_title": "Z=218: WWW", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1010486/chapter_thumbnail/188866.webp?hash=aLoJEJ5yclqOf2Ol5d2QWg&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1010487, + "end_timestamp": 2145884400, + "name": "#219", + "start_timestamp": 1637506800, + "sub_title": "Z=219: Three Heroes", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1010487/chapter_thumbnail/195337.webp?hash=d3YGY3H-tICTT7KCsuwdDQ&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1010488, + "end_timestamp": 2145884400, + "name": "#220", + "start_timestamp": 1638111600, + "sub_title": "Z=220: A Desire for All", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1010488/chapter_thumbnail/195907.webp?hash=ZZk0k8pmX_BK-zXbLH2fQQ&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1011912, + "end_timestamp": 2145884400, + "name": "#221", + "start_timestamp": 1638716400, + "sub_title": "Z=221: Entrusting It All", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1011912/chapter_thumbnail/196519.webp?hash=2M-5i9toJ5K3N219TQ19iw&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1011913, + "end_timestamp": 2145884400, + "name": "#222", + "start_timestamp": 1639321200, + "sub_title": "Z=222: Science Road", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1011913/chapter_thumbnail/197146.webp?hash=vXarR67elYtpV3c6ikdPMw&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1011914, + "end_timestamp": 2145884400, + "name": "#223", + "start_timestamp": 1639926000, + "sub_title": "Z=223: 0", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1011914/chapter_thumbnail/203110.webp?hash=sXhNPVVsTOJYGUAOWHQW0g&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1011915, + "end_timestamp": 2145884400, + "name": "#224", + "start_timestamp": 1641222000, + "sub_title": "Z=224: In Space", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1011915/chapter_thumbnail/203710.webp?hash=AZp9GkSVNPJ1O0lM4RAYtQ&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012639, + "end_timestamp": 2145884400, + "name": "#225", + "start_timestamp": 1642345200, + "sub_title": "Z=225: Docking", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012639/chapter_thumbnail/206689.webp?hash=odLViJKTJpZSb89E0PsMhQ&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012640, + "end_timestamp": 2145884400, + "name": "#226", + "start_timestamp": 1642950000, + "sub_title": "Z=226: Giant Step", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012640/chapter_thumbnail/208087.webp?hash=MBx2zrkQl0gQFEJB8eUWNw&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012641, + "end_timestamp": 2145884400, + "name": "#227", + "start_timestamp": 1643554800, + "sub_title": "Z=227: It Was You", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012641/chapter_thumbnail/208654.webp?hash=lybOixSqPsF8VAsz1Xea-A&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012739, + "end_timestamp": 2145884400, + "name": "#228", + "start_timestamp": 1644159600, + "sub_title": "Z=228: Life Stone", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012739/chapter_thumbnail/209389.webp?hash=KfmHc9aHhzpnwAkaOHQ8KQ&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012780, + "end_timestamp": 2145884400, + "name": "#229", + "start_timestamp": 1644764400, + "sub_title": "Z=229: Why-Man", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012780/chapter_thumbnail/209951.webp?hash=dFAFL6S6RwV0CCbPmApu_w&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012800, + "end_timestamp": 2145884400, + "name": "#230", + "start_timestamp": 1645369200, + "sub_title": "Z=230: Human", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012800/chapter_thumbnail/210667.webp?hash=og1aO4iRrESI8enBmLz85g&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012836, + "end_timestamp": 2145884400, + "name": "#231", + "start_timestamp": 1645974000, + "sub_title": "Z=231: A Future to Get Excited About", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012836/chapter_thumbnail/211153.webp?hash=H2HA_Cs5sv_flSoIRwkxQw&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1012941, + "end_timestamp": 2145884400, + "name": "#232", + "start_timestamp": 1646578800, + "sub_title": "Final Chapter: Dr. Stone", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1012941/chapter_thumbnail/212383.webp?hash=t2aDIU6Sv_L2VKonxovrUQ&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1013713, + "end_timestamp": 2145884400, + "name": "ex", + "start_timestamp": 1656860400, + "sub_title": "Terraforming", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1013713/chapter_thumbnail/224772.webp?hash=inyeMQx-plU0Pg3R_rxQLA&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1019503, + "end_timestamp": 2145884400, + "name": "ex", + "start_timestamp": 1699196400, + "sub_title": "Chapter 1D: Future Message", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1019503/chapter_thumbnail/320824.webp?hash=NypZUqHyfnFG3kEAH5afTw&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1019504, + "end_timestamp": 2145884400, + "name": "ex", + "start_timestamp": 1702825200, + "sub_title": "Chapter 2D: Future Road Map", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1019504/chapter_thumbnail/327171.webp?hash=tGCzSPrCzxsblrkhezsl-g&expires=1771027200", + "title_id": 100010 + }, + { + "chapter_id": 1019502, + "end_timestamp": 2145884400, + "name": "ex", + "start_timestamp": 1703430000, + "sub_title": "Chapter 3D: Future Science", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1019502/chapter_thumbnail/328206.webp?hash=xemI-0fCALxRwdhGQhOKMg&expires=1771027200", + "title_id": 100010 + } + ] + } + ], + "is_simul_released": true, + "number_of_views": 17498075, + "overview": "One fateful day, all of humanity was petrified by a blinding flash of light. After several millennia, high schooler Taiju awakens and finds himself lost in a world of statues. However, he's not alone! His science-loving friend Senku's been up and running for a few months and he's got a grand plan in mind?to kickstart civilization with the power of science!", + "rating": 1, + "sns": { + "body": "#MANGA_Plus Dr. STONE", + "url": "https://jumpg-webapi.tokyo-cdn.com/www/sns_share?title_id=100010" + }, + "title": { + "author": "Riichiro Inagaki / Boichi", + "name": "Dr. STONE", + "portrait_image_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/title_thumbnail_portrait_list/312364.webp?hash=N8-maxyynA0pzXUBWRCIWQ&expires=2145884400", + "title_id": 100010 + }, + "title_image_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/title_thumbnail_main/312361.webp?hash=ExKiQG_xvo2fULojK5vRzg&expires=2145884400", + "viewing_period_description": "The latest 0 chapters are viewable in this title.\nPlease be aware that the 0th latest chapter will be hidden when a new chapter is added." + } + } +} \ No newline at end of file diff --git a/tests/fixtures/api_captures/baseline/0002_manga_viewer_1000311.meta.json b/tests/fixtures/api_captures/baseline/0002_manga_viewer_1000311.meta.json new file mode 100644 index 0000000..da02409 --- /dev/null +++ b/tests/fixtures/api_captures/baseline/0002_manga_viewer_1000311.meta.json @@ -0,0 +1,19 @@ +{ + "captured_at_utc": "2026-02-13T22:09:00.985947+00:00", + "endpoint": "manga_viewer", + "identifier": "1000311", + "params": { + "app_ver": "97", + "chapter_id": 1000311, + "img_quality": "super_high", + "os": "ios", + "os_ver": "18.1", + "secret": "***REDACTED***", + "split": "no" + }, + "parsed_payload_file": "0002_manga_viewer_1000311.response.json", + "payload_sha256": "dde00e41e9180640002f62de69aeb660872aa774bd3c4c69cf7b2d76b34df85f", + "payload_size_bytes": 6718, + "raw_payload_file": "0002_manga_viewer_1000311.pb", + "url": "https://jumpg-api.tokyo-cdn.com/api/manga_viewer" +} \ No newline at end of file diff --git a/tests/fixtures/api_captures/baseline/0002_manga_viewer_1000311.pb b/tests/fixtures/api_captures/baseline/0002_manga_viewer_1000311.pb new file mode 100644 index 0000000..c81b12a Binary files /dev/null and b/tests/fixtures/api_captures/baseline/0002_manga_viewer_1000311.pb differ diff --git a/tests/fixtures/api_captures/baseline/0002_manga_viewer_1000311.response.json b/tests/fixtures/api_captures/baseline/0002_manga_viewer_1000311.response.json new file mode 100644 index 0000000..37417fd --- /dev/null +++ b/tests/fixtures/api_captures/baseline/0002_manga_viewer_1000311.response.json @@ -0,0 +1,297 @@ +{ + "success": { + "manga_viewer": { + "chapter_id": 1000311, + "chapter_name": "#002", + "chapters": [ + { + "already_viewed": true, + "chapter_id": 1000310, + "end_timestamp": 2145884400, + "name": "#001", + "start_timestamp": 1547996400, + "sub_title": "Z=1: Stone World", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1000310/chapter_thumbnail/1684.webp?hash=fe6O3XCjdfpoXhVqDxIWig&expires=1771027200", + "title_id": 100010 + }, + { + "already_viewed": true, + "chapter_id": 1000311, + "end_timestamp": 2145884400, + "name": "#002", + "start_timestamp": 1547996400, + "sub_title": "Z=2: Fantasy vs. Science", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1000311/chapter_thumbnail/1687.webp?hash=EJshh-GXRNlpObwvngdhUQ&expires=1771027200", + "title_id": 100010 + }, + { + "already_viewed": true, + "chapter_id": 1000312, + "end_timestamp": 2145884400, + "name": "#003", + "start_timestamp": 1547996400, + "sub_title": "Z=3: King of the Stone World", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1000312/chapter_thumbnail/1690.webp?hash=BuOcAlWOqonXQB9MTojnNg&expires=1771027200", + "title_id": 100010 + } + ], + "number_of_comments": 49, + "pages": [ + { + "manga_page": { + "height": 2100, + "image_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1000311/manga_page/super_high/1.webp?hash=bKjjgQHsFFEuBwx6Ow1PTQ&expires=1771027200", + "width": 1400 + } + }, + { + "manga_page": { + "height": 2100, + "image_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1000311/manga_page/super_high/2.webp?hash=kaLsldUtJ0ZOhXprD9abyw&expires=1771027200", + "width": 1400 + } + }, + { + "manga_page": { + "height": 2100, + "image_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1000311/manga_page/super_high/3.webp?hash=82xfHVaBvy_yKXs0G6pVwA&expires=1771027200", + "width": 1400 + } + }, + { + "manga_page": { + "height": 2100, + "image_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1000311/manga_page/super_high/4.webp?hash=ywmXzpqCkZoD8-fWx2HKtQ&expires=1771027200", + "width": 1400 + } + }, + { + "manga_page": { + "height": 2100, + "image_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1000311/manga_page/super_high/5.webp?hash=YXkv1pxwX-_gE2ugpIk7xQ&expires=1771027200", + "width": 1400 + } + }, + { + "manga_page": { + "height": 2100, + "image_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1000311/manga_page/super_high/6.webp?hash=ydAccfRn-bn0nHgl4xIiUQ&expires=1771027200", + "width": 1400 + } + }, + { + "manga_page": { + "height": 2100, + "image_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1000311/manga_page/super_high/7.webp?hash=uu4kublp-2yKO-RJ6kE9Gw&expires=1771027200", + "width": 1400 + } + }, + { + "manga_page": { + "height": 2100, + "image_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1000311/manga_page/super_high/8.webp?hash=Iv971siQxU9N-LuM5z9vcA&expires=1771027200", + "width": 1400 + } + }, + { + "manga_page": { + "height": 2100, + "image_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1000311/manga_page/super_high/9.webp?hash=hPDydnvM_p9s6IGIigP_mQ&expires=1771027200", + "width": 1400 + } + }, + { + "manga_page": { + "height": 2100, + "image_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1000311/manga_page/super_high/10.webp?hash=X9p_TfcXvzXoPm3OumJP8Q&expires=1771027200", + "width": 1400 + } + }, + { + "manga_page": { + "height": 2100, + "image_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1000311/manga_page/super_high/11.webp?hash=sFIKF8fZED9dWC07ghzhtA&expires=1771027200", + "width": 1400 + } + }, + { + "manga_page": { + "height": 2100, + "image_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1000311/manga_page/super_high/12.webp?hash=6y_UH-RBevOS2W21sE7yVg&expires=1771027200", + "width": 1400 + } + }, + { + "manga_page": { + "height": 2100, + "image_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1000311/manga_page/super_high/13.webp?hash=MU-7ZukgYCcUVn3AQFvU6w&expires=1771027200", + "width": 1400 + } + }, + { + "manga_page": { + "height": 2100, + "image_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1000311/manga_page/super_high/14.webp?hash=nT8MlorxX9pHFJGwAVFWDg&expires=1771027200", + "width": 1400 + } + }, + { + "manga_page": { + "height": 2100, + "image_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1000311/manga_page/super_high/15.webp?hash=UqPZe9U_2gqyxPqCzia-wg&expires=1771027200", + "width": 1400 + } + }, + { + "manga_page": { + "height": 2100, + "image_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1000311/manga_page/super_high/16.webp?hash=iK2uCv4FR-9cL7UgQBlrbA&expires=1771027200", + "width": 1400 + } + }, + { + "manga_page": { + "height": 2100, + "image_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1000311/manga_page/super_high/17.webp?hash=hc7EcLhbrR-WERKCVh81uA&expires=1771027200", + "width": 1400 + } + }, + { + "manga_page": { + "height": 2100, + "image_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1000311/manga_page/super_high/18.webp?hash=7W3PYonZFWYWzrYg5tTqUQ&expires=1771027200", + "width": 1400 + } + }, + { + "manga_page": { + "height": 2100, + "image_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1000311/manga_page/super_high/19.webp?hash=Z4Yt-Ira50nmDIknf3HlXg&expires=1771027200", + "width": 1400 + } + }, + { + "manga_page": { + "height": 2100, + "image_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1000311/manga_page/super_high/20.webp?hash=3yh7li_QlHZfm6fbfIIu8Q&expires=1771027200", + "width": 1400 + } + }, + { + "manga_page": { + "height": 2100, + "image_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1000311/manga_page/super_high/21.webp?hash=_zaaAYPhYgH2lr4WNVphoA&expires=1771027200", + "width": 1400 + } + }, + { + "manga_page": { + "height": 2100, + "image_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1000311/manga_page/super_high/22.webp?hash=Ks4TJqYURUi1zXpCOXFqLA&expires=1771027200", + "width": 1400 + } + }, + { + "manga_page": { + "height": 2100, + "image_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1000311/manga_page/super_high/23.webp?hash=J7RjON9SaV-Tf0gfs96K5Q&expires=1771027200", + "width": 1400 + } + }, + { + "manga_page": { + "height": 2100, + "image_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1000311/manga_page/super_high/24.webp?hash=pn0vYwGM-KF0u_tIq50R2g&expires=1771027200", + "width": 1400 + } + }, + { + "manga_page": { + "height": 2100, + "image_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1000311/manga_page/super_high/25.webp?hash=AZQ9apcQ1Geps76bVGG5SQ&expires=1771027200", + "width": 1400 + } + }, + { + "last_page": { + "advertisement": {}, + "chapter_type": 1, + "current_chapter": { + "already_viewed": true, + "chapter_id": 1000311, + "end_timestamp": 2145884400, + "name": "#002", + "start_timestamp": 1547996400, + "sub_title": "Z=2: Fantasy vs. Science", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1000311/chapter_thumbnail/1687.webp?hash=EJshh-GXRNlpObwvngdhUQ&expires=1771027200", + "title_id": 100010 + }, + "next_chapter": { + "already_viewed": true, + "chapter_id": 1000312, + "end_timestamp": 2145884400, + "name": "#003", + "start_timestamp": 1547996400, + "sub_title": "Z=3: King of the Stone World", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1000312/chapter_thumbnail/1690.webp?hash=BuOcAlWOqonXQB9MTojnNg&expires=1771027200", + "title_id": 100010 + }, + "top_comments": [ + { + "body": "This manga is awesome! I don't know if it's just me, but the main character reminds me of Edward from Fullmetal Alchemist. ", + "created": 1549306427, + "icon_url": "https://jumpg-assets.tokyo-cdn.com/secure/comment_icon/505.webp?hash=j62Z0uOg0cgxkucJtek5MA&expires=2145884400", + "id": 18618, + "index": 3, + "number_of_likes": 120, + "user_name": "🇺🇸ALL MIGHT🇺🇸" + }, + { + "body": "Honestly, I've only read a few chapters and Im immediately hooked. hope to see more! ", + "created": 1550665491, + "icon_url": "https://jumpg-assets.tokyo-cdn.com/secure/comment_icon/490.webp?hash=XxJv2yajXS_M78VVxF1xzg&expires=2145884400", + "id": 28317, + "index": 5, + "number_of_likes": 77, + "user_name": "TripleSG" + }, + { + "body": "This is the best manga/anime adaptation I’ve seen so far. I’m eager to take a look at how the story develops. Senku is mastermind and a very original character in the anime world. Was not expecting such a good manga when I read the sinopsis but 10/10 would recommend ", + "created": 1567466739, + "icon_url": "https://jumpg-assets.tokyo-cdn.com/secure/comment_icon/412.webp?hash=axvg14npK7yy5V1-JmgVXQ&expires=2145884400", + "id": 163847, + "index": 6, + "number_of_likes": 71, + "user_name": "ValeSenpaiuwu " + }, + { + "body": "bruh the manga is better than the anime change my mind", + "created": 1607121616, + "icon_url": "https://jumpg-assets.tokyo-cdn.com/secure/comment_icon/505.webp?hash=j62Z0uOg0cgxkucJtek5MA&expires=2145884400", + "id": 898232, + "index": 20, + "number_of_likes": 39, + "user_name": "I AM HERE!!!38:)" + }, + { + "body": "I love how he’s just like: “sour grapes? Can I eat them?”\nWithout giving a second thought as to how they might be poisonous 😂", + "created": 1603440374, + "icon_url": "https://jumpg-assets.tokyo-cdn.com/secure/comment_icon/38010.webp?hash=KylTxVmoCIBHe_2ee4RSMg&expires=2145884400", + "id": 803798, + "index": 19, + "number_of_likes": 34, + "user_name": "MeAndMyself" + } + ] + } + } + ], + "sns": { + "body": "#MANGA_Plus Dr. STONE", + "url": "https://jumpg-webapi.tokyo-cdn.com/www/sns_share?title_id=100010&chapter_id=1000311" + }, + "title_id": 100010, + "title_name": "Dr. STONE" + } + } +} \ No newline at end of file diff --git a/tests/fixtures/api_captures/baseline/0003_manga_viewer_1000312.meta.json b/tests/fixtures/api_captures/baseline/0003_manga_viewer_1000312.meta.json new file mode 100644 index 0000000..5a566a6 --- /dev/null +++ b/tests/fixtures/api_captures/baseline/0003_manga_viewer_1000312.meta.json @@ -0,0 +1,19 @@ +{ + "captured_at_utc": "2026-02-13T22:09:13.355930+00:00", + "endpoint": "manga_viewer", + "identifier": "1000312", + "params": { + "app_ver": "97", + "chapter_id": 1000312, + "img_quality": "super_high", + "os": "ios", + "os_ver": "18.1", + "secret": "***REDACTED***", + "split": "no" + }, + "parsed_payload_file": "0003_manga_viewer_1000312.response.json", + "payload_sha256": "bbd2d47306ad0006bb5a1311cb11cc80f94352b4b974999f315d3f5993c89faa", + "payload_size_bytes": 5989, + "raw_payload_file": "0003_manga_viewer_1000312.pb", + "url": "https://jumpg-api.tokyo-cdn.com/api/manga_viewer" +} \ No newline at end of file diff --git a/tests/fixtures/api_captures/baseline/0003_manga_viewer_1000312.pb b/tests/fixtures/api_captures/baseline/0003_manga_viewer_1000312.pb new file mode 100644 index 0000000..f783ded Binary files /dev/null and b/tests/fixtures/api_captures/baseline/0003_manga_viewer_1000312.pb differ diff --git a/tests/fixtures/api_captures/baseline/0003_manga_viewer_1000312.response.json b/tests/fixtures/api_captures/baseline/0003_manga_viewer_1000312.response.json new file mode 100644 index 0000000..32bf2d6 --- /dev/null +++ b/tests/fixtures/api_captures/baseline/0003_manga_viewer_1000312.response.json @@ -0,0 +1,283 @@ +{ + "success": { + "manga_viewer": { + "chapter_id": 1000312, + "chapter_name": "#003", + "chapters": [ + { + "already_viewed": true, + "chapter_id": 1000310, + "end_timestamp": 2145884400, + "name": "#001", + "start_timestamp": 1547996400, + "sub_title": "Z=1: Stone World", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1000310/chapter_thumbnail/1684.webp?hash=fe6O3XCjdfpoXhVqDxIWig&expires=1771027200", + "title_id": 100010 + }, + { + "already_viewed": true, + "chapter_id": 1000311, + "end_timestamp": 2145884400, + "name": "#002", + "start_timestamp": 1547996400, + "sub_title": "Z=2: Fantasy vs. Science", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1000311/chapter_thumbnail/1687.webp?hash=EJshh-GXRNlpObwvngdhUQ&expires=1771027200", + "title_id": 100010 + }, + { + "already_viewed": true, + "chapter_id": 1000312, + "end_timestamp": 2145884400, + "name": "#003", + "start_timestamp": 1547996400, + "sub_title": "Z=3: King of the Stone World", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1000312/chapter_thumbnail/1690.webp?hash=BuOcAlWOqonXQB9MTojnNg&expires=1771027200", + "title_id": 100010 + } + ], + "number_of_comments": 86, + "pages": [ + { + "manga_page": { + "height": 2100, + "image_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1000312/manga_page/super_high/1.webp?hash=PQvy_7kBIVt1KN0YaDnQkw&expires=1771027200", + "width": 1400 + } + }, + { + "manga_page": { + "height": 2100, + "image_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1000312/manga_page/super_high/2.webp?hash=OUz4TZnQ4rCDlLDU_xwD8g&expires=1771027200", + "width": 1400 + } + }, + { + "manga_page": { + "height": 2100, + "image_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1000312/manga_page/super_high/3.webp?hash=VStx5uQwhk2LSYm5S845jA&expires=1771027200", + "width": 1400 + } + }, + { + "manga_page": { + "height": 2100, + "image_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1000312/manga_page/super_high/4.webp?hash=UOb4RmsGOUc9n1R7IfSNBw&expires=1771027200", + "width": 1400 + } + }, + { + "manga_page": { + "height": 2100, + "image_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1000312/manga_page/super_high/5.webp?hash=5fWogMq9zSMJtFPJjQp2Ug&expires=1771027200", + "width": 1400 + } + }, + { + "manga_page": { + "height": 2100, + "image_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1000312/manga_page/super_high/6.webp?hash=8CcyBzLCeIbZKjrVCbOHNg&expires=1771027200", + "width": 1400 + } + }, + { + "manga_page": { + "height": 2100, + "image_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1000312/manga_page/super_high/7.webp?hash=yypQLF2IsvnXP7NuCZbNVw&expires=1771027200", + "width": 1400 + } + }, + { + "manga_page": { + "height": 2100, + "image_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1000312/manga_page/super_high/8.webp?hash=hp8E-gQCyL8Hx4EaH8CN_w&expires=1771027200", + "width": 1400 + } + }, + { + "manga_page": { + "height": 2100, + "image_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1000312/manga_page/super_high/9.webp?hash=Ok6oc9u1Bza1s9h82eextA&expires=1771027200", + "width": 1400 + } + }, + { + "manga_page": { + "height": 2100, + "image_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1000312/manga_page/super_high/10.webp?hash=LLBTc8UkySapqrhKavwyFQ&expires=1771027200", + "width": 1400 + } + }, + { + "manga_page": { + "height": 2100, + "image_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1000312/manga_page/super_high/11.webp?hash=H1R9bYwa1XS7jkqfivksRw&expires=1771027200", + "width": 1400 + } + }, + { + "manga_page": { + "height": 2100, + "image_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1000312/manga_page/super_high/12.webp?hash=jLgHd5xFdNPQYrvyyaToyQ&expires=1771027200", + "width": 1400 + } + }, + { + "manga_page": { + "height": 2100, + "image_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1000312/manga_page/super_high/13.webp?hash=wy4FH5ezzR2-l1T_XveDow&expires=1771027200", + "width": 1400 + } + }, + { + "manga_page": { + "height": 2100, + "image_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1000312/manga_page/super_high/14.webp?hash=dbCQwLMsnsWPxAWpYTIInA&expires=1771027200", + "width": 1400 + } + }, + { + "manga_page": { + "height": 2100, + "image_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1000312/manga_page/super_high/15.webp?hash=AaFd4C-poQI928RvF8HgBQ&expires=1771027200", + "width": 1400 + } + }, + { + "manga_page": { + "height": 2100, + "image_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1000312/manga_page/super_high/16.webp?hash=7W5tTMJxFpHxJoh4xSqdgw&expires=1771027200", + "width": 1400 + } + }, + { + "manga_page": { + "height": 2100, + "image_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1000312/manga_page/super_high/17.webp?hash=LTTQZsXClaIXs_Bjy4JPaA&expires=1771027200", + "width": 1400 + } + }, + { + "manga_page": { + "height": 2100, + "image_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1000312/manga_page/super_high/18.webp?hash=c8SFXo-7622ibiPeKlFXuA&expires=1771027200", + "width": 1400 + } + }, + { + "manga_page": { + "height": 2100, + "image_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1000312/manga_page/super_high/19.webp?hash=eXFmkD4aeE3xG_PsPCWVlQ&expires=1771027200", + "width": 1400 + } + }, + { + "manga_page": { + "height": 2100, + "image_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1000312/manga_page/super_high/20.webp?hash=EPqdgeXD6agtbBxVh_vBxg&expires=1771027200", + "width": 1400 + } + }, + { + "manga_page": { + "height": 2100, + "image_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1000312/manga_page/super_high/21.webp?hash=FSihJaZtqn_gogkIxTnFHg&expires=1771027200", + "width": 1400 + } + }, + { + "manga_page": { + "height": 2100, + "image_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1000312/manga_page/super_high/22.webp?hash=KYjKmhr4pdB43bc6QcM5AA&expires=1771027200", + "width": 1400 + } + }, + { + "manga_page": { + "height": 2100, + "image_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1000312/manga_page/super_high/23.webp?hash=DFM78wW6XOnd25j1PcsiRw&expires=1771027200", + "width": 1400 + } + }, + { + "last_page": { + "advertisement": {}, + "chapter_type": 2, + "current_chapter": { + "already_viewed": true, + "chapter_id": 1000312, + "end_timestamp": 2145884400, + "name": "#003", + "start_timestamp": 1547996400, + "sub_title": "Z=3: King of the Stone World", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1000312/chapter_thumbnail/1690.webp?hash=BuOcAlWOqonXQB9MTojnNg&expires=1771027200", + "title_id": 100010 + }, + "next_chapter": { + "already_viewed": true, + "chapter_id": 1000313, + "end_timestamp": 2145884400, + "name": "#004", + "start_timestamp": 1547996400, + "sub_title": "Z=4: Pure White Seashells", + "thumbnail_url": "https://jumpg-assets.tokyo-cdn.com/secure/title/100010/chapter/1000313/chapter_thumbnail/1693.webp?hash=l0MeH1w8JRGi_3c-58R5EQ&expires=1771027200", + "title_id": 100010 + }, + "top_comments": [ + { + "body": "Would love chapter 4 onward ", + "created": 1549332998, + "icon_url": "https://jumpg-assets.tokyo-cdn.com/secure/comment_icon/415.webp?hash=yB1cMi-rGDOohtAf-B7bgw&expires=2145884400", + "id": 18927, + "index": 3, + "number_of_likes": 200, + "user_name": "GlassHP" + }, + { + "body": "Yoo pls we need the missing chapters we cant go from chap 3 to 98 like that ! ", + "created": 1554851818, + "icon_url": "https://jumpg-assets.tokyo-cdn.com/secure/comment_icon/418.webp?hash=qFB5esuY30sGI7jXkW57tA&expires=2145884400", + "id": 70011, + "index": 11, + "number_of_likes": 173, + "user_name": "ohlekip" + }, + { + "body": "where are the other chapters before 89?", + "created": 1548762327, + "icon_url": "https://jumpg-assets.tokyo-cdn.com/secure/comment_icon/739.webp?hash=U240p1Hk9ZmZzSFwHFh0xg&expires=2145884400", + "id": 10704, + "index": 1, + "number_of_likes": 85, + "user_name": "shrek" + }, + { + "body": "I love Dr stone dude this manga is very fun ", + "created": 1549363307, + "icon_url": "https://jumpg-assets.tokyo-cdn.com/secure/comment_icon/304.webp?hash=_VtB92NbqWqxYMrK2SQWyg&expires=2145884400", + "id": 19236, + "index": 4, + "number_of_likes": 77, + "user_name": "Genta" + }, + { + "body": "the next chapters are still not available because they probably will upload a new one weekly, like the other series.", + "created": 1548817965, + "icon_url": "https://jumpg-assets.tokyo-cdn.com/secure/comment_icon/478.webp?hash=OyzGvQBuriqZu8JcCe5nHQ&expires=2145884400", + "id": 11763, + "index": 2, + "number_of_likes": 55, + "user_name": "Kurochou" + } + ] + } + } + ], + "sns": { + "body": "#MANGA_Plus Dr. STONE", + "url": "https://jumpg-webapi.tokyo-cdn.com/www/sns_share?title_id=100010&chapter_id=1000312" + }, + "title_id": 100010, + "title_name": "Dr. STONE" + } + } +} \ No newline at end of file diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..488270e --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,415 @@ +"""Tests for API helper functions and caching mixin methods.""" + +from __future__ import annotations + +from types import SimpleNamespace +from typing import Any + +import pytest + +from mloader.errors import APIResponseError +from mloader.manga_loader import api + + +class DummySession: + """HTTP session test double collecting outgoing requests.""" + + def __init__(self, content: bytes = b"payload") -> None: + """Initialize a dummy session returning ``content`` responses.""" + self.content = content + self.calls: list[tuple[str, dict[str, Any] | None]] = [] + + def get( + self, + url: str, + params: dict[str, Any] | None = None, + timeout: tuple[float, float] | None = None, + ) -> SimpleNamespace: + """Record request details and return a simple response object.""" + del timeout + self.calls.append((url, params)) + return SimpleNamespace(content=self.content, raise_for_status=lambda: None) + + +class DummyLoader(api.APILoaderMixin): + """APILoaderMixin harness with controllable split and quality values.""" + + def __init__(self, split: bool = True, quality: str = "high") -> None: + """Initialize loader state with a dummy session.""" + self._api_url = "https://api.example" + self.split = split + self.quality = quality + self.request_timeout = (1.0, 2.0) + self.session = DummySession() + self.payload_capture = None + + +def test_parse_manga_viewer_response(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify viewer parser extracts ``success.manga_viewer`` payload.""" + sentinel = SimpleNamespace(title_id=100312, chapter_id=1024959, pages=[SimpleNamespace()]) + + class FakeResponse: + @staticmethod + def FromString(_content: bytes) -> SimpleNamespace: + """Return a namespaced response carrying a manga viewer sentinel.""" + return SimpleNamespace(success=SimpleNamespace(manga_viewer=sentinel)) + + monkeypatch.setattr(api, "Response", FakeResponse) + + assert api._parse_manga_viewer_response(b"raw") is sentinel + + +def test_parse_title_detail_response(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify title-detail parser extracts ``success.title_detail_view`` payload.""" + sentinel = SimpleNamespace( + title=SimpleNamespace(title_id=100312, name="Test"), + chapter_list_group=[ + SimpleNamespace( + first_chapter_list=[SimpleNamespace()], mid_chapter_list=[], last_chapter_list=[] + ) + ], + ) + + class FakeResponse: + @staticmethod + def FromString(_content: bytes) -> SimpleNamespace: + """Return a namespaced response carrying a title-detail sentinel.""" + return SimpleNamespace(success=SimpleNamespace(title_detail_view=sentinel)) + + monkeypatch.setattr(api, "Response", FakeResponse) + + assert api._parse_title_detail_response(b"raw") is sentinel + + +def test_parse_manga_viewer_response_raises_for_missing_payload( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify viewer parser rejects payloads without ``success.manga_viewer``.""" + + class SuccessEnvelope: + def HasField(self, name: str) -> bool: + return name != "manga_viewer" + + class FakeResponse: + @staticmethod + def FromString(_content: bytes) -> SimpleNamespace: + return SimpleNamespace(success=SuccessEnvelope()) + + monkeypatch.setattr(api, "Response", FakeResponse) + + with pytest.raises(APIResponseError, match="no manga_viewer payload"): + api._parse_manga_viewer_response(b"raw") + + +def test_parse_title_detail_response_raises_for_missing_payload( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify title parser rejects payloads without ``success.title_detail_view``.""" + + class SuccessEnvelope: + def HasField(self, name: str) -> bool: + return name != "title_detail_view" + + class FakeResponse: + @staticmethod + def FromString(_content: bytes) -> SimpleNamespace: + return SimpleNamespace(success=SuccessEnvelope()) + + monkeypatch.setattr(api, "Response", FakeResponse) + + with pytest.raises(APIResponseError, match="no title_detail_view payload"): + api._parse_title_detail_response(b"raw") + + +def test_has_message_field_handles_non_protobuf_messages() -> None: + """Verify ``_has_message_field`` returns true for objects without ``HasField``.""" + assert api._has_message_field(object(), "any") is True + + +def test_has_message_field_handles_invalid_field_name() -> None: + """Verify ``_has_message_field`` returns false when protobuf rejects field name.""" + + class Message: + def HasField(self, _name: str) -> bool: + raise ValueError("unknown field") + + assert api._has_message_field(Message(), "missing") is False + + +def test_parse_manga_viewer_response_raises_for_missing_ids( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify viewer parser rejects payloads missing title/chapter IDs.""" + viewer = SimpleNamespace(title_id=0, chapter_id=0, pages=[SimpleNamespace()]) + + class FakeResponse: + @staticmethod + def FromString(_content: bytes) -> SimpleNamespace: + return SimpleNamespace(success=SimpleNamespace(manga_viewer=viewer)) + + monkeypatch.setattr(api, "Response", FakeResponse) + + with pytest.raises(APIResponseError, match="without title/chapter IDs"): + api._parse_manga_viewer_response(b"raw") + + +def test_parse_manga_viewer_response_raises_for_missing_pages( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify viewer parser rejects payloads with no page entries.""" + viewer = SimpleNamespace(title_id=1, chapter_id=2, pages=[]) + + class FakeResponse: + @staticmethod + def FromString(_content: bytes) -> SimpleNamespace: + return SimpleNamespace(success=SimpleNamespace(manga_viewer=viewer)) + + monkeypatch.setattr(api, "Response", FakeResponse) + + with pytest.raises(APIResponseError, match="without pages"): + api._parse_manga_viewer_response(b"raw") + + +def test_parse_title_detail_response_raises_for_missing_title_identity( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify title parser rejects payloads missing title identity fields.""" + title_detail = SimpleNamespace( + title=SimpleNamespace(title_id=0, name=""), + chapter_list_group=[ + SimpleNamespace( + first_chapter_list=[SimpleNamespace()], mid_chapter_list=[], last_chapter_list=[] + ) + ], + ) + + class FakeResponse: + @staticmethod + def FromString(_content: bytes) -> SimpleNamespace: + return SimpleNamespace(success=SimpleNamespace(title_detail_view=title_detail)) + + monkeypatch.setattr(api, "Response", FakeResponse) + + with pytest.raises(APIResponseError, match="without title identity"): + api._parse_title_detail_response(b"raw") + + +def test_parse_title_detail_response_raises_for_missing_groups( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify title parser rejects payloads with no chapter groups.""" + title_detail = SimpleNamespace( + title=SimpleNamespace(title_id=1, name="T"), + chapter_list_group=[], + ) + + class FakeResponse: + @staticmethod + def FromString(_content: bytes) -> SimpleNamespace: + return SimpleNamespace(success=SimpleNamespace(title_detail_view=title_detail)) + + monkeypatch.setattr(api, "Response", FakeResponse) + + with pytest.raises(APIResponseError, match="without chapter groups"): + api._parse_title_detail_response(b"raw") + + +def test_parse_title_detail_response_raises_for_missing_entries( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify title parser rejects groups that contain no chapters.""" + title_detail = SimpleNamespace( + title=SimpleNamespace(title_id=1, name="T"), + chapter_list_group=[ + SimpleNamespace(first_chapter_list=[], mid_chapter_list=[], last_chapter_list=[]) + ], + ) + + class FakeResponse: + @staticmethod + def FromString(_content: bytes) -> SimpleNamespace: + return SimpleNamespace(success=SimpleNamespace(title_detail_view=title_detail)) + + monkeypatch.setattr(api, "Response", FakeResponse) + + with pytest.raises(APIResponseError, match="without chapter entries"): + api._parse_title_detail_response(b"raw") + + +def test_build_title_detail_params_includes_auth_values() -> None: + """Verify title-detail parameter builder includes auth and title ID.""" + params = api._build_title_detail_params(123) + + assert params["title_id"] == 123 + assert "app_ver" in params + assert "secret" in params + + +def test_manga_viewer_url_and_params() -> None: + """Verify viewer URL and query parameter builder output.""" + loader = DummyLoader(split=False, quality="low") + + assert loader._build_manga_viewer_url() == "https://api.example/api/manga_viewer" + + params = loader._build_manga_viewer_params(10) + assert params["chapter_id"] == 10 + assert params["split"] == "no" + assert params["img_quality"] == "low" + + +def test_load_pages_uses_cache(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify load_pages uses cache for repeated chapter lookups.""" + loader = DummyLoader() + + monkeypatch.setattr(api, "_parse_manga_viewer_response", lambda content: {"parsed": content}) + + first = loader._load_pages(5) + second = loader._load_pages(5) + + assert first == {"parsed": b"payload"} + assert second == first + assert len(loader.session.calls) == 1 + + +def test_get_title_details_uses_cache(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify get_title_details uses cache for repeated title lookups.""" + loader = DummyLoader() + + monkeypatch.setattr(api, "_parse_title_detail_response", lambda content: {"parsed": content}) + + first = loader._get_title_details(77) + second = loader._get_title_details(77) + + assert first == {"parsed": b"payload"} + assert second == first + assert len(loader.session.calls) == 1 + + +def test_title_detail_url() -> None: + """Verify title-detail endpoint URL is built correctly.""" + loader = DummyLoader() + assert loader._build_title_detail_url() == "https://api.example/api/title_detailV3" + + +def test_load_pages_captures_payload_when_enabled(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify _load_pages forwards response payloads to capture backend.""" + loader = DummyLoader() + captured: list[dict[str, Any]] = [] + + class Capture: + def capture(self, **kwargs: Any) -> None: + captured.append(kwargs) + + loader.payload_capture = Capture() + monkeypatch.setattr(api, "_parse_manga_viewer_response", lambda content: {"parsed": content}) + + loader._load_pages(123) + + assert len(captured) == 1 + assert captured[0]["endpoint"] == "manga_viewer" + assert captured[0]["identifier"] == 123 + assert captured[0]["url"].endswith("/api/manga_viewer") + assert captured[0]["response_content"] == b"payload" + + +def test_get_title_details_captures_payload_when_enabled( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify _get_title_details forwards response payloads to capture backend.""" + loader = DummyLoader() + captured: list[dict[str, Any]] = [] + + class Capture: + def capture(self, **kwargs: Any) -> None: + captured.append(kwargs) + + loader.payload_capture = Capture() + monkeypatch.setattr(api, "_parse_title_detail_response", lambda content: {"parsed": content}) + + loader._get_title_details(88) + + assert len(captured) == 1 + assert captured[0]["endpoint"] == "title_detailV3" + assert captured[0]["identifier"] == 88 + assert captured[0]["url"].endswith("/api/title_detailV3") + assert captured[0]["response_content"] == b"payload" + + +def test_clear_api_caches_for_run_empties_both_caches(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify run-level clear removes cached title and chapter responses.""" + loader = DummyLoader() + monkeypatch.setattr(api, "_parse_manga_viewer_response", lambda content: {"parsed": content}) + monkeypatch.setattr(api, "_parse_title_detail_response", lambda content: {"parsed": content}) + + loader._load_pages(1) + loader._get_title_details(2) + assert loader._get_viewer_cache() + assert loader._get_title_cache() + + loader._clear_api_caches_for_run() + + assert loader._get_viewer_cache() == {} + assert loader._get_title_cache() == {} + + +def test_clear_api_caches_for_title_removes_selected_entries( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify title-level clear removes the requested title and chapter cache entries.""" + loader = DummyLoader() + monkeypatch.setattr(api, "_parse_manga_viewer_response", lambda content: {"parsed": content}) + monkeypatch.setattr(api, "_parse_title_detail_response", lambda content: {"parsed": content}) + + loader._load_pages(10) + loader._load_pages(11) + loader._get_title_details(20) + loader._get_title_details(21) + + loader._clear_api_caches_for_title(20, [10]) + + assert "20" not in loader._get_title_cache() + assert "21" in loader._get_title_cache() + assert "10" not in loader._get_viewer_cache() + assert "11" in loader._get_viewer_cache() + + +def test_clear_api_caches_for_title_without_chapters_only_clears_title( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify title-only clear keeps viewer cache entries intact.""" + loader = DummyLoader() + monkeypatch.setattr(api, "_parse_manga_viewer_response", lambda content: {"parsed": content}) + monkeypatch.setattr(api, "_parse_title_detail_response", lambda content: {"parsed": content}) + + loader._load_pages(10) + loader._get_title_details(20) + + loader._clear_api_caches_for_title(20, None) + + assert "20" not in loader._get_title_cache() + assert "10" in loader._get_viewer_cache() + + +def test_load_pages_cache_respects_max_size(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify chapter cache evicts oldest entries when max size is exceeded.""" + loader = DummyLoader() + loader._viewer_cache_max_size = 2 + monkeypatch.setattr(api, "_parse_manga_viewer_response", lambda content: {"parsed": content}) + + loader._load_pages(1) + loader._load_pages(2) + loader._load_pages(3) + + assert set(loader._get_viewer_cache().keys()) == {"2", "3"} + + +def test_get_title_details_cache_respects_max_size(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify title cache evicts oldest entries when max size is exceeded.""" + loader = DummyLoader() + loader._title_cache_max_size = 1 + monkeypatch.setattr(api, "_parse_title_detail_response", lambda content: {"parsed": content}) + + loader._get_title_details(1) + loader._get_title_details(2) + + assert set(loader._get_title_cache().keys()) == {"2"} diff --git a/tests/test_application_workflows.py b/tests/test_application_workflows.py new file mode 100644 index 0000000..899bd64 --- /dev/null +++ b/tests/test_application_workflows.py @@ -0,0 +1,453 @@ +"""Unit tests for application-layer workflow helpers.""" + +from __future__ import annotations + +from typing import Any, ClassVar + +import pytest +import requests + +from mloader.application import workflows +from mloader.domain.requests import DownloadRequest, DownloadSummary +from mloader.errors import APIResponseError +from mloader.manga_loader.downloader import DownloadInterruptedError + + +class DummyRawExporter: + """Marker exporter for raw output selection tests.""" + + +class DummyPdfExporter: + """Marker exporter for PDF output selection tests.""" + + +class DummyCbzExporter: + """Marker exporter for CBZ output selection tests.""" + + +class DummyLoader: + """Loader test double for application execute_download tests.""" + + init_args: ClassVar[dict[str, Any] | None] = None + download_args: ClassVar[dict[str, Any] | None] = None + + def __init__( + self, + exporter_factory: Any, + quality: str, + split: bool, + meta: bool, + cover: bool, + destination: str, + output_format: str, + capture_api_dir: str | None, + resume: bool, + manifest_reset: bool, + ) -> None: + """Capture initializer values for assertions.""" + type(self).init_args = { + "exporter_factory": exporter_factory, + "quality": quality, + "split": split, + "meta": meta, + "cover": cover, + "destination": destination, + "output_format": output_format, + "capture_api_dir": capture_api_dir, + "resume": resume, + "manifest_reset": manifest_reset, + } + + def download(self, **kwargs: Any) -> DownloadSummary: + """Capture download invocation payload.""" + type(self).download_args = kwargs + return DownloadSummary( + downloaded=2, + skipped_manifest=1, + failed=0, + failed_chapter_ids=(), + ) + + +class RequestFailingLoader(DummyLoader): + """Loader test double that fails with a request-layer exception.""" + + def download(self, **kwargs: Any) -> None: + """Raise request exception for external-dependency mapping tests.""" + del kwargs + raise requests.RequestException("network") + + +class InvalidPayloadLoader(DummyLoader): + """Loader test double that fails with API payload validation errors.""" + + def download(self, **kwargs: Any) -> None: + """Raise APIResponseError for workflow external-dependency mapping tests.""" + del kwargs + raise APIResponseError("MangaPlus API returned no manga_viewer payload.") + + +class InterruptingLoader(DummyLoader): + """Loader test double that raises interrupt wrapper with partial summary.""" + + def download(self, **kwargs: Any) -> None: + """Raise interrupted-download error to verify workflow mapping behavior.""" + del kwargs + raise DownloadInterruptedError( + DownloadSummary( + downloaded=3, + skipped_manifest=1, + failed=1, + failed_chapter_ids=(77,), + ) + ) + + +class NoneReturningLoader: + """Loader test double returning a non-summary sentinel value.""" + + def __init__( + self, + exporter_factory: Any, + quality: str, + split: bool, + meta: bool, + cover: bool, + destination: str, + output_format: str, + capture_api_dir: str | None, + resume: bool, + manifest_reset: bool, + ) -> None: + """Accept standard loader constructor arguments and ignore payload.""" + del ( + exporter_factory, + quality, + split, + meta, + cover, + destination, + output_format, + capture_api_dir, + resume, + manifest_reset, + ) + + def download(self, **kwargs: Any) -> None: + """Return None to exercise workflow summary fallback branch.""" + del kwargs + return None + + +def _build_request(*, raw: bool = False, output_format: str = "cbz") -> DownloadRequest: + """Build a deterministic download request for workflow helper tests.""" + return DownloadRequest( + out_dir="/tmp/downloads", + raw=raw, + output_format=("pdf" if output_format == "pdf" else "cbz"), + capture_api_dir="/tmp/capture", + quality="high", + split=True, + begin=1, + end=5, + last=True, + chapter_title=True, + chapter_subdir=False, + meta=True, + cover=False, + resume=True, + manifest_reset=False, + chapters=frozenset({10, 11}), + chapter_ids=frozenset({1024959}), + titles=frozenset({100001}), + ) + + +def test_verify_discovery_flags_rejects_list_only_without_all() -> None: + """Verify list-only validation fails when all-mode is disabled.""" + message = workflows.verify_discovery_flags( + download_all_titles=False, + list_only=True, + languages=(), + ) + + assert message == "--list-only requires --all." + + +def test_verify_discovery_flags_rejects_language_without_all() -> None: + """Verify language validation fails when all-mode is disabled.""" + message = workflows.verify_discovery_flags( + download_all_titles=False, + list_only=False, + languages=("english",), + ) + + assert message == "--language requires --all." + + +def test_verify_discovery_flags_accepts_all_mode() -> None: + """Verify discovery flag validation allows all-mode combinations.""" + message = workflows.verify_discovery_flags( + download_all_titles=True, + list_only=True, + languages=("english",), + ) + + assert message is None + + +def test_resolve_exporter_prefers_raw_over_requested_format() -> None: + """Verify raw mode always forces raw exporter selection.""" + request = _build_request(raw=True, output_format="pdf") + + exporter, effective_format = workflows.resolve_exporter( + request, + raw_exporter=DummyRawExporter, + pdf_exporter=DummyPdfExporter, + cbz_exporter=DummyCbzExporter, + ) + + assert exporter is DummyRawExporter + assert effective_format == "raw" + + +def test_resolve_exporter_selects_pdf_when_requested() -> None: + """Verify non-raw PDF requests resolve to PDF exporter.""" + request = _build_request(raw=False, output_format="pdf") + + exporter, effective_format = workflows.resolve_exporter( + request, + raw_exporter=DummyRawExporter, + pdf_exporter=DummyPdfExporter, + cbz_exporter=DummyCbzExporter, + ) + + assert exporter is DummyPdfExporter + assert effective_format == "pdf" + + +def test_resolve_exporter_falls_back_to_cbz() -> None: + """Verify non-raw non-PDF requests resolve to CBZ exporter.""" + request = _build_request(raw=False, output_format="cbz") + + exporter, effective_format = workflows.resolve_exporter( + request, + raw_exporter=DummyRawExporter, + pdf_exporter=DummyPdfExporter, + cbz_exporter=DummyCbzExporter, + ) + + assert exporter is DummyCbzExporter + assert effective_format == "cbz" + + +def test_execute_download_wires_loader_and_download_targets() -> None: + """Verify execute_download builds loader and forwards normalized targets.""" + DummyLoader.init_args = None + DummyLoader.download_args = None + request = _build_request(raw=False, output_format="pdf") + + summary = workflows.execute_download( + request, + loader_factory=DummyLoader, + raw_exporter=DummyRawExporter, + pdf_exporter=DummyPdfExporter, + cbz_exporter=DummyCbzExporter, + ) + + assert DummyLoader.init_args is not None + assert DummyLoader.download_args is not None + assert DummyLoader.init_args["output_format"] == "pdf" + assert DummyLoader.init_args["capture_api_dir"] == "/tmp/capture" + assert DummyLoader.init_args["resume"] is True + assert DummyLoader.init_args["manifest_reset"] is False + assert DummyLoader.init_args["cover"] is False + assert DummyLoader.init_args["quality"] == "high" + assert DummyLoader.download_args["title_ids"] == frozenset({100001}) + assert DummyLoader.download_args["chapter_numbers"] == frozenset({10, 11}) + assert DummyLoader.download_args["chapter_ids"] == frozenset({1024959}) + assert DummyLoader.download_args["min_chapter"] == 1 + assert DummyLoader.download_args["max_chapter"] == 5 + assert DummyLoader.download_args["last_chapter"] is True + assert summary == DownloadSummary( + downloaded=2, + skipped_manifest=1, + failed=0, + failed_chapter_ids=(), + ) + + +def test_execute_download_omits_empty_target_filters() -> None: + """Verify execute_download forwards None when both target sets are empty.""" + DummyLoader.download_args = None + request = DownloadRequest( + out_dir="/tmp/downloads", + raw=False, + output_format="cbz", + capture_api_dir=None, + quality="high", + split=False, + begin=0, + end=None, + last=False, + chapter_title=False, + chapter_subdir=False, + meta=False, + cover=False, + resume=True, + manifest_reset=False, + chapters=frozenset(), + chapter_ids=frozenset(), + titles=frozenset(), + ) + + workflows.execute_download( + request, + loader_factory=DummyLoader, + raw_exporter=DummyRawExporter, + pdf_exporter=DummyPdfExporter, + cbz_exporter=DummyCbzExporter, + ) + + assert DummyLoader.download_args is not None + assert DummyLoader.download_args["title_ids"] is None + assert DummyLoader.download_args["chapter_numbers"] is None + assert DummyLoader.download_args["chapter_ids"] is None + + +def test_execute_download_wraps_request_errors_as_external_dependency_failure() -> None: + """Verify request-layer failures are normalized into workflow external errors.""" + request = _build_request(raw=False, output_format="cbz") + + with pytest.raises(workflows.ExternalDependencyError, match="Download request failed: network"): + workflows.execute_download( + request, + loader_factory=RequestFailingLoader, + raw_exporter=DummyRawExporter, + pdf_exporter=DummyPdfExporter, + cbz_exporter=DummyCbzExporter, + ) + + +def test_execute_download_wraps_api_payload_errors_as_external_dependency_failure() -> None: + """Verify invalid API payload failures map to workflow external errors.""" + request = _build_request(raw=False, output_format="cbz") + + with pytest.raises( + workflows.ExternalDependencyError, + match="Download request failed: MangaPlus API returned no manga_viewer payload.", + ): + workflows.execute_download( + request, + loader_factory=InvalidPayloadLoader, + raw_exporter=DummyRawExporter, + pdf_exporter=DummyPdfExporter, + cbz_exporter=DummyCbzExporter, + ) + + +def test_execute_download_wraps_interrupt_as_workflow_interrupt() -> None: + """Verify interrupted downloader runs are normalized with partial summary.""" + request = _build_request(raw=False, output_format="cbz") + + with pytest.raises(workflows.DownloadInterrupted) as interrupted: + workflows.execute_download( + request, + loader_factory=InterruptingLoader, + raw_exporter=DummyRawExporter, + pdf_exporter=DummyPdfExporter, + cbz_exporter=DummyCbzExporter, + ) + + assert interrupted.value.summary == DownloadSummary( + downloaded=3, + skipped_manifest=1, + failed=1, + failed_chapter_ids=(77,), + ) + + +def test_execute_download_falls_back_when_loader_returns_non_summary() -> None: + """Verify execute_download normalizes non-summary loader returns.""" + request = _build_request(raw=False, output_format="cbz") + + summary = workflows.execute_download( + request, + loader_factory=NoneReturningLoader, + raw_exporter=DummyRawExporter, + pdf_exporter=DummyPdfExporter, + cbz_exporter=DummyCbzExporter, + ) + + assert summary == DownloadSummary( + downloaded=0, + skipped_manifest=0, + failed=0, + failed_chapter_ids=(), + ) + + +def test_format_helpers_return_expected_cli_strings() -> None: + """Verify helper formatters produce deterministic human-facing output.""" + assert workflows.summarize_discovery([1, 2, 3]) == "Discovered 3 title ID(s)." + assert workflows.format_discovered_ids([100001, 100002]) == "100001 100002" + + +def test_build_request_helpers_create_immutable_domain_models() -> None: + """Verify request builder helpers normalize and freeze collection inputs.""" + download_request = workflows.build_download_request( + out_dir="/tmp/downloads", + raw=False, + output_format="pdf", + capture_api_dir=None, + quality="high", + split=False, + begin=0, + end=None, + last=False, + chapter_title=False, + chapter_subdir=False, + meta=False, + cover=True, + resume=False, + manifest_reset=True, + chapters={5, 5}, + chapter_ids={1024959, 1024959}, + titles={100010, 100010}, + ) + discovery_request = workflows.build_discovery_request( + pages=("https://example.com",), + title_index_endpoint="https://api.example/allV2", + id_length=6, + languages=("english",), + browser_fallback=True, + ) + + assert download_request.output_format == "pdf" + assert download_request.chapters == frozenset({5}) + assert download_request.chapter_ids == frozenset({1024959}) + assert download_request.titles == frozenset({100010}) + assert download_request.cover is True + assert download_request.resume is False + assert download_request.manifest_reset is True + assert discovery_request.title_index_endpoint == "https://api.example/allV2" + assert discovery_request.languages == ("english",) + + +def test_to_chapter_id_debug_map_includes_expected_keys() -> None: + """Verify debug-map helper exposes stable low-cardinality fields.""" + request = _build_request(raw=False, output_format="cbz") + debug_map = workflows.to_chapter_id_debug_map(request) + + assert debug_map == { + "target_titles": 1, + "target_chapters": 2, + "target_chapter_ids": 1, + "begin": 1, + "end": 5, + "raw": False, + "format": "cbz", + "cover": False, + "resume": True, + "manifest_reset": False, + } diff --git a/tests/test_capture.py b/tests/test_capture.py new file mode 100644 index 0000000..ed689f0 --- /dev/null +++ b/tests/test_capture.py @@ -0,0 +1,99 @@ +"""Tests for API payload capture persistence helpers.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from mloader.manga_loader import capture as capture_module + + +def test_sanitize_filename_replaces_unsafe_characters() -> None: + """Verify filename sanitizer removes unsupported filesystem characters.""" + assert capture_module._sanitize_filename(" chapter:/1 ") == "chapter_1" + assert capture_module._sanitize_filename("...") == "capture" + + +def test_redact_params_masks_sensitive_keys() -> None: + """Verify sensitive query values are replaced in metadata output.""" + params = {"a": 1, "secret": "abc", "Token": "xyz"} + redacted = capture_module._redact_params(params) + + assert redacted["a"] == 1 + assert redacted["secret"] == "***REDACTED***" + assert redacted["Token"] == "***REDACTED***" + + +def test_payload_capture_writes_raw_metadata_and_parsed_json( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """Verify capture mode writes protobuf bytes, metadata, and parsed JSON.""" + + class FakeResponse: + @staticmethod + def FromString(_payload: bytes) -> object: + return object() + + monkeypatch.setattr(capture_module, "Response", FakeResponse) + monkeypatch.setattr(capture_module, "MessageToDict", lambda _msg, **_kwargs: {"ok": True}) + + recorder = capture_module.APIPayloadCapture(tmp_path) + recorder.capture( + endpoint="manga_viewer", + identifier=123, + url="https://api.example/api/manga_viewer", + params={"chapter_id": 123, "secret": "hidden"}, + response_content=b"\x01\x02", + ) + + raw_files = sorted(tmp_path.glob("*.pb")) + meta_files = sorted(tmp_path.glob("*.meta.json")) + parsed_files = sorted(tmp_path.glob("*.response.json")) + + assert len(raw_files) == 1 + assert len(meta_files) == 1 + assert len(parsed_files) == 1 + + metadata = json.loads(meta_files[0].read_text(encoding="utf-8")) + assert metadata["endpoint"] == "manga_viewer" + assert metadata["identifier"] == "123" + assert metadata["params"]["secret"] == "***REDACTED***" + assert metadata["parsed_payload_file"] == parsed_files[0].name + assert raw_files[0].read_bytes() == b"\x01\x02" + + parsed = json.loads(parsed_files[0].read_text(encoding="utf-8")) + assert parsed == {"ok": True} + + +def test_payload_capture_records_parse_error_without_json( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """Verify parse failures still keep raw payload and metadata.""" + + class FakeResponse: + @staticmethod + def FromString(_payload: bytes) -> object: + raise ValueError("bad payload") + + monkeypatch.setattr(capture_module, "Response", FakeResponse) + + recorder = capture_module.APIPayloadCapture(tmp_path) + recorder.capture( + endpoint="title_detailV3", + identifier="foo", + url="https://api.example/api/title_detailV3", + params={"title_id": 7}, + response_content=b"\x09", + ) + + meta_files = sorted(tmp_path.glob("*.meta.json")) + assert len(meta_files) == 1 + assert list(tmp_path.glob("*.response.json")) == [] + + metadata = json.loads(meta_files[0].read_text(encoding="utf-8")) + assert metadata["endpoint"] == "title_detailV3" + assert "parsed_payload_error" in metadata diff --git a/tests/test_capture_replay.py b/tests/test_capture_replay.py new file mode 100644 index 0000000..4f7d904 --- /dev/null +++ b/tests/test_capture_replay.py @@ -0,0 +1,285 @@ +"""Replay tests using real captured API payload fixtures.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any +from types import SimpleNamespace +from urllib.parse import urlparse + +import pytest + +from mloader.manga_loader import api +from mloader.manga_loader.init import MangaLoader +from mloader.manga_loader.services import ChapterPlanner +from mloader.utils import escape_path + +FIXTURE_CAPTURE_DIR = Path(__file__).parent / "fixtures" / "api_captures" / "baseline" +LOCAL_CAPTURE_DIR = Path("capture") + + +def _as_dict(value: object, context: str) -> dict[str, Any]: + """Return ``value`` as a dictionary or raise a descriptive assertion error.""" + if not isinstance(value, dict): + raise AssertionError(f"Expected dict for {context}, got {type(value).__name__}") + return value + + +def _as_list(value: object, context: str) -> list[Any]: + """Return ``value`` as a list or raise a descriptive assertion error.""" + if not isinstance(value, list): + raise AssertionError(f"Expected list for {context}, got {type(value).__name__}") + return value + + +def _load_json(path: Path) -> dict[str, Any]: + """Load and return JSON object from ``path``.""" + return _as_dict(json.loads(path.read_text(encoding="utf-8")), str(path)) + + +def _collect_capture_records(capture_dir: Path) -> list[tuple[str, dict[str, Any], dict[str, Any]]]: + """Collect capture metadata/response records from ``capture_dir``.""" + records: list[tuple[str, dict[str, Any], dict[str, Any]]] = [] + for meta_path in sorted(capture_dir.glob("*.meta.json")): + stem = meta_path.name.removesuffix(".meta.json") + response_path = capture_dir / f"{stem}.response.json" + if not response_path.exists(): + raise AssertionError(f"Missing response JSON for capture stem: {stem}") + records.append((stem, _load_json(meta_path), _load_json(response_path))) + return records + + +def _schema_signature(meta: dict[str, Any], response: dict[str, Any]) -> dict[str, object]: + """Build a schema signature from capture metadata and parsed response JSON.""" + endpoint = str(meta["endpoint"]) + success = _as_dict(response["success"], "response.success") + signature: dict[str, object] = { + "endpoint": endpoint, + "url_path": urlparse(str(meta["url"])).path, + "meta_keys": sorted(meta.keys()), + "param_keys": sorted(_as_dict(meta["params"], "meta.params").keys()), + "success_keys": sorted(success.keys()), + } + + if endpoint == "manga_viewer": + viewer = _as_dict(success["manga_viewer"], "response.success.manga_viewer") + signature["payload_keys"] = sorted(viewer.keys()) + + pages = _as_list(viewer["pages"], "response.success.manga_viewer.pages") + first_page = _as_dict(pages[0], "response.success.manga_viewer.pages[0]") + last_page = _as_dict(pages[-1], "response.success.manga_viewer.pages[-1]") + signature["first_page_keys"] = sorted(first_page.keys()) + signature["last_page_keys"] = sorted(last_page.keys()) + signature["manga_page_keys"] = sorted( + _as_dict( + first_page["manga_page"], "response.success.manga_viewer.pages[0].manga_page" + ).keys() + ) + signature["last_page_payload_keys"] = sorted( + _as_dict( + last_page["last_page"], "response.success.manga_viewer.pages[-1].last_page" + ).keys() + ) + return signature + + if endpoint == "title_detailV3": + title_detail = _as_dict(success["title_detail_view"], "response.success.title_detail_view") + signature["payload_keys"] = sorted(title_detail.keys()) + signature["title_keys"] = sorted( + _as_dict(title_detail["title"], "response.success.title_detail_view.title").keys() + ) + + chapter_groups = _as_list( + title_detail["chapter_list_group"], + "response.success.title_detail_view.chapter_list_group", + ) + first_group = _as_dict( + chapter_groups[0], + "response.success.title_detail_view.chapter_list_group[0]", + ) + signature["chapter_group_keys"] = sorted(first_group.keys()) + + first_chapter_list = _as_list( + first_group["first_chapter_list"], "chapter_group.first_chapter_list" + ) + first_chapter = _as_dict(first_chapter_list[0], "chapter_group.first_chapter_list[0]") + signature["chapter_keys"] = sorted(first_chapter.keys()) + return signature + + raise AssertionError(f"Unexpected endpoint in capture metadata: {endpoint}") + + +def test_title_detail_fixture_replays_into_chapter_planner() -> None: + """Validate chapter planning logic against a real captured title-detail payload.""" + raw_payload = (FIXTURE_CAPTURE_DIR / "0001_title_detailV3_100010.pb").read_bytes() + title_dump = api._parse_title_detail_response(raw_payload) + + assert title_dump.title.title_id == 100010 + assert title_dump.title.name == "Dr. STONE" + + chapter_data = ChapterPlanner.extract_chapter_data(title_dump, lambda value: value) + assert len(chapter_data) == 236 + + chapter_2 = ChapterPlanner.find_chapter_by_id(title_dump, 1000311) + assert chapter_2 is not None + expected_existing = ChapterPlanner.build_expected_filename( + escape_path(title_dump.title.name).title(), + chapter_2, + chapter_2.sub_title, + ) + + result = ChapterPlanner.filter_chapters_to_download( + chapter_data=chapter_data, + title_dump=title_dump, + title_detail=title_dump.title, + existing_files=[expected_existing], + requested_chapter_ids={1000311, 1000312}, + ) + assert result == [1000312] + + +@pytest.mark.parametrize( + ("fixture_name", "chapter_id", "next_chapter_id"), + [ + ("0002_manga_viewer_1000311.pb", 1000311, 1000312), + ("0003_manga_viewer_1000312.pb", 1000312, 1000313), + ], +) +def test_manga_viewer_fixtures_replay_consistently( + fixture_name: str, + chapter_id: int, + next_chapter_id: int, +) -> None: + """Validate real manga-viewer fixture parsing for chapter linkage.""" + raw_payload = (FIXTURE_CAPTURE_DIR / fixture_name).read_bytes() + viewer = api._parse_manga_viewer_response(raw_payload) + + assert viewer.title_id == 100010 + assert viewer.chapter_id == chapter_id + assert len(viewer.pages) > 20 + assert len(viewer.chapters) == 3 + + last_page = viewer.pages[-1].last_page + assert last_page.current_chapter.chapter_id == chapter_id + assert last_page.next_chapter.chapter_id == next_chapter_id + + +def test_local_capture_schema_matches_baseline_fixture() -> None: + """Compare local capture schema against baseline fixture schema signatures.""" + baseline_records = _collect_capture_records(FIXTURE_CAPTURE_DIR) + assert baseline_records + + baseline_by_endpoint: dict[str, set[str]] = {} + for _stem, meta, response in baseline_records: + signature = _schema_signature(meta, response) + endpoint = str(signature["endpoint"]) + baseline_by_endpoint.setdefault(endpoint, set()).add(json.dumps(signature, sort_keys=True)) + + if not LOCAL_CAPTURE_DIR.exists(): + pytest.skip("No local capture directory exists; skipping schema drift check.") + + local_records = _collect_capture_records(LOCAL_CAPTURE_DIR) + assert local_records, "Capture directory exists but has no capture records." + + for stem, meta, response in local_records: + local_signature = _schema_signature(meta, response) + endpoint = str(local_signature["endpoint"]) + assert endpoint in baseline_by_endpoint, ( + f"Unknown endpoint in local capture '{stem}': {endpoint}" + ) + signature_payload = json.dumps(local_signature, sort_keys=True) + assert signature_payload in baseline_by_endpoint[endpoint], ( + f"Schema drift detected for local capture '{stem}' endpoint '{endpoint}'." + ) + + +def test_full_downloader_replay_with_fixture_payloads( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """Replay title and viewer fixtures through loader download orchestration.""" + title_payload = (FIXTURE_CAPTURE_DIR / "0001_title_detailV3_100010.pb").read_bytes() + viewer_payloads = { + 1000311: (FIXTURE_CAPTURE_DIR / "0002_manga_viewer_1000311.pb").read_bytes(), + 1000312: (FIXTURE_CAPTURE_DIR / "0003_manga_viewer_1000312.pb").read_bytes(), + } + + class ReplaySession: + """Session double that replays captured protobuf responses by endpoint and ID.""" + + def __init__(self) -> None: + """Initialize mutable headers and no-op transport hooks.""" + self.headers: dict[str, str] = {} + + def mount(self, prefix: str, adapter: object) -> None: + """Ignore adapter mounts; they are irrelevant for fixture replay.""" + del prefix, adapter + + def get( + self, + url: str, + params: dict[str, object] | None = None, + timeout: tuple[float, float] | None = None, + ) -> SimpleNamespace: + """Return fixture payload bytes matching requested endpoint and identifier.""" + del timeout + params = params or {} + if url.endswith("/api/title_detailV3"): + return SimpleNamespace(content=title_payload, raise_for_status=lambda: None) + if url.endswith("/api/manga_viewer"): + chapter_id = int(str(params["chapter_id"])) + return SimpleNamespace( + content=viewer_payloads[chapter_id], + raise_for_status=lambda: None, + ) + raise AssertionError(f"Unexpected replay URL: {url}") + + created_exporters: list[SimpleNamespace] = [] + + def exporter_factory(**kwargs: object) -> SimpleNamespace: + """Create exporter double that records page writes and emits output markers.""" + chapter = kwargs["chapter"] + path = tmp_path / f"{chapter.chapter_id}.cbz" + exporter = SimpleNamespace(path=path, images=0) + + def _skip_image(index: int | range) -> bool: + del index + return False + + def _add_image(image_data: bytes, index: int | range) -> None: + del image_data, index + exporter.images += 1 + + def _close() -> None: + path.write_bytes(b"ok") + + exporter.skip_image = _skip_image + exporter.add_image = _add_image + exporter.close = _close + created_exporters.append(exporter) + return exporter + + loader = MangaLoader( + exporter=exporter_factory, + quality="high", + split=False, + meta=False, + destination=str(tmp_path), + output_format="cbz", + session=ReplaySession(), + ) + monkeypatch.setattr(loader._runtime, "_fetch_page_image", lambda _page: b"img") + + summary = loader.download( + title_ids=None, + chapter_ids={1000311, 1000312}, + min_chapter=0, + max_chapter=999, + last_chapter=False, + ) + + assert summary.downloaded == 2 + assert summary.failed == 0 + assert len(created_exporters) == 2 + assert all(exporter.images > 10 for exporter in created_exporters) diff --git a/tests/test_capture_verify.py b/tests/test_capture_verify.py new file mode 100644 index 0000000..f3493fa --- /dev/null +++ b/tests/test_capture_verify.py @@ -0,0 +1,375 @@ +"""Tests for capture verification against required response fields.""" + +from __future__ import annotations + +import json +from hashlib import sha256 +from pathlib import Path + +import pytest + +from mloader.manga_loader.capture_verify import ( + CaptureVerificationError, + _as_dict, + _as_list, + _build_schema_signature, + _load_metadata, + _verify_manga_viewer_payload, + _verify_title_detail_payload, + verify_capture_schema_against_baseline, + verify_capture_schema, +) +from mloader.response_pb2 import Response # type: ignore + +FIXTURE_CAPTURE_DIR = Path(__file__).parent / "fixtures" / "api_captures" / "baseline" + + +def _copy_fixture_set(target_dir: Path) -> None: + """Copy baseline capture fixture files into ``target_dir``.""" + target_dir.mkdir(parents=True, exist_ok=True) + for fixture_file in FIXTURE_CAPTURE_DIR.iterdir(): + if fixture_file.is_file(): + (target_dir / fixture_file.name).write_bytes(fixture_file.read_bytes()) + + +def _update_payload_metadata(meta_path: Path, payload: bytes) -> None: + """Update metadata checksums/size after payload mutation.""" + metadata = json.loads(meta_path.read_text(encoding="utf-8")) + metadata["payload_size_bytes"] = len(payload) + metadata["payload_sha256"] = sha256(payload).hexdigest() + meta_path.write_text(json.dumps(metadata, ensure_ascii=False, indent=2), encoding="utf-8") + + +def test_verify_capture_schema_with_real_fixture_set() -> None: + """Verify baseline fixture set passes schema verification.""" + summary = verify_capture_schema(FIXTURE_CAPTURE_DIR) + + assert summary.total_records == 3 + assert summary.endpoint_counts == {"manga_viewer": 2, "title_detailV3": 1} + + +def test_verify_capture_schema_against_baseline_with_real_fixture_set() -> None: + """Verify baseline comparison passes for matching capture and baseline sets.""" + summary = verify_capture_schema_against_baseline(FIXTURE_CAPTURE_DIR, FIXTURE_CAPTURE_DIR) + assert summary.total_records == 3 + + +def test_verify_capture_schema_against_baseline_detects_drift(tmp_path: Path) -> None: + """Verify baseline comparison fails when capture signature keys drift.""" + _copy_fixture_set(tmp_path) + meta_path = tmp_path / "0002_manga_viewer_1000311.meta.json" + metadata = json.loads(meta_path.read_text(encoding="utf-8")) + metadata["params"]["extra_param"] = "1" + meta_path.write_text(json.dumps(metadata, ensure_ascii=False, indent=2), encoding="utf-8") + + with pytest.raises(CaptureVerificationError, match="Schema drift detected"): + verify_capture_schema_against_baseline(tmp_path, FIXTURE_CAPTURE_DIR) + + +def test_verify_capture_schema_against_baseline_rejects_unknown_capture_endpoint( + tmp_path: Path, +) -> None: + """Verify comparison fails when capture has endpoint absent from baseline set.""" + capture_dir = tmp_path / "capture" + baseline_dir = tmp_path / "baseline" + _copy_fixture_set(capture_dir) + baseline_dir.mkdir(parents=True, exist_ok=True) + for fixture_file in FIXTURE_CAPTURE_DIR.glob("0001_title_detailV3_100010.*"): + (baseline_dir / fixture_file.name).write_bytes(fixture_file.read_bytes()) + + with pytest.raises(CaptureVerificationError, match="Unknown endpoint"): + verify_capture_schema_against_baseline(capture_dir, baseline_dir) + + +def test_verify_capture_schema_fails_without_metadata_files(tmp_path: Path) -> None: + """Verify verifier fails when capture directory has no metadata records.""" + with pytest.raises(CaptureVerificationError, match="No '\\*\\.meta\\.json' files found"): + verify_capture_schema(tmp_path) + + +def test_verify_capture_schema_fails_for_unsupported_endpoint(tmp_path: Path) -> None: + """Verify verifier rejects unknown endpoint names in metadata.""" + _copy_fixture_set(tmp_path) + meta_path = tmp_path / "0001_title_detailV3_100010.meta.json" + metadata = json.loads(meta_path.read_text(encoding="utf-8")) + metadata["endpoint"] = "unsupported_endpoint" + meta_path.write_text(json.dumps(metadata, ensure_ascii=False, indent=2), encoding="utf-8") + + with pytest.raises(CaptureVerificationError, match="Unsupported endpoint"): + verify_capture_schema(tmp_path) + + +def test_verify_capture_schema_fails_for_size_mismatch(tmp_path: Path) -> None: + """Verify verifier catches payload size mismatches from metadata.""" + _copy_fixture_set(tmp_path) + meta_path = tmp_path / "0002_manga_viewer_1000311.meta.json" + metadata = json.loads(meta_path.read_text(encoding="utf-8")) + metadata["payload_size_bytes"] = 1 + meta_path.write_text(json.dumps(metadata, ensure_ascii=False, indent=2), encoding="utf-8") + + with pytest.raises(CaptureVerificationError, match="Payload size mismatch"): + verify_capture_schema(tmp_path) + + +def test_verify_capture_schema_fails_for_sha_mismatch(tmp_path: Path) -> None: + """Verify verifier catches payload checksum mismatches from metadata.""" + _copy_fixture_set(tmp_path) + meta_path = tmp_path / "0002_manga_viewer_1000311.meta.json" + metadata = json.loads(meta_path.read_text(encoding="utf-8")) + metadata["payload_sha256"] = "0" * 64 + meta_path.write_text(json.dumps(metadata, ensure_ascii=False, indent=2), encoding="utf-8") + + with pytest.raises(CaptureVerificationError, match="Payload sha256 mismatch"): + verify_capture_schema(tmp_path) + + +def test_verify_capture_schema_fails_for_manga_viewer_missing_pages(tmp_path: Path) -> None: + """Verify verifier rejects manga_viewer payloads with empty pages.""" + _copy_fixture_set(tmp_path) + + payload_path = tmp_path / "0002_manga_viewer_1000311.pb" + parsed = Response.FromString(payload_path.read_bytes()) + parsed.success.manga_viewer.ClearField("pages") + mutated_payload = parsed.SerializeToString() + payload_path.write_bytes(mutated_payload) + + meta_path = tmp_path / "0002_manga_viewer_1000311.meta.json" + _update_payload_metadata(meta_path, mutated_payload) + + with pytest.raises(CaptureVerificationError, match="No pages found in manga_viewer payload"): + verify_capture_schema(tmp_path) + + +def test_verify_capture_schema_fails_for_title_detail_missing_groups(tmp_path: Path) -> None: + """Verify verifier rejects title_detail payloads with no chapter groups.""" + _copy_fixture_set(tmp_path) + + payload_path = tmp_path / "0001_title_detailV3_100010.pb" + parsed = Response.FromString(payload_path.read_bytes()) + parsed.success.title_detail_view.ClearField("chapter_list_group") + mutated_payload = parsed.SerializeToString() + payload_path.write_bytes(mutated_payload) + + meta_path = tmp_path / "0001_title_detailV3_100010.meta.json" + _update_payload_metadata(meta_path, mutated_payload) + + with pytest.raises(CaptureVerificationError, match="No chapter_list_group records"): + verify_capture_schema(tmp_path) + + +def test_load_metadata_rejects_non_dict_json(tmp_path: Path) -> None: + """Verify metadata loader requires a JSON object.""" + meta_path = tmp_path / "record.meta.json" + meta_path.write_text("[]", encoding="utf-8") + + with pytest.raises(CaptureVerificationError, match="Metadata file is not an object"): + _load_metadata(meta_path) + + +def test_verify_capture_schema_fails_for_missing_directory() -> None: + """Verify verifier rejects nonexistent capture directories.""" + with pytest.raises(CaptureVerificationError, match="Capture directory not found"): + verify_capture_schema("does-not-exist") + + +def test_verify_capture_schema_fails_for_missing_endpoint(tmp_path: Path) -> None: + """Verify verifier rejects metadata without endpoint values.""" + _copy_fixture_set(tmp_path) + meta_path = tmp_path / "0001_title_detailV3_100010.meta.json" + metadata = json.loads(meta_path.read_text(encoding="utf-8")) + metadata["endpoint"] = "" + meta_path.write_text(json.dumps(metadata, ensure_ascii=False, indent=2), encoding="utf-8") + + with pytest.raises(CaptureVerificationError, match="Missing endpoint"): + verify_capture_schema(tmp_path) + + +def test_verify_capture_schema_fails_for_missing_raw_payload_reference(tmp_path: Path) -> None: + """Verify verifier rejects metadata pointing to missing raw payload files.""" + _copy_fixture_set(tmp_path) + meta_path = tmp_path / "0001_title_detailV3_100010.meta.json" + metadata = json.loads(meta_path.read_text(encoding="utf-8")) + metadata["raw_payload_file"] = "missing.pb" + meta_path.write_text(json.dumps(metadata, ensure_ascii=False, indent=2), encoding="utf-8") + + with pytest.raises( + CaptureVerificationError, match="Missing raw payload file referenced by metadata" + ): + verify_capture_schema(tmp_path) + + +def test_verify_capture_schema_fails_for_missing_success_envelope(tmp_path: Path) -> None: + """Verify verifier rejects payloads without a success envelope.""" + capture_name = "0001_title_detailV3_100010" + payload_path = tmp_path / f"{capture_name}.pb" + payload_path.write_bytes(b"") + + metadata = { + "endpoint": "title_detailV3", + "raw_payload_file": payload_path.name, + "payload_size_bytes": 0, + "payload_sha256": sha256(b"").hexdigest(), + } + (tmp_path / f"{capture_name}.meta.json").write_text( + json.dumps(metadata, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + + with pytest.raises(CaptureVerificationError, match="Missing success envelope"): + verify_capture_schema(tmp_path) + + +def test_verify_title_detail_payload_rejects_missing_title_detail() -> None: + """Verify title-detail payload verifier rejects missing payload branch.""" + parsed = Response() + parsed.success.manga_viewer.title_id = 100312 + parsed.success.manga_viewer.chapter_id = 1024959 + with pytest.raises(CaptureVerificationError, match="Missing success.title_detail_view"): + _verify_title_detail_payload(parsed, "sample") + + +def test_verify_title_detail_payload_rejects_missing_title_identity() -> None: + """Verify title-detail payload verifier requires title identity fields.""" + parsed = Response() + parsed.success.title_detail_view.chapter_list_group.add() + with pytest.raises(CaptureVerificationError, match="Missing required title identity fields"): + _verify_title_detail_payload(parsed, "sample") + + +def test_verify_title_detail_payload_rejects_empty_chapter_groups() -> None: + """Verify title-detail payload verifier rejects groups without chapters.""" + parsed = Response() + parsed.success.title_detail_view.title.title_id = 100312 + parsed.success.title_detail_view.title.name = "T" + parsed.success.title_detail_view.chapter_list_group.add() + with pytest.raises( + CaptureVerificationError, match="No chapter entries found in chapter_list_group" + ): + _verify_title_detail_payload(parsed, "sample") + + +def test_verify_manga_viewer_payload_rejects_missing_viewer() -> None: + """Verify manga-viewer payload verifier rejects missing payload branch.""" + parsed = Response() + parsed.success.title_detail_view.title.title_id = 100312 + parsed.success.title_detail_view.title.name = "T" + with pytest.raises(CaptureVerificationError, match="Missing success.manga_viewer"): + _verify_manga_viewer_payload(parsed, "sample") + + +def test_verify_manga_viewer_payload_rejects_missing_ids() -> None: + """Verify manga-viewer payload verifier requires non-zero identity fields.""" + parsed = Response() + parsed.success.manga_viewer.pages.add().manga_page.image_url = "http://img" + with pytest.raises(CaptureVerificationError, match="Missing viewer title_id/chapter_id fields"): + _verify_manga_viewer_payload(parsed, "sample") + + +def test_verify_manga_viewer_payload_rejects_missing_image_urls() -> None: + """Verify manga-viewer payload verifier requires at least one image URL.""" + parsed = Response() + parsed.success.manga_viewer.title_id = 100312 + parsed.success.manga_viewer.chapter_id = 1024959 + parsed.success.manga_viewer.pages.add() + with pytest.raises(CaptureVerificationError, match="No manga_page.image_url found in pages"): + _verify_manga_viewer_payload(parsed, "sample") + + +def test_verify_manga_viewer_payload_rejects_missing_last_page_chapter() -> None: + """Verify manga-viewer payload verifier requires terminal chapter linkage.""" + parsed = Response() + parsed.success.manga_viewer.title_id = 100312 + parsed.success.manga_viewer.chapter_id = 1024959 + page = parsed.success.manga_viewer.pages.add() + page.manga_page.image_url = "http://img" + with pytest.raises(CaptureVerificationError, match="Missing last_page.current_chapter"): + _verify_manga_viewer_payload(parsed, "sample") + + +def test_build_schema_signature_rejects_unknown_endpoint() -> None: + """Verify schema-signature builder rejects unsupported endpoint names.""" + parsed = Response() + parsed.success.title_detail_view.title.title_id = 100312 + parsed.success.title_detail_view.title.name = "title" + group = parsed.success.title_detail_view.chapter_list_group.add() + group.first_chapter_list.add().chapter_id = 1024959 + with pytest.raises(CaptureVerificationError, match="Unsupported endpoint"): + _build_schema_signature( + endpoint="unknown", + metadata={"params": {}, "url": "https://example.invalid"}, + parsed=parsed, + ) + + +def test_as_dict_rejects_non_dict() -> None: + """Verify dict coercion helper rejects non-object values.""" + with pytest.raises(CaptureVerificationError, match="Expected object at"): + _as_dict([], "ctx") + + +def test_as_list_rejects_non_list() -> None: + """Verify list coercion helper rejects non-list values.""" + with pytest.raises(CaptureVerificationError, match="Expected list at"): + _as_list({}, "ctx") + + +def test_build_schema_signature_rejects_empty_pages_list( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify schema signature rejects manga_viewer payloads with explicit empty pages list.""" + monkeypatch.setattr( + "mloader.manga_loader.capture_verify.MessageToDict", + lambda *_args, **_kwargs: {"success": {"manga_viewer": {"pages": []}}}, + ) + + with pytest.raises(CaptureVerificationError, match="Expected at least one page"): + _build_schema_signature( + endpoint="manga_viewer", + metadata={"params": {}, "url": "https://example.invalid"}, + parsed=Response(), + ) + + +def test_build_schema_signature_rejects_empty_title_detail_group_list( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify schema signature rejects title_detail payloads with no groups.""" + monkeypatch.setattr( + "mloader.manga_loader.capture_verify.MessageToDict", + lambda *_args, **_kwargs: { + "success": {"title_detail_view": {"title": {}, "chapter_list_group": []}} + }, + ) + + with pytest.raises(CaptureVerificationError, match="Expected at least one group"): + _build_schema_signature( + endpoint="title_detailV3", + metadata={"params": {}, "url": "https://example.invalid"}, + parsed=Response(), + ) + + +def test_build_schema_signature_rejects_empty_first_chapter_list( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify schema signature rejects title_detail payloads with empty first_chapter_list.""" + monkeypatch.setattr( + "mloader.manga_loader.capture_verify.MessageToDict", + lambda *_args, **_kwargs: { + "success": { + "title_detail_view": { + "title": {}, + "chapter_list_group": [{"first_chapter_list": []}], + } + } + }, + ) + + with pytest.raises( + CaptureVerificationError, match="Expected at least one chapter in first_chapter_list" + ): + _build_schema_signature( + endpoint="title_detailV3", + metadata={"params": {}, "url": "https://example.invalid"}, + parsed=Response(), + ) diff --git a/tests/test_cli_config.py b/tests/test_cli_config.py new file mode 100644 index 0000000..2db1e86 --- /dev/null +++ b/tests/test_cli_config.py @@ -0,0 +1,49 @@ +"""Tests for logging configuration helpers.""" + +from __future__ import annotations + +import logging +from typing import Any + +import pytest + +from mloader.cli import config as cli_config + + +def test_setup_logging_calls_basic_config(monkeypatch: pytest.MonkeyPatch) -> None: + """Ensure setup_logging configures handlers and logger levels.""" + captured: dict[str, Any] = {} + + def fake_basic_config(**kwargs: Any) -> None: + captured.update(kwargs) + + monkeypatch.setattr(logging, "basicConfig", fake_basic_config) + + cli_config.setup_logging() + + assert captured["level"] == logging.INFO + assert captured["style"] == "{" + assert captured["force"] is True + assert isinstance(captured["handlers"][0], logging.StreamHandler) + assert logging.getLogger("requests").level == logging.WARNING + assert logging.getLogger("urllib3").level == logging.WARNING + + +def test_setup_logging_accepts_explicit_level(monkeypatch: pytest.MonkeyPatch) -> None: + """Ensure setup_logging forwards custom logging level overrides.""" + captured: dict[str, Any] = {} + + def fake_basic_config(**kwargs: Any) -> None: + captured.update(kwargs) + + monkeypatch.setattr(logging, "basicConfig", fake_basic_config) + + cli_config.setup_logging(level=logging.DEBUG) + + assert captured["level"] == logging.DEBUG + + +def test_get_logger_returns_named_logger() -> None: + """Ensure get_logger returns a logger configured with the requested name.""" + logger = cli_config.get_logger("mloader.tests") + assert logger.name == "mloader.tests" diff --git a/tests/test_cli_examples.py b/tests/test_cli_examples.py new file mode 100644 index 0000000..1d5b841 --- /dev/null +++ b/tests/test_cli_examples.py @@ -0,0 +1,44 @@ +"""Tests for CLI example catalog generation and option coverage.""" + +from __future__ import annotations + +import re + +import click + +from mloader.cli.examples import build_cli_examples +from mloader.cli.main import main as cli_main + + +def _extract_option_names_from_help(help_text: str) -> set[str]: + """Extract long option names from Click help output.""" + option_names: set[str] = set() + for line in help_text.splitlines(): + stripped = line.strip() + if not stripped.startswith("-"): + continue + matches = re.findall(r"--[a-zA-Z0-9-]+", stripped) + option_names.update(matches) + return option_names + + +def test_cli_examples_cover_all_long_options() -> None: + """Verify example catalog includes at least one command using every long option.""" + context = click.Context(cli_main) + help_text = cli_main.get_help(context) + required_options = _extract_option_names_from_help(help_text) - {"--help"} + + covered_options: set[str] = set() + for example in build_cli_examples(prog_name="mloader"): + covered_options.update(re.findall(r"--[a-zA-Z0-9-]+", example.command)) + + missing = sorted(required_options - covered_options) + assert not missing, f"Examples are missing coverage for: {', '.join(missing)}" + + +def test_cli_examples_render_with_custom_program_name() -> None: + """Verify example command strings are rendered with provided program name.""" + examples = build_cli_examples(prog_name="my-loader") + + assert examples + assert all(example.command.startswith("my-loader ") for example in examples) diff --git a/tests/test_cli_main.py b/tests/test_cli_main.py new file mode 100644 index 0000000..de7a941 --- /dev/null +++ b/tests/test_cli_main.py @@ -0,0 +1,599 @@ +"""Tests for CLI command orchestration.""" + +from __future__ import annotations + +import json +from typing import Any, ClassVar + +import pytest +import requests +from click.testing import CliRunner + +from mloader.cli import main as cli_main +from mloader.cli.exit_codes import EXTERNAL_FAILURE, INTERNAL_BUG, VALIDATION_ERROR +from mloader.domain.requests import DownloadSummary +from mloader.errors import SubscriptionRequiredError +from mloader.manga_loader.downloader import DownloadInterruptedError +from mloader.manga_loader.capture_verify import CaptureVerificationError, CaptureVerificationSummary +from mloader.config import AuthSettings + +CHAPTER_ID = "1024959" +CHAPTER_ID_ALT = "102278" +FAILED_CHAPTER_ID_A = 102300 +FAILED_CHAPTER_ID_B = 102301 + + +class DummyLoader: + """Loader test double capturing constructor and download arguments.""" + + init_args: ClassVar[dict[str, Any] | None] = None + download_args: ClassVar[dict[str, Any] | None] = None + + def __init__( + self, + exporter_factory: Any, + quality: str, + split: bool, + meta: bool, + cover: bool, + destination: str, + output_format: str, + capture_api_dir: str | None, + resume: bool, + manifest_reset: bool, + ) -> None: + """Record initialization arguments for assertions.""" + type(self).init_args = { + "exporter_factory": exporter_factory, + "quality": quality, + "split": split, + "meta": meta, + "cover": cover, + "destination": destination, + "output_format": output_format, + "capture_api_dir": capture_api_dir, + "resume": resume, + "manifest_reset": manifest_reset, + } + + def download(self, **kwargs: Any) -> DownloadSummary: + """Record download call keyword arguments for assertions.""" + type(self).download_args = kwargs + return DownloadSummary( + downloaded=1, + skipped_manifest=0, + failed=0, + failed_chapter_ids=(), + ) + + +class DummyRawExporter: + """Raw exporter marker class for monkeypatched CLI tests.""" + + +class DummyPdfExporter: + """PDF exporter marker class for monkeypatched CLI tests.""" + + +class FailingLoader(DummyLoader): + """Loader test double that always raises from download.""" + + def download(self, **kwargs: Any) -> None: + """Raise a runtime error to exercise CLI exception handling.""" + del kwargs + raise RuntimeError("boom") + + +class SubscriptionLoader(DummyLoader): + """Loader test double that raises subscription-required error.""" + + def download(self, **kwargs: Any) -> None: + """Raise a subscription-required error to test CLI messaging.""" + del kwargs + raise SubscriptionRequiredError("A MAX subscription is required to download this chapter.") + + +class RequestErrorLoader(DummyLoader): + """Loader test double that raises request-layer failures.""" + + def download(self, **kwargs: Any) -> None: + """Raise request exception to verify external-failure mapping.""" + del kwargs + raise requests.RequestException("network down") + + +class PartialFailureLoader(DummyLoader): + """Loader test double returning a failed chapter summary.""" + + def download(self, **kwargs: Any) -> DownloadSummary: + """Return a deterministic summary with chapter failures.""" + del kwargs + return DownloadSummary( + downloaded=2, + skipped_manifest=1, + failed=2, + failed_chapter_ids=(FAILED_CHAPTER_ID_A, FAILED_CHAPTER_ID_B), + ) + + +class InterruptedLoader(DummyLoader): + """Loader test double raising interrupt wrapper with partial summary.""" + + def download(self, **kwargs: Any) -> DownloadSummary: + """Raise downloader interrupt error containing partial run summary.""" + del kwargs + raise DownloadInterruptedError( + DownloadSummary( + downloaded=1, + skipped_manifest=1, + failed=1, + failed_chapter_ids=(int(CHAPTER_ID_ALT),), + ) + ) + + +def test_cli_uses_default_info_logging_level(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify CLI configures INFO logging in default output mode.""" + observed_level: int | None = None + + def _setup_logging(*, level: int, stream: Any = None) -> None: + nonlocal observed_level + del stream + observed_level = level + + monkeypatch.setattr(cli_main, "setup_logging", _setup_logging) + monkeypatch.setattr(cli_main, "MangaLoader", DummyLoader) + + runner = CliRunner() + result = runner.invoke(cli_main.main, ["--chapter-id", CHAPTER_ID]) + + assert result.exit_code == 0 + assert observed_level == 20 + + +def test_cli_exits_when_auth_os_value_is_unsupported(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify CLI warns and exits when auth OS config value is unsupported.""" + monkeypatch.setattr( + cli_main, + "AUTH_SETTINGS", + AuthSettings( + app_ver="97", + os="Windows_NT", + os_ver="18.1", + secret="secret", + ), + ) + monkeypatch.setattr(cli_main, "MangaLoader", DummyLoader) + + runner = CliRunner() + result = runner.invoke(cli_main.main, ["--chapter-id", CHAPTER_ID]) + + assert result.exit_code == VALIDATION_ERROR + assert "Unsupported API auth OS value" in result.output + assert "Windows_NT" in result.output + + +def test_cli_uses_warning_logging_level_in_quiet_mode(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify --quiet configures WARNING logging and suppresses intro text.""" + observed_level: int | None = None + + def _setup_logging(*, level: int, stream: Any = None) -> None: + nonlocal observed_level + del stream + observed_level = level + + monkeypatch.setattr(cli_main, "setup_logging", _setup_logging) + monkeypatch.setattr(cli_main, "MangaLoader", DummyLoader) + + runner = CliRunner() + result = runner.invoke(cli_main.main, ["--chapter-id", CHAPTER_ID, "--quiet"]) + + assert result.exit_code == 0 + assert observed_level == 30 + assert cli_main.about.__intro__ not in result.output + + +def test_cli_uses_debug_logging_level_in_verbose_mode(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify --verbose enables DEBUG logging level.""" + observed_level: int | None = None + + def _setup_logging(*, level: int, stream: Any = None) -> None: + nonlocal observed_level + del stream + observed_level = level + + monkeypatch.setattr(cli_main, "setup_logging", _setup_logging) + monkeypatch.setattr(cli_main, "MangaLoader", DummyLoader) + + runner = CliRunner() + result = runner.invoke(cli_main.main, ["--chapter-id", CHAPTER_ID, "--verbose"]) + + assert result.exit_code == 0 + assert observed_level == 10 + + +def test_cli_uses_raw_exporter_when_raw_flag_is_set( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify --raw selects RawExporter and forwards chapter IDs.""" + monkeypatch.setattr(cli_main, "MangaLoader", DummyLoader) + monkeypatch.setattr(cli_main, "RawExporter", DummyRawExporter) + + runner = CliRunner() + result = runner.invoke(cli_main.main, ["--chapter-id", CHAPTER_ID, "--raw"]) + + assert result.exit_code == 0 + assert DummyLoader.init_args is not None + assert DummyLoader.download_args is not None + assert DummyLoader.init_args["exporter_factory"].func is DummyRawExporter + assert DummyLoader.init_args["output_format"] == "raw" + assert DummyLoader.download_args["chapter_ids"] == {int(CHAPTER_ID)} + + +def test_cli_uses_pdf_exporter_when_requested(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify --format pdf selects PDFExporter and forwards chapter IDs.""" + monkeypatch.setattr(cli_main, "MangaLoader", DummyLoader) + monkeypatch.setattr(cli_main, "PDFExporter", DummyPdfExporter) + + runner = CliRunner() + result = runner.invoke(cli_main.main, ["--chapter-id", CHAPTER_ID, "--format", "pdf"]) + + assert result.exit_code == 0 + assert DummyLoader.init_args is not None + assert DummyLoader.download_args is not None + assert DummyLoader.init_args["exporter_factory"].func is DummyPdfExporter + assert DummyLoader.init_args["output_format"] == "pdf" + assert DummyLoader.download_args["chapter_ids"] == {int(CHAPTER_ID)} + + +def test_cli_returns_error_when_download_fails(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify CLI returns a click error when loader download raises.""" + monkeypatch.setattr(cli_main, "MangaLoader", FailingLoader) + + runner = CliRunner() + result = runner.invoke(cli_main.main, ["--chapter-id", CHAPTER_ID]) + + assert result.exit_code == INTERNAL_BUG + assert "Download failed" in result.output + + +def test_cli_without_ids_prints_help_and_exits_cleanly() -> None: + """Verify CLI prints usage text when no chapter/title input is provided.""" + runner = CliRunner() + result = runner.invoke(cli_main.main, []) + + assert result.exit_code == 0 + assert "Usage:" in result.output + assert "Examples:" not in result.output + + +def test_cli_show_examples_exits_without_download(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify --show-examples prints catalog and exits before download workflow.""" + invoked = {"download_called": False} + + def _raise_if_called(*args: Any, **kwargs: Any) -> None: + del args, kwargs + invoked["download_called"] = True + raise AssertionError("execute_download should not be called in --show-examples mode") + + monkeypatch.setattr(cli_main.workflows, "execute_download", _raise_if_called) + + runner = CliRunner() + result = runner.invoke(cli_main.main, ["--show-examples"]) + + assert result.exit_code == 0 + assert "mloader example catalog" in result.output + assert "--manifest-reset" in result.output + assert invoked["download_called"] is False + + +def test_cli_show_examples_json_mode_returns_catalog() -> None: + """Verify --show-examples with --json emits structured example payload.""" + runner = CliRunner() + result = runner.invoke(cli_main.main, ["--show-examples", "--json"]) + + assert result.exit_code == 0 + payload = json.loads(result.output) + assert payload["status"] == "ok" + assert payload["mode"] == "show_examples" + assert payload["count"] > 0 + assert isinstance(payload["examples"], list) + + +def test_cli_returns_subscription_message(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify CLI exposes subscription requirement failures from downloader.""" + monkeypatch.setattr(cli_main, "MangaLoader", SubscriptionLoader) + + runner = CliRunner() + result = runner.invoke(cli_main.main, ["--chapter-id", CHAPTER_ID]) + + assert result.exit_code == EXTERNAL_FAILURE + assert "A MAX subscription is required to download this chapter." in result.output + + +def test_cli_forwards_capture_directory(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify --capture-api forwards directory to loader initialization.""" + monkeypatch.setattr(cli_main, "MangaLoader", DummyLoader) + + runner = CliRunner() + result = runner.invoke( + cli_main.main, ["--chapter-id", CHAPTER_ID, "--capture-api", "/tmp/captures"] + ) + + assert result.exit_code == 0 + assert DummyLoader.init_args is not None + assert DummyLoader.init_args["capture_api_dir"] == "/tmp/captures" + + +def test_cli_forwards_cover_flag(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify --cover enables title-cover download mode in loader initialization.""" + monkeypatch.setattr(cli_main, "MangaLoader", DummyLoader) + + runner = CliRunner() + result = runner.invoke(cli_main.main, ["--chapter-id", CHAPTER_ID, "--cover"]) + + assert result.exit_code == 0 + assert DummyLoader.init_args is not None + assert DummyLoader.init_args["cover"] is True + + +def test_cli_forwards_resume_and_manifest_reset_options(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify manifest behavior flags are forwarded to loader initialization.""" + monkeypatch.setattr(cli_main, "MangaLoader", DummyLoader) + + runner = CliRunner() + result = runner.invoke( + cli_main.main, + ["--chapter-id", CHAPTER_ID, "--no-resume", "--manifest-reset"], + ) + + assert result.exit_code == 0 + assert DummyLoader.init_args is not None + assert DummyLoader.init_args["resume"] is False + assert DummyLoader.init_args["manifest_reset"] is True + + +def test_cli_verifies_capture_schema_and_exits_without_download( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify schema-verification mode runs and exits without invoking downloads.""" + monkeypatch.setattr( + cli_main, + "verify_capture_schema", + lambda _path: CaptureVerificationSummary( + total_records=3, + endpoint_counts={"manga_viewer": 2, "title_detailV3": 1}, + ), + ) + + runner = CliRunner() + result = runner.invoke( + cli_main.main, + ["--verify-capture-schema", "tests/fixtures/api_captures/baseline"], + ) + + assert result.exit_code == 0 + assert "Verified 3 capture payload(s) in tests/fixtures/api_captures/baseline" in result.output + + +def test_cli_verifies_capture_schema_against_baseline( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify baseline comparison mode calls baseline verification path.""" + monkeypatch.setattr( + cli_main, + "verify_capture_schema_against_baseline", + lambda _capture, _baseline: CaptureVerificationSummary( + total_records=3, + endpoint_counts={"manga_viewer": 2, "title_detailV3": 1}, + ), + ) + + runner = CliRunner() + result = runner.invoke( + cli_main.main, + [ + "--verify-capture-schema", + "tests/fixtures/api_captures/baseline", + "--verify-capture-baseline", + "tests/fixtures/api_captures/baseline", + ], + ) + + assert result.exit_code == 0 + assert "against baseline tests/fixtures/api_captures/baseline" in result.output + + +def test_cli_rejects_baseline_option_without_capture_schema() -> None: + """Verify baseline option requires a capture directory option.""" + runner = CliRunner() + result = runner.invoke( + cli_main.main, + ["--verify-capture-baseline", "tests/fixtures/api_captures/baseline"], + ) + + assert result.exit_code == VALIDATION_ERROR + assert "--verify-capture-baseline requires --verify-capture-schema." in result.output + + +def test_cli_verify_capture_schema_returns_click_error( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify verification failures are exposed as click exceptions.""" + + def _raise_error(_path: str) -> None: + raise CaptureVerificationError("schema drift") + + monkeypatch.setattr(cli_main, "verify_capture_schema", _raise_error) + + runner = CliRunner() + result = runner.invoke( + cli_main.main, + ["--verify-capture-schema", "tests/fixtures/api_captures/baseline"], + ) + + assert result.exit_code == VALIDATION_ERROR + assert "schema drift" in result.output + + +def test_cli_verify_capture_schema_json_mode_returns_structured_payload( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify --json emits machine-readable payload for capture verification mode.""" + monkeypatch.setattr( + cli_main, + "verify_capture_schema", + lambda _path: CaptureVerificationSummary( + total_records=3, + endpoint_counts={"manga_viewer": 2, "title_detailV3": 1}, + ), + ) + + runner = CliRunner() + result = runner.invoke( + cli_main.main, + ["--json", "--verify-capture-schema", "tests/fixtures/api_captures/baseline"], + ) + + assert result.exit_code == 0 + payload = json.loads(result.output) + assert payload == { + "status": "ok", + "mode": "verify_capture", + "exit_code": 0, + "capture_dir": "tests/fixtures/api_captures/baseline", + "baseline_dir": None, + "total_records": 3, + "endpoint_counts": {"manga_viewer": 2, "title_detailV3": 1}, + } + + +def test_cli_json_mode_returns_structured_success_payload( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify --json returns machine-readable success payload for downloads.""" + monkeypatch.setattr(cli_main, "MangaLoader", DummyLoader) + + runner = CliRunner() + result = runner.invoke(cli_main.main, ["--json", "--chapter-id", CHAPTER_ID]) + + assert result.exit_code == 0 + payload = json.loads(result.output) + assert payload["status"] == "ok" + assert payload["mode"] == "download" + assert payload["exit_code"] == 0 + assert payload["targets"]["chapters"] == 0 + assert payload["targets"]["chapter_ids"] == 1 + assert payload["summary"] == { + "downloaded": 1, + "skipped_manifest": 0, + "failed": 0, + "failed_chapter_ids": [], + } + + +def test_cli_json_mode_returns_structured_error_payload( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify --json returns machine-readable error payload and deterministic exit code.""" + monkeypatch.setattr(cli_main, "MangaLoader", FailingLoader) + + runner = CliRunner() + result = runner.invoke(cli_main.main, ["--json", "--chapter-id", CHAPTER_ID]) + + assert result.exit_code == INTERNAL_BUG + payload = json.loads(result.output) + assert payload == { + "status": "error", + "exit_code": INTERNAL_BUG, + "message": "Download failed", + } + + +def test_cli_maps_request_failures_to_external_exit_code( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify request-layer failures are mapped to external-failure exit code.""" + monkeypatch.setattr(cli_main, "MangaLoader", RequestErrorLoader) + + runner = CliRunner() + result = runner.invoke(cli_main.main, ["--chapter-id", CHAPTER_ID]) + + assert result.exit_code == EXTERNAL_FAILURE + assert "Download request failed: network down" in result.output + + +def test_cli_maps_interrupted_download_to_external_exit_code( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify interrupted runs include partial summary and map to external failure.""" + monkeypatch.setattr(cli_main, "MangaLoader", InterruptedLoader) + + runner = CliRunner() + result = runner.invoke(cli_main.main, ["--chapter-id", CHAPTER_ID]) + + assert result.exit_code == EXTERNAL_FAILURE + assert "Download summary: downloaded=1, skipped_manifest=1, failed=1" in result.output + assert f"Failed chapter IDs: {CHAPTER_ID_ALT}" in result.output + assert "Download interrupted by user." in result.output + + +def test_cli_returns_external_failure_when_summary_has_failed_chapters( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify failed chapter summary maps to external failure exit code.""" + monkeypatch.setattr(cli_main, "MangaLoader", PartialFailureLoader) + + runner = CliRunner() + result = runner.invoke(cli_main.main, ["--chapter-id", CHAPTER_ID]) + + assert result.exit_code == EXTERNAL_FAILURE + assert "Download completed with 2 failed chapter(s)." in result.output + + +def test_cli_json_mode_includes_failed_summary_payload( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify JSON error payload includes summary details for partial failures.""" + monkeypatch.setattr(cli_main, "MangaLoader", PartialFailureLoader) + + runner = CliRunner() + result = runner.invoke(cli_main.main, ["--json", "--chapter-id", CHAPTER_ID]) + + assert result.exit_code == EXTERNAL_FAILURE + payload = json.loads(result.output) + assert payload == { + "status": "error", + "exit_code": EXTERNAL_FAILURE, + "message": "Download completed with 2 failed chapter(s).", + "summary": { + "downloaded": 2, + "skipped_manifest": 1, + "failed": 2, + "failed_chapter_ids": [FAILED_CHAPTER_ID_A, FAILED_CHAPTER_ID_B], + }, + } + + +def test_cli_json_mode_includes_interrupted_summary_payload( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify interrupted runs emit JSON error payload including partial summary.""" + monkeypatch.setattr(cli_main, "MangaLoader", InterruptedLoader) + + runner = CliRunner() + result = runner.invoke(cli_main.main, ["--json", "--chapter-id", CHAPTER_ID]) + + assert result.exit_code == EXTERNAL_FAILURE + payload = json.loads(result.output) + assert payload == { + "status": "error", + "exit_code": EXTERNAL_FAILURE, + "message": "Download interrupted by user.", + "summary": { + "downloaded": 1, + "skipped_manifest": 1, + "failed": 1, + "failed_chapter_ids": [int(CHAPTER_ID_ALT)], + }, + } diff --git a/tests/test_cli_presenter.py b/tests/test_cli_presenter.py new file mode 100644 index 0000000..886b28d --- /dev/null +++ b/tests/test_cli_presenter.py @@ -0,0 +1,143 @@ +"""Tests for CLI output presenter behavior.""" + +from __future__ import annotations + +import json + +import pytest + +from mloader.cli.examples import CliExample +from mloader.cli.presenter import CliPresenter +from mloader.domain.requests import DownloadSummary +from mloader.manga_loader.capture_verify import CaptureVerificationSummary + + +def test_presenter_emits_human_intro_when_enabled(capsys: pytest.CaptureFixture[str]) -> None: + """Verify intro banner is emitted in default human-output mode.""" + presenter = CliPresenter(json_output=False, quiet=False) + + presenter.emit_intro("hello") + + assert "hello" in capsys.readouterr().out + + +def test_presenter_suppresses_human_intro_in_quiet_mode( + capsys: pytest.CaptureFixture[str], +) -> None: + """Verify intro banner is suppressed in quiet mode.""" + presenter = CliPresenter(json_output=False, quiet=True) + + presenter.emit_intro("hello") + + assert capsys.readouterr().out == "" + + +def test_presenter_emits_capture_summary_json_mode( + capsys: pytest.CaptureFixture[str], +) -> None: + """Verify capture summary is emitted as structured JSON in json mode.""" + presenter = CliPresenter(json_output=True, quiet=False) + + presenter.emit_capture_verification( + summary=CaptureVerificationSummary(total_records=3, endpoint_counts={"a": 1, "b": 2}), + capture_dir="./capture", + baseline_dir=None, + ) + + payload = json.loads(capsys.readouterr().out) + assert payload["status"] == "ok" + assert payload["mode"] == "verify_capture" + assert payload["total_records"] == 3 + assert payload["endpoint_counts"] == {"a": 1, "b": 2} + + +def test_presenter_suppresses_capture_summary_in_quiet_human_mode( + capsys: pytest.CaptureFixture[str], +) -> None: + """Verify quiet human mode suppresses capture-summary output.""" + presenter = CliPresenter(json_output=False, quiet=True) + + presenter.emit_capture_verification( + summary=CaptureVerificationSummary(total_records=1, endpoint_counts={"manga_viewer": 1}), + capture_dir="./capture", + baseline_dir="./baseline", + ) + + assert capsys.readouterr().out == "" + + +def test_presenter_emits_download_summary_human_mode( + capsys: pytest.CaptureFixture[str], +) -> None: + """Verify human mode prints a compact download summary with failed IDs.""" + presenter = CliPresenter(json_output=False, quiet=False) + presenter.emit_download_summary( + DownloadSummary( + downloaded=3, + skipped_manifest=2, + failed=1, + failed_chapter_ids=(99,), + ) + ) + + output = capsys.readouterr().out + assert "downloaded=3" in output + assert "skipped_manifest=2" in output + assert "failed=1" in output + assert "99" in output + + +def test_presenter_suppresses_download_summary_in_quiet_mode( + capsys: pytest.CaptureFixture[str], +) -> None: + """Verify quiet mode suppresses download-summary output.""" + presenter = CliPresenter(json_output=False, quiet=True) + presenter.emit_download_summary( + DownloadSummary( + downloaded=1, + skipped_manifest=0, + failed=0, + failed_chapter_ids=(), + ) + ) + + assert capsys.readouterr().out == "" + + +def test_presenter_emits_examples_human_mode(capsys: pytest.CaptureFixture[str]) -> None: + """Verify presenter prints example catalog in human mode.""" + presenter = CliPresenter(json_output=False, quiet=False) + presenter.emit_examples( + [ + CliExample( + title="Example title", + command="mloader --chapter-id 1024959", + description="Example description.", + ) + ] + ) + + output = capsys.readouterr().out + assert "mloader example catalog" in output + assert "Example title" in output + assert "mloader --chapter-id 1024959" in output + + +def test_presenter_emits_examples_json_mode(capsys: pytest.CaptureFixture[str]) -> None: + """Verify presenter emits structured example catalog in JSON mode.""" + presenter = CliPresenter(json_output=True, quiet=False) + presenter.emit_examples( + [ + CliExample( + title="Example title", + command="mloader --chapter-id 1024959", + description="Example description.", + ) + ] + ) + + payload = json.loads(capsys.readouterr().out) + assert payload["status"] == "ok" + assert payload["mode"] == "show_examples" + assert payload["count"] == 1 + assert payload["examples"][0]["command"] == "mloader --chapter-id 1024959" diff --git a/tests/test_cli_readme_reference.py b/tests/test_cli_readme_reference.py new file mode 100644 index 0000000..6393b18 --- /dev/null +++ b/tests/test_cli_readme_reference.py @@ -0,0 +1,85 @@ +"""Tests for README CLI reference generation helpers.""" + +from __future__ import annotations + +import click +import pytest + +from mloader.cli import readme_reference + + +def test_format_default_variants() -> None: + """Verify default rendering handles hidden, None, tuple, bool, and scalar values.""" + hidden_default = click.Option(["--hidden"], default="x", show_default=False) + none_default = click.Option(["--none"], default=None, show_default=True) + tuple_default = click.Option(["--multi"], default=("a", "b"), show_default=True) + bool_default = click.Option(["--flag"], is_flag=True, default=True, show_default=True) + scalar_default = click.Option(["--name"], default="abc", show_default=True) + + assert readme_reference._format_default(hidden_default) == "-" + assert readme_reference._format_default(none_default) == "-" + assert readme_reference._format_default(tuple_default) == "a, b" + assert readme_reference._format_default(bool_default) == "true" + assert readme_reference._format_default(scalar_default) == "abc" + + +def test_format_envvar_variants() -> None: + """Verify envvar rendering supports absent, tuple, and scalar envvar values.""" + no_envvar = click.Option(["--no-env"]) + tuple_envvar = click.Option(["--tuple-env"], envvar=("A", "B")) + scalar_envvar = click.Option(["--scalar-env"], envvar="A") + + assert readme_reference._format_envvar(no_envvar) == "-" + assert readme_reference._format_envvar(tuple_envvar) == "A, B" + assert readme_reference._format_envvar(scalar_envvar) == "A" + + +def test_render_cli_parameter_reference_escapes_markdown_cells() -> None: + """Verify rendered table escapes pipes and normalizes multiline help text.""" + command = click.Command( + "demo", + params=[ + click.Option( + ["--option"], + help="line one\nline two | with pipe", + default="value", + show_default=True, + ), + ], + ) + + rendered = readme_reference.render_cli_parameter_reference(command) + + assert "`URLS`:" in rendered + assert "line one line two \\| with pipe" in rendered + assert "| `--option` |" in rendered + + +def test_replace_readme_cli_reference_replaces_marker_section() -> None: + """Verify README replacement updates only the marker-delimited section.""" + template = ( + "prefix\n" + f"{readme_reference.README_CLI_REFERENCE_START}\n" + "old text\n" + f"{readme_reference.README_CLI_REFERENCE_END}\n" + "suffix\n" + ) + command = click.Command( + "demo", + params=[click.Option(["--foo"], help="help", default="bar", show_default=True)], + ) + + updated = readme_reference.replace_readme_cli_reference(template, command=command) + + assert "prefix" in updated + assert "suffix" in updated + assert "old text" not in updated + assert "| `--foo` | help | `bar` | `-` |" in updated + + +def test_replace_readme_cli_reference_raises_without_markers() -> None: + """Verify replacement raises when README marker comments are missing.""" + command = click.Command("demo") + + with pytest.raises(ValueError, match="README CLI reference markers not found"): + readme_reference.replace_readme_cli_reference("no markers here", command=command) diff --git a/tests/test_cli_title_discovery.py b/tests/test_cli_title_discovery.py new file mode 100644 index 0000000..b386b82 --- /dev/null +++ b/tests/test_cli_title_discovery.py @@ -0,0 +1,762 @@ +"""Tests for title discovery utilities and ``mloader --all`` orchestration.""" + +from __future__ import annotations + +import json +import types +import sys +from typing import Any, ClassVar + +import pytest +import requests +from click.testing import CliRunner + +from mloader.cli.exit_codes import EXTERNAL_FAILURE, VALIDATION_ERROR +from mloader.cli import title_discovery +from mloader.cli import main as cli_main +from mloader.domain.requests import DownloadSummary +from mloader.errors import SubscriptionRequiredError +from mloader.response_pb2 import Response # type: ignore + + +class DummyResponse: + """Minimal response test double for scraper requests.""" + + def __init__(self, text: str = "", content: bytes | None = None) -> None: + """Store response body for extraction tests.""" + self.text = text + self.content = content if content is not None else text.encode("utf-8") + + def raise_for_status(self) -> None: + """Simulate successful response status.""" + + +class DummySession: + """Session test double yielding deterministic page content.""" + + def __init__(self, payloads: dict[str, str | bytes]) -> None: + """Store URL-to-payload mapping for request simulations.""" + self.payloads = payloads + self.calls: list[tuple[str, tuple[float, float]]] = [] + + def __enter__(self) -> DummySession: + """Support context manager protocol.""" + return self + + def __exit__(self, *args: object) -> None: + """Support context manager protocol.""" + _ = args + + def get(self, url: str, timeout: tuple[float, float]) -> DummyResponse: + """Record request and return mapped HTML payload.""" + self.calls.append((url, timeout)) + payload = self.payloads[url] + if isinstance(payload, bytes): + return DummyResponse(content=payload) + return DummyResponse(text=payload) + + +class DummyLoader: + """Loader test double capturing init and download payloads.""" + + init_args: ClassVar[dict[str, Any] | None] = None + download_args: ClassVar[dict[str, Any] | None] = None + + def __init__( + self, + exporter_factory: Any, + quality: str, + split: bool, + meta: bool, + cover: bool, + destination: str, + output_format: str, + capture_api_dir: str | None, + resume: bool, + manifest_reset: bool, + ) -> None: + """Store initializer payload for assertions.""" + type(self).init_args = { + "exporter_factory": exporter_factory, + "quality": quality, + "split": split, + "meta": meta, + "cover": cover, + "destination": destination, + "output_format": output_format, + "capture_api_dir": capture_api_dir, + "resume": resume, + "manifest_reset": manifest_reset, + } + + def download(self, **kwargs: Any) -> DownloadSummary: + """Store download payload for assertions.""" + type(self).download_args = kwargs + return DownloadSummary( + downloaded=1, + skipped_manifest=0, + failed=0, + failed_chapter_ids=(), + ) + + +class FailingLoader(DummyLoader): + """Loader test double raising generic runtime exceptions.""" + + def download(self, **kwargs: Any) -> None: + """Raise generic runtime failure for CLI error handling path.""" + del kwargs + raise RuntimeError("boom") + + +class SubscriptionLoader(DummyLoader): + """Loader test double raising subscription errors.""" + + def download(self, **kwargs: Any) -> None: + """Raise subscription error for CLI message handling path.""" + del kwargs + raise SubscriptionRequiredError("subscription required") + + +class PartialFailureLoader(DummyLoader): + """Loader test double returning summary with failed chapters.""" + + def download(self, **kwargs: Any) -> DownloadSummary: + """Return a deterministic partial-failure summary.""" + del kwargs + return DownloadSummary( + downloaded=1, + skipped_manifest=0, + failed=1, + failed_chapter_ids=(123,), + ) + + +def _build_all_titles_payload(title_ids: list[int]) -> bytes: + """Build a minimal serialized all-titles protobuf payload for tests.""" + parsed = Response() + group = parsed.success.all_titles_view.title_groups.add() + group.group_name = "group" + for title_id in title_ids: + title = group.titles.add() + title.title_id = title_id + title.name = f"title-{title_id}" + return parsed.SerializeToString() + + +def _build_all_titles_payload_with_languages(titles: list[tuple[int, int]]) -> bytes: + """Build a minimal serialized all-titles protobuf payload with language codes.""" + parsed = Response() + group = parsed.success.all_titles_view.title_groups.add() + group.group_name = "group" + for title_id, language in titles: + title = group.titles.add() + title.title_id = title_id + title.name = f"title-{title_id}" + title.language = language + return parsed.SerializeToString() + + +def test_extract_title_ids_respects_id_length_filter() -> None: + """Verify HTML extraction keeps only IDs matching configured digit length.""" + html = ( + 'ok' + 'short' + 'dup' + ) + assert title_discovery.extract_title_ids(html, id_length=6) == {123456} + assert title_discovery.extract_title_ids(html, id_length=None) == {10031, 123456} + + +def test_extract_title_ids_matches_escaped_slash_links() -> None: + """Verify extractor supports escaped JSON-style title links.""" + html = r'{"href":"\/titles\/123456\/"}{"href":"\/titles\/654321"}' + assert title_discovery.extract_title_ids(html, id_length=None) == {123456, 654321} + + +def test_extract_title_ids_from_api_payload_respects_id_length_filter() -> None: + """Verify binary API extraction keeps IDs matching configured digit length.""" + payload = _build_all_titles_payload([100001, 99999]) + assert title_discovery.extract_title_ids_from_api_payload(payload, id_length=6) == {100001} + assert title_discovery.extract_title_ids_from_api_payload(payload, id_length=None) == { + 99999, + 100001, + } + + +def test_extract_title_ids_from_api_payload_skips_non_positive_ids() -> None: + """Verify protobuf extraction ignores non-positive title IDs.""" + parsed = Response() + group = parsed.success.all_titles_view.title_groups.add() + title = group.titles.add() + title.title_id = 0 + + assert ( + title_discovery.extract_title_ids_from_api_payload( + parsed.SerializeToString(), + id_length=None, + ) + == set() + ) + + +def test_extract_title_ids_from_api_payload_filters_languages() -> None: + """Verify protobuf extraction can filter by allowed language codes.""" + payload = _build_all_titles_payload_with_languages( + [ + (100001, 0), + (100002, 1), + (100003, 0), + ] + ) + + result = title_discovery.extract_title_ids_from_api_payload_with_language_filter( + payload, + id_length=6, + allowed_languages={0}, + ) + + assert result == {100001, 100003} + + +def test_collect_title_ids_from_api_returns_sorted_unique_ids( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify API scraper deduplicates IDs and returns sorted list.""" + payload = _build_all_titles_payload([100003, 100001, 100001, 100002]) + dummy_session = DummySession({"https://api.example/allV2": payload}) + monkeypatch.setattr(title_discovery.requests, "Session", lambda: dummy_session) + + result = title_discovery.collect_title_ids_from_api( + "https://api.example/allV2", + id_length=6, + allowed_languages=None, + ) + + assert result == [100001, 100002, 100003] + assert dummy_session.calls == [("https://api.example/allV2", (5.0, 30.0))] + + +def test_collect_title_ids_returns_sorted_unique_ids(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify scraper deduplicates IDs and returns sorted list.""" + payloads = { + "https://a.example": 'AB', + "https://b.example": 'CD', + } + dummy_session = DummySession(payloads) + monkeypatch.setattr(title_discovery.requests, "Session", lambda: dummy_session) + + result = title_discovery.collect_title_ids( + ["https://a.example", "https://b.example"], + id_length=6, + ) + + assert result == [100001, 100002, 100003] + assert dummy_session.calls == [ + ("https://a.example", (5.0, 30.0)), + ("https://b.example", (5.0, 30.0)), + ] + + +def test_parse_language_filters_returns_none_for_empty_input() -> None: + """Verify empty language filters preserve unfiltered behavior.""" + assert title_discovery.parse_language_filters(()) is None + + +def test_parse_language_filters_merges_multiple_languages() -> None: + """Verify language filter parser resolves multiple language selectors.""" + result = title_discovery.parse_language_filters(("english", "vietnamese")) + + assert result is not None + assert 0 in result + assert 9 in result + assert 8 in result + + +def test_collect_title_ids_with_browser_returns_sorted_unique_ids( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify browser scrape can collect IDs via rendered DOM links.""" + + class DummyLink: + """Link test double with optional href attribute.""" + + def __init__(self, href: str | None) -> None: + """Store href value returned by DOM attribute access.""" + self.href = href + + def get_attribute(self, _name: str) -> str | None: + """Return configured href value.""" + return self.href + + class DummyPage: + """Page test double with deterministic links for all requested pages.""" + + def goto(self, *_args: object, **_kwargs: object) -> None: + """Accept navigation calls without side effects.""" + + def query_selector_all(self, _selector: str) -> list[DummyLink]: + """Return deterministic set of DOM links for extraction.""" + return [ + DummyLink("/titles/100003"), + DummyLink("/titles/100001/"), + DummyLink("/titles/100001"), + DummyLink(None), + ] + + class DummyBrowser: + """Browser test double producing a single page object.""" + + def new_page(self) -> DummyPage: + """Return page test double for scrape actions.""" + return DummyPage() + + def close(self) -> None: + """Support browser lifecycle teardown call.""" + + class DummyChromium: + """Chromium launcher test double.""" + + def launch(self, *, headless: bool) -> DummyBrowser: + """Return browser test double for headless mode.""" + assert headless is True + return DummyBrowser() + + class DummyPlaywright: + """Playwright root object exposing chromium launcher.""" + + chromium = DummyChromium() + + class DummyPlaywrightContext: + """Context-manager test double returned by sync_playwright().""" + + def __enter__(self) -> DummyPlaywright: + """Return playwright object for context manager body.""" + return DummyPlaywright() + + def __exit__(self, *args: object) -> None: + """Support context manager protocol.""" + _ = args + + sync_api_module = types.ModuleType("playwright.sync_api") + sync_api_module.sync_playwright = lambda: DummyPlaywrightContext() + playwright_module = types.ModuleType("playwright") + playwright_module.sync_api = sync_api_module + monkeypatch.setitem(sys.modules, "playwright", playwright_module) + monkeypatch.setitem(sys.modules, "playwright.sync_api", sync_api_module) + + result = title_discovery.collect_title_ids_with_browser( + ["https://a.example", "https://b.example"], + id_length=6, + ) + + assert result == [100001, 100003] + + +def test_cli_list_only_prints_ids_without_downloading(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify --all --list-only prints IDs and exits before loader initialization.""" + DummyLoader.init_args = None + monkeypatch.setattr( + cli_main.title_discovery, + "collect_title_ids_from_api", + lambda *_args, **_kwargs: [100001, 100002], + ) + monkeypatch.setattr(cli_main, "MangaLoader", DummyLoader) + + runner = CliRunner() + result = runner.invoke(cli_main.main, ["--all", "--list-only"]) + + assert result.exit_code == 0 + assert "100001 100002" in result.output + assert DummyLoader.init_args is None + + +def test_cli_rejects_list_only_without_all() -> None: + """Verify --list-only requires --all mode.""" + runner = CliRunner() + result = runner.invoke(cli_main.main, ["--list-only"]) + + assert result.exit_code == VALIDATION_ERROR + assert "--list-only requires --all." in result.output + + +def test_cli_rejects_language_without_all() -> None: + """Verify --language requires --all mode.""" + runner = CliRunner() + result = runner.invoke(cli_main.main, ["--language", "english"]) + + assert result.exit_code == VALIDATION_ERROR + assert "--language requires --all." in result.output + + +def test_cli_forwards_language_filters_to_api(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify --language is translated into language-code filters for API discovery.""" + observed_languages: set[int] | None = None + + def _collect( + *_args: object, + **kwargs: object, + ) -> list[int]: + nonlocal observed_languages + observed_languages = kwargs["allowed_languages"] # type: ignore[index] + return [100001] + + monkeypatch.setattr(cli_main.title_discovery, "collect_title_ids_from_api", _collect) + monkeypatch.setattr(cli_main, "MangaLoader", DummyLoader) + + runner = CliRunner() + result = runner.invoke( + cli_main.main, + ["--all", "--language", "english", "--language", "spanish"], + ) + + assert result.exit_code == 0 + assert observed_languages == {0, 1} + + +def test_cli_downloads_with_loader_and_forwards_options( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify --all initializes loader and forwards discovered title IDs.""" + DummyLoader.init_args = None + DummyLoader.download_args = None + monkeypatch.setattr( + cli_main.title_discovery, + "collect_title_ids_from_api", + lambda *_args, **_kwargs: [100001, 100002], + ) + monkeypatch.setattr(cli_main, "MangaLoader", DummyLoader) + + runner = CliRunner() + result = runner.invoke( + cli_main.main, + ["--all", "--format", "cbz", "--capture-api", "/tmp/capture", "--out", "/tmp/downloads"], + ) + + assert result.exit_code == 0 + assert DummyLoader.init_args is not None + assert DummyLoader.download_args is not None + assert DummyLoader.init_args["output_format"] == "cbz" + assert DummyLoader.init_args["capture_api_dir"] == "/tmp/capture" + assert DummyLoader.download_args["title_ids"] == {100001, 100002} + + +def test_cli_download_uses_raw_exporter_branch(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify --raw switches output format to raw.""" + DummyLoader.init_args = None + monkeypatch.setattr( + cli_main.title_discovery, + "collect_title_ids_from_api", + lambda *_args, **_kwargs: [100001], + ) + monkeypatch.setattr(cli_main, "MangaLoader", DummyLoader) + + runner = CliRunner() + result = runner.invoke(cli_main.main, ["--all", "--raw"]) + + assert result.exit_code == 0 + assert DummyLoader.init_args is not None + assert DummyLoader.init_args["output_format"] == "raw" + + +def test_cli_fails_when_no_titles_are_discovered(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify CLI returns error when scraper finds no title IDs.""" + monkeypatch.setattr( + cli_main.title_discovery, + "collect_title_ids_from_api", + lambda *_args, **_kwargs: [], + ) + monkeypatch.setattr(cli_main.title_discovery, "collect_title_ids", lambda *_args, **_kwargs: []) + + runner = CliRunner() + result = runner.invoke(cli_main.main, ["--all", "--no-browser-fallback"]) + + assert result.exit_code != 0 + assert "No title IDs found on configured list pages." in result.output + + +def test_cli_fails_when_language_filter_has_no_results(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify empty API results with language filters raise a targeted message.""" + monkeypatch.setattr( + cli_main.title_discovery, + "collect_title_ids_from_api", + lambda *_args, **_kwargs: [], + ) + monkeypatch.setattr( + cli_main.title_discovery, + "collect_title_ids", + lambda *_args, **_kwargs: [100001], + ) + + runner = CliRunner() + result = runner.invoke(cli_main.main, ["--all", "--language", "german"]) + + assert result.exit_code != 0 + assert "No title IDs found for selected language filter(s): german." in result.output + + +def test_cli_fails_when_scraper_request_errors(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify scraper request exceptions are surfaced as click errors.""" + + def _raise_error(*_args: object, **_kwargs: object) -> list[int]: + raise requests.RequestException("network") + + monkeypatch.setattr( + cli_main.title_discovery, + "collect_title_ids_from_api", + lambda *_args, **_kwargs: [], + ) + monkeypatch.setattr(cli_main.title_discovery, "collect_title_ids", _raise_error) + + runner = CliRunner() + result = runner.invoke(cli_main.main, ["--all", "--no-browser-fallback"]) + + assert result.exit_code != 0 + assert "Failed to fetch title pages: network" in result.output + + +def test_cli_fails_when_language_filter_api_request_errors( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify language filtering stops when API request fails.""" + + def _raise_api_error(*_args: object, **_kwargs: object) -> list[int]: + raise requests.RequestException("api down") + + monkeypatch.setattr(cli_main.title_discovery, "collect_title_ids_from_api", _raise_api_error) + + runner = CliRunner() + result = runner.invoke(cli_main.main, ["--all", "--language", "english"]) + + assert result.exit_code == EXTERNAL_FAILURE + assert "Language filtering requires API title-index access" in result.output + + +def test_cli_json_list_only_returns_structured_payload( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify --json --all --list-only emits machine-readable title discovery output.""" + monkeypatch.setattr( + cli_main.title_discovery, + "collect_title_ids_from_api", + lambda *_args, **_kwargs: [100001, 100002], + ) + + runner = CliRunner() + result = runner.invoke(cli_main.main, ["--json", "--all", "--list-only"]) + + assert result.exit_code == 0 + payload = json.loads(result.output) + assert payload == { + "status": "ok", + "mode": "all_list_only", + "exit_code": 0, + "count": 2, + "title_ids": [100001, 100002], + } + + +def test_cli_quiet_list_only_exits_without_human_output( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify --quiet --all --list-only performs discovery and exits quietly.""" + monkeypatch.setattr( + cli_main.title_discovery, + "collect_title_ids_from_api", + lambda *_args, **_kwargs: [100001, 100002], + ) + + runner = CliRunner() + result = runner.invoke(cli_main.main, ["--quiet", "--all", "--list-only"]) + + assert result.exit_code == 0 + assert result.output == "" + + +def test_cli_fails_on_subscription_error(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify subscription failures propagate user-facing click message.""" + monkeypatch.setattr( + cli_main.title_discovery, + "collect_title_ids_from_api", + lambda *_args, **_kwargs: [100001], + ) + monkeypatch.setattr(cli_main, "MangaLoader", SubscriptionLoader) + + runner = CliRunner() + result = runner.invoke(cli_main.main, ["--all"]) + + assert result.exit_code != 0 + assert "subscription required" in result.output + + +def test_cli_all_mode_maps_partial_summary_failures_to_external_failure( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify --all returns external-failure exit code for partial chapter failures.""" + monkeypatch.setattr( + cli_main.title_discovery, + "collect_title_ids_from_api", + lambda *_args, **_kwargs: [100001], + ) + monkeypatch.setattr(cli_main, "MangaLoader", PartialFailureLoader) + + runner = CliRunner() + result = runner.invoke(cli_main.main, ["--all"]) + + assert result.exit_code == EXTERNAL_FAILURE + assert "Download completed with 1 failed chapter(s)." in result.output + + +def test_cli_fails_on_generic_loader_error(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify generic loader exceptions are wrapped as download failures.""" + monkeypatch.setattr( + cli_main.title_discovery, + "collect_title_ids_from_api", + lambda *_args, **_kwargs: [100001], + ) + monkeypatch.setattr(cli_main, "MangaLoader", FailingLoader) + + runner = CliRunner() + result = runner.invoke(cli_main.main, ["--all"]) + + assert result.exit_code != 0 + assert "Download failed" in result.output + + +def test_cli_uses_browser_fallback_when_static_scrape_returns_empty( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify browser fallback is used when static extraction returns no IDs.""" + DummyLoader.download_args = None + monkeypatch.setattr( + cli_main.title_discovery, + "collect_title_ids_from_api", + lambda *_args, **_kwargs: [], + ) + monkeypatch.setattr(cli_main.title_discovery, "collect_title_ids", lambda *_args, **_kwargs: []) + monkeypatch.setattr( + cli_main.title_discovery, + "collect_title_ids_with_browser", + lambda *_args, **_kwargs: [100010, 100011], + ) + monkeypatch.setattr(cli_main, "MangaLoader", DummyLoader) + + runner = CliRunner() + result = runner.invoke(cli_main.main, ["--all"]) + + assert result.exit_code == 0 + assert DummyLoader.download_args is not None + assert DummyLoader.download_args["title_ids"] == {100010, 100011} + + +def test_cli_can_disable_browser_fallback(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify disabling browser fallback keeps empty-result failure behavior.""" + monkeypatch.setattr( + cli_main.title_discovery, + "collect_title_ids_from_api", + lambda *_args, **_kwargs: [], + ) + monkeypatch.setattr(cli_main.title_discovery, "collect_title_ids", lambda *_args, **_kwargs: []) + + runner = CliRunner() + result = runner.invoke(cli_main.main, ["--all", "--no-browser-fallback"]) + + assert result.exit_code != 0 + assert "No title IDs found on configured list pages." in result.output + + +def test_cli_fails_when_browser_fallback_raises_runtime(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify browser fallback runtime failures are surfaced as click errors.""" + monkeypatch.setattr( + cli_main.title_discovery, + "collect_title_ids_from_api", + lambda *_args, **_kwargs: [], + ) + monkeypatch.setattr(cli_main.title_discovery, "collect_title_ids", lambda *_args, **_kwargs: []) + + def _raise_runtime(*_args: object, **_kwargs: object) -> list[int]: + raise RuntimeError("browser missing") + + monkeypatch.setattr(cli_main.title_discovery, "collect_title_ids_with_browser", _raise_runtime) + + runner = CliRunner() + result = runner.invoke(cli_main.main, ["--all"]) + + assert result.exit_code != 0 + assert "browser missing" in result.output + + +def test_cli_fails_when_browser_fallback_raises_generic(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify unexpected browser fallback errors are wrapped predictably.""" + monkeypatch.setattr( + cli_main.title_discovery, + "collect_title_ids_from_api", + lambda *_args, **_kwargs: [], + ) + monkeypatch.setattr(cli_main.title_discovery, "collect_title_ids", lambda *_args, **_kwargs: []) + + def _raise_generic(*_args: object, **_kwargs: object) -> list[int]: + raise ValueError("bad page") + + monkeypatch.setattr(cli_main.title_discovery, "collect_title_ids_with_browser", _raise_generic) + + runner = CliRunner() + result = runner.invoke(cli_main.main, ["--all"]) + + assert result.exit_code != 0 + assert "Browser fallback failed: bad page" in result.output + + +def test_cli_uses_browser_fallback_when_static_fetch_errors( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify static fetch errors are reported before browser fallback succeeds.""" + + def _raise_static_error(*_args: object, **_kwargs: object) -> list[int]: + raise requests.RequestException("static down") + + DummyLoader.download_args = None + monkeypatch.setattr( + cli_main.title_discovery, + "collect_title_ids_from_api", + lambda *_args, **_kwargs: [], + ) + monkeypatch.setattr(cli_main.title_discovery, "collect_title_ids", _raise_static_error) + monkeypatch.setattr( + cli_main.title_discovery, + "collect_title_ids_with_browser", + lambda *_args, **_kwargs: [100070], + ) + monkeypatch.setattr(cli_main, "MangaLoader", DummyLoader) + + runner = CliRunner() + result = runner.invoke(cli_main.main, ["--all"]) + + assert result.exit_code == 0 + assert "Static fetch failed: static down. Retrying with browser fallback." in result.output + assert DummyLoader.download_args is not None + assert DummyLoader.download_args["title_ids"] == {100070} + + +def test_cli_can_use_static_scrape_when_api_fetch_fails(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify static scrape fallback can recover after API request failure.""" + + def _raise_api_error(*_args: object, **_kwargs: object) -> list[int]: + raise requests.RequestException("api down") + + DummyLoader.download_args = None + monkeypatch.setattr(cli_main.title_discovery, "collect_title_ids_from_api", _raise_api_error) + monkeypatch.setattr( + cli_main.title_discovery, + "collect_title_ids", + lambda *_args, **_kwargs: [100050], + ) + monkeypatch.setattr(cli_main, "MangaLoader", DummyLoader) + + runner = CliRunner() + result = runner.invoke(cli_main.main, ["--all"]) + + assert result.exit_code == 0 + assert "API title-index fetch failed: api down" in result.output + assert DummyLoader.download_args is not None + assert DummyLoader.download_args["title_ids"] == {100050} diff --git a/tests/test_config_module.py b/tests/test_config_module.py new file mode 100644 index 0000000..27715d0 --- /dev/null +++ b/tests/test_config_module.py @@ -0,0 +1,150 @@ +"""Tests for environment-backed configuration values.""" + +from __future__ import annotations + +import importlib +from pathlib import Path + +import pytest + +import mloader.config as config + + +def test_auth_params_respect_environment(monkeypatch: pytest.MonkeyPatch) -> None: + """Ensure AUTH_PARAMS reflects environment overrides after reload.""" + monkeypatch.setenv("APP_VER", "999") + monkeypatch.setenv("OS", "android") + monkeypatch.setenv("OS_VER", "42") + monkeypatch.setenv("SECRET", "secret-value") + + reloaded = importlib.reload(config) + + assert reloaded.AUTH_PARAMS == { + "app_ver": "999", + "os": "android", + "os_ver": "42", + "secret": "secret-value", + } + + +def test_load_auth_settings_uses_file_when_env_is_missing(tmp_path: Path) -> None: + """Ensure file-based auth config is used when no env overrides are present.""" + config_file = tmp_path / ".mloader.toml" + config_file.write_text( + '[auth]\napp_ver = "123"\nos = "android"\nos_ver = "14.0"\nsecret = "from-file"\n', + encoding="utf-8", + ) + + settings = config.load_auth_settings(environ={}, config_file=config_file) + + assert settings.as_query_params() == { + "app_ver": "123", + "os": "android", + "os_ver": "14.0", + "secret": "from-file", + } + + +def test_load_auth_settings_uses_env_over_file(tmp_path: Path) -> None: + """Ensure environment values override config-file values by key.""" + config_file = tmp_path / ".mloader.toml" + config_file.write_text( + '[auth]\napp_ver = "123"\nos = "android"\nos_ver = "14.0"\nsecret = "from-file"\n', + encoding="utf-8", + ) + + settings = config.load_auth_settings( + environ={ + "APP_VER": "999", + "OS": "ios", + "OS_VER": "18.1", + "SECRET": "from-env", + }, + config_file=config_file, + ) + + assert settings.as_query_params() == { + "app_ver": "999", + "os": "ios", + "os_ver": "18.1", + "secret": "from-env", + } + + +def test_load_auth_settings_uses_overrides_over_env_and_file(tmp_path: Path) -> None: + """Ensure explicit overrides are highest-priority values.""" + config_file = tmp_path / ".mloader.toml" + config_file.write_text('[auth]\napp_ver = "111"\nos = "android"\n', encoding="utf-8") + + settings = config.load_auth_settings( + environ={"APP_VER": "222", "OS": "ios"}, + config_file=config_file, + overrides={"app_ver": "333"}, + ) + + assert settings.app_ver == "333" + assert settings.os == "ios" + + +def test_load_auth_settings_rejects_unknown_override_key() -> None: + """Ensure unknown override keys fail fast with explicit error message.""" + with pytest.raises(ValueError, match="Unsupported auth override key"): + config.load_auth_settings(overrides={"unknown": "value"}) + + +def test_load_auth_settings_ignores_missing_config_file_path() -> None: + """Ensure missing config file paths are treated as empty config data.""" + settings = config.load_auth_settings(environ={}, config_file="/tmp/does-not-exist-mloader.toml") + + assert settings.as_query_params() == { + "app_ver": "97", + "os": "ios", + "os_ver": "18.1", + "secret": "f40080bcb01a9a963912f46688d411a3", + } + + +def test_load_auth_settings_uses_env_config_file_path(tmp_path: Path) -> None: + """Ensure MLOADER_CONFIG_FILE path is used when explicit path is omitted.""" + config_file = tmp_path / "custom.toml" + config_file.write_text('[auth]\napp_ver = "777"\n', encoding="utf-8") + + settings = config.load_auth_settings( + environ={"MLOADER_CONFIG_FILE": str(config_file)}, + config_file=None, + ) + + assert settings.app_ver == "777" + + +def test_load_auth_settings_reads_default_config_file_from_cwd( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Ensure default .mloader.toml is discovered from current working directory.""" + default_config = tmp_path / ".mloader.toml" + default_config.write_text('[auth]\nsecret = "from-default"\n', encoding="utf-8") + monkeypatch.chdir(tmp_path) + + settings = config.load_auth_settings(environ={}, config_file=None) + + assert settings.secret == "from-default" + + +def test_load_auth_settings_invalid_auth_table_type_raises(tmp_path: Path) -> None: + """Ensure invalid [auth] shape fails fast with explicit error details.""" + config_file = tmp_path / ".mloader.toml" + config_file.write_text('auth = "invalid"', encoding="utf-8") + + with pytest.raises(ValueError, match="\\[auth\\] section must be a table"): + config.load_auth_settings(environ={}, config_file=config_file) + + +def test_load_auth_settings_missing_auth_section_uses_defaults(tmp_path: Path) -> None: + """Ensure config files without [auth] section do not override defaults.""" + config_file = tmp_path / ".mloader.toml" + config_file.write_text('[other]\nvalue = "x"\n', encoding="utf-8") + + settings = config.load_auth_settings(environ={}, config_file=config_file) + + assert settings.as_query_params()["app_ver"] == "97" diff --git a/tests/test_decryption.py b/tests/test_decryption.py new file mode 100644 index 0000000..f2011bf --- /dev/null +++ b/tests/test_decryption.py @@ -0,0 +1,44 @@ +"""Tests for decryption helper functions and mixin behavior.""" + +from __future__ import annotations + +from types import SimpleNamespace + +from mloader.manga_loader import decryption + + +class DummyDecryptor(decryption.DecryptionMixin): + """Simple decryptor test double with an in-memory HTTP response.""" + + def __init__(self, payload: bytes) -> None: + """Store a fake session returning ``payload`` for any URL.""" + self.request_timeout = (1.0, 2.0) + self.session = SimpleNamespace( + get=lambda _url, timeout: SimpleNamespace( + content=payload, raise_for_status=lambda: None + ), + ) + + +def test_convert_hex_to_bytes_and_xor_decrypt_roundtrip() -> None: + """Verify XOR decryption restores the original plaintext bytes.""" + key = decryption._convert_hex_to_bytes("0f") + encrypted = bytearray([0x41 ^ 0x0F, 0x42 ^ 0x0F]) + + decrypted = decryption._xor_decrypt(encrypted, key) + + assert decrypted == bytearray(b"AB") + + +def test_fetch_encrypted_data_and_decrypt_image() -> None: + """Verify mixin fetch and decrypt methods return the expected plaintext.""" + key_hex = "0f0f" + original = bytearray(b"abc") + encrypted = decryption._xor_decrypt(bytearray(original), bytes.fromhex(key_hex)) + + decryptor = DummyDecryptor(bytes(encrypted)) + fetched = decryptor._fetch_encrypted_data("http://example") + decrypted = decryptor._decrypt_image("http://example", key_hex) + + assert fetched == encrypted + assert decrypted == original diff --git a/tests/test_domain_requests.py b/tests/test_domain_requests.py new file mode 100644 index 0000000..56987af --- /dev/null +++ b/tests/test_domain_requests.py @@ -0,0 +1,81 @@ +"""Tests for immutable domain request models.""" + +from __future__ import annotations + +from mloader.domain.requests import DownloadRequest, MAX_CHAPTER_ID + + +def _build_request(*, end: int | None = None) -> DownloadRequest: + """Create a baseline download request for domain model tests.""" + return DownloadRequest( + out_dir="/tmp", + raw=False, + output_format="cbz", + capture_api_dir=None, + quality="super_high", + split=False, + begin=0, + end=end, + last=False, + chapter_title=False, + chapter_subdir=False, + meta=False, + cover=False, + resume=True, + manifest_reset=False, + chapters=frozenset(), + chapter_ids=frozenset(), + titles=frozenset(), + ) + + +def test_download_request_max_chapter_defaults_to_domain_limit() -> None: + """Verify unset max bound resolves to the domain-level fallback limit.""" + request = _build_request(end=None) + + assert request.max_chapter == MAX_CHAPTER_ID + + +def test_download_request_max_chapter_uses_explicit_end_bound() -> None: + """Verify explicit chapter end bound is preserved as max chapter.""" + request = _build_request(end=42) + + assert request.max_chapter == 42 + + +def test_download_request_with_additional_titles_merges_and_deduplicates() -> None: + """Verify helper returns a new request with merged title IDs.""" + request = _build_request(end=42).with_additional_titles({100001, 100002}) + merged = request.with_additional_titles({100002, 100003}) + + assert request.titles == frozenset({100001, 100002}) + assert merged.titles == frozenset({100001, 100002, 100003}) + + +def test_download_request_has_targets_reflects_titles_or_chapters() -> None: + """Verify target presence flag is true when either target bucket is populated.""" + assert _build_request().has_targets is False + assert _build_request().with_additional_titles({1}).has_targets is True + + chapters_only = DownloadRequest( + out_dir="/tmp", + raw=False, + output_format="cbz", + capture_api_dir=None, + quality="super_high", + split=False, + begin=0, + end=None, + last=False, + chapter_title=False, + chapter_subdir=False, + meta=False, + cover=False, + resume=True, + manifest_reset=False, + chapters=frozenset({77}), + chapter_ids=frozenset(), + titles=frozenset(), + ) + + assert chapters_only.has_targets is True diff --git a/tests/test_downloader.py b/tests/test_downloader.py new file mode 100644 index 0000000..18d1e7d --- /dev/null +++ b/tests/test_downloader.py @@ -0,0 +1,1392 @@ +"""Tests for download orchestration helpers.""" + +from __future__ import annotations + +import json +from contextlib import contextmanager +from io import BytesIO +from pathlib import Path +from types import SimpleNamespace +from typing import Any, Iterator + +import click +import pytest +from PIL import Image + +from mloader.constants import PageType +from mloader.domain.requests import DownloadSummary +from mloader.errors import SubscriptionRequiredError +from mloader.manga_loader.downloader import DownloadInterruptedError, DownloadMixin +from mloader.manga_loader.run_report import RunReport +from mloader.manga_loader.services import ChapterMetadata + + +class DummyDownloader(DownloadMixin): + """DownloadMixin harness overriding selected side-effect methods.""" + + def __init__(self, destination: str = "/tmp/out") -> None: + """Initialize downloader with a fake exporter destination.""" + self.exporter = SimpleNamespace(keywords={"destination": destination}) + self.destination = destination + self.output_format = "pdf" + self.request_timeout = (0.1, 0.1) + self.meta = False + self.cover = False + self.resume = True + self.manifest_reset = False + + def _extract_chapter_data(self, title_dump: Any) -> dict[int, dict[str, Any]]: + """Return pre-seeded chapter data stored on ``title_dump``.""" + return title_dump.chapter_data + + def _download_image(self, url: str) -> bytes: + """Return deterministic bytes for a given image URL.""" + return f"img:{url}".encode("utf-8") + + def _decrypt_image(self, url: str, encryption_hex: str) -> bytearray: + """Return deterministic decrypted bytes for encrypted page URLs.""" + return bytearray(f"dec:{url}:{encryption_hex}".encode("utf-8")) + + +class FullDownloader(DownloadMixin): + """DownloadMixin harness using real mixin internals where possible.""" + + def __init__(self, destination: str = "/tmp/out") -> None: + """Initialize downloader with a fake exporter destination and session.""" + self.exporter = SimpleNamespace(keywords={"destination": destination}) + self.destination = destination + self.output_format = "pdf" + self.request_timeout = (0.1, 0.1) + self.meta = False + self.cover = False + self.resume = True + self.manifest_reset = False + self.session = DummySession(DummyResponse(content=b"default")) + + +class DummyResponse: + """Simple HTTP response test double with status tracking.""" + + def __init__(self, content: bytes = b"data") -> None: + """Store the payload and initialize status tracking.""" + self.content = content + self.status_checked = False + + def raise_for_status(self) -> None: + """Record that status validation was executed.""" + self.status_checked = True + + +class DummySession: + """Simple HTTP session test double collecting requested URLs.""" + + def __init__(self, response: DummyResponse) -> None: + """Initialize session with a fixed response object.""" + self.response = response + self.calls: list[str] = [] + + def get(self, url: str, timeout: tuple[float, float]) -> DummyResponse: + """Record URL requests and return the configured response.""" + del timeout + self.calls.append(url) + return self.response + + +def _chapter(chapter_id: int, name: str, sub_title: str = "sub") -> SimpleNamespace: + """Build a minimal chapter object.""" + return SimpleNamespace(chapter_id=chapter_id, name=name, sub_title=sub_title) + + +def _group(chapters: list[SimpleNamespace]) -> SimpleNamespace: + """Build a chapter group wrapper used by title dumps.""" + return SimpleNamespace( + first_chapter_list=list(chapters), + mid_chapter_list=[], + last_chapter_list=[], + ) + + +def _run_report() -> RunReport: + """Return mutable run report instance compatible with downloader internals.""" + return RunReport() + + +def test_filter_chapters_to_download_skips_existing_files() -> None: + """Verify existing chapter files are skipped from download candidates.""" + downloader = DummyDownloader() + chapter_data = { + 1024959: {"chapter_id": 1024959, "sub_title": "Chapter One"}, + 102278: {"chapter_id": 102278, "sub_title": "Chapter Two"}, + } + chapter1 = _chapter(1024959, "#1024959") + chapter2 = _chapter(102278, "#102278") + title_dump = SimpleNamespace(chapter_list_group=[_group([chapter1, chapter2])]) + title_detail = SimpleNamespace(name="My Manga") + + existing = [downloader._build_expected_filename("My Manga", chapter1, "Chapter One")] + + result = downloader._filter_chapters_to_download( + chapter_data, + title_dump, + title_detail, + existing_files=existing, + requested_chapter_ids={1024959, 102278}, + ) + + assert result == [102278] + + +def test_dump_title_metadata_writes_expected_json(tmp_path: Path) -> None: + """Verify metadata exporter writes normalized chapter metadata JSON.""" + downloader = DummyDownloader(destination=str(tmp_path)) + title_dump = SimpleNamespace( + non_appearance_info="n/a", + number_of_views=321, + overview="overview", + title=SimpleNamespace(name="my manga", author="author", portrait_image_url="http://img"), + chapter_data={1: {"chapter_id": 1, "thumbnail_url": "t1", "sub_title": "hello/world"}}, + ) + + export_dir = tmp_path / "My Manga" + downloader._dump_title_metadata(title_dump, export_dir) + + metadata_file = export_dir / "title_metadata.json" + assert metadata_file.exists() + + content = json.loads(metadata_file.read_text(encoding="utf-8")) + assert content["name"] == "my manga" + assert content["author"] == "author" + assert content["chapters"]["1"]["chapter_id"] == 1 + assert content["chapters"]["1"]["sub_title"] == "Hello World" + + +def test_dump_title_metadata_rejects_mapping_without_export_dir() -> None: + """Verify two-argument mode requires an export directory as second value.""" + downloader = DummyDownloader() + title_dump = SimpleNamespace(chapter_data={}) + + with pytest.raises(TypeError, match="Expected export directory"): + downloader._dump_title_metadata(title_dump, {1: {"chapter_id": 1}}) + + +def test_dump_title_metadata_rejects_non_mapping_when_export_dir_is_provided( + tmp_path: Path, +) -> None: + """Verify three-argument mode requires chapter metadata mapping as second value.""" + downloader = DummyDownloader() + title_dump = SimpleNamespace(chapter_data={}) + + with pytest.raises(TypeError, match="Expected chapter metadata mapping"): + downloader._dump_title_metadata(title_dump, str(tmp_path), tmp_path) + + +def test_dump_title_metadata_supports_explicit_chapter_mapping(tmp_path: Path) -> None: + """Verify explicit ``chapter_data`` + ``export_dir`` writes metadata output.""" + downloader = DummyDownloader(destination=str(tmp_path)) + title_dump = SimpleNamespace( + non_appearance_info="n/a", + number_of_views=1, + overview="overview", + title=SimpleNamespace(name="my manga", author="author", portrait_image_url="http://img"), + ) + chapter_data = {1024959: ChapterMetadata(thumbnail_url="t1", chapter_id=1024959, sub_title="A")} + export_dir = tmp_path / "My Manga" + + downloader._dump_title_metadata(title_dump, chapter_data, export_dir) + + content = json.loads((export_dir / "title_metadata.json").read_text(encoding="utf-8")) + assert content["chapters"]["1024959"]["chapter_id"] == 1024959 + assert content["chapters"]["1024959"]["sub_title"] == "A" + + +def test_resolve_cover_image_url_prefers_portrait_and_falls_back_to_main() -> None: + """Verify cover URL resolution prefers portrait image URL over title image URL.""" + downloader = DummyDownloader() + with_main = SimpleNamespace( + title_image_url="https://img/main.webp", + title=SimpleNamespace( + portrait_image_url="https://img/portrait.webp", + landscape_image_url="https://img/landscape.webp", + ), + ) + without_main = SimpleNamespace( + title_image_url="", + title=SimpleNamespace( + portrait_image_url="https://img/portrait.webp", + landscape_image_url="https://img/landscape.webp", + ), + ) + + assert downloader._resolve_cover_image_url(with_main) == "https://img/portrait.webp" + assert downloader._resolve_cover_image_url(without_main) == "https://img/portrait.webp" + + +def test_resolve_cover_image_url_falls_back_to_landscape_then_none() -> None: + """Verify cover URL resolution uses landscape fallback and returns None when absent.""" + downloader = DummyDownloader() + landscape_only = SimpleNamespace( + title_image_url="", + title=SimpleNamespace( + portrait_image_url="", landscape_image_url="https://img/landscape.webp" + ), + ) + no_cover = SimpleNamespace( + title_image_url="", + title=SimpleNamespace(portrait_image_url="", landscape_image_url=""), + ) + + assert downloader._resolve_cover_image_url(landscape_only) == "https://img/landscape.webp" + assert downloader._resolve_cover_image_url(no_cover) is None + + +def test_dump_title_cover_downloads_and_saves_png( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Verify cover export downloads bytes and stores PNG output.""" + downloader = DummyDownloader(destination=str(tmp_path)) + image_bytes = BytesIO() + Image.new("RGB", (1, 1), (255, 0, 0)).save(image_bytes, format="JPEG") + image_blob = image_bytes.getvalue() + + monkeypatch.setattr(downloader, "_download_image", lambda _url: image_blob) + + title_dump = SimpleNamespace( + title_image_url="https://img/main.webp", + title=SimpleNamespace(name="my manga", portrait_image_url="", landscape_image_url=""), + ) + export_dir = tmp_path / "My Manga" + + downloader._dump_title_cover(title_dump, export_dir) + + cover_path = export_dir / "cover.png" + assert cover_path.exists() + assert cover_path.read_bytes().startswith(b"\x89PNG\r\n\x1a\n") + + +def test_dump_title_cover_skips_when_cover_url_is_missing( + tmp_path: Path, + caplog: pytest.LogCaptureFixture, +) -> None: + """Verify cover export logs and returns when no cover URL is available.""" + downloader = DummyDownloader(destination=str(tmp_path)) + title_dump = SimpleNamespace( + title_image_url="", + title=SimpleNamespace(name="my manga", portrait_image_url="", landscape_image_url=""), + ) + + downloader._dump_title_cover(title_dump, tmp_path / "My Manga") + + assert "Cover export skipped" in caplog.text + + +def test_dump_title_cover_skips_when_cover_file_already_exists( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify cover export does not re-download when cover.png already exists.""" + downloader = DummyDownloader(destination=str(tmp_path)) + title_dump = SimpleNamespace( + title_image_url="https://img/main.webp", + title=SimpleNamespace(name="my manga", portrait_image_url="", landscape_image_url=""), + ) + export_dir = tmp_path / "My Manga" + export_dir.mkdir(parents=True, exist_ok=True) + (export_dir / "cover.png").write_bytes(b"already") + + monkeypatch.setattr( + downloader, + "_download_image", + lambda _url: (_ for _ in ()).throw(AssertionError("cover should not be downloaded twice")), + ) + + downloader._dump_title_cover(title_dump, export_dir) + + +def test_process_chapter_pages_handles_double_pages(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify DOUBLE page types are converted into ranged page indexes.""" + downloader = DummyDownloader() + + @contextmanager + def fake_progressbar(items: list[Any], **kwargs: Any) -> Iterator[list[Any]]: + """Yield given items without rendering a real progress bar.""" + del kwargs + yield items + + monkeypatch.setattr(click, "progressbar", fake_progressbar) + + calls: list[tuple[bytes, Any]] = [] + + class FakeExporter: + """Exporter test double recording add_image calls.""" + + def skip_image(self, index: int | range) -> bool: + """Never skip images in this test exporter.""" + del index + return False + + def add_image(self, blob: bytes, index: int | range) -> None: + """Record blob and page index for assertions.""" + calls.append((blob, index)) + + pages = [ + SimpleNamespace(type=PageType.DOUBLE.value, image_url="u1"), + SimpleNamespace(type=PageType.SINGLE.value, image_url="u2"), + ] + + downloader._process_chapter_pages(pages, chapter_name="#1", exporter=FakeExporter()) + + assert calls[0][0] == b"img:u1" + assert calls[0][1] == range(0, 1) + assert calls[1][0] == b"img:u2" + assert calls[1][1] == 2 + + +def test_has_last_page_detection() -> None: + """Verify terminal page detection with and without last_page payload.""" + downloader = DummyDownloader() + + good = SimpleNamespace(pages=[SimpleNamespace(last_page=SimpleNamespace())]) + bad = SimpleNamespace(pages=[SimpleNamespace()]) + + assert downloader._has_last_page(good) is True + assert downloader._has_last_page(bad) is False + + +def test_download_calls_prepare_and_download() -> None: + """Verify download() delegates to normalization and _download methods.""" + calls: dict[str, Any] = {} + + class Orchestrator(DummyDownloader): + """Downloader double capturing orchestration method arguments.""" + + def _prepare_normalized_manga_list(self, *args: Any) -> dict[int, set[int]]: + """Capture prepare args and return a sentinel mapping.""" + calls["prepare"] = args + return {42: {1}} + + def _download(self, mapping: dict[int, set[int]], report: RunReport) -> None: + """Capture the mapping forwarded to _download.""" + del report + calls["download"] = mapping + + loader = Orchestrator() + summary = loader.download( + title_ids={100312}, + chapter_numbers=None, + chapter_ids={1024959}, + min_chapter=1, + max_chapter=5, + last_chapter=True, + ) + + assert calls["prepare"] == ({100312}, None, {1024959}, 1, 5, True) + assert calls["download"] == {42: {1}} + assert summary == DownloadSummary( + downloaded=0, + skipped_manifest=0, + failed=0, + failed_chapter_ids=(), + ) + + +def test_download_clears_run_cache_before_and_after_execution() -> None: + """Verify download lifecycle clears run-level API cache at start and end.""" + calls: list[str] = [] + + class Orchestrator(DummyDownloader): + """Downloader double capturing run-level cache clear calls.""" + + def _prepare_normalized_manga_list(self, *args: Any) -> dict[int, set[int]]: + """Return empty mapping to keep flow deterministic.""" + del args + return {} + + def _download(self, mapping: dict[int, set[int]], report: RunReport) -> None: + """Record download invocation payload.""" + del mapping, report + calls.append("download") + + def _clear_api_caches_for_run(self) -> None: + """Record run-cache clearing hook invocation.""" + calls.append("clear_run") + + loader = Orchestrator() + loader.download( + title_ids={100312}, + chapter_numbers=None, + chapter_ids=None, + min_chapter=0, + max_chapter=10, + ) + + assert calls == ["clear_run", "download", "clear_run"] + + +def test_download_raises_interrupted_error_with_partial_summary() -> None: + """Verify interrupted runs raise partial-summary wrapper error.""" + + class Interrupting(DummyDownloader): + """Downloader double raising keyboard interrupt after partial progress.""" + + def _prepare_normalized_manga_list(self, *args: Any) -> dict[int, set[int]]: + """Return deterministic normalized mapping.""" + del args + return {42: {1}} + + def _download(self, mapping: dict[int, set[int]], report: RunReport) -> None: + """Mark counters, then emulate user interrupt.""" + del mapping + report.mark_downloaded() + report.mark_manifest_skipped(2) + report.mark_failed(99) + raise KeyboardInterrupt + + loader = Interrupting() + + with pytest.raises(DownloadInterruptedError) as interrupted: + loader.download( + title_ids={100312}, + chapter_numbers=None, + chapter_ids=None, + min_chapter=0, + max_chapter=10, + ) + + assert interrupted.value.summary == DownloadSummary( + downloaded=1, + skipped_manifest=2, + failed=1, + failed_chapter_ids=(99,), + ) + + +def test_download_iterates_titles() -> None: + """Verify _download iterates titles in insertion order with indexes.""" + calls: list[tuple[int, int, int, set[int]]] = [] + + class Iterating(DummyDownloader): + """Downloader double capturing _process_title invocations.""" + + def _process_title( + self, + title_index: int, + total_titles: int, + title_id: int, + chapter_ids: set[int], + *, + report: RunReport, + ) -> None: + """Record _process_title invocation payloads.""" + del report + calls.append((title_index, total_titles, title_id, chapter_ids)) + + loader = Iterating() + loader._download({10: {1, 2}, 20: {3}}, report=_run_report()) + + assert calls == [(1, 2, 10, {1, 2}), (2, 2, 20, {3})] + + +def test_process_title_with_no_chapters_to_download( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify _process_title returns early when no chapters remain.""" + downloader = FullDownloader() + title_dump = SimpleNamespace( + title=SimpleNamespace(name="My Manga", author="A"), + chapter_data={}, + ) + + monkeypatch.setattr(downloader, "_get_title_details", lambda _tid: title_dump, raising=False) + monkeypatch.setattr(downloader, "_extract_chapter_data", lambda _dump: {}, raising=False) + monkeypatch.setattr(downloader, "_get_existing_files", lambda _path: []) + monkeypatch.setattr(downloader, "_filter_chapters_to_download", lambda *args, **kwargs: []) + + downloader._process_title(1, 1, 10, {1}, report=_run_report()) + + +def test_process_title_clears_title_cache_after_processing( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify title-level cache clear hook runs after title processing.""" + downloader = FullDownloader() + title_dump = SimpleNamespace( + title=SimpleNamespace(name="My Manga", author="A"), + chapter_data={}, + ) + clear_calls: list[tuple[int, set[int]]] = [] + + monkeypatch.setattr(downloader, "_get_title_details", lambda _tid: title_dump, raising=False) + monkeypatch.setattr(downloader, "_extract_chapter_data", lambda _dump: {}, raising=False) + monkeypatch.setattr(downloader, "_get_existing_files", lambda _path: []) + monkeypatch.setattr(downloader, "_filter_chapters_to_download", lambda *args, **kwargs: []) + monkeypatch.setattr( + downloader, + "_clear_api_caches_for_title", + lambda title_id, chapter_ids: clear_calls.append((title_id, set(chapter_ids))), + ) + + downloader._process_title(1, 1, 10, {1, 2}, report=_run_report()) + + assert clear_calls == [(10, {1, 2})] + + +def test_process_title_downloads_sorted_chapters( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify _process_title processes candidate chapters in sorted order.""" + downloader = FullDownloader() + title_dump = SimpleNamespace( + title=SimpleNamespace(name="My Manga", author="A"), + chapter_data={3: {"chapter_id": 3, "sub_title": "sub"}}, + ) + processed: list[tuple[int, int, int]] = [] + + monkeypatch.setattr(downloader, "_get_title_details", lambda _tid: title_dump, raising=False) + monkeypatch.setattr( + downloader, + "_extract_chapter_data", + lambda _dump: {3: {"chapter_id": 3, "sub_title": "sub"}}, + raising=False, + ) + monkeypatch.setattr(downloader, "_get_existing_files", lambda _path: []) + monkeypatch.setattr( + downloader, "_filter_chapters_to_download", lambda *args, **kwargs: [5, 2, 3] + ) + monkeypatch.setattr( + downloader, + "_process_chapter", + lambda title, index, total, chapter_id, **kwargs: processed.append( + (index, total, chapter_id) + ), + ) + + downloader._process_title(1, 1, 10, {2, 3, 5}, report=_run_report()) + + assert processed == [(1, 3, 2), (2, 3, 3), (3, 3, 5)] + + +def test_process_title_dumps_metadata_when_enabled( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify metadata export is invoked when loader meta flag is enabled.""" + downloader = FullDownloader() + downloader.meta = True + title_dump = SimpleNamespace( + title=SimpleNamespace(name="My Manga", author="A"), + chapter_data={}, + ) + calls = {"metadata": 0} + + monkeypatch.setattr(downloader, "_get_title_details", lambda _tid: title_dump, raising=False) + monkeypatch.setattr(downloader, "_extract_chapter_data", lambda _dump: {}, raising=False) + monkeypatch.setattr(downloader, "_get_existing_files", lambda _path: []) + monkeypatch.setattr(downloader, "_filter_chapters_to_download", lambda *args, **kwargs: []) + monkeypatch.setattr( + downloader, + "_dump_title_metadata", + lambda *_args, **_kwargs: calls.__setitem__("metadata", calls["metadata"] + 1), + ) + + downloader._process_title(1, 1, 10, {1}, report=_run_report()) + + assert calls["metadata"] == 1 + + +def test_process_title_dumps_cover_when_enabled( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify cover export is invoked when loader cover flag is enabled.""" + downloader = DummyDownloader() + downloader.cover = True + title_dump = SimpleNamespace( + title=SimpleNamespace(name="Title", author="Author"), + chapter_list_group=[], + chapter_data={}, + ) + calls = {"cover": 0} + + monkeypatch.setattr(downloader, "_get_title_details", lambda _tid: title_dump, raising=False) + monkeypatch.setattr(downloader, "_extract_chapter_data", lambda _dump: {}, raising=False) + monkeypatch.setattr( + downloader, + "_dump_title_cover", + lambda *_args, **_kwargs: calls.__setitem__("cover", calls["cover"] + 1), + raising=False, + ) + monkeypatch.setattr(downloader, "_filter_chapters_to_download", lambda *args, **kwargs: []) + + downloader._process_title(1, 1, 10, {1}, report=_run_report()) + + assert calls["cover"] == 1 + + +def test_process_title_cover_export_failure_logs_warning_and_continues( + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + """Verify cover export failures do not abort title processing.""" + downloader = DummyDownloader() + downloader.cover = True + title_dump = SimpleNamespace( + title=SimpleNamespace(name="Title", author="Author"), + chapter_list_group=[], + chapter_data={}, + ) + + monkeypatch.setattr(downloader, "_get_title_details", lambda _tid: title_dump, raising=False) + monkeypatch.setattr(downloader, "_extract_chapter_data", lambda _dump: {}, raising=False) + monkeypatch.setattr( + downloader, + "_dump_title_cover", + lambda *_args, **_kwargs: (_ for _ in ()).throw(RuntimeError("cover failed")), + raising=False, + ) + monkeypatch.setattr(downloader, "_filter_chapters_to_download", lambda *args, **kwargs: []) + + downloader._process_title(1, 1, 10, {1}, report=_run_report()) + + assert "Cover export failed" in caplog.text + + +def test_process_title_skips_manifest_completed_chapters( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify title processing excludes chapter IDs marked completed in manifest.""" + downloader = FullDownloader() + title_dump = SimpleNamespace( + title=SimpleNamespace(name="My Manga", author="A"), + chapter_data={}, + ) + processed: list[int] = [] + + class FakeManifest: + def __init__(self, _export_path: Path, *, autosave: bool = False) -> None: + """Store nothing; behavior is fully deterministic for the test.""" + del autosave + + def is_completed(self, chapter_id: int) -> bool: + """Mark chapter 2 as already completed.""" + return chapter_id == 2 + + def flush(self) -> None: + """No-op flush for downloader finalize hooks.""" + + monkeypatch.setattr(downloader, "_get_title_details", lambda _tid: title_dump, raising=False) + monkeypatch.setattr(downloader, "_extract_chapter_data", lambda _dump: {}, raising=False) + monkeypatch.setattr(downloader, "_get_existing_files", lambda _path: []) + monkeypatch.setattr( + downloader, "_filter_chapters_to_download", lambda *args, **kwargs: [1, 2, 3] + ) + monkeypatch.setattr( + "mloader.manga_loader.downloader.TitleDownloadManifest", + FakeManifest, + ) + monkeypatch.setattr( + downloader, + "_process_chapter", + lambda _title, _index, _total, chapter_id, **kwargs: processed.append(chapter_id), + ) + + report = _run_report() + downloader._process_title(1, 1, 10, {1, 2, 3}, report=report) + + assert processed == [1, 3] + assert report.skipped_manifest == 1 + + +def test_process_title_records_failed_chapter_report( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify per-chapter failures are collected and run continues.""" + downloader = FullDownloader() + title_dump = SimpleNamespace( + title=SimpleNamespace(name="My Manga", author="A"), + chapter_data={}, + ) + processed: list[int] = [] + marked_failed: list[int] = [] + flush_calls = 0 + + class FakeManifest: + def __init__(self, _export_path: Path, *, autosave: bool = False) -> None: + del autosave + + def is_completed(self, chapter_id: int) -> bool: + del chapter_id + return False + + def mark_failed(self, chapter_id: int, *, error: str) -> None: + del error + marked_failed.append(chapter_id) + + def flush(self) -> None: + nonlocal flush_calls + flush_calls += 1 + + def _process_chapter( + _title: Any, + _index: int, + _total: int, + chapter_id: int, + **kwargs: Any, + ) -> None: + del kwargs + if chapter_id == 2: + raise RuntimeError("boom") + processed.append(chapter_id) + + monkeypatch.setattr(downloader, "_get_title_details", lambda _tid: title_dump, raising=False) + monkeypatch.setattr(downloader, "_extract_chapter_data", lambda _dump: {}, raising=False) + monkeypatch.setattr(downloader, "_get_existing_files", lambda _path: []) + monkeypatch.setattr( + downloader, "_filter_chapters_to_download", lambda *args, **kwargs: [1, 2, 3] + ) + monkeypatch.setattr( + "mloader.manga_loader.downloader.TitleDownloadManifest", + FakeManifest, + ) + monkeypatch.setattr(downloader, "_process_chapter", _process_chapter) + + report = _run_report() + downloader._process_title(1, 1, 10, {1, 2, 3}, report=report) + + assert processed == [1, 3] + assert report.downloaded == 2 + assert report.failed == 1 + assert report.failed_chapter_ids == [2] + assert marked_failed == [2] + assert flush_calls >= 2 + + +def test_process_title_on_keyboard_interrupt_marks_manifest_and_raises( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify interrupt during chapter processing marks failure, flushes, and re-raises.""" + downloader = FullDownloader() + title_dump = SimpleNamespace( + title=SimpleNamespace(name="My Manga", author="A"), + chapter_data={}, + ) + marked_failed: list[int] = [] + flush_calls = 0 + + class FakeManifest: + def __init__(self, _export_path: Path, *, autosave: bool = False) -> None: + del autosave + + def is_completed(self, chapter_id: int) -> bool: + del chapter_id + return False + + def mark_failed(self, chapter_id: int, *, error: str) -> None: + del error + marked_failed.append(chapter_id) + + def flush(self) -> None: + nonlocal flush_calls + flush_calls += 1 + + monkeypatch.setattr(downloader, "_get_title_details", lambda _tid: title_dump, raising=False) + monkeypatch.setattr(downloader, "_extract_chapter_data", lambda _dump: {}, raising=False) + monkeypatch.setattr(downloader, "_get_existing_files", lambda _path: []) + monkeypatch.setattr(downloader, "_filter_chapters_to_download", lambda *args, **kwargs: [1]) + monkeypatch.setattr( + "mloader.manga_loader.downloader.TitleDownloadManifest", + FakeManifest, + ) + monkeypatch.setattr( + downloader, + "_process_chapter", + lambda *_args, **_kwargs: (_ for _ in ()).throw(KeyboardInterrupt()), + ) + + report = _run_report() + with pytest.raises(KeyboardInterrupt): + downloader._process_title(1, 1, 10, {1}, report=report) + + assert report.failed == 1 + assert report.failed_chapter_ids == [1] + assert marked_failed == [1] + assert flush_calls >= 2 + + +def test_process_title_resume_skips_completed_and_retries_failed_after_restart( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """Verify manifest resume across two runs skips completed and retries failed chapters.""" + title_dump = SimpleNamespace( + title=SimpleNamespace(name="My Manga", author="A"), + chapter_data={}, + ) + + first_run = FullDownloader(destination=str(tmp_path)) + first_attempts: list[int] = [] + + def _first_process_chapter( + _title: Any, + _index: int, + _total: int, + chapter_id: int, + **kwargs: Any, + ) -> None: + manifest = kwargs.get("manifest") + first_attempts.append(chapter_id) + if chapter_id == 2: + raise RuntimeError("boom") + if manifest is not None: + manifest.mark_completed(chapter_id) + + monkeypatch.setattr(first_run, "_get_title_details", lambda _tid: title_dump, raising=False) + monkeypatch.setattr(first_run, "_extract_chapter_data", lambda _dump: {}, raising=False) + monkeypatch.setattr(first_run, "_get_existing_files", lambda _path: []) + monkeypatch.setattr(first_run, "_filter_chapters_to_download", lambda *args, **kwargs: [1, 2]) + monkeypatch.setattr(first_run, "_process_chapter", _first_process_chapter) + + first_report = _run_report() + first_run._process_title(1, 1, 10, {1, 2}, report=first_report) + + assert first_attempts == [1, 2] + assert first_report.downloaded == 1 + assert first_report.failed == 1 + assert first_report.failed_chapter_ids == [2] + + second_run = FullDownloader(destination=str(tmp_path)) + second_attempts: list[int] = [] + + monkeypatch.setattr(second_run, "_get_title_details", lambda _tid: title_dump, raising=False) + monkeypatch.setattr(second_run, "_extract_chapter_data", lambda _dump: {}, raising=False) + monkeypatch.setattr(second_run, "_get_existing_files", lambda _path: []) + monkeypatch.setattr(second_run, "_filter_chapters_to_download", lambda *args, **kwargs: [1, 2]) + monkeypatch.setattr( + second_run, + "_process_chapter", + lambda _title, _index, _total, chapter_id, **kwargs: second_attempts.append(chapter_id), + ) + + second_report = _run_report() + second_run._process_title(1, 1, 10, {1, 2}, report=second_report) + + assert second_attempts == [2] + assert second_report.downloaded == 1 + assert second_report.skipped_manifest == 1 + assert second_report.failed == 0 + + +def test_process_title_disables_manifest_when_resume_is_false( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify --no-resume mode skips manifest-based chapter filtering.""" + downloader = FullDownloader() + downloader.resume = False + title_dump = SimpleNamespace( + title=SimpleNamespace(name="My Manga", author="A"), + chapter_data={}, + ) + processed: list[int] = [] + + monkeypatch.setattr(downloader, "_get_title_details", lambda _tid: title_dump, raising=False) + monkeypatch.setattr(downloader, "_extract_chapter_data", lambda _dump: {}, raising=False) + monkeypatch.setattr(downloader, "_get_existing_files", lambda _path: []) + monkeypatch.setattr(downloader, "_filter_chapters_to_download", lambda *args, **kwargs: [1, 2]) + monkeypatch.setattr( + downloader, + "_process_chapter", + lambda _title, _index, _total, chapter_id, **kwargs: processed.append(chapter_id), + ) + + report = _run_report() + downloader._process_title(1, 1, 10, {1, 2}, report=report) + + assert processed == [1, 2] + assert report.skipped_manifest == 0 + + +def test_process_title_resets_manifest_when_requested( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify --manifest-reset clears existing per-title manifest state.""" + downloader = FullDownloader() + downloader.manifest_reset = True + title_dump = SimpleNamespace( + title=SimpleNamespace(name="My Manga", author="A"), + chapter_data={}, + ) + reset_calls = 0 + + class FakeManifest: + def __init__(self, _export_path: Path, *, autosave: bool = False) -> None: + del autosave + + def reset(self) -> None: + nonlocal reset_calls + reset_calls += 1 + + def is_completed(self, chapter_id: int) -> bool: + del chapter_id + return False + + def flush(self) -> None: + """No-op flush for downloader finalize hooks.""" + + monkeypatch.setattr(downloader, "_get_title_details", lambda _tid: title_dump, raising=False) + monkeypatch.setattr(downloader, "_extract_chapter_data", lambda _dump: {}, raising=False) + monkeypatch.setattr(downloader, "_get_existing_files", lambda _path: []) + monkeypatch.setattr(downloader, "_filter_chapters_to_download", lambda *args, **kwargs: []) + monkeypatch.setattr( + "mloader.manga_loader.downloader.TitleDownloadManifest", + FakeManifest, + ) + + downloader._process_title(1, 1, 10, {1}, report=_run_report()) + + assert reset_calls == 1 + + +def test_process_chapter_raises_when_subscription_required( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify _process_chapter raises subscription error without last_page payload.""" + downloader = FullDownloader() + viewer = SimpleNamespace(chapter_name="C1", pages=[SimpleNamespace()]) + monkeypatch.setattr(downloader, "_load_pages", lambda _cid: viewer, raising=False) + + with pytest.raises(SubscriptionRequiredError): + downloader._process_chapter(SimpleNamespace(name="t"), 1, 1, 10) + + +def test_process_chapter_creates_exporter_and_closes(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify _process_chapter builds exporter, processes pages, and closes exporter.""" + + class ExporterInstance: + """Exporter test double with close tracking.""" + + def __init__(self) -> None: + """Initialize close tracking state.""" + self.closed = False + + def close(self) -> None: + """Record close invocation.""" + self.closed = True + + instance = ExporterInstance() + captured: dict[str, Any] = {} + started: list[tuple[int, str, str, str]] = [] + completed: list[tuple[int, str | None]] = [] + + class Manifest: + def mark_started( + self, + chapter_id: int, + *, + chapter_name: str, + sub_title: str, + output_format: str, + ) -> None: + started.append((chapter_id, chapter_name, sub_title, output_format)) + + def mark_completed(self, chapter_id: int, *, output_path: str | None = None) -> None: + completed.append((chapter_id, output_path)) + + def exporter_factory(**kwargs: Any) -> ExporterInstance: + """Capture exporter constructor arguments and return test instance.""" + captured.update(kwargs) + return instance + + downloader = FullDownloader() + downloader.exporter = exporter_factory + processed: list[tuple[list[Any], str, Any]] = [] + + viewer = SimpleNamespace( + chapter_name="#1", + pages=[ + SimpleNamespace(manga_page=SimpleNamespace(image_url="u1")), + SimpleNamespace( + manga_page=SimpleNamespace(image_url=""), + last_page=SimpleNamespace( + current_chapter=SimpleNamespace(name="#1", sub_title="Sub"), + next_chapter=SimpleNamespace(chapter_id=0), + ), + ), + ], + ) + + monkeypatch.setattr(downloader, "_load_pages", lambda _cid: viewer, raising=False) + monkeypatch.setattr( + downloader, + "_process_chapter_pages", + lambda pages, chapter_name, exporter: processed.append((pages, chapter_name, exporter)), + ) + + title = SimpleNamespace(name="My Manga") + downloader._process_chapter(title, 1, 1, 10, manifest=Manifest()) + + assert captured["title"] is title + assert captured["chapter"].sub_title == "Sub" + assert captured["next_chapter"] is None + assert processed[0][1] == "#1" + assert len(processed[0][0]) == 1 + assert instance.closed is True + assert started == [(10, "#1", "Sub", "pdf")] + assert completed == [(10, None)] + + +def test_process_chapter_marks_manifest_failed_when_page_processing_raises( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify chapter export-processing failures are raised to title-level handling.""" + downloader = FullDownloader() + + class Exporter: + def close(self) -> None: + """No-op close used by this failure-path test.""" + + class Manifest: + def mark_started( + self, + chapter_id: int, + *, + chapter_name: str, + sub_title: str, + output_format: str, + ) -> None: + del chapter_id, chapter_name, sub_title, output_format + + viewer = SimpleNamespace( + chapter_name="#1", + pages=[ + SimpleNamespace(manga_page=SimpleNamespace(image_url="u1")), + SimpleNamespace( + manga_page=SimpleNamespace(image_url=""), + last_page=SimpleNamespace( + current_chapter=SimpleNamespace(name="#1", sub_title="Sub"), + next_chapter=SimpleNamespace(chapter_id=0), + ), + ), + ], + ) + + monkeypatch.setattr(downloader, "_load_pages", lambda _cid: viewer, raising=False) + monkeypatch.setattr(downloader, "exporter", lambda **kwargs: Exporter()) + monkeypatch.setattr( + downloader, + "_process_chapter_pages", + lambda *_args, **_kwargs: (_ for _ in ()).throw(RuntimeError("boom")), + ) + + with pytest.raises(RuntimeError, match="boom"): + downloader._process_chapter(SimpleNamespace(name="My Manga"), 1, 1, 10, manifest=Manifest()) + + +def test_process_chapter_raises_when_no_downloadable_pages( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify _process_chapter fails when viewer payload contains no downloadable image pages.""" + downloader = FullDownloader() + started: list[int] = [] + completed: list[int] = [] + + class Exporter: + def close(self) -> None: + """No-op close for no-pages path.""" + + class Manifest: + def mark_started( + self, + chapter_id: int, + *, + chapter_name: str, + sub_title: str, + output_format: str, + ) -> None: + del chapter_name, sub_title, output_format + started.append(chapter_id) + + def mark_completed(self, chapter_id: int, *, output_path: str | None = None) -> None: + del output_path + completed.append(chapter_id) + + viewer = SimpleNamespace( + chapter_name="#1", + pages=[ + SimpleNamespace(manga_page=SimpleNamespace(image_url="")), + SimpleNamespace( + manga_page=SimpleNamespace(image_url=""), + last_page=SimpleNamespace( + current_chapter=SimpleNamespace(name="#1", sub_title="Sub"), + next_chapter=SimpleNamespace(chapter_id=0), + ), + ), + ], + ) + + monkeypatch.setattr(downloader, "_load_pages", lambda _cid: viewer, raising=False) + monkeypatch.setattr(downloader, "exporter", lambda **_kwargs: Exporter()) + + with pytest.raises(RuntimeError, match="no downloadable pages"): + downloader._process_chapter(SimpleNamespace(name="My Manga"), 1, 1, 10, manifest=Manifest()) + + assert started == [10] + assert completed == [] + + +def test_download_image_calls_raise_for_status() -> None: + """Verify _download_image calls response.raise_for_status before returning bytes.""" + downloader = FullDownloader() + response = DummyResponse(content=b"img") + session = DummySession(response) + downloader.session = session + + result = downloader._download_image("http://img") + + assert session.calls == ["http://img"] + assert response.status_checked is True + assert result == b"img" + + +def test_process_chapter_pages_skips_when_exporter_requests( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify _process_chapter_pages skips add_image when exporter says to skip.""" + downloader = DummyDownloader() + + @contextmanager + def fake_progressbar(items: list[Any], **kwargs: Any) -> Iterator[list[Any]]: + """Yield given items without rendering a real progress bar.""" + del kwargs + yield items + + monkeypatch.setattr(click, "progressbar", fake_progressbar) + + class SkipExporter: + """Exporter test double that always skips images.""" + + def skip_image(self, index: int | range) -> bool: + """Return True for every page index.""" + del index + return True + + def add_image(self, blob: bytes, index: int | range) -> None: + """Fail test if add_image is called while skip_image is True.""" + del blob, index + raise AssertionError("add_image should not be called when skip_image is True") + + pages = [SimpleNamespace(type=PageType.SINGLE.value, image_url="u1")] + downloader._process_chapter_pages(pages, chapter_name="#1", exporter=SkipExporter()) + + +def test_process_chapter_pages_uses_decrypt_for_encrypted_pages( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify encrypted pages route through decrypt path before export.""" + downloader = DummyDownloader() + + @contextmanager + def fake_progressbar(items: list[Any], **kwargs: Any) -> Iterator[list[Any]]: + """Yield given items without rendering a real progress bar.""" + del kwargs + yield items + + monkeypatch.setattr(click, "progressbar", fake_progressbar) + + captured: list[bytes] = [] + + class CapturingExporter: + """Exporter test double collecting written image bytes.""" + + def skip_image(self, index: int | range) -> bool: + """Never skip pages in this test.""" + del index + return False + + def add_image(self, blob: bytes, index: int | range) -> None: + """Store output image blobs for assertion.""" + del index + captured.append(blob) + + pages = [ + SimpleNamespace(type=PageType.SINGLE.value, image_url="u1", encryption_key="abcd"), + SimpleNamespace(type=PageType.SINGLE.value, image_url="u2", encryption_key=""), + ] + downloader._process_chapter_pages(pages, chapter_name="#1", exporter=CapturingExporter()) + + assert captured == [b"dec:u1:abcd", b"img:u2"] + + +def test_extract_chapter_data_from_all_groups() -> None: + """Verify chapter metadata extraction includes first/mid/last chapter groups.""" + downloader = FullDownloader() + title_dump = SimpleNamespace( + chapter_list_group=[ + SimpleNamespace( + first_chapter_list=[ + SimpleNamespace(sub_title="A", thumbnail_url="t1", chapter_id=1024959) + ], + mid_chapter_list=[ + SimpleNamespace(sub_title="B", thumbnail_url="t2", chapter_id=102278) + ], + last_chapter_list=[ + SimpleNamespace(sub_title="C", thumbnail_url="t3", chapter_id=102279) + ], + ) + ] + ) + + result = downloader._extract_chapter_data(title_dump) + + assert result[1024959]["chapter_id"] == 1024959 + assert result[102278]["chapter_id"] == 102278 + assert result[102279]["chapter_id"] == 102279 + assert result[102278]["sub_title"] == "B" + + +def test_extract_chapter_data_keeps_duplicate_subtitles_by_chapter_id() -> None: + """Verify duplicate subtitles do not overwrite chapter metadata entries.""" + downloader = FullDownloader() + title_dump = SimpleNamespace( + chapter_list_group=[ + SimpleNamespace( + first_chapter_list=[ + SimpleNamespace(sub_title="Same", thumbnail_url="t1", chapter_id=1024959), + SimpleNamespace(sub_title="Same", thumbnail_url="t2", chapter_id=102278), + ], + mid_chapter_list=[], + last_chapter_list=[], + ) + ] + ) + + result = downloader._extract_chapter_data(title_dump) + + assert set(result.keys()) == {1024959, 102278} + assert result[1024959]["sub_title"] == "Same" + assert result[102278]["sub_title"] == "Same" + + +def test_get_existing_files_returns_stems(tmp_path: Path) -> None: + """Verify _get_existing_files returns PDF stems only.""" + downloader = DummyDownloader() + export_path = tmp_path / "manga" + export_path.mkdir() + (export_path / "a.pdf").write_bytes(b"1") + (export_path / "b.pdf").write_bytes(b"2") + (export_path / "c.cbz").write_bytes(b"3") + + assert sorted(downloader._get_existing_files(export_path)) == ["a", "b"] + + +def test_get_existing_files_returns_empty_when_missing(tmp_path: Path) -> None: + """Verify _get_existing_files returns empty list for missing export path.""" + downloader = DummyDownloader() + assert downloader._get_existing_files(tmp_path / "missing") == [] + + +def test_get_existing_files_uses_cbz_extension(tmp_path: Path) -> None: + """Verify existing chapter lookup uses cbz extension in CBZ mode.""" + downloader = DummyDownloader() + downloader.output_format = "cbz" + export_path = tmp_path / "manga" + export_path.mkdir() + (export_path / "a.cbz").write_bytes(b"1") + (export_path / "b.pdf").write_bytes(b"2") + + assert downloader._get_existing_files(export_path) == ["a"] + + +def test_get_existing_files_is_disabled_for_raw_mode(tmp_path: Path) -> None: + """Verify raw mode disables chapter-level existing-file prefiltering.""" + downloader = DummyDownloader() + downloader.output_format = "raw" + export_path = tmp_path / "manga" + export_path.mkdir() + (export_path / "a.pdf").write_bytes(b"1") + + assert downloader._get_existing_files(export_path) == [] + + +def test_filter_chapters_warns_when_chapter_missing(caplog: Any) -> None: + """Verify missing chapter IDs log a warning and are excluded.""" + downloader = DummyDownloader() + chapter_data = {99: {"chapter_id": 99, "sub_title": "Missing"}} + title_dump = SimpleNamespace(chapter_list_group=[]) + title_detail = SimpleNamespace(name="My Manga") + + with caplog.at_level("WARNING"): + result = downloader._filter_chapters_to_download( + chapter_data, + title_dump, + title_detail, + existing_files=[], + requested_chapter_ids={102399}, + ) + + assert result == [] + assert "not found in title dump" in caplog.text + + +def test_filter_chapters_accepts_dataclass_metadata_values() -> None: + """Verify chapter filtering accepts ``ChapterMetadata`` objects directly.""" + downloader = DummyDownloader() + chapter = _chapter(102305, "#102305") + title_dump = SimpleNamespace(chapter_list_group=[_group([chapter])]) + title_detail = SimpleNamespace(name="My Manga") + chapter_data = {102305: ChapterMetadata(thumbnail_url="t5", chapter_id=102305, sub_title="Sub")} + + result = downloader._filter_chapters_to_download( + chapter_data, + title_dump, + title_detail, + existing_files=[], + requested_chapter_ids={102305}, + ) + + assert result == [102305] + + +def test_download_mixin_placeholders_raise_not_implemented() -> None: + """Verify abstract data-loader placeholders raise ``NotImplementedError``.""" + with pytest.raises(NotImplementedError): + DownloadMixin._prepare_normalized_manga_list( + None, + None, + None, + None, + 0, + 0, + False, + ) # type: ignore[arg-type] + + with pytest.raises(NotImplementedError): + DownloadMixin._get_title_details(None, 1) # type: ignore[arg-type] + + with pytest.raises(NotImplementedError): + DownloadMixin._load_pages(None, 1) # type: ignore[arg-type] + + with pytest.raises(NotImplementedError): + DownloadMixin._decrypt_image(None, "http://img", "ab") # type: ignore[arg-type] + + +def test_chapter_metadata_mapping_access_and_key_error() -> None: + """Verify compatibility mapping access on ``ChapterMetadata``.""" + metadata = ChapterMetadata(thumbnail_url="thumb", chapter_id=1024959, sub_title="Sub") + + assert metadata["thumbnail_url"] == "thumb" + assert metadata["sub_title"] == "Sub" + with pytest.raises(KeyError): + _ = metadata["unknown"] + + +def test_find_chapter_by_id_returns_match_and_none() -> None: + """Verify chapter lookup returns chapter object when found, else None.""" + downloader = DummyDownloader() + chapter = _chapter(1024959, "#1024959") + title_dump = SimpleNamespace(chapter_list_group=[_group([chapter])]) + + assert downloader._find_chapter_by_id(title_dump, 1024959) is chapter + assert downloader._find_chapter_by_id(title_dump, 102278) is None + + +def test_prepare_filename_keeps_text_when_mojibake_fix_fails() -> None: + """Verify filename sanitizer still returns safe text on decode failures.""" + downloader = DummyDownloader() + assert downloader._prepare_filename("A\u20ac!") == "A" diff --git a/tests/test_entrypoints.py b/tests/test_entrypoints.py new file mode 100644 index 0000000..e27876f --- /dev/null +++ b/tests/test_entrypoints.py @@ -0,0 +1,11 @@ +"""Tests for importable runtime entrypoint modules.""" + +from __future__ import annotations + +import importlib + + +def test_import_mloader_dunder_main_module() -> None: + """Verify the ``python -m`` entrypoint module can be imported.""" + module = importlib.reload(importlib.import_module("mloader.__main__")) + assert callable(module.main) diff --git a/tests/test_exporter_base.py b/tests/test_exporter_base.py new file mode 100644 index 0000000..c4bdde1 --- /dev/null +++ b/tests/test_exporter_base.py @@ -0,0 +1,145 @@ +"""Tests for ExporterBase shared naming and registration behavior.""" + +from __future__ import annotations + +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from mloader.constants import Language +from mloader.exporters.exporter_base import ExporterBase, _is_extra + + +class DummyExporter(ExporterBase): + """Minimal exporter implementation used for ExporterBase tests.""" + + format = "dummy" + + def add_image(self, image_data: bytes, index: int | range) -> None: + """Store the latest add_image call payload for assertions.""" + self.last = (image_data, index) + + def skip_image(self, index: int | range) -> bool: + """Never skip any images in this test exporter.""" + del index + return False + + +def _title( + name: str = "demo title", + language: int = Language.ENGLISH.value, + author: str = "author", +) -> SimpleNamespace: + """Build a minimal title object for exporter-base tests.""" + return SimpleNamespace(name=name, language=language, author=author) + + +def _chapter(name: str = "#1", sub_title: str = "subtitle") -> SimpleNamespace: + """Build a minimal chapter object for exporter-base tests.""" + return SimpleNamespace(name=name, sub_title=sub_title) + + +def test_is_extra_detection() -> None: + """Verify extra chapter detection for Ex and numeric chapters.""" + assert _is_extra("#Ex") is True + assert _is_extra("#1") is False + + +def test_exporter_base_formats_prefix_suffix_and_page_names(tmp_path: Path) -> None: + """Verify exporter base derives expected prefixes, suffixes, and page names.""" + exporter = DummyExporter( + destination=str(tmp_path), + title=_title(language=Language.FRENCH.value), + chapter=_chapter(name="#12", sub_title=""), + ) + + assert "[FRENCH]" in exporter._chapter_prefix + assert exporter._chapter_suffix == "- Unknown" + assert exporter.format_page_name(2) == f"{exporter._chapter_prefix} - p002 - Unknown.jpg" + assert exporter.format_page_name(range(1, 4), ext="png").endswith(".png") + + +def test_exporter_base_handles_legacy_vietnamese_language_code(tmp_path: Path) -> None: + """Verify legacy Vietnamese code still produces a stable language prefix.""" + exporter = DummyExporter( + destination=str(tmp_path), + title=_title(language=8), + chapter=_chapter(), + ) + + assert "[VIETNAMESE]" in exporter._chapter_prefix + + +def test_exporter_base_handles_unknown_language_code(tmp_path: Path) -> None: + """Verify unknown language codes do not raise and keep a readable tag.""" + exporter = DummyExporter( + destination=str(tmp_path), + title=_title(language=99), + chapter=_chapter(), + ) + + assert "[LANG-99]" in exporter._chapter_prefix + + +def test_exporter_base_iso_language_maps_known_code(tmp_path: Path) -> None: + """Verify known internal language codes map to expected ISO values.""" + exporter = DummyExporter( + destination=str(tmp_path), + title=_title(language=Language.SPANISH.value), + chapter=_chapter(), + ) + + assert exporter._iso_language() == "es" + + +def test_exporter_base_iso_language_falls_back_to_english(tmp_path: Path) -> None: + """Verify unknown language codes default to English ISO code.""" + exporter = DummyExporter( + destination=str(tmp_path), + title=_title(language=99), + chapter=_chapter(), + ) + + assert exporter._iso_language() == "en" + + +def test_exporter_base_windows_path_prefix( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """Verify Windows mode adds extended-length prefix to destination paths.""" + monkeypatch.setattr("mloader.exporters.exporter_base.is_windows", lambda: True) + + exporter = DummyExporter(destination=str(tmp_path), title=_title(), chapter=_chapter()) + + assert exporter.destination.startswith("\\\\?\\") + + +def test_exporter_base_registers_subclasses_and_close_pass(tmp_path: Path) -> None: + """Verify subclasses register format keys and default close is callable.""" + exporter = DummyExporter(destination=str(tmp_path), title=_title(), chapter=_chapter()) + exporter.close() + + assert ExporterBase.FORMAT_REGISTRY["dummy"] is DummyExporter + + +def test_abstract_base_methods_have_noop_defaults() -> None: + """Verify abstract base placeholders are callable for coverage purposes.""" + ExporterBase.add_image(None, b"", 0) + ExporterBase.skip_image(None, 0) + + +def test_exporter_base_requires_non_empty_format() -> None: + """Verify subclass registration rejects missing/empty format keys.""" + with pytest.raises(TypeError): + + class _InvalidExporter(ExporterBase): + format = "" + + def add_image(self, image_data: bytes, index: int | range) -> None: + del image_data, index + + def skip_image(self, index: int | range) -> bool: + del index + return False diff --git a/tests/test_exporters.py b/tests/test_exporters.py new file mode 100644 index 0000000..9202f0a --- /dev/null +++ b/tests/test_exporters.py @@ -0,0 +1,217 @@ +"""Tests for concrete exporter implementations.""" + +from __future__ import annotations + +import io +import zipfile +from pathlib import Path +from types import SimpleNamespace + +from PIL import Image + +from mloader.constants import Language +from mloader.exporters.cbz_exporter import CBZExporter +from mloader.exporters.pdf_exporter import PDFExporter +from mloader.exporters.raw_exporter import RawExporter + + +def _title( + name: str = "demo title", + language: int = Language.ENGLISH.value, + author: str = "author", +) -> SimpleNamespace: + """Build a minimal title object for exporter tests.""" + return SimpleNamespace(name=name, language=language, author=author) + + +def _chapter(name: str = "#1", sub_title: str = "start") -> SimpleNamespace: + """Build a minimal chapter object for exporter tests.""" + return SimpleNamespace(name=name, sub_title=sub_title) + + +def _jpeg_bytes(color: tuple[int, int, int] = (255, 0, 0)) -> bytes: + """Create a small in-memory JPEG image payload for tests.""" + image = Image.new("RGB", (20, 20), color=color) + buffer = io.BytesIO() + image.save(buffer, format="JPEG") + return buffer.getvalue() + + +def _png_rgba_bytes() -> bytes: + """Create a small RGBA PNG payload for PDF conversion branch tests.""" + image = Image.new("RGBA", (20, 20), color=(255, 0, 0, 120)) + buffer = io.BytesIO() + image.save(buffer, format="PNG") + return buffer.getvalue() + + +def test_raw_exporter_writes_and_skips_existing_image(tmp_path: Path) -> None: + """Verify raw exporter writes page files and skips existing outputs.""" + exporter = RawExporter(destination=str(tmp_path), title=_title(), chapter=_chapter()) + + exporter.add_image(b"abc", 0) + filename = exporter.format_page_name(0) + path = exporter.path / filename + + assert path.exists() + assert path.read_bytes() == b"abc" + assert exporter.skip_image(0) is True + + +def test_raw_exporter_with_chapter_subdir(tmp_path: Path) -> None: + """Verify raw exporter can place output in chapter-specific subdirectories.""" + exporter = RawExporter( + destination=str(tmp_path), + title=_title(), + chapter=_chapter(), + add_chapter_subdir=True, + ) + assert exporter.path.name == exporter.chapter_name + + +def test_cbz_exporter_creates_archive_with_images(tmp_path: Path) -> None: + """Verify CBZ exporter writes image entries into a created archive.""" + exporter = CBZExporter(destination=str(tmp_path), title=_title(), chapter=_chapter()) + + exporter.add_image(b"img1", 0) + exporter.add_image(b"img2", 1) + exporter.close() + + assert exporter.path.exists() + + with zipfile.ZipFile(exporter.path, "r") as archive: + names = set(archive.namelist()) + comicinfo = archive.read(Path(exporter.chapter_name, "ComicInfo.xml").as_posix()).decode( + "utf-8" + ) + + assert any(name.endswith(".jpg") for name in names) + assert "" in comicinfo + assert "en" in comicinfo + + +def test_cbz_exporter_comicinfo_escapes_metadata(tmp_path: Path) -> None: + """Verify ComicInfo.xml escapes special characters from metadata.""" + exporter = CBZExporter( + destination=str(tmp_path), + title=_title(name="a & b", author="x < y"), + chapter=_chapter(name="#7", sub_title='title "quoted"'), + ) + + exporter.add_image(b"img", 0) + exporter.close() + + with zipfile.ZipFile(exporter.path, "r") as archive: + comicinfo = archive.read(Path(exporter.chapter_name, "ComicInfo.xml").as_posix()).decode( + "utf-8" + ) + + assert "a & b" in comicinfo + assert "x < y" in comicinfo + assert "title "quoted"" in comicinfo + + +def test_cbz_exporter_comicinfo_write_is_idempotent(tmp_path: Path) -> None: + """Verify repeated ComicInfo writes do not create duplicate archive entries.""" + exporter = CBZExporter(destination=str(tmp_path), title=_title(), chapter=_chapter()) + + exporter.add_image(b"img", 0) + exporter._write_comicinfo_xml_entry() + exporter._write_comicinfo_xml_entry() + exporter.close() + + with zipfile.ZipFile(exporter.path, "r") as archive: + names = archive.namelist() + + assert names.count(Path(exporter.chapter_name, "ComicInfo.xml").as_posix()) == 1 + + +def test_cbz_exporter_skips_when_archive_exists(tmp_path: Path) -> None: + """Verify CBZ exporter skips writes when destination archive already exists.""" + first = CBZExporter(destination=str(tmp_path), title=_title(), chapter=_chapter()) + first.add_image(b"img1", 0) + first.close() + + second = CBZExporter(destination=str(tmp_path), title=_title(), chapter=_chapter()) + size_before = second.path.stat().st_size + + second.add_image(b"ignored", 1) + assert second.skip_image(0) is True + second.close() + + assert second.path.stat().st_size == size_before + + +def test_pdf_exporter_writes_pdf(tmp_path: Path) -> None: + """Verify PDF exporter writes a non-empty PDF output file.""" + exporter = PDFExporter(destination=str(tmp_path), title=_title(), chapter=_chapter()) + + exporter.add_image(_jpeg_bytes(), 0) + exporter.close() + + assert exporter.path.exists() + assert exporter.path.stat().st_size > 0 + + +def test_pdf_exporter_skips_when_pdf_exists(tmp_path: Path) -> None: + """Verify PDF exporter skips writes when destination PDF already exists.""" + first = PDFExporter(destination=str(tmp_path), title=_title(), chapter=_chapter()) + first.add_image(_jpeg_bytes(), 0) + first.close() + + second = PDFExporter(destination=str(tmp_path), title=_title(), chapter=_chapter()) + size_before = second.path.stat().st_size + + second.add_image(_jpeg_bytes(), 0) + assert second.skip_image(0) is True + second.close() + + assert second.path.stat().st_size == size_before + + +def test_pdf_exporter_close_without_images_is_noop(tmp_path: Path) -> None: + """Verify closing PDF exporter without images does not create output.""" + exporter = PDFExporter( + destination=str(tmp_path), title=_title(name="other"), chapter=_chapter() + ) + exporter.close() + assert exporter.path.exists() is False + + +def test_pdf_exporter_cleans_temp_page_buffers_after_close(tmp_path: Path) -> None: + """Verify PDF exporter releases temporary buffering state once closed.""" + exporter = PDFExporter( + destination=str(tmp_path), title=_title(name="buffers"), chapter=_chapter() + ) + + exporter.add_image(_jpeg_bytes(), 5) + exporter.add_image(_jpeg_bytes(color=(0, 255, 0)), 1) + exporter.close() + + assert exporter.path.exists() is True + assert exporter._temp_dir is None + assert exporter._page_paths == [] + + +def test_pdf_exporter_add_image_noops_when_temp_dir_is_missing(tmp_path: Path) -> None: + """Verify add_image exits cleanly when temp buffering is unexpectedly unavailable.""" + exporter = PDFExporter( + destination=str(tmp_path), title=_title(name="no-temp"), chapter=_chapter() + ) + exporter._temp_dir = None + exporter.close() + + exporter.add_image(_jpeg_bytes(), 0) + + assert exporter._page_paths == [] + + +def test_pdf_exporter_handles_rgba_images_and_range_index(tmp_path: Path) -> None: + """Verify PDF exporter converts RGBA pages and accepts range-based page indexes.""" + exporter = PDFExporter(destination=str(tmp_path), title=_title(name="rgba"), chapter=_chapter()) + + exporter.add_image(_png_rgba_bytes(), range(1, 2)) + exporter.close() + + assert exporter.path.exists() is True + assert exporter.path.stat().st_size > 0 diff --git a/tests/test_manga_loader_init.py b/tests/test_manga_loader_init.py new file mode 100644 index 0000000..ff8ab32 --- /dev/null +++ b/tests/test_manga_loader_init.py @@ -0,0 +1,96 @@ +"""Tests for MangaLoader initialization behavior.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +from requests import Session + +from mloader.manga_loader.init import MangaLoader + + +def test_manga_loader_creates_independent_default_sessions() -> None: + """Ensure separate MangaLoader instances do not share default sessions.""" + loader_a = MangaLoader(exporter=None, quality="high", split=False, meta=False) + loader_b = MangaLoader(exporter=None, quality="high", split=False, meta=False) + + assert loader_a.session is not loader_b.session + assert "User-Agent" in loader_a.session.headers + + +def test_manga_loader_configures_transport_defaults() -> None: + """Ensure loader sets destination, format, and timeout defaults.""" + session = Session() + loader = MangaLoader( + exporter=None, + quality="high", + split=False, + meta=False, + session=session, + ) + + assert loader.destination == "mloader_downloads" + assert loader.output_format == "cbz" + assert loader.request_timeout == (5.0, 30.0) + assert session.get_adapter("https://").max_retries.total == 3 + assert loader._runtime.resume is True + assert loader._runtime.manifest_reset is False + + +def test_manga_loader_enables_payload_capture_when_directory_is_set(tmp_path: Path) -> None: + """Ensure loader initializes payload capture helper when configured.""" + loader = MangaLoader( + exporter=None, + quality="high", + split=False, + meta=False, + capture_api_dir=str(tmp_path / "captures"), + ) + + assert loader.payload_capture is not None + + +def test_manga_loader_static_transport_proxy_configures_retries() -> None: + """Ensure facade transport helper delegates to runtime transport configuration.""" + session = Session() + + MangaLoader._configure_transport(session, retries=4) + + assert session.get_adapter("https://").max_retries.total == 4 + + +def test_manga_loader_download_delegates_to_runtime(monkeypatch: pytest.MonkeyPatch) -> None: + """Ensure facade download method forwards parameters to composed runtime object.""" + loader = MangaLoader(exporter=None, quality="high", split=False, meta=False) + observed: dict[str, object] = {} + + def _download(**kwargs: object) -> None: + observed.update(kwargs) + + monkeypatch.setattr(loader._runtime, "download", _download) + + loader.download( + title_ids={100312}, + chapter_ids={1024959}, + min_chapter=3, + max_chapter=4, + last_chapter=True, + ) + + assert observed == { + "title_ids": {100312}, + "chapter_numbers": None, + "chapter_ids": {1024959}, + "min_chapter": 3, + "max_chapter": 4, + "last_chapter": True, + } + + +def test_manga_loader_unknown_attribute_raises_attribute_error() -> None: + """Ensure facade exposes only explicit API and rejects unknown attributes.""" + loader = MangaLoader(exporter=None, quality="high", split=False, meta=False) + + with pytest.raises(AttributeError): + _ = loader.runtime_marker diff --git a/tests/test_manifest.py b/tests/test_manifest.py new file mode 100644 index 0000000..f7c97ee --- /dev/null +++ b/tests/test_manifest.py @@ -0,0 +1,290 @@ +"""Tests for persistent title download manifest behavior.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from mloader.manga_loader.manifest import ( + MANIFEST_FILENAME, + MANIFEST_SCHEMA, + MANIFEST_VERSION, + TitleDownloadManifest, +) + + +def _load_manifest(path: Path) -> dict[str, object]: + """Read manifest JSON payload from ``path``.""" + return json.loads(path.read_text(encoding="utf-8")) + + +def test_manifest_tracks_started_completed_and_failed_states(tmp_path: Path) -> None: + """Verify manifest writes state transitions and persists across reloads.""" + manifest = TitleDownloadManifest(tmp_path) + manifest_path = tmp_path / MANIFEST_FILENAME + + manifest.mark_started(1, chapter_name="#1", sub_title="Start", output_format="pdf") + payload_started = _load_manifest(manifest_path) + chapter_started = payload_started["chapters"]["1"] + assert chapter_started["status"] == "in_progress" + assert chapter_started["output_format"] == "pdf" + + manifest.mark_completed(1, output_path="/tmp/out.pdf") + manifest_reloaded = TitleDownloadManifest(tmp_path) + payload_completed = _load_manifest(manifest_path) + chapter_completed = payload_completed["chapters"]["1"] + assert chapter_completed["status"] == "completed" + assert chapter_completed["output_path"] == "/tmp/out.pdf" + assert manifest_reloaded.is_completed(1) is True + + manifest_reloaded.mark_failed(2, error="boom") + payload_failed = _load_manifest(manifest_path) + chapter_failed = payload_failed["chapters"]["2"] + assert chapter_failed["status"] == "failed" + assert chapter_failed["error"] == "boom" + + +def test_manifest_load_migrates_v1_payload_and_persists_current_schema(tmp_path: Path) -> None: + """Verify versioned legacy payloads are migrated to latest schema on load.""" + manifest_path = tmp_path / MANIFEST_FILENAME + manifest_path.write_text( + json.dumps( + { + "version": 1, + "chapters": { + "3": { + "chapter_id": 3, + "status": "completed", + } + }, + } + ), + encoding="utf-8", + ) + + manifest = TitleDownloadManifest(tmp_path) + + assert manifest.is_completed(3) is True + migrated_payload = _load_manifest(manifest_path) + assert migrated_payload["version"] == MANIFEST_VERSION + assert migrated_payload["schema"] == MANIFEST_SCHEMA + + +def test_manifest_load_migrates_unversioned_payload_shape(tmp_path: Path) -> None: + """Verify old unversioned chapter-map payloads are migrated and preserved.""" + manifest_path = tmp_path / MANIFEST_FILENAME + manifest_path.write_text( + json.dumps( + { + "7": { + "chapter_id": 7, + "status": "completed", + } + } + ), + encoding="utf-8", + ) + + manifest = TitleDownloadManifest(tmp_path) + + assert manifest.is_completed(7) is True + migrated_payload = _load_manifest(manifest_path) + assert migrated_payload["version"] == MANIFEST_VERSION + assert migrated_payload["schema"] == MANIFEST_SCHEMA + assert migrated_payload["chapters"]["7"]["status"] == "completed" + + +def test_manifest_load_accepts_future_version_without_migration(tmp_path: Path) -> None: + """Verify newer unknown manifest versions still load chapter completion state.""" + manifest_path = tmp_path / MANIFEST_FILENAME + manifest_path.write_text( + json.dumps( + { + "version": MANIFEST_VERSION + 1, + "chapters": { + "9": { + "chapter_id": 9, + "status": "completed", + } + }, + } + ), + encoding="utf-8", + ) + + manifest = TitleDownloadManifest(tmp_path) + + assert manifest.is_completed(9) is True + + +def test_manifest_load_returns_empty_when_migration_step_is_missing( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """Verify missing migration mapping fails closed with empty in-memory chapter state.""" + manifest_path = tmp_path / MANIFEST_FILENAME + manifest_path.write_text( + json.dumps( + { + "version": 0, + "chapters": { + "1": { + "chapter_id": 1, + "status": "completed", + } + }, + } + ), + encoding="utf-8", + ) + monkeypatch.setattr("mloader.manga_loader.manifest.MANIFEST_MIGRATIONS", {}) + + manifest = TitleDownloadManifest(tmp_path) + + assert manifest.is_completed(1) is False + + +@pytest.mark.parametrize( + "content", + [ + "not-json", + "[]", + "{}", + '{"chapters": []}', + ], +) +def test_manifest_load_handles_invalid_or_unexpected_payloads( + tmp_path: Path, + content: str, +) -> None: + """Verify malformed manifest payloads are ignored without raising.""" + manifest_path = tmp_path / MANIFEST_FILENAME + manifest_path.write_text(content, encoding="utf-8") + + manifest = TitleDownloadManifest(tmp_path) + + assert manifest.is_completed(1) is False + + manifest.mark_started(1, chapter_name="#1", sub_title="Sub", output_format="cbz") + payload = _load_manifest(manifest_path) + assert payload["chapters"]["1"]["status"] == "in_progress" + + +def test_manifest_autosave_disabled_requires_flush(tmp_path: Path) -> None: + """Verify autosave-disabled manifests persist changes only on explicit flush.""" + manifest = TitleDownloadManifest(tmp_path, autosave=False) + manifest_path = tmp_path / MANIFEST_FILENAME + + manifest.mark_started(1, chapter_name="#1", sub_title="Sub", output_format="cbz") + assert manifest_path.exists() is False + + manifest.mark_completed(1, output_path="/tmp/out.cbz") + assert manifest_path.exists() is False + + manifest.flush() + payload = _load_manifest(manifest_path) + assert payload["chapters"]["1"]["status"] == "completed" + assert payload["chapters"]["1"]["output_path"] == "/tmp/out.cbz" + + +def test_manifest_reset_clears_manifest_file(tmp_path: Path) -> None: + """Verify reset removes persisted manifest state.""" + manifest = TitleDownloadManifest(tmp_path) + manifest_path = tmp_path / MANIFEST_FILENAME + manifest.mark_completed(1) + assert manifest_path.exists() is True + + manifest.reset() + + assert manifest_path.exists() is False + assert manifest.is_completed(1) is False + + +def test_manifest_autosave_merges_updates_across_instances(tmp_path: Path) -> None: + """Verify separate instances can append chapter state without dropping previous entries.""" + manifest_a = TitleDownloadManifest(tmp_path) + manifest_b = TitleDownloadManifest(tmp_path) + + manifest_a.mark_completed(1, output_path="/tmp/one.cbz") + manifest_b.mark_failed(2, error="boom") + + payload = _load_manifest(tmp_path / MANIFEST_FILENAME) + assert set(payload["chapters"].keys()) == {"1", "2"} + + +def test_manifest_save_persists_pending_changes(tmp_path: Path) -> None: + """Verify explicit save persists in-memory changes for autosave-disabled mode.""" + manifest = TitleDownloadManifest(tmp_path, autosave=False) + manifest.mark_failed(7, error="boom") + + manifest.save() + + payload = _load_manifest(tmp_path / MANIFEST_FILENAME) + assert payload["chapters"]["7"]["status"] == "failed" + + +def test_manifest_flush_returns_when_dirty_clears_during_lock( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """Verify flush exits when dirty state is cleared before locked write branch.""" + manifest = TitleDownloadManifest(tmp_path, autosave=False) + manifest._dirty = True + + class Lock: + def __enter__(self) -> None: + manifest._dirty = False + return None + + def __exit__(self, *_args: object) -> None: + return None + + monkeypatch.setattr(manifest, "_lock", Lock()) + manifest.flush() + assert manifest._dirty is False + + +def test_manifest_mark_entry_noops_when_update_is_identical_autosave_true( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """Verify autosave mode skips writes when chapter entry does not change.""" + monkeypatch.setattr( + "mloader.manga_loader.manifest._utc_timestamp", lambda: "2026-02-24T00:00:00Z" + ) + manifest = TitleDownloadManifest(tmp_path) + manifest.mark_failed(1, error="boom") + payload_before = _load_manifest(tmp_path / MANIFEST_FILENAME) + + manifest.mark_failed(1, error="boom") + + payload_after = _load_manifest(tmp_path / MANIFEST_FILENAME) + assert payload_before == payload_after + + +def test_manifest_mark_entry_noops_when_update_is_identical_autosave_false( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """Verify autosave-disabled mode skips dirtying when chapter entry is unchanged.""" + monkeypatch.setattr( + "mloader.manga_loader.manifest._utc_timestamp", lambda: "2026-02-24T00:00:00Z" + ) + manifest = TitleDownloadManifest(tmp_path, autosave=False) + manifest.mark_failed(2, error="boom") + manifest._dirty = False + + manifest.mark_failed(2, error="boom") + + assert manifest._dirty is False + + +def test_manifest_reset_is_noop_when_file_is_missing(tmp_path: Path) -> None: + """Verify reset does not fail when manifest file does not exist.""" + manifest = TitleDownloadManifest(tmp_path) + + manifest.reset() + + assert (tmp_path / MANIFEST_FILENAME).exists() is False diff --git a/tests/test_normalization.py b/tests/test_normalization.py new file mode 100644 index 0000000..f06550c --- /dev/null +++ b/tests/test_normalization.py @@ -0,0 +1,201 @@ +"""Tests for ID normalization logic.""" + +from __future__ import annotations + +from types import SimpleNamespace +from typing import Any, Iterable + +import pytest + +from mloader.manga_loader.normalization import NormalizationMixin + + +class DummyNormalizer(NormalizationMixin): + """Normalization mixin harness with injected viewer/title fixtures.""" + + def __init__(self, viewers: dict[int, Any], title_details: dict[int, Any]) -> None: + """Store fake viewer and title-detail mappings used in tests.""" + self._viewers = viewers + self._title_details = title_details + + def _load_pages(self, chapter_id: int) -> Any: + """Return a pre-seeded manga viewer payload for ``chapter_id``.""" + return self._viewers[chapter_id] + + def _get_title_details(self, title_id: int) -> Any: + """Return a pre-seeded title details payload for ``title_id``.""" + return self._title_details[title_id] + + +def _chapter(chapter_id: int, name: str) -> SimpleNamespace: + """Build a minimal chapter object used in normalization inputs.""" + return SimpleNamespace(chapter_id=chapter_id, name=name) + + +def _group(chapters: Iterable[SimpleNamespace]) -> SimpleNamespace: + """Build a chapter-list group containing the provided chapter sequence.""" + return SimpleNamespace( + first_chapter_list=list(chapters), + mid_chapter_list=[], + last_chapter_list=[], + ) + + +def test_normalize_ids_requires_at_least_one_input() -> None: + """Verify normalization rejects empty title and chapter input lists.""" + normalizer = DummyNormalizer({}, {}) + + with pytest.raises(ValueError): + normalizer._normalize_ids([], [], [], 0, 999) + + +def test_normalize_ids_uses_title_details_and_filters_by_chapter_numbers() -> None: + """Verify title-only normalization resolves chapter numbers to chapter IDs.""" + title_details = { + 100312: SimpleNamespace( + chapter_list_group=[ + _group( + [ + _chapter(1024959, "#1"), + _chapter(102278, "#2"), + _chapter(102279, "Special"), + ] + ) + ] + ) + } + normalizer = DummyNormalizer({}, title_details) + + result = normalizer._normalize_ids([100312], [2], [], min_chapter=0, max_chapter=999) + + assert result == {100312: {102278}} + + +def test_normalize_ids_last_chapter_picks_only_final_entry() -> None: + """Verify last_chapter mode returns only the final chapter ID.""" + title_details = { + 100312: SimpleNamespace( + chapter_list_group=[ + _group( + [ + _chapter(1024959, "#1024959"), + _chapter(102278, "#102278"), + _chapter(102279, "#102279"), + ] + ) + ] + ) + } + normalizer = DummyNormalizer({}, title_details) + + result = normalizer._normalize_ids( + [100312], + [], + [], + min_chapter=0, + max_chapter=2_147_483_647, + last_chapter=True, + ) + + assert result == {100312: {102279}} + + +def test_normalize_ids_merges_chapter_id_and_title_number_requests() -> None: + """Verify normalization merges chapter-ID and chapter-number-based targets.""" + viewers = { + 1024959: SimpleNamespace( + title_id=100312, + chapter_id=1024959, + chapter_name="#1", + chapters=[_chapter(1024959, "#1"), _chapter(102278, "#2")], + ), + 102377: SimpleNamespace( + title_id=100412, + chapter_id=102377, + chapter_name="#7", + chapters=[_chapter(102377, "#7")], + ), + } + title_details = { + 100312: SimpleNamespace( + chapter_list_group=[ + _group([_chapter(1024959, "#1"), _chapter(102278, "#2"), _chapter(102279, "#3")]) + ] + ), + 100412: SimpleNamespace(chapter_list_group=[_group([_chapter(102377, "#7")])]), + } + normalizer = DummyNormalizer(viewers, title_details) + + result = normalizer._normalize_ids( + [100312], + [2], + [102377], + min_chapter=0, + max_chapter=2_147_483_647, + ) + + assert result == {100312: {102278}, 100412: {102377}} + + +def test_normalize_ids_prefers_viewer_chapters_when_chapter_id_matches_selected_title() -> None: + """Verify overlapping title + chapter-ID inputs expand from viewer chapter list.""" + viewers = { + 1024959: SimpleNamespace( + title_id=100312, + chapter_id=1024959, + chapter_name="#1", + chapters=[_chapter(1024959, "#1"), _chapter(102278, "#2")], + ) + } + normalizer = DummyNormalizer(viewers, {}) + + result = normalizer._normalize_ids( + [100312], + [], + [1024959], + min_chapter=0, + max_chapter=2_147_483_647, + ) + + assert result == {100312: {1024959, 102278}} + + +def test_normalize_ids_rejects_chapter_numbers_without_title_context() -> None: + """Verify chapter-number-only requests fail without titles or chapter IDs.""" + normalizer = DummyNormalizer({}, {}) + + with pytest.raises(ValueError, match="Chapter numbers require"): + normalizer._normalize_ids([], [1], [], min_chapter=0, max_chapter=999) + + +def test_prepare_normalized_manga_list_delegates_to_normalize_ids( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify wrapper method delegates to _normalize_ids and returns its result.""" + normalizer = DummyNormalizer({}, {}) + sentinel = {100312: {1024959}} + + monkeypatch.setattr( + normalizer, + "_normalize_ids", + lambda *args: sentinel, + ) + + result = normalizer._prepare_normalized_manga_list( + [100312], + [1], + [1024959], + 0, + 2_147_483_647, + False, + ) + assert result is sentinel + + +def test_normalization_mixin_placeholders_raise_not_implemented() -> None: + """Verify default data-loading placeholders raise ``NotImplementedError``.""" + with pytest.raises(NotImplementedError): + NormalizationMixin._load_pages(None, 1) # type: ignore[arg-type] + + with pytest.raises(NotImplementedError): + NormalizationMixin._get_title_details(None, 1) # type: ignore[arg-type] diff --git a/tests/test_readme_cli_options.py b/tests/test_readme_cli_options.py new file mode 100644 index 0000000..5af69b1 --- /dev/null +++ b/tests/test_readme_cli_options.py @@ -0,0 +1,47 @@ +"""Tests ensuring README CLI option docs stay in sync with command options.""" + +from __future__ import annotations + +import re +from pathlib import Path + +import click + +from mloader.cli.main import main as cli_main +from mloader.cli.readme_reference import replace_readme_cli_reference + + +def _extract_option_names_from_help(help_text: str) -> set[str]: + """Extract long option names from Click help output.""" + option_names: set[str] = set() + for line in help_text.splitlines(): + stripped = line.strip() + if not stripped.startswith("-"): + continue + matches = re.findall(r"--[a-zA-Z0-9-]+", stripped) + option_names.update(matches) + return option_names + + +def test_readme_mentions_every_cli_long_option() -> None: + """Verify README command docs mention all currently supported long options.""" + readme_text = Path("README.md").read_text(encoding="utf-8") + context = click.Context(cli_main) + help_text = cli_main.get_help(context) + option_names = _extract_option_names_from_help(help_text) + + # Keep only stable user-facing options from click help extraction. + ignored = {"--help", "--version"} + required_options = sorted(option_names - ignored) + + for option_name in required_options: + assert option_name in readme_text, ( + f"README is missing option documentation for {option_name}" + ) + + +def test_readme_cli_reference_block_is_synced() -> None: + """Verify README auto-generated CLI reference block is synchronized.""" + readme_text = Path("README.md").read_text(encoding="utf-8") + rendered = replace_readme_cli_reference(readme_text, command=cli_main) + assert rendered == readme_text diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000..8982611 --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,41 @@ +"""Tests for generic utility helper functions.""" + +from __future__ import annotations + +import pytest + +from mloader import utils + + +def test_contains_keywords_is_case_insensitive() -> None: + """Verify keyword matching is case-insensitive and requires all keywords.""" + assert utils._contains_keywords("One Shot story", ["one", "SHOT"]) is True + assert utils._contains_keywords("only one", ["one", "shot"]) is False + + +def test_is_oneshot_detects_keywords_and_numbered_chapters() -> None: + """Verify one-shot detection for keyword and numeric chapter naming.""" + assert utils.is_oneshot("#12", "one shot") is False + assert utils.is_oneshot("One shot special", "") is True + assert utils.is_oneshot("Special", "A one shot finale") is True + assert utils.is_oneshot("Special", "Finale") is False + + +def test_chapter_name_to_int_handles_invalid_names() -> None: + """Verify chapter number extraction for valid and invalid names.""" + assert utils.chapter_name_to_int("#42") == 42 + assert utils.chapter_name_to_int("abc") is None + + +def test_escape_path_normalizes_special_characters() -> None: + """Verify unsafe filesystem characters are normalized.""" + assert utils.escape_path(" hello:/world!? ") == "hello world" + + +def test_is_windows_checks_platform(monkeypatch: pytest.MonkeyPatch) -> None: + """Verify platform detection maps correctly for Windows and non-Windows.""" + monkeypatch.setattr(utils.sys, "platform", "win32") + assert utils.is_windows() is True + + monkeypatch.setattr(utils.sys, "platform", "linux") + assert utils.is_windows() is False diff --git a/tests/test_validators.py b/tests/test_validators.py new file mode 100644 index 0000000..57aeafd --- /dev/null +++ b/tests/test_validators.py @@ -0,0 +1,90 @@ +"""Tests for CLI callback validators.""" + +from __future__ import annotations + +from types import SimpleNamespace + +import click +import pytest + +from mloader.cli.validators import validate_ids, validate_urls + + +def test_validate_urls_collects_chapter_ids_and_titles() -> None: + """Verify URL callback extracts viewer chapter IDs and title IDs into context sets.""" + ctx = click.Context(click.Command("mloader")) + + value = ( + "https://mangaplus.shueisha.co.jp/viewer/1024959", + "https://mangaplus.shueisha.co.jp/titles/100312", + "viewer/102278", + ) + + returned = validate_urls(ctx, None, value) + + assert returned == value + assert ctx.params["chapter_ids"] == {1024959, 102278} + assert ctx.params["titles"] == {100312} + + +def test_validate_urls_rejects_invalid_url() -> None: + """Verify malformed URLs raise a click validation error.""" + ctx = click.Context(click.Command("mloader")) + + with pytest.raises(click.BadParameter): + validate_urls(ctx, None, ("not-a-url",)) + + +def test_validate_urls_rejects_invalid_host() -> None: + """Verify URLs outside allowed MangaPlus hosts are rejected.""" + ctx = click.Context(click.Command("mloader")) + with pytest.raises(click.BadParameter): + validate_urls(ctx, None, ("https://example.com/viewer/1024959",)) + + +def test_validate_urls_rejects_unsupported_segment() -> None: + """Verify unknown URL path keys are rejected by validator callback.""" + ctx = click.Context(click.Command("mloader")) + + with pytest.raises(click.BadParameter): + validate_urls(ctx, None, ("https://mangaplus.shueisha.co.jp/chapter/1024959",)) + + +def test_validate_urls_accepts_empty_input() -> None: + """Verify empty URL argument lists are passed through unchanged.""" + ctx = click.Context(click.Command("mloader")) + + assert validate_urls(ctx, None, ()) == () + + +def test_validate_ids_updates_context_for_all_supported_target_types() -> None: + """Verify ID callback updates chapter-number, chapter-ID, and title target sets.""" + ctx = click.Context(click.Command("mloader")) + + validate_ids(ctx, SimpleNamespace(name="chapter"), (12, 13, 13)) + validate_ids(ctx, SimpleNamespace(name="chapter_id"), (1024959, 102278, 102278)) + validate_ids(ctx, SimpleNamespace(name="title"), (100312,)) + + assert ctx.params["chapters"] == {12, 13} + assert ctx.params["chapter_ids"] == {1024959, 102278} + assert ctx.params["titles"] == {100312} + + +def test_validate_ids_accepts_empty_input() -> None: + """Verify empty numeric ID lists are passed through unchanged.""" + ctx = click.Context(click.Command("mloader")) + assert validate_ids(ctx, SimpleNamespace(name="chapter"), ()) == () + + +def test_validate_ids_rejects_missing_param_metadata() -> None: + """Verify validator raises click.BadParameter when param metadata is absent.""" + ctx = click.Context(click.Command("mloader")) + with pytest.raises(click.BadParameter): + validate_ids(ctx, None, (1024959,)) + + +def test_validate_ids_rejects_unexpected_param_name() -> None: + """Verify validator raises click.BadParameter for unsupported parameter names.""" + ctx = click.Context(click.Command("mloader")) + with pytest.raises(click.BadParameter, match="Unexpected parameter"): + validate_ids(ctx, SimpleNamespace(name="unknown"), (1,)) diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..d323520 --- /dev/null +++ b/uv.lock @@ -0,0 +1,435 @@ +version = 1 +revision = 3 +requires-python = ">=3.14" + +[[package]] +name = "certifi" +version = "2026.2.25" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/60/e3bec1881450851b087e301bedc3daa9377a4d45f1c26aa90b0b235e38aa/charset_normalizer-3.4.6.tar.gz", hash = "sha256:1ae6b62897110aa7c79ea2f5dd38d1abca6db663687c0b1ad9aed6f6bae3d9d6", size = 143363, upload-time = "2026-03-15T18:53:25.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/6f/ffe1e1259f384594063ea1869bfb6be5cdb8bc81020fc36c3636bc8302a1/charset_normalizer-3.4.6-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:9cc6e6d9e571d2f863fa77700701dae73ed5f78881efc8b3f9a4398772ff53e8", size = 294458, upload-time = "2026-03-15T18:51:41.134Z" }, + { url = "https://files.pythonhosted.org/packages/56/60/09bb6c13a8c1016c2ed5c6a6488e4ffef506461aa5161662bd7636936fb1/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef5960d965e67165d75b7c7ffc60a83ec5abfc5c11b764ec13ea54fbef8b4421", size = 199277, upload-time = "2026-03-15T18:51:42.953Z" }, + { url = "https://files.pythonhosted.org/packages/00/50/dcfbb72a5138bbefdc3332e8d81a23494bf67998b4b100703fd15fa52d81/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b3694e3f87f8ac7ce279d4355645b3c878d24d1424581b46282f24b92f5a4ae2", size = 218758, upload-time = "2026-03-15T18:51:44.339Z" }, + { url = "https://files.pythonhosted.org/packages/03/b3/d79a9a191bb75f5aa81f3aaaa387ef29ce7cb7a9e5074ba8ea095cc073c2/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5d11595abf8dd942a77883a39d81433739b287b6aa71620f15164f8096221b30", size = 215299, upload-time = "2026-03-15T18:51:45.871Z" }, + { url = "https://files.pythonhosted.org/packages/76/7e/bc8911719f7084f72fd545f647601ea3532363927f807d296a8c88a62c0d/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7bda6eebafd42133efdca535b04ccb338ab29467b3f7bf79569883676fc628db", size = 206811, upload-time = "2026-03-15T18:51:47.308Z" }, + { url = "https://files.pythonhosted.org/packages/e2/40/c430b969d41dda0c465aa36cc7c2c068afb67177bef50905ac371b28ccc7/charset_normalizer-3.4.6-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:bbc8c8650c6e51041ad1be191742b8b421d05bbd3410f43fa2a00c8db87678e8", size = 193706, upload-time = "2026-03-15T18:51:48.849Z" }, + { url = "https://files.pythonhosted.org/packages/48/15/e35e0590af254f7df984de1323640ef375df5761f615b6225ba8deb9799a/charset_normalizer-3.4.6-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:22c6f0c2fbc31e76c3b8a86fba1a56eda6166e238c29cdd3d14befdb4a4e4815", size = 202706, upload-time = "2026-03-15T18:51:50.257Z" }, + { url = "https://files.pythonhosted.org/packages/5e/bd/f736f7b9cc5e93a18b794a50346bb16fbfd6b37f99e8f306f7951d27c17c/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7edbed096e4a4798710ed6bc75dcaa2a21b68b6c356553ac4823c3658d53743a", size = 202497, upload-time = "2026-03-15T18:51:52.012Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ba/2cc9e3e7dfdf7760a6ed8da7446d22536f3d0ce114ac63dee2a5a3599e62/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:7f9019c9cb613f084481bd6a100b12e1547cf2efe362d873c2e31e4035a6fa43", size = 193511, upload-time = "2026-03-15T18:51:53.723Z" }, + { url = "https://files.pythonhosted.org/packages/9e/cb/5be49b5f776e5613be07298c80e1b02a2d900f7a7de807230595c85a8b2e/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:58c948d0d086229efc484fe2f30c2d382c86720f55cd9bc33591774348ad44e0", size = 220133, upload-time = "2026-03-15T18:51:55.333Z" }, + { url = "https://files.pythonhosted.org/packages/83/43/99f1b5dad345accb322c80c7821071554f791a95ee50c1c90041c157ae99/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:419a9d91bd238052642a51938af8ac05da5b3343becde08d5cdeab9046df9ee1", size = 203035, upload-time = "2026-03-15T18:51:56.736Z" }, + { url = "https://files.pythonhosted.org/packages/87/9a/62c2cb6a531483b55dddff1a68b3d891a8b498f3ca555fbcf2978e804d9d/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5273b9f0b5835ff0350c0828faea623c68bfa65b792720c453e22b25cc72930f", size = 216321, upload-time = "2026-03-15T18:51:58.17Z" }, + { url = "https://files.pythonhosted.org/packages/6e/79/94a010ff81e3aec7c293eb82c28f930918e517bc144c9906a060844462eb/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0e901eb1049fdb80f5bd11ed5ea1e498ec423102f7a9b9e4645d5b8204ff2815", size = 208973, upload-time = "2026-03-15T18:51:59.998Z" }, + { url = "https://files.pythonhosted.org/packages/2a/57/4ecff6d4ec8585342f0c71bc03efaa99cb7468f7c91a57b105bcd561cea8/charset_normalizer-3.4.6-cp314-cp314-win32.whl", hash = "sha256:b4ff1d35e8c5bd078be89349b6f3a845128e685e751b6ea1169cf2160b344c4d", size = 144610, upload-time = "2026-03-15T18:52:02.213Z" }, + { url = "https://files.pythonhosted.org/packages/80/94/8434a02d9d7f168c25767c64671fead8d599744a05d6a6c877144c754246/charset_normalizer-3.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:74119174722c4349af9708993118581686f343adc1c8c9c007d59be90d077f3f", size = 154962, upload-time = "2026-03-15T18:52:03.658Z" }, + { url = "https://files.pythonhosted.org/packages/46/4c/48f2cdbfd923026503dfd67ccea45c94fd8fe988d9056b468579c66ed62b/charset_normalizer-3.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:e5bcc1a1ae744e0bb59641171ae53743760130600da8db48cbb6e4918e186e4e", size = 143595, upload-time = "2026-03-15T18:52:05.123Z" }, + { url = "https://files.pythonhosted.org/packages/31/93/8878be7569f87b14f1d52032946131bcb6ebbd8af3e20446bc04053dc3f1/charset_normalizer-3.4.6-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:ad8faf8df23f0378c6d527d8b0b15ea4a2e23c89376877c598c4870d1b2c7866", size = 314828, upload-time = "2026-03-15T18:52:06.831Z" }, + { url = "https://files.pythonhosted.org/packages/06/b6/fae511ca98aac69ecc35cde828b0a3d146325dd03d99655ad38fc2cc3293/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f5ea69428fa1b49573eef0cc44a1d43bebd45ad0c611eb7d7eac760c7ae771bc", size = 208138, upload-time = "2026-03-15T18:52:08.239Z" }, + { url = "https://files.pythonhosted.org/packages/54/57/64caf6e1bf07274a1e0b7c160a55ee9e8c9ec32c46846ce59b9c333f7008/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:06a7e86163334edfc5d20fe104db92fcd666e5a5df0977cb5680a506fe26cc8e", size = 224679, upload-time = "2026-03-15T18:52:10.043Z" }, + { url = "https://files.pythonhosted.org/packages/aa/cb/9ff5a25b9273ef160861b41f6937f86fae18b0792fe0a8e75e06acb08f1d/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e1f6e2f00a6b8edb562826e4632e26d063ac10307e80f7461f7de3ad8ef3f077", size = 223475, upload-time = "2026-03-15T18:52:11.854Z" }, + { url = "https://files.pythonhosted.org/packages/fc/97/440635fc093b8d7347502a377031f9605a1039c958f3cd18dcacffb37743/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95b52c68d64c1878818687a473a10547b3292e82b6f6fe483808fb1468e2f52f", size = 215230, upload-time = "2026-03-15T18:52:13.325Z" }, + { url = "https://files.pythonhosted.org/packages/cd/24/afff630feb571a13f07c8539fbb502d2ab494019492aaffc78ef41f1d1d0/charset_normalizer-3.4.6-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:7504e9b7dc05f99a9bbb4525c67a2c155073b44d720470a148b34166a69c054e", size = 199045, upload-time = "2026-03-15T18:52:14.752Z" }, + { url = "https://files.pythonhosted.org/packages/e5/17/d1399ecdaf7e0498c327433e7eefdd862b41236a7e484355b8e0e5ebd64b/charset_normalizer-3.4.6-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:172985e4ff804a7ad08eebec0a1640ece87ba5041d565fff23c8f99c1f389484", size = 211658, upload-time = "2026-03-15T18:52:16.278Z" }, + { url = "https://files.pythonhosted.org/packages/b5/38/16baa0affb957b3d880e5ac2144caf3f9d7de7bc4a91842e447fbb5e8b67/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4be9f4830ba8741527693848403e2c457c16e499100963ec711b1c6f2049b7c7", size = 210769, upload-time = "2026-03-15T18:52:17.782Z" }, + { url = "https://files.pythonhosted.org/packages/05/34/c531bc6ac4c21da9ddfddb3107be2287188b3ea4b53b70fc58f2a77ac8d8/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:79090741d842f564b1b2827c0b82d846405b744d31e84f18d7a7b41c20e473ff", size = 201328, upload-time = "2026-03-15T18:52:19.553Z" }, + { url = "https://files.pythonhosted.org/packages/fa/73/a5a1e9ca5f234519c1953608a03fe109c306b97fdfb25f09182babad51a7/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:87725cfb1a4f1f8c2fc9890ae2f42094120f4b44db9360be5d99a4c6b0e03a9e", size = 225302, upload-time = "2026-03-15T18:52:21.043Z" }, + { url = "https://files.pythonhosted.org/packages/ba/f6/cd782923d112d296294dea4bcc7af5a7ae0f86ab79f8fefbda5526b6cfc0/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:fcce033e4021347d80ed9c66dcf1e7b1546319834b74445f561d2e2221de5659", size = 211127, upload-time = "2026-03-15T18:52:22.491Z" }, + { url = "https://files.pythonhosted.org/packages/0e/c5/0b6898950627af7d6103a449b22320372c24c6feda91aa24e201a478d161/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ca0276464d148c72defa8bb4390cce01b4a0e425f3b50d1435aa6d7a18107602", size = 222840, upload-time = "2026-03-15T18:52:24.113Z" }, + { url = "https://files.pythonhosted.org/packages/7d/25/c4bba773bef442cbdc06111d40daa3de5050a676fa26e85090fc54dd12f0/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:197c1a244a274bb016dd8b79204850144ef77fe81c5b797dc389327adb552407", size = 216890, upload-time = "2026-03-15T18:52:25.541Z" }, + { url = "https://files.pythonhosted.org/packages/35/1a/05dacadb0978da72ee287b0143097db12f2e7e8d3ffc4647da07a383b0b7/charset_normalizer-3.4.6-cp314-cp314t-win32.whl", hash = "sha256:2a24157fa36980478dd1770b585c0f30d19e18f4fb0c47c13aa568f871718579", size = 155379, upload-time = "2026-03-15T18:52:27.05Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7a/d269d834cb3a76291651256f3b9a5945e81d0a49ab9f4a498964e83c0416/charset_normalizer-3.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:cd5e2801c89992ed8c0a3f0293ae83c159a60d9a5d685005383ef4caca77f2c4", size = 169043, upload-time = "2026-03-15T18:52:28.502Z" }, + { url = "https://files.pythonhosted.org/packages/23/06/28b29fba521a37a8932c6a84192175c34d49f84a6d4773fa63d05f9aff22/charset_normalizer-3.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:47955475ac79cc504ef2704b192364e51d0d473ad452caedd0002605f780101c", size = 148523, upload-time = "2026-03-15T18:52:29.956Z" }, + { url = "https://files.pythonhosted.org/packages/2a/68/687187c7e26cb24ccbd88e5069f5ef00eba804d36dde11d99aad0838ab45/charset_normalizer-3.4.6-py3-none-any.whl", hash = "sha256:947cf925bc916d90adba35a64c82aace04fa39b46b52d4630ece166655905a69", size = 61455, upload-time = "2026-03-15T18:53:23.833Z" }, +] + +[[package]] +name = "click" +version = "8.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "coverage" +version = "7.13.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/e0/70553e3000e345daff267cec284ce4cbf3fc141b6da229ac52775b5428f1/coverage-7.13.5.tar.gz", hash = "sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179", size = 915967, upload-time = "2026-03-17T10:33:18.341Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/77/39703f0d1d4b478bfd30191d3c14f53caf596fac00efb3f8f6ee23646439/coverage-7.13.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fbabfaceaeb587e16f7008f7795cd80d20ec548dc7f94fbb0d4ec2e038ce563f", size = 219621, upload-time = "2026-03-17T10:32:08.589Z" }, + { url = "https://files.pythonhosted.org/packages/e2/3e/51dff36d99ae14639a133d9b164d63e628532e2974d8b1edb99dd1ebc733/coverage-7.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9bb2a28101a443669a423b665939381084412b81c3f8c0fcfbac57f4e30b5b8e", size = 219953, upload-time = "2026-03-17T10:32:10.507Z" }, + { url = "https://files.pythonhosted.org/packages/6a/6c/1f1917b01eb647c2f2adc9962bd66c79eb978951cab61bdc1acab3290c07/coverage-7.13.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bd3a2fbc1c6cccb3c5106140d87cc6a8715110373ef42b63cf5aea29df8c217a", size = 250992, upload-time = "2026-03-17T10:32:12.41Z" }, + { url = "https://files.pythonhosted.org/packages/22/e5/06b1f88f42a5a99df42ce61208bdec3bddb3d261412874280a19796fc09c/coverage-7.13.5-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6c36ddb64ed9d7e496028d1d00dfec3e428e0aabf4006583bb1839958d280510", size = 253503, upload-time = "2026-03-17T10:32:14.449Z" }, + { url = "https://files.pythonhosted.org/packages/80/28/2a148a51e5907e504fa7b85490277734e6771d8844ebcc48764a15e28155/coverage-7.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:380e8e9084d8eb38db3a9176a1a4f3c0082c3806fa0dc882d1d87abc3c789247", size = 254852, upload-time = "2026-03-17T10:32:16.56Z" }, + { url = "https://files.pythonhosted.org/packages/61/77/50e8d3d85cc0b7ebe09f30f151d670e302c7ff4a1bf6243f71dd8b0981fa/coverage-7.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e808af52a0513762df4d945ea164a24b37f2f518cbe97e03deaa0ee66139b4d6", size = 257161, upload-time = "2026-03-17T10:32:19.004Z" }, + { url = "https://files.pythonhosted.org/packages/3b/c4/b5fd1d4b7bf8d0e75d997afd3925c59ba629fc8616f1b3aae7605132e256/coverage-7.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e301d30dd7e95ae068671d746ba8c34e945a82682e62918e41b2679acd2051a0", size = 251021, upload-time = "2026-03-17T10:32:21.344Z" }, + { url = "https://files.pythonhosted.org/packages/f8/66/6ea21f910e92d69ef0b1c3346ea5922a51bad4446c9126db2ae96ee24c4c/coverage-7.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:800bc829053c80d240a687ceeb927a94fd108bbdc68dfbe505d0d75ab578a882", size = 252858, upload-time = "2026-03-17T10:32:23.506Z" }, + { url = "https://files.pythonhosted.org/packages/9e/ea/879c83cb5d61aa2a35fb80e72715e92672daef8191b84911a643f533840c/coverage-7.13.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:0b67af5492adb31940ee418a5a655c28e48165da5afab8c7fa6fd72a142f8740", size = 250823, upload-time = "2026-03-17T10:32:25.516Z" }, + { url = "https://files.pythonhosted.org/packages/8a/fb/616d95d3adb88b9803b275580bdeee8bd1b69a886d057652521f83d7322f/coverage-7.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c9136ff29c3a91e25b1d1552b5308e53a1e0653a23e53b6366d7c2dcbbaf8a16", size = 255099, upload-time = "2026-03-17T10:32:27.944Z" }, + { url = "https://files.pythonhosted.org/packages/1c/93/25e6917c90ec1c9a56b0b26f6cad6408e5f13bb6b35d484a0d75c9cf000d/coverage-7.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:cff784eef7f0b8f6cb28804fbddcfa99f89efe4cc35fb5627e3ac58f91ed3ac0", size = 250638, upload-time = "2026-03-17T10:32:29.914Z" }, + { url = "https://files.pythonhosted.org/packages/fc/7b/dc1776b0464145a929deed214aef9fb1493f159b59ff3c7eeeedf91eddd0/coverage-7.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:68a4953be99b17ac3c23b6efbc8a38330d99680c9458927491d18700ef23ded0", size = 252295, upload-time = "2026-03-17T10:32:31.981Z" }, + { url = "https://files.pythonhosted.org/packages/ea/fb/99cbbc56a26e07762a2740713f3c8f9f3f3106e3a3dd8cc4474954bccd34/coverage-7.13.5-cp314-cp314-win32.whl", hash = "sha256:35a31f2b1578185fbe6aa2e74cea1b1d0bbf4c552774247d9160d29b80ed56cc", size = 222360, upload-time = "2026-03-17T10:32:34.233Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b7/4758d4f73fb536347cc5e4ad63662f9d60ba9118cb6785e9616b2ce5d7fa/coverage-7.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:2aa055ae1857258f9e0045be26a6d62bdb47a72448b62d7b55f4820f361a2633", size = 223174, upload-time = "2026-03-17T10:32:36.369Z" }, + { url = "https://files.pythonhosted.org/packages/2c/f2/24d84e1dfe70f8ac9fdf30d338239860d0d1d5da0bda528959d0ebc9da28/coverage-7.13.5-cp314-cp314-win_arm64.whl", hash = "sha256:1b11eef33edeae9d142f9b4358edb76273b3bfd30bc3df9a4f95d0e49caf94e8", size = 221739, upload-time = "2026-03-17T10:32:38.736Z" }, + { url = "https://files.pythonhosted.org/packages/60/5b/4a168591057b3668c2428bff25dd3ebc21b629d666d90bcdfa0217940e84/coverage-7.13.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10a0c37f0b646eaff7cce1874c31d1f1ccb297688d4c747291f4f4c70741cc8b", size = 220351, upload-time = "2026-03-17T10:32:41.196Z" }, + { url = "https://files.pythonhosted.org/packages/f5/21/1fd5c4dbfe4a58b6b99649125635df46decdfd4a784c3cd6d410d303e370/coverage-7.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b5db73ba3c41c7008037fa731ad5459fc3944cb7452fc0aa9f822ad3533c583c", size = 220612, upload-time = "2026-03-17T10:32:43.204Z" }, + { url = "https://files.pythonhosted.org/packages/d6/fe/2a924b3055a5e7e4512655a9d4609781b0d62334fa0140c3e742926834e2/coverage-7.13.5-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:750db93a81e3e5a9831b534be7b1229df848b2e125a604fe6651e48aa070e5f9", size = 261985, upload-time = "2026-03-17T10:32:45.514Z" }, + { url = "https://files.pythonhosted.org/packages/d7/0d/c8928f2bd518c45990fe1a2ab8db42e914ef9b726c975facc4282578c3eb/coverage-7.13.5-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ddb4f4a5479f2539644be484da179b653273bca1a323947d48ab107b3ed1f29", size = 264107, upload-time = "2026-03-17T10:32:47.971Z" }, + { url = "https://files.pythonhosted.org/packages/ef/ae/4ae35bbd9a0af9d820362751f0766582833c211224b38665c0f8de3d487f/coverage-7.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8a7a2049c14f413163e2bdabd37e41179b1d1ccb10ffc6ccc4b7a718429c607", size = 266513, upload-time = "2026-03-17T10:32:50.1Z" }, + { url = "https://files.pythonhosted.org/packages/9c/20/d326174c55af36f74eac6ae781612d9492f060ce8244b570bb9d50d9d609/coverage-7.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1c85e0b6c05c592ea6d8768a66a254bfb3874b53774b12d4c89c481eb78cb90", size = 267650, upload-time = "2026-03-17T10:32:52.391Z" }, + { url = "https://files.pythonhosted.org/packages/7a/5e/31484d62cbd0eabd3412e30d74386ece4a0837d4f6c3040a653878bfc019/coverage-7.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:777c4d1eff1b67876139d24288aaf1817f6c03d6bae9c5cc8d27b83bcfe38fe3", size = 261089, upload-time = "2026-03-17T10:32:54.544Z" }, + { url = "https://files.pythonhosted.org/packages/e9/d8/49a72d6de146eebb0b7e48cc0f4bc2c0dd858e3d4790ab2b39a2872b62bd/coverage-7.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6697e29b93707167687543480a40f0db8f356e86d9f67ddf2e37e2dfd91a9dab", size = 263982, upload-time = "2026-03-17T10:32:56.803Z" }, + { url = "https://files.pythonhosted.org/packages/06/3b/0351f1bd566e6e4dd39e978efe7958bde1d32f879e85589de147654f57bb/coverage-7.13.5-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8fdf453a942c3e4d99bd80088141c4c6960bb232c409d9c3558e2dbaa3998562", size = 261579, upload-time = "2026-03-17T10:32:59.466Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ce/796a2a2f4017f554d7810f5c573449b35b1e46788424a548d4d19201b222/coverage-7.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:32ca0c0114c9834a43f045a87dcebd69d108d8ffb666957ea65aa132f50332e2", size = 265316, upload-time = "2026-03-17T10:33:01.847Z" }, + { url = "https://files.pythonhosted.org/packages/3d/16/d5ae91455541d1a78bc90abf495be600588aff8f6db5c8b0dae739fa39c9/coverage-7.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8769751c10f339021e2638cd354e13adeac54004d1941119b2c96fe5276d45ea", size = 260427, upload-time = "2026-03-17T10:33:03.945Z" }, + { url = "https://files.pythonhosted.org/packages/48/11/07f413dba62db21fb3fad5d0de013a50e073cc4e2dc4306e770360f6dfc8/coverage-7.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cec2d83125531bd153175354055cdb7a09987af08a9430bd173c937c6d0fba2a", size = 262745, upload-time = "2026-03-17T10:33:06.285Z" }, + { url = "https://files.pythonhosted.org/packages/91/15/d792371332eb4663115becf4bad47e047d16234b1aff687b1b18c58d60ae/coverage-7.13.5-cp314-cp314t-win32.whl", hash = "sha256:0cd9ed7a8b181775459296e402ca4fb27db1279740a24e93b3b41942ebe4b215", size = 223146, upload-time = "2026-03-17T10:33:08.756Z" }, + { url = "https://files.pythonhosted.org/packages/db/51/37221f59a111dca5e85be7dbf09696323b5b9f13ff65e0641d535ed06ea8/coverage-7.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:301e3b7dfefecaca37c9f1aa6f0049b7d4ab8dd933742b607765d757aca77d43", size = 224254, upload-time = "2026-03-17T10:33:11.174Z" }, + { url = "https://files.pythonhosted.org/packages/54/83/6acacc889de8987441aa7d5adfbdbf33d288dad28704a67e574f1df9bcbb/coverage-7.13.5-cp314-cp314t-win_arm64.whl", hash = "sha256:9dacc2ad679b292709e0f5fc1ac74a6d4d5562e424058962c7bb0c658ad25e45", size = 222276, upload-time = "2026-03-17T10:33:13.466Z" }, + { url = "https://files.pythonhosted.org/packages/9e/ee/a4cf96b8ce1e566ed238f0659ac2d3f007ed1d14b181bcb684e19561a69a/coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61", size = 211346, upload-time = "2026-03-17T10:33:15.691Z" }, +] + +[[package]] +name = "filelock" +version = "3.25.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/b8/00651a0f559862f3bb7d6f7477b192afe3f583cc5e26403b44e59a55ab34/filelock-3.25.2.tar.gz", hash = "sha256:b64ece2b38f4ca29dd3e810287aa8c48182bbecd1ae6e9ae126c9b35f1382694", size = 40480, upload-time = "2026-03-11T20:45:38.487Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/a5/842ae8f0c08b61d6484b52f99a03510a3a72d23141942d216ebe81fefbce/filelock-3.25.2-py3-none-any.whl", hash = "sha256:ca8afb0da15f229774c9ad1b455ed96e85a81373065fb10446672f64444ddf70", size = 26759, upload-time = "2026-03-11T20:45:37.437Z" }, +] + +[[package]] +name = "greenlet" +version = "3.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/51/1664f6b78fc6ebbd98019a1fd730e83fa78f2db7058f72b1463d3612b8db/greenlet-3.3.2.tar.gz", hash = "sha256:2eaf067fc6d886931c7962e8c6bede15d2f01965560f3359b27c80bde2d151f2", size = 188267, upload-time = "2026-02-20T20:54:15.531Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/ae/8bffcbd373b57a5992cd077cbe8858fff39110480a9d50697091faea6f39/greenlet-3.3.2-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8d1658d7291f9859beed69a776c10822a0a799bc4bfe1bd4272bb60e62507dab", size = 279650, upload-time = "2026-02-20T20:18:00.783Z" }, + { url = "https://files.pythonhosted.org/packages/d1/c0/45f93f348fa49abf32ac8439938726c480bd96b2a3c6f4d949ec0124b69f/greenlet-3.3.2-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18cb1b7337bca281915b3c5d5ae19f4e76d35e1df80f4ad3c1a7be91fadf1082", size = 650295, upload-time = "2026-02-20T20:47:34.036Z" }, + { url = "https://files.pythonhosted.org/packages/b3/de/dd7589b3f2b8372069ab3e4763ea5329940fc7ad9dcd3e272a37516d7c9b/greenlet-3.3.2-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2e47408e8ce1c6f1ceea0dffcdf6ebb85cc09e55c7af407c99f1112016e45e9", size = 662163, upload-time = "2026-02-20T20:56:01.295Z" }, + { url = "https://files.pythonhosted.org/packages/cd/ac/85804f74f1ccea31ba518dcc8ee6f14c79f73fe36fa1beba38930806df09/greenlet-3.3.2-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e3cb43ce200f59483eb82949bf1835a99cf43d7571e900d7c8d5c62cdf25d2f9", size = 675371, upload-time = "2026-02-20T21:02:49.664Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d8/09bfa816572a4d83bccd6750df1926f79158b1c36c5f73786e26dbe4ee38/greenlet-3.3.2-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63d10328839d1973e5ba35e98cccbca71b232b14051fd957b6f8b6e8e80d0506", size = 664160, upload-time = "2026-02-20T20:21:04.015Z" }, + { url = "https://files.pythonhosted.org/packages/48/cf/56832f0c8255d27f6c35d41b5ec91168d74ec721d85f01a12131eec6b93c/greenlet-3.3.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8e4ab3cfb02993c8cc248ea73d7dae6cec0253e9afa311c9b37e603ca9fad2ce", size = 1619181, upload-time = "2026-02-20T20:49:36.052Z" }, + { url = "https://files.pythonhosted.org/packages/0a/23/b90b60a4aabb4cec0796e55f25ffbfb579a907c3898cd2905c8918acaa16/greenlet-3.3.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94ad81f0fd3c0c0681a018a976e5c2bd2ca2d9d94895f23e7bb1af4e8af4e2d5", size = 1687713, upload-time = "2026-02-20T20:21:11.684Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ca/2101ca3d9223a1dc125140dbc063644dca76df6ff356531eb27bc267b446/greenlet-3.3.2-cp314-cp314-win_amd64.whl", hash = "sha256:8c4dd0f3997cf2512f7601563cc90dfb8957c0cff1e3a1b23991d4ea1776c492", size = 232034, upload-time = "2026-02-20T20:20:08.186Z" }, + { url = "https://files.pythonhosted.org/packages/f6/4a/ecf894e962a59dea60f04877eea0fd5724618da89f1867b28ee8b91e811f/greenlet-3.3.2-cp314-cp314-win_arm64.whl", hash = "sha256:cd6f9e2bbd46321ba3bbb4c8a15794d32960e3b0ae2cc4d49a1a53d314805d71", size = 231437, upload-time = "2026-02-20T20:18:59.722Z" }, + { url = "https://files.pythonhosted.org/packages/98/6d/8f2ef704e614bcf58ed43cfb8d87afa1c285e98194ab2cfad351bf04f81e/greenlet-3.3.2-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:e26e72bec7ab387ac80caa7496e0f908ff954f31065b0ffc1f8ecb1338b11b54", size = 286617, upload-time = "2026-02-20T20:19:29.856Z" }, + { url = "https://files.pythonhosted.org/packages/5e/0d/93894161d307c6ea237a43988f27eba0947b360b99ac5239ad3fe09f0b47/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b466dff7a4ffda6ca975979bab80bdadde979e29fc947ac3be4451428d8b0e4", size = 655189, upload-time = "2026-02-20T20:47:35.742Z" }, + { url = "https://files.pythonhosted.org/packages/f5/2c/d2d506ebd8abcb57386ec4f7ba20f4030cbe56eae541bc6fd6ef399c0b41/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b8bddc5b73c9720bea487b3bffdb1840fe4e3656fba3bd40aa1489e9f37877ff", size = 658225, upload-time = "2026-02-20T20:56:02.527Z" }, + { url = "https://files.pythonhosted.org/packages/d1/67/8197b7e7e602150938049d8e7f30de1660cfb87e4c8ee349b42b67bdb2e1/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:59b3e2c40f6706b05a9cd299c836c6aa2378cabe25d021acd80f13abf81181cf", size = 666581, upload-time = "2026-02-20T21:02:51.526Z" }, + { url = "https://files.pythonhosted.org/packages/8e/30/3a09155fbf728673a1dea713572d2d31159f824a37c22da82127056c44e4/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b26b0f4428b871a751968285a1ac9648944cea09807177ac639b030bddebcea4", size = 657907, upload-time = "2026-02-20T20:21:05.259Z" }, + { url = "https://files.pythonhosted.org/packages/f3/fd/d05a4b7acd0154ed758797f0a43b4c0962a843bedfe980115e842c5b2d08/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1fb39a11ee2e4d94be9a76671482be9398560955c9e568550de0224e41104727", size = 1618857, upload-time = "2026-02-20T20:49:37.309Z" }, + { url = "https://files.pythonhosted.org/packages/6f/e1/50ee92a5db521de8f35075b5eff060dd43d39ebd46c2181a2042f7070385/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:20154044d9085151bc309e7689d6f7ba10027f8f5a8c0676ad398b951913d89e", size = 1680010, upload-time = "2026-02-20T20:21:13.427Z" }, + { url = "https://files.pythonhosted.org/packages/29/4b/45d90626aef8e65336bed690106d1382f7a43665e2249017e9527df8823b/greenlet-3.3.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c04c5e06ec3e022cbfe2cd4a846e1d4e50087444f875ff6d2c2ad8445495cf1a", size = 237086, upload-time = "2026-02-20T20:20:45.786Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "mloader-ng" +version = "2.0.3" +source = { editable = "." } +dependencies = [ + { name = "click" }, + { name = "filelock" }, + { name = "pillow" }, + { name = "playwright" }, + { name = "protobuf" }, + { name = "python-dotenv" }, + { name = "requests" }, + { name = "urllib3" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "ruff" }, + { name = "ty" }, +] + +[package.metadata] +requires-dist = [ + { name = "click", specifier = ">=8.3.1" }, + { name = "filelock", specifier = ">=3.20.0" }, + { name = "pillow", specifier = ">=12.0.0" }, + { name = "playwright", specifier = ">=1.55.0" }, + { name = "protobuf", specifier = ">=6.33.5,<7" }, + { name = "python-dotenv", specifier = ">=1.2.1" }, + { name = "requests", specifier = ">=2.32.5" }, + { name = "urllib3", specifier = ">=2.5.0" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=8.4.0" }, + { name = "pytest-cov", specifier = ">=6.2.0" }, + { name = "ruff", specifier = ">=0.13.0" }, + { name = "ty", specifier = ">=0.0.24" }, +] + +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, +] + +[[package]] +name = "pillow" +version = "12.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1f/42/5c74462b4fd957fcd7b13b04fb3205ff8349236ea74c7c375766d6c82288/pillow-12.1.1.tar.gz", hash = "sha256:9ad8fa5937ab05218e2b6a4cff30295ad35afd2f83ac592e68c0d871bb0fdbc4", size = 46980264, upload-time = "2026-02-11T04:23:07.146Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/d0/bebb3ffbf31c5a8e97241476c4cf8b9828954693ce6744b4a2326af3e16b/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:417423db963cb4be8bac3fc1204fe61610f6abeed1580a7a2cbb2fbda20f12af", size = 4062652, upload-time = "2026-02-11T04:21:53.19Z" }, + { url = "https://files.pythonhosted.org/packages/2d/c0/0e16fb0addda4851445c28f8350d8c512f09de27bbb0d6d0bbf8b6709605/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:b957b71c6b2387610f556a7eb0828afbe40b4a98036fc0d2acfa5a44a0c2036f", size = 4138823, upload-time = "2026-02-11T04:22:03.088Z" }, + { url = "https://files.pythonhosted.org/packages/6b/fb/6170ec655d6f6bb6630a013dd7cf7bc218423d7b5fa9071bf63dc32175ae/pillow-12.1.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:097690ba1f2efdeb165a20469d59d8bb03c55fb6621eb2041a060ae8ea3e9642", size = 3601143, upload-time = "2026-02-11T04:22:04.909Z" }, + { url = "https://files.pythonhosted.org/packages/59/04/dc5c3f297510ba9a6837cbb318b87dd2b8f73eb41a43cc63767f65cb599c/pillow-12.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2815a87ab27848db0321fb78c7f0b2c8649dee134b7f2b80c6a45c6831d75ccd", size = 5266254, upload-time = "2026-02-11T04:22:07.656Z" }, + { url = "https://files.pythonhosted.org/packages/05/30/5db1236b0d6313f03ebf97f5e17cda9ca060f524b2fcc875149a8360b21c/pillow-12.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f7ed2c6543bad5a7d5530eb9e78c53132f93dfa44a28492db88b41cdab885202", size = 4657499, upload-time = "2026-02-11T04:22:09.613Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/008d2ca0eb612e81968e8be0bbae5051efba24d52debf930126d7eaacbba/pillow-12.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:652a2c9ccfb556235b2b501a3a7cf3742148cd22e04b5625c5fe057ea3e3191f", size = 6232137, upload-time = "2026-02-11T04:22:11.434Z" }, + { url = "https://files.pythonhosted.org/packages/70/f1/f14d5b8eeb4b2cd62b9f9f847eb6605f103df89ef619ac68f92f748614ea/pillow-12.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d6e4571eedf43af33d0fc233a382a76e849badbccdf1ac438841308652a08e1f", size = 8042721, upload-time = "2026-02-11T04:22:13.321Z" }, + { url = "https://files.pythonhosted.org/packages/5a/d6/17824509146e4babbdabf04d8171491fa9d776f7061ff6e727522df9bd03/pillow-12.1.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b574c51cf7d5d62e9be37ba446224b59a2da26dc4c1bb2ecbe936a4fb1a7cb7f", size = 6347798, upload-time = "2026-02-11T04:22:15.449Z" }, + { url = "https://files.pythonhosted.org/packages/d1/ee/c85a38a9ab92037a75615aba572c85ea51e605265036e00c5b67dfafbfe2/pillow-12.1.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a37691702ed687799de29a518d63d4682d9016932db66d4e90c345831b02fb4e", size = 7039315, upload-time = "2026-02-11T04:22:17.24Z" }, + { url = "https://files.pythonhosted.org/packages/ec/f3/bc8ccc6e08a148290d7523bde4d9a0d6c981db34631390dc6e6ec34cacf6/pillow-12.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f95c00d5d6700b2b890479664a06e754974848afaae5e21beb4d83c106923fd0", size = 6462360, upload-time = "2026-02-11T04:22:19.111Z" }, + { url = "https://files.pythonhosted.org/packages/f6/ab/69a42656adb1d0665ab051eec58a41f169ad295cf81ad45406963105408f/pillow-12.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:559b38da23606e68681337ad74622c4dbba02254fc9cb4488a305dd5975c7eeb", size = 7165438, upload-time = "2026-02-11T04:22:21.041Z" }, + { url = "https://files.pythonhosted.org/packages/02/46/81f7aa8941873f0f01d4b55cc543b0a3d03ec2ee30d617a0448bf6bd6dec/pillow-12.1.1-cp314-cp314-win32.whl", hash = "sha256:03edcc34d688572014ff223c125a3f77fb08091e4607e7745002fc214070b35f", size = 6431503, upload-time = "2026-02-11T04:22:22.833Z" }, + { url = "https://files.pythonhosted.org/packages/40/72/4c245f7d1044b67affc7f134a09ea619d4895333d35322b775b928180044/pillow-12.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:50480dcd74fa63b8e78235957d302d98d98d82ccbfac4c7e12108ba9ecbdba15", size = 7176748, upload-time = "2026-02-11T04:22:24.64Z" }, + { url = "https://files.pythonhosted.org/packages/e4/ad/8a87bdbe038c5c698736e3348af5c2194ffb872ea52f11894c95f9305435/pillow-12.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:5cb1785d97b0c3d1d1a16bc1d710c4a0049daefc4935f3a8f31f827f4d3d2e7f", size = 2544314, upload-time = "2026-02-11T04:22:26.685Z" }, + { url = "https://files.pythonhosted.org/packages/6c/9d/efd18493f9de13b87ede7c47e69184b9e859e4427225ea962e32e56a49bc/pillow-12.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1f90cff8aa76835cba5769f0b3121a22bd4eb9e6884cfe338216e557a9a548b8", size = 5268612, upload-time = "2026-02-11T04:22:29.884Z" }, + { url = "https://files.pythonhosted.org/packages/f8/f1/4f42eb2b388eb2ffc660dcb7f7b556c1015c53ebd5f7f754965ef997585b/pillow-12.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1f1be78ce9466a7ee64bfda57bdba0f7cc499d9794d518b854816c41bf0aa4e9", size = 4660567, upload-time = "2026-02-11T04:22:31.799Z" }, + { url = "https://files.pythonhosted.org/packages/01/54/df6ef130fa43e4b82e32624a7b821a2be1c5653a5fdad8469687a7db4e00/pillow-12.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:42fc1f4677106188ad9a55562bbade416f8b55456f522430fadab3cef7cd4e60", size = 6269951, upload-time = "2026-02-11T04:22:33.921Z" }, + { url = "https://files.pythonhosted.org/packages/a9/48/618752d06cc44bb4aae8ce0cd4e6426871929ed7b46215638088270d9b34/pillow-12.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:98edb152429ab62a1818039744d8fbb3ccab98a7c29fc3d5fcef158f3f1f68b7", size = 8074769, upload-time = "2026-02-11T04:22:35.877Z" }, + { url = "https://files.pythonhosted.org/packages/c3/bd/f1d71eb39a72fa088d938655afba3e00b38018d052752f435838961127d8/pillow-12.1.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d470ab1178551dd17fdba0fef463359c41aaa613cdcd7ff8373f54be629f9f8f", size = 6381358, upload-time = "2026-02-11T04:22:37.698Z" }, + { url = "https://files.pythonhosted.org/packages/64/ef/c784e20b96674ed36a5af839305f55616f8b4f8aa8eeccf8531a6e312243/pillow-12.1.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6408a7b064595afcab0a49393a413732a35788f2a5092fdc6266952ed67de586", size = 7068558, upload-time = "2026-02-11T04:22:39.597Z" }, + { url = "https://files.pythonhosted.org/packages/73/cb/8059688b74422ae61278202c4e1ad992e8a2e7375227be0a21c6b87ca8d5/pillow-12.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5d8c41325b382c07799a3682c1c258469ea2ff97103c53717b7893862d0c98ce", size = 6493028, upload-time = "2026-02-11T04:22:42.73Z" }, + { url = "https://files.pythonhosted.org/packages/c6/da/e3c008ed7d2dd1f905b15949325934510b9d1931e5df999bb15972756818/pillow-12.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7697918b5be27424e9ce568193efd13d925c4481dd364e43f5dff72d33e10f8", size = 7191940, upload-time = "2026-02-11T04:22:44.543Z" }, + { url = "https://files.pythonhosted.org/packages/01/4a/9202e8d11714c1fc5951f2e1ef362f2d7fbc595e1f6717971d5dd750e969/pillow-12.1.1-cp314-cp314t-win32.whl", hash = "sha256:d2912fd8114fc5545aa3a4b5576512f64c55a03f3ebcca4c10194d593d43ea36", size = 6438736, upload-time = "2026-02-11T04:22:46.347Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ca/cbce2327eb9885476b3957b2e82eb12c866a8b16ad77392864ad601022ce/pillow-12.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:4ceb838d4bd9dab43e06c363cab2eebf63846d6a4aeaea283bbdfd8f1a8ed58b", size = 7182894, upload-time = "2026-02-11T04:22:48.114Z" }, + { url = "https://files.pythonhosted.org/packages/ec/d2/de599c95ba0a973b94410477f8bf0b6f0b5e67360eb89bcb1ad365258beb/pillow-12.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:7b03048319bfc6170e93bd60728a1af51d3dd7704935feb228c4d4faab35d334", size = 2546446, upload-time = "2026-02-11T04:22:50.342Z" }, +] + +[[package]] +name = "playwright" +version = "1.58.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet" }, + { name = "pyee" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/c9/9c6061d5703267f1baae6a4647bfd1862e386fbfdb97d889f6f6ae9e3f64/playwright-1.58.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:96e3204aac292ee639edbfdef6298b4be2ea0a55a16b7068df91adac077cc606", size = 42251098, upload-time = "2026-01-30T15:09:24.028Z" }, + { url = "https://files.pythonhosted.org/packages/e0/40/59d34a756e02f8c670f0fee987d46f7ee53d05447d43cd114ca015cb168c/playwright-1.58.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:70c763694739d28df71ed578b9c8202bb83e8fe8fb9268c04dd13afe36301f71", size = 41039625, upload-time = "2026-01-30T15:09:27.558Z" }, + { url = "https://files.pythonhosted.org/packages/e1/ee/3ce6209c9c74a650aac9028c621f357a34ea5cd4d950700f8e2c4b7fe2c4/playwright-1.58.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:185e0132578733d02802dfddfbbc35f42be23a45ff49ccae5081f25952238117", size = 42251098, upload-time = "2026-01-30T15:09:30.461Z" }, + { url = "https://files.pythonhosted.org/packages/f1/af/009958cbf23fac551a940d34e3206e6c7eed2b8c940d0c3afd1feb0b0589/playwright-1.58.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:c95568ba1eda83812598c1dc9be60b4406dffd60b149bc1536180ad108723d6b", size = 46235268, upload-time = "2026-01-30T15:09:33.787Z" }, + { url = "https://files.pythonhosted.org/packages/d9/a6/0e66ad04b6d3440dae73efb39540c5685c5fc95b17c8b29340b62abbd952/playwright-1.58.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f9999948f1ab541d98812de25e3a8c410776aa516d948807140aff797b4bffa", size = 45964214, upload-time = "2026-01-30T15:09:36.751Z" }, + { url = "https://files.pythonhosted.org/packages/0e/4b/236e60ab9f6d62ed0fd32150d61f1f494cefbf02304c0061e78ed80c1c32/playwright-1.58.0-py3-none-win32.whl", hash = "sha256:1e03be090e75a0fabbdaeab65ce17c308c425d879fa48bb1d7986f96bfad0b99", size = 36815998, upload-time = "2026-01-30T15:09:39.627Z" }, + { url = "https://files.pythonhosted.org/packages/41/f8/5ec599c5e59d2f2f336a05b4f318e733077cd5044f24adb6f86900c3e6a7/playwright-1.58.0-py3-none-win_amd64.whl", hash = "sha256:a2bf639d0ce33b3ba38de777e08697b0d8f3dc07ab6802e4ac53fb65e3907af8", size = 36816005, upload-time = "2026-01-30T15:09:42.449Z" }, + { url = "https://files.pythonhosted.org/packages/c8/c4/cc0229fea55c87d6c9c67fe44a21e2cd28d1d558a5478ed4d617e9fb0c93/playwright-1.58.0-py3-none-win_arm64.whl", hash = "sha256:32ffe5c303901a13a0ecab91d1c3f74baf73b84f4bedbb6b935f5bc11cc98e1b", size = 33085919, upload-time = "2026-01-30T15:09:45.71Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "protobuf" +version = "6.33.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/66/70/e908e9c5e52ef7c3a6c7902c9dfbb34c7e29c25d2f81ade3856445fd5c94/protobuf-6.33.6.tar.gz", hash = "sha256:a6768d25248312c297558af96a9f9c929e8c4cee0659cb07e780731095f38135", size = 444531, upload-time = "2026-03-18T19:05:00.988Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/9f/2f509339e89cfa6f6a4c4ff50438db9ca488dec341f7e454adad60150b00/protobuf-6.33.6-cp310-abi3-win32.whl", hash = "sha256:7d29d9b65f8afef196f8334e80d6bc1d5d4adedb449971fefd3723824e6e77d3", size = 425739, upload-time = "2026-03-18T19:04:48.373Z" }, + { url = "https://files.pythonhosted.org/packages/76/5d/683efcd4798e0030c1bab27374fd13a89f7c2515fb1f3123efdfaa5eab57/protobuf-6.33.6-cp310-abi3-win_amd64.whl", hash = "sha256:0cd27b587afca21b7cfa59a74dcbd48a50f0a6400cfb59391340ad729d91d326", size = 437089, upload-time = "2026-03-18T19:04:50.381Z" }, + { url = "https://files.pythonhosted.org/packages/5c/01/a3c3ed5cd186f39e7880f8303cc51385a198a81469d53d0fdecf1f64d929/protobuf-6.33.6-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:9720e6961b251bde64edfdab7d500725a2af5280f3f4c87e57c0208376aa8c3a", size = 427737, upload-time = "2026-03-18T19:04:51.866Z" }, + { url = "https://files.pythonhosted.org/packages/ee/90/b3c01fdec7d2f627b3a6884243ba328c1217ed2d978def5c12dc50d328a3/protobuf-6.33.6-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:e2afbae9b8e1825e3529f88d514754e094278bb95eadc0e199751cdd9a2e82a2", size = 324610, upload-time = "2026-03-18T19:04:53.096Z" }, + { url = "https://files.pythonhosted.org/packages/9b/ca/25afc144934014700c52e05103c2421997482d561f3101ff352e1292fb81/protobuf-6.33.6-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:c96c37eec15086b79762ed265d59ab204dabc53056e3443e702d2681f4b39ce3", size = 339381, upload-time = "2026-03-18T19:04:54.616Z" }, + { url = "https://files.pythonhosted.org/packages/16/92/d1e32e3e0d894fe00b15ce28ad4944ab692713f2e7f0a99787405e43533a/protobuf-6.33.6-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:e9db7e292e0ab79dd108d7f1a94fe31601ce1ee3f7b79e0692043423020b0593", size = 323436, upload-time = "2026-03-18T19:04:55.768Z" }, + { url = "https://files.pythonhosted.org/packages/c4/72/02445137af02769918a93807b2b7890047c32bfb9f90371cbc12688819eb/protobuf-6.33.6-py3-none-any.whl", hash = "sha256:77179e006c476e69bf8e8ce866640091ec42e1beb80b213c3900006ecfba6901", size = 170656, upload-time = "2026-03-18T19:04:59.826Z" }, +] + +[[package]] +name = "pyee" +version = "13.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/04/e7c1fe4dc78a6fdbfd6c337b1c3732ff543b8a397683ab38378447baa331/pyee-13.0.1.tar.gz", hash = "sha256:0b931f7c14535667ed4c7e0d531716368715e860b988770fc7eb8578d1f67fc8", size = 31655, upload-time = "2026-02-14T21:12:28.044Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/c4/b4d4827c93ef43c01f599ef31453ccc1c132b353284fc6c87d535c233129/pyee-13.0.1-py3-none-any.whl", hash = "sha256:af2f8fede4171ef667dfded53f96e2ed0d6e6bd7ee3bb46437f77e3b57689228", size = 15659, upload-time = "2026-02-14T21:12:26.263Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage" }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + +[[package]] +name = "requests" +version = "2.32.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/22/9e4f66ee588588dc6c9af6a994e12d26e19efbe874d1a909d09a6dac7a59/ruff-0.15.7.tar.gz", hash = "sha256:04f1ae61fc20fe0b148617c324d9d009b5f63412c0b16474f3d5f1a1a665f7ac", size = 4601277, upload-time = "2026-03-19T16:26:22.605Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/2f/0b08ced94412af091807b6119ca03755d651d3d93a242682bf020189db94/ruff-0.15.7-py3-none-linux_armv6l.whl", hash = "sha256:a81cc5b6910fb7dfc7c32d20652e50fa05963f6e13ead3c5915c41ac5d16668e", size = 10489037, upload-time = "2026-03-19T16:26:32.47Z" }, + { url = "https://files.pythonhosted.org/packages/91/4a/82e0fa632e5c8b1eba5ee86ecd929e8ff327bbdbfb3c6ac5d81631bef605/ruff-0.15.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:722d165bd52403f3bdabc0ce9e41fc47070ac56d7a91b4e0d097b516a53a3477", size = 10955433, upload-time = "2026-03-19T16:27:00.205Z" }, + { url = "https://files.pythonhosted.org/packages/ab/10/12586735d0ff42526ad78c049bf51d7428618c8b5c467e72508c694119df/ruff-0.15.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7fbc2448094262552146cbe1b9643a92f66559d3761f1ad0656d4991491af49e", size = 10269302, upload-time = "2026-03-19T16:26:26.183Z" }, + { url = "https://files.pythonhosted.org/packages/eb/5d/32b5c44ccf149a26623671df49cbfbd0a0ae511ff3df9d9d2426966a8d57/ruff-0.15.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b39329b60eba44156d138275323cc726bbfbddcec3063da57caa8a8b1d50adf", size = 10607625, upload-time = "2026-03-19T16:27:03.263Z" }, + { url = "https://files.pythonhosted.org/packages/5d/f1/f0001cabe86173aaacb6eb9bb734aa0605f9a6aa6fa7d43cb49cbc4af9c9/ruff-0.15.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:87768c151808505f2bfc93ae44e5f9e7c8518943e5074f76ac21558ef5627c85", size = 10324743, upload-time = "2026-03-19T16:27:09.791Z" }, + { url = "https://files.pythonhosted.org/packages/7a/87/b8a8f3d56b8d848008559e7c9d8bf367934d5367f6d932ba779456e2f73b/ruff-0.15.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fb0511670002c6c529ec66c0e30641c976c8963de26a113f3a30456b702468b0", size = 11138536, upload-time = "2026-03-19T16:27:06.101Z" }, + { url = "https://files.pythonhosted.org/packages/e4/f2/4fd0d05aab0c5934b2e1464784f85ba2eab9d54bffc53fb5430d1ed8b829/ruff-0.15.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e0d19644f801849229db8345180a71bee5407b429dd217f853ec515e968a6912", size = 11994292, upload-time = "2026-03-19T16:26:48.718Z" }, + { url = "https://files.pythonhosted.org/packages/64/22/fc4483871e767e5e95d1622ad83dad5ebb830f762ed0420fde7dfa9d9b08/ruff-0.15.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4806d8e09ef5e84eb19ba833d0442f7e300b23fe3f0981cae159a248a10f0036", size = 11398981, upload-time = "2026-03-19T16:26:54.513Z" }, + { url = "https://files.pythonhosted.org/packages/b0/99/66f0343176d5eab02c3f7fcd2de7a8e0dd7a41f0d982bee56cd1c24db62b/ruff-0.15.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dce0896488562f09a27b9c91b1f58a097457143931f3c4d519690dea54e624c5", size = 11242422, upload-time = "2026-03-19T16:26:29.277Z" }, + { url = "https://files.pythonhosted.org/packages/5d/3a/a7060f145bfdcce4c987ea27788b30c60e2c81d6e9a65157ca8afe646328/ruff-0.15.7-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:1852ce241d2bc89e5dc823e03cff4ce73d816b5c6cdadd27dbfe7b03217d2a12", size = 11232158, upload-time = "2026-03-19T16:26:42.321Z" }, + { url = "https://files.pythonhosted.org/packages/a7/53/90fbb9e08b29c048c403558d3cdd0adf2668b02ce9d50602452e187cd4af/ruff-0.15.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:5f3e4b221fb4bd293f79912fc5e93a9063ebd6d0dcbd528f91b89172a9b8436c", size = 10577861, upload-time = "2026-03-19T16:26:57.459Z" }, + { url = "https://files.pythonhosted.org/packages/2f/aa/5f486226538fe4d0f0439e2da1716e1acf895e2a232b26f2459c55f8ddad/ruff-0.15.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b15e48602c9c1d9bdc504b472e90b90c97dc7d46c7028011ae67f3861ceba7b4", size = 10327310, upload-time = "2026-03-19T16:26:35.909Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/271afdffb81fe7bfc8c43ba079e9d96238f674380099457a74ccb3863857/ruff-0.15.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1b4705e0e85cedc74b0a23cf6a179dbb3df184cb227761979cc76c0440b5ab0d", size = 10840752, upload-time = "2026-03-19T16:26:45.723Z" }, + { url = "https://files.pythonhosted.org/packages/bf/29/a4ae78394f76c7759953c47884eb44de271b03a66634148d9f7d11e721bd/ruff-0.15.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:112c1fa316a558bb34319282c1200a8bf0495f1b735aeb78bfcb2991e6087580", size = 11336961, upload-time = "2026-03-19T16:26:39.076Z" }, + { url = "https://files.pythonhosted.org/packages/26/6b/8786ba5736562220d588a2f6653e6c17e90c59ced34a2d7b512ef8956103/ruff-0.15.7-py3-none-win32.whl", hash = "sha256:6d39e2d3505b082323352f733599f28169d12e891f7dd407f2d4f54b4c2886de", size = 10582538, upload-time = "2026-03-19T16:26:15.992Z" }, + { url = "https://files.pythonhosted.org/packages/2b/e9/346d4d3fffc6871125e877dae8d9a1966b254fbd92a50f8561078b88b099/ruff-0.15.7-py3-none-win_amd64.whl", hash = "sha256:4d53d712ddebcd7dace1bc395367aec12c057aacfe9adbb6d832302575f4d3a1", size = 11755839, upload-time = "2026-03-19T16:26:19.897Z" }, + { url = "https://files.pythonhosted.org/packages/8f/e8/726643a3ea68c727da31570bde48c7a10f1aa60eddd628d94078fec586ff/ruff-0.15.7-py3-none-win_arm64.whl", hash = "sha256:18e8d73f1c3fdf27931497972250340f92e8c861722161a9caeb89a58ead6ed2", size = 11023304, upload-time = "2026-03-19T16:26:51.669Z" }, +] + +[[package]] +name = "ty" +version = "0.0.24" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7a/96/652a425030f95dc2c9548d9019e52502e17079e1daeefbc4036f1c0905b4/ty-0.0.24.tar.gz", hash = "sha256:9fe42f6b98207bdaef51f71487d6d087f2cb02555ee3939884d779b2b3cc8bfc", size = 5354286, upload-time = "2026-03-19T16:55:57.035Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/e5/34457ee11708e734ba81ad65723af83030e484f961e281d57d1eecf08951/ty-0.0.24-py3-none-linux_armv6l.whl", hash = "sha256:1ab4f1f61334d533a3fdf5d9772b51b1300ac5da4f3cdb0be9657a3ccb2ce3e7", size = 10394877, upload-time = "2026-03-19T16:55:54.246Z" }, + { url = "https://files.pythonhosted.org/packages/44/81/bc9a1b1a87f43db15ab64ad781a4f999734ec3b470ad042624fa875b20e6/ty-0.0.24-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:facbf2c4aaa6985229e08f8f9bf152215eb078212f22b5c2411f35386688ab42", size = 10211109, upload-time = "2026-03-19T16:55:28.554Z" }, + { url = "https://files.pythonhosted.org/packages/e4/63/cfc805adeaa61d63ba3ea71127efa7d97c40ba36d97ee7bd957341d05107/ty-0.0.24-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b6d2a3b6d4470c483552a31e9b368c86f154dcc964bccb5406159dc9cd362246", size = 9694769, upload-time = "2026-03-19T16:55:34.309Z" }, + { url = "https://files.pythonhosted.org/packages/33/09/edc220726b6ec44a58900401f6b27140997ef15026b791e26b69a6e69eb5/ty-0.0.24-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0c94c25d0500939fd5f8f16ce41cbed5b20528702c1d649bf80300253813f0a2", size = 10176287, upload-time = "2026-03-19T16:55:37.17Z" }, + { url = "https://files.pythonhosted.org/packages/f8/bf/cbe2227be711e65017655d8ee4d050f4c92b113fb4dc4c3bd6a19d3a86d8/ty-0.0.24-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:89cbe7bc7df0fab02dbd8cda79b737df83f1ef7fb573b08c0ee043dc68cffb08", size = 10214832, upload-time = "2026-03-19T16:56:08.518Z" }, + { url = "https://files.pythonhosted.org/packages/af/1d/d15803ee47e9143d10e10bd81ccc14761d08758082bda402950685f0ddfe/ty-0.0.24-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:db2c5d269bcc9b764850c99f457b5018a79b3ef40ecfbc03344e65effd6cf743", size = 10709892, upload-time = "2026-03-19T16:56:05.727Z" }, + { url = "https://files.pythonhosted.org/packages/36/12/6db0d86c477147f67b9052de209421d76c3e855197b000c25fcbbe86b3a2/ty-0.0.24-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ba44512db5b97c3bbd59d93e11296e8548d0c9a3bdd1280de36d7ff22d351896", size = 11280872, upload-time = "2026-03-19T16:56:02.899Z" }, + { url = "https://files.pythonhosted.org/packages/1b/fc/155fe83a97c06d33ccc9e0f428258b32df2e08a428300c715d34757f0111/ty-0.0.24-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a52b7f589c3205512a9c50ba5b2b1e8c0698b72e51b8b9285c90420c06f1cae8", size = 11060520, upload-time = "2026-03-19T16:55:59.956Z" }, + { url = "https://files.pythonhosted.org/packages/ac/f1/32c05a1c4c3c2a95c5b7361dee03a9bf1231d4ad096b161c838b45bce5a0/ty-0.0.24-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7981df5c709c054da4ac5d7c93f8feb8f45e69e829e4461df4d5f0988fe67d04", size = 10791455, upload-time = "2026-03-19T16:55:25.728Z" }, + { url = "https://files.pythonhosted.org/packages/17/2c/53c1ea6bedfa4d4ab64d4de262d8f5e405ecbffefd364459c628c0310d33/ty-0.0.24-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:b2860151ad95a00d0f0280b8fef79900d08dcd63276b57e6e5774f2c055979c5", size = 10156708, upload-time = "2026-03-19T16:55:45.563Z" }, + { url = "https://files.pythonhosted.org/packages/45/39/7d2919cf194707169474d80720a5f3d793e983416f25e7ffcf80504c9df2/ty-0.0.24-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:5674a1146d927ab77ff198a88e0c4505134ced342a0e7d1beb4a076a728b7496", size = 10236263, upload-time = "2026-03-19T16:55:31.474Z" }, + { url = "https://files.pythonhosted.org/packages/cf/7f/48eac722f2fd12a5b7aae0effdcb75c46053f94b783d989e3ef0d7380082/ty-0.0.24-py3-none-musllinux_1_2_i686.whl", hash = "sha256:438ecbf1608a9b16dd84502f3f1b23ef2ef32bbd0ab3e0ca5a82f0e0d1cd41ea", size = 10402559, upload-time = "2026-03-19T16:55:39.602Z" }, + { url = "https://files.pythonhosted.org/packages/75/e0/8cf868b9749ce1e5166462759545964e95b02353243594062b927d8bff2a/ty-0.0.24-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:ddeed3098dd92a83964e7aa7b41e509ba3530eb539fc4cd8322ff64a09daf1f5", size = 10893684, upload-time = "2026-03-19T16:55:51.439Z" }, + { url = "https://files.pythonhosted.org/packages/17/9f/f54bf3be01d2c2ed731d10a5afa3324dc66f987a6ae0a4a6cbfa2323d080/ty-0.0.24-py3-none-win32.whl", hash = "sha256:83013fb3a4764a8f8bcc6ca11ff8bdfd8c5f719fc249241cb2b8916e80778eb1", size = 9781542, upload-time = "2026-03-19T16:56:11.588Z" }, + { url = "https://files.pythonhosted.org/packages/fb/49/c004c5cc258b10b3a145666e9a9c28ae7678bc958c8926e8078d5d769081/ty-0.0.24-py3-none-win_amd64.whl", hash = "sha256:748a60eb6912d1cf27aaab105ffadb6f4d2e458a3fcadfbd3cf26db0d8062eeb", size = 10764801, upload-time = "2026-03-19T16:55:42.752Z" }, + { url = "https://files.pythonhosted.org/packages/e2/59/006a074e185bfccf5e4c026015245ab4fcd2362b13a8d24cf37a277909a9/ty-0.0.24-py3-none-win_arm64.whl", hash = "sha256:280a3d31e86d0721947238f17030c33f0911cae851d108ea9f4e3ab12a5ed01f", size = 10194093, upload-time = "2026-03-19T16:55:48.303Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "urllib3" +version = "2.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, +]