[
  {
    "path": ".editorconfig",
    "content": "root = true\n\n[*]\ncharset = utf-8\nend_of_line = lf\ninsert_final_newline = true\ntrim_trailing_whitespace = true\n\n[**.py]\nindent_size = 4\nindent_style = space\nmax_line_length = 79\n\n[Makefile]\nindent_style = tab\n"
  },
  {
    "path": ".github/FUNDING.yml",
    "content": "ko_fi: mikefaehrmann\ncustom: https://www.paypal.me/mikefaehrmann\n"
  },
  {
    "path": ".github/workflows/docker.yml",
    "content": "name: Docker Images\n\non:\n  workflow_dispatch:\n  push:\n    branches:\n    - master\n    tags:\n    - v[0-9]+.[0-9]+.[0-9]+\n\n\npermissions:\n  packages: write\n\nconcurrency:\n  group: docker\n  cancel-in-progress: false\n\njobs:\n  build:\n\n    runs-on: ubuntu-latest\n\n    # on release commits, run only for tag event\n    if: ${{\n        github.repository == 'mikf/gallery-dl' &&\n        ( ! startsWith( github.event.head_commit.message , 'release version ' ) ||\n            startsWith( github.ref , 'refs/tags/v' ) )\n      }}\n\n    steps:\n    - uses: actions/checkout@v5\n\n    - uses: docker/metadata-action@v5\n      id: metadata\n      with:\n        images: |\n          mikf123/gallery-dl\n          ghcr.io/mikf/gallery-dl\n        tags: |\n          type=ref,event=tag\n          type=raw,value=dev\n          type=sha,format=long,prefix=\n          type=raw,priority=500,value={{date 'YYYY.MM.DD'}}\n\n    - uses: docker/setup-qemu-action@v3\n\n    - uses: docker/setup-buildx-action@v3\n\n    - name: Login to DockerHub\n      uses: docker/login-action@v3\n      with:\n        username: ${{ secrets.DOCKERHUB_USERNAME }}\n        password: ${{ secrets.DOCKERHUB_TOKEN }}\n\n    - name: Login to GitHub Container Registry\n      uses: docker/login-action@v3\n      with:\n        registry: ghcr.io\n        username: ${{ github.repository_owner }}\n        password: ${{ secrets.GHCR_TOKEN }}\n\n    - uses: docker/build-push-action@v5\n      with:\n        context: .\n        push: true\n        tags: ${{ steps.metadata.outputs.tags }}\n        labels: ${{ steps.metadata.outputs.labels }}\n        platforms: linux/amd64,linux/arm64\n"
  },
  {
    "path": ".github/workflows/executables.yml",
    "content": "name: Executables\n\non:\n  workflow_dispatch:\n  push:\n    branches:\n    - master\n    tags-ignore:\n    - \"*\"\n\nenv:\n  DATE_FORMAT: \"%Y.%m.%d\"\n\njobs:\n  build:\n\n    if: github.repository == 'mikf/gallery-dl'\n    runs-on: ${{ matrix.os }}\n    defaults:\n      run:\n        shell: bash\n    strategy:\n      fail-fast: false\n      matrix:\n        os: [\"windows-latest\", \"macOS-latest\"]\n        architecture: [\"x64\"]\n        python-version: [\"3.14\"]\n        python-packages: [\"\"]\n        include:\n        - os: \"ubuntu-latest\"\n          architecture: \"x64\"\n          python-version: \"3.13\"\n          python-packages: \"secretstorage\"\n        - os: \"windows-2022\"\n          architecture: \"x86\"\n          python-version: \"3.8\"\n          python-packages: \"toml\"\n\n    steps:\n    - uses: actions/checkout@v5\n\n    - name: Set up Python ${{ matrix.python-version }} ${{ matrix.architecture }}\n      uses: actions/setup-python@v6\n      with:\n        python-version: ${{ matrix.python-version }}\n        architecture: ${{ matrix.architecture }}\n\n    - name: Environment Variables\n      run: |\n        echo \"DATE=$(date '+${{ env.DATE_FORMAT }}')\" >> \"$GITHUB_ENV\"\n        echo \"LABEL=$(python ./scripts/pyinstaller.py --print --os '${{ matrix.os }}' --arch '${{ matrix.architecture }}')\" >> \"$GITHUB_ENV\"\n\n    - name: Update Version\n      # use Python since its behavior is consistent across operating systems\n      shell: python\n      run: |\n        import re\n        path = \"./gallery_dl/version.py\"\n        with open(path) as fp:\n            content = fp.read()\n        content = re.sub(\n            r'\\b(__version__ = \"[^\"]+)',\n            r\"\\1:${{ env.DATE }}\",\n            content)\n        content = re.sub(\n            r'\\b(__variant__ =).+',\n            r'\\1 \"dev/${{ env.LABEL }}\"',\n            content)\n        with open(path, \"w\") as fp:\n            fp.write(content)\n\n    - name: Build executable\n      run: |\n        pip install requests requests[socks] yt-dlp[default] pyyaml ${{ matrix.python-packages }} pyinstaller\n        pip install truststore || true\n        python ./scripts/pyinstaller.py --label '${{ env.LABEL }}'\n\n    - uses: actions/upload-artifact@v4\n      with:\n        name: executable-${{ matrix.os }}-${{ matrix.architecture }}-${{ matrix.python-version }}\n        path: dist/*\n        retention-days: 1\n        compression-level: 0\n\n  release:\n\n    needs: build\n    runs-on: ubuntu-latest\n\n    steps:\n    - uses: actions/download-artifact@v4\n\n    - name: Environment Variables\n      run: echo \"DATE=$(date '+${{ env.DATE_FORMAT }}')\" >> \"$GITHUB_ENV\"\n\n    - name: Body\n      run: printf 'https://github.com/%s/commit/%s' '${{ github.repository }}' '${{ github.sha }}' > body.md\n\n    - uses: ncipollo/release-action@v1\n      with:\n        owner: gdl-org\n        repo: builds\n        tag: ${{ env.DATE }}\n        bodyFile: body.md\n        artifacts: \"executable-*/*\"\n        allowUpdates: true\n        makeLatest: true\n        token: ${{ secrets.REPO_TOKEN }}\n"
  },
  {
    "path": ".github/workflows/pages.yml",
    "content": "name: GitHub Pages\n\non:\n  workflow_dispatch:\n  push:\n    branches:\n    - master\n    paths:\n    - docs/**\n\npermissions:\n  contents: read\n  pages: write\n  id-token: write\n\nconcurrency:\n  group: pages\n  cancel-in-progress: false\n\njobs:\n  dispatch:\n\n    if: github.repository == 'mikf/gallery-dl'\n    runs-on: ubuntu-latest\n\n    steps:\n    - name: Dispatch to gdl-org/docs\n      run: >\n        curl -L\n        -X POST\n        -H \"Accept: application/vnd.github+json\"\n        -H \"Authorization: Bearer ${{ secrets.REPO_TOKEN }}\"\n        -H \"X-GitHub-Api-Version: 2022-11-28\"\n        https://api.github.com/repos/gdl-org/docs/actions/workflows/pages.yml/dispatches\n        -d '{\"ref\":\"master\"}'\n\n  deploy:\n\n    if: github.repository == 'mikf/gallery-dl'\n    runs-on: ubuntu-latest\n\n    environment:\n      name: github-pages\n      url: ${{ steps.deployment.outputs.page_url }}\n\n    steps:\n    - uses: actions/checkout@v5\n    - uses: actions/configure-pages@v4\n\n    - name: Copy static files\n      run: |\n        mkdir --parents -- ./_site\n        cp --archive --target-directory=./_site -- \\\n          ./docs/oauth-redirect.html\n\n    - uses: actions/upload-pages-artifact@v3\n    - uses: actions/deploy-pages@v4\n      id: deployment\n"
  },
  {
    "path": ".github/workflows/tests.yml",
    "content": "name: Tests\n\non:\n  workflow_dispatch:\n  push:\n    branches:\n    - master\n  pull_request:\n    branches:\n    - master\n\njobs:\n  test:\n\n    runs-on: ubuntu-22.04\n    strategy:\n      fail-fast: false\n      matrix:\n        python-version:\n        - \"3.8\"\n        - \"3.9\"\n        - \"3.10\"\n        - \"3.11\"\n        - \"3.12\"\n        - \"3.13\"\n        - \"3.14\"\n        - \"pypy3.9\"\n        - \"pypy3.11\"\n\n    steps:\n    - uses: actions/checkout@v5\n\n    - name: Check file permissions\n      run: |\n        if [[ \"$(find ./gallery_dl -type f -not -perm 644)\" ]]; then exit 1; fi\n\n    - name: Set up Python ${{ matrix.python-version }}\n      uses: actions/setup-python@v6\n      with:\n        python-version: ${{ matrix.python-version }}\n\n    - name: Install dependencies\n      run: |\n        pip install -r requirements.txt\n        pip install flake8 youtube-dl\n\n    - name: Install yt-dlp\n      run: |\n        case \"${{ matrix.python-version }}\" in\n            3.8|3.9|pypy3.9)\n                # install from PyPI\n                pip install yt-dlp\n                ;;\n            *)\n                # install from master\n                pip install https://github.com/yt-dlp/yt-dlp/archive/refs/heads/master.tar.gz\n                ;;\n        esac\n\n    - name: Lint with flake8\n      run: |\n        flake8 .\n\n    - name: Run tests\n      run: |\n        make test\n\n    - name: Test autogeneration of man pages, bash/zsh/fish completion, etc\n      run: |\n        make\n"
  },
  {
    "path": ".gitignore",
    "content": "archive/\n\n# Byte-compiled / optimized / DLL files\n__pycache__/\n*.py[cod]\n\n# C extensions\n*.so\n\n# Distribution / packaging\n.Python\nenv/\ndata/\nbuild/\ndevelop-eggs/\ndist/\ndownloads/\neggs/\n.eggs/\nlib/\nlib64/\nparts/\nsdist/\nvar/\n*.egg-info/\n.installed.cfg\n*.egg\n\n# PyInstaller\n#  Usually these files are written by a python script from a template\n#  before PyInstaller builds the exe, so as to inject date/other infos into it.\n*.manifest\n*.spec\n\n# Installer logs\npip-log.txt\npip-delete-this-directory.txt\n\n# Unit test / coverage reports\nhtmlcov/\n.tox/\n.coverage\n.coverage.*\n.cache\nnosetests.xml\ncoverage.xml\n*,cover\n\n# Translations\n*.mo\n*.pot\n\n# Django stuff:\n*.log\n\n# Sphinx documentation\ndocs/_build/\n\n# PyBuilder\ntarget/\n\n# Snap packaging specific\n/snap/.snapcraft/\n/parts/\n/stage/\n/prime/\n\n/*.snap\n/*_source.tar.bz2\n/gallery-dl\n"
  },
  {
    "path": "CHANGELOG.md",
    "content": "# Changelog\n\n## 1.31.5 - 2026-01-31\n### Extractors\n#### Additions\n- [discord] add `server-search` extractor\n- [listal] add `image` & `people` extractors ([#1589](https://github.com/mikf/gallery-dl/issues/1589) [#8921](https://github.com/mikf/gallery-dl/issues/8921))\n- [mangafreak] add support ([#8928](https://github.com/mikf/gallery-dl/issues/8928))\n- [mangatown] add support ([#8925](https://github.com/mikf/gallery-dl/issues/8925))\n- [xenforo] support `titsintops.com` ([#8945](https://github.com/mikf/gallery-dl/issues/8945))\n- [xenforo] support `forums.socialmediagirls.com` ([#8964](https://github.com/mikf/gallery-dl/issues/8964))\n#### Fixes\n- [civitai:user-posts] fix pagination ([#8955](https://github.com/mikf/gallery-dl/issues/8955))\n- [imhentai] detect galleries without image data ([#8951](https://github.com/mikf/gallery-dl/issues/8951))\n- [kemono] fix possible `AttributeError` when processing revisions ([#8929](https://github.com/mikf/gallery-dl/issues/8929))\n- [mangataro] fix `manga` extractor ([#8930](https://github.com/mikf/gallery-dl/issues/8930))\n- [pornhub] fix `400 Bad Request` when logged in ([#8942](https://github.com/mikf/gallery-dl/issues/8942))\n- [tiktok] solve JS challenges ([#8850](https://github.com/mikf/gallery-dl/issues/8850))\n- [tiktok] fix account extraction ([#8931](https://github.com/mikf/gallery-dl/issues/8931))\n- [tiktok] extract more story item list pages ([#8932](https://github.com/mikf/gallery-dl/issues/8932))\n- [tiktok] do not fail story extraction if a user has no stories ([#8938](https://github.com/mikf/gallery-dl/issues/8938))\n- [weebdex] make metadata extraction non-fatal ([#8939](https://github.com/mikf/gallery-dl/issues/8939) [#8954](https://github.com/mikf/gallery-dl/issues/8954))\n- [weibo] fix `KeyError - 'pid'` when processing subalbums ([#8792](https://github.com/mikf/gallery-dl/issues/8792))\n- [xenforo] improve `attachment` extraction ([#8947](https://github.com/mikf/gallery-dl/issues/8947))\n- [xenforo] fix cookies check before login ([#8919](https://github.com/mikf/gallery-dl/issues/8919))\n#### Improvements\n- [exhentai] implement Multi-Page Viewer support ([#2616](https://github.com/mikf/gallery-dl/issues/2616) [#5268](https://github.com/mikf/gallery-dl/issues/5268))\n- [kemono] reduce `revisions` API requests when possible\n- [tiktok] implement `subtitles` support ([#8805](https://github.com/mikf/gallery-dl/issues/8805))\n- [tiktok] implement downloading all `cover` types ([#8805](https://github.com/mikf/gallery-dl/issues/8805))\n- [tiktok] do not stop extraction if a post fails ([#8962](https://github.com/mikf/gallery-dl/issues/8962))\n- [weebdex] add `lang` option ([#8957](https://github.com/mikf/gallery-dl/issues/8957))\n- [weebdex] support query parameter filters\n- [weibo] add `subalbums` include ([#8792](https://github.com/mikf/gallery-dl/issues/8792))\n- [xenforo] improve error message extraction ([#8919](https://github.com/mikf/gallery-dl/issues/8919))\n- [xenforo] decode `/goto/link-confirmation` links ([#8964](https://github.com/mikf/gallery-dl/issues/8964))\n### Post Processors\n- [mtime] fix overwriting `Last-Modified` mtime when selecting invalid values ([#8918](https://github.com/mikf/gallery-dl/issues/8918))\n### Miscellaneous\n- [docs/options] add Table of Contents\n- [job] add `output.jsonl` option ([#8953](https://github.com/mikf/gallery-dl/issues/8953))\n- [job] add `extractor.*.parent` option\n- [job] enable all `parent-…` options for parent extractors by default\n\n## 1.31.4 - 2026-01-24\n### Extractors\n#### Additions\n- [kaliscan] add support ([#8917](https://github.com/mikf/gallery-dl/issues/8917))\n- [turbo] add support - rewrite `saint` extractors ([#8893](https://github.com/mikf/gallery-dl/issues/8893) [#8896](https://github.com/mikf/gallery-dl/issues/8896))\n- [xenforo] support `celebforum.to` ([#8902](https://github.com/mikf/gallery-dl/issues/8902))\n- [xenforo] add `media-album` extractor ([#8902](https://github.com/mikf/gallery-dl/issues/8902))\n#### Fixes\n- [mangafire] fix extractors - generate `vrf` tokens ([#8400](https://github.com/mikf/gallery-dl/issues/8400) [#8906](https://github.com/mikf/gallery-dl/issues/8906))\n- [nitter] use `gallery-dl/<version>` User-Agent ([#7045](https://github.com/mikf/gallery-dl/issues/7045) [#8130](https://github.com/mikf/gallery-dl/issues/8130) [#8409](https://github.com/mikf/gallery-dl/issues/8409))\n- [tiktok] fix `following` extractor ([#8849](https://github.com/mikf/gallery-dl/issues/8849))\n- [xenforo] fix using cookies for custom instances ([#8902](https://github.com/mikf/gallery-dl/issues/8902))\n#### Improvements\n- [imagebam] raise `NotFoundError` for deleted images & galleries ([#8890](https://github.com/mikf/gallery-dl/issues/8890))\n- [kemono:discord] improve `filename` parsing\n- [kemono:discord] support server URLs with trailing `/`\n- [tiktok] download best quality videos ([#8846](https://github.com/mikf/gallery-dl/issues/8846))\n- [tiktok] prefer `legacy` endpoint for user post extraction ([#8812](https://github.com/mikf/gallery-dl/issues/8812) [#8847](https://github.com/mikf/gallery-dl/issues/8847))\n- [twitter] implement `\"ratelimit\": \"abort:N\"` ([#5251](https://github.com/mikf/gallery-dl/issues/5251) [#8864](https://github.com/mikf/gallery-dl/issues/8864))\n- [weebdex] add `data-saver` option ([#8914](https://github.com/mikf/gallery-dl/issues/8914))\n- [xenforo] ignore links starting with `#`\n#### Metadata\n- [kemono:discord] extract `archives` metadata ([#8898](https://github.com/mikf/gallery-dl/issues/8898))\n- [xenforo:media-album] extract `album` metadata ([#8902](https://github.com/mikf/gallery-dl/issues/8902))\n#### Removals\n- [batoto] remove module ([#8834](https://github.com/mikf/gallery-dl/issues/8834) [#8908](https://github.com/mikf/gallery-dl/issues/8908))\n### Miscellaneous\n- [common] implement `parent-session` option\n- [common] add `googlebot` User-Agent preset\n- [docker] build from `python:3.14-alpine`\n- [release] add more checks before committing a release\n- [util] replace classes with functions for predicates, Popen, HTTPBasicAuth\n\n## 1.31.3 - 2026-01-16\n### Extractors\n#### Additions\n- [booth] add `category` extractor ([#8867](https://github.com/mikf/gallery-dl/issues/8867))\n- [thefap] add support ([#8821](https://github.com/mikf/gallery-dl/issues/8821) [#8822](https://github.com/mikf/gallery-dl/issues/8822))\n- [xenforo] implement `media` support ([#8785](https://github.com/mikf/gallery-dl/issues/8785))\n  - add `media-item`, `media-user`, `media-category` extractors\n#### Fixes\n- [ahottie:album] support multiple pages ([#8862](https://github.com/mikf/gallery-dl/issues/8862) [#8886](https://github.com/mikf/gallery-dl/issues/8886))\n- [bellazon] use `data-full-image` URLs if available ([#8833](https://github.com/mikf/gallery-dl/issues/8833))\n- [fanbox] make `comments` extraction non-fatal ([#8814](https://github.com/mikf/gallery-dl/issues/8814))\n- [imagevenue] fix `NotFoundError` for valid image links ([#8818](https://github.com/mikf/gallery-dl/issues/8818))\n- [xenforo] fix/improve `bb*Wrapper` extraction ([#8868](https://github.com/mikf/gallery-dl/issues/8868))\n#### Improvements\n- [bellazon] match thread URLs with escaped characters\n- [bilibili] add support for Live Photo downloads ([#8860](https://github.com/mikf/gallery-dl/issues/8860))\n- [booth:item] support URLs with language codes\n- [chevereto] implement password support\n- [chevereto:user] support `album` results\n- [dankefuerslesen] support `/reader/series` URLs ([#8811](https://github.com/mikf/gallery-dl/issues/8811))\n- [furaffinity:favorite] support URLs with custom start position\n- [imagetwist:gallery] support multiple pages ([#8826](https://github.com/mikf/gallery-dl/issues/8826))\n- [koofr] refactor ([#8803](https://github.com/mikf/gallery-dl/issues/8803))\n- [pixeldrain:album] add `zip` option\n- [pixeldrain] warn about hotlink-protected files ([#8803](https://github.com/mikf/gallery-dl/issues/8803))\n- [pixeldrain] improve `filename` parsing\n- [rule34xyz] support URLs with `www` subdomain ([#8875](https://github.com/mikf/gallery-dl/issues/8875))\n- [saint] support `turbovid.cr` & `turbo.cr` URLs ([#8851](https://github.com/mikf/gallery-dl/issues/8851) [#8888](https://github.com/mikf/gallery-dl/issues/8888))\n- [shopify] support URLs starting with language codes\n- [webtoons] extend `bgm` option ([#8733](https://github.com/mikf/gallery-dl/issues/8733))\n- [weibo:album] implement `subalbum` support ([#8792](https://github.com/mikf/gallery-dl/issues/8792))\n- [wikimedia] use `gallery-dl/<version>` User-Agent ([#8770](https://github.com/mikf/gallery-dl/issues/8770) [#8861](https://github.com/mikf/gallery-dl/issues/8861))\n#### Metadata\n- [bellazon] unescape `thread[slug]` names\n- [imagetwist:gallery] extract `gallery_title` & `gallery_id` metadata\n- [motherless] fix gallery media metadata issues ([#8873](https://github.com/mikf/gallery-dl/issues/8873))\n- [rule34xyz] recognize `system` & `meta` tag types\n- [sexcom] fix `tags` when passing cookies ([#8880](https://github.com/mikf/gallery-dl/issues/8880))\n- [tiktok] provide `post_type` metadata ([#8815](https://github.com/mikf/gallery-dl/issues/8815))\n- [xenforo] extract `author_slug` metadata ([#8785](https://github.com/mikf/gallery-dl/issues/8785))\n### Downloaders\n- [ytdl] implement `_ytdl_manifest_remux`\n### Miscellaneous\n- [common] implement `\"user-agent\": \"+PRESET\"`\n- [docs/README] add Discord link\n- [formatter] overload `.` operator\n- [job] fix extractor `kwdict` values being unavailable in filters\n- [util] adjust Firefox/Chrome User-Agent versions\n- [util] rename `USERAGENT` to `USERAGENT_GALLERYDL` ([#8836](https://github.com/mikf/gallery-dl/issues/8836))\n\n## 1.31.2 - 2026-01-02\n### Extractors\n#### Additions\n- [ahottie] add aupport ([#8710](https://github.com/mikf/gallery-dl/issues/8710))\n- [discord] add `server-assets` extractor ([#8589](https://github.com/mikf/gallery-dl/issues/8589))\n- [imgpv] add `image` extractor ([#8773](https://github.com/mikf/gallery-dl/issues/8773))\n- [manganelo] add `bookmark` extractor ([#8776](https://github.com/mikf/gallery-dl/issues/8776))\n- [tiktok] add support for stories, liked posts, saved posts, reposts ([#8035](https://github.com/mikf/gallery-dl/issues/8035) [#8466](https://github.com/mikf/gallery-dl/issues/8466) [#8715](https://github.com/mikf/gallery-dl/issues/8715))\n- [twitter] add `notifications` extractor ([#7974](https://github.com/mikf/gallery-dl/issues/7974))\n- [whyp] add support ([#8725](https://github.com/mikf/gallery-dl/issues/8725))\n- [yourlesbians] add `album` extractor ([#8713](https://github.com/mikf/gallery-dl/issues/8713))\n#### Fixes\n- [batoto] replace k-subdomain image URLs with n-subdomain ([#8791](https://github.com/mikf/gallery-dl/issues/8791))\n- [civitai] update `quality-videos` default ([#8787](https://github.com/mikf/gallery-dl/issues/8787))\n- [deviantart:stash] fix `JSONDecodeEerror` for folders ([#8750](https://github.com/mikf/gallery-dl/issues/8750))\n- [exhentai] fix possible exception in `finalize()` ([#8741](https://github.com/mikf/gallery-dl/issues/8741))\n- [instagram:stories] extract correct `expires` dates ([#8764](https://github.com/mikf/gallery-dl/issues/8764))\n- [sankaku] fix re-authentication ([#8779](https://github.com/mikf/gallery-dl/issues/8779))\n- [tapas] unescape HTML entities in image URLs ([#8790](https://github.com/mikf/gallery-dl/issues/8790))\n- [tumblr:search] prevent `KeyError` when using `offset` pagination ([#8720](https://github.com/mikf/gallery-dl/issues/8720))\n- [xenforo] fix incomplete video URLs ([#8786](https://github.com/mikf/gallery-dl/issues/8786))\n#### Improvements\n- [bunkr] detect new maintenance video file ([#8802](https://github.com/mikf/gallery-dl/issues/8802))\n- [fansly] disable `formats` check by default ([#8757](https://github.com/mikf/gallery-dl/issues/8757))\n- [instagram] detect homepage redirects ([#8714](https://github.com/mikf/gallery-dl/issues/8714))\n- [instagram] don't warn on minor image size differences ([#8300](https://github.com/mikf/gallery-dl/issues/8300))\n- [pixiv] support `sketch` include ([#8789](https://github.com/mikf/gallery-dl/issues/8789))\n- [subscribestar] support filterting `user` posts by `tag` ([#8737](https://github.com/mikf/gallery-dl/issues/8737))\n- [tiktok] remove yt-dlp dependency ([#7246](https://github.com/mikf/gallery-dl/issues/7246) [#8466](https://github.com/mikf/gallery-dl/issues/8466) [#8730](https://github.com/mikf/gallery-dl/issues/8730) [#8715](https://github.com/mikf/gallery-dl/issues/8715))\n- [webtoons] download episode background music ([#8733](https://github.com/mikf/gallery-dl/issues/8733))\n- [xenforo] support `/#post-ID` URLs\n#### Metadata\n- [pixiv] provide `count` metadata ([#8794](https://github.com/mikf/gallery-dl/issues/8794))\n- [tiktok] combine `…_id` fields into a single `file_id` one ([#8804](https://github.com/mikf/gallery-dl/issues/8804))\n- [webtoons] extract `num_play` & `num_stop` for background music ([#8733](https://github.com/mikf/gallery-dl/issues/8733) [#8755](https://github.com/mikf/gallery-dl/issues/8755))\n#### Options\n- [facebook] add `loop` option ([#8696](https://github.com/mikf/gallery-dl/issues/8696))\n- [fansly] add `previews` option ([#8686](https://github.com/mikf/gallery-dl/issues/8686))\n- [koofr] implement `zip` option ([#6582](https://github.com/mikf/gallery-dl/issues/6582) [#8700](https://github.com/mikf/gallery-dl/issues/8700))\n- [tiktok] add `order-posts` option ([#8730](https://github.com/mikf/gallery-dl/issues/8730) [#8715](https://github.com/mikf/gallery-dl/issues/8715))\n### Downloaders\n- [http] add MIME type and signature for `.aac` files\n- [ytdl] fix overwriting `mtime` of downloaded files ([#8767](https://github.com/mikf/gallery-dl/issues/8767))\n- [ytdl] expect all exception types when extracting `info_dict` ([#8343](https://github.com/mikf/gallery-dl/issues/8343))\n### Miscellaneous\n- [actions] fix `abort` ([#8753](https://github.com/mikf/gallery-dl/issues/8753))\n- [options] add `--sleep-skip` ([#6582](https://github.com/mikf/gallery-dl/issues/6582))\n\n## 1.31.1 - 2025-12-20\n### Extractors\n#### Additions\n- [2chen] implement generic `2chen` board extractors\n  - support `https://schan.help/` ([#8680](https://github.com/mikf/gallery-dl/issues/8680))\n- [aryion] add `watch` extractor ([#8705](https://github.com/mikf/gallery-dl/issues/8705))\n- [comedywildlifephoto] add `gallery` extractor ([#8690](https://github.com/mikf/gallery-dl/issues/8690))\n- [koofr] add `shared` extractor ([#8700](https://github.com/mikf/gallery-dl/issues/8700))\n- [picazor] add `user` extractor ([#7083](https://github.com/mikf/gallery-dl/issues/7083) [#7504](https://github.com/mikf/gallery-dl/issues/7504) [#7795](https://github.com/mikf/gallery-dl/issues/7795) [#8717](https://github.com/mikf/gallery-dl/issues/8717))\n- [weebdex] add support ([#8722](https://github.com/mikf/gallery-dl/issues/8722))\n- [xenforo] support `allthefallen.moe/forum` ([#3249](https://github.com/mikf/gallery-dl/issues/3249) [#8268](https://github.com/mikf/gallery-dl/issues/8268))\n#### Fixes\n- [aryion:favorite] fix extraction ([#8705](https://github.com/mikf/gallery-dl/issues/8705) [#8723](https://github.com/mikf/gallery-dl/issues/8723) [#8728](https://github.com/mikf/gallery-dl/issues/8728))\n- [aryion] fix `description` metadata\n- [boosty] include `Authorization` header with file downloads ([#8704](https://github.com/mikf/gallery-dl/issues/8704))\n- [fanbox] make `_extract_post()` non-fatal ([#8711](https://github.com/mikf/gallery-dl/issues/8711))\n- [furaffinity] fix `tags` metadata ([#8724](https://github.com/mikf/gallery-dl/issues/8724))\n- [mastodon] fix `AttributeError: 'parse_datetime_iso'` ([#8709](https://github.com/mikf/gallery-dl/issues/8709))\n- [tenor] fix `title` metadata\n- [twitter] fix `avatar` & `background` downloads with `\"expand\": true` ([#8698](https://github.com/mikf/gallery-dl/issues/8698))\n#### Improvements\n- [boosty] warn about expired `auth` cookie tokens ([#8704](https://github.com/mikf/gallery-dl/issues/8704))\n- [misskey] implement `order-posts` option ([#8516](https://github.com/mikf/gallery-dl/issues/8516))\n- [reddit] use `\"videos\": \"dash\"` by default ([#8657](https://github.com/mikf/gallery-dl/issues/8657))\n- [pixiv] warn about invalid `PHPSESSID` cookie ([#8689](https://github.com/mikf/gallery-dl/issues/8689))\n### Downloaders\n- [ytdl] fix `UnboundLocalError: 'tries'` ([#8707](https://github.com/mikf/gallery-dl/issues/8707))\n- [ytdl] respect `--no-skip`\n### Miscellaneous\n- [path] implement dynamic length directories ([#1350](https://github.com/mikf/gallery-dl/issues/1350))\n- [formatter] add `I` format specifier - identity\n- [tests] add `path` tests\n\n## 1.31.0 - 2025-12-12\n### Extractors\n#### Additions\n- [arena] add `channel` extractor ([#5847](https://github.com/mikf/gallery-dl/issues/5847) [#8509](https://github.com/mikf/gallery-dl/issues/8509))\n- [aryion] add `search` extractor ([#8567](https://github.com/mikf/gallery-dl/issues/8567))\n- [audiochan] add support ([#8602](https://github.com/mikf/gallery-dl/issues/8602))\n- [cfake] add support ([#707](https://github.com/mikf/gallery-dl/issues/707) [#6021](https://github.com/mikf/gallery-dl/issues/6021) [#8549](https://github.com/mikf/gallery-dl/issues/8549) [#8687](https://github.com/mikf/gallery-dl/issues/8687) [#8430](https://github.com/mikf/gallery-dl/issues/8430))\n- [cyberfile] add `shared` extractor ([#8323](https://github.com/mikf/gallery-dl/issues/8323))\n- [Danbooru] add `media-asset` extractor ([#8580](https://github.com/mikf/gallery-dl/issues/8580))\n- [e621] add `artist` & `artist-search` extractors ([#8448](https://github.com/mikf/gallery-dl/issues/8448))\n- [eporner] add support ([#8581](https://github.com/mikf/gallery-dl/issues/8581))\n- [fikfap] add support ([#8673](https://github.com/mikf/gallery-dl/issues/8673))\n- [fitnakedgirls] add support ([#8671](https://github.com/mikf/gallery-dl/issues/8671))\n- [myhentaigallery] add `tag` extractor ([#8537](https://github.com/mikf/gallery-dl/issues/8537))\n- [nudostarforum] add support ([#8664](https://github.com/mikf/gallery-dl/issues/8664))\n- [okporn] add support ([#8575](https://github.com/mikf/gallery-dl/issues/8575))\n- [pornpics] add `category` & `listing` extractors ([#8662](https://github.com/mikf/gallery-dl/issues/8662))\n- [pornstarstube] add support ([#8576](https://github.com/mikf/gallery-dl/issues/8576))\n- [sexcom] add `feed` extractor ([#8519](https://github.com/mikf/gallery-dl/issues/8519))\n- [shimmie2] support `soybooru.com` ([#8467](https://github.com/mikf/gallery-dl/issues/8467))\n- [sxypix] add support ([#4507](https://github.com/mikf/gallery-dl/issues/4507) [#8391](https://github.com/mikf/gallery-dl/issues/8391) [#8574](https://github.com/mikf/gallery-dl/issues/8574))\n- [xenforo] implement generic `XenForo` forum extractors\n#### Fixes\n- [bellazon] fix errors when handling guest users ([#8397](https://github.com/mikf/gallery-dl/issues/8397))\n- [belazon] fix starting from a specific page\n- [cien] fix `creator` & `recent` extractors ([#8524](https://github.com/mikf/gallery-dl/issues/8524))\n- [fanbox:redirect] disable cookie usage ([#8565](https://github.com/mikf/gallery-dl/issues/8565))\n- [gofile] fix extraction ([#8681](https://github.com/mikf/gallery-dl/issues/8681) [#8683](https://github.com/mikf/gallery-dl/issues/8683))\n- [imagebam] fix `filename` & `extension` for names without ext ([#8476](https://github.com/mikf/gallery-dl/issues/8476))\n- [instagram] fix `AttributeError: 'videos_dash'` ([#8561](https://github.com/mikf/gallery-dl/issues/8561))\n- [motherless] fix `gallery_title` extraction ([#8605](https://github.com/mikf/gallery-dl/issues/8605))\n- [paheal] fix `AttributeError`\n- [pixiv] fix `KeyError: 'is_bookmarked'` ([#8398](https://github.com/mikf/gallery-dl/issues/8398))\n- [postimg] fix extraction ([#8505](https://github.com/mikf/gallery-dl/issues/8505))\n- [rawkuma] update extractors to new site layout ([#8568](https://github.com/mikf/gallery-dl/issues/8568))\n- [realbooru] fix `tags` for video posts ([#8455](https://github.com/mikf/gallery-dl/issues/8455))\n- [reddit] fix `KeyError: 'media_metadata'` for embeds ([#8551](https://github.com/mikf/gallery-dl/issues/8551))\n- [sankaku][idolcomplex] fix download URLs ([#8666](https://github.com/mikf/gallery-dl/issues/8666))\n- [schalenetwork] fix `tags` categories ([#8625](https://github.com/mikf/gallery-dl/issues/8625))\n- [silverpic] fix extraction & force `.net` TLD\n- [simpcity] fix `content` for first post of a thread\n- [simpcity] fix starting from a specific page ([#8599](https://github.com/mikf/gallery-dl/issues/8599))\n- [twitter] fix `KeyError` for `temporarily unavailable` users ([#8423](https://github.com/mikf/gallery-dl/issues/8423))\n- [twitter] fix `KeyError - 'source_id'` with disabled `transform` ([#8429](https://github.com/mikf/gallery-dl/issues/8429))\n- [twitter] fix `AttributeError` for `search-pagination\": \"max_id\"` ([#8613](https://github.com/mikf/gallery-dl/issues/8613))\n- [twitter] update & fix `pinned` Tweet extraction ([#8500](https://github.com/mikf/gallery-dl/issues/8500))\n- [vsco] use `\"browser\": \"firefox\"` by default ([#8127](https://github.com/mikf/gallery-dl/issues/8127))\n- [webtoons] fix `thumbnail` extraction ([#8413](https://github.com/mikf/gallery-dl/issues/8413))\n- [xasiat] fix `IndexError` for albums without category ([#8569](https://github.com/mikf/gallery-dl/issues/8569))\n#### Improvements\n- [2ch] support `.org` TLD ([#8629](https://github.com/mikf/gallery-dl/issues/8629))\n- [bunkr] detect when an album is deleted mid-download ([#8619](https://github.com/mikf/gallery-dl/issues/8619))\n- [cyberdrop] update domain to `cyberdrop.cr` ([#8496](https://github.com/mikf/gallery-dl/issues/8496))\n- [cyberfile:folder] support subfolders ([#8323](https://github.com/mikf/gallery-dl/issues/8323))\n- [deviantart:gallery] match URLs with query parameters ([#8514](https://github.com/mikf/gallery-dl/issues/8514))\n- [discord] limit length of default filenames ([#6582](https://github.com/mikf/gallery-dl/issues/6582))\n- [erome] improve error message for deleted & copyrighted albums ([#8665](https://github.com/mikf/gallery-dl/issues/8665))\n- [fansly] handle posts without `accountId`/`contentId`/`attachments` ([#8572](https://github.com/mikf/gallery-dl/issues/8572))\n- [flickr] extract public API key from website ([#7564](https://github.com/mikf/gallery-dl/issues/7564) [#7649](https://github.com/mikf/gallery-dl/issues/7649) [#7700](https://github.com/mikf/gallery-dl/issues/7700) [#8553](https://github.com/mikf/gallery-dl/issues/8553))\n- [imagehost] improve `filename` & `extension` handling\n- [imagetwist] detect deleted images ([#8415](https://github.com/mikf/gallery-dl/issues/8415))\n- [imagevenue] improve error for deleted images ([#8477](https://github.com/mikf/gallery-dl/issues/8477))\n- [imgbox] match direct links ([#8474](https://github.com/mikf/gallery-dl/issues/8474))\n- [imhentai:search] support `/advanced-search/` URLs ([#8507](https://github.com/mikf/gallery-dl/issues/8507))\n- [instagram] improve error for non-existent profiles ([#8550](https://github.com/mikf/gallery-dl/issues/8550))\n- [jpgfish] update domain to `jpg7.cr` ([#8530](https://github.com/mikf/gallery-dl/issues/8530))\n- [newgrounds] set error status when encountering inaccessible posts ([#8654](https://github.com/mikf/gallery-dl/issues/8654))\n- [realbooru] extract video fallbacks ([#8661](https://github.com/mikf/gallery-dl/issues/8661))\n- [reddit] use REST API by default ([#8559](https://github.com/mikf/gallery-dl/issues/8559))\n- [reddit] support comment share link ([#8434](https://github.com/mikf/gallery-dl/issues/8434))\n- [rule34us:tag] support URLs with empty `q` query parameter ([#8546](https://github.com/mikf/gallery-dl/issues/8546))\n- [pixhost] force `.to` TLD ([#8428](https://github.com/mikf/gallery-dl/issues/8428))\n- [postimg] force `postimg.cc` domain ([#8505](https://github.com/mikf/gallery-dl/issues/8505))\n- [sankaku][idolcomplex] support URLs with locale code ([#8667](https://github.com/mikf/gallery-dl/issues/8667))\n- [simpcity] implement login with username & password ([#8418](https://github.com/mikf/gallery-dl/issues/8418))\n- [simpcity] extract attachment/inline files ([#8560](https://github.com/mikf/gallery-dl/issues/8560))\n- [simpcity] extract `click to load media` URLs ([#8609](https://github.com/mikf/gallery-dl/issues/8609))\n- [tiktok] ignore empty `music` entries ([#8571](https://github.com/mikf/gallery-dl/issues/8571))\n- [twitter] restore better user NotFoundError messages ([#8621](https://github.com/mikf/gallery-dl/issues/8621))\n- [twitter] implement workarounds for empty `core` data ([#8613](https://github.com/mikf/gallery-dl/issues/8613))\n- [wikimedia] add `format=original` to `fandom`/`wikigg` file URLs ([#5512](https://github.com/mikf/gallery-dl/issues/5512))\n- [wikimedia] implement config lookups for `fandom`/`wikigg` sites ([#7283](https://github.com/mikf/gallery-dl/issues/7283))\n#### Metadata\n- [bellazon] add `num_internal` & `num_external` metadata fields ([#8415](https://github.com/mikf/gallery-dl/issues/8415))\n- [bellazon] remove query parameters from attachment IDs ([#8544](https://github.com/mikf/gallery-dl/issues/8544))\n- [bunkr] extract `album_…` metadata for `/f/` URLs ([#8405](https://github.com/mikf/gallery-dl/issues/8405))\n- [chevereto] extract `album_id` & `album_slug` metadata ([#8604](https://github.com/mikf/gallery-dl/issues/8604))\n- [chevereto:album] extract `count` & `num` metadata ([#8604](https://github.com/mikf/gallery-dl/issues/8604))\n- [civitai] implement extracting `tags` metadata ([#8626](https://github.com/mikf/gallery-dl/issues/8626))\n- [fanbox] return metadata of inaccessible posts ([#8643](https://github.com/mikf/gallery-dl/issues/8643))\n- [hentaifoundry] extract `categories` metadata ([#8656](https://github.com/mikf/gallery-dl/issues/8656))\n- [imagehosts] provide `post_url` metadata ([#6582](https://github.com/mikf/gallery-dl/issues/6582))\n- [instagram] extract `subscription` metadata for story/highlight items ([#8459](https://github.com/mikf/gallery-dl/issues/8459))\n- [instagram] extract correct `width` & `height` for videos ([#8399](https://github.com/mikf/gallery-dl/issues/8399))\n- [kemono] improve `filename`\n- [patreon] include full metadata with each URL ([#4286](https://github.com/mikf/gallery-dl/issues/4286) [#8498](https://github.com/mikf/gallery-dl/issues/8498))\n- [pixhost] extract `directory` metadata ([#6582](https://github.com/mikf/gallery-dl/issues/6582))\n- [subscribestar] improve `filename` ([#8416](https://github.com/mikf/gallery-dl/issues/8416))\n- [wikimedia] provide `lang` metadata ([#7283](https://github.com/mikf/gallery-dl/issues/7283))\n#### Options\n- [bluesky] add `api-server` option ([#8668](https://github.com/mikf/gallery-dl/issues/8668))\n- [civitai] add `sort` & `period` options ([#8426](https://github.com/mikf/gallery-dl/issues/8426))\n- [fanbox:creator] add `offset` option ([#6582](https://github.com/mikf/gallery-dl/issues/6582))\n- [itaku] add `order` option\n- [mangadex] add `data-saver` option ([#8573](https://github.com/mikf/gallery-dl/issues/8573))\n- [misskey] add `date-min` & `date-max` options ([#8516](https://github.com/mikf/gallery-dl/issues/8516))\n- [misskey] add `text-posts` option ([#8516](https://github.com/mikf/gallery-dl/issues/8516))\n- [patreon] add `order-posts` option ([#7856](https://github.com/mikf/gallery-dl/issues/7856) [#8482](https://github.com/mikf/gallery-dl/issues/8482))\n- [schalenetwork][hdoujin] re-implement `cbz` option ([#8431](https://github.com/mikf/gallery-dl/issues/8431))\n- [tiktok] add `covers` option ([#8515](https://github.com/mikf/gallery-dl/issues/8515))\n- [twitter] add general `limit` option ([#8173](https://github.com/mikf/gallery-dl/issues/8173))\n- [twitter] implement `retries-api` option ([#8317](https://github.com/mikf/gallery-dl/issues/8317))\n- [twitter] implement `search-results` option ([#8613](https://github.com/mikf/gallery-dl/issues/8613))\n- [twitter] implement using fallback values for `search-limit` ([#8173](https://github.com/mikf/gallery-dl/issues/8173))\n- [weibo] add `text` option ([#8422](https://github.com/mikf/gallery-dl/issues/8422))\n#### Removals\n- [redbust] remove module ([#6582](https://github.com/mikf/gallery-dl/issues/6582))\n#### Common\n- allow general ISO 8601 values for `date-min` & `date-max`\n- use `parent` value as `parent-metadata` default ([#8525](https://github.com/mikf/gallery-dl/issues/8525) [#8604](https://github.com/mikf/gallery-dl/issues/8604))\n### Downloaders\n- [http] fail downloads of empty files ([#8661](https://github.com/mikf/gallery-dl/issues/8661))\n- [http] implement `_http_segmented` ([#8602](https://github.com/mikf/gallery-dl/issues/8602))\n- [ytdl] implement `retry` functionality ([#1131](https://github.com/mikf/gallery-dl/issues/1131) [#8269](https://github.com/mikf/gallery-dl/issues/8269))\n- [ytdl] improve error detection\n- [ytdl] improve error message when importing default ytdl modules\n- [ytdl] update `_extract_manifest()`\n- [ytdl] forward `_ytdl_manifest_headers` to formats\n- [ytdl] restructure code\n### Post Processors\n- [metadata] add `newline` option ([#8439](https://github.com/mikf/gallery-dl/issues/8439))\n- [exec] add `verbose` option ([#7743](https://github.com/mikf/gallery-dl/issues/7743))\n### Formatter\n- add `Lb` format specifier - `L` for bytes\n- add `Xb` format specifier - `X` for bytes ([#6582](https://github.com/mikf/gallery-dl/issues/6582))\n### Job\n- add `keywords-global` option ([#6582](https://github.com/mikf/gallery-dl/issues/6582))\n- implement `post-filter` & `post-range` options\n- implement `\"archive-event\": \"after\"` ([#8373](https://github.com/mikf/gallery-dl/issues/8373))\n- use identity checks\n- inline `dispatch` loop\n### Path\n- implement conditional `part-directory` ([#8329](https://github.com/mikf/gallery-dl/issues/8329))\n- treat broken symlinks as existing files ([#8490](https://github.com/mikf/gallery-dl/issues/8490))\n- improve `exists()` performance\n### Miscellaneous\n- [cookies] fix cookie count logging message order ([#8414](https://github.com/mikf/gallery-dl/issues/8414))\n- [dt] move datetime utils into separate `dt` module\n- [output] add `defer` option for logging files ([#8523](https://github.com/mikf/gallery-dl/issues/8523))\n- [output] add `Logger.traceback()` helper\n- [scripts/init] fix error when running with default values ([#8583](https://github.com/mikf/gallery-dl/issues/8583))\n- [util] support integer values for `…-range` options ([#8604](https://github.com/mikf/gallery-dl/issues/8604))\n- [workflows:tests] include Python 3.14 & pypy3.11\n\n## 1.30.10 - 2025-10-12\n### Extractors\n#### Additions\n- [bluesky] add `bookmark` extractor ([#8370](https://github.com/mikf/gallery-dl/issues/8370))\n- [dandadan] add support ([#8381](https://github.com/mikf/gallery-dl/issues/8381))\n#### Fixes\n- [bellazon] fix video URL extraction ([#8392](https://github.com/mikf/gallery-dl/issues/8392))\n- [bluesky] handle exceptions during file extraction\n- [civitai] prevent downloading random posts from deleted users ([#8299](https://github.com/mikf/gallery-dl/issues/8299))\n- [girlsreleased] update API endpoints ([#8360](https://github.com/mikf/gallery-dl/issues/8360))\n- [instagram] restore `video_dash_manifest` downloads ([#8364](https://github.com/mikf/gallery-dl/issues/8364))\n- [kemono] prevent fatal exceptions when retrieving user profile data ([#8382](https://github.com/mikf/gallery-dl/issues/8382))\n- [mangadex] fix `RuntimeError` for titles without a `description` ([#8389](https://github.com/mikf/gallery-dl/issues/8389))\n- [naver-blog] fix video extraction ([#8385](https://github.com/mikf/gallery-dl/issues/8385))\n- [poipiku] fix original file downloads ([#8356](https://github.com/mikf/gallery-dl/issues/8356))\n- [weibo] fix retrieving followers-only content ([#6447](https://github.com/mikf/gallery-dl/issues/6447) [#7939](https://github.com/mikf/gallery-dl/issues/7939) [#8063](https://github.com/mikf/gallery-dl/issues/8063) [#8354](https://github.com/mikf/gallery-dl/issues/8354) [#8357](https://github.com/mikf/gallery-dl/issues/8357))\n- [weibo] use `page` parameter for `feed` results ([#7523](https://github.com/mikf/gallery-dl/issues/7523) [#8128](https://github.com/mikf/gallery-dl/issues/8128) [#8357](https://github.com/mikf/gallery-dl/issues/8357))\n- [wikimedia] fix name & extension of files without an extension ([#8344](https://github.com/mikf/gallery-dl/issues/8344))\n- [wikimedia] ignore missing files ([#8388](https://github.com/mikf/gallery-dl/issues/8388))\n#### Improvements\n- [bellazon] ignore links to other threads ([#8392](https://github.com/mikf/gallery-dl/issues/8392))\n- [common] disable delay for `request_location()`\n- [fansly] update format selection ([#4401](https://github.com/mikf/gallery-dl/issues/4401))\n- [fansly] download user posts from all account walls ([#4401](https://github.com/mikf/gallery-dl/issues/4401))\n- [instagram] support `/share/SHORTCODE` URLs ([#8340](https://github.com/mikf/gallery-dl/issues/8340))\n- [weibo] ignore ongoing live streams ([#8339](https://github.com/mikf/gallery-dl/issues/8339))\n- [zerochan] forward URL parameters to API requests ([#8377](https://github.com/mikf/gallery-dl/issues/8377))\n#### Metadata\n- [instagram] extract `subscription` metadata ([#8349](https://github.com/mikf/gallery-dl/issues/8349))\n- [webtoons] fix `episode` metadata extraction ([#2591](https://github.com/mikf/gallery-dl/issues/2591))\n#### Removals\n- [twitter] remove login support ([#4202](https://github.com/mikf/gallery-dl/issues/4202) [#6029](https://github.com/mikf/gallery-dl/issues/6029) [#6040](https://github.com/mikf/gallery-dl/issues/6040) [#8362](https://github.com/mikf/gallery-dl/issues/8362))\n### Post Processors\n- [exec] support `{_temppath}` replacement fields ([#8329](https://github.com/mikf/gallery-dl/issues/8329))\n### Miscellaneous\n- [formatter] improve error messages ([#8369](https://github.com/mikf/gallery-dl/issues/8369))\n- [path] implement conditional `base-directory`\n- use `utf-8` encoding when opening files in text mode ([#8376](https://github.com/mikf/gallery-dl/issues/8376))\n\n## 1.30.9 - 2025-10-03\n### Extractors\n#### Additions\n- [mangafire] add support ([#7064](https://github.com/mikf/gallery-dl/issues/7064) [#7701](https://github.com/mikf/gallery-dl/issues/7701))\n- [mangareader] add support ([#6529](https://github.com/mikf/gallery-dl/issues/6529) [#6868](https://github.com/mikf/gallery-dl/issues/6868))\n- [patreon] add `collection` extractor ([#8286](https://github.com/mikf/gallery-dl/issues/8286))\n- [s3ndpics] add support ([#8322](https://github.com/mikf/gallery-dl/issues/8322))\n#### Fixes\n- [chevereto] fix `id` for links without file name ([#8307](https://github.com/mikf/gallery-dl/issues/8307))\n- [chevereto:album] fix video downloads ([#8149](https://github.com/mikf/gallery-dl/issues/8149) [#8295](https://github.com/mikf/gallery-dl/issues/8295))\n- [hdoujin] fix `KeyError: 13` by adding `reclass` tag type ([#8290](https://github.com/mikf/gallery-dl/issues/8290))\n- [misskey] include `withRenotes` parameter in API requests ([#8285](https://github.com/mikf/gallery-dl/issues/8285))\n- [nozomi] percent-encode search tags ([#8328](https://github.com/mikf/gallery-dl/issues/8328))\n- [simpcity] fix `KeyError: 'url'` when thread author is deleted ([#8323](https://github.com/mikf/gallery-dl/issues/8323))\n- [twitter] fix `quote_id` of individual Tweets ([#8284](https://github.com/mikf/gallery-dl/issues/8284))\n- [zerochan] prevent `HttpError: '503 Service Temporarily Unavailable'` ([#8288](https://github.com/mikf/gallery-dl/issues/8288))\n#### Improvements\n- [chevereto] support URLs with `www` subdomain ([#8149](https://github.com/mikf/gallery-dl/issues/8149))\n- [imxto:gallery] support multiple pages ([#8282](https://github.com/mikf/gallery-dl/issues/8282))\n- [instagram] add `warn-images` & `warn-videos` options ([#8283](https://github.com/mikf/gallery-dl/issues/8283))\n- [instagram] use `reel` subcategory for `/reel/SHORTCODE` URLs ([#8274](https://github.com/mikf/gallery-dl/issues/8274))\n- [instagram] support `/reels/SHORTCODE` URLs ([#8318](https://github.com/mikf/gallery-dl/issues/8318))\n- [paheal] normalize `No results` output message ([#8313](https://github.com/mikf/gallery-dl/issues/8313))\n- [pixiv] implement searching past 5000 results ([#1686](https://github.com/mikf/gallery-dl/issues/1686) [#7082](https://github.com/mikf/gallery-dl/issues/7082) [#8298](https://github.com/mikf/gallery-dl/issues/8298))\n- [thehentaiworld] support more `post` URL formats ([#8277](https://github.com/mikf/gallery-dl/issues/8277))\n- [weibo] download `.m3u8` manifests with ytdl ([#8339](https://github.com/mikf/gallery-dl/issues/8339))\n- [weibo] resolve `wblive-out.api.weibo.com` URLs ([#8339](https://github.com/mikf/gallery-dl/issues/8339))\n- [weibo] use `replay_hd` URLs as video fallback ([#8339](https://github.com/mikf/gallery-dl/issues/8339))\n- [wikimedia] add ability to download image revisions ([#7283](https://github.com/mikf/gallery-dl/issues/7283) [#8330](https://github.com/mikf/gallery-dl/issues/8330))\n- [zerochan] normalize `No results` output message ([#8313](https://github.com/mikf/gallery-dl/issues/8313))\n#### Metadata\n- [hdoujin] extract `source` metadata ([#8280](https://github.com/mikf/gallery-dl/issues/8280))\n- [instagram] provide `type` metadata ([#8274](https://github.com/mikf/gallery-dl/issues/8274))\n- [mangadex] extract more manga-related metadata ([#8325](https://github.com/mikf/gallery-dl/issues/8325))\n#### Removals\n- [chevereto] remove `img.kiwi`\n### Downloaders\n- [http] add MIME type and signature for m3u8 & mpd files ([#8339](https://github.com/mikf/gallery-dl/issues/8339))\n### Post Processors\n- [python] restore `archive` functionality\n### Miscellaneous\n- [cookies] add support for `Orion` browser ([#8303](https://github.com/mikf/gallery-dl/issues/8303))\n- [docker] include more optional Python dependencies ([#8026](https://github.com/mikf/gallery-dl/issues/8026))\n- [docs] update `configuration.rst` formatting\n\n## 1.30.8 - 2025-09-23\n### Extractors\n#### Additions\n- [chevereto] support `imglike.com` ([#5179](https://github.com/mikf/gallery-dl/issues/5179))\n- [chevereto] add `category` extractor ([#5179](https://github.com/mikf/gallery-dl/issues/5179))\n- [Danbooru] add `random` extractor ([#8270](https://github.com/mikf/gallery-dl/issues/8270))\n- [hdoujin] add support ([#6810](https://github.com/mikf/gallery-dl/issues/6810))\n- [imgpile] add support ([#5044](https://github.com/mikf/gallery-dl/issues/5044))\n- [mangadex] add `covers` extractor ([#4994](https://github.com/mikf/gallery-dl/issues/4994))\n- [mangataro] add support ([#8237](https://github.com/mikf/gallery-dl/issues/8237))\n- [thehentaiworld] add support ([#274](https://github.com/mikf/gallery-dl/issues/274) [#8237](https://github.com/mikf/gallery-dl/issues/8237))\n#### Fixes\n- [4archive] fix `TypeError` ([#8217](https://github.com/mikf/gallery-dl/issues/8217))\n- [bellazon] fix video attachments ([#8239](https://github.com/mikf/gallery-dl/issues/8239))\n- [bunkr] fix `JSONDecodeError` for files with URL slugs containing apostrophes `'` ([#8150](https://github.com/mikf/gallery-dl/issues/8150))\n- [instagram] ensure manifest data exists before attempting a DASH download ([#8267](https://github.com/mikf/gallery-dl/issues/8267))\n- [schalenetwork] fix extraction ([#6948](https://github.com/mikf/gallery-dl/issues/6948) [#7391](https://github.com/mikf/gallery-dl/issues/7391) [#7728](https://github.com/mikf/gallery-dl/issues/7728))\n- [twitter] fix quoted Tweets being marked as `deleted` ([#8225](https://github.com/mikf/gallery-dl/issues/8225))\n#### Improvements\n- [2ch] update domain to `2ch.su`, support `2ch.life` URLs ([#8216](https://github.com/mikf/gallery-dl/issues/8216))\n- [bellazon][simpcity][vipergirls] process threads in descending order ([#8248](https://github.com/mikf/gallery-dl/issues/8248))\n- [bellazon] extract `inline` images (##8247)\n- [bellazon] support video embeds ([#8239](https://github.com/mikf/gallery-dl/issues/8239))\n- [bellazon] support `#comment-12345` post links ([#8239](https://github.com/mikf/gallery-dl/issues/8239))\n- [lensdump] support new direct file URL pattern ([#8251](https://github.com/mikf/gallery-dl/issues/8251))\n- [simpcity] extract URLs of `<iframe>` embeds ([#8214](https://github.com/mikf/gallery-dl/issues/8214) [#8256](https://github.com/mikf/gallery-dl/issues/8256))\n- [simpcity] improve post content extraction ([#8214](https://github.com/mikf/gallery-dl/issues/8214))\n#### Metadata\n- [facebook] extract `biography` metadata ([#8233](https://github.com/mikf/gallery-dl/issues/8233))\n- [instagram:tagged] provide full `tagged_…` metadata when using `id:…` URLs ([#8263](https://github.com/mikf/gallery-dl/issues/8263))\n- [iwara] extract more metadata ([#6582](https://github.com/mikf/gallery-dl/issues/6582))\n- [iwara] make `type` available for directories ([#8245](https://github.com/mikf/gallery-dl/issues/8245))\n- [reddit] provide `comment` metadata for all media files ([#8228](https://github.com/mikf/gallery-dl/issues/8228))\n#### Options\n- [bellazon] add `quoted` option ([#8247](https://github.com/mikf/gallery-dl/issues/8247))\n- [bellazon] implement `order-posts` option ([#8248](https://github.com/mikf/gallery-dl/issues/8248))\n- [kemono:discord] implement `order-posts` option ([#8241](https://github.com/mikf/gallery-dl/issues/8241))\n- [simpcity] implement `order-posts` option ([#8248](https://github.com/mikf/gallery-dl/issues/8248))\n- [vipergirls] implement `order-posts` option ([#8248](https://github.com/mikf/gallery-dl/issues/8248))\n### Downloaders\n- [ytdl] fix errors caused by deprecated options removal\n### Post Processors\n- [metadata] add `\"mode\": \"print\"` ([#2691](https://github.com/mikf/gallery-dl/issues/2691))\n- [python] add `\"mode\": \"eval\"`\n- close archive database connections ([#8243](https://github.com/mikf/gallery-dl/issues/8243))\n### Miscellaneous\n- [util] define `__enter__` & `__exit__` methods for `NullResponse` objects ([#8227](https://github.com/mikf/gallery-dl/issues/8227))\n- [util] extend list of ISO 639 language codes\n\n## 1.30.7 - 2025-09-14\n### Extractors\n#### Additions\n- [bellazon] add support ([#7480](https://github.com/mikf/gallery-dl/issues/7480))\n- [cyberfile] add support ([#5015](https://github.com/mikf/gallery-dl/issues/5015))\n- [fansly] add `creator-media` extractor ([#4401](https://github.com/mikf/gallery-dl/issues/4401))\n- [simpcity] add support ([#3127](https://github.com/mikf/gallery-dl/issues/3127) [#5145](https://github.com/mikf/gallery-dl/issues/5145) [#5879](https://github.com/mikf/gallery-dl/issues/5879) [#8187](https://github.com/mikf/gallery-dl/issues/8187))\n#### Fixes\n- [aibooru] fix download URLs ([#8212](https://github.com/mikf/gallery-dl/issues/8212))\n- [ao3] fix pagination ([#8206](https://github.com/mikf/gallery-dl/issues/8206))\n- [boosty] fix extracting `accessToken` from cookies ([#8203](https://github.com/mikf/gallery-dl/issues/8203))\n- [comick] update `buildId` on `404` errors ([#8157](https://github.com/mikf/gallery-dl/issues/8157))\n- [facebook] fix `/photo/?fbid=…&set=…` URLs being handled as a set ([#8181](https://github.com/mikf/gallery-dl/issues/8181))\n- [fansly] fix & improve format selection ([#4401](https://github.com/mikf/gallery-dl/issues/4401))\n- [fansly] fix posts with more than 5 files ([#4401](https://github.com/mikf/gallery-dl/issues/4401))\n- [imgbb] fix & update ([#7936](https://github.com/mikf/gallery-dl/issues/7936))\n- [tiktok] fix `KeyError: 'author'` ([#8189](https://github.com/mikf/gallery-dl/issues/8189))\n#### Improvements\n- [comick] handle redirects\n- [fansly] provide fallback URL for manifest downloads ([#4401](https://github.com/mikf/gallery-dl/issues/4401))\n- [fansly:creator] support custom wall IDs ([#4401](https://github.com/mikf/gallery-dl/issues/4401))\n- [tungsten:user] support filtering results by tag ([#8061](https://github.com/mikf/gallery-dl/issues/8061))\n- [twitter] continue searches on empty response ([#8173](https://github.com/mikf/gallery-dl/issues/8173))\n- [twitter] implement various `search-…` options ([#8173](https://github.com/mikf/gallery-dl/issues/8173))\n### Miscellaneous\n- [formatter] exclude `<>\\` characters from `!R` results ([#8180](https://github.com/mikf/gallery-dl/issues/8180))\n- [formatter] support negative indicies\n- [util] emit debug `Proxy Map` logging message ([#8195](https://github.com/mikf/gallery-dl/issues/8195))\n\n## 1.30.6 - 2025-09-06\n### Extractors\n#### Additions\n- [chevereto] add `video` extractor ([#8149](https://github.com/mikf/gallery-dl/issues/8149))\n- [comick] add `covers` extractor\n- [fansly] add support ([#4401](https://github.com/mikf/gallery-dl/issues/4401))\n- [instagram] add `stories-tray` extractor ([#6582](https://github.com/mikf/gallery-dl/issues/6582))\n- [shimmie2] support `co.llection.pics` ([#8166](https://github.com/mikf/gallery-dl/issues/8166))\n- [tungsten] add support ([#8061](https://github.com/mikf/gallery-dl/issues/8061))\n- [vk] add `wall-post` extractor ([#474](https://github.com/mikf/gallery-dl/issues/474) [#6378](https://github.com/mikf/gallery-dl/issues/6378) [#8159](https://github.com/mikf/gallery-dl/issues/8159))\n#### Fixes\n- [bunkr] fix downloading albums with more than 100 files ([#8150](https://github.com/mikf/gallery-dl/issues/8150) [#8155](https://github.com/mikf/gallery-dl/issues/8155) [#8175](https://github.com/mikf/gallery-dl/issues/8175))\n- [chevereto:user] fix names starting with an `a` ([#8149](https://github.com/mikf/gallery-dl/issues/8149))\n- [common] prevent exception when using empty `user-agent` ([#8116](https://github.com/mikf/gallery-dl/issues/8116))\n- [deviantart:search] fix extraction ([#8083](https://github.com/mikf/gallery-dl/issues/8083))\n- [hentaifoundry:story] fix `src` & `description` extraction ([#8163](https://github.com/mikf/gallery-dl/issues/8163))\n- [imagebam] update guard page bypass cookies ([#8123](https://github.com/mikf/gallery-dl/issues/8123))\n- [kemono] fix `.bin` archive files not being added to archives list ([#8156](https://github.com/mikf/gallery-dl/issues/8156))\n- [reddit] fix `TypeaError` when processing comments ([#8139](https://github.com/mikf/gallery-dl/issues/8139))\n- [tumblr] fix pagination when using `date-max`\n- [twitter] prevent exceptions in `_transform_community()` ([#8134](https://github.com/mikf/gallery-dl/issues/8134))\n- [twitter] prevent `KeyError: 'name'` in `_transform_user()` ([#8154](https://github.com/mikf/gallery-dl/issues/8154))\n- [twitter] fix `KeyError: 'core'` when processing communities ([#8141](https://github.com/mikf/gallery-dl/issues/8141))\n- [zerochan] fix `500 Internal Server Error` during login ([#8097](https://github.com/mikf/gallery-dl/issues/8097) [#8114](https://github.com/mikf/gallery-dl/issues/8114))\n#### Improvements\n- [comick] detect broken chapters ([#8054](https://github.com/mikf/gallery-dl/issues/8054))\n- [erome] handle reposts on user profiles ([#6582](https://github.com/mikf/gallery-dl/issues/6582))\n- [instagram] improve video quality warning regex ([#8078](https://github.com/mikf/gallery-dl/issues/8078))\n- [jpgfish] update domain to `jpg6.su`\n- [reddit] add `api` & `limit` options ([#7997](https://github.com/mikf/gallery-dl/issues/7997) [#8012](https://github.com/mikf/gallery-dl/issues/8012) [#8092](https://github.com/mikf/gallery-dl/issues/8092))\n- [reddit] support video embeds ([#8139](https://github.com/mikf/gallery-dl/issues/8139))\n- [tumblr:tagged] support `/archive/tagged/` URLs ([#8160](https://github.com/mikf/gallery-dl/issues/8160))\n#### Metadata\n- [khinsider] extract `description` metadata\n- [tumblr:tagged] provide `search_tags` metadata ([#8160](https://github.com/mikf/gallery-dl/issues/8160))\n- [vk] parse `date` & `description` metadata ([#8029](https://github.com/mikf/gallery-dl/issues/8029))\n- [vk:album] extract more metadata ([#8029](https://github.com/mikf/gallery-dl/issues/8029))\n### Downloaders\n- [ytdl] implement `_ytdl_manifest_cookies`\n### Miscellaneous\n- [formatter] add `R` conversion - extract URLs ([#8125](https://github.com/mikf/gallery-dl/issues/8125))\n- [options] add `-a` as short option for `--user-agent`\n- [scripts/init] implement `-s/--subcategory`\n\n## 1.30.5 - 2025-08-24\n### Extractors\n#### Additions\n- [shimmie2] support `noz.rip/booru` ([#8101](https://github.com/mikf/gallery-dl/issues/8101))\n- [sizebooru] add support ([#7667](https://github.com/mikf/gallery-dl/issues/7667))\n- [twitter] add `highlights` extractor ([#7826](https://github.com/mikf/gallery-dl/issues/7826))\n- [twitter] add `home` extractor ([#7974](https://github.com/mikf/gallery-dl/issues/7974))\n#### Fixes\n- [aryion] fix pagination ([#8091](https://github.com/mikf/gallery-dl/issues/8091))\n- [rule34] support using `api-key` & `user-id` ([#8077](https://github.com/mikf/gallery-dl/issues/8077) [#8088](https://github.com/mikf/gallery-dl/issues/8088) [#8098](https://github.com/mikf/gallery-dl/issues/8098))\n- [tumblr:search] fix `ValueError: not enough values to unpack` ([#8079](https://github.com/mikf/gallery-dl/issues/8079))\n- [twitter] handle `KeyError: 'result'` for retweets ([#8072](https://github.com/mikf/gallery-dl/issues/8072))\n- [zerochan] expect `500 Internal Server Error` responses for HTML requests ([#8097](https://github.com/mikf/gallery-dl/issues/8097))\n#### Improvements\n- [civitai:search] add `token` option ([#8093](https://github.com/mikf/gallery-dl/issues/8093))\n- [instagram] warn about lower quality video downloads ([#7921](https://github.com/mikf/gallery-dl/issues/7921) [#8078](https://github.com/mikf/gallery-dl/issues/8078))\n- [instagram] remove `candidates` warning ([#7921](https://github.com/mikf/gallery-dl/issues/7921) [#7989](https://github.com/mikf/gallery-dl/issues/7989) [#8071](https://github.com/mikf/gallery-dl/issues/8071))\n- [oauth] improve error messages ([#8086](https://github.com/mikf/gallery-dl/issues/8086))\n- [pixiv] distinguish empty from deleted profiles ([#8066](https://github.com/mikf/gallery-dl/issues/8066))\n- [twitter] update API endpoint query hashes & parameters\n#### Metadata\n- [batoto] extract more metadata ([#7994](https://github.com/mikf/gallery-dl/issues/7994))\n- [instagram:highlights] extract `author` & `owner` & `user` metadata ([#7846](https://github.com/mikf/gallery-dl/issues/7846))\n- [newgrounds] extract `slug` metadata ([#8064](https://github.com/mikf/gallery-dl/issues/8064))\n- [twitter] extract `community` metadata ([#7424](https://github.com/mikf/gallery-dl/issues/7424))\n#### Removals\n- [shimmie2] remove `sizechangebooru.com` ([#7667](https://github.com/mikf/gallery-dl/issues/7667))\n- [zzup] remove module ([#4604](https://github.com/mikf/gallery-dl/issues/4604))\n### Downloaders\n- [ytdl] improve playlist handling ([#8085](https://github.com/mikf/gallery-dl/issues/8085))\n### Scripts\n- implement `rm` helper script\n- add `-g/--git` command-line options\n- [util] add `git()` & `lines()` helper functions\n### Miscellaneous\n- [config] add `conf` argument to `config.load()` ([#8084](https://github.com/mikf/gallery-dl/issues/8084))\n\n## 1.30.4 - 2025-08-16\n### Extractors\n#### Additions\n- [civitai] add 'videos' extractor ([#6644](https://github.com/mikf/gallery-dl/issues/6644))\n#### Fixes\n- [civitai] fix posts not returning video files ([#8053](https://github.com/mikf/gallery-dl/issues/8053))\n- [civitai] fix '403 Forbidden' errors for searches\n- [kemono] use 'Accept: text/css' for API requests ([#8047](https://github.com/mikf/gallery-dl/issues/8047) [#8057](https://github.com/mikf/gallery-dl/issues/8057))\n#### Improvements\n- [newgrounds] add fallback for images with empty 'full_image_text' 'src'\n### Miscellaneous\n- fix accessing methods through 'path-metadata' proxy ([#6582](https://github.com/mikf/gallery-dl/issues/6582))\n\n## 1.30.3 - 2025-08-15\n### Extractors\n#### Additions\n- [booth] add support ([#7920](https://github.com/mikf/gallery-dl/issues/7920))\n- [civitai] add `collection` & `user-collections` extractors ([#8005](https://github.com/mikf/gallery-dl/issues/8005))\n- [facebook] add `info` extractor ([#6582](https://github.com/mikf/gallery-dl/issues/6582))\n- [facebook] add `albums` extractor ([#7848](https://github.com/mikf/gallery-dl/issues/7848))\n- [imgdrive] add `image` extractor ([#7976](https://github.com/mikf/gallery-dl/issues/7976))\n- [imgtaxi] add `image` extractor ([#8019](https://github.com/mikf/gallery-dl/issues/8019))\n- [imgwallet] add `image` extractor ([#8021](https://github.com/mikf/gallery-dl/issues/8021))\n- [picstate] add `image` extractor ([#7946](https://github.com/mikf/gallery-dl/issues/7946))\n- [silverpic] add `image` extractor ([#8020](https://github.com/mikf/gallery-dl/issues/8020))\n- [tumblr] add `following` & `followers` extractors ([#8018](https://github.com/mikf/gallery-dl/issues/8018))\n- [xasiat] add support ([#4161](https://github.com/mikf/gallery-dl/issues/4161) [#5929](https://github.com/mikf/gallery-dl/issues/5929) [#7934](https://github.com/mikf/gallery-dl/issues/7934))\n#### Fixes\n- [blogger] fix video extraction ([#7892](https://github.com/mikf/gallery-dl/issues/7892))\n- [comick] handle chapters without chapter data ([#7972](https://github.com/mikf/gallery-dl/issues/7972))\n- [comick] handle volume-only chapters ([#8043](https://github.com/mikf/gallery-dl/issues/8043))\n- [comick] fix exception when filtering by translation group ([#8045](https://github.com/mikf/gallery-dl/issues/8045))\n- [deviantart:tiptap] fix `KeyError: 'attrs'` ([#7929](https://github.com/mikf/gallery-dl/issues/7929))\n- [everia] fix image extraction ([#7973](https://github.com/mikf/gallery-dl/issues/7973) [#7977](https://github.com/mikf/gallery-dl/issues/7977))\n- [facebook] fix `avatar` extraction for empty profiles ([#7962](https://github.com/mikf/gallery-dl/issues/7962))\n- [facebook] handle profiles without photos or `set_id` ([#7962](https://github.com/mikf/gallery-dl/issues/7962))\n- [fappic] rewrite thumbnail URLs ([#8013](https://github.com/mikf/gallery-dl/issues/8013))\n- [idolcomplex] update to new domain and interface ([#7559](https://github.com/mikf/gallery-dl/issues/7559) [#8009](https://github.com/mikf/gallery-dl/issues/8009))\n- [kemono][coomer] fix extraction ([#8028](https://github.com/mikf/gallery-dl/issues/8028) [#8031](https://github.com/mikf/gallery-dl/issues/8031))\n- [kemono] update `/creators` endpoint ([#8039](https://github.com/mikf/gallery-dl/issues/8039) [#8040](https://github.com/mikf/gallery-dl/issues/8040))\n- [kemono] don't set error status for posts without comments ([#7961](https://github.com/mikf/gallery-dl/issues/7961))\n- [pixiv] fix `IndexError` for unviewable works ([#7940](https://github.com/mikf/gallery-dl/issues/7940))\n- [pixiv] fix artworks downloads when using expired cookies ([#7987](https://github.com/mikf/gallery-dl/issues/7987))\n- [scrolller] fix NSFW subreddit pagination ([#7945](https://github.com/mikf/gallery-dl/issues/7945))\n- [twitter] fix potential `UnboundLocalError` when `videos` are disabled ([#7932](https://github.com/mikf/gallery-dl/issues/7932))\n- [vsco] disable TLS 1.2 cipher suites by default ([#7984](https://github.com/mikf/gallery-dl/issues/7984) [#7986](https://github.com/mikf/gallery-dl/issues/7986))\n- [wikimedia:wiki] fix `AttributeError: 'subcategories'` ([#7931](https://github.com/mikf/gallery-dl/issues/7931))\n#### Improvements\n- [aibooru] support `general.aibooru.online` & `aibooru.download`\n- [comick] add `lang` option ([#7938](https://github.com/mikf/gallery-dl/issues/7938))\n- [hentaifoundry] add `descriptions` option ([#7952](https://github.com/mikf/gallery-dl/issues/7952))\n- [facebook] raise `AuthRequired` for profiles requiring cookies ([#7962](https://github.com/mikf/gallery-dl/issues/7962))\n- [instagram] warn about lower quality image downloads ([#7921](https://github.com/mikf/gallery-dl/issues/7921))\n- [kemono] support `\"endpoint\": \"posts+\"` for full metadata ([#8028](https://github.com/mikf/gallery-dl/issues/8028))\n- [misskey] support `misskey.art` ([#7923](https://github.com/mikf/gallery-dl/issues/7923))\n- [motherless] detect `404`/`File not found` pages\n- [pixiv] detect suspended/deleted accounts ([#7990](https://github.com/mikf/gallery-dl/issues/7990))\n- [pixiv] improve API error messages\n- [pixiv] remove redundant cookies initialization code\n- [scrolller] limit `title` length in default filenames\n- [skeb] implement `include` option ([#6558](https://github.com/mikf/gallery-dl/issues/6558) [#7267](https://github.com/mikf/gallery-dl/issues/7267))\n- [vk] update default `archive_fmt` ([#8030](https://github.com/mikf/gallery-dl/issues/8030))\n#### Metadata\n- [cien] provide `author[id]` metadata ([#6582](https://github.com/mikf/gallery-dl/issues/6582))\n- [dankefuerslesen] extract more metadata ([#7915](https://github.com/mikf/gallery-dl/issues/7915))\n- [dankefuerslesen:manga] fix metadata being overwritten\n- [facebook] ensure numeric `user_id` values ([#7953](https://github.com/mikf/gallery-dl/issues/7953))\n- [facebook:set] fix/improve `user_id` extraction ([#7848](https://github.com/mikf/gallery-dl/issues/7848))\n- [fappic] fix `filename` values\n#### Common\n- [common] implement `\"user-agent\": \"@BROWSER\"` ([#7947](https://github.com/mikf/gallery-dl/issues/7947))\n- [common] improve error message for non-Netscape cookie files ([#8014](https://github.com/mikf/gallery-dl/issues/8014))\n### Downloaders\n- [ytdl] don't overwrite existing `filename` data ([#7964](https://github.com/mikf/gallery-dl/issues/7964))\n### Miscellaneous\n- [docs/configuration] improve `client-id` & `api-key` instructions\n- [docs/formatting] update and improve\n- [job] apply `extension-map` to `SimulationJob` results ([#7954](https://github.com/mikf/gallery-dl/issues/7954))\n- [job] improve URL `scheme` extraction performance\n- [job] split collected DataJob results\n- [path] implement `path-convert` option ([#493](https://github.com/mikf/gallery-dl/issues/493) [#6582](https://github.com/mikf/gallery-dl/issues/6582))\n- [scripts] improve and extend `init`, `generate_test_result`, and `pyprint`\n- extend `-A`/`--abort` & `\"skip\": \"abort\"` functionality ([#7891](https://github.com/mikf/gallery-dl/issues/7891))\n- use more f-strings ([#7671](https://github.com/mikf/gallery-dl/issues/7671))\n\n## 1.30.2 - 2025-07-27\n### Extractors\n#### Additions\n- [itaku] add `posts` & `bookmarks` extractors ([#7707](https://github.com/mikf/gallery-dl/issues/7707))\n#### Fixes\n- [kemono] support new `kemono.cr` domain ([#7902](https://github.com/mikf/gallery-dl/issues/7902) [#7909](https://github.com/mikf/gallery-dl/issues/7909) [#7911](https://github.com/mikf/gallery-dl/issues/7911) [#7913](https://github.com/mikf/gallery-dl/issues/7913) [#7904](https://github.com/mikf/gallery-dl/issues/7904))\n- [coomer] support new `coomer.st` domain ([#7907](https://github.com/mikf/gallery-dl/issues/7907) [#7909](https://github.com/mikf/gallery-dl/issues/7909) [#7911](https://github.com/mikf/gallery-dl/issues/7911) [#7904](https://github.com/mikf/gallery-dl/issues/7904))\n### Post Processors\n- [exec] use `False` as `start_new_session` default to avoid a `TypeError` ([#7899](https://github.com/mikf/gallery-dl/issues/7899))\n### Miscellaneous\n- [tests/postprocessor] fix `TypeError` when logging an error ([#6582](https://github.com/mikf/gallery-dl/issues/6582))\n\n## 1.30.1 - 2025-07-26\n### Extractors\n#### Additions\n- [civitai] add `generated` extractor ([#7796](https://github.com/mikf/gallery-dl/issues/7796))\n- [facebook] add `avatar` extractor ([#7848](https://github.com/mikf/gallery-dl/issues/7848))\n- [imgadult] add `image` extractor ([#7893](https://github.com/mikf/gallery-dl/issues/7893))\n- [itaku] add `following` & `followers` extractors ([#7707](https://github.com/mikf/gallery-dl/issues/7707))\n- [leakgallery] add support ([#7872](https://github.com/mikf/gallery-dl/issues/7872))\n- [madokami] add `manga` extractor ([#7828](https://github.com/mikf/gallery-dl/issues/7828))\n#### Changes\n- [civitai] change default `user` includes to `[\"user-images\", \"user-videos\"]` ([#7874](https://github.com/mikf/gallery-dl/issues/7874))\n#### Fixes\n- [behance] fix `403 Forbidden` errors by using `\"browser\": \"firefox\"` ([#7803](https://github.com/mikf/gallery-dl/issues/7803) [#7877](https://github.com/mikf/gallery-dl/issues/7877))\n- [civitai] fix `AttributeError` when a file's post was deleted ([#7860](https://github.com/mikf/gallery-dl/issues/7860))\n- [pornhub] fix `gallery` extractor ([#7842](https://github.com/mikf/gallery-dl/issues/7842))\n- [readcomiconline] force `One page` reading mode ([#7890](https://github.com/mikf/gallery-dl/issues/7890))\n- [sexcom] update `search` extractor ([#7807](https://github.com/mikf/gallery-dl/issues/7807))\n- [urlgalleries] fix extraction ([#7858](https://github.com/mikf/gallery-dl/issues/7858))\n- [wikimedia] add missing `self` argument when calling `prepare()` ([#7835](https://github.com/mikf/gallery-dl/issues/7835))\n#### Improvements\n- [4chan] detect files containing only null bytes ([#7883](https://github.com/mikf/gallery-dl/issues/7883))\n- [azurelanewiki] prevent Anubis challenge\n- [bilibili] warn about blocked articles ([#7880](https://github.com/mikf/gallery-dl/issues/7880))\n- [civitai] fix `extension` for videos without `name` and `mimeType`\n- [common] detect Cloudflare & DDoS-Guard challenge pages in `request_json()` & `request_xml()` ([#7833](https://github.com/mikf/gallery-dl/issues/7833))\n- [facebook] add retries to profile page requests ([#7725](https://github.com/mikf/gallery-dl/issues/7725) [#7834](https://github.com/mikf/gallery-dl/issues/7834) [#7852](https://github.com/mikf/gallery-dl/issues/7852))\n- [facebook] implement `include` option ([#7848](https://github.com/mikf/gallery-dl/issues/7848))\n- [itaku] implement `include` option ([#7707](https://github.com/mikf/gallery-dl/issues/7707))\n- [patreon] implement `cursor` support ([#7856](https://github.com/mikf/gallery-dl/issues/7856))\n- [patreon] support `date-max` for `/home` URLs ([#7856](https://github.com/mikf/gallery-dl/issues/7856))\n- [pixiv] improve AJAX error messages ([#7896](https://github.com/mikf/gallery-dl/issues/7896))\n#### Metadata\n- [behance] provide `creator[name]` metadata ([#7885](https://github.com/mikf/gallery-dl/issues/7885))\n- [civitai] ensure `file` & `post` data has a `date` value ([#7548](https://github.com/mikf/gallery-dl/issues/7548))\n- [inkbunny] enable `pool` metadata ([#7850](https://github.com/mikf/gallery-dl/issues/7850))\n- [nhentai] provide `gallery_id` for pagination results ([#7868](https://github.com/mikf/gallery-dl/issues/7868))\n### Downloaders\n- [ytdl] add `deprecations` option\n### Post Processors\n- [exec] add `session` option ([#6582](https://github.com/mikf/gallery-dl/issues/6582))\n### Snap\n- migrate base to `core22` ([#7841](https://github.com/mikf/gallery-dl/issues/7841))\n- switch to `yt-dlp` ([#7865](https://github.com/mikf/gallery-dl/issues/7865))\n- fix deprecated `CRAFT_ARCH_TRIPLET` usage ([#7866](https://github.com/mikf/gallery-dl/issues/7866))\n### Formatter\n- add `Jinja` template support ([#1390](https://github.com/mikf/gallery-dl/issues/1390))\n- add `W` conversion - sanitize whitespace ([#6582](https://github.com/mikf/gallery-dl/issues/6582))\n### Miscellaneous\n- [actions] fix `parse_logging` import ([#7837](https://github.com/mikf/gallery-dl/issues/7837))\n- [options] add `--sleep-429` command-line option ([#7871](https://github.com/mikf/gallery-dl/issues/7871))\n- [scripts] ensure files use `utf-8` encoding and `\\n` newlines ([#7872](https://github.com/mikf/gallery-dl/issues/7872))\n- [tests/extractor] improve example URL mismatch error message ([#7872](https://github.com/mikf/gallery-dl/issues/7872))\n- [tests/results] fix `#log` checks for URLs raising exceptions\n- fix exit status for requests' `JSONDecodeError` ([#4380](https://github.com/mikf/gallery-dl/issues/4380))\n- use walrus operators `:=` in `if` statements ([#7671](https://github.com/mikf/gallery-dl/issues/7671))\n\n## 1.30.0 - 2025-07-15\n### Changes\n- raise minimum supported Python version to 3.8 ([#7671](https://github.com/mikf/gallery-dl/issues/7671))\n- update extractor names ([#7746](https://github.com/mikf/gallery-dl/issues/7746) [#7612](https://github.com/mikf/gallery-dl/issues/7612))\n  - |         Old            | New                    |\n    |------------------------|------------------------|\n    | `kemonoparty`          | `kemono`               |\n    | `coomerparty`          | `coomer`               |\n    | `koharu`               | `schalenetwork`        |\n    | `naver`                | `naver-blog`           |\n    | `chzzk`                | `naver-chzzk`          |\n    | `naverwebtoon`         | `naver-webtoon`        |\n    | `pixiv:novel`          | `pixiv-novel:novel`    |\n    | `pixiv:novel-user`     | `pixiv-novel:user`     |\n    | `pixiv:novel-series`   | `pixiv-novel:series`   |\n    | `pixiv:novel-bookmark` | `pixiv-novel:bookmark` |\n  - config settings will automatically use the old values\n  - target directories using `{category}` will use the *new* category names by default\n  - use `--compat` or `\"category-map\": \"compat\"` to restore old `category` names\n- include exit status bitmasks of `NotFoundError` (`8`) and `NoExtractorError` (`64`)\n  into general `HttpError` (`4`) and `InputError` (`32`) respectively\n### Extractors\n#### Additions\n- [civitai] add `search-images` and `posts` extractors ([#7609](https://github.com/mikf/gallery-dl/issues/7609))\n- [comick] add support ([#1825](https://github.com/mikf/gallery-dl/issues/1825) [#6782](https://github.com/mikf/gallery-dl/issues/6782))\n- [dankefuerslesen] add support ([#7669](https://github.com/mikf/gallery-dl/issues/7669))\n- [dynastyscans] add `anthology` extractor ([#7627](https://github.com/mikf/gallery-dl/issues/7627))\n- [girlsreleased] add support ([#6200](https://github.com/mikf/gallery-dl/issues/6200))\n- [girlswithmuscle] add support ([#4493](https://github.com/mikf/gallery-dl/issues/4493) [#6016](https://github.com/mikf/gallery-dl/issues/6016))\n- [iwara] add support ([#2652](https://github.com/mikf/gallery-dl/issues/2652) [#5840](https://github.com/mikf/gallery-dl/issues/5840) [#7785](https://github.com/mikf/gallery-dl/issues/7785))\n- [kemono] add `artists` extractor ([#7582](https://github.com/mikf/gallery-dl/issues/7582))\n- [misskey] add `avatar`, `background`, and `info` extractors ([#5347](https://github.com/mikf/gallery-dl/issues/5347))\n- [motherless] add `group` extractor ([#7774](https://github.com/mikf/gallery-dl/issues/7774) [#7787](https://github.com/mikf/gallery-dl/issues/7787))\n- [naver-chzzk] add `comment` and `community` extractors ([#7735](https://github.com/mikf/gallery-dl/issues/7735) [#7741](https://github.com/mikf/gallery-dl/issues/7741))\n- [nudostar] add support ([#5735](https://github.com/mikf/gallery-dl/issues/5735) [#6556](https://github.com/mikf/gallery-dl/issues/6556))\n- [rawkuma] add support ([#4571](https://github.com/mikf/gallery-dl/issues/4571))\n- [redbust] add support ([#6759](https://github.com/mikf/gallery-dl/issues/6759) [#6918](https://github.com/mikf/gallery-dl/issues/6918) [#7043](https://github.com/mikf/gallery-dl/issues/7043))\n#### Fixes\n- [4archive] fix `thread` extractor\n- [arcalive] fix download URLs ([#7678](https://github.com/mikf/gallery-dl/issues/7678))\n- [arcalive] replace `ac-p.namu` subdomains with `ac-o.namu` ([#7556](https://github.com/mikf/gallery-dl/issues/7556))\n- [archivedmoe] fix redirection issue ([#7652](https://github.com/mikf/gallery-dl/issues/7652) [#7653](https://github.com/mikf/gallery-dl/issues/7653) [#7664](https://github.com/mikf/gallery-dl/issues/7664))\n- [aryion] fix `favorite` extractor ([#7775](https://github.com/mikf/gallery-dl/issues/7775))\n- [batoto] fix downloading manga with alerts/notices ([#7657](https://github.com/mikf/gallery-dl/issues/7657))\n- [behance] fix `403 Forbidden` errors ([#7710](https://github.com/mikf/gallery-dl/issues/7710))\n- [bunkr] fix file downloads ([#7747](https://github.com/mikf/gallery-dl/issues/7747))\n- [civitai] fix & update `search` extractor ([#7609](https://github.com/mikf/gallery-dl/issues/7609))\n- [danbooru] fix Ugoira conversions for posts without `ZIP:ZipFileName` ([#7630](https://github.com/mikf/gallery-dl/issues/7630))\n- [deviantart:tag] fix `username` ([#7587](https://github.com/mikf/gallery-dl/issues/7587))\n- [deviantart:tiptap] fix `TypeError` when `textAlign` is null ([#7639](https://github.com/mikf/gallery-dl/issues/7639))\n- [directlink] fix config lookups by subcategory ([#6582](https://github.com/mikf/gallery-dl/issues/6582))\n- [discord] support forwarded messages & handle missing threads ([#7706](https://github.com/mikf/gallery-dl/issues/7706) [#7722](https://github.com/mikf/gallery-dl/issues/7722))\n- [furaffinity] fix `submissions` results ([#7759](https://github.com/mikf/gallery-dl/issues/7759))\n- [hitomi] fix negative tag searches ([#7694](https://github.com/mikf/gallery-dl/issues/7694))\n- [kemono] fix tagged creator posts\n- [mangadex:list] fix config lookups for `list-feed` subcategory\n- [nijie] fix file extraction ([#7624](https://github.com/mikf/gallery-dl/issues/7624))\n- [paheal] fix `404 Not Found` error for tags with URL encoded characters ([#7642](https://github.com/mikf/gallery-dl/issues/7642))\n- [patreon] send `Referer` header when downloading `.m3u8` videos ([#7571](https://github.com/mikf/gallery-dl/issues/7571))\n- [patreon] fix `campaign_id` extraction from Next.js 13 creator pages ([#7773](https://github.com/mikf/gallery-dl/issues/7773))\n- [readcomiconline] fix extraction ([#7606](https://github.com/mikf/gallery-dl/issues/7606) [#7789](https://github.com/mikf/gallery-dl/issues/7789))\n- [reddit] fix archive IDs of fallback files ([#7760](https://github.com/mikf/gallery-dl/issues/7760))\n- [rule34] fix file downloads ([#7697](https://github.com/mikf/gallery-dl/issues/7697))\n- [sankaku] fix extracting extended tag categories ([#7744](https://github.com/mikf/gallery-dl/issues/7744))\n- [sexcom] prevent `.css` file downloads ([#7632](https://github.com/mikf/gallery-dl/issues/7632))\n- [skeb] fix `KeyError - 'frame_rate'` ([#7798](https://github.com/mikf/gallery-dl/issues/7798))\n- [tiktok] handle exceptions when extracting avatars ([#7682](https://github.com/mikf/gallery-dl/issues/7682))\n- [vsco] fix JSON returned by VSCO ([#7821](https://github.com/mikf/gallery-dl/issues/7821))\n- [warosu] HTML attribute fix ([#7676](https://github.com/mikf/gallery-dl/issues/7676) [#7677](https://github.com/mikf/gallery-dl/issues/7677) [#7777](https://github.com/mikf/gallery-dl/issues/7777))\n#### Improvements\n- [artstation] support downloading `.mview` files ([#7812](https://github.com/mikf/gallery-dl/issues/7812))\n- [civitai] support \"My Reactions\" results for videos ([#7608](https://github.com/mikf/gallery-dl/issues/7608))\n- [e621] support `e621.cc/posts` URLs ([#6809](https://github.com/mikf/gallery-dl/issues/6809))\n- [erome] restructure extractor hierarchy ([#7804](https://github.com/mikf/gallery-dl/issues/7804))\n- [everia] prevent redirect when fetching post pages\n- [exhentai] ensure file signature bytes aren`t all zero ([#4902](https://github.com/mikf/gallery-dl/issues/4902))\n- [exhentai] implement `\"source\": \"metadata\"` ([#4902](https://github.com/mikf/gallery-dl/issues/4902))\n- [fanbox] return `fileMap` files in order ([#2718](https://github.com/mikf/gallery-dl/issues/2718))\n- [gelbooru] improve error message for `401 Unauthorized` responses ([#7674](https://github.com/mikf/gallery-dl/issues/7674))\n- [imagevenue] detect `404` image files ([#7570](https://github.com/mikf/gallery-dl/issues/7570))\n- [instagram] provide more descriptive URLs for `video_dash_manifest` videos ([#7631](https://github.com/mikf/gallery-dl/issues/7631))\n- [pinterest] support `pin.it` redirects to board ([#7805](https://github.com/mikf/gallery-dl/issues/7805))\n- [pinterest] match board URLs with query strings ([#7805](https://github.com/mikf/gallery-dl/issues/7805))\n- [rule34us] prioritize `video.rule34.us` for video downloads ([#6582](https://github.com/mikf/gallery-dl/issues/6582))\n- [rule34xyz] implement login with username & password ([#7736](https://github.com/mikf/gallery-dl/issues/7736))\n- [sankaku] allow passing cookies ([#7333](https://github.com/mikf/gallery-dl/issues/7333))\n- [sexcom] support `/pics/` URLs ([#7611](https://github.com/mikf/gallery-dl/issues/7611))\n- [tiktok] detect `login` page redirects ([#7716](https://github.com/mikf/gallery-dl/issues/7716))\n- [vk] detect `challenge` page redirects ([#7650](https://github.com/mikf/gallery-dl/issues/7650))\n- [vk] prevent `404 Not Found` errors for file downloads\n- [vk] add continuation message ([#7650](https://github.com/mikf/gallery-dl/issues/7650))\n- [warosu] detect missing images by checking hostname ([#7698](https://github.com/mikf/gallery-dl/issues/7698) [#7699](https://github.com/mikf/gallery-dl/issues/7699))\n- [ytdl] set domain as subcategory when using `Generic` extractor ([#6582](https://github.com/mikf/gallery-dl/issues/6582))\n#### Metadata\n- [civitai] always provide `file[…]` metadata ([#7548](https://github.com/mikf/gallery-dl/issues/7548))\n- [everia] improve `filename` by unquoting URLs ([#7620](https://github.com/mikf/gallery-dl/issues/7620))\n- [fanbox] extract `archives` metadata ([#7454](https://github.com/mikf/gallery-dl/issues/7454))\n- [gelbooru_v02] extract `total`/`search_count` metadata ([#7689](https://github.com/mikf/gallery-dl/issues/7689))\n- [instagram] provide `post_url` for stories and highlights ([#7810](https://github.com/mikf/gallery-dl/issues/7810))\n- [kemono:discord] update server & channel metadata ([#7569](https://github.com/mikf/gallery-dl/issues/7569))\n- [mangaread] fix `manga_alt` metadata\n- [newgrounds] filter `<script>` content in `tags` ([#7604](https://github.com/mikf/gallery-dl/issues/7604))\n- [patreon] return metadata for paywalled posts ([#6582](https://github.com/mikf/gallery-dl/issues/6582))\n- [pinterest] remove excess whitespace from `description` fields ([#4335](https://github.com/mikf/gallery-dl/issues/4335))\n- [pixiv] remove `/jump.php` from `caption` links ([#4327](https://github.com/mikf/gallery-dl/issues/4327))\n- [tenor] extract more metadata\n- [twitter] extract `source_id` and `source_user` metadata ([#7470](https://github.com/mikf/gallery-dl/issues/7470) [#7640](https://github.com/mikf/gallery-dl/issues/7640))\n- [twitter] extract `sensitive_flags` metadata ([#2523](https://github.com/mikf/gallery-dl/issues/2523))\n- [vk] fix `user` metadata extraction\n#### Options\n- [civitai] add option to retrieve `post` metadata ([#7548](https://github.com/mikf/gallery-dl/issues/7548))\n- [exhentai] add `limits-action` option ([#6504](https://github.com/mikf/gallery-dl/issues/6504))\n- [fanbox] add `fee-max` option ([#7726](https://github.com/mikf/gallery-dl/issues/7726))\n- [kemono] extend `duplicates` option ([#7696](https://github.com/mikf/gallery-dl/issues/7696))\n- [mangadex] allow `ratings` to be a (comma-separated) string ([#7799](https://github.com/mikf/gallery-dl/issues/7799))\n- [misskey] add `include` option ([#5347](https://github.com/mikf/gallery-dl/issues/5347))\n- [sankaku] remove `id-format` option ([#5073](https://github.com/mikf/gallery-dl/issues/5073) [#6808](https://github.com/mikf/gallery-dl/issues/6808))\n- [webtoons] add `banners` and `thumbnails` options ([#6468](https://github.com/mikf/gallery-dl/issues/6468) [#7441](https://github.com/mikf/gallery-dl/issues/7441))\n#### Common\n- update `browser` User-Agents and headers\n- allow using predefined Firefox/Chrome `headers` & `ciphers`\n- allow overriding `user-agent` when `browser` is used ([#7647](https://github.com/mikf/gallery-dl/issues/7647))\n- support using system certificates via `truststore` ([#6582](https://github.com/mikf/gallery-dl/issues/6582))\n- fix URLs not getting written to `-e/--error-file` ([#7758](https://github.com/mikf/gallery-dl/issues/7758))\n- raise ChallengeError for Cloudflare & DDoS-Guard challenge pages ([#1945](https://github.com/mikf/gallery-dl/issues/1945))\n- prevent exceptions for for non-fatal requests ([#7598](https://github.com/mikf/gallery-dl/issues/7598))\n- simplify `user` extractors by using `Dispatch` mixin\n- allow `GalleryExtractor` instances to return additional asset files\n#### Removals\n- [mangasee] remove module\n### Downloaders\n- support dynamic download `rate` limits ([#7638](https://github.com/mikf/gallery-dl/issues/7638))\n- [http] fail downloads with HTML content ([#4798](https://github.com/mikf/gallery-dl/issues/4798) [#7697](https://github.com/mikf/gallery-dl/issues/7697))\n- [http] add MIME type and signature check for `.html` files\n- [http] fix potential `FileExistsError` when `.part` file moved ([#5385](https://github.com/mikf/gallery-dl/issues/5385))\n- [http] implement `_http_signature` checks ([#4902](https://github.com/mikf/gallery-dl/issues/4902))\n- [ytdl] fix `KeyError - 'filepath'` when using legacy `youtube_dl` ([#6949](https://github.com/mikf/gallery-dl/issues/6949) [#7752](https://github.com/mikf/gallery-dl/issues/7752) [#7824](https://github.com/mikf/gallery-dl/issues/7824))\n- [ytdl] fix postprocessing/merge errors ([#7581](https://github.com/mikf/gallery-dl/issues/7581))\n- [ytdl] detect `yt-dlp` independent of module name ([#7599](https://github.com/mikf/gallery-dl/issues/7599))\n- [ytdl] support custom headers when fetching HLS/DASH manifests\n### Post Processors\n- implement shortcuts for `mode` and `event` options (e.g. `metadata/jsonl@post`)\n- [exec] implement `commands` option\n### Options\n- add `category-map` and `config-map` ([#7612](https://github.com/mikf/gallery-dl/issues/7612))\n- add `signals-actions` ([#1861](https://github.com/mikf/gallery-dl/issues/1861) [#6582](https://github.com/mikf/gallery-dl/issues/6582))\n- add `--compat` command-line option\n- add `--Print` command-line option\n- swap `--print` and `--Print` semantics\n### Cookies\n- add native support for LibreWolf profiles ([#4101](https://github.com/mikf/gallery-dl/issues/4101) [#7625](https://github.com/mikf/gallery-dl/issues/7625))\n- improve cookie-related logging messages\n- update expired cookie messages ([#7644](https://github.com/mikf/gallery-dl/issues/7644))\n### Formatter\n- add `D` conversion - ISO 8601 string to `datetime`\n- add `L` conversion - ISO 639-1 code to language name\n- change old `L`/length conversion to `n`\n- implement `M` format specifier\n### Optimizations\n- replace `%`-formatted and `.format(…)` strings with `f-strings` ([#7671](https://github.com/mikf/gallery-dl/issues/7671))\n- improve regular expression usage\n- replace `match.group(N)` with `match[N]` ([#7671](https://github.com/mikf/gallery-dl/issues/7671))\n- update `match.lastindex` usage\n- remove pre-3.8 workarounds ([#7671](https://github.com/mikf/gallery-dl/issues/7671))\n- remove `@staticmethod` decorators\n- add `request_json()` and `request_xml()` functions\n- slightly improve performance of `extract` functions\n- slightly improve `filter` performance\n### Miscellaneous\n- [actions] add `flag` and `raise` actions\n- [job] refactor parent-child config path building ([#7527](https://github.com/mikf/gallery-dl/issues/7527))\n- [job:data] wrap exceptions in a dict ([#7723](https://github.com/mikf/gallery-dl/issues/7723))\n- [path] support character sequences in `path-restrict` replacements ([#1707](https://github.com/mikf/gallery-dl/issues/1707))\n- [pyinstaller] exclude `pkg_resources` module ([#7592](https://github.com/mikf/gallery-dl/issues/7592))\n- [scripts/init] add scripts to generate initial extractor code and test results\n- [scripts/options] make output width independent of terminal size\n- fix Last-Modified mtime overwriting post processor mtime ([#7529](https://github.com/mikf/gallery-dl/issues/7529))\n- use `pip` for `make install` ([#7628](https://github.com/mikf/gallery-dl/issues/7628))\n\n## 1.29.7 - 2025-05-23\n### Extractors\n#### Additions\n- [mangadex] add `following` extractor ([#7487](https://github.com/mikf/gallery-dl/issues/7487))\n- [pixeldrain] add support for filesystem URLs ([#7473](https://github.com/mikf/gallery-dl/issues/7473))\n#### Fixes\n- [bluesky] handle posts without `record` data ([#7499](https://github.com/mikf/gallery-dl/issues/7499))\n- [civitai] fix & improve video downloads ([#7502](https://github.com/mikf/gallery-dl/issues/7502))\n- [civitai] fix exception for images without `modelVersionId` ([#7432](https://github.com/mikf/gallery-dl/issues/7432))\n- [civitai] make metadata extraction non-fatal ([#7562](https://github.com/mikf/gallery-dl/issues/7562))\n- [fanbox] use `\"browser\": \"firefox\"` by default ([#7490](https://github.com/mikf/gallery-dl/issues/7490))\n- [idolcomplex] fix pagination logic ([#7549](https://github.com/mikf/gallery-dl/issues/7549))\n- [idolcomplex] fix 429 error during login by adding a 10s delay\n- [instagram:stories] fix `post_date` metadata ([#7521](https://github.com/mikf/gallery-dl/issues/7521))\n- [motherless] fix video gallery downloads ([#7530](https://github.com/mikf/gallery-dl/issues/7530))\n- [pinterest] handle `story_pin_product_sticker_block` blocks ([#7563](https://github.com/mikf/gallery-dl/issues/7563))\n- [subscribestar] fix `content` and `title` metadata ([#7486](https://github.com/mikf/gallery-dl/issues/7486) [#7526](https://github.com/mikf/gallery-dl/issues/7526))\n#### Improvements\n- [arcalive] allow overriding default `User-Agent` header ([#7556](https://github.com/mikf/gallery-dl/issues/7556))\n- [fanbox] update API headers ([#7490](https://github.com/mikf/gallery-dl/issues/7490))\n- [flickr] add `info` option ([#4720](https://github.com/mikf/gallery-dl/issues/4720) [#6817](https://github.com/mikf/gallery-dl/issues/6817))\n- [flickr] add `profile` option\n- [instagram:stories] add `split` option ([#7521](https://github.com/mikf/gallery-dl/issues/7521))\n- [mangadex] implement login with client credentials\n- [mangadex] send `Authorization` header only when necessary\n- [mastodon] support Akkoma/Pleroma `/notice/:ID` URLs ([#7496](https://github.com/mikf/gallery-dl/issues/7496))\n- [mastodon] support Akkoma/Pleroma `/objects/:UUID` URLs ([#7497](https://github.com/mikf/gallery-dl/issues/7497))\n- [pixiv] Implement sanity handling for ugoira works ([#4327](https://github.com/mikf/gallery-dl/issues/4327) [#6297](https://github.com/mikf/gallery-dl/issues/6297) [#7285](https://github.com/mikf/gallery-dl/issues/7285) [#7434](https://github.com/mikf/gallery-dl/issues/7434))\n- [twitter:ctid] reduce chance of generating the same ID\n#### Metadata\n- [civitai] provide proper `extension` for model files ([#7432](https://github.com/mikf/gallery-dl/issues/7432))\n- [flickr] provide `license_name` metadata\n- [sankaku] support new `tags` categories ([#7333](https://github.com/mikf/gallery-dl/issues/7333) [#7553](https://github.com/mikf/gallery-dl/issues/7553))\n- [vipergirls] provide `num` and `count` metadata ([#7479](https://github.com/mikf/gallery-dl/issues/7479))\n- [vipergirls] extract more metadata & rename fields ([#7479](https://github.com/mikf/gallery-dl/issues/7479))\n### Downloaders\n- [http] fix setting `mtime` per file ([#7529](https://github.com/mikf/gallery-dl/issues/7529))\n- [ytdl] improve temp/part file handling ([#6949](https://github.com/mikf/gallery-dl/issues/6949) [#7494](https://github.com/mikf/gallery-dl/issues/7494))\n### Cookies\n- support Zen browser ([#7233](https://github.com/mikf/gallery-dl/issues/7233) [#7546](https://github.com/mikf/gallery-dl/issues/7546))\n\n## 1.29.6 - 2025-05-04\n### Extractors\n#### Additions\n- [manganelo] support `nelomanga.net` and mirror domains ([#7423](https://github.com/mikf/gallery-dl/issues/7423))\n#### Fixes\n- [deviantart] unescape `\\'` in JSON data ([#6653](https://github.com/mikf/gallery-dl/issues/6653))\n- [kemonoparty] revert to using default creator posts endpoint ([#7438](https://github.com/mikf/gallery-dl/issues/7438) [#7450](https://github.com/mikf/gallery-dl/issues/7450) [#7462](https://github.com/mikf/gallery-dl/issues/7462))\n- [pixiv:novel] fix `embeds` extraction by using AJAX API ([#7422](https://github.com/mikf/gallery-dl/issues/7422) [#7435](https://github.com/mikf/gallery-dl/issues/7435))\n- [scrolller] fix exception for albums with missing media ([#7428](https://github.com/mikf/gallery-dl/issues/7428))\n- [twitter] fix `404 Not Found ()` errors ([#7382](https://github.com/mikf/gallery-dl/issues/7382) [#7386](https://github.com/mikf/gallery-dl/issues/7386) [#7426](https://github.com/mikf/gallery-dl/issues/7426) [#7430](https://github.com/mikf/gallery-dl/issues/7430) [#7431](https://github.com/mikf/gallery-dl/issues/7431) [#7445](https://github.com/mikf/gallery-dl/issues/7445) [#7459](https://github.com/mikf/gallery-dl/issues/7459))\n#### Improvements\n- [kemonoparty] add `endpoint` option ([#7438](https://github.com/mikf/gallery-dl/issues/7438) [#7450](https://github.com/mikf/gallery-dl/issues/7450) [#7462](https://github.com/mikf/gallery-dl/issues/7462))\n- [tumblr] improve error message for dashboard-only blogs ([#7455](https://github.com/mikf/gallery-dl/issues/7455))\n- [weasyl] support `/view/` URLs ([#7469](https://github.com/mikf/gallery-dl/issues/7469))\n#### Metadata\n- [chevereto] extract `date` metadata ([#7437](https://github.com/mikf/gallery-dl/issues/7437))\n- [civitai] implement retrieving `model` and `version` metadata ([#7432](https://github.com/mikf/gallery-dl/issues/7432))\n- [manganelo] extract more metadata\n### Post Processors\n- [directory] add `directory` post processor ([#7432](https://github.com/mikf/gallery-dl/issues/7432))\n### Miscellaneous\n- [job] do not reset skip count when `skip-filter` fails ([#7433](https://github.com/mikf/gallery-dl/issues/7433))\n\n## 1.29.5 - 2025-04-26\n### Extractors\n#### Additions\n- [bluesky] add `video` extractor ([#4438](https://github.com/mikf/gallery-dl/issues/4438))\n- [instagram] add `followers` extractor ([#7374](https://github.com/mikf/gallery-dl/issues/7374))\n- [itaku] add `stars` extractor ([#7411](https://github.com/mikf/gallery-dl/issues/7411))\n- [pictoa] add support ([#6683](https://github.com/mikf/gallery-dl/issues/6683) [#7409](https://github.com/mikf/gallery-dl/issues/7409))\n- [twitter] add `followers` extractor ([#6331](https://github.com/mikf/gallery-dl/issues/6331))\n#### Fixes\n- [architizer] fix `project` extractor ([#7421](https://github.com/mikf/gallery-dl/issues/7421))\n- [bluesky:likes] fix infinite loop ([#7194](https://github.com/mikf/gallery-dl/issues/7194) [#7287](https://github.com/mikf/gallery-dl/issues/7287))\n- [deviantart] fix `401 Unauthorized` errors for for multi-image posts ([#6653](https://github.com/mikf/gallery-dl/issues/6653))\n- [everia] fix `title` extraction ([#7379](https://github.com/mikf/gallery-dl/issues/7379))\n- [fanbox] fix `comments` extraction\n- [fapello] stop pagination on empty results ([#7385](https://github.com/mikf/gallery-dl/issues/7385))\n- [kemonoparty] fix `archives` option ([#7416](https://github.com/mikf/gallery-dl/issues/7416) [#7419](https://github.com/mikf/gallery-dl/issues/7419))\n- [pixiv] fix `user_details` requests not being cached ([#7414](https://github.com/mikf/gallery-dl/issues/7414))\n- [pixiv:novel] handle exceptions during `embeds` extraction ([#7422](https://github.com/mikf/gallery-dl/issues/7422))\n- [subscribestar] fix username & password login\n- [wikifeet] support site redesign ([#7286](https://github.com/mikf/gallery-dl/issues/7286) [#7396](https://github.com/mikf/gallery-dl/issues/7396))\n#### Improvements\n- [bluesky:likes] use `repo.listRecords` endpoint ([#7194](https://github.com/mikf/gallery-dl/issues/7194) [#7287](https://github.com/mikf/gallery-dl/issues/7287))\n- [gelbooru] don't hardcode image server domains ([#7392](https://github.com/mikf/gallery-dl/issues/7392))\n- [instagram] support `/share/` URLs ([#7241](https://github.com/mikf/gallery-dl/issues/7241))\n- [kemonoparty] use `/posts-legacy` endpoint ([#6780](https://github.com/mikf/gallery-dl/issues/6780) [#6931](https://github.com/mikf/gallery-dl/issues/6931) [#7404](https://github.com/mikf/gallery-dl/issues/7404))\n- [naver] support videos ([#4682](https://github.com/mikf/gallery-dl/issues/4682) [#7395](https://github.com/mikf/gallery-dl/issues/7395))\n- [scrolller] support album posts ([#7339](https://github.com/mikf/gallery-dl/issues/7339))\n- [subscribestar] add warning for missing login cookie\n- [twitter] update API endpoint query hashes ([#7382](https://github.com/mikf/gallery-dl/issues/7382) [#7386](https://github.com/mikf/gallery-dl/issues/7386))\n- [weasyl] use `gallery-dl` User-Agent header ([#7412](https://github.com/mikf/gallery-dl/issues/7412))\n#### Metadata\n- [deviantart:stash] extract more metadata ([#7397](https://github.com/mikf/gallery-dl/issues/7397))\n- [moebooru:pool] replace underscores in pool names ([#4646](https://github.com/mikf/gallery-dl/issues/4646))\n- [naver] fix recent `date` bug ([#4682](https://github.com/mikf/gallery-dl/issues/4682))\n### Post Processors\n- [ugoira] restore `keep-files` functionality ([#7304](https://github.com/mikf/gallery-dl/issues/7304))\n- [ugoira] support `\"keep-files\": true` + custom extension ([#7304](https://github.com/mikf/gallery-dl/issues/7304))\n- [ugoira] use `_ugoira_frame_index` to detect `.zip` files\n### Miscellaneous\n- [util] auto-update Chrome version\n- use internal version of `re.compile()` for extractor patterns\n\n## 1.29.4 - 2025-04-13\n### Extractors\n#### Additions\n- [chevereto] support `imagepond.net` ([#7278](https://github.com/mikf/gallery-dl/issues/7278))\n- [webtoons] add `artist` extractor ([#7274](https://github.com/mikf/gallery-dl/issues/7274))\n#### Fixes\n- [deviantart] fix `KeyError: 'has_subfolders'` ([#7272](https://github.com/mikf/gallery-dl/issues/7272) [#7337](https://github.com/mikf/gallery-dl/issues/7337))\n- [discord] fix `parent` keyword inconsistency ([#7341](https://github.com/mikf/gallery-dl/issues/7341) [#7353](https://github.com/mikf/gallery-dl/issues/7353))\n- [E621:pool] fix `AttributeError` ([#7265](https://github.com/mikf/gallery-dl/issues/7265) [#7344](https://github.com/mikf/gallery-dl/issues/7344))\n- [everia] fix/improve image extraction ([#7270](https://github.com/mikf/gallery-dl/issues/7270))\n- [gelbooru] fix video URLs ([#7345](https://github.com/mikf/gallery-dl/issues/7345))\n- [hentai2read] fix `AttributeError` exception for chapters without artist ([#7355](https://github.com/mikf/gallery-dl/issues/7355))\n- [issuu] fix extractors ([#7317](https://github.com/mikf/gallery-dl/issues/7317))\n- [kemonoparty] fix file paths with backslashes ([#7321](https://github.com/mikf/gallery-dl/issues/7321))\n- [readcomiconline] fix `issue` extractor ([#7269](https://github.com/mikf/gallery-dl/issues/7269) [#7330](https://github.com/mikf/gallery-dl/issues/7330))\n- [rule34xyz] update to API v2 ([#7289](https://github.com/mikf/gallery-dl/issues/7289))\n- [zerochan] fix `KeyError: 'author'` ([#7282](https://github.com/mikf/gallery-dl/issues/7282))\n#### Improvements\n- [instagram] use Chrome `User-Agent` by default ([#6379](https://github.com/mikf/gallery-dl/issues/6379))\n- [pixiv] support `phixiv.net` URLs ([#7352](https://github.com/mikf/gallery-dl/issues/7352))\n- [tumblr] support URLs without subdomain ([#7358](https://github.com/mikf/gallery-dl/issues/7358))\n- [webtoons] download JPEG files in higher quality\n- [webtoons] use a default 0.5-1.5s delay between requests ([#7329](https://github.com/mikf/gallery-dl/issues/7329))\n- [zzup] support `w.zzup.com` URLs ([#7327](https://github.com/mikf/gallery-dl/issues/7327))\n### Downloaders\n- [ytdl] fix `KeyError: 'extractor'` exception when `ytdl` reports an error ([#7301](https://github.com/mikf/gallery-dl/issues/7301))\n### Post Processors\n- [metadata] add `metadata-path` option ([#6582](https://github.com/mikf/gallery-dl/issues/6582))\n- [metadata] fix handling of empty directory paths ([#7296](https://github.com/mikf/gallery-dl/issues/7296))\n- [ugoira] preserve `extension` when using `\"mode\": \"archive\"` ([#7304](https://github.com/mikf/gallery-dl/issues/7304))\n### Miscellaneous\n- [formatter] add `i` and `f` conversions ([#6582](https://github.com/mikf/gallery-dl/issues/6582))\n\n## 1.29.3 - 2025-03-29\n### Extractors\n#### Additions\n- [danbooru] add `favgroup` extractor\n- [imhentai] support `hentaienvy.com` and `hentaizap.com` ([#7192](https://github.com/mikf/gallery-dl/issues/7192) [#7218](https://github.com/mikf/gallery-dl/issues/7218))\n#### Fixes\n- [bunkr] fix `filename` extraction ([#7237](https://github.com/mikf/gallery-dl/issues/7237))\n- [deviantart:stash] fix legacy `sta.sh` links ([#7181](https://github.com/mikf/gallery-dl/issues/7181))\n- [hitomi] fix extractors ([#7230](https://github.com/mikf/gallery-dl/issues/7230))\n- [mangapark] fix extractors ([#4999](https://github.com/mikf/gallery-dl/issues/4999) [#5883](https://github.com/mikf/gallery-dl/issues/5883) [#6507](https://github.com/mikf/gallery-dl/issues/6507) [#6908](https://github.com/mikf/gallery-dl/issues/6908) [#7232](https://github.com/mikf/gallery-dl/issues/7232))\n- [nozomi] fix extractors ([#7242](https://github.com/mikf/gallery-dl/issues/7242))\n- [patreon] include subdomains in `session_id` cookie check ([#7188](https://github.com/mikf/gallery-dl/issues/7188))\n- [patreon] do not match `/messages` URLs as creator ([#7187](https://github.com/mikf/gallery-dl/issues/7187))\n- [pinterest] handle `story_pin_static_sticker_block` blocks ([#7251](https://github.com/mikf/gallery-dl/issues/7251))\n- [sexcom] fix `gif` pin extraction ([#7239](https://github.com/mikf/gallery-dl/issues/7239))\n- [skeb] make exceptions when extracting posts non-fatal ([#7250](https://github.com/mikf/gallery-dl/issues/7250))\n- [zerochan] parse `JSON-LD` data ([#7178](https://github.com/mikf/gallery-dl/issues/7178))\n#### Improvements\n- [arcalive] extend `gifs` option\n- [deviantart] support multiple images for single posts ([#6653](https://github.com/mikf/gallery-dl/issues/6653) [#7261](https://github.com/mikf/gallery-dl/issues/7261))\n- [deviantart] add subfolder support ([#4988](https://github.com/mikf/gallery-dl/issues/4988) [#7185](https://github.com/mikf/gallery-dl/issues/7185) [#7220](https://github.com/mikf/gallery-dl/issues/7220))\n- [deviantart] match `/gallery/recommended-for-you` URLs ([#7168](https://github.com/mikf/gallery-dl/issues/7168) [#7243](https://github.com/mikf/gallery-dl/issues/7243))\n- [instagram] extract videos from `video_dash_manifest` data ([#6379](https://github.com/mikf/gallery-dl/issues/6379) [#7006](https://github.com/mikf/gallery-dl/issues/7006))\n- [mangapark] support mirror domains\n- [mangapark] support v3 URLs ([#2072](https://github.com/mikf/gallery-dl/issues/2072))\n- [mastodon] support `/statuses` URLs ([#7255](https://github.com/mikf/gallery-dl/issues/7255))\n- [sexcom] support new-style `/gifs` and `/videos` URLs ([#7239](https://github.com/mikf/gallery-dl/issues/7239))\n- [subscribestar] detect redirects to `/age_confirmation_warning` pages\n- [tiktok] add retry mechanism to rehydration data extraction ([#7191](https://github.com/mikf/gallery-dl/issues/7191))\n#### Metadata\n- [bbc] extract more metadata ([#6582](https://github.com/mikf/gallery-dl/issues/6582))\n- [kemonoparty] extract `archives` metadata ([#7195](https://github.com/mikf/gallery-dl/issues/7195))\n- [kemonoparty] enable `username`/`user_profile` metadata by default\n- [kemonoparty:discord] always provide `channel_name` metadata ([#7245](https://github.com/mikf/gallery-dl/issues/7245))\n- [sexcom] extract `date_url` metadata ([#7239](https://github.com/mikf/gallery-dl/issues/7239))\n- [subscribestar] extract `title` metadata ([#7219](https://github.com/mikf/gallery-dl/issues/7219))\n### Downloaders\n- [ytdl] support processing inline HLS/DASH manifest data ([#6379](https://github.com/mikf/gallery-dl/issues/6379) [#7006](https://github.com/mikf/gallery-dl/issues/7006))\n### Miscellaneous\n- [aes] simplify `block_count` calculation\n- [common] add `subdomains` argument to `cookies_check()` ([#7188](https://github.com/mikf/gallery-dl/issues/7188))\n- [config] fix using the same key multiple times with `apply` ([#7127](https://github.com/mikf/gallery-dl/issues/7127))\n- [tests] implement expected failures\n\n## 1.29.2 - 2025-03-15\n### Extractors\n#### Additions\n- [arcalive] add support ([#5657](https://github.com/mikf/gallery-dl/issues/5657) [#7100](https://github.com/mikf/gallery-dl/issues/7100))\n- [furaffinity] add `folder` extractor ([#1817](https://github.com/mikf/gallery-dl/issues/1817) [#7159](https://github.com/mikf/gallery-dl/issues/7159))\n#### Fixes\n- [civitai] fix/improve query parameter handling ([#7138](https://github.com/mikf/gallery-dl/issues/7138))\n- [facebook] improve `date` extraction ([#7151](https://github.com/mikf/gallery-dl/issues/7151))\n- [sankaku] update API URLs ([#7154](https://github.com/mikf/gallery-dl/issues/7154) [#7155](https://github.com/mikf/gallery-dl/issues/7155) [#7163](https://github.com/mikf/gallery-dl/issues/7163))\n- [twitter] prevent exception in `_extract_components()` ([#7139](https://github.com/mikf/gallery-dl/issues/7139))\n#### Improvements\n- [batoto] add `domain` option ([#7174](https://github.com/mikf/gallery-dl/issues/7174))\n- [furaffinity] extract `scraps` metadata ([#7015](https://github.com/mikf/gallery-dl/issues/7015))\n- [tiktok] implement audio extraction without `yt-dlp`\n- [wikimedia] add `subcategories` option ([#2340](https://github.com/mikf/gallery-dl/issues/2340))\n\n## 1.29.1 - 2025-03-08\n### Extractors\n#### Additions\n- [tenor] add support ([#6075](https://github.com/mikf/gallery-dl/issues/6075))\n#### Fixes\n- [bunkr] update API endpoint ([#7097](https://github.com/mikf/gallery-dl/issues/7097))\n- [erome] fix `AttributeError` for albums without tags ([#7076](https://github.com/mikf/gallery-dl/issues/7076))\n- [furaffinity] fix `artist` metadata ([#6582](https://github.com/mikf/gallery-dl/issues/6582) [#7115](https://github.com/mikf/gallery-dl/issues/7115) [#7123](https://github.com/mikf/gallery-dl/issues/7123) [#7130](https://github.com/mikf/gallery-dl/issues/7130))\n- [jpgfish] decrypt file URLs ([#7073](https://github.com/mikf/gallery-dl/issues/7073) [#7079](https://github.com/mikf/gallery-dl/issues/7079) [#7109](https://github.com/mikf/gallery-dl/issues/7109))\n- [sankaku] fix search tag limit check\n- [vsco] fix `video` extractor ([#7113](https://github.com/mikf/gallery-dl/issues/7113))\n- [vsco] fix extracting videos from `/gallery` results ([#7113](https://github.com/mikf/gallery-dl/issues/7113))\n#### Improvements\n- [bunkr] add `endpoint` option ([#7097](https://github.com/mikf/gallery-dl/issues/7097))\n- [danbooru:pool] download posts in pool order, add `order-posts` option ([#7091](https://github.com/mikf/gallery-dl/issues/7091))\n- [erome:search] recognize all URL query parameters ([#7125](https://github.com/mikf/gallery-dl/issues/7125))\n- [reddit] add `selftext` option ([#7111](https://github.com/mikf/gallery-dl/issues/7111))\n- [redgifs:search] support `/search?query=...` URLs ([#7118](https://github.com/mikf/gallery-dl/issues/7118))\n- [sankaku] increase wait time on 429 errors ([#7129](https://github.com/mikf/gallery-dl/issues/7129))\n- [tiktok] improve `tiktok-range` parsing ([#7098](https://github.com/mikf/gallery-dl/issues/7098))\n### Downloaders\n- [http] detect Cloudflare/DDoS-Guard challenges ([#7066](https://github.com/mikf/gallery-dl/issues/7066) [#7121](https://github.com/mikf/gallery-dl/issues/7121))\n- warn about invalid `subcategory` values ([#7103](https://github.com/mikf/gallery-dl/issues/7103) [#7119](https://github.com/mikf/gallery-dl/issues/7119))\n\n## 1.29.0 - 2025-03-01\n### Changes\n- build `gallery-dl.exe` on Windows 10 / Python 3.13 ([#6684](https://github.com/mikf/gallery-dl/issues/6684))\n- provide Windows 7 / Python 3.8 builds as `gallery-dl_x86.exe`\n### Extractors\n#### Additions\n- [bilibili] add `user-articles-favorite` extractor ([#6725](https://github.com/mikf/gallery-dl/issues/6725) [#6781](https://github.com/mikf/gallery-dl/issues/6781))\n- [boosty] add `direct-messages` extractor ([#6768](https://github.com/mikf/gallery-dl/issues/6768))\n- [discord] add support ([#454](https://github.com/mikf/gallery-dl/issues/454) [#6836](https://github.com/mikf/gallery-dl/issues/6836) [#7059](https://github.com/mikf/gallery-dl/issues/7059) [#7067](https://github.com/mikf/gallery-dl/issues/7067))\n- [furry34] add support ([#1078](https://github.com/mikf/gallery-dl/issues/1078) [#7018](https://github.com/mikf/gallery-dl/issues/7018))\n- [hentaiera] add support ([#3046](https://github.com/mikf/gallery-dl/issues/3046) [#6952](https://github.com/mikf/gallery-dl/issues/6952) [#7020](https://github.com/mikf/gallery-dl/issues/7020))\n- [hentairox] add support ([#7003](https://github.com/mikf/gallery-dl/issues/7003))\n- [imgur] add support for personal posts ([#6990](https://github.com/mikf/gallery-dl/issues/6990))\n- [imhentai] add support ([#1660](https://github.com/mikf/gallery-dl/issues/1660) [#3046](https://github.com/mikf/gallery-dl/issues/3046) [#3824](https://github.com/mikf/gallery-dl/issues/3824) [#4338](https://github.com/mikf/gallery-dl/issues/4338) [#5936](https://github.com/mikf/gallery-dl/issues/5936))\n- [tiktok] add support ([#3061](https://github.com/mikf/gallery-dl/issues/3061) [#4177](https://github.com/mikf/gallery-dl/issues/4177) [#5646](https://github.com/mikf/gallery-dl/issues/5646) [#6878](https://github.com/mikf/gallery-dl/issues/6878) [#6708](https://github.com/mikf/gallery-dl/issues/6708))\n- [vsco] support `/video/` URLs ([#4295](https://github.com/mikf/gallery-dl/issues/4295) [#6973](https://github.com/mikf/gallery-dl/issues/6973))\n#### Fixes\n- [bunkr] decrypt file URLs ([#7058](https://github.com/mikf/gallery-dl/issues/7058) [#7070](https://github.com/mikf/gallery-dl/issues/7070) [#7085](https://github.com/mikf/gallery-dl/issues/7085) [#7089](https://github.com/mikf/gallery-dl/issues/7089) [#7090](https://github.com/mikf/gallery-dl/issues/7090))\n- [chevereto/jpgfish] fix extraction ([#7073](https://github.com/mikf/gallery-dl/issues/7073) [#7079](https://github.com/mikf/gallery-dl/issues/7079))\n- [generic] fix config lookups by subcategory\n- [philomena] fix `date` values without UTC offset ([#6921](https://github.com/mikf/gallery-dl/issues/6921))\n- [philomena] download `full` URLs to prevent potential 404 errors ([#6922](https://github.com/mikf/gallery-dl/issues/6922))\n- [pixiv] prevent exceptions during `comments` extraction ([#6965](https://github.com/mikf/gallery-dl/issues/6965))\n- [reddit] restrict subreddit search results ([#7025](https://github.com/mikf/gallery-dl/issues/7025))\n- [sankaku] fix extraction ([#7071](https://github.com/mikf/gallery-dl/issues/7071) [#7072](https://github.com/mikf/gallery-dl/issues/7072))\n- [subscribestar] fix `post` extractor ([#6582](https://github.com/mikf/gallery-dl/issues/6582))\n- [twitter] revert generated CSRF token length to 32 characters ([#6895](https://github.com/mikf/gallery-dl/issues/6895))\n- [vipergirls] change default `domain` to `viper.click` ([#4166](https://github.com/mikf/gallery-dl/issues/4166))\n- [weebcentral] fix extracting wrong number of chapter pages ([#6966](https://github.com/mikf/gallery-dl/issues/6966))\n#### Improvements\n- [b4k] update domain to `arch.b4k.dev` ([#6955](https://github.com/mikf/gallery-dl/issues/6955) [#6956](https://github.com/mikf/gallery-dl/issues/6956))\n- [bunkr] update default archive ID format ([#6935](https://github.com/mikf/gallery-dl/issues/6935))\n- [bunkr] provide fallback URLs for 403 download links ([#6732](https://github.com/mikf/gallery-dl/issues/6732) [#6972](https://github.com/mikf/gallery-dl/issues/6972))\n- [bunkr] implement fast `--range` support ([#6985](https://github.com/mikf/gallery-dl/issues/6985))\n- [furaffinity] use a default delay of 1 second between requests ([#7054](https://github.com/mikf/gallery-dl/issues/7054))\n- [itaku] support gallery section URLs ([#6951](https://github.com/mikf/gallery-dl/issues/6951))\n- [patreon] support `/profile/creators` URLs\n- [subscribestar] detect and handle redirects ([#6916](https://github.com/mikf/gallery-dl/issues/6916))\n- [twibooru] match URLs with `www` subdomain ([#6903](https://github.com/mikf/gallery-dl/issues/6903))\n- [twitter] support `grok` cards content ([#7040](https://github.com/mikf/gallery-dl/issues/7040))\n- [vsco] improve `m3u8` handling\n- [weibo] add `movies` option ([#6988](https://github.com/mikf/gallery-dl/issues/6988))\n#### Metadata\n- [bunkr] extract `id_url` metadata ([#6935](https://github.com/mikf/gallery-dl/issues/6935))\n- [erome] extract `tags` metadata ([#7076](https://github.com/mikf/gallery-dl/issues/7076))\n- [issuu] unescape HTML entities\n- [newgrounds] provide `comment_html` metadata ([#7038](https://github.com/mikf/gallery-dl/issues/7038))\n- [patreon] extract `campaign` metadata ([#6989](https://github.com/mikf/gallery-dl/issues/6989))\n### Downloaders\n- implement `downloader` options per extractor category\n- [http] add `sleep-429` option ([#6996](https://github.com/mikf/gallery-dl/issues/6996))\n- [ytdl] support specifying `module` as filesystem paths ([#6991](https://github.com/mikf/gallery-dl/issues/6991))\n### Archives\n- [archive] implement support for PostgreSQL databases ([#6152](https://github.com/mikf/gallery-dl/issues/6152))\n- [archive] add `archive-table` option ([#6152](https://github.com/mikf/gallery-dl/issues/6152))\n### Miscellaneous\n- [aes] handle errors during `cryptodome` import ([#6906](https://github.com/mikf/gallery-dl/issues/6906))\n- [executables] fix loading `certifi` SSL certificates ([#6393](https://github.com/mikf/gallery-dl/issues/6393))\n- improve `\\f` format string handling for `--print`\n\n## 1.28.5 - 2025-01-28\n### Extractors\n#### Additions\n- [nekohouse] add support ([#5241](https://github.com/mikf/gallery-dl/issues/5241), [#6738](https://github.com/mikf/gallery-dl/issues/6738))\n- [turboimagehost] add support for galleries ([#6855](https://github.com/mikf/gallery-dl/issues/6855))\n- [xfolio] add support ([#5514](https://github.com/mikf/gallery-dl/issues/5514), [#6351](https://github.com/mikf/gallery-dl/issues/6351), [#6837](https://github.com/mikf/gallery-dl/issues/6837))\n#### Fixes\n- [4archive] fix `TypeError`\n- [adultempire] bypass age confirmation check\n- [architizer] fix extraction\n- [artstation] avoid Cloudflare challenges ([#5817](https://github.com/mikf/gallery-dl/issues/5817), [#5658](https://github.com/mikf/gallery-dl/issues/5658), [#5564](https://github.com/mikf/gallery-dl/issues/5564), [#5554](https://github.com/mikf/gallery-dl/issues/5554))\n- [deviantart] prevent crash when accessing `premium_folder` data ([#6873](https://github.com/mikf/gallery-dl/issues/6873))\n- [fapachi] fix extraction ([#6881](https://github.com/mikf/gallery-dl/issues/6881))\n- [issuu] fix `user` extractor\n- [kemonoparty] fix `username` metadata and filtering by `tag` for `/posts` URLs ([#6833](https://github.com/mikf/gallery-dl/issues/6833))\n- [mangafox] fix chapter extraction\n- [mangahere] fix chapter extraction\n- [pixiv] fix `sanity_level` workaround ([#4327](https://github.com/mikf/gallery-dl/issues/4327))\n- [pornpics] fix pagination results from HTML pages\n- [twitter] handle exceptions during file extraction ([#6647](https://github.com/mikf/gallery-dl/issues/6647))\n- [vsco] fix `JSONDecodeError` ([#6887](https://github.com/mikf/gallery-dl/issues/6887), [#6891](https://github.com/mikf/gallery-dl/issues/6891))\n- [weebcentral] fix extraction ([#6860](https://github.com/mikf/gallery-dl/issues/6860))\n- [xhamster] fix `gallery` extractor ([#6818](https://github.com/mikf/gallery-dl/issues/6818), [#6876](https://github.com/mikf/gallery-dl/issues/6876))\n#### Improvements\n- [batoto] use `chapter_id` in default archive IDs ([#6835](https://github.com/mikf/gallery-dl/issues/6835))\n- [e621] support `e621.cc` and `e621.anthro.fr` frontend URLs ([#6809](https://github.com/mikf/gallery-dl/issues/6809))\n- [e621] prevent premature pagination end ([#6886](https://github.com/mikf/gallery-dl/issues/6886))\n- [facebook] allow accessing all metadata in `directory` format strings ([#6874](https://github.com/mikf/gallery-dl/issues/6874))\n- [hiperdex] update domain to `hiperdex.com`\n- [kemonoparty] enable filtering creator posts by tag ([#6833](https://github.com/mikf/gallery-dl/issues/6833))\n- [khinsider] add `covers` option ([#6844](https://github.com/mikf/gallery-dl/issues/6844))\n- [komikcast] update domain to `komikcast.la`\n- [lofter] improve error handling ([#6865](https://github.com/mikf/gallery-dl/issues/6865))\n- [pornpics] avoid redirect when retrieving a gallery page\n- [urlgalleries] support new URL format\n#### Metadata\n- [bunkr] extract better `filename` metadata ([#6824](https://github.com/mikf/gallery-dl/issues/6824))\n- [hiperdex] fix `description` metadata\n- [khinsider] extract more `album` metadata ([#6844](https://github.com/mikf/gallery-dl/issues/6844))\n- [mangaread] fix manga metadata extraction\n- [rule34xyz] fix `date` and `tags` metadata\n- [saint] fix metadata of `/d/` URLs\n- [toyhouse] fix `date`, `artists`, and `characters` metadata\n- [webtoons] fix `username` and `author_name` metadata\n#### Removals\n- [cohost] remove module\n- [fanleaks] remove module\n- [shimmie2] remove `tentaclerape.net`\n- [szurubooru] remove `booru.foalcon.com`\n### Miscellaneous\n- [docs] add `nix` docs to README ([#6606](https://github.com/mikf/gallery-dl/issues/6606))\n- [path] fix exception when using `--rename-to` + `--no-download` ([#6861](https://github.com/mikf/gallery-dl/issues/6861))\n- [release] include `scripts/run_tests.py` in release tarball ([#6856](https://github.com/mikf/gallery-dl/issues/6856))\n\n## 1.28.4 - 2025-01-12\n### Extractors\n#### Additions\n- [pexels] add support ([#2286](https://github.com/mikf/gallery-dl/issues/2286), [#4214](https://github.com/mikf/gallery-dl/issues/4214), [#6769](https://github.com/mikf/gallery-dl/issues/6769))\n- [weebcentral] add support ([#6778](https://github.com/mikf/gallery-dl/issues/6778))\n#### Fixes\n- [bunkr] update to new site layout ([#6798](https://github.com/mikf/gallery-dl/issues/6798), [#6805](https://github.com/mikf/gallery-dl/issues/6805))\n- [bunkr] fix `ValueError` on relative redirects ([#6790](https://github.com/mikf/gallery-dl/issues/6790))\n- [plurk] fix `user` data extraction and make it non-fatal ([#6742](https://github.com/mikf/gallery-dl/issues/6742))\n#### Improvements\n- [bunkr] support `/f/` media URLs\n- [e621] accept `tag` search URLs with empty tag ([#6783](https://github.com/mikf/gallery-dl/issues/6783))\n- [pixiv] provide fallback URLs ([#6762](https://github.com/mikf/gallery-dl/issues/6762))\n- [wallhaven] extract `search[tags]` and `search[tag_id]` metadata ([#6772](https://github.com/mikf/gallery-dl/issues/6772))\n### Miscellaneous\n- [util] support not splitting `value` argument when calling `contains()`  ([#6773](https://github.com/mikf/gallery-dl/issues/6773))\n\n## 1.28.3 - 2025-01-04\n### Extractors\n#### Additions\n- [civitai] add `user-videos` extractor ([#6644](https://github.com/mikf/gallery-dl/issues/6644))\n- [szurubooru] support `visuabusters.com/booru` ([#6729](https://github.com/mikf/gallery-dl/issues/6729))\n#### Fixes\n- [8muses] skip albums without valid `permalink` ([#6717](https://github.com/mikf/gallery-dl/issues/6717))\n- [batoto] update domains ([#6714](https://github.com/mikf/gallery-dl/issues/6714))\n- [deviantart:tiptap] fix deviation embeds without `token`\n- [hitomi] fix searches ([#6713](https://github.com/mikf/gallery-dl/issues/6713))\n- [instagram:reels] fix `pinned` values ([#6719](https://github.com/mikf/gallery-dl/issues/6719))\n- [kemonoparty] handle `discord` favorites ([#6706](https://github.com/mikf/gallery-dl/issues/6706))\n- [piczel] fix extraction ([#6735](https://github.com/mikf/gallery-dl/issues/6735))\n- [poipiku] fix downloads when post has a warning ([#6736](https://github.com/mikf/gallery-dl/issues/6736))\n- [sankaku] support alphanumeric book/pool IDs ([#6757](https://github.com/mikf/gallery-dl/issues/6757))\n- [subscribestar] fix attachment downloads ([#6721](https://github.com/mikf/gallery-dl/issues/6721), [#6724](https://github.com/mikf/gallery-dl/issues/6724), [#6758](https://github.com/mikf/gallery-dl/issues/6758))\n- [subscribestar] improve `content` metadata extraction ([#6761](https://github.com/mikf/gallery-dl/issues/6761))\n- [tapas] fix `TypeError` for locked episodes ([#6700](https://github.com/mikf/gallery-dl/issues/6700))\n#### Improvements\n- [boosty] support `file` post attachments ([#6760](https://github.com/mikf/gallery-dl/issues/6760))\n- [deviantart:tiptap] support more content block types ([#6686](https://github.com/mikf/gallery-dl/issues/6686))\n- [directlink] use domain as `subcategory` ([#6703](https://github.com/mikf/gallery-dl/issues/6703))\n- [hitomi] provide `search_tags` metadata for `tag` and `search` results ([#6756](https://github.com/mikf/gallery-dl/issues/6756))\n- [subscribestar] support `audio` files ([#6758](https://github.com/mikf/gallery-dl/issues/6758))\n### Miscellaneous\n- [workflows:executables] build with Python 3.13\n\n## 1.28.2 - 2024-12-20\n### Extractors\n#### Additions\n- [cyberdrop] add extractor for media URLs ([#2496](https://github.com/mikf/gallery-dl/issues/2496))\n- [itaku] add `search` extractor ([#6613](https://github.com/mikf/gallery-dl/issues/6613))\n- [lofter] add initial support ([#650](https://github.com/mikf/gallery-dl/issues/650), [#2294](https://github.com/mikf/gallery-dl/issues/2294), [#4095](https://github.com/mikf/gallery-dl/issues/4095), [#4728](https://github.com/mikf/gallery-dl/issues/4728), [#5656](https://github.com/mikf/gallery-dl/issues/5656), [#6607](https://github.com/mikf/gallery-dl/issues/6607))\n- [yiffverse] add support ([#6611](https://github.com/mikf/gallery-dl/issues/6611))\n#### Fixes\n- [facebook] decode Unicode surrogate pairs in metadata values ([#6599](https://github.com/mikf/gallery-dl/issues/6599))\n- [zerochan] parse API responses manually when receiving invalid JSON ([#6632](https://github.com/mikf/gallery-dl/issues/6632))\n- [zerochan] fix `source` metadata extraction when not logged in\n#### Improvements\n- [bilibili] extract files from `module_top` entries ([#6687](https://github.com/mikf/gallery-dl/issues/6687))\n- [bilibili] support `/upload/opus` URLs ([#6687](https://github.com/mikf/gallery-dl/issues/6687))\n- [bluesky] default to `posts` timeline when `reposts` or `quoted` is enabled ([#6583](https://github.com/mikf/gallery-dl/issues/6583))\n- [common] simplify HTTP error messages\n- [common] detect `DDoS-Guard` challenge pages\n- [deviantart] improve `tiptap` markup to HTML conversion ([#6686](https://github.com/mikf/gallery-dl/issues/6686))\n  - fix `KeyError: 'attrs'` for links without `href`\n  - support `heading` content blocks\n  - support `strike` text markers\n- [instagram] extract `date` metadata for stories ([#6677](https://github.com/mikf/gallery-dl/issues/6677))\n- [kemonoparty:favorite] support new URL format ([#6676](https://github.com/mikf/gallery-dl/issues/6676))\n- [saint] support `saint2.cr` URLs ([#6692](https://github.com/mikf/gallery-dl/issues/6692))\n- [tapas] improve extractor hierarchy ([#6680](https://github.com/mikf/gallery-dl/issues/6680))\n#### Options\n- [cohost] add `avatar` and `background` options ([#6656](https://github.com/mikf/gallery-dl/issues/6656))\n### Miscellaneous\n- support `*` wildcards for `parent>child` categories, for example `reddit>*` ([#6673](https://github.com/mikf/gallery-dl/issues/6673))\n- use latest Firefox UA as default `user-agent`\n- use random unused port for `\"user-agent\": \"browser\"` requests\n\n## 1.28.1 - 2024-12-07\n### Extractors\n#### Additions\n- [bluesky] add `info` extractor\n#### Fixes\n- [bluesky] fix exception when encountering non-quote embeds ([#6577](https://github.com/mikf/gallery-dl/issues/6577))\n- [bluesky] unescape search queries ([#6579](https://github.com/mikf/gallery-dl/issues/6579))\n- [common] restore using environment proxy settings by default ([#6553](https://github.com/mikf/gallery-dl/issues/6553), [#6609](https://github.com/mikf/gallery-dl/issues/6609))\n- [common] improve handling of `user-agent` settings ([#6594](https://github.com/mikf/gallery-dl/issues/6594))\n- [e621] fix `TypeError` when `metadata` is enabled ([#6587](https://github.com/mikf/gallery-dl/issues/6587))\n- [gofile] fix website token extraction ([#6596](https://github.com/mikf/gallery-dl/issues/6596))\n- [inkbunny] fix re-login loop ([#6618](https://github.com/mikf/gallery-dl/issues/6618))\n- [instagram] handle empty `carousel_media` entries ([#6595](https://github.com/mikf/gallery-dl/issues/6595))\n- [kemonoparty] fix `o` query parameter handling ([#6597](https://github.com/mikf/gallery-dl/issues/6597))\n- [nhentai] fix download URLs ([#6620](https://github.com/mikf/gallery-dl/issues/6620))\n- [readcomiconline] fix `chapter` extraction ([#6070](https://github.com/mikf/gallery-dl/issues/6070), [#6335](https://github.com/mikf/gallery-dl/issues/6335))\n- [realbooru] fix extraction ([#6543](https://github.com/mikf/gallery-dl/issues/6543))\n- [rule34] fix `favorite` extraction ([#6573](https://github.com/mikf/gallery-dl/issues/6573))\n- [zerochan] download `.webp` and `.gif` files ([#6576](https://github.com/mikf/gallery-dl/issues/6576))\n#### Improvements\n- [hentaicosplays] update domains ([#6578](https://github.com/mikf/gallery-dl/issues/6578))\n- [pixiv:ranking] implement filtering results by `content` ([#6574](https://github.com/mikf/gallery-dl/issues/6574))\n- [pixiv] include user ID in failed AJAX request warnings ([#6581](https://github.com/mikf/gallery-dl/issues/6581))\n#### Options\n- [patreon] add `format-images` option ([#6569](https://github.com/mikf/gallery-dl/issues/6569))\n- [zerochan] add `extensions` option ([#6576](https://github.com/mikf/gallery-dl/issues/6576))\n\n## 1.28.0 - 2024-11-30\n### Changes\n- [common] disable using environment network settings by default (`HTTP_PROXY`, `.netrc`, …)\n  - disable `trust_env` session attribute\n  - disable `Authorization` header injection from `.netrc` auth ([#5780](https://github.com/mikf/gallery-dl/issues/5780), [#6134](https://github.com/mikf/gallery-dl/issues/6134), [#6455](https://github.com/mikf/gallery-dl/issues/6455))\n  - add `proxy-env` option\n- [ytdl] change `forward-cookies` default value to `true` ([#6401](https://github.com/mikf/gallery-dl/issues/6401), [#6348](https://github.com/mikf/gallery-dl/issues/6348))\n### Extractors\n#### Additions\n- [bilibili] add support for `opus` articles ([#2824](https://github.com/mikf/gallery-dl/issues/2824), [#6443](https://github.com/mikf/gallery-dl/issues/6443))\n- [bluesky] add `hashtag` extractor ([#4438](https://github.com/mikf/gallery-dl/issues/4438))\n- [danbooru] add `artist` and `artist-search` extractors ([#5348](https://github.com/mikf/gallery-dl/issues/5348))\n- [everia] add support ([#1067](https://github.com/mikf/gallery-dl/issues/1067), [#2472](https://github.com/mikf/gallery-dl/issues/2472), [#4091](https://github.com/mikf/gallery-dl/issues/4091), [#6227](https://github.com/mikf/gallery-dl/issues/6227))\n- [facebook] add support ([#470](https://github.com/mikf/gallery-dl/issues/470), [#2612](https://github.com/mikf/gallery-dl/issues/2612), [#5626](https://github.com/mikf/gallery-dl/issues/5626), [#6548](https://github.com/mikf/gallery-dl/issues/6548))\n- [hentaifoundry] add `tag` extractor ([#6465](https://github.com/mikf/gallery-dl/issues/6465))\n- [hitomi] add `index` and `search` extractors ([#2502](https://github.com/mikf/gallery-dl/issues/2502), [#6392](https://github.com/mikf/gallery-dl/issues/6392), [#3720](https://github.com/mikf/gallery-dl/issues/3720))\n- [motherless] add support ([#2074](https://github.com/mikf/gallery-dl/issues/2074), [#4413](https://github.com/mikf/gallery-dl/issues/4413), [#6221](https://github.com/mikf/gallery-dl/issues/6221))\n- [noop] add `noop` extractor\n- [rule34vault] add support ([#5708](https://github.com/mikf/gallery-dl/issues/5708), [#6240](https://github.com/mikf/gallery-dl/issues/6240))\n- [rule34xyz] add support ([#1078](https://github.com/mikf/gallery-dl/issues/1078), [#4960](https://github.com/mikf/gallery-dl/issues/4960))\n- [saint] add support ([#4405](https://github.com/mikf/gallery-dl/issues/4405), [#6324](https://github.com/mikf/gallery-dl/issues/6324))\n- [tumblr] add `search` extractor ([#6394](https://github.com/mikf/gallery-dl/issues/6394))\n#### Fixes\n- [8chan] avoid performing network requests within `_init()` ([#6387](https://github.com/mikf/gallery-dl/issues/6387))\n- [bluesky] fix downloads from non-bsky PDSs ([#6406](https://github.com/mikf/gallery-dl/issues/6406))\n- [bunkr] fix album names containing `<>&` characters\n- [flickr] use `download` URLs ([#6360](https://github.com/mikf/gallery-dl/issues/6360), [#6464](https://github.com/mikf/gallery-dl/issues/6464))\n- [hiperdex] update domain to `hipertoon.com` ([#6420](https://github.com/mikf/gallery-dl/issues/6420))\n- [imagechest] fix extractors ([#6475](https://github.com/mikf/gallery-dl/issues/6475), [#6491](https://github.com/mikf/gallery-dl/issues/6491))\n- [instagram] fix using numeric cursor values ([#6414](https://github.com/mikf/gallery-dl/issues/6414))\n- [kemonoparty] update to new site layout ([#6415](https://github.com/mikf/gallery-dl/issues/6415), [#6503](https://github.com/mikf/gallery-dl/issues/6503), [#6528](https://github.com/mikf/gallery-dl/issues/6528), [#6530](https://github.com/mikf/gallery-dl/issues/6530), [#6536](https://github.com/mikf/gallery-dl/issues/6536), [#6542](https://github.com/mikf/gallery-dl/issues/6542), [#6554](https://github.com/mikf/gallery-dl/issues/6554))\n- [koharu] update domain to `niyaniya.moe` ([#6430](https://github.com/mikf/gallery-dl/issues/6430), [#6432](https://github.com/mikf/gallery-dl/issues/6432))\n- [mangadex] apply `lang` option only to chapter results ([#6372](https://github.com/mikf/gallery-dl/issues/6372))\n- [newgrounds] fix metadata extraction ([#6463](https://github.com/mikf/gallery-dl/issues/6463), [#6533](https://github.com/mikf/gallery-dl/issues/6533))\n- [nhentai] support `.webp` files ([#6442](https://github.com/mikf/gallery-dl/issues/6442), [#6479](https://github.com/mikf/gallery-dl/issues/6479))\n- [patreon] use legacy mobile UA when no `session_id` is set\n- [pinterest] update API headers ([#6513](https://github.com/mikf/gallery-dl/issues/6513))\n- [pinterest] detect video/audio by block content ([#6421](https://github.com/mikf/gallery-dl/issues/6421))\n- [scrolller] prevent exception for posts without `mediaSources` ([#5051](https://github.com/mikf/gallery-dl/issues/5051))\n- [tumblrgallery] fix file downloads ([#6391](https://github.com/mikf/gallery-dl/issues/6391))\n- [twitter] make `source` metadata extraction non-fatal ([#6472](https://github.com/mikf/gallery-dl/issues/6472))\n- [weibo] fix livephoto `filename` & `extension` ([#6471](https://github.com/mikf/gallery-dl/issues/6471))\n#### Improvements\n- [bluesky] support `main.bsky.dev` URLs ([#4438](https://github.com/mikf/gallery-dl/issues/4438))\n- [bluesky] match common embed fixes ([#6410](https://github.com/mikf/gallery-dl/issues/6410), [#6411](https://github.com/mikf/gallery-dl/issues/6411))\n- [boosty] update default video format list ([#2387](https://github.com/mikf/gallery-dl/issues/2387))\n- [bunkr] support `bunkr.cr` URLs\n- [common] allow passing cookies to OAuth extractors\n- [common] allow overriding more default `User-Agent` headers ([#6496](https://github.com/mikf/gallery-dl/issues/6496))\n- [philomena] switch default `ponybooru` filter ([#6437](https://github.com/mikf/gallery-dl/issues/6437))\n- [pinterest] support `story_pin_music` blocks ([#6421](https://github.com/mikf/gallery-dl/issues/6421))\n- [pixiv] get ugoira frame extension from `meta_single_page` values ([#6056](https://github.com/mikf/gallery-dl/issues/6056))\n- [reddit] support user profile share links ([#6389](https://github.com/mikf/gallery-dl/issues/6389))\n- [steamgriddb] disable `adjust-extensions` for `fake-png` files ([#5274](https://github.com/mikf/gallery-dl/issues/5274))\n- [twitter] remove cookies migration workaround\n#### Metadata\n- [bluesky] provide `author[instance]` metadata ([#4438](https://github.com/mikf/gallery-dl/issues/4438))\n- [instagram] fix `extension` of apparent `.webp` files ([#6541](https://github.com/mikf/gallery-dl/issues/6541))\n- [pillowfort] provide `count` metadata ([#6478](https://github.com/mikf/gallery-dl/issues/6478))\n- [pixiv:ranking] add `rank` metadata field ([#6531](https://github.com/mikf/gallery-dl/issues/6531))\n- [poipiku] return `count` as proper number ([#6445](https://github.com/mikf/gallery-dl/issues/6445))\n- [webtoons] extract `episode_no` for comic results ([#6439](https://github.com/mikf/gallery-dl/issues/6439))\n#### Options\n- [civitai] add `metadata` option - support fetching `generation` data ([#6383](https://github.com/mikf/gallery-dl/issues/6383))\n- [exhentai] implement `tags` option ([#2117](https://github.com/mikf/gallery-dl/issues/2117))\n- [koharu] implement `tags` option\n- [rule34xyz] add `format` option ([#1078](https://github.com/mikf/gallery-dl/issues/1078))\n### Downloaders\n- [ytdl] fix `AttributeError` caused by `decodeOption()` removal ([#6552](https://github.com/mikf/gallery-dl/issues/6552))\n### Post Processors\n- [classify] rewrite - fix skipping existing files ([#5213](https://github.com/mikf/gallery-dl/issues/5213))\n- enable inheriting options from global `postprocessor` objects\n- allow `postprocessors` values to be a single post processor object\n### Cookies\n- support Chromium table version 24 ([#6162](https://github.com/mikf/gallery-dl/issues/6162))\n- fix GCM pad length calculation ([#6162](https://github.com/mikf/gallery-dl/issues/6162))\n- try decryption with empty password as fallback\n### Documentation\n- update recommended `pip` command for installing `dev` version ([#6493](https://github.com/mikf/gallery-dl/issues/6493))\n- update `gallery-dl.conf` ([#6501](https://github.com/mikf/gallery-dl/issues/6501))\n### Options\n- add `-4/--force-ipv4` and `-6/--force-ipv6` command-line options\n- fix passing negative numbers as arguments ([#5262](https://github.com/mikf/gallery-dl/issues/5262))\n### Miscellaneous\n- [output] use default ANSI colors only when stream is a TTY\n- [util] implement `defaultdict` filters-environment\n- [util] enable using multiple statements for all `filter` options ([#5262](https://github.com/mikf/gallery-dl/issues/5262))\n\n## 1.27.7 - 2024-10-25\n### Extractors\n#### Additions\n- [civitai] add extractors for global `models` and `images` ([#6310](https://github.com/mikf/gallery-dl/issues/6310))\n- [mangadex] add `author` extractor ([#6372](https://github.com/mikf/gallery-dl/issues/6372))\n- [scrolller] add support ([#295](https://github.com/mikf/gallery-dl/issues/295), [#3418](https://github.com/mikf/gallery-dl/issues/3418), [#5051](https://github.com/mikf/gallery-dl/issues/5051))\n#### Fixes\n- [8chan] automatically detect `TOS` cookie name ([#6318](https://github.com/mikf/gallery-dl/issues/6318))\n- [bunkr] update to new site layout ([#6344](https://github.com/mikf/gallery-dl/issues/6344), [#6352](https://github.com/mikf/gallery-dl/issues/6352), [#6368](https://github.com/mikf/gallery-dl/issues/6368))\n- [bunkr] send proper `Referer` headers for file downloads ([#6319](https://github.com/mikf/gallery-dl/issues/6319))\n- [civitai] add `uuid` metadata field & use it as default archive format ([#6326](https://github.com/mikf/gallery-dl/issues/6326))\n- [civitai] fix \"My Reactions\" results ([#6263](https://github.com/mikf/gallery-dl/issues/6263))\n- [civitai] fix `model` file download URLs for tRPC API\n- [lensdump] fix extraction ([#6313](https://github.com/mikf/gallery-dl/issues/6313))\n- [pixiv] make retrieving ugoira metadata non-fatal ([#6297](https://github.com/mikf/gallery-dl/issues/6297))\n- [pixiv] fix exception when processing deleted `sanity_level` works ([#6339](https://github.com/mikf/gallery-dl/issues/6339))\n- [urlgalleries] fix extraction\n- [wikimedia] fix non-English Fandom/wiki.gg articles ([#6370](https://github.com/mikf/gallery-dl/issues/6370))\n#### Improvements\n- [8chan] support `/last/` thread URLs ([#6318](https://github.com/mikf/gallery-dl/issues/6318))\n- [bunkr] support `bunkr.ph` and `bunkr.ps` URLs\n- [newgrounds] support page numbers in URLs ([#6320](https://github.com/mikf/gallery-dl/issues/6320))\n- [patreon] support `/c/` prefix in creator URLs ([#6348](https://github.com/mikf/gallery-dl/issues/6348))\n- [pinterest] support `story` pins ([#6188](https://github.com/mikf/gallery-dl/issues/6188), [#6078](https://github.com/mikf/gallery-dl/issues/6078), [#4229](https://github.com/mikf/gallery-dl/issues/4229))\n- [pixiv] implement `sanity_level` workaround for user artworks results ([#4327](https://github.com/mikf/gallery-dl/issues/4327), [#5435](https://github.com/mikf/gallery-dl/issues/5435), [#6339](https://github.com/mikf/gallery-dl/issues/6339))\n#### Options\n- [bluesky] add `quoted` option ([#6323](https://github.com/mikf/gallery-dl/issues/6323))\n- [pixiv] add `captions` option ([#4327](https://github.com/mikf/gallery-dl/issues/4327))\n- [reddit] add `embeds` option ([#6357](https://github.com/mikf/gallery-dl/issues/6357))\n- [vk] add `offset` option ([#6328](https://github.com/mikf/gallery-dl/issues/6328))\n### Downloaders\n- [ytdl] implement explicit HLS/DASH handling\n### Post Processors\n- add `error` event\n### Miscellaneous\n- [cookies] convert Chromium `expires_utc` values to Unix timestamps\n- [util] add `std` object to global eval namespace ([#6330](https://github.com/mikf/gallery-dl/issues/6330))\n- add `--print` and `--print-to-file` command-line options ([#6343](https://github.com/mikf/gallery-dl/issues/6343))\n- use child extractor fallbacks only when a non-user error occurs ([#6329](https://github.com/mikf/gallery-dl/issues/6329))\n\n## 1.27.6 - 2024-10-11\n### Extractors\n#### Additions\n- [ao3] add `subscriptions` extractor ([#6247](https://github.com/mikf/gallery-dl/issues/6247))\n- [boosty] add support ([#2387](https://github.com/mikf/gallery-dl/issues/2387))\n- [civitai] add `post` extractors ([#6279](https://github.com/mikf/gallery-dl/issues/6279))\n- [pixiv] support unlisted artworks ([#5162](https://github.com/mikf/gallery-dl/issues/5162))\n#### Fixes\n- [cohost] sanitize default filenames ([#6262](https://github.com/mikf/gallery-dl/issues/6262))\n  - limit `headline` length\n  - remove `plainTextBody`\n- [deviantart] fix & improve journal/literature extraction ([#6254](https://github.com/mikf/gallery-dl/issues/6254), [#6207](https://github.com/mikf/gallery-dl/issues/6207), [#6196](https://github.com/mikf/gallery-dl/issues/6196))\n  - extract journal HTML from webpage if possible\n  - support converting `tiptap` markup to HTML\n- [deviantart] fix `stash` folder extraction\n- [flickr] update default API credentials ([#6300](https://github.com/mikf/gallery-dl/issues/6300))\n- [flickr] fix `ZeroDivisionError` ([#6252](https://github.com/mikf/gallery-dl/issues/6252))\n- [imagefap] fix `{num}` in single image default filenames\n- [myhentaigallery] fix `tags` extraction\n- [patreon] extract `attachments_media` files ([#6241](https://github.com/mikf/gallery-dl/issues/6241), [#6268](https://github.com/mikf/gallery-dl/issues/6268))\n- [pixiv] implement workaround for `limit_sanity_level` works ([#4327](https://github.com/mikf/gallery-dl/issues/4327), [#4747](https://github.com/mikf/gallery-dl/issues/4747), [#5054](https://github.com/mikf/gallery-dl/issues/5054), [#5435](https://github.com/mikf/gallery-dl/issues/5435), [#5651](https://github.com/mikf/gallery-dl/issues/5651), [#5655](https://github.com/mikf/gallery-dl/issues/5655))\n- [pornhub] fix `KeyError` when album images are missing ([#6299](https://github.com/mikf/gallery-dl/issues/6299))\n- [rule34us] fix extraction ([#6289](https://github.com/mikf/gallery-dl/issues/6289))\n- [8chan] set TOS cookie for current and previous day\n#### Improvements\n- [bunkr] support `bunkr.pk` URLs ([#6272](https://github.com/mikf/gallery-dl/issues/6272))\n- [civitai] use tRPC API by default ([#6279](https://github.com/mikf/gallery-dl/issues/6279))\n- [civitai] improve default archive format ([#6302](https://github.com/mikf/gallery-dl/issues/6302))\n- [komikcast] update domain to `komikcast.cz`\n- [newgrounds] detect more comment embeds ([#6253](https://github.com/mikf/gallery-dl/issues/6253))\n- [newgrounds] add more fallback URL formats for `art-images` files\n- [oauth] prevent empty browser names\n- [patreon] use mobile UA ([#6241](https://github.com/mikf/gallery-dl/issues/6241), [#6239](https://github.com/mikf/gallery-dl/issues/6239), [#6140](https://github.com/mikf/gallery-dl/issues/6140))\n- [patreon] handle suspended accounts\n- [pixiv] detect works requiring `My pixiv` access\n#### Metadata\n- [civitai] ensure image files have an `id` ([#6251](https://github.com/mikf/gallery-dl/issues/6251))\n- [gelbooru_v02] unescape HTML entities in categorized tags\n- [generic] ensure `path` metadata is always defined\n- [pixiv] retrieve `caption` from AJAX API when empty ([#4327](https://github.com/mikf/gallery-dl/issues/4327), [#5191](https://github.com/mikf/gallery-dl/issues/5191))\n#### Options\n- [fanbox] add `comments` option, extend `metadata` option ([#6287](https://github.com/mikf/gallery-dl/issues/6287))\n- [pixiv] add `comments` option ([#6287](https://github.com/mikf/gallery-dl/issues/6287))\n#### Removals\n- [blogger] remove `micmicidol.club`\n- [chevereto] remove `deltaporno.com`\n- [lolisafe] remove `xbunkr.com`\n- [pururin] remove module\n- [shimmie2] remove `loudbooru.com`\n### Post Processors\n- [ugoira] fix `BadZipFile` exceptions ([#6285](https://github.com/mikf/gallery-dl/issues/6285))\n- [ugoira] catch all exceptions when extracting ZIP archives ([#6285](https://github.com/mikf/gallery-dl/issues/6285))\n- [ugoira] forward frame data as `_ugoira_frame_data` ([#6154](https://github.com/mikf/gallery-dl/issues/6154), [#6285](https://github.com/mikf/gallery-dl/issues/6285))\n### Miscellaneous\n- [build] remove setuptools and requests version restrictions\n- [docker] build from `python:3.12-alpine`\n- [text] improve `parse_query()` performance\n\n## 1.27.5 - 2024-09-28\n### Extractors\n#### Additions\n- [ao3] add support ([#6013](https://github.com/mikf/gallery-dl/issues/6013))\n- [civitai] add support ([#3706](https://github.com/mikf/gallery-dl/issues/3706), [#3787](https://github.com/mikf/gallery-dl/issues/3787), [#4129](https://github.com/mikf/gallery-dl/issues/4129), [#5995](https://github.com/mikf/gallery-dl/issues/5995), [#6220](https://github.com/mikf/gallery-dl/issues/6220))\n- [cohost] add support ([#4483](https://github.com/mikf/gallery-dl/issues/4483), [#6191](https://github.com/mikf/gallery-dl/issues/6191))\n#### Fixes\n- [8chan] update `TOS` cookie name\n- [deviantart] work around OAuth API returning empty journal texts ([#6196](https://github.com/mikf/gallery-dl/issues/6196), [#6207](https://github.com/mikf/gallery-dl/issues/6207), [#5916](https://github.com/mikf/gallery-dl/issues/5916))\n- [weasyl:favorite] fix pagination ([#6113](https://github.com/mikf/gallery-dl/issues/6113))\n#### Improvements\n- [bluesky] support video downloads ([#6183](https://github.com/mikf/gallery-dl/issues/6183))\n- [deviantart] add `previews` option ([#3782](https://github.com/mikf/gallery-dl/issues/3782), [#6124](https://github.com/mikf/gallery-dl/issues/6124))\n- [deviantart] warn about empty journal texts ([#5916](https://github.com/mikf/gallery-dl/issues/5916))\n- [inkbunny:favorite] update default directory ([#6115](https://github.com/mikf/gallery-dl/issues/6115))\n- [jpgfish] update domain to `jpg5.su` ([#6231](https://github.com/mikf/gallery-dl/issues/6231))\n- [skeb] prevent 429 errors and need for `request_key` cookie\n- [weasyl:favorite] support readable URL format ([#6113](https://github.com/mikf/gallery-dl/issues/6113))\n- [wikimedia] automatically detect API endpoint when none is defined\n- [zzup] support `up.zzup.com` galleries ([#6181](https://github.com/mikf/gallery-dl/issues/6181))\n### Post Processors\n- [ugoira] implement storing \"original\" frames in ZIP archives ([#6147](https://github.com/mikf/gallery-dl/issues/6147))\n- [ugoira] fix `KeyError: '_ugoira_frame_index'` ([#6154](https://github.com/mikf/gallery-dl/issues/6154))\n### Formatter\n- add `L` conversion - returns the length of a value\n- allow accessing `util.NONE` via global `_nul`\n### Miscellaneous\n- [cookies] add `cookies-select` option\n- [cookies:firefox] support using domain & container filters together\n- [docker] prevent errors in Dockerfile build\n- [tests] make `#category` result entries optional\n- allow filtering `--list-extractors` results\n- implement alternatives for deprecated `utc` datetime functions\n\n## 1.27.4 - 2024-09-06\n### Extractors\n#### Additions\n- [sexcom] add `likes` extractor ([#6149](https://github.com/mikf/gallery-dl/issues/6149))\n- [wikimedia] add `wiki` extractor ([#6050](https://github.com/mikf/gallery-dl/issues/6050))\n#### Fixes\n- [bunkr] fix file downloads ([#6037](https://github.com/mikf/gallery-dl/issues/6037))\n- [cyberdrop] fix extraction\n- [deviantart] fix `\"pagination\": \"manual\"` for cursor-based endpoints ([#6079](https://github.com/mikf/gallery-dl/issues/6079))\n- [deviantart] fix `\"original\": \"images\"` ([#6124](https://github.com/mikf/gallery-dl/issues/6124))\n- [exhentai] fix `limits` option ([#6090](https://github.com/mikf/gallery-dl/issues/6090))\n- [flickr] make `exif` and `context` metadata extraction non-fatal ([#6002](https://github.com/mikf/gallery-dl/issues/6002), [#6077](https://github.com/mikf/gallery-dl/issues/6077))\n- [flickr] make `album` metadata extraction non-fatal ([#3441](https://github.com/mikf/gallery-dl/issues/3441))\n- [furaffinity] fix `favorite` pagination ([#6151](https://github.com/mikf/gallery-dl/issues/6151))\n- [gofile] fix `KeyError: 'childrenIds'` ([#5993](https://github.com/mikf/gallery-dl/issues/5993))\n- [newgrounds] fix warning for age-restricted posts ([#6005](https://github.com/mikf/gallery-dl/issues/6005))\n- [toyhouse] fix extraction of image URLs\n- [tumblr] fix `401 Unauthorized` for likes when using api-key ([#5994](https://github.com/mikf/gallery-dl/issues/5994))\n- [twitter] fix pinned Tweet extraction ([#6102](https://github.com/mikf/gallery-dl/issues/6102))\n- [ytdl] fix processing playlists of playlists ([#6127](https://github.com/mikf/gallery-dl/issues/6127))\n#### Improvements\n- [bcbnsfw] use `*` as query when retrieving all posts ([#6135](https://github.com/mikf/gallery-dl/issues/6135))\n- [bunkr] support `bunkr:` URL prefix ([#6017](https://github.com/mikf/gallery-dl/issues/6017))\n- [e621] cache pool metadata API calls ([#6001](https://github.com/mikf/gallery-dl/issues/6001))\n- [generic] better directory names ([#6104](https://github.com/mikf/gallery-dl/issues/6104))\n- [koharu] improve format selection ([#6088](https://github.com/mikf/gallery-dl/issues/6088))\n- [pixiv] implement downloading \"original\" ugoira frames ([#6056](https://github.com/mikf/gallery-dl/issues/6056))\n- [pixiv] use mobile API for `series` ([#5983](https://github.com/mikf/gallery-dl/issues/5983))\n#### Metadata\n- [batoto] improve chapter info regex ([#5988](https://github.com/mikf/gallery-dl/issues/5988), [#5997](https://github.com/mikf/gallery-dl/issues/5997))\n- [batoto] extract `chapter_url` metadata ([#5562](https://github.com/mikf/gallery-dl/issues/5562))\n- [batoto] improve `title` extraction ([#5988](https://github.com/mikf/gallery-dl/issues/5988))\n- [hitomi] extract `extension_original` metadata ([#6049](https://github.com/mikf/gallery-dl/issues/6049))\n- [instagram] add `post_date` metadata field ([#6081](https://github.com/mikf/gallery-dl/issues/6081), [#6091](https://github.com/mikf/gallery-dl/issues/6091))\n- [sankaku] restore old `tags` format ([#6043](https://github.com/mikf/gallery-dl/issues/6043))\n- [twitter] extract `type` metadata ([#6111](https://github.com/mikf/gallery-dl/issues/6111))\n#### Options\n- [bunkr] add `tlds` option to match URLs with all possible TLDs ([#5875](https://github.com/mikf/gallery-dl/issues/5875), [#6017](https://github.com/mikf/gallery-dl/issues/6017))\n- [instagram] add `max-posts` option ([#6054](https://github.com/mikf/gallery-dl/issues/6054))\n- [instagram] add `info` as a possible `include` value\n- [instagram] allow disabling `cursor` output\n- [twitter] add `info` as a possible `include` value ([#6114](https://github.com/mikf/gallery-dl/issues/6114))\n- [twitter] allow disabling `cursor` output ([#5990](https://github.com/mikf/gallery-dl/issues/5990))\n### Post Processors\n- [hash] add `hash` post processor to compute file hash metadata ([#6099](https://github.com/mikf/gallery-dl/issues/6099))\n- [metadata] add `include` and `exclude` options ([#6058](https://github.com/mikf/gallery-dl/issues/6058))\n- [metadata] fix using `..` in directories on Windows ([#5942](https://github.com/mikf/gallery-dl/issues/5942), [#6094](https://github.com/mikf/gallery-dl/issues/6094))\n- [rename] add `rename` post processor to rename previously downloaded files ([#5846](https://github.com/mikf/gallery-dl/issues/5846), [#6044](https://github.com/mikf/gallery-dl/issues/6044))\n- [ugoira] support converting \"original\" frames ([#6056](https://github.com/mikf/gallery-dl/issues/6056))\n- [ugoira] add `skip` option ([#6056](https://github.com/mikf/gallery-dl/issues/6056))\n### Miscellaneous\n- [cookies:firefox] extract only cookies without container by default ([#5957](https://github.com/mikf/gallery-dl/issues/5957))\n- [formatter] implement `A` format specifier ([#6036](https://github.com/mikf/gallery-dl/issues/6036))\n- [tests] fix bug when running tests in a certain order\n- [util] extend `CustomNone` with arithmetic operators ([#6007](https://github.com/mikf/gallery-dl/issues/6007), [#6009](https://github.com/mikf/gallery-dl/issues/6009))\n- add `--rename` and `--rename-to` command-line options ([#5846](https://github.com/mikf/gallery-dl/issues/5846), [#6044](https://github.com/mikf/gallery-dl/issues/6044))\n- add `input-files` config option ([#6059](https://github.com/mikf/gallery-dl/issues/6059))\n\n## 1.27.3 - 2024-08-10\n### Extractors\n#### Additions\n- [bunkr] support `bunkr.ci` and `bunkrrr.org` ([#5970](https://github.com/mikf/gallery-dl/issues/5970))\n- [furaffinity] add `submissions` extractor ([#5954](https://github.com/mikf/gallery-dl/issues/5954))\n- [hentaicosplays] support `hentai-cosplay-xxx.com` ([#5959](https://github.com/mikf/gallery-dl/issues/5959))\n#### Fixes\n- [behance] fix `KeyError: 'fields'` ([#5965](https://github.com/mikf/gallery-dl/issues/5965))\n- [behance] fix video extraction ([#5965](https://github.com/mikf/gallery-dl/issues/5965))\n- [cien] extract all files when authenticated ([#5934](https://github.com/mikf/gallery-dl/issues/5934))\n- [deviantart] fix `KeyError - 'category'` ([#5960](https://github.com/mikf/gallery-dl/issues/5960), [#5961](https://github.com/mikf/gallery-dl/issues/5961), [#5969](https://github.com/mikf/gallery-dl/issues/5969), [#5971](https://github.com/mikf/gallery-dl/issues/5971), [#5976](https://github.com/mikf/gallery-dl/issues/5976), [#5978](https://github.com/mikf/gallery-dl/issues/5978))\n- [fanbox] update pagination logic ([#5949](https://github.com/mikf/gallery-dl/issues/5949), [#5951](https://github.com/mikf/gallery-dl/issues/5951), [#5956](https://github.com/mikf/gallery-dl/issues/5956))\n- [hotleak] fix AttributeError ([#5950](https://github.com/mikf/gallery-dl/issues/5950))\n- [instagram] restore GraphQL API functionality ([#5920](https://github.com/mikf/gallery-dl/issues/5920))\n- [twitter] update `x-csrf-token` header during login ([#5945](https://github.com/mikf/gallery-dl/issues/5945))\n#### Improvements\n- [bunkr] fail downloads for `maintenance` files ([#5952](https://github.com/mikf/gallery-dl/issues/5952))\n- [zerochan] improve tag redirect handling, add `redirects` option ([#5891](https://github.com/mikf/gallery-dl/issues/5891))\n### Post Processors\n- [metadata] add `base-directory` option ([#5262](https://github.com/mikf/gallery-dl/issues/5262), [#5728](https://github.com/mikf/gallery-dl/issues/5728))\n\n## 1.27.2 - 2024-08-03\n### Extractors\n#### Additions\n- [agnph] add `tag` and `post` extractors ([#5284](https://github.com/mikf/gallery-dl/issues/5284), [#5890](https://github.com/mikf/gallery-dl/issues/5890))\n- [aryion] add `favorite` extractor ([#4511](https://github.com/mikf/gallery-dl/issues/4511), [#5870](https://github.com/mikf/gallery-dl/issues/5870))\n- [cien] add support ([#2885](https://github.com/mikf/gallery-dl/issues/2885), [#4103](https://github.com/mikf/gallery-dl/issues/4103), [#5240](https://github.com/mikf/gallery-dl/issues/5240))\n- [instagram] add `info` extractor ([#5262](https://github.com/mikf/gallery-dl/issues/5262))\n- [koharu] add `gallery`, `search`, and `favorite` extractors ([#5893](https://github.com/mikf/gallery-dl/issues/5893), [#4707](https://github.com/mikf/gallery-dl/issues/4707))\n- [twitter] add `info` extractor ([#3623](https://github.com/mikf/gallery-dl/issues/3623))\n#### Fixes\n- [8chan] update `TOS` cookie name ([#5868](https://github.com/mikf/gallery-dl/issues/5868))\n- [behance] fix image extraction ([#5873](https://github.com/mikf/gallery-dl/issues/5873), [#5926](https://github.com/mikf/gallery-dl/issues/5926))\n- [booru] prevent crash when file URL is empty ([#5859](https://github.com/mikf/gallery-dl/issues/5859))\n- [deviantart] try to work around journal/status API changes ([#5916](https://github.com/mikf/gallery-dl/issues/5916))\n- [hentainexus] fix error with spread pages ([#5827](https://github.com/mikf/gallery-dl/issues/5827))\n- [hotleak] fix faulty image URLs ([#5915](https://github.com/mikf/gallery-dl/issues/5915))\n- [inkbunny:following] fix potentially infinite loop\n- [nijie] fix image URLs of single image posts ([#5842](https://github.com/mikf/gallery-dl/issues/5842))\n- [readcomiconline] fix extraction ([#5866](https://github.com/mikf/gallery-dl/issues/5866))\n- [toyhouse] fix Content Warning bypass ([#5820](https://github.com/mikf/gallery-dl/issues/5820))\n- [tumblr] revert to `offset` pagination, implement `pagination` option ([#5880](https://github.com/mikf/gallery-dl/issues/5880))\n- [twitter] fix `username-alt` option name ([#5715](https://github.com/mikf/gallery-dl/issues/5715))\n- [warosu] fix extraction\n- [zerochan] handle `KeyError - 'items'` ([#5826](https://github.com/mikf/gallery-dl/issues/5826))\n- [zerochan] fix error on tag redirections ([#5891](https://github.com/mikf/gallery-dl/issues/5891))\n- [zerochan] fix `Invalid control character` errors ([#5892](https://github.com/mikf/gallery-dl/issues/5892))\n#### Improvements\n- [bunkr] support `bunkr.fi` domain ([#5872](https://github.com/mikf/gallery-dl/issues/5872))\n- [deviantart:following] use OAuth API endpoint ([#2511](https://github.com/mikf/gallery-dl/issues/2511))\n- [directlink] extend recognized file extensions ([#5924](https://github.com/mikf/gallery-dl/issues/5924))\n- [exhentai] improve error message when temporarily banned ([#5845](https://github.com/mikf/gallery-dl/issues/5845))\n- [gelbooru_v02] use total number of posts as pagination end marker ([#5830](https://github.com/mikf/gallery-dl/issues/5830))\n- [imagefap] add enumeration index to default filenames ([#1746](https://github.com/mikf/gallery-dl/issues/1746), [#5887](https://github.com/mikf/gallery-dl/issues/5887))\n- [paheal] implement fast `--range` support ([#5905](https://github.com/mikf/gallery-dl/issues/5905))\n- [redgifs] support URLs with numeric IDs ([#5898](https://github.com/mikf/gallery-dl/issues/5898), [#5899](https://github.com/mikf/gallery-dl/issues/5899))\n- [sankaku] match URLs with `www` subdomain ([#5907](https://github.com/mikf/gallery-dl/issues/5907))\n- [sankakucomplex] update domain to `news.sankakucomplex.com`\n- [twitter] implement `cursor` support ([#5753](https://github.com/mikf/gallery-dl/issues/5753))\n- [vipergirls] improve `thread` URL pattern\n- [wallpapercave] support `album` listings ([#5925](https://github.com/mikf/gallery-dl/issues/5925))\n#### Metadata\n- [dynastyscans] extract chapter `tags` ([#5904](https://github.com/mikf/gallery-dl/issues/5904))\n- [erome] extract `date` metadata ([#5796](https://github.com/mikf/gallery-dl/issues/5796))\n- [furaffinity] extract `folders` and `thumbnail` metadata ([#1284](https://github.com/mikf/gallery-dl/issues/1284), [#5824](https://github.com/mikf/gallery-dl/issues/5824))\n- [sankaku] implement `notes` extraction ([#5865](https://github.com/mikf/gallery-dl/issues/5865))\n- [subscribestar] fix `date` parsing in updated posts ([#5783](https://github.com/mikf/gallery-dl/issues/5783))\n- [twitter] extract `bookmark_count` and `view_count` metadata ([#5802](https://github.com/mikf/gallery-dl/issues/5802))\n- [zerochan] fix `source` metadata\n- [zerochan] fix tag category extraction ([#5874](https://github.com/mikf/gallery-dl/issues/5874))\n- [zerochan] delay fetching extended metadata ([#5869](https://github.com/mikf/gallery-dl/issues/5869))\n#### Options\n- [agnph] implement `tags` option ([#5284](https://github.com/mikf/gallery-dl/issues/5284))\n- [booru] allow multiple `url` keys ([#5859](https://github.com/mikf/gallery-dl/issues/5859))\n- [cien] add `files` option ([#2885](https://github.com/mikf/gallery-dl/issues/2885))\n- [koharu] add `cbz` and `format` options ([#5893](https://github.com/mikf/gallery-dl/issues/5893))\n- [vsco] add `include` option ([#5911](https://github.com/mikf/gallery-dl/issues/5911))\n- [zerochan] implement `tags` option ([#5874](https://github.com/mikf/gallery-dl/issues/5874))\n#### Removals\n- [fallenangels] remove module\n### Post Processors\n- [metadata] allow using format strings for `directory` ([#5728](https://github.com/mikf/gallery-dl/issues/5728))\n### Options\n- add `--print-traffic` command-line option\n- add `-J/--resolve-json` command-line option ([#5864](https://github.com/mikf/gallery-dl/issues/5864))\n- add `filters-environment` option\n- implement `archive-event` option ([#5784](https://github.com/mikf/gallery-dl/issues/5784))\n### Actions\n- [actions] support multiple actions per pattern\n- [actions] add `exec` action ([#5619](https://github.com/mikf/gallery-dl/issues/5619))\n- [actions] add `abort` and `terminate` actions ([#5778](https://github.com/mikf/gallery-dl/issues/5778))\n- [actions] allow setting a duration for `wait`\n- [actions] emit logging messages before waiting/exiting/etc\n### Tests\n- [tests] enable test results for external extractors ([#5262](https://github.com/mikf/gallery-dl/issues/5262))\n- [tests] load results from `${GDL_TEST_RESULTS}` ([#5262](https://github.com/mikf/gallery-dl/issues/5262))\n### Miscellaneous\n- [cookies] add `thorium` support ([#5781](https://github.com/mikf/gallery-dl/issues/5781))\n- [job] add `resolve` argument to DataJob ([#5864](https://github.com/mikf/gallery-dl/issues/5864))\n- [path] fix moving temporary files across drives on Windows ([#5807](https://github.com/mikf/gallery-dl/issues/5807))\n- [ytdl] fix `--cookies-from-browser` option parsing ([#5885](https://github.com/mikf/gallery-dl/issues/5885))\n- make exceptions in filters/conditionals non-fatal\n- update default User-Agent header to Firefox 128 ESR\n- include `zstd` in Accept-Encoding header when supported\n\n## 1.27.1 - 2024-06-22\n### Extractors\n#### Additions\n- [hentainexus] restore module ([#5275](https://github.com/mikf/gallery-dl/issues/5275), [#5712](https://github.com/mikf/gallery-dl/issues/5712))\n- [shimmie2] support `vidya.pics` ([#5632](https://github.com/mikf/gallery-dl/issues/5632))\n- [tcbscans] support other domains ([#5774](https://github.com/mikf/gallery-dl/issues/5774))\n#### Fixes\n- [deviantart] fix watching module ID extraction ([#5696](https://github.com/mikf/gallery-dl/issues/5696), [#5772](https://github.com/mikf/gallery-dl/issues/5772))\n- [fanbox] handle KeyError for no longer existing plans ([#5759](https://github.com/mikf/gallery-dl/issues/5759))\n- [kemonoparty:favorite] fix exception when sorting `null` objects ([#5692](https://github.com/mikf/gallery-dl/issues/5692). [#5721](https://github.com/mikf/gallery-dl/issues/5721))\n- [skeb] fix `429 Too Many Requests` errors ([#5766](https://github.com/mikf/gallery-dl/issues/5766))\n- [speakerdeck] fix extraction ([#5730](https://github.com/mikf/gallery-dl/issues/5730))\n- [twitter] fix duplicate `ArkoseLogin` check\n#### Improvements\n- [nijie] support downloading videos ([#5707](https://github.com/mikf/gallery-dl/issues/5707), [#5617](https://github.com/mikf/gallery-dl/issues/5617))\n- [philomena] support downloading `.svg` files ([#5643](https://github.com/mikf/gallery-dl/issues/5643))\n- [szurubooru] support empty tag searches ([#5711](https://github.com/mikf/gallery-dl/issues/5711))\n- [twitter] ignore `Unavailable` media ([#5736](https://github.com/mikf/gallery-dl/issues/5736))\n#### Metadata\n- [hitomi] extract `title_jpn` metadata ([#5706](https://github.com/mikf/gallery-dl/issues/5706))\n- [instagram] extract `liked` metadata ([#5609](https://github.com/mikf/gallery-dl/issues/5609))\n#### Options\n- [newgrounds] extend `format` option ([#5709](https://github.com/mikf/gallery-dl/issues/5709))\n- [twitter] extend `ratelimit` option ([#5532](https://github.com/mikf/gallery-dl/issues/5532))\n- [twitter] add `username-alt` option ([#5715](https://github.com/mikf/gallery-dl/issues/5715))\n#### Removals\n- [photobucket] remove module\n- [nitter] remove instances\n- [vichan] remove `wikieat.club`\n### Downloaders\n- [ytdl] fix exception due to missing `ext` in unavailable videos ([#5675](https://github.com/mikf/gallery-dl/issues/5675))\n### Formatter\n- implement `C` format specifier ([#5647](https://github.com/mikf/gallery-dl/issues/5647))\n- implement `X` format specifier ([#5770](https://github.com/mikf/gallery-dl/issues/5770))\n### Options\n- add `--no-input` command-line and `input` config option ([#5733](https://github.com/mikf/gallery-dl/issues/5733))\n- add `--config-open` command-line option ([#5713](https://github.com/mikf/gallery-dl/issues/5713))\n- add `--config-status` command-line option ([#5713](https://github.com/mikf/gallery-dl/issues/5713))\n### Miscellaneous\n- [actions] fix exception when `msg` is not a string ([#5683](https://github.com/mikf/gallery-dl/issues/5683))\n\n## 1.27.0 - 2024-06-01\n### Extractors\n#### Additions\n- [mastodon] add `favorite`, `list`, and `hashtag` extractors ([#5529](https://github.com/mikf/gallery-dl/issues/5529))\n- [mastodon] add support for card images\n- [pixeldrain] add support for single-file album downloads ([#5641](https://github.com/mikf/gallery-dl/issues/5641))\n- [reddit] support comment embeds ([#5366](https://github.com/mikf/gallery-dl/issues/5366))\n- [seiga] re-implement login with username & password\n- [tapas] add `creator` extractor ([#5306](https://github.com/mikf/gallery-dl/issues/5306))\n- [vsco] add `avatar` extractor ([#5341](https://github.com/mikf/gallery-dl/issues/5341))\n- [wikimedia] support `wiki.gg` wikis\n#### Fixes\n- [4archive] fix extraction\n- [8chan] fix file downloads by sending a `TOS` cookie ([#5578](https://github.com/mikf/gallery-dl/issues/5578))\n- [artstation] disable TLS 1.2 ciphers by default ([#5564](https://github.com/mikf/gallery-dl/issues/5564), [#5658](https://github.com/mikf/gallery-dl/issues/5658))\n- [bluesky] filter reposts only for user timelines ([#5528](https://github.com/mikf/gallery-dl/issues/5528))\n- [common] disable `check_hostname` for custom SSLContexts ([#3614](https://github.com/mikf/gallery-dl/issues/3614), [#4891](https://github.com/mikf/gallery-dl/issues/4891), [#5576](https://github.com/mikf/gallery-dl/issues/5576))\n- [exhentai] fix Multi-Page Viewer detection ([#4969](https://github.com/mikf/gallery-dl/issues/4969))\n- [exhentai] fix blank page detection\n- [hiperdex] update domain to `hiperdex.top` ([#5635](https://github.com/mikf/gallery-dl/issues/5635))\n- [hotleak] download files returning a 404 status code ([#5395](https://github.com/mikf/gallery-dl/issues/5395))\n- [imgur] match URLs with title slugs ([#5593](https://github.com/mikf/gallery-dl/issues/5593))\n- [kemonoparty] fix `KeyError - 'path'` for posts without files ([#5368](https://github.com/mikf/gallery-dl/issues/5368), [#5394](https://github.com/mikf/gallery-dl/issues/5394), [#5422](https://github.com/mikf/gallery-dl/issues/5422), [#5488](https://github.com/mikf/gallery-dl/issues/5488))\n- [kemonoparty] fix crash on posts with missing datetime info ([#5422](https://github.com/mikf/gallery-dl/issues/5422))\n- [mastodon] send canonical `true`/`false` boolean values ([#5516](https://github.com/mikf/gallery-dl/issues/5516))\n- [newgrounds] update and fix login procedure ([#5109](https://github.com/mikf/gallery-dl/issues/5109))\n- [patreon] fix `bootstrap` data extraction ([#5624](https://github.com/mikf/gallery-dl/issues/5624))\n- [poipiku] fix downloading R-18 posts ([#5567](https://github.com/mikf/gallery-dl/issues/5567))\n- [poipoku] avoid language-specific extraction ([#5590](https://github.com/mikf/gallery-dl/issues/5590), [#5591](https://github.com/mikf/gallery-dl/issues/5591))\n- [realbooru] fix videos and provide fallback URLs ([#2530](https://github.com/mikf/gallery-dl/issues/2530))\n- [slideshare] fix extraction\n- [subscribestar] fix file URLs ([#5631](https://github.com/mikf/gallery-dl/issues/5631))\n- [twitter] update domain to `x.com` ([#5597](https://github.com/mikf/gallery-dl/issues/5597))\n- [twitter] transfer `twitter.com` cookies to `x.com` ([#5597](https://github.com/mikf/gallery-dl/issues/5597))\n- [twitter] prevent crash when extracting `birdwatch` metadata ([#5403](https://github.com/mikf/gallery-dl/issues/5403))\n- [twitter] handle missing `expanded_url` fields ([#5463](https://github.com/mikf/gallery-dl/issues/5463), [#5490](https://github.com/mikf/gallery-dl/issues/5490))\n- [wikimedia] suppress exception for entries without `imageinfo` ([#5384](https://github.com/mikf/gallery-dl/issues/5384))\n- [wikimedia] fix exception for files with empty `metadata`\n#### Improvements\n- [exhentai] detect CAPTCHAs during login ([#5492](https://github.com/mikf/gallery-dl/issues/5492))\n- [foolfuuka] improve `board` pattern & support pages ([#5408](https://github.com/mikf/gallery-dl/issues/5408))\n- [furaffinity] match `fxfuraffinity.net`/`fxraffinity.net`/`xfuraffinity.net` URLs ([#5511](https://github.com/mikf/gallery-dl/issues/5511), [#5568](https://github.com/mikf/gallery-dl/issues/5568))\n- [gelbooru] improve pagination logic for meta tags ([#5478](https://github.com/mikf/gallery-dl/issues/5478))\n- [kemonoparty:favorite] return artists/posts in native order and support `sort` and `order` query parameters ([#5375](https://github.com/mikf/gallery-dl/issues/5375), [#5620](https://github.com/mikf/gallery-dl/issues/5620))\n- [oauth] use `Extractor.request()` for HTTP requests to support proxy servers etc ([#5433](https://github.com/mikf/gallery-dl/issues/5433))\n- [pixiv] change `sanity_level` debug message to a warning ([#5180](https://github.com/mikf/gallery-dl/issues/5180))\n- [twitter] improve username & password login procedure ([#5445](https://github.com/mikf/gallery-dl/issues/5445))\n- [twitter] wait for rate limit reset before encountering a 429 error ([#5532](https://github.com/mikf/gallery-dl/issues/5532))\n- [twitter] match `fixvx.com` URLs ([#5511](https://github.com/mikf/gallery-dl/issues/5511))\n- [twitter] match Tweet URLs with query parameters ([#5371](https://github.com/mikf/gallery-dl/issues/5371), [#5372](https://github.com/mikf/gallery-dl/issues/5372))\n- [twitter] match `/photo/` and `/video/` Tweet URLs ([#5443](https://github.com/mikf/gallery-dl/issues/5443), [#5601](https://github.com/mikf/gallery-dl/issues/5601))\n#### Options\n- [common] add `sleep-429` option ([#5160](https://github.com/mikf/gallery-dl/issues/5160))\n- [common] implement `skip-filter` option ([#5255](https://github.com/mikf/gallery-dl/issues/5255))\n- [common] implement `keywords-eval` option ([#5621](https://github.com/mikf/gallery-dl/issues/5621))\n- [kemonoparty] add `announcements` option ([#5262](https://github.com/mikf/gallery-dl/issues/5262))\n- [pixiv:novel] add `covers` option ([#5373](https://github.com/mikf/gallery-dl/issues/5373))\n- [twitter] implement `relogin` option ([#5445](https://github.com/mikf/gallery-dl/issues/5445))\n### Downloaders\n- [http] add MIME type and signature for `.m4v` files ([#5505](https://github.com/mikf/gallery-dl/issues/5505))\n### Post Processors\n- [mtime] do not overwrite `_mtime` values with `None` ([#5439](https://github.com/mikf/gallery-dl/issues/5439))\n- [ugoira] log errors for general exceptions\n### Archives\n- [archive] move DownloadArchive code into its own module\n- [archive] implement `DownloadArchiveMemory` class ([#5255](https://github.com/mikf/gallery-dl/issues/5255))\n- [archive] add `archive-mode` option ([#5255](https://github.com/mikf/gallery-dl/issues/5255))\n### Cookies\n- [cookies] use temporary file when saving cookies.txt files ([#5461](https://github.com/mikf/gallery-dl/issues/5461))\n- [cookies] optimize `_find_most_recently_used_file()` for exact profiles ([#5538](https://github.com/mikf/gallery-dl/issues/5538))\n- [cookies] set proper `expires` value for Chrome session cookies\n### Documentation\n- [docs] update docs/configuration links ([#5059](https://github.com/mikf/gallery-dl/issues/5059), [#5369](https://github.com/mikf/gallery-dl/issues/5369), [#5423](https://github.com/mikf/gallery-dl/issues/5423))\n- [docs] update link to \"nightly\" builds ([#5618](https://github.com/mikf/gallery-dl/issues/5618))\n- [docs] replace AnchorJS with custom script\n- [docs] update defaults of `sleep-request`, `browser`, `tls12`\n- [docs] complete Authentication info in docs/supportedsites\n### Formatter\n- [formatter] allow dots in `'...'` literals ([#5539](https://github.com/mikf/gallery-dl/issues/5539))\n### Output\n- [output] enable colored output by default\n- [output] extend `output.colors` ([#2566](https://github.com/mikf/gallery-dl/issues/2566))\n- [output] support `NO_COLOR` environment variable\n- [output] add `--no-colors` command-line option\n- [output] add `-w/--warning` command-line option ([#5474](https://github.com/mikf/gallery-dl/issues/5474))\n### Tests\n- [tests] select unused port number for local HTTP server\n- [tests] allow filtering extractor result tests by URL or comment\n- [tests] mark tests with missing auth as `only_matching`\n### Update\n- implement update-related command-line options ([#5233](https://github.com/mikf/gallery-dl/issues/5233))\n  - `-U`/`--update` updates an executable file to the latest release\n  - `--update-check` checks if the local version is up to date\n  - `--update-to` allows switching to a different release channel (`stable` or `dev`)\n    as well as upgrading/downgrading to a specific tag.\n    - `--update-to dev`\n    - `--update-to dev@2024.05.25`\n    - `--update-to v1.25.2`\n  - (non-executable installations have only access to `-U`/`--update-check` for version checks)\n### Miscellaneous\n- add workaround for requests 2.32.3 issues ([#5665](https://github.com/mikf/gallery-dl/issues/5665))\n- fix exit status of `--clear-cache`/`--list-extractors`/`--list-modules`\n- restore `LD_LIBRARY_PATH` for executables built with PyInstaller  ([#5421](https://github.com/mikf/gallery-dl/issues/5421))\n- store `match` and `groups` values in Extractor objects\n\n## 1.26.9 - 2024-03-23\n### Extractors\n#### Additions\n- [artstation] support video clips ([#2566](https://github.com/mikf/gallery-dl/issues/2566), [#3309](https://github.com/mikf/gallery-dl/issues/3309), [#3911](https://github.com/mikf/gallery-dl/issues/3911))\n- [artstation] support collections ([#146](https://github.com/mikf/gallery-dl/issues/146))\n- [deviantart] recognize `deviantart.com/stash/…` URLs\n- [idolcomplex] support new pool URLs\n- [lensdump] recognize direct image links ([#5293](https://github.com/mikf/gallery-dl/issues/5293))\n- [skeb] add extractor for followed users ([#5290](https://github.com/mikf/gallery-dl/issues/5290))\n- [twitter] add `quotes` extractor ([#5262](https://github.com/mikf/gallery-dl/issues/5262))\n- [wikimedia] support `azurlane.koumakan.jp` ([#5256](https://github.com/mikf/gallery-dl/issues/5256))\n- [xvideos] support `/channels/` URLs ([#5244](https://github.com/mikf/gallery-dl/issues/5244))\n#### Fixes\n- [artstation] fix handling usernames with dashes in domain names ([#5224](https://github.com/mikf/gallery-dl/issues/5224))\n- [bluesky] fix not spawning child extractors for followed users ([#5246](https://github.com/mikf/gallery-dl/issues/5246))\n- [deviantart] handle CloudFront blocks ([#5363](https://github.com/mikf/gallery-dl/issues/5363))\n- [deviantart:avatar] fix `index` for URLs without `?` ([#5276](https://github.com/mikf/gallery-dl/issues/5276))\n- [deviantart:stash] fix `index` values ([#5335](https://github.com/mikf/gallery-dl/issues/5335))\n- [gofile] fix extraction\n- [hiperdex] update URL patterns & fix `manga` metadata ([#5340](https://github.com/mikf/gallery-dl/issues/5340))\n- [idolcomplex] fix metadata extraction\n- [imagefap] fix folder extraction ([#5333](https://github.com/mikf/gallery-dl/issues/5333))\n- [instagram] make accessing `like_count` non-fatal ([#5218](https://github.com/mikf/gallery-dl/issues/5218))\n- [mastodon] fix handling null `moved` account field ([#5321](https://github.com/mikf/gallery-dl/issues/5321))\n- [naver] fix EUC-KR encoding issue in old image URLs ([#5126](https://github.com/mikf/gallery-dl/issues/5126))\n- [nijie] increase default delay between requests ([#5221](https://github.com/mikf/gallery-dl/issues/5221))\n- [nitter] ignore invalid Tweets ([#5253](https://github.com/mikf/gallery-dl/issues/5253))\n- [pixiv:novel] fix text extraction ([#5285](https://github.com/mikf/gallery-dl/issues/5285), [#5309](https://github.com/mikf/gallery-dl/issues/5309))\n- [skeb] retry 429 responses containing a `request_key` cookie ([#5210](https://github.com/mikf/gallery-dl/issues/5210))\n- [warosu] fix crash for threads with deleted posts ([#5289](https://github.com/mikf/gallery-dl/issues/5289))\n- [weibo] fix retweets ([#2825](https://github.com/mikf/gallery-dl/issues/2825), [#3874](https://github.com/mikf/gallery-dl/issues/3874), [#5263](https://github.com/mikf/gallery-dl/issues/5263))\n- [weibo] fix `livephoto` filename extensions ([#5287](https://github.com/mikf/gallery-dl/issues/5287))\n- [xvideos] fix galleries with more than 500 images ([#5244](https://github.com/mikf/gallery-dl/issues/5244))\n#### Improvements\n- [bluesky] improve API error messages\n- [bluesky] handle posts with different `embed` structure\n- [deviantart:avatar] ignore default avatars ([#5276](https://github.com/mikf/gallery-dl/issues/5276))\n- [fapello] download full-sized images ([#5349](https://github.com/mikf/gallery-dl/issues/5349))\n- [gelbooru:favorite] automatically detect returned post order ([#5220](https://github.com/mikf/gallery-dl/issues/5220))\n- [imgur] fail downloads when redirected to `removed.png` ([#5308](https://github.com/mikf/gallery-dl/issues/5308))\n- [instagram] raise proper error for missing `reels_media` ([#5257](https://github.com/mikf/gallery-dl/issues/5257))\n- [instagram] change `posts are private` exception to a warning ([#5322](https://github.com/mikf/gallery-dl/issues/5322))\n- [reddit] improve preview fallback formats ([#5296](https://github.com/mikf/gallery-dl/issues/5296), [#5315](https://github.com/mikf/gallery-dl/issues/5315))\n- [steamgriddb] raise exception for deleted assets\n- [twitter] handle \"account is temporarily locked\" errors ([#5300](https://github.com/mikf/gallery-dl/issues/5300))\n- [weibo] rework pagination logic ([#4168](https://github.com/mikf/gallery-dl/issues/4168))\n- [zerochan] fetch more posts by using the API ([#3669](https://github.com/mikf/gallery-dl/issues/3669))\n#### Metadata\n- [bluesky] add `instance` metadata field ([#4438](https://github.com/mikf/gallery-dl/issues/4438))\n- [gelbooru:favorite] add `date_favorited` metadata field\n- [imagefap] extract `folder` metadata ([#5270](https://github.com/mikf/gallery-dl/issues/5270))\n- [instagram] default `likes` to `0` ([#5323](https://github.com/mikf/gallery-dl/issues/5323))\n- [kemonoparty] add `revision_count` metadata field ([#5334](https://github.com/mikf/gallery-dl/issues/5334))\n- [naver] unescape post `title` and `description`\n- [pornhub:gif] extract `viewkey` and `timestamp` metadata ([#4463](https://github.com/mikf/gallery-dl/issues/4463))\n- [redgifs] make `date` available for directories ([#5262](https://github.com/mikf/gallery-dl/issues/5262))\n- [subscribestar] fix `date` metadata\n- [twitter] add `birdwatch` metadata field ([#5317](https://github.com/mikf/gallery-dl/issues/5317))\n- [twitter] add `protected` metadata field ([#5327](https://github.com/mikf/gallery-dl/issues/5327))\n- [warosu] fix `board_name` metadata\n#### Options\n- [bluesky] add `reposts` option ([#4438](https://github.com/mikf/gallery-dl/issues/4438), [#5248](https://github.com/mikf/gallery-dl/issues/5248))\n- [deviantart] add `comments-avatars` option ([#4995](https://github.com/mikf/gallery-dl/issues/4995))\n- [deviantart] extend `metadata` option ([#5175](https://github.com/mikf/gallery-dl/issues/5175))\n- [flickr] add `contexts` option ([#5324](https://github.com/mikf/gallery-dl/issues/5324))\n- [gelbooru:favorite] add `order-posts` option ([#5220](https://github.com/mikf/gallery-dl/issues/5220))\n- [kemonoparty] add `order-revisions` option ([#5334](https://github.com/mikf/gallery-dl/issues/5334))\n- [vipergirls] add `like` option ([#4166](https://github.com/mikf/gallery-dl/issues/4166))\n- [vipergirls] add `domain` option ([#4166](https://github.com/mikf/gallery-dl/issues/4166))\n### Downloaders\n- [http] add MIME type and signature for `.mov` files ([#5287](https://github.com/mikf/gallery-dl/issues/5287))\n### Docker\n- build images from source instead of PyPI package\n- build `linux/arm64` images ([#5227](https://github.com/mikf/gallery-dl/issues/5227))\n- build images on every push to master\n  - tag images as `YYYY.MM.DD`\n  - tag the most recent build from master as `dev`\n  - tag the most recent release build as `latest`\n- reduce image size ([#5097](https://github.com/mikf/gallery-dl/issues/5097))\n### Miscellaneous\n- [formatter] fix local DST datetime offsets for `:O`\n- build Linux executable on Ubuntu 22.04 LTS ([#4184](https://github.com/mikf/gallery-dl/issues/4184))\n- automatically create directories for logging files ([#5249](https://github.com/mikf/gallery-dl/issues/5249))\n\n## 1.26.8 - 2024-02-17\n### Extractors\n#### Additions\n- [bluesky] add support ([#4438](https://github.com/mikf/gallery-dl/issues/4438), [#4708](https://github.com/mikf/gallery-dl/issues/4708), [#4722](https://github.com/mikf/gallery-dl/issues/4722), [#5047](https://github.com/mikf/gallery-dl/issues/5047))\n- [bunkr] support new domains ([#5114](https://github.com/mikf/gallery-dl/issues/5114), [#5130](https://github.com/mikf/gallery-dl/issues/5130), [#5134](https://github.com/mikf/gallery-dl/issues/5134))\n- [fanbox] add `home` and `supporting` extractors ([#5138](https://github.com/mikf/gallery-dl/issues/5138))\n- [imagechest] add `user` extractor ([#5143](https://github.com/mikf/gallery-dl/issues/5143))\n- [imagetwist] add `gallery` extractor ([#5190](https://github.com/mikf/gallery-dl/issues/5190))\n- [kemonoparty] add `posts` extractor ([#5194](https://github.com/mikf/gallery-dl/issues/5194), [#5198](https://github.com/mikf/gallery-dl/issues/5198))\n- [twitter] support communities ([#4913](https://github.com/mikf/gallery-dl/issues/4913))\n- [vsco] support spaces ([#5202](https://github.com/mikf/gallery-dl/issues/5202))\n- [weibo] add `gifs` option ([#5183](https://github.com/mikf/gallery-dl/issues/5183))\n- [wikimedia] support `www.pidgi.net` ([#5205](https://github.com/mikf/gallery-dl/issues/5205))\n- [wikimedia] support `bulbapedia.bulbagarden.net` ([#5206](https://github.com/mikf/gallery-dl/issues/5206))\n#### Fixes\n- [archivedmoe] fix `thebarchive` WebM URLs ([#5116](https://github.com/mikf/gallery-dl/issues/5116))\n- [batoto] fix crash when manga name or chapter contains a `-` ([#5200](https://github.com/mikf/gallery-dl/issues/5200))\n- [bunkr] fix extraction ([#5088](https://github.com/mikf/gallery-dl/issues/5088), [#5151](https://github.com/mikf/gallery-dl/issues/5151), [#5153](https://github.com/mikf/gallery-dl/issues/5153))\n- [gofile] update `website_token` extraction\n- [idolcomplex] fix pagination for tags containing `:` ([#5184](https://github.com/mikf/gallery-dl/issues/5184))\n- [kemonoparty] fix deleting file names when computing `revision_hash` ([#5103](https://github.com/mikf/gallery-dl/issues/5103))\n- [luscious] fix IndexError for files without thumbnail ([#5122](https://github.com/mikf/gallery-dl/issues/5122), [#5124](https://github.com/mikf/gallery-dl/issues/5124), [#5182](https://github.com/mikf/gallery-dl/issues/5182))\n- [naverwebtoon] fix `title` for comics with empty tags ([#5120](https://github.com/mikf/gallery-dl/issues/5120))\n- [pinterest] fix section URLs for boards with `/`, `?`, or `#` in their name ([#5104](https://github.com/mikf/gallery-dl/issues/5104))\n- [twitter] update query hashes\n- [zerochan] fix skipping every other post\n#### Improvements\n- [deviantart] skip locked/blurred posts ([#4567](https://github.com/mikf/gallery-dl/issues/4567), [#5193](https://github.com/mikf/gallery-dl/issues/5193))\n- [deviantart] implement downloading PNG versions of non-original images with `\"quality\": \"png\"` ([#4846](https://github.com/mikf/gallery-dl/issues/4846))\n- [flickr] handle non-JSON errors ([#5131](https://github.com/mikf/gallery-dl/issues/5131))\n- [idolcomplex] support alphanumeric post IDs ([#5171](https://github.com/mikf/gallery-dl/issues/5171))\n- [kemonoparty] implement filtering duplicate revisions with `\"revisions\": \"unique\"`([#5013](https://github.com/mikf/gallery-dl/issues/5013))\n- [naverwebtoon] support `/webtoon/` paths for all comics ([#5123](https://github.com/mikf/gallery-dl/issues/5123))\n#### Metadata\n- [idolcomplex] extract `id_alnum` metadata ([#5171](https://github.com/mikf/gallery-dl/issues/5171))\n- [pornpics] support multiple values for `channel` ([#5195](https://github.com/mikf/gallery-dl/issues/5195))\n- [sankaku] add `id-format` option ([#5073](https://github.com/mikf/gallery-dl/issues/5073))\n- [skeb] add `num` and `count` metadata fields ([#5187](https://github.com/mikf/gallery-dl/issues/5187))\n### Downloaders\n#### Fixes\n- [http] remove `pyopenssl` import ([#5156](https://github.com/mikf/gallery-dl/issues/5156))\n### Miscellaneous\n- fix filename formatting silently failing under certain circumstances ([#5185](https://github.com/mikf/gallery-dl/issues/5185), [#5186](https://github.com/mikf/gallery-dl/issues/5186))\n\n## 1.26.7 - 2024-01-21\n### Extractors\n#### Additions\n- [2ch] add support ([#1009](https://github.com/mikf/gallery-dl/issues/1009), [#3540](https://github.com/mikf/gallery-dl/issues/3540), [#4444](https://github.com/mikf/gallery-dl/issues/4444))\n- [deviantart:avatar] add `formats` option ([#4995](https://github.com/mikf/gallery-dl/issues/4995))\n- [hatenablog] add support ([#5036](https://github.com/mikf/gallery-dl/issues/5036), [#5037](https://github.com/mikf/gallery-dl/issues/5037))\n- [mangadex] add `list` extractor ([#5025](https://github.com/mikf/gallery-dl/issues/5025))\n- [steamgriddb] add support ([#5033](https://github.com/mikf/gallery-dl/issues/5033), [#5041](https://github.com/mikf/gallery-dl/issues/5041))\n- [wikimedia] add support ([#1443](https://github.com/mikf/gallery-dl/issues/1443), [#2906](https://github.com/mikf/gallery-dl/issues/2906), [#3660](https://github.com/mikf/gallery-dl/issues/3660), [#2340](https://github.com/mikf/gallery-dl/issues/2340))\n- [wikimedia] support `fandom` wikis ([#2677](https://github.com/mikf/gallery-dl/issues/2677), [#3378](https://github.com/mikf/gallery-dl/issues/3378))\n#### Fixes\n- [blogger] fix `lh-*.googleusercontent.com` URLs ([#5091](https://github.com/mikf/gallery-dl/issues/5091))\n- [bunkr] update domain ([#5088](https://github.com/mikf/gallery-dl/issues/5088))\n- [deviantart] fix AttributeError for URLs without username ([#5065](https://github.com/mikf/gallery-dl/issues/5065))\n- [deviantart] fix `KeyError: 'premium_folder_data'` ([#5063](https://github.com/mikf/gallery-dl/issues/5063))\n- [deviantart:avatar] fix exception when `comments` are enabled ([#4995](https://github.com/mikf/gallery-dl/issues/4995))\n- [fuskator] make metadata extraction non-fatal ([#5039](https://github.com/mikf/gallery-dl/issues/5039))\n- [gelbooru] only log \"Incomplete API response\" for favorites ([#5045](https://github.com/mikf/gallery-dl/issues/5045))\n- [giantessbooru] update domain\n- [issuu] fix extraction\n- [nijie] fix download URLs of single image posts ([#5049](https://github.com/mikf/gallery-dl/issues/5049))\n- [patreon] fix `KeyError: 'name'` ([#5048](https://github.com/mikf/gallery-dl/issues/5048), [#5069](https://github.com/mikf/gallery-dl/issues/5069), [#5093](https://github.com/mikf/gallery-dl/issues/5093))\n- [pixiv] update API headers ([#5029](https://github.com/mikf/gallery-dl/issues/5029))\n- [realbooru] fix download URLs of older posts\n- [twitter] revert to using `media` timeline by default ([#4953](https://github.com/mikf/gallery-dl/issues/4953))\n- [vk] transform image URLs to non-blurred versions ([#5017](https://github.com/mikf/gallery-dl/issues/5017))\n#### Improvements\n- [batoto] support more mirror domains ([#5042](https://github.com/mikf/gallery-dl/issues/5042))\n- [batoto] improve v2 manga URL pattern\n- [gelbooru] support `all` tag and URLs with empty tags ([#5076](https://github.com/mikf/gallery-dl/issues/5076))\n- [patreon] download `m3u8` manifests with ytdl\n- [sankaku] support post URLs with alphanumeric IDs ([#5073](https://github.com/mikf/gallery-dl/issues/5073))\n#### Metadata\n- [batoto] improve `manga_id` extraction ([#5042](https://github.com/mikf/gallery-dl/issues/5042))\n- [erome] fix `count` metadata\n- [kemonoparty] add `revision_hash` metadata ([#4706](https://github.com/mikf/gallery-dl/issues/4706), [#4727](https://github.com/mikf/gallery-dl/issues/4727), [#5013](https://github.com/mikf/gallery-dl/issues/5013))\n- [paheal] fix `source` metadata\n- [webtoons] extract more metadata ([#5061](https://github.com/mikf/gallery-dl/issues/5061), [#5094](https://github.com/mikf/gallery-dl/issues/5094))\n#### Removals\n- [chevereto] remove `pixl.li`\n- [hbrowse] remove module\n- [nitter] remove `nitter.lacontrevoie.fr`\n\n## 1.26.6 - 2024-01-06\n### Extractors\n#### Additions\n- [batoto] add `chapter` and `manga` extractors ([#1434](https://github.com/mikf/gallery-dl/issues/1434), [#2111](https://github.com/mikf/gallery-dl/issues/2111), [#4979](https://github.com/mikf/gallery-dl/issues/4979))\n- [deviantart] add `avatar` and `background` extractors ([#4995](https://github.com/mikf/gallery-dl/issues/4995))\n- [poringa] add support ([#4675](https://github.com/mikf/gallery-dl/issues/4675), [#4962](https://github.com/mikf/gallery-dl/issues/4962))\n- [szurubooru] support `snootbooru.com` ([#5023](https://github.com/mikf/gallery-dl/issues/5023))\n- [zzup] add `gallery` extractor ([#4517](https://github.com/mikf/gallery-dl/issues/4517), [#4604](https://github.com/mikf/gallery-dl/issues/4604), [#4659](https://github.com/mikf/gallery-dl/issues/4659), [#4863](https://github.com/mikf/gallery-dl/issues/4863), [#5016](https://github.com/mikf/gallery-dl/issues/5016))\n#### Fixes\n- [gelbooru] fix `favorite` extractor ([#4903](https://github.com/mikf/gallery-dl/issues/4903))\n- [idolcomplex] fix extraction & update URL patterns ([#5002](https://github.com/mikf/gallery-dl/issues/5002))\n- [imagechest] fix loading more than 10 images in a gallery ([#4469](https://github.com/mikf/gallery-dl/issues/4469))\n- [jpgfish] update domain\n- [komikcast] fix `manga` extractor ([#5027](https://github.com/mikf/gallery-dl/issues/5027))\n- [komikcast] update domain ([#5027](https://github.com/mikf/gallery-dl/issues/5027))\n- [lynxchan] update `bbw-chan` domain ([#4970](https://github.com/mikf/gallery-dl/issues/4970))\n- [manganelo] fix extraction & recognize `.to` TLDs ([#5005](https://github.com/mikf/gallery-dl/issues/5005))\n- [paheal] restore `extension` metadata ([#4976](https://github.com/mikf/gallery-dl/issues/4976))\n- [rule34us] add fallback for `video-cdn1` videos ([#4985](https://github.com/mikf/gallery-dl/issues/4985))\n- [weibo] fix AttributeError in `user` extractor ([#5022](https://github.com/mikf/gallery-dl/issues/5022))\n#### Improvements\n- [gelbooru] show error for invalid API responses ([#4903](https://github.com/mikf/gallery-dl/issues/4903))\n- [rule34] recognize URLs with `www` subdomain ([#4984](https://github.com/mikf/gallery-dl/issues/4984))\n- [twitter] raise error for invalid `strategy` values ([#4953](https://github.com/mikf/gallery-dl/issues/4953))\n#### Metadata\n- [fanbox] add `metadata` option ([#4921](https://github.com/mikf/gallery-dl/issues/4921))\n- [nijie] add `count` metadata ([#146](https://github.com/mikf/gallery-dl/issues/146))\n- [pinterest] add `count` metadata ([#4981](https://github.com/mikf/gallery-dl/issues/4981))\n### Miscellaneous\n- fix and update zsh completion ([#4972](https://github.com/mikf/gallery-dl/issues/4972))\n- fix `--cookies-from-browser` macOS Firefox profile path\n\n## 1.26.5 - 2023-12-23\n### Extractors\n#### Additions\n- [deviantart] add `intermediary` option ([#4955](https://github.com/mikf/gallery-dl/issues/4955))\n- [inkbunny] add `unread` extractor ([#4934](https://github.com/mikf/gallery-dl/issues/4934))\n- [mastodon] support non-numeric status IDs ([#4936](https://github.com/mikf/gallery-dl/issues/4936))\n- [myhentaigallery] recognize `/g/` URLs ([#4920](https://github.com/mikf/gallery-dl/issues/4920))\n- [postmill] add support ([#4917](https://github.com/mikf/gallery-dl/issues/4917), [#4919](https://github.com/mikf/gallery-dl/issues/4919))\n- {shimmie2[ support `rule34hentai.net` ([#861](https://github.com/mikf/gallery-dl/issues/861), [#4789](https://github.com/mikf/gallery-dl/issues/4789), [#4945](https://github.com/mikf/gallery-dl/issues/4945))\n#### Fixes\n- [deviantart] add workaround for integer `client-id` values ([#4924](https://github.com/mikf/gallery-dl/issues/4924))\n- [exhentai] fix error for infinite `fallback-retries` ([#4911](https://github.com/mikf/gallery-dl/issues/4911))\n- [inkbunny] stop pagination on empty results\n- [patreon] fix bootstrap data extraction again ([#4904](https://github.com/mikf/gallery-dl/issues/4904))\n- [tumblr] fix exception after waiting for rate limit ([#4916](https://github.com/mikf/gallery-dl/issues/4916))\n#### Improvements\n- [exhentai] output continuation URL when interrupted ([#4782](https://github.com/mikf/gallery-dl/issues/4782))\n- [inkbunny] improve `/submissionsviewall.php` patterns ([#4934](https://github.com/mikf/gallery-dl/issues/4934))\n- [tumblr] support infinite `fallback-retries`\n- [twitter] default to `tweets` timeline when `replies` are enabled ([#4953](https://github.com/mikf/gallery-dl/issues/4953))\n#### Metadata\n- [danbooru] provide `tags` as list ([#4942](https://github.com/mikf/gallery-dl/issues/4942))\n- [deviantart] set `is_original` for intermediary URLs to `false`\n- [twitter] remove `date_liked` ([#3850](https://github.com/mikf/gallery-dl/issues/3850), [#4108](https://github.com/mikf/gallery-dl/issues/4108), [#4657](https://github.com/mikf/gallery-dl/issues/4657))\n### Docker\n- add Docker instructions to README ([#4850](https://github.com/mikf/gallery-dl/issues/4850))\n- fix auto-generation of `latest` tags\n\n## 1.26.4 - 2023-12-10\n### Extractors\n#### Additions\n- [exhentai] add `fallback-retries` option ([#4792](https://github.com/mikf/gallery-dl/issues/4792))\n- [urlgalleries] add `gallery` extractor ([#919](https://github.com/mikf/gallery-dl/issues/919), [#1184](https://github.com/mikf/gallery-dl/issues/1184), [#2905](https://github.com/mikf/gallery-dl/issues/2905), [#4886](https://github.com/mikf/gallery-dl/issues/4886))\n#### Fixes\n- [nijie] fix image URLs of multi-image posts ([#4876](https://github.com/mikf/gallery-dl/issues/4876))\n- [patreon] fix bootstrap data extraction ([#4904](https://github.com/mikf/gallery-dl/issues/4904), [#4906](https://github.com/mikf/gallery-dl/issues/4906))\n- [twitter] fix `/media` timelines ([#4898](https://github.com/mikf/gallery-dl/issues/4898), [#4899](https://github.com/mikf/gallery-dl/issues/4899))\n- [twitter] retry API requests when response contains incomplete results ([#4811](https://github.com/mikf/gallery-dl/issues/4811))\n#### Improvements\n- [exhentai] store more cookies when logging in with username & password ([#4881](https://github.com/mikf/gallery-dl/issues/4881))\n- [twitter] generalize \"Login Required\" errors ([#4734](https://github.com/mikf/gallery-dl/issues/4734), [#4324](https://github.com/mikf/gallery-dl/issues/4324))\n### Options\n- add `-e/--error-file` command-line and `output.errorfile` config option ([#4732](https://github.com/mikf/gallery-dl/issues/4732))\n### Miscellaneous\n- automatically build and push Docker images\n- prompt for passwords on login when necessary\n- fix `util.dump_response()` to work with `bytes` header values\n\n## 1.26.3 - 2023-11-27\n### Extractors\n#### Additions\n- [behance] support `text` modules ([#4799](https://github.com/mikf/gallery-dl/issues/4799))\n- [behance] add `modules` option ([#4799](https://github.com/mikf/gallery-dl/issues/4799))\n- [blogger] support `www.micmicidol.club` ([#4759](https://github.com/mikf/gallery-dl/issues/4759))\n- [erome] add `count` metadata ([#4812](https://github.com/mikf/gallery-dl/issues/4812))\n- [exhentai] add `gp` option ([#4576](https://github.com/mikf/gallery-dl/issues/4576))\n- [fapello] support `.su` TLD ([#4840](https://github.com/mikf/gallery-dl/issues/4840), [#4841](https://github.com/mikf/gallery-dl/issues/4841))\n- [pixeldrain] add `file` and `album` extractors ([#4839](https://github.com/mikf/gallery-dl/issues/4839))\n- [pixeldrain] add `api-key` option ([#4839](https://github.com/mikf/gallery-dl/issues/4839))\n- [tmohentai] add `gallery` extractor ([#4808](https://github.com/mikf/gallery-dl/issues/4808), [#4832](https://github.com/mikf/gallery-dl/issues/4832))\n#### Fixes\n- [cyberdrop] update to site layout changes\n- [exhentai] handle `Downloading … requires GP` errors ([#4576](https://github.com/mikf/gallery-dl/issues/4576), [#4763](https://github.com/mikf/gallery-dl/issues/4763))\n- [exhentai] fix empty API URL with `\"source\": \"hitomi\"` ([#4829](https://github.com/mikf/gallery-dl/issues/4829))\n- [hentaifoundry] check for and update expired sessions ([#4694](https://github.com/mikf/gallery-dl/issues/4694))\n- [hiperdex] fix `manga` metadata\n- [idolcomplex] update to site layout changes\n- [imagefap] fix resolution of single images\n- [instagram] fix exception on empty `video_versions` ([#4795](https://github.com/mikf/gallery-dl/issues/4795))\n- [mangaread] fix extraction\n- [mastodon] fix reblogs ([#4580](https://github.com/mikf/gallery-dl/issues/4580))\n- [nitter] fix video extraction ([#4853](https://github.com/mikf/gallery-dl/issues/4853), [#4855](https://github.com/mikf/gallery-dl/issues/4855))\n- [pornhub] fix `user` metadata for gifs\n- [tumblr] fix `day` extractor\n- [wallpapercave] fix extraction\n- [warosu] fix file URLs\n- [webtoons] fix pagination when receiving an HTTP redirect\n- [xvideos] fix metadata extraction\n- [zerochan] fix metadata extraction\n#### Improvements\n- [hentaicosplays] force `https://` for download URLs\n- [oauth] warn when cache is enabled but not writeable ([#4771](https://github.com/mikf/gallery-dl/issues/4771))\n- [sankaku] update URL patterns\n- [twitter] ignore promoted Tweets ([#3894](https://github.com/mikf/gallery-dl/issues/3894), [#4790](https://github.com/mikf/gallery-dl/issues/4790))\n- [weibo] detect redirects to login page ([#4773](https://github.com/mikf/gallery-dl/issues/4773))\n#### Removals\n- [foolslide] remove `powermanga.org`\n### Downloaders\n#### Changes\n- [http] treat files not passing `filesize-min`/`-max` as skipped ([#4821](https://github.com/mikf/gallery-dl/issues/4821))\n### Options\n#### Additions\n- add `metadata-extractor` option ([#4549](https://github.com/mikf/gallery-dl/issues/4549))\n- support `metadata-*` names for `*-metadata` options\n  (for example `url-metadata` is now also recognized as `metadata-url`)\n### CLI\n#### Additions\n- implement `-I/--input-file-comment` and `-x/--input-file-delete` options ([#4732](https://github.com/mikf/gallery-dl/issues/4732))\n- add `--ugoira` as a general version of `--ugoira-conv` and co.\n- add `--mtime` as a general version of `--mtime-from-date`\n- add `--cbz`\n#### Fixes\n- allow `--mtime-from-date` to work with Weibo`s metadata structure\n### Miscellaneous\n#### Additions\n- add a simple Dockerfile ([#4831](https://github.com/mikf/gallery-dl/issues/4831))\n\n## 1.26.2 - 2023-11-04\n### Extractors\n#### Additions\n- [4archive] add `thread` and `board` extractors ([#1262](https://github.com/mikf/gallery-dl/issues/1262), [#2418](https://github.com/mikf/gallery-dl/issues/2418), [#4400](https://github.com/mikf/gallery-dl/issues/4400), [#4710](https://github.com/mikf/gallery-dl/issues/4710), [#4714](https://github.com/mikf/gallery-dl/issues/4714))\n- [hitomi] recognize `imageset` gallery URLs ([#4756](https://github.com/mikf/gallery-dl/issues/4756))\n- [kemonoparty] add `revision_index` metadata field ([#4727](https://github.com/mikf/gallery-dl/issues/4727))\n- [misskey] support `misskey.design` ([#4713](https://github.com/mikf/gallery-dl/issues/4713))\n- [reddit] support Reddit Mobile share links ([#4693](https://github.com/mikf/gallery-dl/issues/4693))\n- [sankaku] support `/posts/` tag search URLs ([#4740](https://github.com/mikf/gallery-dl/issues/4740))\n- [twitter] recognize `fixupx.com` URLs ([#4755](https://github.com/mikf/gallery-dl/issues/4755))\n#### Fixes\n- [exhentai] update to site layout changes ([#4730](https://github.com/mikf/gallery-dl/issues/4730), [#4754](https://github.com/mikf/gallery-dl/issues/4754))\n- [exhentai] provide fallback URLs ([#1021](https://github.com/mikf/gallery-dl/issues/1021), [#4745](https://github.com/mikf/gallery-dl/issues/4745))\n- [exhentai] disable `DH` ciphers to avoid `DH_KEY_TOO_SMALL` errors ([#1021](https://github.com/mikf/gallery-dl/issues/1021), [#4593](https://github.com/mikf/gallery-dl/issues/4593))\n- [idolcomplex] disable sending Referer headers ([#4726](https://github.com/mikf/gallery-dl/issues/4726))\n- [instagram] update API headers\n- [kemonoparty] fix parsing of non-standard `date` values ([#4676](https://github.com/mikf/gallery-dl/issues/4676))\n- [patreon] fix `campaign_id` extraction ([#4699](https://github.com/mikf/gallery-dl/issues/4699), [#4715](https://github.com/mikf/gallery-dl/issues/4715), [#4736](https://github.com/mikf/gallery-dl/issues/4736), [#4738](https://github.com/mikf/gallery-dl/issues/4738))\n- [pixiv] load cookies for non-OAuth URLs ([#4760](https://github.com/mikf/gallery-dl/issues/4760))\n- [twitter] fix avatars without `date` information ([#4696](https://github.com/mikf/gallery-dl/issues/4696))\n- [twitter] restore truncated retweet texts ([#3430](https://github.com/mikf/gallery-dl/issues/3430), [#4690](https://github.com/mikf/gallery-dl/issues/4690))\n- [weibo] fix Sina Visitor requests\n#### Improvements\n- [behance] unescape embed URLs ([#4742](https://github.com/mikf/gallery-dl/issues/4742))\n- [fantia] simplify `tags` to a list of strings ([#4752](https://github.com/mikf/gallery-dl/issues/4752))\n- [kemonoparty] limit `title` length ([#4741](https://github.com/mikf/gallery-dl/issues/4741))\n- [nijie] set 1-2s delay between requests to avoid 429 errors\n- [patreon] provide ways to manually specify a user's campaign_id\n  - `https://www.patreon.com/id:12345`\n  - `https://www.patreon.com/USER?c=12345`\n  - `https://www.patreon.com/USER?campaign_id=12345`\n- [twitter] cache `user_by_…` results ([#4719](https://github.com/mikf/gallery-dl/issues/4719))\n### Post Processors\n#### Fixes\n- [metadata] ignore non-string tag values ([#4764](https://github.com/mikf/gallery-dl/issues/4764))\n### Miscellaneous\n#### Fixes\n- prevent crash when `stdout.line_buffering` is not defined ([#642](https://github.com/mikf/gallery-dl/issues/642))\n\n## 1.26.1 - 2023-10-21\n### Extractors\n#### Additions\n- [bunkr] add extractor for media URLs ([#4684](https://github.com/mikf/gallery-dl/issues/4684))\n- [chevereto] add generic extractors for `chevereto` sites ([#4664](https://github.com/mikf/gallery-dl/issues/4664))\n  - `deltaporno.com` ([#1381](https://github.com/mikf/gallery-dl/issues/1381))\n  - `img.kiwi`\n  - `jpgfish`\n  - `pixl.li` ([#3179](https://github.com/mikf/gallery-dl/issues/3179), [#4357](https://github.com/mikf/gallery-dl/issues/4357))\n- [deviantart] implement `\"group\": \"skip\"` ([#4630](https://github.com/mikf/gallery-dl/issues/4630))\n- [fantia] add `content_count` and `content_num` metadata fields ([#4627](https://github.com/mikf/gallery-dl/issues/4627))\n- [imgbb] add `displayname` and `user_id` metadata ([#4626](https://github.com/mikf/gallery-dl/issues/4626))\n- [kemonoparty] support post revisions; add `revisions` option ([#4498](https://github.com/mikf/gallery-dl/issues/4498), [#4597](https://github.com/mikf/gallery-dl/issues/4597))\n- [kemonoparty] support searches ([#3385](https://github.com/mikf/gallery-dl/issues/3385), [#4057](https://github.com/mikf/gallery-dl/issues/4057))\n- [kemonoparty] support discord URLs with channel IDs ([#4662](https://github.com/mikf/gallery-dl/issues/4662))\n- [moebooru] add `metadata` option ([#4646](https://github.com/mikf/gallery-dl/issues/4646))\n- [newgrounds] support multi-image posts ([#4642](https://github.com/mikf/gallery-dl/issues/4642))\n- [sankaku] support `/posts/` URLs ([#4688](https://github.com/mikf/gallery-dl/issues/4688))\n- [twitter] add `sensitive` metadata field ([#4619](https://github.com/mikf/gallery-dl/issues/4619))\n#### Fixes\n- [4chanarchives] disable Referer headers by default ([#4686](https://github.com/mikf/gallery-dl/issues/4686))\n- [bunkr] fix `/d/` file URLs ([#4685](https://github.com/mikf/gallery-dl/issues/4685))\n- [deviantart] expand nested comment replies ([#4653](https://github.com/mikf/gallery-dl/issues/4653))\n- [deviantart] disable `jwt` ([#4652](https://github.com/mikf/gallery-dl/issues/4652))\n- [hentaifoundry] fix `.swf` file downloads ([#4641](https://github.com/mikf/gallery-dl/issues/4641))\n- [imgbb] fix `user` metadata extraction ([#4626](https://github.com/mikf/gallery-dl/issues/4626))\n- [imgbb] update pagination end condition ([#4626](https://github.com/mikf/gallery-dl/issues/4626))\n- [kemonoparty] update API endpoints ([#4676](https://github.com/mikf/gallery-dl/issues/4676), [#4677](https://github.com/mikf/gallery-dl/issues/4677))\n- [patreon] update `campaign_id` path ([#4639](https://github.com/mikf/gallery-dl/issues/4639))\n- [reddit] fix wrong previews ([#4649](https://github.com/mikf/gallery-dl/issues/4649))\n- [redgifs] fix `niches` extraction ([#4666](https://github.com/mikf/gallery-dl/issues/4666), [#4667](https://github.com/mikf/gallery-dl/issues/4667))\n- [twitter] fix crash due to missing `source` ([#4620](https://github.com/mikf/gallery-dl/issues/4620))\n- [warosu] fix extraction ([#4634](https://github.com/mikf/gallery-dl/issues/4634))\n### Post Processors\n#### Additions\n- support `{_filename}`, `{_directory}`, and `{_path}` replacement fields for `--exec` ([#4633](https://github.com/mikf/gallery-dl/issues/4633))\n### Miscellaneous\n#### Improvements\n- avoid temporary copies with `--cookies-from-browser` by opening cookie databases in read-only mode\n\n## 1.26.0 - 2023-10-03\n- ### Extractors\n    #### Additions\n    - [behance] add `date` metadata field ([#4417](https://github.com/mikf/gallery-dl/issues/4417))\n    - [danbooru] support `booru.borvar.art` ([#4096](https://github.com/mikf/gallery-dl/issues/4096))\n    - [danbooru] support `donmai.moe`\n    - [deviantart] add `is_original` metadata field ([#4559](https://github.com/mikf/gallery-dl/issues/4559))\n    - [e621] support `e6ai.net` ([#4320](https://github.com/mikf/gallery-dl/issues/4320))\n    - [exhentai] add `fav` option ([#4409](https://github.com/mikf/gallery-dl/issues/4409))\n    - [gelbooru_v02] support `xbooru.com` ([#4493](https://github.com/mikf/gallery-dl/issues/4493))\n    - [instagram] add `following` extractor ([#1848](https://github.com/mikf/gallery-dl/issues/1848))\n    - [pillowfort] support `/tagged/` URLs ([#4570](https://github.com/mikf/gallery-dl/issues/4570))\n    - [pornhub] add `gif` support ([#4463](https://github.com/mikf/gallery-dl/issues/4463))\n    - [reddit] add `previews` option ([#4322](https://github.com/mikf/gallery-dl/issues/4322))\n    - [redgifs] add `niches` extractor ([#4311](https://github.com/mikf/gallery-dl/issues/4311), [#4312](https://github.com/mikf/gallery-dl/issues/4312))\n    - [redgifs] support `order` parameter for user URLs ([#4583](https://github.com/mikf/gallery-dl/issues/4583))\n    - [twitter] add `user` extractor and `include` option ([#4275](https://github.com/mikf/gallery-dl/issues/4275))\n    - [twitter] add `tweet-endpoint` option ([#4307](https://github.com/mikf/gallery-dl/issues/4307))\n    - [twitter] add `date_original` metadata for retweets ([#4337](https://github.com/mikf/gallery-dl/issues/4337), [#4443](https://github.com/mikf/gallery-dl/issues/4443))\n    - [twitter] extract `source` metadata ([#4459](https://github.com/mikf/gallery-dl/issues/4459))\n    - [twitter] support `x.com` URLs ([#4452](https://github.com/mikf/gallery-dl/issues/4452))\n    #### Improvements\n    - include `Referer` header in all HTTP requests ([#4490](https://github.com/mikf/gallery-dl/issues/4490), [#4518](https://github.com/mikf/gallery-dl/issues/4518))\n      (can be disabled with `referer` option)\n    - [behance] show errors for mature content ([#4417](https://github.com/mikf/gallery-dl/issues/4417))\n    - [deviantart] re-add `quality` option and `/intermediary/` transform\n    - [fantia] improve metadata extraction ([#4126](https://github.com/mikf/gallery-dl/issues/4126))\n    - [instagram] better error messages for invalid users ([#4606](https://github.com/mikf/gallery-dl/issues/4606))\n    - [mangadex] support multiple values for `lang` ([#4093](https://github.com/mikf/gallery-dl/issues/4093))\n    - [mastodon] support `/@USER/following` URLs ([#4608](https://github.com/mikf/gallery-dl/issues/4608))\n    - [moebooru] match search URLs with empty `tags` ([#4354](https://github.com/mikf/gallery-dl/issues/4354))\n    - [pillowfort] extract `b2_lg_url` media ([#4570](https://github.com/mikf/gallery-dl/issues/4570))\n    - [reddit] improve comment metadata ([#4482](https://github.com/mikf/gallery-dl/issues/4482))\n    - [reddit] ignore `/message/compose` URLs ([#4482](https://github.com/mikf/gallery-dl/issues/4482), [#4581](https://github.com/mikf/gallery-dl/issues/4581))\n    - [redgifs] provide `collection` metadata as separate field ([#4508](https://github.com/mikf/gallery-dl/issues/4508))\n    - [redgifs] match `gfycat` image URLs ([#4558](https://github.com/mikf/gallery-dl/issues/4558))\n    - [twitter] improve error messages for single Tweets ([#4369](https://github.com/mikf/gallery-dl/issues/4369))\n    #### Fixes\n    - [acidimg] fix extraction\n    - [architizer] fix extraction ([#4537](https://github.com/mikf/gallery-dl/issues/4537))\n    - [behance] fix and update `user` extractor ([#4417](https://github.com/mikf/gallery-dl/issues/4417))\n    - [behance] fix cookie usage ([#4417](https://github.com/mikf/gallery-dl/issues/4417))\n    - [behance] handle videos without `renditions` ([#4523](https://github.com/mikf/gallery-dl/issues/4523))\n    - [bunkr] fix media domain for `cdn9` ([#4386](https://github.com/mikf/gallery-dl/issues/4386), [#4412](https://github.com/mikf/gallery-dl/issues/4412))\n    - [bunkr] fix extracting `.wmv` files ([#4419](https://github.com/mikf/gallery-dl/issues/4419))\n    - [bunkr] fix media domain for `cdn-pizza.bunkr.ru` ([#4489](https://github.com/mikf/gallery-dl/issues/4489))\n    - [bunkr] fix extraction ([#4514](https://github.com/mikf/gallery-dl/issues/4514), [#4532](https://github.com/mikf/gallery-dl/issues/4532), [#4529](https://github.com/mikf/gallery-dl/issues/4529), [#4540](https://github.com/mikf/gallery-dl/issues/4540))\n    - [deviantart] fix full resolution URLs for non-downloadable images ([#293](https://github.com/mikf/gallery-dl/issues/293), [#4548](https://github.com/mikf/gallery-dl/issues/4548), [#4563](https://github.com/mikf/gallery-dl/issues/4563))\n    - [deviantart] fix shortened URLs ([#4316](https://github.com/mikf/gallery-dl/issues/4316))\n    - [deviantart] fix search ([#4384](https://github.com/mikf/gallery-dl/issues/4384))\n    - [deviantart] update Eclipse API endpoints ([#4553](https://github.com/mikf/gallery-dl/issues/4553), [#4615](https://github.com/mikf/gallery-dl/issues/4615))\n    - [deviantart] use private tokens for `is_mature` posts ([#4563](https://github.com/mikf/gallery-dl/issues/4563))\n    - [flickr] update default API credentials ([#4332](https://github.com/mikf/gallery-dl/issues/4332))\n    - [giantessbooru] fix extraction ([#4373](https://github.com/mikf/gallery-dl/issues/4373))\n    - [hiperdex] fix crash for titles containing Unicode characters ([#4325](https://github.com/mikf/gallery-dl/issues/4325))\n    - [hiperdex] fix `manga` metadata\n    - [imagefap] fix pagination ([#3013](https://github.com/mikf/gallery-dl/issues/3013))\n    - [imagevenue] fix extraction ([#4473](https://github.com/mikf/gallery-dl/issues/4473))\n    - [instagram] fix private posts with long shortcodes ([#4362](https://github.com/mikf/gallery-dl/issues/4362))\n    - [instagram] fix video preview archive IDs ([#2135](https://github.com/mikf/gallery-dl/issues/2135), [#4455](https://github.com/mikf/gallery-dl/issues/4455))\n    - [instagram] handle exceptions due to missing media ([#4555](https://github.com/mikf/gallery-dl/issues/4555))\n    - [issuu] fix extraction ([#4420](https://github.com/mikf/gallery-dl/issues/4420))\n    - [jpgfish] update domain to `jpg1.su` ([#4494](https://github.com/mikf/gallery-dl/issues/4494))\n    - [kemonoparty] update `favorite` API endpoint ([#4522](https://github.com/mikf/gallery-dl/issues/4522))\n    - [lensdump] fix extraction ([#4352](https://github.com/mikf/gallery-dl/issues/4352))\n    - [mangakakalot] update domain\n    - [reddit] fix `preview.redd.it` URLs ([#4470](https://github.com/mikf/gallery-dl/issues/4470))\n    - [patreon] fix extraction ([#4547](https://github.com/mikf/gallery-dl/issues/4547))\n    - [pixiv] handle errors for private novels ([#4481](https://github.com/mikf/gallery-dl/issues/4481))\n    - [pornhub] fix extraction ([#4301](https://github.com/mikf/gallery-dl/issues/4301))\n    - [pururin] fix extraction ([#4375](https://github.com/mikf/gallery-dl/issues/4375))\n    - [subscribestar] fix preview detection ([#4468](https://github.com/mikf/gallery-dl/issues/4468))\n    - [twitter] fix crash on private user ([#4349](https://github.com/mikf/gallery-dl/issues/4349))\n    - [twitter] fix `TweetWithVisibilityResults` ([#4369](https://github.com/mikf/gallery-dl/issues/4369))\n    - [twitter] fix crash when `sortIndex` is undefined ([#4499](https://github.com/mikf/gallery-dl/issues/4499))\n    - [zerochan] fix `tags` extraction ([#4315](https://github.com/mikf/gallery-dl/issues/4315), [#4319](https://github.com/mikf/gallery-dl/issues/4319))\n    #### Removals\n    - [gfycat] remove module\n    - [shimmie2] remove `meme.museum`\n- ### Post Processors\n    #### Changes\n    - update `finalize` events\n        - add `finalize-error` and `finalize-success` events that trigger\n          depending on whether error(s) did or did not happen\n        - change `finalize` to always trigger regardless of error status\n    #### Additions\n    - add `python` post processor\n    - add `prepare-after` event ([#4083](https://github.com/mikf/gallery-dl/issues/4083))\n    - [ugoira] add `\"framerate\": \"uniform\"` ([#4421](https://github.com/mikf/gallery-dl/issues/4421))\n    #### Improvements\n    - [ugoira] extend `ffmpeg-output` ([#4421](https://github.com/mikf/gallery-dl/issues/4421))\n    #### Fixes\n    - [ugoira] restore `libx264-prevent-odd` ([#4407](https://github.com/mikf/gallery-dl/issues/4407))\n    - [ugoira] fix high frame rates ([#4421](https://github.com/mikf/gallery-dl/issues/4421))\n- ### Downloaders\n    #### Fixes\n    - [http] close connection when file already exists ([#4403](https://github.com/mikf/gallery-dl/issues/4403))\n- ### Options\n    #### Additions\n    - support `parent>child` categories for child extractor options,\n      for example an `imgur` album from a `reddit` thread with `reddit>imgur`\n    - implement `subconfigs` option ([#4440](https://github.com/mikf/gallery-dl/issues/4440))\n    - add `\"ascii+\"` as a special `path-restrict` value ([#4371](https://github.com/mikf/gallery-dl/issues/4371))\n    #### Removals\n    - remove `pyopenssl` option\n- ### Tests\n    #### Improvements\n    - move extractor results into their own, separate files ([#4504](https://github.com/mikf/gallery-dl/issues/4504))\n    - include fallback URLs in content tests ([#3163](https://github.com/mikf/gallery-dl/issues/3163))\n    - various test method improvements\n- ### Miscellaneous\n    #### Fixes\n    - [formatter] use value of last alternative ([#4492](https://github.com/mikf/gallery-dl/issues/4492))\n    - fix imports when running `__main__.py` ([#4581](https://github.com/mikf/gallery-dl/issues/4581))\n    - fix symlink resolution in `__main__.py`\n    - fix default Firefox user agent string\n\n## 1.25.8 - 2023-07-15\n### Changes\n- update default User-Agent header to Firefox 115 ESR\n### Additions\n- [gfycat] support `@me` user ([#3770](https://github.com/mikf/gallery-dl/issues/3770), [#4271](https://github.com/mikf/gallery-dl/issues/4271))\n- [gfycat] implement login support ([#3770](https://github.com/mikf/gallery-dl/issues/3770), [#4271](https://github.com/mikf/gallery-dl/issues/4271))\n- [reddit] notify users about registering an OAuth application ([#4292](https://github.com/mikf/gallery-dl/issues/4292))\n- [twitter] add `ratelimit` option ([#4251](https://github.com/mikf/gallery-dl/issues/4251))\n- [twitter] use `TweetResultByRestId` endpoint that allows accessing single Tweets without login ([#4250](https://github.com/mikf/gallery-dl/issues/4250))\n### Fixes\n- [bunkr] use `.la` TLD for `media-files12` servers ([#4147](https://github.com/mikf/gallery-dl/issues/4147), [#4276](https://github.com/mikf/gallery-dl/issues/4276))\n- [erome] ignore duplicate album IDs\n- [fantia] send `X-Requested-With` header ([#4273](https://github.com/mikf/gallery-dl/issues/4273))\n- [gelbooru_v01] fix `source` metadata ([#4302](https://github.com/mikf/gallery-dl/issues/4302), [#4303](https://github.com/mikf/gallery-dl/issues/4303))\n- [gelbooru_v01] update `vidyart` domain\n- [jpgfish] update domain to `jpeg.pet`\n- [mangaread] fix `tags` metadata extraction\n- [naverwebtoon] fix `comic` metadata extraction\n- [newgrounds] extract & pass auth token during login ([#4268](https://github.com/mikf/gallery-dl/issues/4268))\n- [paheal] fix extraction ([#4262](https://github.com/mikf/gallery-dl/issues/4262), [#4293](https://github.com/mikf/gallery-dl/issues/4293))\n- [paheal] unescape `source`\n- [philomena] fix `--range` ([#4288](https://github.com/mikf/gallery-dl/issues/4288))\n- [philomena] handle `429 Too Many Requests` errors ([#4288](https://github.com/mikf/gallery-dl/issues/4288))\n- [pornhub] set `accessAgeDisclaimerPH` cookie ([#4301](https://github.com/mikf/gallery-dl/issues/4301))\n- [reddit] use 0.6s delay between API requests ([#4292](https://github.com/mikf/gallery-dl/issues/4292))\n- [seiga] set `skip_fetish_warning` cookie ([#4242](https://github.com/mikf/gallery-dl/issues/4242))\n- [slideshare] fix extraction\n- [twitter] fix `following` extractor not getting all users ([#4287](https://github.com/mikf/gallery-dl/issues/4287))\n- [twitter] use GraphQL search endpoint by default ([#4264](https://github.com/mikf/gallery-dl/issues/4264))\n- [twitter] do not treat missing `TimelineAddEntries` instruction as fatal ([#4278](https://github.com/mikf/gallery-dl/issues/4278))\n- [weibo] fix cursor based pagination\n- [wikifeet] fix `tag` extraction ([#4289](https://github.com/mikf/gallery-dl/issues/4289), [#4291](https://github.com/mikf/gallery-dl/issues/4291))\n### Removals\n- [bcy] remove module\n- [lineblog] remove module\n\n## 1.25.7 - 2023-07-02\n### Additions\n- [flickr] add 'exif' option\n- [flickr] add 'metadata' option ([#4227](https://github.com/mikf/gallery-dl/issues/4227))\n- [mangapark] add 'source' option ([#3969](https://github.com/mikf/gallery-dl/issues/3969))\n- [twitter] extend 'conversations' option ([#4211](https://github.com/mikf/gallery-dl/issues/4211))\n### Fixes\n- [furaffinity] improve 'description' HTML ([#4224](https://github.com/mikf/gallery-dl/issues/4224))\n- [gelbooru_v01] fix '--range' ([#4167](https://github.com/mikf/gallery-dl/issues/4167))\n- [hentaifox] fix titles containing '@' ([#4201](https://github.com/mikf/gallery-dl/issues/4201))\n- [mangapark] update to v5 ([#3969](https://github.com/mikf/gallery-dl/issues/3969))\n- [piczel] update API server address ([#4244](https://github.com/mikf/gallery-dl/issues/4244))\n- [poipiku] improve error detection ([#4206](https://github.com/mikf/gallery-dl/issues/4206))\n- [sankaku] improve warnings for unavailable posts\n- [senmanga] ensure download URLs have a scheme ([#4235](https://github.com/mikf/gallery-dl/issues/4235))\n\n## 1.25.6 - 2023-06-17\n### Additions\n- [blogger] download files from `lh*.googleusercontent.com` ([#4070](https://github.com/mikf/gallery-dl/issues/4070))\n- [fantia] extract `plan` metadata ([#2477](https://github.com/mikf/gallery-dl/issues/2477))\n- [fantia] emit warning for non-visible content sections ([#4128](https://github.com/mikf/gallery-dl/issues/4128))\n- [furaffinity] extract `favorite_id` metadata ([#4133](https://github.com/mikf/gallery-dl/issues/4133))\n- [jschan] add generic extractors for jschan image boards ([#3447](https://github.com/mikf/gallery-dl/issues/3447))\n- [kemonoparty] support `.su` TLDs ([#4139](https://github.com/mikf/gallery-dl/issues/4139))\n- [pixiv:novel] add `novel-bookmark` extractor ([#4111](https://github.com/mikf/gallery-dl/issues/4111))\n- [pixiv:novel] add `full-series` option ([#4111](https://github.com/mikf/gallery-dl/issues/4111))\n- [postimage] add gallery support, update image extractor ([#3115](https://github.com/mikf/gallery-dl/issues/3115), [#4134](https://github.com/mikf/gallery-dl/issues/4134))\n- [redgifs] support galleries ([#4021](https://github.com/mikf/gallery-dl/issues/4021))\n- [twitter] extract `conversation_id` metadata ([#3839](https://github.com/mikf/gallery-dl/issues/3839))\n- [vipergirls] add login support ([#4166](https://github.com/mikf/gallery-dl/issues/4166))\n- [vipergirls] use API endpoints ([#4166](https://github.com/mikf/gallery-dl/issues/4166))\n- [formatter] implement `H` conversion ([#4164](https://github.com/mikf/gallery-dl/issues/4164))\n### Fixes\n- [acidimg] fix extraction ([#4136](https://github.com/mikf/gallery-dl/issues/4136))\n- [bunkr] update domain to bunkrr.su ([#4159](https://github.com/mikf/gallery-dl/issues/4159), [#4189](https://github.com/mikf/gallery-dl/issues/4189))\n- [bunkr] fix video downloads\n- [fanbox] prevent exception due to missing embeds ([#4088](https://github.com/mikf/gallery-dl/issues/4088))\n- [instagram] fix retrieving `/tagged` posts ([#4122](https://github.com/mikf/gallery-dl/issues/4122))\n- [jpgfish] update domain to `jpg.pet` ([#4138](https://github.com/mikf/gallery-dl/issues/4138))\n- [pixiv:novel] fix error with embeds extraction ([#4175](https://github.com/mikf/gallery-dl/issues/4175))\n- [pornhub] improve redirect handling ([#4188](https://github.com/mikf/gallery-dl/issues/4188))\n- [reddit] fix crash due to empty `crosspost_parent_lists` ([#4120](https://github.com/mikf/gallery-dl/issues/4120), [#4172](https://github.com/mikf/gallery-dl/issues/4172))\n- [redgifs] update `search` URL pattern ([#4115](https://github.com/mikf/gallery-dl/issues/4115), [#4185](https://github.com/mikf/gallery-dl/issues/4185))\n- [senmanga] fix and update ([#4160](https://github.com/mikf/gallery-dl/issues/4160))\n- [twitter] use GraphQL API search endpoint ([#3942](https://github.com/mikf/gallery-dl/issues/3942))\n- [wallhaven] improve HTTP error handling ([#4192](https://github.com/mikf/gallery-dl/issues/4192))\n- [weibo] prevent fatal exception due to missing video data ([#4150](https://github.com/mikf/gallery-dl/issues/4150))\n- [weibo] fix `.json` extension for some videos\n\n## 1.25.5 - 2023-05-27\n### Additions\n- [8muses] add `parts` metadata field ([#3329](https://github.com/mikf/gallery-dl/issues/3329))\n- [danbooru] add `date` metadata field ([#4047](https://github.com/mikf/gallery-dl/issues/4047))\n- [e621] add `date` metadata field ([#4047](https://github.com/mikf/gallery-dl/issues/4047))\n- [gofile] add basic password support ([#4056](https://github.com/mikf/gallery-dl/issues/4056))\n- [imagechest] implement API support ([#4065](https://github.com/mikf/gallery-dl/issues/4065))\n- [instagram] add `order-files` option ([#3993](https://github.com/mikf/gallery-dl/issues/3993), [#4017](https://github.com/mikf/gallery-dl/issues/4017))\n- [instagram] add `order-posts` option ([#3993](https://github.com/mikf/gallery-dl/issues/3993), [#4017](https://github.com/mikf/gallery-dl/issues/4017))\n- [instagram] add `metadata` option ([#3107](https://github.com/mikf/gallery-dl/issues/3107))\n- [jpgfish] add `jpg.fishing` extractors ([#2657](https://github.com/mikf/gallery-dl/issues/2657), [#2719](https://github.com/mikf/gallery-dl/issues/2719))\n- [lensdump] add `lensdump.com` extractors ([#2078](https://github.com/mikf/gallery-dl/issues/2078), [#4104](https://github.com/mikf/gallery-dl/issues/4104))\n- [mangaread] add `mangaread.org` extractors ([#2425](https://github.com/mikf/gallery-dl/issues/2425), [#2781](https://github.com/mikf/gallery-dl/issues/2781))\n- [misskey] add `favorite` extractor ([#3950](https://github.com/mikf/gallery-dl/issues/3950))\n- [pixiv] add `novel` support ([#1241](https://github.com/mikf/gallery-dl/issues/1241), [#4044](https://github.com/mikf/gallery-dl/issues/4044))\n- [reddit] support cross-posted media ([#887](https://github.com/mikf/gallery-dl/issues/887), [#3586](https://github.com/mikf/gallery-dl/issues/3586), [#3976](https://github.com/mikf/gallery-dl/issues/3976))\n- [postprocessor:exec] support tilde expansion for `command`\n- [formatter] support slicing strings as bytes ([#4087](https://github.com/mikf/gallery-dl/issues/4087))\n### Fixes\n- [8muses] fix value of `album[url]` ([#3329](https://github.com/mikf/gallery-dl/issues/3329))\n- [danbooru] refactor pagination logic ([#4002](https://github.com/mikf/gallery-dl/issues/4002))\n- [fanbox] skip invalid posts ([#4088](https://github.com/mikf/gallery-dl/issues/4088))\n- [gofile] automatically fetch `website-token`\n- [kemonoparty] fix kemono and coomer logins sharing the same cache ([#4098](https://github.com/mikf/gallery-dl/issues/4098))\n- [newgrounds] add default delay between requests ([#4046](https://github.com/mikf/gallery-dl/issues/4046))\n- [nsfwalbum] detect placeholder images\n- [poipiku] extract full `descriptions` ([#4066](https://github.com/mikf/gallery-dl/issues/4066))\n- [tcbscans] update domain to `tcbscans.com` ([#4080](https://github.com/mikf/gallery-dl/issues/4080))\n- [twitter] extract TwitPic URLs in text ([#3792](https://github.com/mikf/gallery-dl/issues/3792), [#3796](https://github.com/mikf/gallery-dl/issues/3796))\n- [weibo] require numeric IDs to have length >= 10 ([#4059](https://github.com/mikf/gallery-dl/issues/4059))\n- [ytdl] fix crash due to removed `no_color` attribute\n- [cookies] improve logging behavior ([#4050](https://github.com/mikf/gallery-dl/issues/4050))\n\n## 1.25.4 - 2023-05-07\n### Additions\n- [4chanarchives] add `thread` and `board` extractors ([#4012](https://github.com/mikf/gallery-dl/issues/4012))\n- [foolfuuka] add `archive.palanq.win`\n- [imgur] add `favorite-folder` extractor ([#4016](https://github.com/mikf/gallery-dl/issues/4016))\n- [mangadex] add `status` and `tags` metadata ([#4031](https://github.com/mikf/gallery-dl/issues/4031))\n- allow selecting a domain with `--cookies-from-browser`\n- add `--cookies-export` command-line option\n- add `-C` as short option for `--cookies`\n- include exception type in config error messages\n### Fixes\n- [exhentai] update sadpanda check\n- [imagechest] load all images when a \"Load More\" button is present ([#4028](https://github.com/mikf/gallery-dl/issues/4028))\n- [imgur] fix bug causing some images/albums from user profiles and favorites to be ignored\n- [pinterest] update endpoint for related board pins\n- [pinterest] fix `pin.it` extractor\n- [ytdl] fix yt-dlp `--xff/--geo-bypass` tests ([#3989](https://github.com/mikf/gallery-dl/issues/3989))\n### Removals\n- [420chan] remove module\n- [foolfuuka] remove `archive.alice.al` and `tokyochronos.net`\n- [foolslide] remove `sensescans.com`\n- [nana] remove module\n\n## 1.25.3 - 2023-04-30\n### Additions\n- [imagefap] extract `description` and `categories` metadata ([#3905](https://github.com/mikf/gallery-dl/issues/3905))\n- [imxto] add `gallery` extractor ([#1289](https://github.com/mikf/gallery-dl/issues/1289))\n- [itchio] add `game` extractor ([#3923](https://github.com/mikf/gallery-dl/issues/3923))\n- [nitter] extract user IDs from encoded banner URLs\n- [pixiv] allow sorting search results by popularity ([#3970](https://github.com/mikf/gallery-dl/issues/3970))\n- [reddit] match `preview.redd.it` URLs ([#3935](https://github.com/mikf/gallery-dl/issues/3935))\n- [sankaku] support post URLs with MD5 hashes ([#3952](https://github.com/mikf/gallery-dl/issues/3952))\n- [shimmie2] add generic extractors for Shimmie2 sites ([#3734](https://github.com/mikf/gallery-dl/issues/3734), [#943](https://github.com/mikf/gallery-dl/issues/943))\n- [tumblr] add `day` extractor ([#3951](https://github.com/mikf/gallery-dl/issues/3951))\n- [twitter] support `profile-conversation` entries ([#3938](https://github.com/mikf/gallery-dl/issues/3938))\n- [vipergirls] add `thread` and `post` extractors ([#3812](https://github.com/mikf/gallery-dl/issues/3812), [#2720](https://github.com/mikf/gallery-dl/issues/2720), [#731](https://github.com/mikf/gallery-dl/issues/731))\n- [downloader:http] add `consume-content` option ([#3748](https://github.com/mikf/gallery-dl/issues/3748))\n### Fixes\n- [2chen] update domain to sturdychan.help\n- [behance] fix extraction ([#3980](https://github.com/mikf/gallery-dl/issues/3980))\n- [deviantart] retry downloads with private token ([#3941](https://github.com/mikf/gallery-dl/issues/3941))\n- [imagefap] fix empty `tags` metadata\n- [manganelo] support arbitrary minor version separators ([#3972](https://github.com/mikf/gallery-dl/issues/3972))\n- [nozomi] fix file URLs ([#3925](https://github.com/mikf/gallery-dl/issues/3925))\n- [oauth] catch exceptions from `webbrowser.get()` ([#3947](https://github.com/mikf/gallery-dl/issues/3947))\n- [pixiv] fix `pixivision` extraction\n- [reddit] ignore `id-max` value `\"zik0zj\"`/`2147483647` ([#3939](https://github.com/mikf/gallery-dl/issues/3939), [#3862](https://github.com/mikf/gallery-dl/issues/3862), [#3697](https://github.com/mikf/gallery-dl/issues/3697), [#3606](https://github.com/mikf/gallery-dl/issues/3606), [#3546](https://github.com/mikf/gallery-dl/issues/3546), [#3521](https://github.com/mikf/gallery-dl/issues/3521), [#3412](https://github.com/mikf/gallery-dl/issues/3412))\n- [sankaku] sanitize `date:` tags ([#1790](https://github.com/mikf/gallery-dl/issues/1790))\n- [tumblr] fix and update pagination logic ([#2191](https://github.com/mikf/gallery-dl/issues/2191))\n- [twitter] fix `user` metadata when downloading quoted Tweets ([#3922](https://github.com/mikf/gallery-dl/issues/3922))\n- [ytdl] fix crash due to `--geo-bypass` deprecation ([#3975](https://github.com/mikf/gallery-dl/issues/3975))\n- [postprocessor:metadata] support putting keys in quotes\n- include more optional dependencies in executables ([#3907](https://github.com/mikf/gallery-dl/issues/3907))\n\n## 1.25.2 - 2023-04-15\n### Additions\n- [deviantart] add `public` option\n- [nitter] extract videos from `source` elements ([#3912](https://github.com/mikf/gallery-dl/issues/3912))\n- [twitter] add `date_liked` and `date_bookmarked` metadata for liked and bookmarked Tweets ([#3816](https://github.com/mikf/gallery-dl/issues/3816))\n- [urlshortener] add support for bit.ly & t.co ([#3841](https://github.com/mikf/gallery-dl/issues/3841))\n- [downloader:http] add MIME type and signature for `.heic` files ([#3915](https://github.com/mikf/gallery-dl/issues/3915))\n### Fixes\n- [blogger] update regex to get the highest resolution URLs ([#3863](https://github.com/mikf/gallery-dl/issues/3863), [#3870](https://github.com/mikf/gallery-dl/issues/3870))\n- [bunkr] update domain to `bunkr.la` ([#3813](https://github.com/mikf/gallery-dl/issues/3813), [#3877](https://github.com/mikf/gallery-dl/issues/3877))\n- [deviantart] keep using private access tokens when requesting download URLs ([#3845](https://github.com/mikf/gallery-dl/issues/3845), [#3857](https://github.com/mikf/gallery-dl/issues/3857), [#3896](https://github.com/mikf/gallery-dl/issues/3896))\n- [hentaifoundry] fix content filters ([#3887](https://github.com/mikf/gallery-dl/issues/3887))\n- [hotleak] fix downloading of creators whose name starts with a category name ([#3871](https://github.com/mikf/gallery-dl/issues/3871))\n- [imagechest] fix extraction ([#3914](https://github.com/mikf/gallery-dl/issues/3914))\n- [realbooru] fix extraction ([#2530](https://github.com/mikf/gallery-dl/issues/2530))\n- [sexcom] fix pagination ([#3906](https://github.com/mikf/gallery-dl/issues/3906))\n- [sexcom] fix HD video extraction\n- [shopify] fix `collection` extractor ([#3866](https://github.com/mikf/gallery-dl/issues/3866), [#3868](https://github.com/mikf/gallery-dl/issues/3868))\n- [twitter] update to bookmark timeline v2 ([#3859](https://github.com/mikf/gallery-dl/issues/3859), [#3854](https://github.com/mikf/gallery-dl/issues/3854))\n- [twitter] warn about \"withheld\" Tweets and users ([#3864](https://github.com/mikf/gallery-dl/issues/3864))\n### Improvements\n- [danbooru] reduce number of API requests when fetching extended `metadata`\n- [deviantart:search] detect login redirects ([#3860](https://github.com/mikf/gallery-dl/issues/3860))\n- [generic] write regular expressions without `x` flags\n- [mastodon] try to get account IDs without access token\n- [twitter] calculate `date` from Tweet IDs\n\n## 1.25.1 - 2023-03-25\n### Additions\n- [nitter] support nitter.it ([#3819](https://github.com/mikf/gallery-dl/issues/3819))\n- [twitter] add `hashtag` extractor ([#3783](https://github.com/mikf/gallery-dl/issues/3783))\n- [twitter] support Tweet content with >280 characters\n- [formatter] support loading f-strings from template files ([#3800](https://github.com/mikf/gallery-dl/issues/3800))\n- [formatter] support filesystem paths for `\\fM` modules ([#3399](https://github.com/mikf/gallery-dl/issues/3399))\n- [formatter] support putting keys in quotes (e.g. `user['name']`) ([#2559](https://github.com/mikf/gallery-dl/issues/2559))\n- [postprocessor:metadata] add `skip` option ([#3786](https://github.com/mikf/gallery-dl/issues/3786))\n### Fixes\n- [output] set `errors=replace` for output streams ([#3765](https://github.com/mikf/gallery-dl/issues/3765))\n- [gelbooru] extract favorites without needing cookies ([#3704](https://github.com/mikf/gallery-dl/issues/3704))\n- [gelbooru] fix and improve `--range` for pools\n- [hiperdex] fix extraction ([#3768](https://github.com/mikf/gallery-dl/issues/3768))\n- [naverwebtoon] fix extraction ([#3729](https://github.com/mikf/gallery-dl/issues/3729))\n- [nitter] fix extraction for instances without user banners\n- [twitter] update API query hashes and parameters\n- [weibo] support `mix_media_info` entries ([#3793](https://github.com/mikf/gallery-dl/issues/3793))\n- fix circular reference detection for `-K`\n### Changes\n- update `globals` instead of overwriting the default ([#3773](https://github.com/mikf/gallery-dl/issues/3773))\n\n## 1.25.0 - 2023-03-11\n### Changes\n- [e621] split `e621` extractors from `danbooru` module ([#3425](https://github.com/mikf/gallery-dl/issues/3425))\n- [deviantart] remove mature scraps warning ([#3691](https://github.com/mikf/gallery-dl/issues/3691))\n- [deviantart] use `/collections/all` endpoint for favorites ([#3666](https://github.com/mikf/gallery-dl/issues/3666), [#3668](https://github.com/mikf/gallery-dl/issues/3668))\n- [newgrounds] update default image and audio archive IDs to prevent ID overlap ([#3681](https://github.com/mikf/gallery-dl/issues/3681))\n- rename `--ignore-config` to `--config-ignore`\n### Extractors\n- [catbox] add `file` extractor ([#3570](https://github.com/mikf/gallery-dl/issues/3570))\n- [deviantart] add `search` extractor ([#538](https://github.com/mikf/gallery-dl/issues/538), [#1264](https://github.com/mikf/gallery-dl/issues/1264), [#2954](https://github.com/mikf/gallery-dl/issues/2954), [#2970](https://github.com/mikf/gallery-dl/issues/2970), [#3577](https://github.com/mikf/gallery-dl/issues/3577))\n- [deviantart] add `gallery-search` extractor ([#1695](https://github.com/mikf/gallery-dl/issues/1695))\n- [deviantart] support `fxdeviantart.com` URLs (##3740)\n- [e621] implement `notes` and `pools` metadata extraction ([#3425](https://github.com/mikf/gallery-dl/issues/3425))\n- [gelbooru] add `favorite` extractor ([#3704](https://github.com/mikf/gallery-dl/issues/3704))\n- [imagetwist] support `phun.imagetwist.com` and `imagehaha.com` domains ([#3622](https://github.com/mikf/gallery-dl/issues/3622))\n- [instagram] add `user` metadata field ([#3107](https://github.com/mikf/gallery-dl/issues/3107))\n- [manganelo] update and fix metadata extraction\n- [manganelo] support mobile-only chapters\n- [mangasee] extract `author` and `genre` metadata ([#3703](https://github.com/mikf/gallery-dl/issues/3703))\n- [misskey] add `misskey` extractors ([#3717](https://github.com/mikf/gallery-dl/issues/3717))\n- [pornpics] add `gallery` and `search` extractors ([#263](https://github.com/mikf/gallery-dl/issues/263), [#3544](https://github.com/mikf/gallery-dl/issues/3544), [#3654](https://github.com/mikf/gallery-dl/issues/3654))\n- [redgifs] support v3 URLs ([#3588](https://github.com/mikf/gallery-dl/issues/3588). [#3589](https://github.com/mikf/gallery-dl/issues/3589))\n- [redgifs] add `collection` extractors ([#3427](https://github.com/mikf/gallery-dl/issues/3427), [#3662](https://github.com/mikf/gallery-dl/issues/3662))\n- [shopify] support ohpolly.com ([#440](https://github.com/mikf/gallery-dl/issues/440), [#3596](https://github.com/mikf/gallery-dl/issues/3596))\n- [szurubooru] add `tag` and `post` extractors ([#3583](https://github.com/mikf/gallery-dl/issues/3583), [#3713](https://github.com/mikf/gallery-dl/issues/3713))\n- [twitter] add `transform` option\n### Options\n- [postprocessor:metadata] add `sort` and `separators` options\n- [postprocessor:exec] implement archive options ([#3584](https://github.com/mikf/gallery-dl/issues/3584))\n- add `--config-create` command-line option ([#2333](https://github.com/mikf/gallery-dl/issues/2333))\n- add `--config-toml` command-line option to load config files in TOML format\n- add `output.stdout`, `output.stdin`, and `output.stderr` options ([#1621](https://github.com/mikf/gallery-dl/issues/1621), [#2152](https://github.com/mikf/gallery-dl/issues/2152), [#2529](https://github.com/mikf/gallery-dl/issues/2529))\n- add `hash_md5` and `hash_sha1` functions ([#3679](https://github.com/mikf/gallery-dl/issues/3679))\n- implement `globals` option to enable defining custom functions for `eval` statements\n- implement `archive-pragma` option to use SQLite PRAGMA statements\n- implement `actions` to trigger events on logging messages ([#3338](https://github.com/mikf/gallery-dl/issues/3338), [#3630](https://github.com/mikf/gallery-dl/issues/3630))\n- implement ability to load external extractor classes\n  - `-X/--extractors` command-line options\n  - `extractor.modules-sources` config option\n### Fixes\n- [bunkr] fix extraction ([#3636](https://github.com/mikf/gallery-dl/issues/3636), [#3655](https://github.com/mikf/gallery-dl/issues/3655))\n- [danbooru] send gallery-dl User-Agent ([#3665](https://github.com/mikf/gallery-dl/issues/3665))\n- [deviantart] fix crash when handling deleted deviations in status updates ([#3656](https://github.com/mikf/gallery-dl/issues/3656))\n- [fanbox] fix crash with missing images ([#3673](https://github.com/mikf/gallery-dl/issues/3673))\n- [imagefap] update `gallery` URLs ([#3595](https://github.com/mikf/gallery-dl/issues/3595))\n- [imagefap] fix infinite pagination loop ([#3594](https://github.com/mikf/gallery-dl/issues/3594))\n- [imagefap] fix metadata extraction\n- [oauth] use default name for browsers without `name` attribute\n- [pinterest] unescape search terms ([#3621](https://github.com/mikf/gallery-dl/issues/3621))\n- [pixiv] fix `--write-tags` for `\"tags\": \"original\"` ([#3675](https://github.com/mikf/gallery-dl/issues/3675))\n- [poipiku] warn about incorrect passwords ([#3646](https://github.com/mikf/gallery-dl/issues/3646))\n- [reddit] update `videos` option ([#3712](https://github.com/mikf/gallery-dl/issues/3712))\n- [soundgasm] rewrite ([#3578](https://github.com/mikf/gallery-dl/issues/3578))\n- [telegraph] fix extraction when images are not in `<figure>` elements ([#3590](https://github.com/mikf/gallery-dl/issues/3590))\n- [tumblr] raise more detailed errors for dashboard-only blogs ([#3628](https://github.com/mikf/gallery-dl/issues/3628))\n- [twitter] fix some `original` retweets not downloading ([#3744](https://github.com/mikf/gallery-dl/issues/3744))\n- [ytdl] fix `--parse-metadata` ([#3663](https://github.com/mikf/gallery-dl/issues/3663))\n- [downloader:ytdl] prevent exception on empty results\n### Improvements\n- [downloader:http] use `time.monotonic()`\n- [downloader:http] update `_http_retry` to accept a Python function ([#3569](https://github.com/mikf/gallery-dl/issues/3569))\n- [postprocessor:metadata] speed up JSON encoding\n- replace `json.loads/dumps` with direct calls to `JSONDecoder.decode/JSONEncoder.encode`\n- improve `option.Formatter` performance\n### Removals\n- [nitter] remove `nitter.pussthecat.org`\n\n## 1.24.5 - 2023-01-28\n### Additions\n- [booru] add `url` option\n- [danbooru] extend `metadata` option ([#3505](https://github.com/mikf/gallery-dl/issues/3505))\n- [deviantart] add extractor for status updates ([#3539](https://github.com/mikf/gallery-dl/issues/3539), [#3541](https://github.com/mikf/gallery-dl/issues/3541))\n- [deviantart] add support for `/deviation/` and `fav.me` URLs ([#3558](https://github.com/mikf/gallery-dl/issues/3558), [#3560](https://github.com/mikf/gallery-dl/issues/3560))\n- [kemonoparty] extract `hash` metadata for discord files ([#3531](https://github.com/mikf/gallery-dl/issues/3531))\n- [lexica] add `search` extractor ([#3567](https://github.com/mikf/gallery-dl/issues/3567))\n- [mastodon] add `num` and `count` metadata fields ([#3517](https://github.com/mikf/gallery-dl/issues/3517))\n- [nudecollect] add `image` and `album` extractors ([#2430](https://github.com/mikf/gallery-dl/issues/2430), [#2818](https://github.com/mikf/gallery-dl/issues/2818), [#3575](https://github.com/mikf/gallery-dl/issues/3575))\n- [wikifeet] add `gallery` extractor ([#519](https://github.com/mikf/gallery-dl/issues/519), [#3537](https://github.com/mikf/gallery-dl/issues/3537))\n- [downloader:http] add signature checks for `.blend`, `.obj`, and `.clip` files ([#3535](https://github.com/mikf/gallery-dl/issues/3535))\n- add `extractor.retry-codes` option\n- add `-O/--postprocessor-option` command-line option ([#3565](https://github.com/mikf/gallery-dl/issues/3565))\n- improve `write-pages` output\n### Fixes\n- [bunkr] fix downloading `.mkv` and `.ts` files ([#3571](https://github.com/mikf/gallery-dl/issues/3571))\n- [fantia] send `X-CSRF-Token` headers ([#3576](https://github.com/mikf/gallery-dl/issues/3576))\n- [generic] fix regex for non-src image URLs ([#3555](https://github.com/mikf/gallery-dl/issues/3555))\n- [hiperdex] update domain ([#3572](https://github.com/mikf/gallery-dl/issues/3572))\n- [hotleak] fix video URLs ([#3516](https://github.com/mikf/gallery-dl/issues/3516), [#3525](https://github.com/mikf/gallery-dl/issues/3525), [#3563](https://github.com/mikf/gallery-dl/issues/3563), [#3581](https://github.com/mikf/gallery-dl/issues/3581))\n- [instagram] always show `cursor` value after errors ([#3440](https://github.com/mikf/gallery-dl/issues/3440))\n- [instagram] update API domain, headers, and csrf token handling\n- [oauth] show `client-id`/`api-key` values ([#3518](https://github.com/mikf/gallery-dl/issues/3518))\n- [philomena] match URLs with www subdomain\n- [sankaku] update URL pattern ([#3523](https://github.com/mikf/gallery-dl/issues/3523))\n- [twitter] refresh guest tokens ([#3445](https://github.com/mikf/gallery-dl/issues/3445), [#3458](https://github.com/mikf/gallery-dl/issues/3458))\n- [twitter] fix search pagination ([#3536](https://github.com/mikf/gallery-dl/issues/3536), [#3534](https://github.com/mikf/gallery-dl/issues/3534), [#3549](https://github.com/mikf/gallery-dl/issues/3549))\n- [twitter] use `\"browser\": \"firefox\"` by default ([#3522](https://github.com/mikf/gallery-dl/issues/3522))\n\n## 1.24.4 - 2023-01-11\n### Additions\n- [downloader:http] add `validate` option\n### Fixes\n- [kemonoparty] fix regression from commit 473bd380 ([#3519](https://github.com/mikf/gallery-dl/issues/3519))\n\n## 1.24.3 - 2023-01-10\n### Additions\n- [danbooru] extract `uploader` metadata ([#3457](https://github.com/mikf/gallery-dl/issues/3457))\n- [deviantart] initial implementation of username & password login for `scraps` ([#1029](https://github.com/mikf/gallery-dl/issues/1029))\n- [fanleaks] add `post` and `model` extractors ([#3468](https://github.com/mikf/gallery-dl/issues/3468), [#3474](https://github.com/mikf/gallery-dl/issues/3474))\n- [imagefap] add `folder` extractor ([#3504](https://github.com/mikf/gallery-dl/issues/3504))\n- [lynxchan] support `bbw-chan.nl` ([#3456](https://github.com/mikf/gallery-dl/issues/3456), [#3463](https://github.com/mikf/gallery-dl/issues/3463))\n- [pinterest] support `All Pins` boards ([#2855](https://github.com/mikf/gallery-dl/issues/2855), [#3484](https://github.com/mikf/gallery-dl/issues/3484))\n- [pinterest] add `domain` option ([#3484](https://github.com/mikf/gallery-dl/issues/3484))\n- [pixiv] implement `metadata-bookmark` option ([#3417](https://github.com/mikf/gallery-dl/issues/3417))\n- [tcbscans] add `chapter` and `manga` extractors ([#3189](https://github.com/mikf/gallery-dl/issues/3189))\n- [twitter] implement `syndication=extended` ([#3483](https://github.com/mikf/gallery-dl/issues/3483))\n- implement slice notation for `range` options ([#918](https://github.com/mikf/gallery-dl/issues/918), [#2865](https://github.com/mikf/gallery-dl/issues/2865))\n- allow `filter` options to be a list of expressions\n### Fixes\n- [behance] use delay between requests ([#2507](https://github.com/mikf/gallery-dl/issues/2507))\n- [bunkr] fix URLs returned by API ([#3481](https://github.com/mikf/gallery-dl/issues/3481))\n- [fanbox] return `imageMap` files in order ([#2718](https://github.com/mikf/gallery-dl/issues/2718))\n- [imagefap] use delay between requests ([#1140](https://github.com/mikf/gallery-dl/issues/1140))\n- [imagefap] warn about redirects to `/human-verification` ([#1140](https://github.com/mikf/gallery-dl/issues/1140))\n- [kemonoparty] reject invalid/empty files ([#3510](https://github.com/mikf/gallery-dl/issues/3510))\n- [myhentaigallery] handle whitespace before title tag ([#3503](https://github.com/mikf/gallery-dl/issues/3503))\n- [poipiku] fix extraction for a different warning button style ([#3493](https://github.com/mikf/gallery-dl/issues/3493), [#3460](https://github.com/mikf/gallery-dl/issues/3460))\n- [poipiku] warn about login requirements\n- [telegraph] fix file URLs ([#3506](https://github.com/mikf/gallery-dl/issues/3506))\n- [twitter] fix crash when using `expand` and `syndication` ([#3473](https://github.com/mikf/gallery-dl/issues/3473))\n- [twitter] apply tweet type checks before uniqueness check ([#3439](https://github.com/mikf/gallery-dl/issues/3439), [#3455](https://github.com/mikf/gallery-dl/issues/3455))\n- [twitter] force `https://` for TwitPic URLs ([#3449](https://github.com/mikf/gallery-dl/issues/3449))\n- [ytdl] adapt to yt-dlp changes\n- update and improve documentation ([#3453](https://github.com/mikf/gallery-dl/issues/3453), [#3462](https://github.com/mikf/gallery-dl/issues/3462), [#3496](https://github.com/mikf/gallery-dl/issues/3496))\n\n## 1.24.2 - 2022-12-18\n### Additions\n- [2chen] support `.club` URLs ([#3406](https://github.com/mikf/gallery-dl/issues/3406))\n- [deviantart] extract sta.sh URLs from `text_content` ([#3366](https://github.com/mikf/gallery-dl/issues/3366))\n- [deviantart] add `/view` URL support ([#3367](https://github.com/mikf/gallery-dl/issues/3367))\n- [e621] implement `threshold` option to control pagination ([#3413](https://github.com/mikf/gallery-dl/issues/3413))\n- [fapello] add `post`, `user` and `path` extractors ([#3065](https://github.com/mikf/gallery-dl/issues/3065), [#3360](https://github.com/mikf/gallery-dl/issues/3360), [#3415](https://github.com/mikf/gallery-dl/issues/3415))\n- [imgur] add support for imgur.io URLs ([#3419](https://github.com/mikf/gallery-dl/issues/3419))\n- [lynxchan] add generic extractors for lynxchan imageboards ([#3389](https://github.com/mikf/gallery-dl/issues/3389), [#3394](https://github.com/mikf/gallery-dl/issues/3394))\n- [mangafox] extract more metadata ([#3167](https://github.com/mikf/gallery-dl/issues/3167))\n- [pixiv] extract `date_url` metadata ([#3405](https://github.com/mikf/gallery-dl/issues/3405))\n- [soundgasm] add `audio` and `user` extractors ([#3384](https://github.com/mikf/gallery-dl/issues/3384), [#3388](https://github.com/mikf/gallery-dl/issues/3388))\n- [webmshare] add `video` extractor ([#2410](https://github.com/mikf/gallery-dl/issues/2410))\n- support Firefox containers for `--cookies-from-browser` ([#3346](https://github.com/mikf/gallery-dl/issues/3346))\n### Fixes\n- [2chen] fix file URLs\n- [bunkr] update domain ([#3391](https://github.com/mikf/gallery-dl/issues/3391))\n- [exhentai] fix pagination\n- [imagetwist] fix extraction\n- [imgth] rewrite\n- [instagram] prevent post `date` overwriting file `date` ([#3392](https://github.com/mikf/gallery-dl/issues/3392))\n- [khinsider] fix metadata extraction\n- [komikcast] update domain and fix extraction\n- [reddit] increase `id-max` default value ([#3397](https://github.com/mikf/gallery-dl/issues/3397))\n- [seiga] raise error when redirected to login page ([#3401](https://github.com/mikf/gallery-dl/issues/3401))\n- [sexcom] fix video URLs ([#3408](https://github.com/mikf/gallery-dl/issues/3408), [#3414](https://github.com/mikf/gallery-dl/issues/3414))\n- [twitter] update `search` pagination ([#544](https://github.com/mikf/gallery-dl/issues/544))\n- [warosu] fix and update\n- [zerochan] update for layout v3\n- restore paths for archived files ([#3362](https://github.com/mikf/gallery-dl/issues/3362), [#3377](https://github.com/mikf/gallery-dl/issues/3377))\n- use `util.NONE` as `keyword-default` default value ([#3334](https://github.com/mikf/gallery-dl/issues/3334))\n### Removals\n- [foolslide] remove `kireicake`\n- [kissgoddess] remove module\n\n## 1.24.1 - 2022-12-04\n### Additions\n- [artstation] add `pro-first` option ([#3273](https://github.com/mikf/gallery-dl/issues/3273))\n- [artstation] add `max-posts` option ([#3270](https://github.com/mikf/gallery-dl/issues/3270))\n- [fapachi] add `post` and `user` extractors ([#3339](https://github.com/mikf/gallery-dl/issues/3339), [#3347](https://github.com/mikf/gallery-dl/issues/3347))\n- [inkbunny] provide additional metadata ([#3274](https://github.com/mikf/gallery-dl/issues/3274))\n- [nitter] add `retweets` option ([#3278](https://github.com/mikf/gallery-dl/issues/3278))\n- [nitter] add `videos` option ([#3279](https://github.com/mikf/gallery-dl/issues/3279))\n- [nitter] support `/i/web/` and `/i/user/` URLs ([#3310](https://github.com/mikf/gallery-dl/issues/3310))\n- [pixhost] add `gallery` support ([#3336](https://github.com/mikf/gallery-dl/issues/3336), [#3353](https://github.com/mikf/gallery-dl/issues/3353))\n- [weibo] add `count` metadata field ([#3305](https://github.com/mikf/gallery-dl/issues/3305))\n- [downloader:http] add `retry-codes` option ([#3313](https://github.com/mikf/gallery-dl/issues/3313))\n- [formatter] implement `S` format specifier to sort lists ([#3266](https://github.com/mikf/gallery-dl/issues/3266))\n- implement `version-metadata` option ([#3201](https://github.com/mikf/gallery-dl/issues/3201))\n### Fixes\n- [2chen] fix extraction ([#3354](https://github.com/mikf/gallery-dl/issues/3354), [#3356](https://github.com/mikf/gallery-dl/issues/3356))\n- [bcy] fix JSONDecodeError ([#3321](https://github.com/mikf/gallery-dl/issues/3321))\n- [bunkr] fix video downloads ([#3326](https://github.com/mikf/gallery-dl/issues/3326), [#3335](https://github.com/mikf/gallery-dl/issues/3335))\n- [bunkr] use `media-files` servers for more file types\n- [itaku] remove `Extreme` rating ([#3285](https://github.com/mikf/gallery-dl/issues/3285), [#3287](https://github.com/mikf/gallery-dl/issues/3287))\n- [hitomi] apply format check for every image ([#3280](https://github.com/mikf/gallery-dl/issues/3280))\n- [hotleak] fix UnboundLocalError ([#3288](https://github.com/mikf/gallery-dl/issues/3288), [#3293](https://github.com/mikf/gallery-dl/issues/3293))\n- [nitter] sanitize filenames ([#3294](https://github.com/mikf/gallery-dl/issues/3294))\n- [nitter] retry downloads on 404 ([#3313](https://github.com/mikf/gallery-dl/issues/3313))\n- [nitter] set `hlsPlayback` cookie\n- [patreon] fix `403 Forbidden` errors ([#3341](https://github.com/mikf/gallery-dl/issues/3341))\n- [patreon] improve `campaign_id` extraction ([#3235](https://github.com/mikf/gallery-dl/issues/3235))\n- [patreon] update API query parameters\n- [pixiv] preserve `tags` order ([#3266](https://github.com/mikf/gallery-dl/issues/3266))\n- [reddit] use `dash_url` for videos ([#3258](https://github.com/mikf/gallery-dl/issues/3258), [#3306](https://github.com/mikf/gallery-dl/issues/3306))\n- [twitter] fix error when using user IDs for suspended accounts\n- [weibo] fix bug with empty `playback_list` ([#3301](https://github.com/mikf/gallery-dl/issues/3301))\n- [downloader:http] fix potential `ZeroDivisionError` ([#3328](https://github.com/mikf/gallery-dl/issues/3328))\n### Removals\n- [lolisafe] remove `zz.ht`\n\n## 1.24.0 - 2022-11-20\n### Additions\n- [exhentai] add metadata to search results ([#3181](https://github.com/mikf/gallery-dl/issues/3181))\n- [gelbooru_v02] implement `notes` extraction\n- [instagram] add `guide` extractor ([#3192](https://github.com/mikf/gallery-dl/issues/3192))\n- [lolisafe] add support for xbunkr ([#3153](https://github.com/mikf/gallery-dl/issues/3153), [#3156](https://github.com/mikf/gallery-dl/issues/3156))\n- [mastodon] add `instance_remote` metadata field ([#3119](https://github.com/mikf/gallery-dl/issues/3119))\n- [nitter] add extractors for Nitter instances ([#2415](https://github.com/mikf/gallery-dl/issues/2415), [#2696](https://github.com/mikf/gallery-dl/issues/2696))\n- [pixiv] add support for new daily AI rankings category ([#3214](https://github.com/mikf/gallery-dl/issues/3214), [#3221](https://github.com/mikf/gallery-dl/issues/3221))\n- [twitter] add `avatar` and `background` extractors ([#349](https://github.com/mikf/gallery-dl/issues/349), [#3023](https://github.com/mikf/gallery-dl/issues/3023))\n- [uploadir] add support for `uploadir.com` ([#3162](https://github.com/mikf/gallery-dl/issues/3162))\n- [wallhaven] add `user` extractor ([#3212](https://github.com/mikf/gallery-dl/issues/3212), [#3213](https://github.com/mikf/gallery-dl/issues/3213), [#3226](https://github.com/mikf/gallery-dl/issues/3226))\n- [downloader:http] add `chunk-size` option ([#3143](https://github.com/mikf/gallery-dl/issues/3143))\n- [downloader:http] add file signature check for `.mp4` files\n- [downloader:http] add file signature check and MIME type for `.avif` files\n- [postprocessor] implement `post-after` event ([#3117](https://github.com/mikf/gallery-dl/issues/3117))\n- [postprocessor:metadata] implement `\"mode\": \"jsonl\"`\n- [postprocessor:metadata] add `open`, `encoding`, and `private` options\n- add `--chunk-size` command-line option ([#3143](https://github.com/mikf/gallery-dl/issues/3143))\n- add `--user-agent` command-line option\n- implement `http-metadata` option\n- implement `\"user-agent\": \"browser\"` ([#2636](https://github.com/mikf/gallery-dl/issues/2636))\n### Changes\n- [deviantart] restore cookies warning for mature scraps ([#3129](https://github.com/mikf/gallery-dl/issues/3129))\n- [instagram] use REST API for unauthenticated users by default\n- [downloader:http] increase default `chunk-size` to 32768 bytes ([#3143](https://github.com/mikf/gallery-dl/issues/3143))\n- build Windows executables using py2exe's new `freeze()` API\n- build executables on GitHub Actions with Python 3.11\n- reword error text for unsupported URLs\n### Fixes\n- [exhentai] fix pagination ([#3181](https://github.com/mikf/gallery-dl/issues/3181))\n- [khinsider] fix extraction ([#3215](https://github.com/mikf/gallery-dl/issues/3215), [#3219](https://github.com/mikf/gallery-dl/issues/3219))\n- [realbooru] fix download URLs ([#2530](https://github.com/mikf/gallery-dl/issues/2530))\n- [realbooru] fix `tags` extraction ([#2530](https://github.com/mikf/gallery-dl/issues/2530))\n- [tumblr] fall back to `gifv` when possible ([#3095](https://github.com/mikf/gallery-dl/issues/3095), [#3159](https://github.com/mikf/gallery-dl/issues/3159))\n- [twitter] fix login ([#3220](https://github.com/mikf/gallery-dl/issues/3220))\n- [twitter] update URL for syndication API ([#3160](https://github.com/mikf/gallery-dl/issues/3160))\n- [weibo] send `Referer` headers ([#3188](https://github.com/mikf/gallery-dl/issues/3188))\n- [ytdl] update `parse_bytes` location ([#3256](https://github.com/mikf/gallery-dl/issues/3256))\n### Improvements\n- [imxto] extract additional metadata ([#3118](https://github.com/mikf/gallery-dl/issues/3118), [#3175](https://github.com/mikf/gallery-dl/issues/3175))\n- [instagram] allow downloading avatars for private profiles ([#3255](https://github.com/mikf/gallery-dl/issues/3255))\n- [pixiv] raise error for invalid search/ranking parameters ([#3214](https://github.com/mikf/gallery-dl/issues/3214))\n- [twitter] update `bookmarks` pagination ([#3172](https://github.com/mikf/gallery-dl/issues/3172))\n- [downloader:http] refactor file signature checks\n- [downloader:http] improve `-r/--limit-rate` accuracy ([#3143](https://github.com/mikf/gallery-dl/issues/3143))\n- add loaded config files to debug output\n- improve `-K` output for lists\n### Removals\n- [instagram] remove login support ([#3139](https://github.com/mikf/gallery-dl/issues/3139), [#3141](https://github.com/mikf/gallery-dl/issues/3141), [#3191](https://github.com/mikf/gallery-dl/issues/3191))\n- [instagram] remove `channel` extractor\n- [ngomik] remove module\n\n## 1.23.5 - 2022-10-30\n### Fixes\n- [instagram] fix AttributeError on user stories extraction ([#3123](https://github.com/mikf/gallery-dl/issues/3123))\n\n## 1.23.4 - 2022-10-29\n### Additions\n- [aibooru] add support for aibooru.online ([#3075](https://github.com/mikf/gallery-dl/issues/3075))\n- [instagram] add 'avatar' extractor ([#929](https://github.com/mikf/gallery-dl/issues/929), [#1097](https://github.com/mikf/gallery-dl/issues/1097), [#2992](https://github.com/mikf/gallery-dl/issues/2992))\n- [instagram] support 'instagram.com/s/' highlight URLs ([#3076](https://github.com/mikf/gallery-dl/issues/3076))\n- [instagram] extract 'coauthors' metadata ([#3107](https://github.com/mikf/gallery-dl/issues/3107))\n- [mangasee] add support for 'mangalife' ([#3086](https://github.com/mikf/gallery-dl/issues/3086))\n- [mastodon] add 'bookmark' extractor ([#3109](https://github.com/mikf/gallery-dl/issues/3109))\n- [mastodon] support cross-instance user references and '/web/' URLs ([#3109](https://github.com/mikf/gallery-dl/issues/3109))\n- [moebooru] implement 'notes' extraction ([#3094](https://github.com/mikf/gallery-dl/issues/3094))\n- [pixiv] extend 'metadata' option ([#3057](https://github.com/mikf/gallery-dl/issues/3057))\n- [reactor] match 'best', 'new', 'all' URLs ([#3073](https://github.com/mikf/gallery-dl/issues/3073))\n- [smugloli] add 'smugloli' extractors ([#3060](https://github.com/mikf/gallery-dl/issues/3060))\n- [tumblr] add 'fallback-delay' and 'fallback-retries' options ([#2957](https://github.com/mikf/gallery-dl/issues/2957))\n- [vichan] add generic extractors for vichan imageboards\n### Fixes\n- [bcy] fix extraction ([#3103](https://github.com/mikf/gallery-dl/issues/3103))\n- [gelbooru] support alternate parameter order in post URLs ([#2821](https://github.com/mikf/gallery-dl/issues/2821))\n- [hentai2read] support minor versions in chapter URLs ([#3089](https://github.com/mikf/gallery-dl/issues/3089))\n- [hentaihere] support minor versions in chapter URLs\n- [kemonoparty] fix 'dms' extraction ([#3106](https://github.com/mikf/gallery-dl/issues/3106))\n- [kemonoparty] update pagination offset\n- [manganelo] update domain to 'chapmanganato.com' ([#3097](https://github.com/mikf/gallery-dl/issues/3097))\n- [pixiv] use 'exact_match_for_tags' as default search mode ([#3092](https://github.com/mikf/gallery-dl/issues/3092))\n- [redgifs] fix 'token' extraction ([#3080](https://github.com/mikf/gallery-dl/issues/3080), [#3081](https://github.com/mikf/gallery-dl/issues/3081))\n- [skeb] fix extraction ([#3112](https://github.com/mikf/gallery-dl/issues/3112))\n- improve compatibility of DownloadArchive ([#3078](https://github.com/mikf/gallery-dl/issues/3078))\n\n## 1.23.3 - 2022-10-15\n### Additions\n- [2chen] Add `2chen.moe` extractor ([#2707](https://github.com/mikf/gallery-dl/issues/2707))\n- [8chan] add `thread` and `board` extractors ([#2938](https://github.com/mikf/gallery-dl/issues/2938))\n- [deviantart] add `group` option ([#3018](https://github.com/mikf/gallery-dl/issues/3018))\n- [fanbox] add `content` metadata field ([#3020](https://github.com/mikf/gallery-dl/issues/3020))\n- [instagram] restore `cursor` functionality ([#2991](https://github.com/mikf/gallery-dl/issues/2991))\n- [instagram] restore warnings for private profiles ([#3004](https://github.com/mikf/gallery-dl/issues/3004), [#3045](https://github.com/mikf/gallery-dl/issues/3045))\n- [nana] add `nana` extractors ([#2967](https://github.com/mikf/gallery-dl/issues/2967))\n- [nijie] add `feed` and `followed` extractors ([#3048](https://github.com/mikf/gallery-dl/issues/3048))\n- [tumblr] support `https://www.tumblr.com/BLOGNAME` URLs ([#3034](https://github.com/mikf/gallery-dl/issues/3034))\n- [tumblr] add `offset` option\n- [vk] add `tagged` extractor ([#2997](https://github.com/mikf/gallery-dl/issues/2997))\n- add `path-extended` option ([#3021](https://github.com/mikf/gallery-dl/issues/3021))\n- emit debug logging messages before calling time.sleep() ([#2982](https://github.com/mikf/gallery-dl/issues/2982))\n### Changes\n- [postprocessor:metadata] assume `\"mode\": \"custom\"` when `format` is given\n### Fixes\n- [artstation] skip missing projects ([#3016](https://github.com/mikf/gallery-dl/issues/3016))\n- [danbooru] fix ugoira metadata extraction ([#3056](https://github.com/mikf/gallery-dl/issues/3056))\n- [deviantart] fix `deviation` extraction ([#2981](https://github.com/mikf/gallery-dl/issues/2981))\n- [hitomi] fall back to `webp` when selected format is not available ([#3030](https://github.com/mikf/gallery-dl/issues/3030))\n- [imagefap] fix and improve folder extraction and gallery pagination ([#3013](https://github.com/mikf/gallery-dl/issues/3013))\n- [instagram] fix login ([#3011](https://github.com/mikf/gallery-dl/issues/3011), [#3015](https://github.com/mikf/gallery-dl/issues/3015))\n- [nozomi] fix extraction ([#3051](https://github.com/mikf/gallery-dl/issues/3051))\n- [redgifs] fix extraction ([#3037](https://github.com/mikf/gallery-dl/issues/3037))\n- [tumblr] sleep between fallback retries ([#2957](https://github.com/mikf/gallery-dl/issues/2957))\n- [vk] unescape error messages\n- fix duplicated metadata bug with `-j` ([#3033](https://github.com/mikf/gallery-dl/issues/3033))\n- fix bug when processing input file comments ([#2808](https://github.com/mikf/gallery-dl/issues/2808))\n\n## 1.23.2 - 2022-10-01\n### Additions\n- [artstation] support search filters ([#2970](https://github.com/mikf/gallery-dl/issues/2970))\n- [blogger] add `label` and `query` metadata fields ([#2930](https://github.com/mikf/gallery-dl/issues/2930))\n- [exhentai] add a slash to the end of gallery URLs ([#2947](https://github.com/mikf/gallery-dl/issues/2947))\n- [instagram] add `count` metadata field ([#2979](https://github.com/mikf/gallery-dl/issues/2979))\n- [instagram] add `api` option\n- [kemonoparty] add `count` metadata field ([#2952](https://github.com/mikf/gallery-dl/issues/2952))\n- [mastodon] warn about moved accounts ([#2939](https://github.com/mikf/gallery-dl/issues/2939))\n- [newgrounds] add `games` extractor ([#2955](https://github.com/mikf/gallery-dl/issues/2955))\n- [newgrounds] extract `type` metadata\n- [pixiv] add `series` extractor ([#2964](https://github.com/mikf/gallery-dl/issues/2964))\n- [sankaku] implement `refresh` option ([#2958](https://github.com/mikf/gallery-dl/issues/2958))\n- [skeb] add `search` extractor and `filters` option ([#2945](https://github.com/mikf/gallery-dl/issues/2945))\n### Fixes\n- [deviantart] fix extraction ([#2981](https://github.com/mikf/gallery-dl/issues/2981), [#2983](https://github.com/mikf/gallery-dl/issues/2983))\n- [fappic] fix extraction\n- [instagram] extract higher-resolution photos ([#2666](https://github.com/mikf/gallery-dl/issues/2666))\n- [instagram] fix `username` and `fullname` metadata for saved posts ([#2911](https://github.com/mikf/gallery-dl/issues/2911))\n- [instagram] update API headers\n- [kemonoparty] send `Referer` headers ([#2989](https://github.com/mikf/gallery-dl/issues/2989), [#2990](https://github.com/mikf/gallery-dl/issues/2990))\n- [kemonoparty] restore `favorites` API endpoints ([#2994](https://github.com/mikf/gallery-dl/issues/2994))\n- [myportfolio] use fallback when no images are found ([#2959](https://github.com/mikf/gallery-dl/issues/2959))\n- [plurk] fix extraction ([#2977](https://github.com/mikf/gallery-dl/issues/2977))\n- [sankaku] detect expired links ([#2958](https://github.com/mikf/gallery-dl/issues/2958))\n- [tumblr] retry extraction of failed higher-resolution images ([#2957](https://github.com/mikf/gallery-dl/issues/2957))\n\n## 1.23.1 - 2022-09-18\n### Additions\n- [flickr] add support for `secure.flickr.com` URLs ([#2910](https://github.com/mikf/gallery-dl/issues/2910))\n- [hotleak] add hotleak extractors ([#2890](https://github.com/mikf/gallery-dl/issues/2890), [#2909](https://github.com/mikf/gallery-dl/issues/2909))\n- [instagram] add `highlight_title` and `date` metadata for highlight downloads ([#2879](https://github.com/mikf/gallery-dl/issues/2879))\n- [paheal] add support for videos ([#2892](https://github.com/mikf/gallery-dl/issues/2892))\n- [tumblr] fetch high-quality inline images ([#2877](https://github.com/mikf/gallery-dl/issues/2877))\n- [tumblr] implement `ratelimit` option ([#2919](https://github.com/mikf/gallery-dl/issues/2919))\n- [twitter] add general support for unified cards ([#2875](https://github.com/mikf/gallery-dl/issues/2875))\n- [twitter] implement `cards-blacklist` option ([#2875](https://github.com/mikf/gallery-dl/issues/2875))\n- [zerochan] add `metadata` option ([#2861](https://github.com/mikf/gallery-dl/issues/2861))\n- [postprocessor:zip] implement `files` option ([#2872](https://github.com/mikf/gallery-dl/issues/2872))\n### Fixes\n- [bunkr] fix extraction ([#2903](https://github.com/mikf/gallery-dl/issues/2903))\n- [bunkr] use `media-files` servers for `m4v` and `mov` downloads ([#2925](https://github.com/mikf/gallery-dl/issues/2925))\n- [exhentai] improve 509.gif detection ([#2901](https://github.com/mikf/gallery-dl/issues/2901))\n- [exhentai] guess extension for original files ([#2842](https://github.com/mikf/gallery-dl/issues/2842))\n- [poipiku] use `img-org.poipiku.com` as image domain ([#2796](https://github.com/mikf/gallery-dl/issues/2796))\n- [reddit] prevent exception with empty submission URLs ([#2913](https://github.com/mikf/gallery-dl/issues/2913))\n- [redgifs] fix download URLs ([#2884](https://github.com/mikf/gallery-dl/issues/2884))\n- [smugmug] update default API credentials ([#2881](https://github.com/mikf/gallery-dl/issues/2881))\n- [twitter] provide proper `date` for syndication results ([#2920](https://github.com/mikf/gallery-dl/issues/2920))\n- [twitter] fix new-style `/card_img/` URLs\n- remove all whitespace before comments after input file URLs ([#2808](https://github.com/mikf/gallery-dl/issues/2808))\n\n## 1.23.0 - 2022-08-28\n### Changes\n- [twitter] update `user` and `author` metdata fields\n  - for URLs with a single username or ID like `https://twitter.com/USER` or a search with a single `from:` statement, `user` will now always refer to the user referenced in the URL.\n  - for all other URLs like `https://twitter.com/i/bookmarks`, `user` and `author` refer to the same user\n  - `author` will always refer to the original Tweet author\n- [twitter] update `quote_id` and `quote_by` metadata fields\n  - `quote_id` is now non-zero for quoted Tweets and contains the Tweet ID of the quotng Tweet (was the other way round before)\n  - `quote_by` is only defined for quoted Tweets like before, but now contains the screen name of the user quoting this Tweet\n- [skeb] improve archive IDs for thumbnails and article images\n### Additions\n- [artstation] add `num` and `count` metadata fields ([#2764](https://github.com/mikf/gallery-dl/issues/2764))\n- [catbox] add `album` extractor ([#2410](https://github.com/mikf/gallery-dl/issues/2410))\n- [blogger] emit metadata for posts without files ([#2789](https://github.com/mikf/gallery-dl/issues/2789))\n- [foolfuuka] update supported domains\n- [gelbooru] add support for `api_key` and `user_id` ([#2767](https://github.com/mikf/gallery-dl/issues/2767))\n- [gelbooru] implement pagination for `pool` results ([#2853](https://github.com/mikf/gallery-dl/issues/2853))\n- [instagram] add support for a user's saved collections ([#2769](https://github.com/mikf/gallery-dl/issues/2769))\n- [instagram] provide `date` for directory format strings ([#2830](https://github.com/mikf/gallery-dl/issues/2830))\n- [kemonoparty] add `favorites` option ([#2826](https://github.com/mikf/gallery-dl/issues/2826), [#2831](https://github.com/mikf/gallery-dl/issues/2831))\n- [oauth] add `host` config option ([#2806](https://github.com/mikf/gallery-dl/issues/2806))\n- [rule34] implement pagination for `pool` results ([#2853](https://github.com/mikf/gallery-dl/issues/2853))\n- [skeb] add option to download `article` images ([#1031](https://github.com/mikf/gallery-dl/issues/1031))\n- [tumblr] download higher-quality images ([#2761](https://github.com/mikf/gallery-dl/issues/2761))\n- [tumblr] add `count` metadata field ([#2804](https://github.com/mikf/gallery-dl/issues/2804))\n- [wallhaven] implement `metadata` option ([#2803](https://github.com/mikf/gallery-dl/issues/2803))\n- [zerochan] add `tag` and `image` extractors ([#1434](https://github.com/mikf/gallery-dl/issues/1434))\n- [zerochan] implement login with username & password ([#1434](https://github.com/mikf/gallery-dl/issues/1434))\n- [postprocessor:metadata] implement `mode: modify` and `mode: delete` ([#2640](https://github.com/mikf/gallery-dl/issues/2640))\n- [formatter] add `g` conversion for slugifying a string ([#2410](https://github.com/mikf/gallery-dl/issues/2410))\n- [formatter] apply `:J` only to lists ([#2833](https://github.com/mikf/gallery-dl/issues/2833))\n- implement `path-metadata` option ([#2734](https://github.com/mikf/gallery-dl/issues/2734))\n- allow comments after input file URLs ([#2808](https://github.com/mikf/gallery-dl/issues/2808))\n- add global `warnings` option to control `urllib3` warning behavior ([#2762](https://github.com/mikf/gallery-dl/issues/2762))\n### Fixes\n- [bunkr] fix extraction ([#2788](https://github.com/mikf/gallery-dl/issues/2788))\n- [deviantart] use public access token for journals ([#2702](https://github.com/mikf/gallery-dl/issues/2702))\n- [e621] fix extraction of `popular` posts\n- [fanbox] download cover images in original size ([#2784](https://github.com/mikf/gallery-dl/issues/2784))\n- [mastodon] allow downloading without access token ([#2782](https://github.com/mikf/gallery-dl/issues/2782))\n- [hitomi] update cache expiry time ([#2863](https://github.com/mikf/gallery-dl/issues/2863))\n- [hitomi] fix error when number of tag results is a multiple of 25 ([#2870](https://github.com/mikf/gallery-dl/issues/2870))\n- [mangahere] fix `page-reverse` option ([#2795](https://github.com/mikf/gallery-dl/issues/2795))\n- [poipiku] fix posts with more than one image ([#2796](https://github.com/mikf/gallery-dl/issues/2796))\n- [poipiku] update filter for static images ([#2796](https://github.com/mikf/gallery-dl/issues/2796))\n- [slideshare] fix metadata extraction\n- [twitter] unescape `+` in search queries ([#2226](https://github.com/mikf/gallery-dl/issues/2226))\n- [twitter] fall back to unfiltered search ([#2766](https://github.com/mikf/gallery-dl/issues/2766))\n- [twitter] ignore invalid user entries ([#2850](https://github.com/mikf/gallery-dl/issues/2850))\n- [vk] prevent exceptions for broken/invalid photos ([#2774](https://github.com/mikf/gallery-dl/issues/2774))\n- [vsco] fix `collection` extraction\n- [weibo] prevent exception for missing `playback_list` ([#2792](https://github.com/mikf/gallery-dl/issues/2792))\n- [weibo] prevent errors when paginating over album entries ([#2817](https://github.com/mikf/gallery-dl/issues/2817))\n\n## 1.22.4 - 2022-07-15\n### Additions\n- [instagram] add `pinned` metadata field ([#2752](https://github.com/mikf/gallery-dl/issues/2752))\n- [itaku] categorize sections by group ([#1842](https://github.com/mikf/gallery-dl/issues/1842))\n- [khinsider] extract `platform` metadata\n- [tumblr] support `/blog/view` URLs ([#2760](https://github.com/mikf/gallery-dl/issues/2760))\n- [twitter] implement `strategy` option ([#2712](https://github.com/mikf/gallery-dl/issues/2712))\n- [twitter] add `count` metadata field ([#2741](https://github.com/mikf/gallery-dl/issues/2741))\n- [formatter] implement `O` format specifier ([#2736](https://github.com/mikf/gallery-dl/issues/2736))\n- [postprocessor:mtime] add `value` option ([#2739](https://github.com/mikf/gallery-dl/issues/2739))\n- add `--no-postprocessors` command-line option ([#2725](https://github.com/mikf/gallery-dl/issues/2725))\n- implement `format-separator` option ([#2737](https://github.com/mikf/gallery-dl/issues/2737))\n### Changes\n- [pinterest] handle section pins with separate extractors ([#2684](https://github.com/mikf/gallery-dl/issues/2684))\n- [postprocessor:ugoira] enable `mtime` by default ([#2714](https://github.com/mikf/gallery-dl/issues/2714))\n### Fixes\n- [bunkr] fix extraction ([#2732](https://github.com/mikf/gallery-dl/issues/2732))\n- [hentaifoundry] fix metadata extraction\n- [itaku] fix user caching ([#1842](https://github.com/mikf/gallery-dl/issues/1842))\n- [itaku] fix `date` parsing\n- [kemonoparty] ensure all files have an `extension` ([#2740](https://github.com/mikf/gallery-dl/issues/2740))\n- [komikcast] update domain\n- [mangakakalot] update domain\n- [newgrounds] only attempt to login if necessary ([#2715](https://github.com/mikf/gallery-dl/issues/2715))\n- [newgrounds] prevent exception on empty results ([#2727](https://github.com/mikf/gallery-dl/issues/2727))\n- [nozomi] reduce memory consumption during searches ([#2754](https://github.com/mikf/gallery-dl/issues/2754))\n- [pixiv] fix default `background` filenames\n- [sankaku] rewrite file URLs to s.sankakucomplex.com ([#2746](https://github.com/mikf/gallery-dl/issues/2746))\n- [slideshare] fix `description` extraction\n- [twitter] ignore previously seen Tweets ([#2712](https://github.com/mikf/gallery-dl/issues/2712))\n- [twitter] unescape HTML entities in `content` ([#2757](https://github.com/mikf/gallery-dl/issues/2757))\n- [weibo] handle invalid or broken status objects\n- [postprocessor:zip] ensure target directory exists ([#2758](https://github.com/mikf/gallery-dl/issues/2758))\n- make `brotli` an *optional* dependency ([#2716](https://github.com/mikf/gallery-dl/issues/2716))\n- limit path length for `--write-pages` output on Windows ([#2733](https://github.com/mikf/gallery-dl/issues/2733))\n### Removals\n- [foolfuuka] remove archive.wakarimasen.moe\n\n## 1.22.3 - 2022-06-28\n### Changes\n- [twitter] revert strategy changes for user URLs ([#2712](https://github.com/mikf/gallery-dl/issues/2712), [#2710](https://github.com/mikf/gallery-dl/issues/2710))\n- update default User-Agent headers\n\n## 1.22.2 - 2022-06-27\n### Additions\n- [cyberdrop] add fallback URLs ([#2668](https://github.com/mikf/gallery-dl/issues/2668))\n- [horne] add support for horne.red ([#2700](https://github.com/mikf/gallery-dl/issues/2700))\n- [itaku] add `gallery` and `image` extractors ([#1842](https://github.com/mikf/gallery-dl/issues/1842))\n- [poipiku] add `user` and `post` extractors ([#1602](https://github.com/mikf/gallery-dl/issues/1602))\n- [skeb] add `following` extractor ([#2698](https://github.com/mikf/gallery-dl/issues/2698))\n- [twitter] implement `expand` option ([#2665](https://github.com/mikf/gallery-dl/issues/2665))\n- [twitter] implement `csrf` option ([#2676](https://github.com/mikf/gallery-dl/issues/2676))\n- [unsplash] add `collection_title` and `collection_id` metadata fields ([#2670](https://github.com/mikf/gallery-dl/issues/2670))\n- [weibo] support `tabtype=video` listings ([#2601](https://github.com/mikf/gallery-dl/issues/2601))\n- [formatter] implement slice operator as format specifier\n- support cygwin/BSD/etc for `--cookies-from-browser`\n### Fixes\n- [instagram] improve metadata generated by `_parse_post_api()` ([#2695](https://github.com/mikf/gallery-dl/issues/2695), [#2660](https://github.com/mikf/gallery-dl/issues/2660))\n- [instagram} fix `tag` extractor ([#2659](https://github.com/mikf/gallery-dl/issues/2659))\n- [instagram] automatically invalidate expired login sessions\n- [twitter] fix pagination for conversion tweets\n- [twitter] improve `\"replies\": \"self\"` ([#2665](https://github.com/mikf/gallery-dl/issues/2665))\n- [twitter] improve strategy for user URLs ([#2665](https://github.com/mikf/gallery-dl/issues/2665))\n- [vk] take URLs from `*_src` entries ([#2535](https://github.com/mikf/gallery-dl/issues/2535))\n- [weibo] fix URLs generated by `user` extractor ([#2601](https://github.com/mikf/gallery-dl/issues/2601))\n- [weibo] fix retweets ([#2601](https://github.com/mikf/gallery-dl/issues/2601))\n- [downloader:ytdl] update `_set_outtmpl()` ([#2692](https://github.com/mikf/gallery-dl/issues/2692))\n- [formatter] fix `!j` conversion for non-serializable types ([#2624](https://github.com/mikf/gallery-dl/issues/2624))\n- [snap] Fix missing libslang dependency ([#2655](https://github.com/mikf/gallery-dl/issues/2655))\n\n## 1.22.1 - 2022-06-04\n### Additions\n- [gfycat] add support for collections ([#2629](https://github.com/mikf/gallery-dl/issues/2629))\n- [instagram] support specifying users by ID\n- [paheal] extract more metadata ([#2641](https://github.com/mikf/gallery-dl/issues/2641))\n- [reddit] add `home` extractor ([#2614](https://github.com/mikf/gallery-dl/issues/2614))\n- [weibo] support usernames in URLs ([#1662](https://github.com/mikf/gallery-dl/issues/1662))\n- [weibo] support `livephoto` and `gif` files ([#2146](https://github.com/mikf/gallery-dl/issues/2146))\n- [weibo] add support for several different `tabtype` listings ([#686](https://github.com/mikf/gallery-dl/issues/686), [#2601](https://github.com/mikf/gallery-dl/issues/2601))\n- [postprocessor:metadata] write to stdout by setting filename to \"-\" ([#2624](https://github.com/mikf/gallery-dl/issues/2624))\n- implement `output.ansi` option ([#2628](https://github.com/mikf/gallery-dl/issues/2628))\n- support user-defined `output.mode` settings ([#2529](https://github.com/mikf/gallery-dl/issues/2529))\n### Changes\n- [readcomiconline] remove default `browser` setting ([#2625](https://github.com/mikf/gallery-dl/issues/2625))\n- [weibo] switch to desktop API ([#2601](https://github.com/mikf/gallery-dl/issues/2601))\n- fix command-line argument name of `--cookies-from-browser` ([#1606](https://github.com/mikf/gallery-dl/issues/1606), [#2630](https://github.com/mikf/gallery-dl/issues/2630))\n### Fixes\n- [bunkr] change domain to `app.bunkr.is` ([#2634](https://github.com/mikf/gallery-dl/issues/2634))\n- [deviantart] fix folder listings with `\"pagination\": \"manual\"` ([#2488](https://github.com/mikf/gallery-dl/issues/2488))\n- [gofile] fix 401 Unauthorized errors ([#2632](https://github.com/mikf/gallery-dl/issues/2632))\n- [hypnohub] move to gelbooru_v02 instances ([#2631](https://github.com/mikf/gallery-dl/issues/2631))\n- [instagram] fix and update extractors ([#2644](https://github.com/mikf/gallery-dl/issues/2644))\n- [nozomi] remove slashes from search terms ([#2653](https://github.com/mikf/gallery-dl/issues/2653))\n- [pixiv] include `.gif` in background fallback URLs ([#2495](https://github.com/mikf/gallery-dl/issues/2495))\n- [sankaku] extend URL patterns ([#2647](https://github.com/mikf/gallery-dl/issues/2647))\n- [subscribestar] fix `date` metadata ([#2642](https://github.com/mikf/gallery-dl/issues/2642))\n\n## 1.22.0 - 2022-05-25\n### Additions\n- [gelbooru_v01] add `favorite` extractor ([#2546](https://github.com/mikf/gallery-dl/issues/2546))\n- [Instagram] add `tagged_users` to keywords for stories ([#2582](https://github.com/mikf/gallery-dl/issues/2582), [#2584](https://github.com/mikf/gallery-dl/issues/2584))\n- [lolisafe] implement `domain` option ([#2575](https://github.com/mikf/gallery-dl/issues/2575))\n- [naverwebtoon] support (best)challenge comics ([#2542](https://github.com/mikf/gallery-dl/issues/2542))\n- [nijie] support /history_nuita.php listings ([#2541](https://github.com/mikf/gallery-dl/issues/2541))\n- [pixiv] provide more data when `metadata` is enabled ([#2594](https://github.com/mikf/gallery-dl/issues/2594))\n- [shopify] support several more sites by default ([#2089](https://github.com/mikf/gallery-dl/issues/2089))\n- [twitter] extract alt texts as `description` ([#2617](https://github.com/mikf/gallery-dl/issues/2617))\n- [twitter] recognize vxtwitter URLs ([#2621](https://github.com/mikf/gallery-dl/issues/2621))\n- [weasyl] implement `metadata` option ([#2610](https://github.com/mikf/gallery-dl/issues/2610))\n- implement `--cookies-from-browser` ([#1606](https://github.com/mikf/gallery-dl/issues/1606))\n- implement `output.colors` options ([#2532](https://github.com/mikf/gallery-dl/issues/2532))\n- implement string literals in replacement fields\n- support using extended format strings for archive keys\n### Changes\n- [foolfuuka] match 4chan filenames ([#2577](https://github.com/mikf/gallery-dl/issues/2577))\n- [pixiv] implement `include` option\n  - provide `avatar`/`background` downloads as separate extractors ([#2495](https://github.com/mikf/gallery-dl/issues/2495))\n- [twitter] use a better strategy for user URLs\n- [twitter] disable `cards` by default\n- delay directory creation ([#2461](https://github.com/mikf/gallery-dl/issues/2461), [#2474](https://github.com/mikf/gallery-dl/issues/2474))\n- flush writes to stdout/stderr ([#2529](https://github.com/mikf/gallery-dl/issues/2529))\n- build executables on GitHub Actions with Python 3.10\n### Fixes\n- [artstation] use `\"browser\": \"firefox\"` by default ([#2527](https://github.com/mikf/gallery-dl/issues/2527))\n- [imgur] prevent exception with empty albums ([#2557](https://github.com/mikf/gallery-dl/issues/2557))\n- [instagram] report redirects to captcha challenges ([#2543](https://github.com/mikf/gallery-dl/issues/2543))\n- [khinsider] fix metadata extraction ([#2611](https://github.com/mikf/gallery-dl/issues/2611))\n- [mangafox] send Referer headers ([#2592](https://github.com/mikf/gallery-dl/issues/2592))\n- [mangahere] send Referer headers ([#2592](https://github.com/mikf/gallery-dl/issues/2592))\n- [mangasee] use randomly generated PHPSESSID cookie ([#2560](https://github.com/mikf/gallery-dl/issues/2560))\n- [pixiv] make retrieving ugoira metadata non-fatal ([#2562](https://github.com/mikf/gallery-dl/issues/2562))\n- [readcomiconline] update deobfuscation code ([#2481](https://github.com/mikf/gallery-dl/issues/2481))\n- [realbooru] fix extraction ([#2530](https://github.com/mikf/gallery-dl/issues/2530))\n- [vk] handle photos without width/height info ([#2535](https://github.com/mikf/gallery-dl/issues/2535))\n- [vk] fix user ID extraction ([#2535](https://github.com/mikf/gallery-dl/issues/2535))\n- [webtoons] extract real episode numbers ([#2591](https://github.com/mikf/gallery-dl/issues/2591))\n- create missing directories for archive files ([#2597](https://github.com/mikf/gallery-dl/issues/2597))\n- detect circular references with `-K` ([#2609](https://github.com/mikf/gallery-dl/issues/2609))\n- replace \"\\f\" in `--filename` arguments with a form feed character ([#2396](https://github.com/mikf/gallery-dl/issues/2396))\n### Removals\n- [gelbooru_v01] remove tlb.booru.org from supported domains\n\n## 1.21.2 - 2022-04-27\n### Additions\n- [deviantart] implement `pagination` option ([#2488](https://github.com/mikf/gallery-dl/issues/2488))\n- [pixiv] implement `background` option ([#623](https://github.com/mikf/gallery-dl/issues/623), [#1124](https://github.com/mikf/gallery-dl/issues/1124), [#2495](https://github.com/mikf/gallery-dl/issues/2495))\n- [postprocessor:ugoira] report ffmpeg/mkvmerge errors ([#2487](https://github.com/mikf/gallery-dl/issues/2487))\n### Fixes\n- [cyberdrop] match cyberdrop.to URLs ([#2496](https://github.com/mikf/gallery-dl/issues/2496))\n- [e621] fix 403 errors ([#2533](https://github.com/mikf/gallery-dl/issues/2533))\n- [issuu] fix extraction ([#2483](https://github.com/mikf/gallery-dl/issues/2483))\n- [mangadex] download from available chapters despite `externalUrl` ([#2503](https://github.com/mikf/gallery-dl/issues/2503))\n- [photovogue] update domain and api endpoint ([#2494](https://github.com/mikf/gallery-dl/issues/2494))\n- [sexcom] add fallback for empty files ([#2485](https://github.com/mikf/gallery-dl/issues/2485))\n- [twitter] improve syndication video selection ([#2354](https://github.com/mikf/gallery-dl/issues/2354))\n- [twitter] fix various syndication issues ([#2499](https://github.com/mikf/gallery-dl/issues/2499), [#2354](https://github.com/mikf/gallery-dl/issues/2354))\n- [vk] fix extraction ([#2512](https://github.com/mikf/gallery-dl/issues/2512))\n- [weibo] fix infinite retries for deleted accounts ([#2521](https://github.com/mikf/gallery-dl/issues/2521))\n- [postprocessor:ugoira] use compatible paths with mkvmerge ([#2487](https://github.com/mikf/gallery-dl/issues/2487))\n- [postprocessor:ugoira] do not auto-select the `image2` demuxer ([#2492](https://github.com/mikf/gallery-dl/issues/2492))\n\n## 1.21.1 - 2022-04-08\n### Additions\n- [gofile] add gofile.io extractor ([#2364](https://github.com/mikf/gallery-dl/issues/2364))\n- [instagram] add `previews` option ([#2135](https://github.com/mikf/gallery-dl/issues/2135))\n- [kemonoparty] add `duplicates` option ([#2440](https://github.com/mikf/gallery-dl/issues/2440))\n- [pinterest] add extractor for created pins ([#2452](https://github.com/mikf/gallery-dl/issues/2452))\n- [pinterest] support multiple files per pin ([#1619](https://github.com/mikf/gallery-dl/issues/1619), [#2452](https://github.com/mikf/gallery-dl/issues/2452))\n- [telegraph] Add telegra.ph extractor ([#2312](https://github.com/mikf/gallery-dl/issues/2312))\n- [twitter] add `syndication` option ([#2354](https://github.com/mikf/gallery-dl/issues/2354))\n- [twitter] accept fxtwitter.com URLs ([#2484](https://github.com/mikf/gallery-dl/issues/2484))\n- [downloader:http] support using an arbitrary method and sending POST data ([#2433](https://github.com/mikf/gallery-dl/issues/2433))\n- [postprocessor:metadata] implement archive options ([#2421](https://github.com/mikf/gallery-dl/issues/2421))\n- [postprocessor:ugoira] add `mtime` option ([#2307](https://github.com/mikf/gallery-dl/issues/2307))\n- [postprocessor:ugoira] support setting timecodes with `mkvmerge` ([#1550](https://github.com/mikf/gallery-dl/issues/1550))\n- [formatter] support evaluating f-string literals\n- add `--ugoira-conv-copy` command-line option ([#1550](https://github.com/mikf/gallery-dl/issues/1550))\n- implement a `contains()` function for filter statements ([#2446](https://github.com/mikf/gallery-dl/issues/2446))\n### Fixes\n- [aryion] provide correct `date` metadata independent of DST\n- [furaffinity] fix search result pagination ([#2402](https://github.com/mikf/gallery-dl/issues/2402))\n- [hitomi] update and fix metadata extraction ([#2444](https://github.com/mikf/gallery-dl/issues/2444))\n- [kissgoddess] extract all images ([#2473](https://github.com/mikf/gallery-dl/issues/2473))\n- [mangasee] unescape manga names ([#2454](https://github.com/mikf/gallery-dl/issues/2454))\n- [newgrounds] update and fix pagination ([#2456](https://github.com/mikf/gallery-dl/issues/2456))\n- [newgrounds] warn about age-restricted posts ([#2456](https://github.com/mikf/gallery-dl/issues/2456))\n- [pinterest] do not force `m3u8_native` for video downloads ([#2436](https://github.com/mikf/gallery-dl/issues/2436))\n- [twibooru] fix posts without `name` ([#2434](https://github.com/mikf/gallery-dl/issues/2434))\n- [unsplash] replace dash with space in search API queries ([#2429](https://github.com/mikf/gallery-dl/issues/2429))\n- [postprocessor:mtime] fix timestamps from datetime objects ([#2307](https://github.com/mikf/gallery-dl/issues/2307))\n- fix yet another bug in `_check_cookies()` ([#2372](https://github.com/mikf/gallery-dl/issues/2372))\n- fix loading/storing cookies without domain\n\n## 1.21.0 - 2022-03-14\n### Additions\n- [fantia] add `num` enumeration index ([#2377](https://github.com/mikf/gallery-dl/issues/2377))\n- [fantia] support \"Blog Post\" content ([#2381](https://github.com/mikf/gallery-dl/issues/2381))\n- [imagebam] add support for /view/ paths ([#2378](https://github.com/mikf/gallery-dl/issues/2378))\n- [kemonoparty] match beta.kemono.party URLs ([#2348](https://github.com/mikf/gallery-dl/issues/2348))\n- [kissgoddess] add `gallery` and `model` extractors ([#1052](https://github.com/mikf/gallery-dl/issues/1052), [#2304](https://github.com/mikf/gallery-dl/issues/2304))\n- [mememuseum] add `tag` and `post` extractors ([#2264](https://github.com/mikf/gallery-dl/issues/2264))\n- [newgrounds] add `post_url` metadata field ([#2328](https://github.com/mikf/gallery-dl/issues/2328))\n- [patreon] add `image_large` file type ([#2257](https://github.com/mikf/gallery-dl/issues/2257))\n- [toyhouse] support `art` listings ([#1546](https://github.com/mikf/gallery-dl/issues/1546), [#2331](https://github.com/mikf/gallery-dl/issues/2331))\n- [twibooru] add extractors for searches, galleries, and posts ([#2219](https://github.com/mikf/gallery-dl/issues/2219))\n- [postprocessor:metadata] implement `mtime` option ([#2307](https://github.com/mikf/gallery-dl/issues/2307))\n- [postprocessor:mtime] add `event` option ([#2307](https://github.com/mikf/gallery-dl/issues/2307))\n- add fish shell completion ([#2363](https://github.com/mikf/gallery-dl/issues/2363))\n- add `timedelta` class to global namespace in filter expressions\n### Changes\n- [seiga] require authentication with `user_session` cookie ([#2372](https://github.com/mikf/gallery-dl/issues/2372))\n  - remove username & password login due to 2FA\n- refactor proxy support ([#2357](https://github.com/mikf/gallery-dl/issues/2357))\n  - allow gallery-dl proxy settings to overwrite environment proxies\n  - allow specifying different proxies for data extraction and download\n### Fixes\n- [bunkr] fix mp4 downloads ([#2239](https://github.com/mikf/gallery-dl/issues/2239))\n- [fanbox] fetch data for each individual post ([#2388](https://github.com/mikf/gallery-dl/issues/2388))\n- [hentaicosplays] send `Referer` header ([#2317](https://github.com/mikf/gallery-dl/issues/2317))\n- [imagebam] set `nsfw_inter` cookie ([#2334](https://github.com/mikf/gallery-dl/issues/2334))\n- [kemonoparty] limit default filename length ([#2373](https://github.com/mikf/gallery-dl/issues/2373))\n- [mangadex] fix chapters without `translatedLanguage` ([#2352](https://github.com/mikf/gallery-dl/issues/2352))\n- [newgrounds] fix video descriptions ([#2328](https://github.com/mikf/gallery-dl/issues/2328))\n- [skeb] add `sent-requests` option ([#2322](https://github.com/mikf/gallery-dl/issues/2322), [#2330](https://github.com/mikf/gallery-dl/issues/2330))\n- [slideshare] fix extraction\n- [subscribestar] unescape attachment URLs ([#2370](https://github.com/mikf/gallery-dl/issues/2370))\n- [twitter] fix handling of 429 Too Many Requests responses ([#2339](https://github.com/mikf/gallery-dl/issues/2339))\n- [twitter] warn about age-restricted Tweets ([#2354](https://github.com/mikf/gallery-dl/issues/2354))\n- [twitter] handle Tweets with \"softIntervention\" entries\n- [twitter] update query hashes\n- fix another bug in `_check_cookies()` ([#2160](https://github.com/mikf/gallery-dl/issues/2160))\n\n## 1.20.5 - 2022-02-14\n### Additions\n- [furaffinity] add `layout` option ([#2277](https://github.com/mikf/gallery-dl/issues/2277))\n- [lightroom] add Lightroom gallery extractor ([#2263](https://github.com/mikf/gallery-dl/issues/2263))\n- [reddit] support standalone submissions on personal user pages ([#2301](https://github.com/mikf/gallery-dl/issues/2301))\n- [redgifs] support i.redgifs.com URLs ([#2300](https://github.com/mikf/gallery-dl/issues/2300))\n- [wallpapercave] add extractor for images and search results ([#2205](https://github.com/mikf/gallery-dl/issues/2205))\n- add `signals-ignore` option ([#2296](https://github.com/mikf/gallery-dl/issues/2296))\n### Changes\n- [danbooru] merge `danbooru` and `e621` extractors\n  - support `atfbooru` ([#2283](https://github.com/mikf/gallery-dl/issues/2283))\n  - remove support for old e621 tag search URLs\n### Fixes\n- [furaffinity] improve new/old layout detection ([#2277](https://github.com/mikf/gallery-dl/issues/2277))\n- [imgbox] fix ImgboxExtractor ([#2281](https://github.com/mikf/gallery-dl/issues/2281))\n- [inkbunny] rename search parameters to their API equivalents\n- [kemonoparty] handle files without names ([#2276](https://github.com/mikf/gallery-dl/issues/2276))\n- [twitter] fix extraction ([#2275](https://github.com/mikf/gallery-dl/issues/2275), [#2295](https://github.com/mikf/gallery-dl/issues/2295))\n- [vk] fix infinite pagination loops ([#2297](https://github.com/mikf/gallery-dl/issues/2297))\n- [downloader:ytdl] make `ImportError`s non-fatal ([#2273](https://github.com/mikf/gallery-dl/issues/2273))\n\n## 1.20.4 - 2022-02-06\n### Additions\n- [e621] add `favorite` extractor ([#2250](https://github.com/mikf/gallery-dl/issues/2250))\n- [hitomi] add `format` option ([#2260](https://github.com/mikf/gallery-dl/issues/2260))\n- [kohlchan] add Kohlchan extractors ([#2251](https://github.com/mikf/gallery-dl/issues/2251))\n- [sexcom] add `pins` extractor ([#2265](https://github.com/mikf/gallery-dl/issues/2265))\n- [twitter] add `warnings` option ([#2258](https://github.com/mikf/gallery-dl/issues/2258))\n- add ability to disable TLS 1.2 ([#2243](https://github.com/mikf/gallery-dl/issues/2243))\n- add examples for custom gelbooru instances ([#2262](https://github.com/mikf/gallery-dl/issues/2262))\n### Fixes\n- [bunkr] fix mp4 downloads ([#2239](https://github.com/mikf/gallery-dl/issues/2239))\n- [gelbooru] improve and fix pagination ([#2230](https://github.com/mikf/gallery-dl/issues/2230), [#2232](https://github.com/mikf/gallery-dl/issues/2232))\n- [hitomi] \"fix\" 403 errors ([#2260](https://github.com/mikf/gallery-dl/issues/2260))\n- [kemonoparty] fix downloading smaller text files ([#2267](https://github.com/mikf/gallery-dl/issues/2267))\n- [patreon] disable TLS 1.2 by default ([#2249](https://github.com/mikf/gallery-dl/issues/2249))\n- [twitter] restore errors for protected timelines etc ([#2237](https://github.com/mikf/gallery-dl/issues/2237))\n- [twitter] restore `logout` functionality ([#1719](https://github.com/mikf/gallery-dl/issues/1719))\n- [twitter] provide fallback URLs for card images\n- [weibo] update pagination code ([#2244](https://github.com/mikf/gallery-dl/issues/2244))\n\n## 1.20.3 - 2022-01-26\n### Fixes\n- [kemonoparty] fix DMs extraction ([#2008](https://github.com/mikf/gallery-dl/issues/2008))\n- [twitter] fix crash on Tweets with deleted quotes ([#2225](https://github.com/mikf/gallery-dl/issues/2225))\n- [twitter] fix crash on suspended Tweets without `legacy` entry ([#2216](https://github.com/mikf/gallery-dl/issues/2216))\n- [twitter] fix crash on unified cards without `type`\n- [twitter] prevent crash on invalid/deleted Retweets ([#2225](https://github.com/mikf/gallery-dl/issues/2225))\n- [twitter] update query hashes\n\n## 1.20.2 - 2022-01-24\n### Additions\n- [twitter] add `event` extractor (closes [#2109](https://github.com/mikf/gallery-dl/issues/2109))\n- [twitter] support image_carousel_website unified cards\n- add `--source-address` command-line option ([#2206](https://github.com/mikf/gallery-dl/issues/2206))\n- add environment variable syntax to formatting.md ([#2065](https://github.com/mikf/gallery-dl/issues/2065))\n### Changes\n- [twitter] changes to `cards` option\n  - enable `cards` by default\n  - require `cards` to be set to `\"ytdl\"` to invoke youtube-dl/yt-dlp on unsupported cards\n### Fixes\n- [blogger] support new image domain ([#2204](https://github.com/mikf/gallery-dl/issues/2204))\n- [gelbooru] improve video file detection ([#2188](https://github.com/mikf/gallery-dl/issues/2188))\n- [hitomi] fix `tag` extraction ([#2189](https://github.com/mikf/gallery-dl/issues/2189))\n- [instagram] fix highlights extraction ([#2197](https://github.com/mikf/gallery-dl/issues/2197))\n- [mangadex] re-enable warning for external chapters ([#2193](https://github.com/mikf/gallery-dl/issues/2193))\n- [newgrounds] set suitabilities filter before starting a search ([#2173](https://github.com/mikf/gallery-dl/issues/2173))\n- [philomena] fix search parameter escaping ([#2215](https://github.com/mikf/gallery-dl/issues/2215))\n- [reddit] allow downloading from quarantined subreddits ([#2180](https://github.com/mikf/gallery-dl/issues/2180))\n- [sexcom] extend URL pattern ([#2220](https://github.com/mikf/gallery-dl/issues/2220))\n- [twitter] update to GraphQL API ([#2212](https://github.com/mikf/gallery-dl/issues/2212))\n\n## 1.20.1 - 2022-01-08\n### Additions\n- [newgrounds] add `search` extractor ([#2161](https://github.com/mikf/gallery-dl/issues/2161))\n### Changes\n- restore `-d/--dest` functionality from before 1.20.0 ([#2148](https://github.com/mikf/gallery-dl/issues/2148))\n- change short option for `--directory` to `-D`\n### Fixes\n- [gelbooru] handle changed API response format ([#2157](https://github.com/mikf/gallery-dl/issues/2157))\n- [hitomi] fix image URLs ([#2153](https://github.com/mikf/gallery-dl/issues/2153))\n- [mangadex] fix extraction ([#2177](https://github.com/mikf/gallery-dl/issues/2177))\n- [rule34] use `https://api.rule34.xxx` for API requests\n- fix cookie checks for patreon, fanbox, fantia\n- improve UNC path handling ([#2126](https://github.com/mikf/gallery-dl/issues/2126))\n\n## 1.20.0 - 2021-12-29\n### Additions\n- [500px] add `favorite` extractor ([#1927](https://github.com/mikf/gallery-dl/issues/1927))\n- [exhentai] add `source` option\n- [fanbox] support pixiv redirects ([#2122](https://github.com/mikf/gallery-dl/issues/2122))\n- [inkbunny] add `search` extractor ([#2094](https://github.com/mikf/gallery-dl/issues/2094))\n- [kemonoparty] support coomer.party ([#2100](https://github.com/mikf/gallery-dl/issues/2100))\n- [lolisafe] add generic album extractor for lolisafe/chibisafe instances ([#2038](https://github.com/mikf/gallery-dl/issues/2038), [#2105](https://github.com/mikf/gallery-dl/issues/2105))\n- [rule34us] add `tag` and `post` extractors ([#1527](https://github.com/mikf/gallery-dl/issues/1527))\n- add a generic extractor ([#735](https://github.com/mikf/gallery-dl/issues/735), [#683](https://github.com/mikf/gallery-dl/issues/683))\n- add `-d/--directory` and `-f/--filename` command-line options\n- add `--sleep-request` and `--sleep-extractor` command-line options\n- allow specifying `sleep-*` options as string\n### Changes\n- [cyberdrop] include file ID in default filenames\n- [hitomi] disable `metadata` by default\n- [kemonoparty] use `service` as subcategory ([#2147](https://github.com/mikf/gallery-dl/issues/2147))\n- [kemonoparty] change default `files` order to `attachments,file,inline` ([#1991](https://github.com/mikf/gallery-dl/issues/1991))\n- [output] write download progress indicator to stderr\n- [ytdl] prefer yt-dlp over youtube-dl ([#1850](https://github.com/mikf/gallery-dl/issues/1850), [#2028](https://github.com/mikf/gallery-dl/issues/2028))\n- rename `--write-infojson` to `--write-info-json`\n### Fixes\n- [500px] create directories per photo\n- [artstation] create directories per asset ([#2136](https://github.com/mikf/gallery-dl/issues/2136))\n- [deviantart] use `/browse/newest` for most-recent searches ([#2096](https://github.com/mikf/gallery-dl/issues/2096))\n- [hitomi] fix image URLs\n- [instagram] fix error when PostPage data is not in GraphQL format ([#2037](https://github.com/mikf/gallery-dl/issues/2037))\n- [instagran] match post URLs with usernames ([#2085](https://github.com/mikf/gallery-dl/issues/2085))\n- [instagram] allow downloading specific stories ([#2088](https://github.com/mikf/gallery-dl/issues/2088))\n- [furaffinity] warn when no session cookies were found\n- [pixiv] respect date ranges in search URLs ([#2133](https://github.com/mikf/gallery-dl/issues/2133))\n- [sexcom] fix and improve embed extraction ([#2145](https://github.com/mikf/gallery-dl/issues/2145))\n- [tumblrgallery] fix extraction ([#2112](https://github.com/mikf/gallery-dl/issues/2112))\n- [tumblrgallery] improve `id` extraction ([#2115](https://github.com/mikf/gallery-dl/issues/2115))\n- [tumblrgallery] improve search pagination ([#2132](https://github.com/mikf/gallery-dl/issues/2132))\n- [twitter] include `4096x4096` as a default image fallback ([#1881](https://github.com/mikf/gallery-dl/issues/1881), [#2107](https://github.com/mikf/gallery-dl/issues/2107))\n- [ytdl] update argument parsing to latest yt-dlp changes ([#2124](https://github.com/mikf/gallery-dl/issues/2124))\n- handle UNC paths ([#2113](https://github.com/mikf/gallery-dl/issues/2113))\n\n## 1.19.3 - 2021-11-27\n### Additions\n- [dynastyscans] add `manga` extractor ([#2035](https://github.com/mikf/gallery-dl/issues/2035))\n- [instagram] include user metadata for `tagged` downloads ([#2024](https://github.com/mikf/gallery-dl/issues/2024))\n- [kemonoparty] implement `files` option ([#1991](https://github.com/mikf/gallery-dl/issues/1991))\n- [kemonoparty] add `dms` option ([#2008](https://github.com/mikf/gallery-dl/issues/2008))\n- [mangadex] always provide `artist`, `author`, and `group` metadata fields ([#2049](https://github.com/mikf/gallery-dl/issues/2049))\n- [philomena] support furbooru.org ([#1995](https://github.com/mikf/gallery-dl/issues/1995))\n- [reactor] support thatpervert.com ([#2029](https://github.com/mikf/gallery-dl/issues/2029))\n- [shopify] support loungeunderwear.com ([#2053](https://github.com/mikf/gallery-dl/issues/2053))\n- [skeb] add `thumbnails` option ([#2047](https://github.com/mikf/gallery-dl/issues/2047), [#2051](https://github.com/mikf/gallery-dl/issues/2051))\n- [subscribestar] add `num` enumeration index ([#2040](https://github.com/mikf/gallery-dl/issues/2040))\n- [subscribestar] emit metadata for posts without media ([#1569](https://github.com/mikf/gallery-dl/issues/1569))\n- [ytdl] implement `cmdline-args` and `config-file` options to allow parsing ytdl command-line options ([#1680](https://github.com/mikf/gallery-dl/issues/1680))\n- [formatter] implement `D` format specifier\n- extend `blacklist`/`whitelist` syntax ([#2025](https://github.com/mikf/gallery-dl/issues/2025))\n### Fixes\n- [dynastyscans] provide `date` as datetime object ([#2050](https://github.com/mikf/gallery-dl/issues/2050))\n- [exhentai] fix extraction for disowned galleries ([#2055](https://github.com/mikf/gallery-dl/issues/2055))\n- [gelbooru] apply workaround for pagination limits\n- [kemonoparty] skip duplicate files ([#2032](https://github.com/mikf/gallery-dl/issues/2032), [#1991](https://github.com/mikf/gallery-dl/issues/1991), [#1899](https://github.com/mikf/gallery-dl/issues/1899))\n- [kemonoparty] provide `date` metadata for gumroad ([#2007](https://github.com/mikf/gallery-dl/issues/2007))\n- [mangoxo] fix metadata extraction\n- [twitter] distinguish between fatal & nonfatal errors ([#2020](https://github.com/mikf/gallery-dl/issues/2020))\n- [twitter] fix extractor for direct image links ([#2030](https://github.com/mikf/gallery-dl/issues/2030))\n- [webtoons] use download URLs that do not require a `Referer` header ([#2005](https://github.com/mikf/gallery-dl/issues/2005))\n- [ytdl] improve error handling ([#1680](https://github.com/mikf/gallery-dl/issues/1680))\n- [downloader:ytdl] prevent crash in `_progress_hook()` ([#1680](https://github.com/mikf/gallery-dl/issues/1680))\n### Removals\n- [seisoparty] remove module\n\n## 1.19.2 - 2021-11-05\n### Additions\n- [kemonoparty] add `comments` option ([#1980](https://github.com/mikf/gallery-dl/issues/1980))\n- [skeb] add `user` and `post` extractors ([#1031](https://github.com/mikf/gallery-dl/issues/1031), [#1971](https://github.com/mikf/gallery-dl/issues/1971))\n- [twitter] add `pinned` option\n- support accessing environment variables and the current local datetime in format strings ([#1968](https://github.com/mikf/gallery-dl/issues/1968))\n- add special type format strings to docs ([#1987](https://github.com/mikf/gallery-dl/issues/1987))\n### Fixes\n- [cyberdrop] fix video extraction ([#1993](https://github.com/mikf/gallery-dl/issues/1993))\n- [deviantart] fix `index` values for stashed deviations\n- [gfycat] provide consistent `userName` values for `user` downloads ([#1962](https://github.com/mikf/gallery-dl/issues/1962))\n- [gfycat] show warning when there are no available formats\n- [hitomi] fix image URLs ([#1975](https://github.com/mikf/gallery-dl/issues/1975), [#1982](https://github.com/mikf/gallery-dl/issues/1982), [#1988](https://github.com/mikf/gallery-dl/issues/1988))\n- [instagram] update query hashes\n- [mangakakalot] update domain and fix extraction\n- [mangoxo] fix login and extraction\n- [reddit] prevent crash for galleries with no `media_metadata` ([#2001](https://github.com/mikf/gallery-dl/issues/2001))\n- [redgifs] update to API v2 ([#1984](https://github.com/mikf/gallery-dl/issues/1984))\n- fix calculating retry sleep times ([#1990](https://github.com/mikf/gallery-dl/issues/1990))\n\n## 1.19.1 - 2021-10-24\n### Additions\n- [inkbunny] add `following` extractor ([#515](https://github.com/mikf/gallery-dl/issues/515))\n- [inkbunny] add `pool` extractor ([#1937](https://github.com/mikf/gallery-dl/issues/1937))\n- [kemonoparty] add `discord` extractor ([#1827](https://github.com/mikf/gallery-dl/issues/1827), [#1940](https://github.com/mikf/gallery-dl/issues/1940))\n- [nhentai] add `tag` extractor ([#1950](https://github.com/mikf/gallery-dl/issues/1950), [#1955](https://github.com/mikf/gallery-dl/issues/1955))\n- [patreon] add `files` option ([#1935](https://github.com/mikf/gallery-dl/issues/1935))\n- [picarto] add `gallery` extractor ([#1931](https://github.com/mikf/gallery-dl/issues/1931))\n- [pixiv] add `sketch` extractor ([#1497](https://github.com/mikf/gallery-dl/issues/1497))\n- [seisoparty] add `favorite` extractor ([#1906](https://github.com/mikf/gallery-dl/issues/1906))\n- [twitter] add `size` option ([#1881](https://github.com/mikf/gallery-dl/issues/1881))\n- [vk] add `album` extractor ([#474](https://github.com/mikf/gallery-dl/issues/474), [#1952](https://github.com/mikf/gallery-dl/issues/1952))\n- [postprocessor:compare] add `equal` option ([#1592](https://github.com/mikf/gallery-dl/issues/1592))\n### Fixes\n- [cyberdrop] extract direct download URLs ([#1943](https://github.com/mikf/gallery-dl/issues/1943))\n- [deviantart] update `search` argument handling ([#1911](https://github.com/mikf/gallery-dl/issues/1911))\n- [deviantart] full resolution for non-downloadable images ([#293](https://github.com/mikf/gallery-dl/issues/293))\n- [furaffinity] unquote search queries ([#1958](https://github.com/mikf/gallery-dl/issues/1958))\n- [inkbunny] match \"long\" URLs for pools and favorites ([#1937](https://github.com/mikf/gallery-dl/issues/1937))\n- [kemonoparty] improve inline extraction ([#1899](https://github.com/mikf/gallery-dl/issues/1899))\n- [mangadex] update parameter handling for API requests ([#1908](https://github.com/mikf/gallery-dl/issues/1908))\n- [patreon] better filenames for `content` images ([#1954](https://github.com/mikf/gallery-dl/issues/1954))\n- [redgifs][gfycat] provide fallback URLs ([#1962](https://github.com/mikf/gallery-dl/issues/1962))\n- [downloader:ytdl] prevent crash in `_progress_hook()`\n- restore SOCKS support for Windows executables\n\n## 1.19.0 - 2021-10-01\n### Additions\n- [aryion] add `tag` extractor ([#1849](https://github.com/mikf/gallery-dl/issues/1849))\n- [desktopography] implement desktopography extractors ([#1740](https://github.com/mikf/gallery-dl/issues/1740))\n- [deviantart] implement `auto-unwatch` option ([#1466](https://github.com/mikf/gallery-dl/issues/1466), [#1757](https://github.com/mikf/gallery-dl/issues/1757))\n- [fantia] add `date` metadata field ([#1853](https://github.com/mikf/gallery-dl/issues/1853))\n- [fappic] add `image` extractor ([#1898](https://github.com/mikf/gallery-dl/issues/1898))\n- [gelbooru_v02] add `favorite` extractor ([#1834](https://github.com/mikf/gallery-dl/issues/1834))\n- [kemonoparty] add `favorite` extractor ([#1824](https://github.com/mikf/gallery-dl/issues/1824))\n- [kemonoparty] implement login with username & password ([#1824](https://github.com/mikf/gallery-dl/issues/1824))\n- [mastodon] add `following` extractor ([#1891](https://github.com/mikf/gallery-dl/issues/1891))\n- [mastodon] support specifying accounts by ID\n- [twitter] support `/with_replies` URLs ([#1833](https://github.com/mikf/gallery-dl/issues/1833))\n- [twitter] add `quote_by` metadata field ([#1481](https://github.com/mikf/gallery-dl/issues/1481))\n- [postprocessor:compare] extend `action` option ([#1592](https://github.com/mikf/gallery-dl/issues/1592))\n- implement a download progress indicator ([#1519](https://github.com/mikf/gallery-dl/issues/1519))\n- implement a `page-reverse` option ([#1854](https://github.com/mikf/gallery-dl/issues/1854))\n- implement a way to specify extended format strings\n- allow specifying a minimum/maximum for `sleep-*` options ([#1835](https://github.com/mikf/gallery-dl/issues/1835))\n- add a `--write-infojson` command-line option\n### Changes\n- [cyberdrop] change directory name format ([#1871](https://github.com/mikf/gallery-dl/issues/1871))\n- [instagram] update default delay to 6-12 seconds ([#1835](https://github.com/mikf/gallery-dl/issues/1835))\n- [reddit] extend subcategory depending on input URL ([#1836](https://github.com/mikf/gallery-dl/issues/1836))\n- move util.Formatter and util.PathFormat into their own modules\n### Fixes\n- [artstation] use `/album/all` view for user portfolios ([#1826](https://github.com/mikf/gallery-dl/issues/1826))\n- [aryion] update/improve pagination ([#1849](https://github.com/mikf/gallery-dl/issues/1849))\n- [deviantart] fix bug with fetching premium content ([#1879](https://github.com/mikf/gallery-dl/issues/1879))\n- [deviantart] update default archive_fmt for single deviations ([#1874](https://github.com/mikf/gallery-dl/issues/1874))\n- [erome] send Referer header for file downloads ([#1829](https://github.com/mikf/gallery-dl/issues/1829))\n- [hiperdex] fix extraction\n- [kemonoparty] update file download URLs ([#1902](https://github.com/mikf/gallery-dl/issues/1902), [#1903](https://github.com/mikf/gallery-dl/issues/1903))\n- [mangadex] fix extraction ([#1852](https://github.com/mikf/gallery-dl/issues/1852))\n- [mangadex] fix retrieving chapters from \"pornographic\" titles ([#1908](https://github.com/mikf/gallery-dl/issues/1908))\n- [nozomi] preserve case of search tags ([#1860](https://github.com/mikf/gallery-dl/issues/1860))\n- [redgifs][gfycat] remove webtoken code ([#1907](https://github.com/mikf/gallery-dl/issues/1907))\n- [twitter] ensure card entries have a `url` ([#1868](https://github.com/mikf/gallery-dl/issues/1868))\n- implement a way to correctly shorten displayed filenames containing east-asian characters ([#1377](https://github.com/mikf/gallery-dl/issues/1377))\n\n## 1.18.4 - 2021-09-04\n### Additions\n- [420chan] add `thread` and `board` extractors ([#1773](https://github.com/mikf/gallery-dl/issues/1773))\n- [deviantart] add `tag` extractor ([#1803](https://github.com/mikf/gallery-dl/issues/1803))\n- [deviantart] add `comments` option ([#1800](https://github.com/mikf/gallery-dl/issues/1800))\n- [deviantart] implement a `auto-watch` option ([#1466](https://github.com/mikf/gallery-dl/issues/1466), [#1757](https://github.com/mikf/gallery-dl/issues/1757))\n- [foolfuuka] add `gallery` extractor ([#1785](https://github.com/mikf/gallery-dl/issues/1785))\n- [furaffinity] expand URL pattern for searches ([#1780](https://github.com/mikf/gallery-dl/issues/1780))\n- [kemonoparty] automatically generate required DDoS-GUARD cookies ([#1779](https://github.com/mikf/gallery-dl/issues/1779))\n- [nhentai] add `favorite` extractor ([#1814](https://github.com/mikf/gallery-dl/issues/1814))\n- [shopify] support windsorstore.com ([#1793](https://github.com/mikf/gallery-dl/issues/1793))\n- [twitter] add `url` to user objects ([#1787](https://github.com/mikf/gallery-dl/issues/1787), [#1532](https://github.com/mikf/gallery-dl/issues/1532))\n- [twitter] expand t.co links in user descriptions ([#1787](https://github.com/mikf/gallery-dl/issues/1787), [#1532](https://github.com/mikf/gallery-dl/issues/1532))\n- show a warning if an extractor doesn`t yield any results ([#1428](https://github.com/mikf/gallery-dl/issues/1428), [#1759](https://github.com/mikf/gallery-dl/issues/1759))\n- add a `j` format string conversion\n- implement a `fallback` option ([#1770](https://github.com/mikf/gallery-dl/issues/1770))\n- implement a `path-strip` option\n### Changes\n- [shopify] use API for product listings ([#1793](https://github.com/mikf/gallery-dl/issues/1793))\n- update default User-Agent headers\n### Fixes\n- [deviantart] prevent exceptions for \"empty\" videos ([#1796](https://github.com/mikf/gallery-dl/issues/1796))\n- [exhentai] improve image limits check ([#1808](https://github.com/mikf/gallery-dl/issues/1808))\n- [inkbunny] fix extraction ([#1816](https://github.com/mikf/gallery-dl/issues/1816))\n- [mangadex] prevent exceptions for manga without English title ([#1815](https://github.com/mikf/gallery-dl/issues/1815))\n- [oauth] use defaults when config values are set to `null` ([#1778](https://github.com/mikf/gallery-dl/issues/1778))\n- [pixiv] fix pixivision title extraction\n- [reddit] delay RedditAPI initialization ([#1813](https://github.com/mikf/gallery-dl/issues/1813))\n- [twitter] improve error reporting ([#1759](https://github.com/mikf/gallery-dl/issues/1759))\n- [twitter] fix issue when filtering quote tweets ([#1792](https://github.com/mikf/gallery-dl/issues/1792))\n- [twitter] fix `logout` option ([#1719](https://github.com/mikf/gallery-dl/issues/1719))\n### Removals\n- [deviantart] remove the \"you need session cookies to download mature scraps\" warning ([#1777](https://github.com/mikf/gallery-dl/issues/1777), [#1776](https://github.com/mikf/gallery-dl/issues/1776))\n- [foolslide] remove entry for kobato.hologfx.com\n\n## 1.18.3 - 2021-08-13\n### Additions\n- [bbc] add `width` option ([#1706](https://github.com/mikf/gallery-dl/issues/1706))\n- [danbooru] add `external` option ([#1747](https://github.com/mikf/gallery-dl/issues/1747))\n- [furaffinity] add `external` option ([#1492](https://github.com/mikf/gallery-dl/issues/1492))\n- [luscious] add `gif` option ([#1701](https://github.com/mikf/gallery-dl/issues/1701))\n- [newgrounds] add `format` option ([#1729](https://github.com/mikf/gallery-dl/issues/1729))\n- [reactor] add `gif` option ([#1701](https://github.com/mikf/gallery-dl/issues/1701))\n- [twitter] warn about suspended accounts ([#1759](https://github.com/mikf/gallery-dl/issues/1759))\n- [twitter] extend `replies` option ([#1254](https://github.com/mikf/gallery-dl/issues/1254))\n- [twitter] add option to log out and retry when blocked ([#1719](https://github.com/mikf/gallery-dl/issues/1719))\n- [wikieat] add `thread` and `board` extractors ([#1699](https://github.com/mikf/gallery-dl/issues/1699), [#1607](https://github.com/mikf/gallery-dl/issues/1607))\n### Changes\n- [instagram] increase default delay between HTTP requests from 5s to 8s ([#1732](https://github.com/mikf/gallery-dl/issues/1732))\n### Fixes\n- [bbc] improve image dimensions ([#1706](https://github.com/mikf/gallery-dl/issues/1706))\n- [bbc] support multi-page gallery listings ([#1730](https://github.com/mikf/gallery-dl/issues/1730))\n- [behance] fix `collection` extraction\n- [deviantart] get original files for GIF previews ([#1731](https://github.com/mikf/gallery-dl/issues/1731))\n- [furaffinity] fix errors when using `category-transfer` ([#1274](https://github.com/mikf/gallery-dl/issues/1274))\n- [hitomi] fix image URLs ([#1765](https://github.com/mikf/gallery-dl/issues/1765))\n- [instagram] use custom User-Agent header for video downloads ([#1682](https://github.com/mikf/gallery-dl/issues/1682), [#1623](https://github.com/mikf/gallery-dl/issues/1623), [#1580](https://github.com/mikf/gallery-dl/issues/1580))\n- [kemonoparty] fix username extraction ([#1750](https://github.com/mikf/gallery-dl/issues/1750))\n- [kemonoparty] update file server domain ([#1764](https://github.com/mikf/gallery-dl/issues/1764))\n- [newgrounds] fix errors when using `category-transfer` ([#1274](https://github.com/mikf/gallery-dl/issues/1274))\n- [nsfwalbum] retry backend requests when extracting image URLs ([#1733](https://github.com/mikf/gallery-dl/issues/1733), [#1271](https://github.com/mikf/gallery-dl/issues/1271))\n- [vk] prevent exception for empty/private profiles ([#1742](https://github.com/mikf/gallery-dl/issues/1742))\n\n## 1.18.2 - 2021-07-23\n### Additions\n- [bbc] add `gallery` and `programme` extractors ([#1706](https://github.com/mikf/gallery-dl/issues/1706))\n- [comicvine] add extractor ([#1712](https://github.com/mikf/gallery-dl/issues/1712))\n- [kemonoparty] add `max-posts` option ([#1674](https://github.com/mikf/gallery-dl/issues/1674))\n- [kemonoparty] parse `o` query parameters ([#1674](https://github.com/mikf/gallery-dl/issues/1674))\n- [mastodon] add `reblogs` and `replies` options ([#1669](https://github.com/mikf/gallery-dl/issues/1669))\n- [pixiv] add extractor for `pixivision` articles ([#1672](https://github.com/mikf/gallery-dl/issues/1672))\n- [ytdl] add experimental extractor for sites supported by youtube-dl ([#1680](https://github.com/mikf/gallery-dl/issues/1680), [#878](https://github.com/mikf/gallery-dl/issues/878))\n- extend `parent-metadata` functionality ([#1687](https://github.com/mikf/gallery-dl/issues/1687), [#1651](https://github.com/mikf/gallery-dl/issues/1651), [#1364](https://github.com/mikf/gallery-dl/issues/1364))\n- add `archive-prefix` option ([#1711](https://github.com/mikf/gallery-dl/issues/1711))\n- add `url-metadata` option ([#1659](https://github.com/mikf/gallery-dl/issues/1659), [#1073](https://github.com/mikf/gallery-dl/issues/1073))\n### Changes\n- [kemonoparty] skip duplicated patreon files ([#1689](https://github.com/mikf/gallery-dl/issues/1689), [#1667](https://github.com/mikf/gallery-dl/issues/1667))\n- [mangadex] use custom User-Agent header ([#1535](https://github.com/mikf/gallery-dl/issues/1535))\n### Fixes\n- [hitomi] fix image URLs ([#1679](https://github.com/mikf/gallery-dl/issues/1679))\n- [imagevenue] fix extraction ([#1677](https://github.com/mikf/gallery-dl/issues/1677))\n- [instagram] fix extraction of `/explore/tags/` posts ([#1666](https://github.com/mikf/gallery-dl/issues/1666))\n- [moebooru] fix `tags` ending with a `+` when logged in ([#1702](https://github.com/mikf/gallery-dl/issues/1702))\n- [naverwebtoon] fix comic extraction\n- [pururin] update domain and fix extraction\n- [vk] improve metadata extraction and URL pattern ([#1691](https://github.com/mikf/gallery-dl/issues/1691))\n- [downloader:ytdl] fix `outtmpl` setting for yt-dlp ([#1680](https://github.com/mikf/gallery-dl/issues/1680))\n\n## 1.18.1 - 2021-07-04\n### Additions\n- [mangafox] add `manga` extractor ([#1633](https://github.com/mikf/gallery-dl/issues/1633))\n- [mangasee] add `chapter` and `manga` extractors\n- [mastodon] implement `text-posts` option ([#1569](https://github.com/mikf/gallery-dl/issues/1569), [#1669](https://github.com/mikf/gallery-dl/issues/1669))\n- [seisoparty] add `user` and `post` extractors ([#1635](https://github.com/mikf/gallery-dl/issues/1635))\n- implement conditional directories ([#1394](https://github.com/mikf/gallery-dl/issues/1394))\n- add `T` format string conversion ([#1646](https://github.com/mikf/gallery-dl/issues/1646))\n- document format string syntax\n### Changes\n- [twitter] set `retweet_id` for original retweets ([#1481](https://github.com/mikf/gallery-dl/issues/1481))\n### Fixes\n- [directlink] manually encode Referer URLs ([#1647](https://github.com/mikf/gallery-dl/issues/1647))\n- [hiperdex] use domain from input URL\n- [kemonoparty] fix `username` extraction ([#1652](https://github.com/mikf/gallery-dl/issues/1652))\n- [kemonoparty] warn about missing DDoS-GUARD cookies\n- [twitter] ensure guest tokens are returned as string ([#1665](https://github.com/mikf/gallery-dl/issues/1665))\n- [webtoons] match arbitrary language codes ([#1643](https://github.com/mikf/gallery-dl/issues/1643))\n- fix depth counter in UrlJob when specifying `-g` multiple times\n\n## 1.18.0 - 2021-06-19\n### Additions\n- [foolfuuka] support `archive.wakarimasen.moe` ([#1595](https://github.com/mikf/gallery-dl/issues/1595))\n- [mangadex] implement login with username & password ([#1535](https://github.com/mikf/gallery-dl/issues/1535))\n- [mangadex] add extractor for a user's followed feed ([#1535](https://github.com/mikf/gallery-dl/issues/1535))\n- [pixiv] support fetching privately followed users ([#1628](https://github.com/mikf/gallery-dl/issues/1628))\n- implement conditional filenames ([#1394](https://github.com/mikf/gallery-dl/issues/1394))\n- implement `filter` option for post processors ([#1460](https://github.com/mikf/gallery-dl/issues/1460))\n- add `-T/--terminate` command-line option ([#1399](https://github.com/mikf/gallery-dl/issues/1399))\n- add `-P/--postprocessor` command-line option ([#1583](https://github.com/mikf/gallery-dl/issues/1583))\n### Changes\n- [kemonoparty] update default filenames and archive IDs ([#1514](https://github.com/mikf/gallery-dl/issues/1514))\n- [twitter] update default settings\n  - change `retweets` and `quoted` options from `true` to `false`\n  - change directory format for search results to the same as other extractors\n- require an argument for `--clear-cache`\n### Fixes\n- [500px] update GraphQL queries\n- [furaffinity] improve metadata extraction ([#1630](https://github.com/mikf/gallery-dl/issues/1630))\n- [hitomi] update image URL generation ([#1637](https://github.com/mikf/gallery-dl/issues/1637))\n- [idolcomplex] improve and fix pagination ([#1594](https://github.com/mikf/gallery-dl/issues/1594), [#1601](https://github.com/mikf/gallery-dl/issues/1601))\n- [instagram] fix login ([#1631](https://github.com/mikf/gallery-dl/issues/1631))\n- [instagram] update query hashes\n- [mangadex] update to API v5 ([#1535](https://github.com/mikf/gallery-dl/issues/1535))\n- [mangafox] improve URL pattern ([#1608](https://github.com/mikf/gallery-dl/issues/1608))\n- [oauth] prevent exceptions when reporting errors ([#1603](https://github.com/mikf/gallery-dl/issues/1603))\n- [philomena] fix tag escapes handling ([#1629](https://github.com/mikf/gallery-dl/issues/1629))\n- [redgifs] update API server address ([#1632](https://github.com/mikf/gallery-dl/issues/1632))\n- [sankaku] handle empty tags ([#1617](https://github.com/mikf/gallery-dl/issues/1617))\n- [subscribestar] improve attachment filenames ([#1609](https://github.com/mikf/gallery-dl/issues/1609))\n- [unsplash] update collections URL pattern ([#1627](https://github.com/mikf/gallery-dl/issues/1627))\n- [postprocessor:metadata] handle dicts in `mode:tags` ([#1598](https://github.com/mikf/gallery-dl/issues/1598))\n\n## 1.17.5 - 2021-05-30\n### Additions\n- [kemonoparty] add `metadata` option ([#1548](https://github.com/mikf/gallery-dl/issues/1548))\n- [kemonoparty] add `type` metadata field ([#1556](https://github.com/mikf/gallery-dl/issues/1556))\n- [mangapark] recognize v2.mangapark URLs ([#1578](https://github.com/mikf/gallery-dl/issues/1578))\n- [patreon] extract user-defined `tags` ([#1539](https://github.com/mikf/gallery-dl/issues/1539), [#1540](https://github.com/mikf/gallery-dl/issues/1540))\n- [pillowfort] implement login with username & password ([#846](https://github.com/mikf/gallery-dl/issues/846))\n- [pillowfort] add `inline` and `external` options ([#846](https://github.com/mikf/gallery-dl/issues/846))\n- [pixiv] implement `max-posts` option ([#1558](https://github.com/mikf/gallery-dl/issues/1558))\n- [pixiv] add `metadata` option ([#1551](https://github.com/mikf/gallery-dl/issues/1551))\n- [twitter] add `text-tweets` option ([#570](https://github.com/mikf/gallery-dl/issues/570))\n- [weibo] extend `retweets` option ([#1542](https://github.com/mikf/gallery-dl/issues/1542))\n- [postprocessor:ugoira] support using the `image2` demuxer ([#1550](https://github.com/mikf/gallery-dl/issues/1550))\n- [postprocessor:ugoira] add `repeat-last-frame` option ([#1550](https://github.com/mikf/gallery-dl/issues/1550))\n- support `XDG_CONFIG_HOME` ([#1545](https://github.com/mikf/gallery-dl/issues/1545))\n- implement `parent-skip` and `\"skip\": \"terminate\"` options ([#1399](https://github.com/mikf/gallery-dl/issues/1399))\n### Changes\n- [twitter] resolve `t.co` URLs in `content` ([#1532](https://github.com/mikf/gallery-dl/issues/1532))\n### Fixes\n- [500px] update query hashes ([#1573](https://github.com/mikf/gallery-dl/issues/1573))\n- [aryion] find text posts in `recursive=false` mode ([#1568](https://github.com/mikf/gallery-dl/issues/1568))\n- [imagebam] fix extraction of NSFW images ([#1534](https://github.com/mikf/gallery-dl/issues/1534))\n- [imgur] update URL patterns ([#1561](https://github.com/mikf/gallery-dl/issues/1561))\n- [manganelo] update domain to `manganato.com`\n- [reactor] skip deleted/empty posts\n- [twitter] add missing retweet media entities ([#1555](https://github.com/mikf/gallery-dl/issues/1555))\n- fix ISO 639-1 code for Japanese (`jp` -> `ja`)\n\n## 1.17.4 - 2021-05-07\n### Additions\n- [gelbooru] add extractor for `/redirect.php` URLs ([#1530](https://github.com/mikf/gallery-dl/issues/1530))\n- [inkbunny] add `favorite` extractor ([#1521](https://github.com/mikf/gallery-dl/issues/1521))\n- add `output.skip` option\n- add an optional argument to `--clear-cache` to select which cache entries to remove ([#1230](https://github.com/mikf/gallery-dl/issues/1230))\n### Changes\n- [pixiv] update `translated-tags` option ([#1507](https://github.com/mikf/gallery-dl/issues/1507))\n  - rename to `tags`\n  - accept `\"japanese\"`, `\"translated\"`, and `\"original\"` as values\n### Fixes\n- [500px] update query hashes\n- [kemonoparty] fix download URLs ([#1514](https://github.com/mikf/gallery-dl/issues/1514))\n- [imagebam] fix extraction\n- [instagram] update query hashes\n- [nozomi] update default archive-fmt for `tag` and `search` extractors ([#1529](https://github.com/mikf/gallery-dl/issues/1529))\n- [pixiv] remove duplicate translated tags ([#1507](https://github.com/mikf/gallery-dl/issues/1507))\n- [readcomiconline] change domain to `readcomiconline.li` ([#1517](https://github.com/mikf/gallery-dl/issues/1517))\n- [sankaku] update invalid-token detection ([#1515](https://github.com/mikf/gallery-dl/issues/1515))\n- fix crash when using `--no-download` with `--ugoira-conv` ([#1507](https://github.com/mikf/gallery-dl/issues/1507))\n\n## 1.17.3 - 2021-04-25\n### Additions\n- [danbooru] add option for extended metadata extraction ([#1458](https://github.com/mikf/gallery-dl/issues/1458))\n- [fanbox] add extractors ([#1459](https://github.com/mikf/gallery-dl/issues/1459))\n- [fantia] add extractors ([#1459](https://github.com/mikf/gallery-dl/issues/1459))\n- [gelbooru] add an option to extract notes ([#1457](https://github.com/mikf/gallery-dl/issues/1457))\n- [hentaicosplays] add extractor ([#907](https://github.com/mikf/gallery-dl/issues/907), [#1473](https://github.com/mikf/gallery-dl/issues/1473), [#1483](https://github.com/mikf/gallery-dl/issues/1483))\n- [instagram] add extractor for `tagged` posts ([#1439](https://github.com/mikf/gallery-dl/issues/1439))\n- [naverwebtoon] ignore non-comic images\n- [pixiv] also save untranslated tags when `translated-tags` is enabled ([#1501](https://github.com/mikf/gallery-dl/issues/1501))\n- [shopify] support omgmiamiswimwear.com ([#1280](https://github.com/mikf/gallery-dl/issues/1280))\n- implement `output.fallback` option\n- add archive format to InfoJob output ([#875](https://github.com/mikf/gallery-dl/issues/875))\n- build executables with SOCKS proxy support ([#1424](https://github.com/mikf/gallery-dl/issues/1424))\n### Fixes\n- [500px] update query hashes\n- [8muses] fix JSON deobfuscation\n- [artstation] download `/4k/` images ([#1422](https://github.com/mikf/gallery-dl/issues/1422))\n- [deviantart] fix pagination for Eclipse results ([#1444](https://github.com/mikf/gallery-dl/issues/1444))\n- [deviantart] improve folder name matching ([#1451](https://github.com/mikf/gallery-dl/issues/1451))\n- [erome] skip deleted albums ([#1447](https://github.com/mikf/gallery-dl/issues/1447))\n- [exhentai] fix image limit detection ([#1437](https://github.com/mikf/gallery-dl/issues/1437))\n- [exhentai] restore `limits` option ([#1487](https://github.com/mikf/gallery-dl/issues/1487))\n- [gelbooru] fix tag category extraction ([#1455](https://github.com/mikf/gallery-dl/issues/1455))\n- [instagram] update query hashes\n- [komikcast] fix extraction\n- [simplyhentai] fix extraction\n- [slideshare] fix extraction\n- [webtoons] update agegate/GDPR cookies ([#1431](https://github.com/mikf/gallery-dl/issues/1431))\n- fix `category-transfer` option\n### Removals\n- [yuki] remove module for yuki.la\n\n## 1.17.2 - 2021-04-02\n### Additions\n- [deviantart] add support for posts from watched users ([#794](https://github.com/mikf/gallery-dl/issues/794))\n- [manganelo] add `chapter` and `manga` extractors ([#1415](https://github.com/mikf/gallery-dl/issues/1415))\n- [pinterest] add `search` extractor ([#1411](https://github.com/mikf/gallery-dl/issues/1411))\n- [sankaku] add `tag_string` metadata field ([#1388](https://github.com/mikf/gallery-dl/issues/1388))\n- [sankaku] add enumeration index for books ([#1388](https://github.com/mikf/gallery-dl/issues/1388))\n- [tapas] add `series` and `episode` extractors ([#692](https://github.com/mikf/gallery-dl/issues/692))\n- [tapas] implement login with username & password ([#692](https://github.com/mikf/gallery-dl/issues/692))\n- [twitter] allow specifying a custom format for user results ([#1337](https://github.com/mikf/gallery-dl/issues/1337))\n- [twitter] add extractor for direct image links ([#1417](https://github.com/mikf/gallery-dl/issues/1417))\n- [vk] add support for albums ([#474](https://github.com/mikf/gallery-dl/issues/474))\n### Fixes\n- [aryion] unescape paths ([#1414](https://github.com/mikf/gallery-dl/issues/1414))\n- [bcy] improve pagination\n- [deviantart] update `watch` URL pattern ([#794](https://github.com/mikf/gallery-dl/issues/794))\n- [deviantart] fix arguments for search/popular results ([#1408](https://github.com/mikf/gallery-dl/issues/1408))\n- [deviantart] use fallback for `/intermediary/` URLs\n- [exhentai] improve and simplify image limit checks\n- [komikcast] fix extraction\n- [pixiv] fix `favorite` URL pattern ([#1405](https://github.com/mikf/gallery-dl/issues/1405))\n- [sankaku] simplify `pool` tags ([#1388](https://github.com/mikf/gallery-dl/issues/1388))\n- [twitter] improve error message when trying to log in with 2FA ([#1409](https://github.com/mikf/gallery-dl/issues/1409))\n- [twitter] don't use youtube-dl for cards when videos are disabled ([#1416](https://github.com/mikf/gallery-dl/issues/1416))\n\n## 1.17.1 - 2021-03-19\n### Additions\n- [architizer] add `project` and `firm` extractors ([#1369](https://github.com/mikf/gallery-dl/issues/1369))\n- [deviantart] add `watch` extractor ([#794](https://github.com/mikf/gallery-dl/issues/794))\n- [exhentai] support `/tag/` URLs ([#1363](https://github.com/mikf/gallery-dl/issues/1363))\n- [gelbooru_v01] support `drawfriends.booru.org`, `vidyart.booru.org`, and `tlb.booru.org` by default\n- [nozomi] support `/index-N.html` URLs ([#1365](https://github.com/mikf/gallery-dl/issues/1365))\n- [philomena] add generalized extractors for philomena sites ([#1379](https://github.com/mikf/gallery-dl/issues/1379))\n- [philomena] support post URLs without `/images/`\n- [twitter] implement `users` option ([#1337](https://github.com/mikf/gallery-dl/issues/1337))\n- implement `parent-metadata` option ([#1364](https://github.com/mikf/gallery-dl/issues/1364))\n### Changes\n- [deviantart] revert previous changes to `extra` option ([#1356](https://github.com/mikf/gallery-dl/issues/1356), [#1387](https://github.com/mikf/gallery-dl/issues/1387))\n### Fixes\n- [exhentai] improve favorites count extraction ([#1360](https://github.com/mikf/gallery-dl/issues/1360))\n- [gelbooru] update domain for video downloads ([#1368](https://github.com/mikf/gallery-dl/issues/1368))\n- [hentaifox] improve image and metadata extraction ([#1366](https://github.com/mikf/gallery-dl/issues/1366), [#1378](https://github.com/mikf/gallery-dl/issues/1378))\n- [imgur] fix and improve rate limit handling ([#1386](https://github.com/mikf/gallery-dl/issues/1386))\n- [weasyl] improve favorites URL pattern ([#1374](https://github.com/mikf/gallery-dl/issues/1374))\n- use type check before applying `browser` option ([#1358](https://github.com/mikf/gallery-dl/issues/1358))\n- ensure `-s/--simulate` always prints filenames ([#1360](https://github.com/mikf/gallery-dl/issues/1360))\n### Removals\n- [hentaicafe]  remove module\n- [hentainexus] remove module\n- [mangareader] remove module\n- [mangastream] remove module\n\n## 1.17.0 - 2021-03-05\n### Additions\n- [cyberdrop] add support for `https://cyberdrop.me/` ([#1328](https://github.com/mikf/gallery-dl/issues/1328))\n- [exhentai] add `metadata` option; extract more metadata from gallery pages ([#1325](https://github.com/mikf/gallery-dl/issues/1325))\n- [hentaicafe] add `search` and `tag` extractors ([#1345](https://github.com/mikf/gallery-dl/issues/1345))\n- [hentainexus] add `original` option ([#1322](https://github.com/mikf/gallery-dl/issues/1322))\n- [instagram] support `/user/reels/` URLs ([#1329](https://github.com/mikf/gallery-dl/issues/1329))\n- [naverwebtoon] add support for `https://comic.naver.com/` ([#1331](https://github.com/mikf/gallery-dl/issues/1331))\n- [pixiv] add `translated-tags` option ([#1354](https://github.com/mikf/gallery-dl/issues/1354))\n- [tbib] add support for `https://tbib.org/` ([#473](https://github.com/mikf/gallery-dl/issues/473), [#1082](https://github.com/mikf/gallery-dl/issues/1082))\n- [tumblrgallery] add support for `https://tumblrgallery.xyz/` ([#1298](https://github.com/mikf/gallery-dl/issues/1298))\n- [twitter] add extractor for followed users ([#1337](https://github.com/mikf/gallery-dl/issues/1337))\n- [twitter] add option to download all media from conversations ([#1319](https://github.com/mikf/gallery-dl/issues/1319))\n- [wallhaven] add `collections` extractor ([#1351](https://github.com/mikf/gallery-dl/issues/1351))\n- [snap] allow access to user's .netrc for site authentication ([#1352](https://github.com/mikf/gallery-dl/issues/1352))\n- add extractors for Gelbooru v0.1 sites ([#234](https://github.com/mikf/gallery-dl/issues/234), [#426](https://github.com/mikf/gallery-dl/issues/426), [#473](https://github.com/mikf/gallery-dl/issues/473), [#767](https://github.com/mikf/gallery-dl/issues/767), [#1238](https://github.com/mikf/gallery-dl/issues/1238))\n- add `-E/--extractor-info` command-line option ([#875](https://github.com/mikf/gallery-dl/issues/875))\n- add GitHub Actions workflow for building standalone executables ([#1312](https://github.com/mikf/gallery-dl/issues/1312))\n- add `browser` and `headers` options ([#1117](https://github.com/mikf/gallery-dl/issues/1117))\n- add option to use different youtube-dl forks ([#1330](https://github.com/mikf/gallery-dl/issues/1330))\n- support using multiple input files at once ([#1353](https://github.com/mikf/gallery-dl/issues/1353))\n### Changes\n- [deviantart] extend `extra` option to also download embedded DeviantArt posts.\n- [exhentai] rename metadata fields to match API results ([#1325](https://github.com/mikf/gallery-dl/issues/1325))\n- [mangadex] use `api.mangadex.org` as default API server\n- [mastodon] cache OAuth tokens ([#616](https://github.com/mikf/gallery-dl/issues/616))\n- replace `wait-min` and `wait-max` with `sleep-request`\n### Fixes\n- [500px] skip unavailable photos ([#1335](https://github.com/mikf/gallery-dl/issues/1335))\n- [komikcast] fix extraction\n- [readcomiconline] download high quality image versions ([#1347](https://github.com/mikf/gallery-dl/issues/1347))\n- [twitter] update GraphQL endpoints\n- fix crash when `base-directory` is an empty string ([#1339](https://github.com/mikf/gallery-dl/issues/1339))\n### Removals\n- remove support for formerly deprecated options\n- remove `cloudflare` module\n\n## 1.16.5 - 2021-02-14\n### Additions\n- [behance] support `video` modules ([#1282](https://github.com/mikf/gallery-dl/issues/1282))\n- [erome] add `album`, `user`, and `search` extractors ([#409](https://github.com/mikf/gallery-dl/issues/409))\n- [hentaifox] support searching by group ([#1294](https://github.com/mikf/gallery-dl/issues/1294))\n- [imgclick] add `image` extractor ([#1307](https://github.com/mikf/gallery-dl/issues/1307))\n- [kemonoparty] extract inline images ([#1286](https://github.com/mikf/gallery-dl/issues/1286))\n- [kemonoparty] support URLs with non-numeric user and post IDs ([#1303](https://github.com/mikf/gallery-dl/issues/1303))\n- [pillowfort] add `user` and `post` extractors ([#846](https://github.com/mikf/gallery-dl/issues/846))\n### Changes\n- [kemonoparty] include `service` in directories and archive keys\n- [pixiv] require a `refresh-token` to login ([#1304](https://github.com/mikf/gallery-dl/issues/1304))\n- [snap] use `core18` as base\n### Fixes\n- [500px] update query hashes\n- [deviantart] update parameters for `/browse/popular` ([#1267](https://github.com/mikf/gallery-dl/issues/1267))\n- [deviantart] provide filename extension for original file downloads ([#1272](https://github.com/mikf/gallery-dl/issues/1272))\n- [deviantart] fix `folders` option ([#1302](https://github.com/mikf/gallery-dl/issues/1302))\n- [inkbunny] add `sid` parameter to private file downloads ([#1281](https://github.com/mikf/gallery-dl/issues/1281))\n- [kemonoparty] fix absolute file URLs\n- [mangadex] revert to `https://mangadex.org/api/` and add `api-server` option ([#1310](https://github.com/mikf/gallery-dl/issues/1310))\n- [nsfwalbum] use fallback for deleted content ([#1259](https://github.com/mikf/gallery-dl/issues/1259))\n- [sankaku] update `invalid token` detection ([#1309](https://github.com/mikf/gallery-dl/issues/1309))\n- [slideshare] fix extraction\n- [postprocessor:metadata] fix crash with `extension-format` ([#1285](https://github.com/mikf/gallery-dl/issues/1285))\n\n## 1.16.4 - 2021-01-23\n### Additions\n- [furaffinity] add `descriptions` option ([#1231](https://github.com/mikf/gallery-dl/issues/1231))\n- [kemonoparty] add `user` and `post` extractors ([#1216](https://github.com/mikf/gallery-dl/issues/1216))\n- [nozomi] add `num` enumeration index ([#1239](https://github.com/mikf/gallery-dl/issues/1239))\n- [photovogue] added portfolio extractor ([#1253](https://github.com/mikf/gallery-dl/issues/1253))\n- [twitter] match `/i/user/ID` URLs\n- [unsplash] add extractors ([#1197](https://github.com/mikf/gallery-dl/issues/1197))\n- [vipr] add image extractor ([#1258](https://github.com/mikf/gallery-dl/issues/1258))\n### Changes\n- [derpibooru] use \"Everything\" filter by default ([#862](https://github.com/mikf/gallery-dl/issues/862))\n### Fixes\n- [derpibooru] update `date` parsing\n- [foolfuuka] stop search when results are exhausted ([#1174](https://github.com/mikf/gallery-dl/issues/1174))\n- [instagram] fix regex for `/saved` URLs ([#1251](https://github.com/mikf/gallery-dl/issues/1251))\n- [mangadex] update API URLs\n- [mangakakalot] fix extraction\n- [newgrounds] fix flash file extraction ([#1257](https://github.com/mikf/gallery-dl/issues/1257))\n- [sankaku] simplify login process\n- [twitter] fix retries after hitting rate limit\n\n## 1.16.3 - 2021-01-10\n### Fixes\n- fix crash when using a `dict` for `path-restrict`\n- [postprocessor:metadata] sanitize custom filenames\n\n## 1.16.2 - 2021-01-09\n### Additions\n- [derpibooru] add `search` and `gallery` extractors ([#862](https://github.com/mikf/gallery-dl/issues/862))\n- [foolfuuka] add `board` and `search` extractors ([#1044](https://github.com/mikf/gallery-dl/issues/1044), [#1174](https://github.com/mikf/gallery-dl/issues/1174))\n- [gfycat] add `date` metadata field ([#1138](https://github.com/mikf/gallery-dl/issues/1138))\n- [pinterest] add support for getting all boards of a user ([#1205](https://github.com/mikf/gallery-dl/issues/1205))\n- [sankaku] add support for book searches ([#1204](https://github.com/mikf/gallery-dl/issues/1204))\n- [twitter] fetch media from pinned tweets ([#1203](https://github.com/mikf/gallery-dl/issues/1203))\n- [wikiart] add extractor for single paintings ([#1233](https://github.com/mikf/gallery-dl/issues/1233))\n- [downloader:http] add MIME type and signature for `.ico` files ([#1211](https://github.com/mikf/gallery-dl/issues/1211))\n- add `d` format string conversion for timestamp values\n- add `\"ascii\"` as a special `path-restrict` value\n### Fixes\n- [hentainexus] fix extraction ([#1234](https://github.com/mikf/gallery-dl/issues/1234))\n- [instagram] categorize single highlight URLs as `highlights` ([#1222](https://github.com/mikf/gallery-dl/issues/1222))\n- [redgifs] fix search results\n- [twitter] fix login with username & password\n- [twitter] fetch tweets from `homeConversation` entries\n\n## 1.16.1 - 2020-12-27\n### Additions\n- [instagram] add `include` option ([#1180](https://github.com/mikf/gallery-dl/issues/1180))\n- [pinterest] implement video support ([#1189](https://github.com/mikf/gallery-dl/issues/1189))\n- [sankaku] reimplement login support ([#1176](https://github.com/mikf/gallery-dl/issues/1176), [#1182](https://github.com/mikf/gallery-dl/issues/1182))\n- [sankaku] add support for sankaku.app URLs ([#1193](https://github.com/mikf/gallery-dl/issues/1193))\n### Changes\n- [e621] return pool posts in order ([#1195](https://github.com/mikf/gallery-dl/issues/1195))\n- [hentaicafe] prefer title of `/hc.fyi/` pages ([#1106](https://github.com/mikf/gallery-dl/issues/1106))\n- [hentaicafe] simplify default filenames\n- [sankaku] normalize `created_at` metadata ([#1190](https://github.com/mikf/gallery-dl/issues/1190))\n- [postprocessor:exec] do not add missing `{}` to command ([#1185](https://github.com/mikf/gallery-dl/issues/1185))\n### Fixes\n- [booru] improve error handling\n- [instagram] warn about private profiles ([#1187](https://github.com/mikf/gallery-dl/issues/1187))\n- [keenspot] improve redirect handling\n- [mangadex] respect `chapter-reverse` settings ([#1194](https://github.com/mikf/gallery-dl/issues/1194))\n- [pixiv] output debug message on failed login attempts ([#1192](https://github.com/mikf/gallery-dl/issues/1192))\n- increase SQLite connection timeouts ([#1173](https://github.com/mikf/gallery-dl/issues/1173))\n### Removals\n- [mangapanda] remove module\n\n## 1.16.0 - 2020-12-12\n### Additions\n- [booru] implement generalized extractors for `*booru` and `moebooru` sites\n  - add support for sakugabooru.com ([#1136](https://github.com/mikf/gallery-dl/issues/1136))\n  - add support for lolibooru.moe ([#1050](https://github.com/mikf/gallery-dl/issues/1050))\n  - provide formattable `date` metadata fields ([#1138](https://github.com/mikf/gallery-dl/issues/1138))\n- [postprocessor:metadata] add `event` and `filename` options ([#315](https://github.com/mikf/gallery-dl/issues/315), [#866](https://github.com/mikf/gallery-dl/issues/866), [#984](https://github.com/mikf/gallery-dl/issues/984))\n- [postprocessor:exec] add `event` option ([#992](https://github.com/mikf/gallery-dl/issues/992))\n### Changes\n- [flickr] update default directories and improve metadata consistency ([#828](https://github.com/mikf/gallery-dl/issues/828))\n- [sankaku] use API endpoints from `beta.sankakucomplex.com`\n- [downloader:http] improve filename extension handling ([#776](https://github.com/mikf/gallery-dl/issues/776))\n- replace all JPEG filename extensions with `jpg` by default\n### Fixes\n- [hentainexus] fix extraction ([#1166](https://github.com/mikf/gallery-dl/issues/1166))\n- [instagram] rewrite ([#1113](https://github.com/mikf/gallery-dl/issues/1113), [#1122](https://github.com/mikf/gallery-dl/issues/1122), [#1128](https://github.com/mikf/gallery-dl/issues/1128), [#1130](https://github.com/mikf/gallery-dl/issues/1130), [#1149](https://github.com/mikf/gallery-dl/issues/1149))\n- [mangadex] handle external chapters ([#1154](https://github.com/mikf/gallery-dl/issues/1154))\n- [nozomi] handle empty `date` fields  ([#1163](https://github.com/mikf/gallery-dl/issues/1163))\n- [paheal] create directory for each post ([#1147](https://github.com/mikf/gallery-dl/issues/1147))\n- [piczel] update API URLs\n- [twitter] update image URL format ([#1145](https://github.com/mikf/gallery-dl/issues/1145))\n- [twitter] improve `x-csrf-token` header handling ([#1170](https://github.com/mikf/gallery-dl/issues/1170))\n- [webtoons] update `ageGate` cookies\n### Removals\n- [sankaku] remove login support\n\n## 1.15.4 - 2020-11-27\n### Fixes\n- [2chan] skip external links\n- [hentainexus] fix extraction ([#1125](https://github.com/mikf/gallery-dl/issues/1125))\n- [mangadex] switch to API v2 ([#1129](https://github.com/mikf/gallery-dl/issues/1129))\n- [mangapanda] use http://\n- [mangoxo] fix extraction\n- [reddit] skip invalid gallery items ([#1127](https://github.com/mikf/gallery-dl/issues/1127))\n\n## 1.15.3 - 2020-11-13\n### Additions\n- [sankakucomplex] extract videos and embeds ([#308](https://github.com/mikf/gallery-dl/issues/308))\n- [twitter] add support for lists ([#1096](https://github.com/mikf/gallery-dl/issues/1096))\n- [postprocessor:metadata] accept string-lists for `content-format` ([#1080](https://github.com/mikf/gallery-dl/issues/1080))\n- implement `modules` and `extension-map` options\n### Fixes\n- [500px] update query hashes\n- [8kun] fix file URLs of older posts ([#1101](https://github.com/mikf/gallery-dl/issues/1101))\n- [exhentai] update image URL parsing ([#1094](https://github.com/mikf/gallery-dl/issues/1094))\n- [hentaifoundry] update `YII_CSRF_TOKEN` cookie handling ([#1083](https://github.com/mikf/gallery-dl/issues/1083))\n- [hentaifoundry] use scheme from input URLs ([#1095](https://github.com/mikf/gallery-dl/issues/1095))\n- [mangoxo] fix metadata extraction\n- [paheal] fix extraction ([#1088](https://github.com/mikf/gallery-dl/issues/1088))\n- collect post processors from `basecategory` entries ([#1084](https://github.com/mikf/gallery-dl/issues/1084))\n\n## 1.15.2 - 2020-10-24\n### Additions\n- [pinterest] implement login support ([#1055](https://github.com/mikf/gallery-dl/issues/1055))\n- [reddit] add `date` metadata field ([#1068](https://github.com/mikf/gallery-dl/issues/1068))\n- [seiga] add metadata for single image downloads ([#1063](https://github.com/mikf/gallery-dl/issues/1063))\n- [twitter] support media from Cards ([#937](https://github.com/mikf/gallery-dl/issues/937), [#1005](https://github.com/mikf/gallery-dl/issues/1005))\n- [weasyl] support api-key authentication ([#1057](https://github.com/mikf/gallery-dl/issues/1057))\n- add a `t` format string conversion for trimming whitespace ([#1065](https://github.com/mikf/gallery-dl/issues/1065))\n### Fixes\n- [blogger] handle URLs with specified width/height ([#1061](https://github.com/mikf/gallery-dl/issues/1061))\n- [fallenangels] fix extraction of `.5` chapters\n- [gelbooru] rewrite mp4 video URLs ([#1048](https://github.com/mikf/gallery-dl/issues/1048))\n- [hitomi] fix image URLs and gallery URL pattern\n- [mangadex] unescape more metadata fields ([#1066](https://github.com/mikf/gallery-dl/issues/1066))\n- [mangahere] ensure download URLs have a scheme ([#1070](https://github.com/mikf/gallery-dl/issues/1070))\n- [mangakakalot] ignore \"Go Home\" buttons in chapter pages\n- [newgrounds] handle embeds without scheme ([#1033](https://github.com/mikf/gallery-dl/issues/1033))\n- [newgrounds] provide fallback URLs for video downloads ([#1042](https://github.com/mikf/gallery-dl/issues/1042))\n- [xhamster] fix user profile extraction\n\n## 1.15.1 - 2020-10-11\n### Additions\n- [hentaicafe] add `manga_id` metadata field ([#1036](https://github.com/mikf/gallery-dl/issues/1036))\n- [hentaifoundry] add support for stories ([#734](https://github.com/mikf/gallery-dl/issues/734))\n- [hentaifoundry] add `include` option\n- [newgrounds] extract image embeds ([#1033](https://github.com/mikf/gallery-dl/issues/1033))\n- [nijie] add `include` option ([#1018](https://github.com/mikf/gallery-dl/issues/1018))\n- [reactor] match URLs without subdomain ([#1053](https://github.com/mikf/gallery-dl/issues/1053))\n- [twitter] extend `retweets` option ([#1026](https://github.com/mikf/gallery-dl/issues/1026))\n- [weasyl] add extractors ([#977](https://github.com/mikf/gallery-dl/issues/977))\n### Fixes\n- [500px] update query hashes\n- [behance] fix `collection` extraction\n- [newgrounds] fix video extraction ([#1042](https://github.com/mikf/gallery-dl/issues/1042))\n- [twitter] improve twitpic extraction ([#1019](https://github.com/mikf/gallery-dl/issues/1019))\n- [weibo] handle posts with more than 9 images ([#926](https://github.com/mikf/gallery-dl/issues/926))\n- [xvideos] fix `title` extraction\n- fix crash when using `--download-archive` with `--no-skip` ([#1023](https://github.com/mikf/gallery-dl/issues/1023))\n- fix issues with `blacklist`/`whitelist` defaults ([#1051](https://github.com/mikf/gallery-dl/issues/1051), [#1056](https://github.com/mikf/gallery-dl/issues/1056))\n### Removals\n- [kissmanga] remove module\n\n## 1.15.0 - 2020-09-20\n### Additions\n- [deviantart] support watchers-only/paid deviations ([#995](https://github.com/mikf/gallery-dl/issues/995))\n- [myhentaigallery] add gallery extractor ([#1001](https://github.com/mikf/gallery-dl/issues/1001))\n- [twitter] support specifying users by ID ([#980](https://github.com/mikf/gallery-dl/issues/980))\n- [twitter] support `/intent/user?user_id=…` URLs ([#980](https://github.com/mikf/gallery-dl/issues/980))\n- add `--no-skip` command-line option ([#986](https://github.com/mikf/gallery-dl/issues/986))\n- add `blacklist` and `whitelist` options ([#492](https://github.com/mikf/gallery-dl/issues/492), [#844](https://github.com/mikf/gallery-dl/issues/844))\n- add `filesize-min` and `filesize-max` options ([#780](https://github.com/mikf/gallery-dl/issues/780))\n- add `sleep-extractor` and `sleep-request` options ([#788](https://github.com/mikf/gallery-dl/issues/788))\n- write skipped files to archive ([#550](https://github.com/mikf/gallery-dl/issues/550))\n### Changes\n- [exhentai] update wait time before original image downloads ([#978](https://github.com/mikf/gallery-dl/issues/978))\n- [imgur] use new API endpoints for image/album data\n- [tumblr] create directories for each post ([#965](https://github.com/mikf/gallery-dl/issues/965))\n- support format string replacement fields in download archive paths ([#985](https://github.com/mikf/gallery-dl/issues/985))\n- reduce wait time growth rate for HTTP retries from exponential to linear\n### Fixes\n- [500px] update query hash\n- [aryion] improve post ID extraction ([#981](https://github.com/mikf/gallery-dl/issues/981), [#982](https://github.com/mikf/gallery-dl/issues/982))\n- [danbooru] handle posts without `id` ([#1004](https://github.com/mikf/gallery-dl/issues/1004))\n- [furaffinity] update download URL extraction ([#988](https://github.com/mikf/gallery-dl/issues/988))\n- [imgur] fix image/album detection for galleries\n- [postprocessor:zip] defer zip file creation ([#968](https://github.com/mikf/gallery-dl/issues/968))\n### Removals\n- [jaiminisbox] remove extractors\n- [worldthree] remove extractors\n\n## 1.14.5 - 2020-08-30\n### Additions\n- [aryion] add username/password support ([#960](https://github.com/mikf/gallery-dl/issues/960))\n- [exhentai] add ability to specify a custom image limit ([#940](https://github.com/mikf/gallery-dl/issues/940))\n- [furaffinity] add `search` extractor ([#915](https://github.com/mikf/gallery-dl/issues/915))\n- [imgur] add `search` and `tag` extractors ([#934](https://github.com/mikf/gallery-dl/issues/934))\n### Fixes\n- [500px] fix extraction and update URL patterns ([#956](https://github.com/mikf/gallery-dl/issues/956))\n- [aryion] update folder mime type list ([#945](https://github.com/mikf/gallery-dl/issues/945))\n- [gelbooru] fix extraction without API\n- [hentaihand] update to new site layout\n- [hitomi] fix redirect processing\n- [reddit] handle deleted galleries ([#953](https://github.com/mikf/gallery-dl/issues/953))\n- [reddit] improve gallery extraction ([#955](https://github.com/mikf/gallery-dl/issues/955))\n\n## 1.14.4 - 2020-08-15\n### Additions\n- [blogger] add `search` extractor ([#925](https://github.com/mikf/gallery-dl/issues/925))\n- [blogger] support searching posts by labels ([#925](https://github.com/mikf/gallery-dl/issues/925))\n- [inkbunny] add `user` and `post` extractors ([#283](https://github.com/mikf/gallery-dl/issues/283))\n- [instagram] support `/reel/` URLs\n- [pinterest] support `pinterest.co.uk` URLs ([#914](https://github.com/mikf/gallery-dl/issues/914))\n- [reddit] support gallery posts ([#920](https://github.com/mikf/gallery-dl/issues/920))\n- [subscribestar] extract attached media files ([#852](https://github.com/mikf/gallery-dl/issues/852))\n### Fixes\n- [blogger] improve error messages for missing posts/blogs ([#903](https://github.com/mikf/gallery-dl/issues/903))\n- [exhentai] adjust image limit costs ([#940](https://github.com/mikf/gallery-dl/issues/940))\n- [gfycat] skip malformed gfycat responses ([#902](https://github.com/mikf/gallery-dl/issues/902))\n- [imgur] handle 403 overcapacity responses ([#910](https://github.com/mikf/gallery-dl/issues/910))\n- [instagram] wait before GraphQL requests ([#901](https://github.com/mikf/gallery-dl/issues/901))\n- [mangareader] fix extraction\n- [mangoxo] fix login\n- [pixnet] detect password-protected albums ([#177](https://github.com/mikf/gallery-dl/issues/177))\n- [simplyhentai] fix `gallery_id` extraction\n- [subscribestar] update `date` parsing\n- [vsco] handle missing `description` fields\n- [xhamster] fix extraction ([#917](https://github.com/mikf/gallery-dl/issues/917))\n- allow `parent-directory` to work recursively ([#905](https://github.com/mikf/gallery-dl/issues/905))\n- skip external OAuth tests ([#908](https://github.com/mikf/gallery-dl/issues/908))\n### Removals\n- [bobx] remove module\n\n## 1.14.3 - 2020-07-18\n### Additions\n- [8muses] support `comics.8muses.com` URLs\n- [artstation] add `following` extractor ([#888](https://github.com/mikf/gallery-dl/issues/888))\n- [exhentai] add `domain` option ([#897](https://github.com/mikf/gallery-dl/issues/897))\n- [gfycat] add `user` and `search` extractors\n- [imgur] support all `/t/...` URLs ([#880](https://github.com/mikf/gallery-dl/issues/880))\n- [khinsider] add `format` option ([#840](https://github.com/mikf/gallery-dl/issues/840))\n- [mangakakalot] add `manga` and `chapter` extractors ([#876](https://github.com/mikf/gallery-dl/issues/876))\n- [redgifs] support `gifsdeliverynetwork.com` URLs ([#874](https://github.com/mikf/gallery-dl/issues/874))\n- [subscribestar] add `user` and `post` extractors ([#852](https://github.com/mikf/gallery-dl/issues/852))\n- [twitter] add support for nitter.net URLs ([#890](https://github.com/mikf/gallery-dl/issues/890))\n- add Zsh completion script ([#150](https://github.com/mikf/gallery-dl/issues/150))\n### Fixes\n- [gfycat] retry 404'ed videos on redgifs.com ([#874](https://github.com/mikf/gallery-dl/issues/874))\n- [newgrounds] fix favorites extraction\n- [patreon] yield images and attachments before post files ([#871](https://github.com/mikf/gallery-dl/issues/871))\n- [reddit] fix AttributeError when using `recursion` ([#879](https://github.com/mikf/gallery-dl/issues/879))\n- [twitter] raise proper exception if a user doesn't exist ([#891](https://github.com/mikf/gallery-dl/issues/891))\n- defer directory creation ([#722](https://github.com/mikf/gallery-dl/issues/722))\n- set pseudo extension for Metadata messages ([#865](https://github.com/mikf/gallery-dl/issues/865))\n- prevent exception on Cloudflare challenges ([#868](https://github.com/mikf/gallery-dl/issues/868))\n\n## 1.14.2 - 2020-06-27\n### Additions\n- [artstation] add `date` metadata field ([#839](https://github.com/mikf/gallery-dl/issues/839))\n- [mastodon] add `date` metadata field ([#839](https://github.com/mikf/gallery-dl/issues/839))\n- [pinterest] add support for board sections ([#835](https://github.com/mikf/gallery-dl/issues/835))\n- [twitter] add extractor for liked tweets ([#837](https://github.com/mikf/gallery-dl/issues/837))\n- [twitter] add option to filter media from quoted tweets ([#854](https://github.com/mikf/gallery-dl/issues/854))\n- [weibo] add `date` metadata field to `status` objects ([#829](https://github.com/mikf/gallery-dl/issues/829))\n### Fixes\n- [aryion] fix user gallery extraction ([#832](https://github.com/mikf/gallery-dl/issues/832))\n- [imgur] build directory paths for each file ([#842](https://github.com/mikf/gallery-dl/issues/842))\n- [tumblr] prevent errors when using `reblogs=same-blog` ([#851](https://github.com/mikf/gallery-dl/issues/851))\n- [twitter] always provide an `author` metadata field ([#831](https://github.com/mikf/gallery-dl/issues/831), [#833](https://github.com/mikf/gallery-dl/issues/833))\n- [twitter] don't download video previews ([#833](https://github.com/mikf/gallery-dl/issues/833))\n- [twitter] improve handling of deleted tweets ([#838](https://github.com/mikf/gallery-dl/issues/838))\n- [twitter] fix search results ([#847](https://github.com/mikf/gallery-dl/issues/847))\n- [twitter] improve handling of quoted tweets ([#854](https://github.com/mikf/gallery-dl/issues/854))\n- fix config lookups when multiple locations are involved ([#843](https://github.com/mikf/gallery-dl/issues/843))\n- improve output of `-K/--list-keywords` for parent extractors ([#825](https://github.com/mikf/gallery-dl/issues/825))\n- call `flush()` after writing JSON in `DataJob()` ([#727](https://github.com/mikf/gallery-dl/issues/727))\n\n## 1.14.1 - 2020-06-12\n### Additions\n- [furaffinity] add `artist_url` metadata field ([#821](https://github.com/mikf/gallery-dl/issues/821))\n- [redgifs] add `user` and `search` extractors ([#724](https://github.com/mikf/gallery-dl/issues/724))\n### Changes\n- [deviantart] extend `extra` option; also search journals for sta.sh links ([#712](https://github.com/mikf/gallery-dl/issues/712))\n- [twitter] rewrite; use new interface ([#806](https://github.com/mikf/gallery-dl/issues/806), [#740](https://github.com/mikf/gallery-dl/issues/740))\n### Fixes\n- [kissmanga] work around CAPTCHAs ([#818](https://github.com/mikf/gallery-dl/issues/818))\n- [nhentai] fix extraction ([#819](https://github.com/mikf/gallery-dl/issues/819))\n- [webtoons] generalize comic extraction code ([#820](https://github.com/mikf/gallery-dl/issues/820))\n\n## 1.14.0 - 2020-05-31\n### Additions\n- [imagechest] add new extractor for imgchest.com ([#750](https://github.com/mikf/gallery-dl/issues/750))\n- [instagram] add `post_url`, `tags`, `location`, `tagged_users` metadata ([#743](https://github.com/mikf/gallery-dl/issues/743))\n- [redgifs] add image extractor ([#724](https://github.com/mikf/gallery-dl/issues/724))\n- [webtoons] add new extractor for webtoons.com ([#761](https://github.com/mikf/gallery-dl/issues/761))\n- implement `--write-pages` option ([#736](https://github.com/mikf/gallery-dl/issues/736))\n- extend `path-restrict` option ([#662](https://github.com/mikf/gallery-dl/issues/662))\n- implement `path-replace` option ([#662](https://github.com/mikf/gallery-dl/issues/662), [#755](https://github.com/mikf/gallery-dl/issues/755))\n- make `path` and `keywords` available in logging messages ([#574](https://github.com/mikf/gallery-dl/issues/574), [#575](https://github.com/mikf/gallery-dl/issues/575))\n### Changes\n- [danbooru] change default value of `ugoira` to `false`\n- [downloader:ytdl] change default value of `forward-cookies` to `false`\n- [downloader:ytdl] fix file extensions when merging into `.mkv` ([#720](https://github.com/mikf/gallery-dl/issues/720))\n- write OAuth tokens to cache ([#616](https://github.com/mikf/gallery-dl/issues/616))\n- use `%APPDATA%\\gallery-dl` for config files and cache on Windows\n- use `util.Formatter` for formatting logging messages\n- reuse HTTP connections from parent extractors\n### Fixes\n- [deviantart] use private access tokens for Journals ([#738](https://github.com/mikf/gallery-dl/issues/738))\n- [gelbooru] simplify and fix pool extraction\n- [imgur] fix extraction of animated images without `mp4` entry\n- [imgur] treat `/t/unmuted/` URLs as galleries\n- [instagram] fix login with username & password ([#756](https://github.com/mikf/gallery-dl/issues/756), [#771](https://github.com/mikf/gallery-dl/issues/771), [#797](https://github.com/mikf/gallery-dl/issues/797), [#803](https://github.com/mikf/gallery-dl/issues/803))\n- [reddit] don't send OAuth headers for file downloads ([#729](https://github.com/mikf/gallery-dl/issues/729))\n- fix/improve Cloudflare bypass code ([#728](https://github.com/mikf/gallery-dl/issues/728), [#757](https://github.com/mikf/gallery-dl/issues/757))\n- reset filenames on empty file extensions ([#733](https://github.com/mikf/gallery-dl/issues/733))\n\n## 1.13.6 - 2020-05-02\n### Additions\n- [patreon] respect filters and sort order in query parameters ([#711](https://github.com/mikf/gallery-dl/issues/711))\n- [speakerdeck] add a new extractor for speakerdeck.com ([#726](https://github.com/mikf/gallery-dl/issues/726))\n- [twitter] add `replies` option ([#705](https://github.com/mikf/gallery-dl/issues/705))\n- [weibo] add `videos` option\n- [downloader:http] add MIME types for `.psd` files ([#714](https://github.com/mikf/gallery-dl/issues/714))\n### Fixes\n- [artstation] improve embed extraction ([#720](https://github.com/mikf/gallery-dl/issues/720))\n- [deviantart] limit API wait times ([#721](https://github.com/mikf/gallery-dl/issues/721))\n- [newgrounds] fix URLs produced by the `following` extractor ([#684](https://github.com/mikf/gallery-dl/issues/684))\n- [patreon] improve file hash extraction ([#713](https://github.com/mikf/gallery-dl/issues/713))\n- [vsco] fix user gallery extraction\n- fix/improve Cloudflare bypass code ([#728](https://github.com/mikf/gallery-dl/issues/728))\n\n## 1.13.5 - 2020-04-27\n### Additions\n- [500px] recognize `web.500px.com` URLs\n- [aryion] support downloading from folders ([#694](https://github.com/mikf/gallery-dl/issues/694))\n- [furaffinity] add extractor for followed users ([#515](https://github.com/mikf/gallery-dl/issues/515))\n- [hitomi] add extractor for tag searches ([#697](https://github.com/mikf/gallery-dl/issues/697))\n- [instagram] add `post_id` and `num` metadata fields ([#698](https://github.com/mikf/gallery-dl/issues/698))\n- [newgrounds] add extractor for followed users ([#684](https://github.com/mikf/gallery-dl/issues/684))\n- [patreon] recognize URLs with creator IDs ([#711](https://github.com/mikf/gallery-dl/issues/711))\n- [twitter] add `reply` metadata field ([#705](https://github.com/mikf/gallery-dl/issues/705))\n- [xhamster] recognize `xhamster.porncache.net` URLs ([#700](https://github.com/mikf/gallery-dl/issues/700))\n### Fixes\n- [gelbooru] improve post ID extraction in pool listings\n- [hitomi] fix extraction of galleries without tags\n- [jaiminisbox] update metadata decoding procedure ([#702](https://github.com/mikf/gallery-dl/issues/702))\n- [mastodon] fix pagination ([#701](https://github.com/mikf/gallery-dl/issues/701))\n- [mastodon] improve account searches ([#704](https://github.com/mikf/gallery-dl/issues/704))\n- [patreon] fix hash extraction from download URLs ([#693](https://github.com/mikf/gallery-dl/issues/693))\n- improve parameter extraction when solving Cloudflare challenges\n\n## 1.13.4 - 2020-04-12\n### Additions\n- [aryion] add `gallery` and `post` extractors ([#390](https://github.com/mikf/gallery-dl/issues/390), [#673](https://github.com/mikf/gallery-dl/issues/673))\n- [deviantart] detect and handle folders in sta.sh listings ([#659](https://github.com/mikf/gallery-dl/issues/659))\n- [hentainexus] add `circle`, `event`, and `title_conventional` metadata fields ([#661](https://github.com/mikf/gallery-dl/issues/661))\n- [hiperdex] add `artist` extractor ([#606](https://github.com/mikf/gallery-dl/issues/606))\n- [mastodon] add access tokens for `mastodon.social` and `baraag.net` ([#665](https://github.com/mikf/gallery-dl/issues/665))\n### Changes\n- [deviantart] retrieve *all* download URLs through the OAuth API\n- automatically read config files in PyInstaller executable directories ([#682](https://github.com/mikf/gallery-dl/issues/682))\n### Fixes\n- [deviantart] handle \"Request blocked\" errors ([#655](https://github.com/mikf/gallery-dl/issues/655))\n- [deviantart] improve JPEG quality replacement pattern\n- [hiperdex] fix extraction\n- [mastodon] handle API rate limits ([#665](https://github.com/mikf/gallery-dl/issues/665))\n- [mastodon] update OAuth credentials for pawoo.net ([#665](https://github.com/mikf/gallery-dl/issues/665))\n- [myportfolio] fix extraction of galleries without title\n- [piczel] fix extraction of single images\n- [vsco] fix collection extraction\n- [weibo] accept status URLs with non-numeric IDs ([#664](https://github.com/mikf/gallery-dl/issues/664))\n\n## 1.13.3 - 2020-03-28\n### Additions\n- [instagram] Add support for user's saved medias ([#644](https://github.com/mikf/gallery-dl/issues/644))\n- [nozomi] support multiple images per post ([#646](https://github.com/mikf/gallery-dl/issues/646))\n- [35photo] add `tag` extractor\n### Changes\n- [mangadex] transform timestamps from `date` fields to datetime objects\n### Fixes\n- [deviantart] handle decode errors for `extended_fetch` results ([#655](https://github.com/mikf/gallery-dl/issues/655))\n- [e621] fix bug in API rate limiting and improve pagination ([#651](https://github.com/mikf/gallery-dl/issues/651))\n- [instagram] update pattern for user profile URLs\n- [mangapark] fix metadata extraction\n- [nozomi] sort search results ([#646](https://github.com/mikf/gallery-dl/issues/646))\n- [piczel] fix extraction\n- [twitter] fix typo in `x-twitter-auth-type` header ([#625](https://github.com/mikf/gallery-dl/issues/625))\n- remove trailing dots from Windows directory names ([#647](https://github.com/mikf/gallery-dl/issues/647))\n- fix crash with missing `stdout`/`stderr`/`stdin` handles ([#653](https://github.com/mikf/gallery-dl/issues/653))\n\n## 1.13.2 - 2020-03-14\n### Additions\n- [furaffinity] extract more metadata\n- [instagram] add `post_shortcode` metadata field ([#525](https://github.com/mikf/gallery-dl/issues/525))\n- [kabeuchi] add extractor ([#561](https://github.com/mikf/gallery-dl/issues/561))\n- [newgrounds] add extractor for favorited posts ([#394](https://github.com/mikf/gallery-dl/issues/394))\n- [pixiv] implement `avatar` option ([#595](https://github.com/mikf/gallery-dl/issues/595), [#623](https://github.com/mikf/gallery-dl/issues/623))\n- [twitter] add extractor for bookmarked Tweets ([#625](https://github.com/mikf/gallery-dl/issues/625))\n### Fixes\n- [bcy] reduce number of HTTP requests during data extraction\n- [e621] update to new interface ([#635](https://github.com/mikf/gallery-dl/issues/635))\n- [exhentai] handle incomplete MIME types ([#632](https://github.com/mikf/gallery-dl/issues/632))\n- [hitomi] improve metadata extraction\n- [mangoxo] fix login\n- [newgrounds] improve error handling when extracting post data\n\n## 1.13.1 - 2020-03-01\n### Additions\n- [hentaihand] add extractors ([#605](https://github.com/mikf/gallery-dl/issues/605))\n- [hiperdex] add chapter and manga extractors ([#606](https://github.com/mikf/gallery-dl/issues/606))\n- [oauth] implement option to write DeviantArt refresh-tokens to cache ([#616](https://github.com/mikf/gallery-dl/issues/616))\n- [downloader:http] add more MIME types for `.bmp` and `.rar` files ([#621](https://github.com/mikf/gallery-dl/issues/621), [#628](https://github.com/mikf/gallery-dl/issues/628))\n- warn about expired cookies\n### Fixes\n- [bcy] fix partial image URLs ([#613](https://github.com/mikf/gallery-dl/issues/613))\n- [danbooru] fix Ugoira downloads and metadata\n- [deviantart] check availability of `/intermediary/` URLs ([#609](https://github.com/mikf/gallery-dl/issues/609))\n- [hitomi] follow multiple redirects & fix image URLs\n- [piczel] improve and update\n- [tumblr] replace `-` with ` ` in tag searches ([#611](https://github.com/mikf/gallery-dl/issues/611))\n- [vsco] update gallery URL pattern\n- fix `--verbose` and `--quiet` command-line options\n\n## 1.13.0 - 2020-02-16\n### Additions\n- Support for\n  - `furaffinity` - https://www.furaffinity.net/ ([#284](https://github.com/mikf/gallery-dl/issues/284))\n  - `8kun`        - https://8kun.top/            ([#582](https://github.com/mikf/gallery-dl/issues/582))\n  - `bcy`         - https://bcy.net/             ([#592](https://github.com/mikf/gallery-dl/issues/592))\n- [blogger] implement video extraction ([#587](https://github.com/mikf/gallery-dl/issues/587))\n- [oauth] add option to specify port number used by local server ([#604](https://github.com/mikf/gallery-dl/issues/604))\n- [pixiv] add `rating` metadata field ([#595](https://github.com/mikf/gallery-dl/issues/595))\n- [pixiv] recognize tags at the end of new bookmark URLs\n- [reddit] add `videos` option\n- [weibo] use youtube-dl to download from m3u8 manifests\n- implement `parent-directory` option ([#551](https://github.com/mikf/gallery-dl/issues/551))\n- extend filename formatting capabilities:\n  - implement field name alternatives ([#525](https://github.com/mikf/gallery-dl/issues/525))\n  - allow multiple \"special\" format specifiers per replacement field ([#595](https://github.com/mikf/gallery-dl/issues/595))\n  - allow for numeric list and string indices\n### Changes\n- [reddit] handle reddit-hosted images and videos natively ([#551](https://github.com/mikf/gallery-dl/issues/551))\n- [twitter] change default value for `videos` to `true`\n### Fixes\n- [cloudflare] unescape challenge URLs\n- [deviantart] fix video extraction from `extended_fetch` results\n- [hitomi] implement workaround for \"broken\" redirects\n- [khinsider] fix and improve metadata extraction\n- [patreon] filter duplicate files per post ([#590](https://github.com/mikf/gallery-dl/issues/590))\n- [piczel] fix extraction\n- [pixiv] fix user IDs for bookmarks API calls ([#596](https://github.com/mikf/gallery-dl/issues/596))\n- [sexcom] fix image URLs\n- [twitter] force old login page layout ([#584](https://github.com/mikf/gallery-dl/issues/584), [#598](https://github.com/mikf/gallery-dl/issues/598))\n- [vsco] skip \"invalid\" entities\n- improve functions to load/save cookies.txt files ([#586](https://github.com/mikf/gallery-dl/issues/586))\n### Removals\n- [yaplog] remove module\n\n## 1.12.3 - 2020-01-19\n### Additions\n- [hentaifoundry] extract more metadata ([#565](https://github.com/mikf/gallery-dl/issues/565))\n- [twitter] add option to extract TwitPic embeds ([#579](https://github.com/mikf/gallery-dl/issues/579))\n- implement a post-processor module to compare file versions ([#530](https://github.com/mikf/gallery-dl/issues/530))\n### Fixes\n- [hitomi] update image URL generation\n- [mangadex] revert domain to `mangadex.org`\n- [pinterest] improve detection of invalid pin.it links\n- [pixiv] update URL patterns for user profiles and bookmarks ([#568](https://github.com/mikf/gallery-dl/issues/568))\n- [twitter] Fix stop before real end ([#573](https://github.com/mikf/gallery-dl/issues/573))\n- remove temp files before downloading from fallback URLs\n### Removals\n- [erolord] remove extractor\n\n## 1.12.2 - 2020-01-05\n### Additions\n- [deviantart] match new search/popular URLs ([#538](https://github.com/mikf/gallery-dl/issues/538))\n- [deviantart] match `/favourites/all` URLs ([#555](https://github.com/mikf/gallery-dl/issues/555))\n- [deviantart] add extractor for followed users ([#515](https://github.com/mikf/gallery-dl/issues/515))\n- [pixiv] support listing followed users ([#515](https://github.com/mikf/gallery-dl/issues/515))\n- [imagefap] handle beta.imagefap.com URLs ([#552](https://github.com/mikf/gallery-dl/issues/552))\n- [postprocessor:metadata] add `directory` option ([#520](https://github.com/mikf/gallery-dl/issues/520))\n### Fixes\n- [artstation] fix search result pagination ([#537](https://github.com/mikf/gallery-dl/issues/537))\n- [directlink] send Referer headers ([#536](https://github.com/mikf/gallery-dl/issues/536))\n- [exhentai] restrict default directory name length ([#545](https://github.com/mikf/gallery-dl/issues/545))\n- [mangadex] change domain to mangadex.cc ([#559](https://github.com/mikf/gallery-dl/issues/559))\n- [mangahere] send `isAdult` cookies ([#556](https://github.com/mikf/gallery-dl/issues/556))\n- [newgrounds] fix tags metadata extraction\n- [pixiv] retry after rate limit errors ([#535](https://github.com/mikf/gallery-dl/issues/535))\n- [twitter] handle quoted tweets ([#526](https://github.com/mikf/gallery-dl/issues/526))\n- [twitter] handle API rate limits ([#526](https://github.com/mikf/gallery-dl/issues/526))\n- [twitter] fix URLs forwarded to youtube-dl ([#540](https://github.com/mikf/gallery-dl/issues/540))\n- prevent infinite recursion when spawning new extractors ([#489](https://github.com/mikf/gallery-dl/issues/489))\n- improve output of `--list-keywords` for \"parent\" extractors ([#548](https://github.com/mikf/gallery-dl/issues/548))\n- provide fallback for SQLite versions with missing `WITHOUT ROWID` support ([#553](https://github.com/mikf/gallery-dl/issues/553))\n\n## 1.12.1 - 2019-12-22\n### Additions\n- [4chan] add extractor for entire boards ([#510](https://github.com/mikf/gallery-dl/issues/510))\n- [realbooru] add extractors for pools, posts, and tag searches ([#514](https://github.com/mikf/gallery-dl/issues/514))\n- [instagram] implement a `videos` option ([#521](https://github.com/mikf/gallery-dl/issues/521))\n- [vsco] implement a `videos` option\n- [postprocessor:metadata] implement a `bypost` option for downloading the metadata of an entire post ([#511](https://github.com/mikf/gallery-dl/issues/511))\n### Changes\n- [reddit] change the default value for `comments` to `0`\n- [vsco] improve image resolutions\n- make filesystem-related errors during file downloads non-fatal ([#512](https://github.com/mikf/gallery-dl/issues/512))\n### Fixes\n- [foolslide] add fallback for chapter data extraction\n- [instagram] ignore errors during post-page extraction\n- [patreon] avoid errors when fetching user info ([#508](https://github.com/mikf/gallery-dl/issues/508))\n- [patreon] improve URL pattern for single posts\n- [reddit] fix errors with `t1` submissions\n- [vsco] fix user profile extraction … again\n- [weibo] handle unavailable/deleted statuses\n- [downloader:http] improve rate limit handling\n- retain trailing zeroes in Cloudflare challenge answers\n\n## 1.12.0 - 2019-12-08\n### Additions\n- [flickr] support 3k, 4k, 5k, and 6k photo sizes ([#472](https://github.com/mikf/gallery-dl/issues/472))\n- [imgur] add extractor for subreddit links ([#500](https://github.com/mikf/gallery-dl/issues/500))\n- [newgrounds] add extractors for `audio` listings and general `media` files ([#394](https://github.com/mikf/gallery-dl/issues/394))\n- [newgrounds] implement login support ([#394](https://github.com/mikf/gallery-dl/issues/394))\n- [postprocessor:metadata] implement a `extension-format` option ([#477](https://github.com/mikf/gallery-dl/issues/477))\n- `--exec-after`\n### Changes\n- [deviantart] ensure consistent username capitalization ([#455](https://github.com/mikf/gallery-dl/issues/455))\n- [directlink] split `{path}` into `{path}/{filename}.{extension}`\n- [twitter] update metadata fields with user/author information\n- [postprocessor:metadata] filter private entries & rename `format` to `content-format`\n- Enable `cookies-update` by default\n### Fixes\n- [2chan] fix metadata extraction\n- [behance] get images from 'media_collection' modules\n- [bobx] fix image downloads by randomly generating session cookies ([#482](https://github.com/mikf/gallery-dl/issues/482))\n- [deviantart] revert to getting download URLs from OAuth API calls ([#488](https://github.com/mikf/gallery-dl/issues/488))\n- [deviantart] fix URL generation from '/extended_fetch' results ([#505](https://github.com/mikf/gallery-dl/issues/505))\n- [flickr] adjust OAuth redirect URI ([#503](https://github.com/mikf/gallery-dl/issues/503))\n- [hentaifox] fix extraction\n- [imagefap] adapt to new image URL format\n- [imgbb] fix error in galleries without user info ([#471](https://github.com/mikf/gallery-dl/issues/471))\n- [instagram] prevent errors with missing 'video_url' fields ([#479](https://github.com/mikf/gallery-dl/issues/479))\n- [nijie] fix `date` parsing\n- [pixiv] match new search URLs ([#507](https://github.com/mikf/gallery-dl/issues/507))\n- [plurk] fix comment pagination\n- [sexcom] send specific Referer headers when downloading videos\n- [twitter] fix infinite loops ([#499](https://github.com/mikf/gallery-dl/issues/499))\n- [vsco] fix user profile and collection extraction ([#480](https://github.com/mikf/gallery-dl/issues/480))\n- Fix Cloudflare DDoS protection bypass\n### Removals\n- `--abort-on-skip`\n\n## 1.11.1 - 2019-11-09\n### Fixes\n- Fix inclusion of bash completion and man pages in source distributions\n\n## 1.11.0 - 2019-11-08\n### Additions\n- Support for\n  - `blogger` - https://www.blogger.com/ ([#364](https://github.com/mikf/gallery-dl/issues/364))\n  - `nozomi`  - https://nozomi.la/       ([#388](https://github.com/mikf/gallery-dl/issues/388))\n  - `issuu`   - https://issuu.com/       ([#413](https://github.com/mikf/gallery-dl/issues/413))\n  - `naver`   - https://blog.naver.com/  ([#447](https://github.com/mikf/gallery-dl/issues/447))\n- Extractor for `twitter` search results ([#448](https://github.com/mikf/gallery-dl/issues/448))\n- Extractor for `deviantart` user profiles with configurable targets ([#377](https://github.com/mikf/gallery-dl/issues/377), [#419](https://github.com/mikf/gallery-dl/issues/419))\n- `--ugoira-conv-lossless` ([#432](https://github.com/mikf/gallery-dl/issues/432))\n- `cookies-update` option to allow updating cookies.txt files ([#445](https://github.com/mikf/gallery-dl/issues/445))\n- Optional `cloudflare` and `video` installation targets ([#460](https://github.com/mikf/gallery-dl/issues/460))\n- Allow executing commands with the `exec` post-processor after all files are downloaded ([#413](https://github.com/mikf/gallery-dl/issues/413), [#421](https://github.com/mikf/gallery-dl/issues/421))\n### Changes\n- Rewrite `imgur` using its public API ([#446](https://github.com/mikf/gallery-dl/issues/446))\n- Rewrite `luscious` using GraphQL queries ([#457](https://github.com/mikf/gallery-dl/issues/457))\n- Adjust default `nijie` filenames to match `pixiv`\n- Change enumeration index for gallery extractors from `page` to `num`\n- Return non-zero exit status when errors occurred\n- Forward proxy settings to youtube-dl downloader\n- Install bash completion script into `share/bash-completion/completions`\n### Fixes\n- Adapt to new `instagram` page layout when logged in ([#391](https://github.com/mikf/gallery-dl/issues/391))\n- Support protected `twitter` videos ([#452](https://github.com/mikf/gallery-dl/issues/452))\n- Extend `hitomi` URL pattern and fix gallery extraction\n- Restore OAuth2 authentication error messages\n- Miscellaneous fixes for `patreon` ([#444](https://github.com/mikf/gallery-dl/issues/444)), `deviantart` ([#455](https://github.com/mikf/gallery-dl/issues/455)), `sexcom` ([#464](https://github.com/mikf/gallery-dl/issues/464)), `imgur` ([#467](https://github.com/mikf/gallery-dl/issues/467)), `simplyhentai`\n\n## 1.10.6 - 2019-10-11\n### Additions\n- `--exec` command-line option to specify a command to run after each file download ([#421](https://github.com/mikf/gallery-dl/issues/421))\n### Changes\n- Include titles in `gfycat` default filenames ([#434](https://github.com/mikf/gallery-dl/issues/434))\n### Fixes\n- Fetch working download URLs for `deviantart` ([#436](https://github.com/mikf/gallery-dl/issues/436))\n- Various fixes and improvements for `yaplog` blogs ([#443](https://github.com/mikf/gallery-dl/issues/443))\n- Fix image URL generation for `hitomi` galleries\n- Miscellaneous fixes for `behance` and `xvideos`\n\n## 1.10.5 - 2019-09-28\n### Additions\n- `instagram.highlights` option to include highlighted stories when downloading user profiles ([#329](https://github.com/mikf/gallery-dl/issues/329))\n- Support for `/user/` URLs on `reddit` ([#350](https://github.com/mikf/gallery-dl/issues/350))\n- Support for `imgur` user profiles and favorites ([#420](https://github.com/mikf/gallery-dl/issues/420))\n- Additional metadata fields on `nijie`([#423](https://github.com/mikf/gallery-dl/issues/423))\n### Fixes\n- Improve handling of private `deviantart` artworks ([#414](https://github.com/mikf/gallery-dl/issues/414)) and 429 status codes ([#424](https://github.com/mikf/gallery-dl/issues/424))\n- Prevent fatal errors when trying to open download-archive files ([#417](https://github.com/mikf/gallery-dl/issues/417))\n- Detect and ignore unavailable videos on `weibo` ([#427](https://github.com/mikf/gallery-dl/issues/427))\n- Update the `scope` of new `reddit` refresh-tokens ([#428](https://github.com/mikf/gallery-dl/issues/428))\n- Fix inconsistencies with the `reddit.comments` option ([#429](https://github.com/mikf/gallery-dl/issues/429))\n- Extend URL patterns for `hentaicafe` manga and `pixiv` artworks\n- Improve detection of unavailable albums on `luscious` and `imgbb`\n- Miscellaneous fixes for `tsumino`\n\n## 1.10.4 - 2019-09-08\n### Additions\n- Support for\n  - `lineblog` - https://www.lineblog.me/ ([#404](https://github.com/mikf/gallery-dl/issues/404))\n  - `fuskator` - https://fuskator.com/    ([#407](https://github.com/mikf/gallery-dl/issues/407))\n- `ugoira` option for `danbooru` to download pre-rendered ugoira animations ([#406](https://github.com/mikf/gallery-dl/issues/406))\n### Fixes\n- Download the correct files from `twitter` replies ([#403](https://github.com/mikf/gallery-dl/issues/403))\n- Prevent crash when trying to use unavailable downloader modules ([#405](https://github.com/mikf/gallery-dl/issues/405))\n- Fix `pixiv` authentication ([#411](https://github.com/mikf/gallery-dl/issues/411))\n- Improve `exhentai` image limit checks\n- Miscellaneous fixes for `hentaicafe`, `simplyhentai`, `tumblr`\n\n## 1.10.3 - 2019-08-30\n### Additions\n- Provide `filename` metadata for all `deviantart` files ([#392](https://github.com/mikf/gallery-dl/issues/392), [#400](https://github.com/mikf/gallery-dl/issues/400))\n- Implement a `ytdl.outtmpl` option to let youtube-dl handle filenames by itself ([#395](https://github.com/mikf/gallery-dl/issues/395))\n- Support `seiga` mobile URLs ([#401](https://github.com/mikf/gallery-dl/issues/401))\n### Fixes\n- Extract more than the first 32 posts from `piczel` galleries ([#396](https://github.com/mikf/gallery-dl/issues/396))\n- Fix filenames of archives created with `--zip` ([#397](https://github.com/mikf/gallery-dl/issues/397))\n- Skip unavailable images and videos on `flickr` ([#398](https://github.com/mikf/gallery-dl/issues/398))\n- Fix filesystem paths on Windows with Python 3.6 and lower ([#402](https://github.com/mikf/gallery-dl/issues/402))\n\n## 1.10.2 - 2019-08-23\n### Additions\n- Support for `instagram` stories and IGTV ([#371](https://github.com/mikf/gallery-dl/issues/371), [#373](https://github.com/mikf/gallery-dl/issues/373))\n- Support for individual `imgbb` images ([#363](https://github.com/mikf/gallery-dl/issues/363))\n- `deviantart.quality` option to set the JPEG compression quality for newer images ([#369](https://github.com/mikf/gallery-dl/issues/369))\n- `enumerate` option for `extractor.skip` ([#306](https://github.com/mikf/gallery-dl/issues/306))\n- `adjust-extensions` option to control filename extension adjustments\n- `path-remove` option to remove control characters etc. from filesystem paths\n### Changes\n- Rename `restrict-filenames` to `path-restrict`\n- Adjust `pixiv` metadata and default filename format ([#366](https://github.com/mikf/gallery-dl/issues/366))\n  - Set `filename` to `\"{category}_{user[id]}_{id}{suffix}.{extension}\"` to restore the old default\n- Improve and optimize directory and filename generation\n### Fixes\n- Allow the `classify` post-processor to handle files with unknown filename extension ([#138](https://github.com/mikf/gallery-dl/issues/138))\n- Fix rate limit handling for OAuth APIs ([#368](https://github.com/mikf/gallery-dl/issues/368))\n- Fix artwork and scraps extraction on `deviantart` ([#376](https://github.com/mikf/gallery-dl/issues/376), [#392](https://github.com/mikf/gallery-dl/issues/392))\n- Distinguish between `imgur` album and gallery URLs ([#380](https://github.com/mikf/gallery-dl/issues/380))\n- Prevent crash when using `--ugoira-conv` ([#382](https://github.com/mikf/gallery-dl/issues/382))\n- Handle multi-image posts on `patreon` ([#383](https://github.com/mikf/gallery-dl/issues/383))\n- Miscellaneous fixes for `*reactor`, `simplyhentai`\n\n## 1.10.1 - 2019-08-02\n### Fixes\n- Use the correct domain for exhentai.org input URLs\n\n## 1.10.0 - 2019-08-01\n### Warning\n- Prior to version 1.10.0 all cache files were created world readable (mode `644`)\n  leading to possible sensitive information disclosure on multi-user systems\n- It is recommended to restrict access permissions of already existing files\n  (`/tmp/.gallery-dl.cache`) with `chmod 600`\n- Windows users should not be affected\n### Additions\n- Support for\n  - `vsco`        - https://vsco.co/             ([#331](https://github.com/mikf/gallery-dl/issues/331))\n  - `imgbb`       - https://imgbb.com/           ([#361](https://github.com/mikf/gallery-dl/issues/361))\n  - `adultempire` - https://www.adultempire.com/ ([#340](https://github.com/mikf/gallery-dl/issues/340))\n- `restrict-filenames` option to create Windows-compatible filenames on any platform ([#348](https://github.com/mikf/gallery-dl/issues/348))\n- `forward-cookies` option to control cookie forwarding to youtube-dl ([#352](https://github.com/mikf/gallery-dl/issues/352))\n### Changes\n- The default cache file location on non-Windows systems is now\n  - `$XDG_CACHE_HOME/gallery-dl/cache.sqlite3` or\n  - `~/.cache/gallery-dl/cache.sqlite3`\n- New cache files are created with mode `600`\n- `exhentai` extractors will always use `e-hentai.org` as domain\n### Fixes\n- Better handling of `exhentai` image limits and errors ([#356](https://github.com/mikf/gallery-dl/issues/356), [#360](https://github.com/mikf/gallery-dl/issues/360))\n- Try to prevent ZIP file corruption ([#355](https://github.com/mikf/gallery-dl/issues/355))\n- Miscellaneous fixes for `behance`, `ngomik`\n\n## 1.9.0 - 2019-07-19\n### Additions\n- Support for\n  - `erolord` - http://erolord.com/ ([#326](https://github.com/mikf/gallery-dl/issues/326))\n- Add login support for `instagram` ([#195](https://github.com/mikf/gallery-dl/issues/195))\n- Add `--no-download` and `extractor.*.download` disable file downloads ([#220](https://github.com/mikf/gallery-dl/issues/220))\n- Add `-A/--abort` to specify the number of consecutive download skips before aborting\n- Interpret `-1` as infinite retries ([#300](https://github.com/mikf/gallery-dl/issues/300))\n- Implement custom log message formats per log-level ([#304](https://github.com/mikf/gallery-dl/issues/304))\n- Implement an `mtime` post-processor that sets file modification times according to metadata fields ([#332](https://github.com/mikf/gallery-dl/issues/332))\n- Implement a `twitter.content` option to enable tweet text extraction ([#333](https://github.com/mikf/gallery-dl/issues/333), [#338](https://github.com/mikf/gallery-dl/issues/338))\n- Enable `date-min/-max/-format` options for `tumblr` ([#337](https://github.com/mikf/gallery-dl/issues/337))\n### Changes\n- Set file modification times according to their `Last-Modified` header when downloading ([#236](https://github.com/mikf/gallery-dl/issues/236), [#277](https://github.com/mikf/gallery-dl/issues/277))\n  - Use `--no-mtime` or `downloader.*.mtime` to disable this behavior\n- Duplicate download URLs are no longer silently ignored (controllable with `extractor.*.image-unique`)\n- Deprecate `--abort-on-skip`\n### Fixes\n- Retry downloads on OpenSSL exceptions ([#324](https://github.com/mikf/gallery-dl/issues/324))\n- Ignore unavailable pins on `sexcom` instead of raising an exception ([#325](https://github.com/mikf/gallery-dl/issues/325))\n- Use Firefox's SSL/TLS ciphers to prevent Cloudflare CAPTCHAs ([#342](https://github.com/mikf/gallery-dl/issues/342))\n- Improve folder name matching on `deviantart` ([#343](https://github.com/mikf/gallery-dl/issues/343))\n- Forward cookies to `youtube-dl` to allow downloading private videos\n- Miscellaneous fixes for `35photo`, `500px`, `newgrounds`, `simplyhentai`\n\n## 1.8.7 - 2019-06-28\n### Additions\n- Support for\n  - `vanillarock` - https://vanilla-rock.com/ ([#254](https://github.com/mikf/gallery-dl/issues/254))\n  - `nsfwalbum`   - https://nsfwalbum.com/    ([#287](https://github.com/mikf/gallery-dl/issues/287))\n- `artist` and `tags` metadata for `hentaicafe` ([#238](https://github.com/mikf/gallery-dl/issues/238))\n- `description` metadata for `instagram` ([#310](https://github.com/mikf/gallery-dl/issues/310))\n- Format string option to replace a substring with another - `R<old>/<new>/` ([#318](https://github.com/mikf/gallery-dl/issues/318))\n### Changes\n- Delete empty archives created by the `zip` post-processor ([#316](https://github.com/mikf/gallery-dl/issues/316))\n### Fixes\n- Handle `hitomi` Game CG galleries correctly ([#321](https://github.com/mikf/gallery-dl/issues/321))\n- Miscellaneous fixes for `deviantart`, `hitomi`, `pururin`, `kissmanga`, `keenspot`, `mangoxo`, `imagefap`\n\n## 1.8.6 - 2019-06-14\n### Additions\n- Support for\n  - `slickpic` - https://www.slickpic.com/ ([#249](https://github.com/mikf/gallery-dl/issues/249))\n  - `xhamster` - https://xhamster.com/     ([#281](https://github.com/mikf/gallery-dl/issues/281))\n  - `pornhub`  - https://www.pornhub.com/  ([#282](https://github.com/mikf/gallery-dl/issues/282))\n  - `8muses`   - https://www.8muses.com/   ([#305](https://github.com/mikf/gallery-dl/issues/305))\n- `extra` option for `deviantart` to download Sta.sh content linked in description texts ([#302](https://github.com/mikf/gallery-dl/issues/302))\n### Changes\n- Detect `directlink` URLs with upper case filename extensions ([#296](https://github.com/mikf/gallery-dl/issues/296))\n### Fixes\n- Improved error handling for `tumblr` API calls ([#297](https://github.com/mikf/gallery-dl/issues/297))\n- Fixed extraction of `livedoor` blogs ([#301](https://github.com/mikf/gallery-dl/issues/301))\n- Fixed extraction of special `deviantart` Sta.sh items ([#307](https://github.com/mikf/gallery-dl/issues/307))\n- Fixed pagination for specific `keenspot` comics\n\n## 1.8.5 - 2019-06-01\n### Additions\n- Support for\n  - `keenspot`       - http://keenspot.com/           ([#223](https://github.com/mikf/gallery-dl/issues/223))\n  - `sankakucomplex` - https://www.sankakucomplex.com ([#258](https://github.com/mikf/gallery-dl/issues/258))\n- `folders` option for `deviantart` to add a list of containing folders to each file ([#276](https://github.com/mikf/gallery-dl/issues/276))\n- `captcha` option for `kissmanga` and `readcomiconline` to control CAPTCHA handling ([#279](https://github.com/mikf/gallery-dl/issues/279))\n- `filename` metadata for files downloaded with youtube-dl ([#291](https://github.com/mikf/gallery-dl/issues/291))\n### Changes\n- Adjust `wallhaven` extractors to new page layout:\n  - use API and add `api-key` option\n  - removed traditional login support\n- Provide original filenames for `patreon` downloads ([#268](https://github.com/mikf/gallery-dl/issues/268))\n- Use e-hentai.org or exhentai.org depending on input URL ([#278](https://github.com/mikf/gallery-dl/issues/278))\n### Fixes\n- Fix pagination over `sankaku` popular listings ([#265](https://github.com/mikf/gallery-dl/issues/265))\n- Fix folder and collection extraction on `deviantart` ([#271](https://github.com/mikf/gallery-dl/issues/271))\n- Detect \"AreYouHuman\" redirects on `readcomiconline` ([#279](https://github.com/mikf/gallery-dl/issues/279))\n- Miscellaneous fixes for `hentainexus`, `livedoor`, `ngomik`\n\n## 1.8.4 - 2019-05-17\n### Additions\n- Support for\n  - `patreon`     - https://www.patreon.com/ ([#226](https://github.com/mikf/gallery-dl/issues/226))\n  - `hentainexus` - https://hentainexus.com/ ([#256](https://github.com/mikf/gallery-dl/issues/256))\n- `date` metadata fields for `pixiv` ([#248](https://github.com/mikf/gallery-dl/issues/248)), `instagram` ([#250](https://github.com/mikf/gallery-dl/issues/250)), `exhentai`, and `newgrounds`\n### Changes\n- Improved `flickr` metadata and video extraction ([#246](https://github.com/mikf/gallery-dl/issues/246))\n### Fixes\n- Download original GIF animations from `deviantart` ([#242](https://github.com/mikf/gallery-dl/issues/242))\n- Ignore missing `edge_media_to_comment` fields on `instagram` ([#250](https://github.com/mikf/gallery-dl/issues/250))\n- Fix serialization of `datetime` objects for `--write-metadata` ([#251](https://github.com/mikf/gallery-dl/issues/251), [#252](https://github.com/mikf/gallery-dl/issues/252))\n- Allow multiple post-processor command-line options at once ([#253](https://github.com/mikf/gallery-dl/issues/253))\n- Prevent crash on `booru` sites when no tags are available ([#259](https://github.com/mikf/gallery-dl/issues/259))\n- Fix extraction on `instagram` after `rhx_gis` field removal ([#266](https://github.com/mikf/gallery-dl/issues/266))\n- Avoid Cloudflare CAPTCHAs for Python interpreters built against OpenSSL < 1.1.1\n- Miscellaneous fixes for `luscious`\n\n## 1.8.3 - 2019-05-04\n### Additions\n- Support for\n  - `plurk`  - https://www.plurk.com/ ([#212](https://github.com/mikf/gallery-dl/issues/212))\n  - `sexcom` - https://www.sex.com/   ([#147](https://github.com/mikf/gallery-dl/issues/147))\n- `--clear-cache`\n- `date` metadata fields for `deviantart`, `twitter`, and `tumblr` ([#224](https://github.com/mikf/gallery-dl/issues/224), [#232](https://github.com/mikf/gallery-dl/issues/232))\n### Changes\n- Standalone executables are now built using PyInstaller:\n  - uses the latest CPython interpreter (Python 3.7.3)\n  - available on several platforms (Windows, Linux, macOS)\n  - includes the `certifi` CA bundle, `youtube-dl`, and `pyOpenSSL` on Windows\n### Fixes\n- Patch `urllib3`'s  default list of SSL/TLS ciphers to prevent Cloudflare CAPTCHAs ([#227](https://github.com/mikf/gallery-dl/issues/227))\n  (Windows users need to install `pyOpenSSL` for this to take effect)\n- Provide fallback URLs for `twitter` images ([#237](https://github.com/mikf/gallery-dl/issues/237))\n- Send `Referer` headers when downloading from `hitomi` ([#239](https://github.com/mikf/gallery-dl/issues/239))\n- Updated login procedure on `mangoxo`\n\n## 1.8.2 - 2019-04-12\n### Additions\n- Support for\n  - `pixnet`   - https://www.pixnet.net/  ([#177](https://github.com/mikf/gallery-dl/issues/177))\n  - `wikiart`  - https://www.wikiart.org/ ([#179](https://github.com/mikf/gallery-dl/issues/179))\n  - `mangoxo`  - https://www.mangoxo.com/ ([#184](https://github.com/mikf/gallery-dl/issues/184))\n  - `yaplog`   - https://yaplog.jp/       ([#190](https://github.com/mikf/gallery-dl/issues/190))\n  - `livedoor` - http://blog.livedoor.jp/ ([#190](https://github.com/mikf/gallery-dl/issues/190))\n- Login support for `mangoxo` ([#184](https://github.com/mikf/gallery-dl/issues/184)) and `twitter` ([#214](https://github.com/mikf/gallery-dl/issues/214))\n### Changes\n- Increased required `Requests` version to 2.11.0\n### Fixes\n- Improved image quality on `reactor` sites ([#210](https://github.com/mikf/gallery-dl/issues/210))\n- Support `imagebam` galleries with more than 100 images ([#219](https://github.com/mikf/gallery-dl/issues/219))\n- Updated Cloudflare bypass code\n\n## 1.8.1 - 2019-03-29\n### Additions\n- Support for:\n  - `35photo` - https://35photo.pro/ ([#162](https://github.com/mikf/gallery-dl/issues/162))\n  - `500px`   - https://500px.com/   ([#185](https://github.com/mikf/gallery-dl/issues/185))\n- `instagram` extractor for hashtags ([#202](https://github.com/mikf/gallery-dl/issues/202))\n- Option to get more metadata on `deviantart` ([#189](https://github.com/mikf/gallery-dl/issues/189))\n- Man pages and bash completion ([#150](https://github.com/mikf/gallery-dl/issues/150))\n- Snap improvements ([#197](https://github.com/mikf/gallery-dl/issues/197), [#199](https://github.com/mikf/gallery-dl/issues/199), [#207](https://github.com/mikf/gallery-dl/issues/207))\n### Changes\n- Better FFmpeg arguments for `--ugoira-conv`\n- Adjusted metadata for `luscious` albums\n### Fixes\n- Proper handling of `instagram` multi-image posts ([#178](https://github.com/mikf/gallery-dl/issues/178), [#201](https://github.com/mikf/gallery-dl/issues/201))\n- Fixed `tumblr` avatar URLs when not using OAuth1.0 ([#193](https://github.com/mikf/gallery-dl/issues/193))\n- Miscellaneous fixes for `exhentai`, `komikcast`\n\n## 1.8.0 - 2019-03-15\n### Additions\n- Support for:\n  - `weibo`       - https://www.weibo.com/\n  - `pururin`     - https://pururin.io/          ([#174](https://github.com/mikf/gallery-dl/issues/174))\n  - `fashionnova` - https://www.fashionnova.com/ ([#175](https://github.com/mikf/gallery-dl/issues/175))\n  - `shopify` sites in general ([#175](https://github.com/mikf/gallery-dl/issues/175))\n- Snap packaging ([#169](https://github.com/mikf/gallery-dl/issues/169), [#170](https://github.com/mikf/gallery-dl/issues/170), [#187](https://github.com/mikf/gallery-dl/issues/187), [#188](https://github.com/mikf/gallery-dl/issues/188))\n- Automatic Cloudflare DDoS protection bypass\n- Extractor and Job information for logging format strings\n- `dynastyscans` image and search extractors ([#163](https://github.com/mikf/gallery-dl/issues/163))\n- `deviantart` scraps extractor ([#168](https://github.com/mikf/gallery-dl/issues/168))\n- `artstation` extractor for artwork listings ([#172](https://github.com/mikf/gallery-dl/issues/172))\n- `smugmug` video support and improved image format selection ([#183](https://github.com/mikf/gallery-dl/issues/183))\n### Changes\n- More metadata for `nhentai` galleries\n- Combined `myportfolio` extractors into one\n- Renamed `name` metadata field to `filename` and removed the original `filename` field\n- Simplified and improved internal data structures\n- Optimized creation of child extractors\n### Fixes\n- Filter empty `tumblr` URLs ([#165](https://github.com/mikf/gallery-dl/issues/165))\n- Filter ads and improve connection speed on `hentaifoundry`\n- Show proper error messages if `luscious` galleries are unavailable\n- Miscellaneous fixes for `mangahere`, `ngomik`, `simplyhentai`, `imgspice`\n### Removals\n- `seaotterscans`\n\n## 1.7.0 - 2019-02-05\n- Added support for:\n  - `photobucket` - http://photobucket.com/ ([#117](https://github.com/mikf/gallery-dl/issues/117))\n  - `hentaifox` - https://hentaifox.com/ ([#160](https://github.com/mikf/gallery-dl/issues/160))\n  - `tsumino` - https://www.tsumino.com/ ([#161](https://github.com/mikf/gallery-dl/issues/161))\n- Added the ability to dynamically generate extractors based on a user's config file for\n  - [`mastodon`](https://github.com/tootsuite/mastodon) instances ([#144](https://github.com/mikf/gallery-dl/issues/144))\n  - [`foolslide`](https://github.com/FoolCode/FoOlSlide) based sites\n  - [`foolfuuka`](https://github.com/FoolCode/FoolFuuka) based archives\n- Added an extractor for `behance` collections ([#157](https://github.com/mikf/gallery-dl/issues/157))\n- Added login support for `luscious` ([#159](https://github.com/mikf/gallery-dl/issues/159)) and `tsumino` ([#161](https://github.com/mikf/gallery-dl/issues/161))\n- Added an option to stop downloading if the `exhentai` image limit is exceeded ([#141](https://github.com/mikf/gallery-dl/issues/141))\n- Fixed extraction issues for `behance` and `mangapark`\n\n## 1.6.3 - 2019-01-18\n- Added `metadata` post-processor to write image metadata to an external file ([#135](https://github.com/mikf/gallery-dl/issues/135))\n- Added option to reverse chapter order of manga extractors ([#149](https://github.com/mikf/gallery-dl/issues/149))\n- Added authentication support for `danbooru` ([#151](https://github.com/mikf/gallery-dl/issues/151))\n- Added tag metadata for `exhentai` and `hbrowse` galleries\n- Improved `*reactor` extractors ([#148](https://github.com/mikf/gallery-dl/issues/148))\n- Fixed extraction issues for `nhentai` ([#156](https://github.com/mikf/gallery-dl/issues/156)), `pinterest`, `mangapark`\n\n## 1.6.2 - 2019-01-01\n- Added support for:\n  - `instagram` - https://www.instagram.com/ ([#134](https://github.com/mikf/gallery-dl/issues/134))\n- Added support for multiple items on sta.sh pages ([#113](https://github.com/mikf/gallery-dl/issues/113))\n- Added option to download `tumblr` avatars ([#137](https://github.com/mikf/gallery-dl/issues/137))\n- Changed defaults for visited post types and inline media on `tumblr`\n- Improved inline extraction of `tumblr` posts ([#133](https://github.com/mikf/gallery-dl/issues/133), [#137](https://github.com/mikf/gallery-dl/issues/137))\n- Improved error handling and retry behavior of all API calls\n- Improved handling of missing fields in format strings ([#136](https://github.com/mikf/gallery-dl/issues/136))\n- Fixed hash extraction for unusual `tumblr` URLs ([#129](https://github.com/mikf/gallery-dl/issues/129))\n- Fixed image subdomains for `hitomi` galleries ([#142](https://github.com/mikf/gallery-dl/issues/142))\n- Fixed and improved miscellaneous issues for `kissmanga` ([#20](https://github.com/mikf/gallery-dl/issues/20)), `luscious`, `mangapark`, `readcomiconline`\n\n## 1.6.1 - 2018-11-28\n- Added support for:\n  - `joyreactor` - http://joyreactor.cc/ ([#114](https://github.com/mikf/gallery-dl/issues/114))\n  - `pornreactor` - http://pornreactor.cc/ ([#114](https://github.com/mikf/gallery-dl/issues/114))\n  - `newgrounds` - https://www.newgrounds.com/ ([#119](https://github.com/mikf/gallery-dl/issues/119))\n- Added extractor for search results on `luscious` ([#127](https://github.com/mikf/gallery-dl/issues/127))\n- Fixed filenames of ZIP archives ([#126](https://github.com/mikf/gallery-dl/issues/126))\n- Fixed extraction issues for `gfycat`, `hentaifoundry` ([#125](https://github.com/mikf/gallery-dl/issues/125)), `mangafox`\n\n## 1.6.0 - 2018-11-17\n- Added support for:\n  - `wallhaven` - https://alpha.wallhaven.cc/\n  - `yuki` - https://yuki.la/\n- Added youtube-dl integration and video downloads for `twitter` ([#99](https://github.com/mikf/gallery-dl/issues/99)), `behance`, `artstation`\n- Added per-extractor options for network connections (`retries`, `timeout`, `verify`)\n- Added a `--no-check-certificate` command-line option\n- Added ability to specify the number of skipped downloads before aborting/exiting ([#115](https://github.com/mikf/gallery-dl/issues/115))\n- Added extractors for scraps, favorites, popular and recent images on `hentaifoundry` ([#110](https://github.com/mikf/gallery-dl/issues/110))\n- Improved login procedure for `pixiv`  to avoid unwanted emails on each new login\n- Improved album metadata and error handling for `flickr` ([#109](https://github.com/mikf/gallery-dl/issues/109))\n- Updated default User-Agent string to Firefox 62 ([#122](https://github.com/mikf/gallery-dl/issues/122))\n- Fixed `twitter` API response handling when logged in ([#123](https://github.com/mikf/gallery-dl/issues/123))\n- Fixed issue when converting Ugoira using H.264\n- Fixed miscellaneous issues for `2chan`, `deviantart`, `fallenangels`, `flickr`, `imagefap`, `pinterest`, `turboimagehost`, `warosu`, `yuki` ([#112](https://github.com/mikf/gallery-dl/issues/112))\n\n## 1.5.3 - 2018-09-14\n- Added support for:\n  - `hentaicafe` - https://hentai.cafe/ ([#101](https://github.com/mikf/gallery-dl/issues/101))\n  - `bobx` - http://www.bobx.com/dark/\n- Added black-/whitelist options for post-processor modules\n- Added support for `tumblr` inline videos ([#102](https://github.com/mikf/gallery-dl/issues/102))\n- Fixed extraction of `smugmug` albums without owner ([#100](https://github.com/mikf/gallery-dl/issues/100))\n- Fixed issues when using default config values with `reddit` extractors ([#104](https://github.com/mikf/gallery-dl/issues/104))\n- Fixed pagination for user favorites on `sankaku` ([#106](https://github.com/mikf/gallery-dl/issues/106))\n- Fixed a crash when processing `deviantart` journals ([#108](https://github.com/mikf/gallery-dl/issues/108))\n\n## 1.5.2 - 2018-08-31\n- Added support for `twitter` timelines ([#96](https://github.com/mikf/gallery-dl/issues/96))\n- Added option to suppress FFmpeg output during ugoira conversions\n- Improved filename formatter performance\n- Improved inline image quality on `tumblr` ([#98](https://github.com/mikf/gallery-dl/issues/98))\n- Fixed image URLs for newly released `mangadex` chapters\n- Fixed a smaller issue with `deviantart` journals\n- Replaced `subapics` with `ngomik`\n\n## 1.5.1 - 2018-08-17\n- Added support for:\n  - `piczel` - https://piczel.tv/\n- Added support for related pins on `pinterest`\n- Fixed accessing \"offensive\" galleries on `exhentai` ([#97](https://github.com/mikf/gallery-dl/issues/97))\n- Fixed extraction issues for `mangadex`, `komikcast` and `behance`\n- Removed original-image functionality from `tumblr`, since \"raw\" images are no longer accessible\n\n## 1.5.0 - 2018-08-03\n- Added support for:\n  - `behance` - https://www.behance.net/\n  - `myportfolio` - https://www.myportfolio.com/ ([#95](https://github.com/mikf/gallery-dl/issues/95))\n- Added custom format string options to handle long strings ([#92](https://github.com/mikf/gallery-dl/issues/92), [#94](https://github.com/mikf/gallery-dl/issues/94))\n  - Slicing: `\"{field[10:40]}\"`\n  - Replacement: `\"{field:L40/too long/}\"`\n- Improved frame rate handling for ugoira conversions\n- Improved private access token usage on `deviantart`\n- Fixed metadata extraction for some images on `nijie`\n- Fixed chapter extraction on `mangahere`\n- Removed `whatisthisimnotgoodwithcomputers`\n- Removed support for Python 3.3\n\n## 1.4.2 - 2018-07-06\n- Added image-pool extractors for `safebooru` and `rule34`\n- Added option for extended tag information on `booru` sites ([#92](https://github.com/mikf/gallery-dl/issues/92))\n- Added support for DeviantArt's new URL format\n- Added support for `mangapark` mirrors\n- Changed `imagefap` extractors to use HTTPS\n- Fixed crash when skipping downloads for files without known extension\n\n## 1.4.1 - 2018-06-22\n- Added an `ugoira` post-processor to convert  `pixiv` animations to WebM\n- Added `--zip` and `--ugoira-conv` command-line options\n- Changed how ugoira frame information is handled\n  - instead of being written to a separate file, it is now made available as metadata field of the ZIP archive\n- Fixed manga and chapter titles for `mangadex`\n- Fixed file deletion by post-processors\n\n## 1.4.0 - 2018-06-08\n- Added support for:\n  - `simplyhentai` - https://www.simply-hentai.com/ ([#89](https://github.com/mikf/gallery-dl/issues/89))\n- Added extractors for\n  - `pixiv` search results and followed users\n  - `deviantart` search results and popular listings\n- Added post-processors to perform actions on downloaded files\n- Added options to configure logging behavior\n- Added OAuth support for `smugmug`\n- Changed `pixiv` extractors to use the AppAPI\n  - this breaks `favorite` archive IDs and changes some metadata fields\n- Changed the default filename format for `tumblr` and renamed `offset` to `num`\n- Fixed a possible UnicodeDecodeError during installation ([#86](https://github.com/mikf/gallery-dl/issues/86))\n- Fixed extraction of `mangadex` manga with more than 100 chapters ([#84](https://github.com/mikf/gallery-dl/issues/84))\n- Fixed miscellaneous issues for `imgur`, `reddit`, `komikcast`, `mangafox` and `imagebam`\n\n## 1.3.5 - 2018-05-04\n- Added support for:\n  - `smugmug` - https://www.smugmug.com/\n- Added title information for `mangadex` chapters\n- Improved the `pinterest` API implementation ([#83](https://github.com/mikf/gallery-dl/issues/83))\n- Improved error handling for `deviantart` and `tumblr`\n- Removed `gomanga` and `puremashiro`\n\n## 1.3.4 - 2018-04-20\n- Added support for custom OAuth2 credentials for `pinterest`\n- Improved rate limit handling for `tumblr` extractors\n- Improved `hentaifoundry` extractors\n- Improved `imgur` URL patterns\n- Fixed miscellaneous extraction issues for `luscious` and `komikcast`\n- Removed `loveisover` and `spectrumnexus`\n\n## 1.3.3 - 2018-04-06\n- Added extractors for\n  - `nhentai` search results\n  - `exhentai` search results and favorites\n  - `nijie` doujins and favorites\n- Improved metadata extraction for `exhentai` and `nijie`\n- Improved `tumblr` extractors by avoiding unnecessary API calls\n- Fixed Cloudflare DDoS protection bypass\n- Fixed errors when trying to print unencodable characters\n\n## 1.3.2 - 2018-03-23\n- Added extractors for `artstation` albums, challenges and search results\n- Improved URL and metadata extraction for `hitomi`and `nhentai`\n- Fixed page transitions for `danbooru` API results ([#82](https://github.com/mikf/gallery-dl/issues/82))\n\n## 1.3.1 - 2018-03-16\n- Added support for:\n  - `mangadex` - https://mangadex.org/\n  - `artstation` - https://www.artstation.com/\n- Added Cloudflare DDoS protection bypass to `komikcast` extractors\n- Changed archive ID formats for `deviantart` folders and collections\n- Improved error handling for `deviantart` API calls\n- Removed `imgchili` and various smaller image hosts\n\n## 1.3.0 - 2018-03-02\n- Added `--proxy` to explicitly specify a proxy server ([#76](https://github.com/mikf/gallery-dl/issues/76))\n- Added options to customize [archive ID formats](https://github.com/mikf/gallery-dl/blob/master/docs/configuration.rst#extractorarchive-format) and [undefined replacement fields](https://github.com/mikf/gallery-dl/blob/master/docs/configuration.rst#extractorkeywords-default)\n- Changed various archive ID formats to improve their behavior for favorites / bookmarks / etc.\n  - Affected modules are `deviantart`, `flickr`, `tumblr`, `pixiv` and all …boorus\n- Improved `sankaku` and `idolcomplex` support by\n  - respecting `page` and `next` URL parameters ([#79](https://github.com/mikf/gallery-dl/issues/79))\n  - bypassing the page-limit for unauthenticated users\n- Improved `directlink` metadata by properly unquoting it\n- Fixed `pixiv` ugoira extraction ([#78](https://github.com/mikf/gallery-dl/issues/78))\n- Fixed miscellaneous extraction issues for `mangastream` and `tumblr`\n- Removed `yeet`, `chronos`, `coreimg`, `hosturimage`, `imageontime`, `img4ever`, `imgmaid`, `imgupload`\n\n## 1.2.0 - 2018-02-16\n- Added support for:\n  - `paheal` - https://rule34.paheal.net/ ([#69](https://github.com/mikf/gallery-dl/issues/69))\n  - `komikcast` - https://komikcast.com/ ([#70](https://github.com/mikf/gallery-dl/issues/70))\n  - `subapics` - http://subapics.com/ ([#70](https://github.com/mikf/gallery-dl/issues/70))\n- Added `--download-archive` to record downloaded files in an archive file\n- Added `--write-log` to write logging output to a file\n- Added a filetype check on download completion to fix incorrectly assigned filename extensions ([#63](https://github.com/mikf/gallery-dl/issues/63))\n- Added the `tumblr:...` pseudo URI scheme to support custom domains for Tumblr blogs ([#71](https://github.com/mikf/gallery-dl/issues/71))\n- Added fallback URLs for `tumblr` images ([#64](https://github.com/mikf/gallery-dl/issues/64))\n- Added support for `reddit`-hosted images ([#68](https://github.com/mikf/gallery-dl/issues/68))\n- Improved the input file format by allowing comments and per-URL options\n- Fixed OAuth 1.0 signature generation for Python 3.3 and 3.4 ([#75](https://github.com/mikf/gallery-dl/issues/75))\n- Fixed smaller issues for `luscious`, `hentai2read`, `hentaihere` and `imgur`\n- Removed the `batoto` module\n\n## 1.1.2 - 2018-01-12\n- Added support for:\n  - `puremashiro` - http://reader.puremashiro.moe/ ([#66](https://github.com/mikf/gallery-dl/issues/66))\n  - `idolcomplex` - https://idol.sankakucomplex.com/\n- Added an option to filter reblogs on `tumblr` ([#61](https://github.com/mikf/gallery-dl/issues/61))\n- Added OAuth user authentication for `tumblr` ([#65](https://github.com/mikf/gallery-dl/issues/65))\n- Added support for `slideshare` mobile URLs ([#67](https://github.com/mikf/gallery-dl/issues/67))\n- Improved pagination for various …booru sites to work around page limits\n- Fixed chapter information parsing for certain manga on `kissmanga` ([#58](https://github.com/mikf/gallery-dl/issues/58)) and `batoto` ([#60](https://github.com/mikf/gallery-dl/issues/60))\n\n## 1.1.1 - 2017-12-22\n- Added support for:\n  - `slideshare` - https://www.slideshare.net/ ([#54](https://github.com/mikf/gallery-dl/issues/54))\n- Added pool- and post-extractors for `sankaku`\n- Added OAuth user authentication for `deviantart`\n- Updated `luscious` to support `members.luscious.net` URLs ([#55](https://github.com/mikf/gallery-dl/issues/55))\n- Updated `mangahere` to use their new domain name (mangahere.cc) and support mobile URLs\n- Updated `gelbooru` to not be restricted to the first 20,000 images ([#56](https://github.com/mikf/gallery-dl/issues/56))\n- Fixed extraction issues for `nhentai` and `khinsider`\n\n## 1.1.0 - 2017-12-08\n- Added the ``-r/--limit-rate`` command-line option to set a maximum download rate\n- Added the ``--sleep`` command-line option to specify the number of seconds to sleep before each download\n- Updated `gelbooru` to no longer use their now disabled API\n- Fixed SWF extraction for `sankaku` ([#52](https://github.com/mikf/gallery-dl/issues/52))\n- Fixed extraction issues for `hentai2read` and `khinsider`\n- Removed the deprecated `--images` and `--chapters` options\n- Removed the ``mangazuki`` module\n\n## 1.0.2 - 2017-11-24\n- Added an option to set a [custom user-agent string](https://github.com/mikf/gallery-dl/blob/master/docs/configuration.rst#extractoruser-agent)\n- Improved retry behavior for failed HTTP requests\n- Improved `seiga` by providing better metadata and getting more than the latest 200 images\n- Improved `tumblr` by adding support for [all post types](https://github.com/mikf/gallery-dl/blob/master/docs/configuration.rst#extractortumblrposts), scanning for [inline images](https://github.com/mikf/gallery-dl/blob/master/docs/configuration.rst#extractortumblrinline) and following [external links](https://github.com/mikf/gallery-dl/blob/master/docs/configuration.rst#extractortumblrexternal) ([#48](https://github.com/mikf/gallery-dl/issues/48))\n- Fixed extraction issues for `hbrowse`, `khinsider` and `senmanga`\n\n## 1.0.1 - 2017-11-10\n- Added support for:\n  - `xvideos` - https://www.xvideos.com/ ([#45](https://github.com/mikf/gallery-dl/issues/45))\n- Fixed exception handling during file downloads which could lead to a premature exit\n- Fixed an issue with `tumblr` where not all images would be downloaded when using tags ([#48](https://github.com/mikf/gallery-dl/issues/48))\n- Fixed extraction issues for `imgbox` ([#47](https://github.com/mikf/gallery-dl/issues/47)), `mangastream` ([#49](https://github.com/mikf/gallery-dl/issues/49)) and `mangahere`\n\n## 1.0.0 - 2017-10-27\n- Added support for:\n  - `warosu` - https://warosu.org/\n  - `b4k` - https://arch.b4k.co/\n- Added support for `pixiv` ranking lists\n- Added support for `booru` popular lists (`danbooru`, `e621`, `konachan`, `yandere`, `3dbooru`)\n- Added the `--cookies` command-line and [`cookies`](https://github.com/mikf/gallery-dl/blob/master/docs/configuration.rst#extractorcookies) config option to load additional cookies\n- Added the `--filter` and `--chapter-filter` command-line options to select individual images or manga-chapters by their metadata using simple Python expressions ([#43](https://github.com/mikf/gallery-dl/issues/43))\n- Added the [`verify`](https://github.com/mikf/gallery-dl/blob/master/docs/configuration.rst#downloaderhttpverify) config option to control certificate verification during file downloads\n- Added config options to overwrite internally used API credentials ([API Tokens & IDs](https://github.com/mikf/gallery-dl/blob/master/docs/configuration.rst#api-tokens-ids))\n- Added `-K` as a shortcut for `--list-keywords`\n- Changed the `--images` and `--chapters` command-line options to `--range` and `--chapter-range`\n- Changed keyword names for various modules to make them accessible by `--filter`. In general minus signs have been replaced with underscores (e.g. `gallery-id`  -> `gallery_id`).\n- Changed default filename formats for manga extractors to optionally use volume and title information\n- Improved the downloader modules to use [`.part` files](https://github.com/mikf/gallery-dl/blob/master/docs/configuration.rst#downloaderpart) and support resuming incomplete downloads ([#29](https://github.com/mikf/gallery-dl/issues/29))\n- Improved `deviantart` by distinguishing between users and groups ([#26](https://github.com/mikf/gallery-dl/issues/26)), always using HTTPS, and always downloading full-sized original images\n- Improved `sankaku` by adding authentication support and fixing various other issues ([#44](https://github.com/mikf/gallery-dl/issues/44))\n- Improved URL pattern for direct image links ([#30](https://github.com/mikf/gallery-dl/issues/30))\n- Fixed an issue with `luscious` not getting original image URLs ([#33](https://github.com/mikf/gallery-dl/issues/33))\n- Fixed various smaller issues for `batoto`, `hentai2read` ([#38](https://github.com/mikf/gallery-dl/issues/38)), `jaiminisbox`, `khinsider`, `kissmanga` ([#28](https://github.com/mikf/gallery-dl/issues/28), [#46](https://github.com/mikf/gallery-dl/issues/46)), `mangahere`, `pawoo`, `twitter`\n- Removed `kisscomic` and `yonkouprod` modules\n\n## 0.9.1 - 2017-07-24\n- Added support for:\n  - `2chan` - https://www.2chan.net/\n  - `4plebs` - https://archive.4plebs.org/\n  - `archivedmoe` - https://archived.moe/\n  - `archiveofsins` - https://archiveofsins.com/\n  - `desuarchive` - https://desuarchive.org/\n  - `fireden` - https://boards.fireden.net/\n  - `loveisover` - https://archive.loveisover.me/\n  - `nyafuu` - https://archive.nyafuu.org/\n  - `rbt` - https://rbt.asia/\n  - `thebarchive` - https://thebarchive.com/\n  - `mangazuki` - https://mangazuki.co/\n- Improved `reddit` to allow submission filtering by ID and human-readable dates\n- Improved `deviantart` to support group galleries and gallery folders ([#26](https://github.com/mikf/gallery-dl/issues/26))\n- Changed `deviantart` to use better default path formats\n- Fixed extraction of larger `imgur` albums\n- Fixed some smaller issues for `pixiv`, `batoto` and `fallenangels`\n\n## 0.9.0 - 2017-06-28\n- Added support for:\n  - `reddit` - https://www.reddit.com/ ([#15](https://github.com/mikf/gallery-dl/issues/15))\n  - `flickr` - https://www.flickr.com/ ([#16](https://github.com/mikf/gallery-dl/issues/16))\n  - `gfycat` - https://gfycat.com/\n- Added support for direct image links\n- Added user authentication via [OAuth](https://github.com/mikf/gallery-dl#52oauth) for `reddit` and `flickr`\n- Added support for user authentication data from [`.netrc`](https://stackoverflow.com/tags/.netrc/info) files ([#22](https://github.com/mikf/gallery-dl/issues/22))\n- Added a simple progress indicator for multiple URLs ([#19](https://github.com/mikf/gallery-dl/issues/19))\n- Added the `--write-unsupported` command-line option to write unsupported URLs to a file\n- Added documentation for all available config options ([configuration.rst](https://github.com/mikf/gallery-dl/blob/master/docs/configuration.rst))\n- Improved `pixiv` to support tags for user downloads ([#17](https://github.com/mikf/gallery-dl/issues/17))\n- Improved `pixiv` to support shortened and http://pixiv.me/... URLs ([#23](https://github.com/mikf/gallery-dl/issues/23))\n- Improved `imgur` to properly handle `.gifv` images and provide better metadata\n- Fixed an issue with `kissmanga` where metadata parsing for some series failed ([#20](https://github.com/mikf/gallery-dl/issues/20))\n- Fixed an issue with getting filename extensions from `Content-Type` response headers\n\n## 0.8.4 - 2017-05-21\n- Added the `--abort-on-skip` option to stop extraction if a download would be skipped\n- Improved the output format of the `--list-keywords` option\n- Updated `deviantart` to support all media types and journals\n- Updated `fallenangels` to support their [Vietnamese version](https://truyen.fascans.com/)\n- Fixed an issue with multiple tags on ...booru sites\n- Removed the `yomanga` module\n\n## 0.8.3 - 2017-05-01\n- Added support for https://pawoo.net/\n- Added manga extractors for all [FoOlSlide](https://foolcode.github.io/FoOlSlide/)-based modules\n- Added the `-q/--quiet` and `-v/--verbose` options to control output verbosity\n- Added the `-j/--dump-json` option to dump extractor results in JSON format\n- Added the `--ignore-config` option\n- Updated the `exhentai` extractor to fall back to using the e-hentai version if no username is given\n- Updated `deviantart` to support sta.sh URLs\n- Fixed an issue with `kissmanga` which prevented image URLs from being decrypted properly (again)\n- Fixed an issue with `pixhost` where for an image inside an album it would always download the first image of that album ([#13](https://github.com/mikf/gallery-dl/issues/13))\n- Removed the `mangashare` and `readcomics` modules\n\n## 0.8.2 - 2017-04-10\n- Fixed an issue in `kissmanga` which prevented image URLs from being decrypted properly\n\n## 0.8.1 - 2017-04-09\n- Added new extractors:\n  - `kireicake` - https://reader.kireicake.com/\n  - `seaotterscans` - https://reader.seaotterscans.com/\n- Added a favourites extractor for `deviantart`\n- Re-enabled the `kissmanga` module\n- Updated `nijie` to support multi-page image listings\n- Updated `mangastream` to support readms.net URLs\n- Updated `exhentai` to support e-hentai.org URLs\n- Updated `fallenangels` to support their new domain and site layout\n\n## 0.8.0 - 2017-03-28\n- Added logging support\n- Added the `-R/--retries` option to specify how often a download should be retried before giving up\n- Added the `--http-timeout` option to set a timeout for HTTP connections\n- Improved error handling/tolerance during HTTP file downloads ([#10](https://github.com/mikf/gallery-dl/issues/10))\n- Improved option parsing and the help message from `-h/--help`\n- Changed the way configuration values are used by prioritizing top-level values\n  - This allows for cmdline options like `-u/--username` to overwrite values set in configuration files\n- Fixed an issue with `imagefap.com` where incorrectly reported gallery sizes would cause the extractor to fail ([#9](https://github.com/mikf/gallery-dl/issues/9))\n- Fixed an issue with `seiga.nicovideo.jp` where invalid characters in an API response caused the XML parser to fail\n- Fixed an issue with `seiga.nicovideo.jp` where the filename extension for the first image would be used for all others\n- Removed support for old configuration paths on Windows\n- Removed several modules:\n  - `mangamint`: site is down\n  - `whentai`: now requires account with VIP status for original images\n  - `kissmanga`: encrypted image URLs (will be re-added later)\n\n## 0.7.0 - 2017-03-06\n- Added `--images` and `--chapters` options\n  - Specifies which images (or chapters) to download through a comma-separated list of indices or index-ranges\n  - Example: `--images -2,4,6-8,10-` will select images with index 1, 2, 4, 6, 7, 8 and 10 up to the last one\n- Changed the `-g`/`--get-urls` option\n  - The amount of how often the -g option is given now determines up until which level URLs are resolved.\n  - See 3bca86618505c21628cd9c7179ce933a78d00ca2\n- Changed several option keys:\n  - `directory_fmt` -> `directory`\n  - `filename_fmt` -> `filename`\n  - `download-original` -> `original`\n- Improved [FoOlSlide](https://foolcode.github.io/FoOlSlide/)-based extractors\n- Fixed URL extraction for hentai2read\n- Fixed an issue with deviantart, where the API access token wouldn't get refreshed\n\n## 0.6.4 - 2017-02-13\n- Added new extractors:\n  - fallenangels (famatg.com)\n- Fixed url- and data-extraction for:\n  - nhentai\n  - mangamint\n  - twitter\n  - imagetwist\n- Disabled InsecureConnectionWarning when no certificates are available\n\n## 0.6.3 - 2017-01-25\n- Added new extractors:\n  - gomanga\n  - yomanga\n  - mangafox\n- Fixed deviantart extractor failing - switched to using their API\n- Fixed an issue with SQLite on Python 3.6\n- Automated test builds via Travis CI\n- Standalone executables for Windows\n\n## 0.6.2 - 2017-01-05\n- Added new extractors:\n  - kisscomic\n  - readcomics\n  - yonkouprod\n  - jaiminisbox\n- Added manga extractor to batoto-module\n- Added user extractor to seiga-module\n- Added `-i`/`--input-file` argument to allow local files and stdin as input (like wget)\n- Added basic support for `file://` URLs\n  - this allows for the recursive extractor to be applied to local files:\n  - `$ gallery-dl r:file://[path to file]`\n- Added a utility extractor to run unit test URLs\n- Updated luscious to deal with API changes\n- Fixed twitter to provide the original image URL\n- Minor fixes to hentaifoundry\n- Removed imgclick extractor\n\n## 0.6.1 - 2016-11-30\n- Added new extractors:\n  - whentai\n  - readcomiconline\n  - sensescans, worldthree\n  - imgmaid, imagevenue, img4ever, imgspot, imgtrial, pixhost\n- Added base class for extractors of [FoOlSlide](https://foolcode.github.io/FoOlSlide/)-based sites\n- Changed default paths for configuration files on Windows\n  - old paths are still supported, but that will change in future versions\n- Fixed aborting downloads if a single one failed ([#5](https://github.com/mikf/gallery-dl/issues/5))\n- Fixed cloudflare-bypass cache containing outdated cookies\n- Fixed image URLs for hitomi and 8chan\n- Updated deviantart to always provide the highest quality image\n- Updated README.rst\n- Removed doujinmode extractor\n\n## 0.6.0 - 2016-10-08\n- Added new extractors:\n  - hentaihere\n  - dokireader\n  - twitter\n  - rapidimg, picmaniac\n- Added support to find filename extensions by Content-Type response header\n- Fixed filename/path issues on Windows ([#4](https://github.com/mikf/gallery-dl/issues/4)):\n  - Enable path names with more than 260 characters\n  - Remove trailing spaces in path segments\n- Updated Job class to automatically set category/subcategory keywords\n\n## 0.5.2 - 2016-09-23\n- Added new extractors:\n  - pinterest\n  - rule34\n  - dynastyscans\n  - imagebam, coreimg, imgcandy, imgtrex\n- Added login capabilities for batoto\n- Added `--version` cmdline argument to print the current program version and exit\n- Added `--list-extractors` cmdline argument to print names of all extractor classes together with descriptions and example URLs\n- Added proper error messages if an image/user does not exist\n- Added unittests for every extractor\n\n## 0.5.1 - 2016-08-22\n- Added new extractors:\n  - luscious\n  - doujinmode\n  - hentaibox\n  - seiga\n  - imagefap\n- Changed error output to use stderr instead of stdout\n- Fixed broken pipes causing an exception-dump by catching BrokenPipeErrors\n\n## 0.5.0 - 2016-07-25\n\n## 0.4.1 - 2015-12-03\n- New modules (imagetwist, turboimagehost)\n- Manga-extractors: Download entire manga and not just single chapters\n- Generic extractor (provisional)\n- Better and configurable console output\n- Windows support\n\n## 0.4.0 - 2015-11-26\n\n## 0.3.3 - 2015-11-10\n\n## 0.3.2 - 2015-11-04\n\n## 0.3.1 - 2015-10-30\n\n## 0.3.0 - 2015-10-05\n\n## 0.2.0 - 2015-06-28\n\n## 0.1.0 - 2015-05-27\n"
  },
  {
    "path": "Dockerfile",
    "content": "FROM python:3.14-alpine\nENV LANG=C.UTF-8\n\nRUN : \\\n    && apk --no-interactive update \\\n    && apk --no-interactive --no-cache add ffmpeg \\\n    && rm -rf /var/cache/apk \\\n    && :\n\nRUN : \\\n    && python3 -B -m pip --no-cache-dir --no-input --disable-pip-version-check install --root-user-action ignore -U \\\n        pip \\\n    && python3 -B -m pip --no-cache-dir --no-input --disable-pip-version-check install --root-user-action ignore -U \\\n        https://github.com/mikf/gallery-dl/archive/refs/heads/master.tar.gz \\\n        yt-dlp[default] \\\n        requests[socks] \\\n        truststore \\\n        jinja2 \\\n        pyyaml \\\n    && ( rm -rf /root/.cache/pip || true ) \\\n    && ( find /usr/local/lib/python3.*/site-packages/setuptools -name __pycache__ -exec rm -rf {} + || true ) \\\n    && ( find /usr/local/lib/python3.*/site-packages/wheel      -name __pycache__ -exec rm -rf {} + || true ) \\\n    && :\n\nENTRYPOINT [ \"gallery-dl\" ]\n"
  },
  {
    "path": "LICENSE",
    "content": "                    GNU GENERAL PUBLIC LICENSE\n                       Version 2, June 1991\n\n Copyright (C) 1989, 1991 Free Software Foundation, Inc.,\n 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA\n Everyone is permitted to copy and distribute verbatim copies\n of this license document, but changing it is not allowed.\n\n                            Preamble\n\n  The licenses for most software are designed to take away your\nfreedom to share and change it.  By contrast, the GNU General Public\nLicense is intended to guarantee your freedom to share and change free\nsoftware--to make sure the software is free for all its users.  This\nGeneral Public License applies to most of the Free Software\nFoundation's software and to any other program whose authors commit to\nusing it.  (Some other Free Software Foundation software is covered by\nthe GNU Lesser General Public License instead.)  You can apply it to\nyour programs, too.\n\n  When we speak of free software, we are referring to freedom, not\nprice.  Our General Public Licenses are designed to make sure that you\nhave the freedom to distribute copies of free software (and charge for\nthis service if you wish), that you receive source code or can get it\nif you want it, that you can change the software or use pieces of it\nin new free programs; and that you know you can do these things.\n\n  To protect your rights, we need to make restrictions that forbid\nanyone to deny you these rights or to ask you to surrender the rights.\nThese restrictions translate to certain responsibilities for you if you\ndistribute copies of the software, or if you modify it.\n\n  For example, if you distribute copies of such a program, whether\ngratis or for a fee, you must give the recipients all the rights that\nyou have.  You must make sure that they, too, receive or can get the\nsource code.  And you must show them these terms so they know their\nrights.\n\n  We protect your rights with two steps: (1) copyright the software, and\n(2) offer you this license which gives you legal permission to copy,\ndistribute and/or modify the software.\n\n  Also, for each author's protection and ours, we want to make certain\nthat everyone understands that there is no warranty for this free\nsoftware.  If the software is modified by someone else and passed on, we\nwant its recipients to know that what they have is not the original, so\nthat any problems introduced by others will not reflect on the original\nauthors' reputations.\n\n  Finally, any free program is threatened constantly by software\npatents.  We wish to avoid the danger that redistributors of a free\nprogram will individually obtain patent licenses, in effect making the\nprogram proprietary.  To prevent this, we have made it clear that any\npatent must be licensed for everyone's free use or not licensed at all.\n\n  The precise terms and conditions for copying, distribution and\nmodification follow.\n\n                    GNU GENERAL PUBLIC LICENSE\n   TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION\n\n  0. This License applies to any program or other work which contains\na notice placed by the copyright holder saying it may be distributed\nunder the terms of this General Public License.  The \"Program\", below,\nrefers to any such program or work, and a \"work based on the Program\"\nmeans either the Program or any derivative work under copyright law:\nthat is to say, a work containing the Program or a portion of it,\neither verbatim or with modifications and/or translated into another\nlanguage.  (Hereinafter, translation is included without limitation in\nthe term \"modification\".)  Each licensee is addressed as \"you\".\n\nActivities other than copying, distribution and modification are not\ncovered by this License; they are outside its scope.  The act of\nrunning the Program is not restricted, and the output from the Program\nis covered only if its contents constitute a work based on the\nProgram (independent of having been made by running the Program).\nWhether that is true depends on what the Program does.\n\n  1. You may copy and distribute verbatim copies of the Program's\nsource code as you receive it, in any medium, provided that you\nconspicuously and appropriately publish on each copy an appropriate\ncopyright notice and disclaimer of warranty; keep intact all the\nnotices that refer to this License and to the absence of any warranty;\nand give any other recipients of the Program a copy of this License\nalong with the Program.\n\nYou may charge a fee for the physical act of transferring a copy, and\nyou may at your option offer warranty protection in exchange for a fee.\n\n  2. You may modify your copy or copies of the Program or any portion\nof it, thus forming a work based on the Program, and copy and\ndistribute such modifications or work under the terms of Section 1\nabove, provided that you also meet all of these conditions:\n\n    a) You must cause the modified files to carry prominent notices\n    stating that you changed the files and the date of any change.\n\n    b) You must cause any work that you distribute or publish, that in\n    whole or in part contains or is derived from the Program or any\n    part thereof, to be licensed as a whole at no charge to all third\n    parties under the terms of this License.\n\n    c) If the modified program normally reads commands interactively\n    when run, you must cause it, when started running for such\n    interactive use in the most ordinary way, to print or display an\n    announcement including an appropriate copyright notice and a\n    notice that there is no warranty (or else, saying that you provide\n    a warranty) and that users may redistribute the program under\n    these conditions, and telling the user how to view a copy of this\n    License.  (Exception: if the Program itself is interactive but\n    does not normally print such an announcement, your work based on\n    the Program is not required to print an announcement.)\n\nThese requirements apply to the modified work as a whole.  If\nidentifiable sections of that work are not derived from the Program,\nand can be reasonably considered independent and separate works in\nthemselves, then this License, and its terms, do not apply to those\nsections when you distribute them as separate works.  But when you\ndistribute the same sections as part of a whole which is a work based\non the Program, the distribution of the whole must be on the terms of\nthis License, whose permissions for other licensees extend to the\nentire whole, and thus to each and every part regardless of who wrote it.\n\nThus, it is not the intent of this section to claim rights or contest\nyour rights to work written entirely by you; rather, the intent is to\nexercise the right to control the distribution of derivative or\ncollective works based on the Program.\n\nIn addition, mere aggregation of another work not based on the Program\nwith the Program (or with a work based on the Program) on a volume of\na storage or distribution medium does not bring the other work under\nthe scope of this License.\n\n  3. You may copy and distribute the Program (or a work based on it,\nunder Section 2) in object code or executable form under the terms of\nSections 1 and 2 above provided that you also do one of the following:\n\n    a) Accompany it with the complete corresponding machine-readable\n    source code, which must be distributed under the terms of Sections\n    1 and 2 above on a medium customarily used for software interchange; or,\n\n    b) Accompany it with a written offer, valid for at least three\n    years, to give any third party, for a charge no more than your\n    cost of physically performing source distribution, a complete\n    machine-readable copy of the corresponding source code, to be\n    distributed under the terms of Sections 1 and 2 above on a medium\n    customarily used for software interchange; or,\n\n    c) Accompany it with the information you received as to the offer\n    to distribute corresponding source code.  (This alternative is\n    allowed only for noncommercial distribution and only if you\n    received the program in object code or executable form with such\n    an offer, in accord with Subsection b above.)\n\nThe source code for a work means the preferred form of the work for\nmaking modifications to it.  For an executable work, complete source\ncode means all the source code for all modules it contains, plus any\nassociated interface definition files, plus the scripts used to\ncontrol compilation and installation of the executable.  However, as a\nspecial exception, the source code distributed need not include\nanything that is normally distributed (in either source or binary\nform) with the major components (compiler, kernel, and so on) of the\noperating system on which the executable runs, unless that component\nitself accompanies the executable.\n\nIf distribution of executable or object code is made by offering\naccess to copy from a designated place, then offering equivalent\naccess to copy the source code from the same place counts as\ndistribution of the source code, even though third parties are not\ncompelled to copy the source along with the object code.\n\n  4. You may not copy, modify, sublicense, or distribute the Program\nexcept as expressly provided under this License.  Any attempt\notherwise to copy, modify, sublicense or distribute the Program is\nvoid, and will automatically terminate your rights under this License.\nHowever, parties who have received copies, or rights, from you under\nthis License will not have their licenses terminated so long as such\nparties remain in full compliance.\n\n  5. You are not required to accept this License, since you have not\nsigned it.  However, nothing else grants you permission to modify or\ndistribute the Program or its derivative works.  These actions are\nprohibited by law if you do not accept this License.  Therefore, by\nmodifying or distributing the Program (or any work based on the\nProgram), you indicate your acceptance of this License to do so, and\nall its terms and conditions for copying, distributing or modifying\nthe Program or works based on it.\n\n  6. Each time you redistribute the Program (or any work based on the\nProgram), the recipient automatically receives a license from the\noriginal licensor to copy, distribute or modify the Program subject to\nthese terms and conditions.  You may not impose any further\nrestrictions on the recipients' exercise of the rights granted herein.\nYou are not responsible for enforcing compliance by third parties to\nthis License.\n\n  7. If, as a consequence of a court judgment or allegation of patent\ninfringement or for any other reason (not limited to patent issues),\nconditions are imposed on you (whether by court order, agreement or\notherwise) that contradict the conditions of this License, they do not\nexcuse you from the conditions of this License.  If you cannot\ndistribute so as to satisfy simultaneously your obligations under this\nLicense and any other pertinent obligations, then as a consequence you\nmay not distribute the Program at all.  For example, if a patent\nlicense would not permit royalty-free redistribution of the Program by\nall those who receive copies directly or indirectly through you, then\nthe only way you could satisfy both it and this License would be to\nrefrain entirely from distribution of the Program.\n\nIf any portion of this section is held invalid or unenforceable under\nany particular circumstance, the balance of the section is intended to\napply and the section as a whole is intended to apply in other\ncircumstances.\n\nIt is not the purpose of this section to induce you to infringe any\npatents or other property right claims or to contest validity of any\nsuch claims; this section has the sole purpose of protecting the\nintegrity of the free software distribution system, which is\nimplemented by public license practices.  Many people have made\ngenerous contributions to the wide range of software distributed\nthrough that system in reliance on consistent application of that\nsystem; it is up to the author/donor to decide if he or she is willing\nto distribute software through any other system and a licensee cannot\nimpose that choice.\n\nThis section is intended to make thoroughly clear what is believed to\nbe a consequence of the rest of this License.\n\n  8. If the distribution and/or use of the Program is restricted in\ncertain countries either by patents or by copyrighted interfaces, the\noriginal copyright holder who places the Program under this License\nmay add an explicit geographical distribution limitation excluding\nthose countries, so that distribution is permitted only in or among\ncountries not thus excluded.  In such case, this License incorporates\nthe limitation as if written in the body of this License.\n\n  9. The Free Software Foundation may publish revised and/or new versions\nof the General Public License from time to time.  Such new versions will\nbe similar in spirit to the present version, but may differ in detail to\naddress new problems or concerns.\n\nEach version is given a distinguishing version number.  If the Program\nspecifies a version number of this License which applies to it and \"any\nlater version\", you have the option of following the terms and conditions\neither of that version or of any later version published by the Free\nSoftware Foundation.  If the Program does not specify a version number of\nthis License, you may choose any version ever published by the Free Software\nFoundation.\n\n  10. If you wish to incorporate parts of the Program into other free\nprograms whose distribution conditions are different, write to the author\nto ask for permission.  For software which is copyrighted by the Free\nSoftware Foundation, write to the Free Software Foundation; we sometimes\nmake exceptions for this.  Our decision will be guided by the two goals\nof preserving the free status of all derivatives of our free software and\nof promoting the sharing and reuse of software generally.\n\n                            NO WARRANTY\n\n  11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY\nFOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW.  EXCEPT WHEN\nOTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES\nPROVIDE THE PROGRAM \"AS IS\" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED\nOR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF\nMERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE.  THE ENTIRE RISK AS\nTO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU.  SHOULD THE\nPROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,\nREPAIR OR CORRECTION.\n\n  12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING\nWILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR\nREDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,\nINCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING\nOUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED\nTO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY\nYOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER\nPROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE\nPOSSIBILITY OF SUCH DAMAGES.\n\n                     END OF TERMS AND CONDITIONS\n\n            How to Apply These Terms to Your New Programs\n\n  If you develop a new program, and you want it to be of the greatest\npossible use to the public, the best way to achieve this is to make it\nfree software which everyone can redistribute and change under these terms.\n\n  To do so, attach the following notices to the program.  It is safest\nto attach them to the start of each source file to most effectively\nconvey the exclusion of warranty; and each file should have at least\nthe \"copyright\" line and a pointer to where the full notice is found.\n\n    <one line to give the program's name and a brief idea of what it does.>\n    Copyright (C) <year>  <name of author>\n\n    This program is free software; you can redistribute it and/or modify\n    it under the terms of the GNU General Public License as published by\n    the Free Software Foundation; either version 2 of the License, or\n    (at your option) any later version.\n\n    This program is distributed in the hope that it will be useful,\n    but WITHOUT ANY WARRANTY; without even the implied warranty of\n    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n    GNU General Public License for more details.\n\n    You should have received a copy of the GNU General Public License along\n    with this program; if not, write to the Free Software Foundation, Inc.,\n    51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.\n\nAlso add information on how to contact you by electronic and paper mail.\n\nIf the program is interactive, make it output a short notice like this\nwhen it starts in an interactive mode:\n\n    Gnomovision version 69, Copyright (C) year name of author\n    Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.\n    This is free software, and you are welcome to redistribute it\n    under certain conditions; type `show c' for details.\n\nThe hypothetical commands `show w' and `show c' should show the appropriate\nparts of the General Public License.  Of course, the commands you use may\nbe called something other than `show w' and `show c'; they could even be\nmouse-clicks or menu items--whatever suits your program.\n\nYou should also get your employer (if you work as a programmer) or your\nschool, if any, to sign a \"copyright disclaimer\" for the program, if\nnecessary.  Here is a sample; alter the names:\n\n  Yoyodyne, Inc., hereby disclaims all copyright interest in the program\n  `Gnomovision' (which makes passes at compilers) written by James Hacker.\n\n  <signature of Ty Coon>, 1 April 1989\n  Ty Coon, President of Vice\n\nThis General Public License does not permit incorporating your program into\nproprietary programs.  If your program is a subroutine library, you may\nconsider it more useful to permit linking proprietary applications with the\nlibrary.  If this is what you want to do, use the GNU Lesser General\nPublic License instead of this License.\n"
  },
  {
    "path": "MANIFEST.in",
    "content": "include README.rst CHANGELOG.md LICENSE scripts/run_tests.py\nrecursive-include docs *.conf\n"
  },
  {
    "path": "Makefile",
    "content": "\nPREFIX ?= /usr/local\nBINDIR ?= $(PREFIX)/bin\nMANDIR ?= $(PREFIX)/man\nSHAREDIR ?= $(PREFIX)/share\nPYTHON ?= /usr/bin/env python3\n\n\nall: man completion supportedsites options\n\nclean:\n\t$(RM) -r build/\n\t$(RM) -r data/\n\ninstall: man completion\n\t$(PYTHON) -m pip install gallery_dl\n\nrelease: man completion supportedsites\n\tscripts/release.sh\n\ntest:\n\tscripts/run_tests.py\n\nexecutable:\n\tscripts/pyinstaller.py\n\ncompletion: data/completion/gallery-dl data/completion/_gallery-dl data/completion/gallery-dl.fish\n\nman: data/man/gallery-dl.1 data/man/gallery-dl.conf.5\n\nsupportedsites: docs/supportedsites.md\n\noptions: docs/options.md\n\n.PHONY: all clean install release test executable completion man supportedsites options\n\ndocs/supportedsites.md: gallery_dl/*/*.py scripts/supportedsites.py\n\t$(PYTHON) scripts/supportedsites.py\n\ndocs/options.md: gallery_dl/option.py scripts/options.py\n\t$(PYTHON) scripts/options.py\n\ndata/man/gallery-dl.1: gallery_dl/option.py gallery_dl/version.py scripts/man.py\n\t$(PYTHON) scripts/man.py\n\ndata/man/gallery-dl.conf.5: docs/configuration.rst gallery_dl/version.py scripts/man.py\n\t$(PYTHON) scripts/man.py\n\ndata/completion/gallery-dl: gallery_dl/option.py scripts/completion_bash.py\n\t$(PYTHON) scripts/completion_bash.py\n\ndata/completion/_gallery-dl: gallery_dl/option.py scripts/completion_zsh.py\n\t$(PYTHON) scripts/completion_zsh.py\n\ndata/completion/gallery-dl.fish: gallery_dl/option.py scripts/completion_fish.py\n\t$(PYTHON) scripts/completion_fish.py\n"
  },
  {
    "path": "README.rst",
    "content": "==========\ngallery-dl\n==========\n\n*gallery-dl* is a command-line program\nto download image galleries and collections\nfrom several image hosting sites\n(see `Supported Sites <docs/supportedsites.md>`__).\nIt is a cross-platform tool\nwith many\n`command-line <https://gdl-org.github.io/docs/options.html>`__ and\n`configuration <https://gdl-org.github.io/docs/configuration.html>`__\noptions, as well as powerful\n`file-naming capabilities <https://gdl-org.github.io/docs/formatting.html>`__.\n\n\n|pypi| |discord| |build|\n\n.. contents::\n\n\nDependencies\n============\n\n- Python_ 3.8+\n- Requests_\n\nOptional\n--------\n\n- yt-dlp_ or youtube-dl_: HLS/DASH video downloads, ``ytdl`` integration\n- FFmpeg_: Pixiv Ugoira conversion\n- mkvmerge_: Accurate Ugoira frame timecodes\n- PySocks_: SOCKS proxy support\n- brotli_ or brotlicffi_: Brotli compression support\n- zstandard_: Zstandard compression support\n- PyYAML_: YAML configuration file support\n- toml_: TOML configuration file support for Python<3.11\n- SecretStorage_: GNOME keyring passwords for ``--cookies-from-browser``\n- Psycopg_: PostgreSQL archive support\n- truststore_: Native system certificate support\n- Jinja_: Jinja template support\n\n\nInstallation\n============\n\n\nPip\n---\n\nThe stable releases of *gallery-dl* are distributed on PyPI_ and can be\neasily installed or upgraded using pip_:\n\n.. code:: bash\n\n    python3 -m pip install -U gallery-dl\n\nInstalling the latest dev version directly from GitHub can be done with\npip_ as well:\n\n.. code:: bash\n\n    python3 -m pip install -U --force-reinstall --no-deps https://github.com/mikf/gallery-dl/archive/master.tar.gz\n\nOmit :code:`--no-deps` if Requests_ hasn't been installed yet.\n\nNote: Windows users should use :code:`py -3` instead of :code:`python3`.\n\nIt is advised to use the latest version of pip_,\nincluding the essential packages :code:`setuptools` and :code:`wheel`.\nTo ensure these packages are up-to-date, run\n\n.. code:: bash\n\n    python3 -m pip install --upgrade pip setuptools wheel\n\n\nStandalone Executable\n---------------------\n\nPrebuilt executable files with a Python interpreter and\nrequired Python packages included are available for\n\n- `Windows <https://github.com/mikf/gallery-dl/releases/download/v1.31.5/gallery-dl.exe>`__\n  (Requires `Microsoft Visual C++ Redistributable Package (x86) <https://aka.ms/vs/17/release/vc_redist.x86.exe>`__)\n- `Linux   <https://github.com/mikf/gallery-dl/releases/download/v1.31.5/gallery-dl.bin>`__\n\n\nNightly Builds\n--------------\n\n| Executables build from the latest commit can be found at\n| https://github.com/gdl-org/builds/releases\n\n\nSnap\n----\n\nLinux users that are using a distro that is supported by Snapd_ can install *gallery-dl* from the Snap Store:\n\n.. code:: bash\n\n    snap install gallery-dl\n\n\nChocolatey\n----------\n\nWindows users that have Chocolatey_ installed can install *gallery-dl* from the Chocolatey Community Packages repository:\n\n.. code:: powershell\n\n    choco install gallery-dl\n\n\nScoop\n-----\n\n*gallery-dl* is also available in the Scoop_ \"main\" bucket for Windows users:\n\n.. code:: powershell\n\n    scoop install gallery-dl\n\nHomebrew\n--------\n\nFor macOS or Linux users using Homebrew:\n\n.. code:: bash\n\n    brew install gallery-dl\n\nMacPorts\n--------\n\nFor macOS users with MacPorts:\n\n.. code:: bash\n\n    sudo port install gallery-dl\n\nDocker\n--------\nUsing the Dockerfile in the repository:\n\n.. code:: bash\n\n    git clone https://github.com/mikf/gallery-dl.git\n    cd gallery-dl/\n    docker build -t gallery-dl:latest .\n\nPulling image from `Docker Hub <https://hub.docker.com/r/mikf123/gallery-dl>`__:\n\n.. code:: bash\n\n    docker pull mikf123/gallery-dl\n    docker tag mikf123/gallery-dl gallery-dl\n\nPulling image from `GitHub Container Registry <https://github.com/mikf/gallery-dl/pkgs/container/gallery-dl>`__:\n\n.. code:: bash\n\n    docker pull ghcr.io/mikf/gallery-dl\n    docker tag ghcr.io/mikf/gallery-dl gallery-dl\n\nPulling *Nightly Build* images built from the latest commit by using the ``dev`` tag:\n\n.. code:: bash\n\n    docker pull mikf123/gallery-dl:dev\n    docker pull ghcr.io/mikf/gallery-dl:dev\n\nTo run the container you will probably want to attach some directories on the host so that the config file and downloads can persist across runs.\n\nMake sure to either download the example config file reference in the repo and place it in the mounted volume location or touch an empty file there.\n\nIf you gave the container a different tag or are using podman then make sure you adjust.  Run ``docker image ls`` to check the name if you are not sure.\n\nThis will remove the container after every use so you will always have a fresh environment for it to run. If you setup a ci-cd pipeline to autobuild the container you can also add a ``--pull=newer`` flag so that when you run it docker will check to see if there is a newer container and download it before running.\n\n.. code:: bash\n\n    docker run --rm  -v $HOME/Downloads/:/gallery-dl/ -v $HOME/.config/gallery-dl/gallery-dl.conf:/etc/gallery-dl.conf -it gallery-dl:latest\n\nYou can also add an alias to your shell for \"gallery-dl\" or create a simple bash script and drop it somewhere in your $PATH to act as a shim for this command.\n\nNix and Home Manager\n--------------------------\n\nAdding *gallery-dl* to your system environment:\n\n.. code:: nix\n\n    environment.systemPackages = with pkgs; [\n      gallery-dl\n    ];\n\nUsing :code:`nix-shell`\n\n.. code:: bash\n\n    nix-shell -p gallery-dl\n\n.. code:: bash\n\n    nix-shell -p gallery-dl --run \"gallery-dl <args>\"\n\nFor Home Manager users, you can manage *gallery-dl* declaratively:\n\n.. code:: nix\n\n    programs.gallery-dl = {\n      enable = true;\n      settings = {\n        extractor.base-directory = \"~/Downloads\";\n      };\n    };\n\nAlternatively, you can just add it to :code:`home.packages` if you don't want to manage it declaratively:\n\n.. code:: nix\n\n    home.packages = with pkgs; [\n      gallery-dl\n    ];\n\nAfter making these changes, simply rebuild your configuration and open a new shell to have *gallery-dl* available.\n\nUsage\n=====\n\nTo use *gallery-dl* simply call it with the URLs you wish to download images\nfrom:\n\n.. code:: bash\n\n    gallery-dl [OPTIONS]... URLS...\n\nUse :code:`gallery-dl --help` or see `<docs/options.md>`__\nfor a full list of all command-line options.\n\n\nExamples\n--------\n\nDownload images; in this case from danbooru via tag search for 'bonocho':\n\n.. code:: bash\n\n    gallery-dl \"https://danbooru.donmai.us/posts?tags=bonocho\"\n\n\nGet the direct URL of an image from a site supporting authentication with username & password:\n\n.. code:: bash\n\n    gallery-dl -g -u \"<username>\" -p \"<password>\" \"https://twitter.com/i/web/status/604341487988576256\"\n\n\nFilter manga chapters by chapter number and language:\n\n.. code:: bash\n\n    gallery-dl --chapter-filter \"10 <= chapter < 20\" -o \"lang=fr\" \"https://mangadex.org/title/59793dd0-a2d8-41a2-9758-8197287a8539\"\n\n\n| Search a remote resource for URLs and download images from them:\n| (URLs for which no extractor can be found will be silently ignored)\n\n.. code:: bash\n\n    gallery-dl \"r:https://pastebin.com/raw/FLwrCYsT\"\n\n\nIf a site's address is nonstandard for its extractor, you can prefix the URL with the\nextractor's name to force the use of a specific extractor:\n\n.. code:: bash\n\n    gallery-dl \"tumblr:https://sometumblrblog.example\"\n\n\nConfiguration\n=============\n\nConfiguration files for *gallery-dl* use a JSON-based file format.\n\n\nDocumentation\n-------------\n\nA list of all available configuration options and their descriptions\ncan be found at `<https://gdl-org.github.io/docs/configuration.html>`__.\n\n| For a default configuration file with available options set to their\n  default values, see `<docs/gallery-dl.conf>`__.\n\n| For a commented example with more involved settings and option usage,\n  see `<docs/gallery-dl-example.conf>`__.\n\n\nLocations\n---------\n\n*gallery-dl* searches for configuration files in the following places:\n\nWindows:\n    * ``%APPDATA%\\gallery-dl\\config.json``\n    * ``%USERPROFILE%\\gallery-dl\\config.json``\n    * ``%USERPROFILE%\\gallery-dl.conf``\n\n    (``%USERPROFILE%`` usually refers to a user's home directory,\n    i.e. ``C:\\Users\\<username>\\``)\n\nLinux, macOS, etc.:\n    * ``/etc/gallery-dl.conf``\n    * ``${XDG_CONFIG_HOME}/gallery-dl/config.json``\n    * ``${HOME}/.config/gallery-dl/config.json``\n    * ``${HOME}/.gallery-dl.conf``\n\nWhen run as `executable <Standalone Executable_>`__,\n*gallery-dl* will also look for a ``gallery-dl.conf`` file\nin the same directory as said executable.\n\nIt is possible to use more than one configuration file at a time.\nIn this case, any values from files after the first will get merged\ninto the already loaded settings and potentially override previous ones.\n\n\nAuthentication\n==============\n\nUsername & Password\n-------------------\n\nSome extractors require you to provide valid login credentials in the form of\na username & password pair. This is necessary for\n``nijie``\nand optional for\n``aryion``,\n``danbooru``,\n``e621``,\n``exhentai``,\n``idolcomplex``,\n``imgbb``,\n``inkbunny``,\n``mangadex``,\n``mangoxo``,\n``pillowfort``,\n``sankaku``,\n``subscribestar``,\n``tapas``,\n``tsumino``,\n``twitter``,\nand ``zerochan``.\n\nYou can set the necessary information in your\n`configuration file <Configuration_>`__\n\n.. code:: json\n\n    {\n        \"extractor\": {\n            \"twitter\": {\n                \"username\": \"<username>\",\n                \"password\": \"<password>\"\n            }\n        }\n    }\n\nor you can provide them directly via the\n:code:`-u/--username` and :code:`-p/--password` or via the\n:code:`-o/--option` command-line options\n\n.. code:: bash\n\n    gallery-dl -u \"<username>\" -p \"<password>\" \"URL\"\n    gallery-dl -o \"username=<username>\" -o \"password=<password>\" \"URL\"\n\n\nCookies\n-------\n\nFor sites where login with username & password is not possible due to\nCAPTCHA or similar, or has not been implemented yet, you can use the\ncookies from a browser login session and input them into *gallery-dl*.\n\nThis can be done via the\n`cookies <https://gdl-org.github.io/docs/configuration.html#extractor-cookies>`__\noption in your configuration file by specifying\n\n- | the path to a Mozilla/Netscape format cookies.txt file exported by a browser addon\n  | (e.g. `Get cookies.txt LOCALLY <https://chrome.google.com/webstore/detail/get-cookiestxt-locally/cclelndahbckbenkjhflpdbgdldlbecc>`__ for Chrome,\n    `Export Cookies <https://addons.mozilla.org/en-US/firefox/addon/export-cookies-txt/>`__ for Firefox)\n\n- | a list of name-value pairs gathered from your browser's web developer tools\n  | (in `Chrome <https://developers.google.com/web/tools/chrome-devtools/storage/cookies>`__,\n     in `Firefox <https://developer.mozilla.org/en-US/docs/Tools/Storage_Inspector>`__)\n\n- | the name of a browser to extract cookies from\n  | (supported browsers are Chromium-based ones, Firefox, and Safari)\n\nFor example:\n\n.. code:: json\n\n    {\n        \"extractor\": {\n            \"instagram\": {\n                \"cookies\": \"$HOME/path/to/cookies.txt\"\n            },\n            \"patreon\": {\n                \"cookies\": {\n                    \"session_id\": \"K1T57EKu19TR49C51CDjOJoXNQLF7VbdVOiBrC9ye0a\"\n                }\n            },\n            \"twitter\": {\n                \"cookies\": [\"firefox\"]\n            }\n        }\n    }\n\n| You can also specify a cookies.txt file with\n  the :code:`--cookies` command-line option\n| or a browser to extract cookies from with :code:`--cookies-from-browser`:\n\n.. code:: bash\n\n    gallery-dl --cookies \"$HOME/path/to/cookies.txt\" \"URL\"\n    gallery-dl --cookies-from-browser firefox \"URL\"\n\n\nOAuth\n-----\n\n*gallery-dl* supports user authentication via OAuth_ for some extractors.\nThis is necessary for\n``pixiv``\nand optional for\n``deviantart``,\n``flickr``,\n``reddit``,\n``smugmug``,\n``tumblr``,\nand ``mastodon`` instances.\n\nLinking your account to *gallery-dl* grants it the ability to issue requests\non your account's behalf and enables it to access resources which would\notherwise be unavailable to a public user.\n\nTo do so, start by invoking it with ``oauth:<sitename>`` as an argument.\nFor example:\n\n.. code:: bash\n\n    gallery-dl oauth:flickr\n\nYou will be sent to the site's authorization page and asked to grant read\naccess to *gallery-dl*. Authorize it and you will be shown one or more\n\"tokens\", which should be added to your configuration file.\n\nTo authenticate with a ``mastodon`` instance, run *gallery-dl* with\n``oauth:mastodon:<instance>`` as argument. For example:\n\n.. code:: bash\n\n    gallery-dl oauth:mastodon:pawoo.net\n    gallery-dl oauth:mastodon:https://mastodon.social/\n\n\n.. _Python:     https://www.python.org/downloads/\n.. _PyPI:       https://pypi.org/\n.. _pip:        https://pip.pypa.io/en/stable/\n.. _Requests:   https://requests.readthedocs.io/en/latest/\n.. _FFmpeg:     https://www.ffmpeg.org/\n.. _mkvmerge:   https://www.matroska.org/downloads/mkvtoolnix.html\n.. _yt-dlp:     https://github.com/yt-dlp/yt-dlp\n.. _youtube-dl: https://ytdl-org.github.io/youtube-dl/\n.. _PySocks:    https://pypi.org/project/PySocks/\n.. _brotli:     https://github.com/google/brotli\n.. _brotlicffi: https://github.com/python-hyper/brotlicffi\n.. _zstandard:  https://github.com/indygreg/python-zstandard\n.. _PyYAML:     https://pyyaml.org/\n.. _toml:       https://pypi.org/project/toml/\n.. _SecretStorage: https://pypi.org/project/SecretStorage/\n.. _Psycopg:    https://www.psycopg.org/\n.. _truststore: https://truststore.readthedocs.io/en/latest/\n.. _Jinja:      https://jinja.palletsprojects.com/\n.. _Snapd:      https://docs.snapcraft.io/installing-snapd\n.. _OAuth:      https://en.wikipedia.org/wiki/OAuth\n.. _Chocolatey: https://chocolatey.org/install\n.. _Scoop:      https://scoop.sh/\n\n.. |pypi| image:: https://img.shields.io/pypi/v/gallery-dl?logo=pypi&label=PyPI\n    :target: https://pypi.org/project/gallery-dl/\n\n.. |build| image:: https://github.com/mikf/gallery-dl/actions/workflows/tests.yml/badge.svg\n    :target: https://github.com/mikf/gallery-dl/actions\n\n.. |gitter| image:: https://badges.gitter.im/gallery-dl/main.svg\n    :target: https://gitter.im/gallery-dl/main\n\n.. |discord| image:: https://img.shields.io/discord/1067148002722062416?logo=discord&label=Discord&color=blue\n    :target: https://discord.gg/rSzQwRvGnE\n"
  },
  {
    "path": "bin/gallery-dl",
    "content": "#!/usr/bin/env python3\n# -*- coding: utf-8 -*-\n\nimport sys\nimport gallery_dl\nsys.exit(gallery_dl.main())\n"
  },
  {
    "path": "docs/_layouts/default.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en-US\">\n  <head>\n    <meta charset=\"UTF-8\">\n    <meta http-equiv=\"X-UA-Compatible\" content=\"IE=edge\">\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n\n{% seo %}\n\n    <link rel=\"stylesheet\" href=\"{{ \"/assets/css/style.css?v=\" | append: site.github.build_revision | relative_url }}\">\n    <script src=\"links.js\"></script>\n  </head>\n  <body>\n    <div class=\"container-lg px-3 my-5 markdown-body\">\n\n      {{ content }}\n\n    </div>\n  </body>\n</html>\n"
  },
  {
    "path": "docs/configuration.rst",
    "content": "Configuration\n#############\n\n| Configuration files for *gallery-dl* use a JSON-based file format.\n| For a (more or less) complete example with options set to their default values,\n  see `gallery-dl.conf <gallery-dl.conf>`__.\n| For a configuration file example with more involved settings and options,\n  see `gallery-dl-example.conf <gallery-dl-example.conf>`__.\n|\n\nThis file lists all available configuration options and their descriptions.\n\n\nContents\n========\n\n1) `Extractor Options`_\n2) `Extractor-specific Options`_\n3) `Downloader Options`_\n4) `Output Options`_\n5) `Postprocessor Options`_\n6) `Miscellaneous Options`_\n7) `API Tokens & IDs`_\n8) `Custom Types`_\n\n\n\nExtractor Options\n=================\n\n\nEach extractor is identified by its ``category`` and ``subcategory``.\n\n``category`` is the lowercase site name without any spaces or special\ncharacters, which is usually just the module name\n(``pixiv``, ``danbooru``, ...).\n\n``subcategory`` is a lowercase word describing the general functionality\nof that extractor (``user``, ``favorite``, ``manga``, ...).\n\nThe ``category`` and ``subcategory`` of all extractors are included in the\noutput of ``gallery-dl --list-extractors``.\nFor a specific URL, these values\ncan also be determined by using the\n``-E`` / ``--extractor-info`` and ``-K`` / ``--list-keywords``\ncommand-line optiona (see the example below).\n\nEach one of the following options can be specified on multiple levels of the\nconfiguration tree:\n\n================== =======\nBase level:        ``extractor.<option-name>``\nCategory level:    ``extractor.<category>.<option-name>``\nSubcategory level: ``extractor.<category>.<subcategory>.<option-name>``\n================== =======\n\nJSON Representation:\n\n.. code:: json\n\n    {\n        \"extractor\": {\n            \"<option-name>\": \"<value-base>\",\n\n            \"<category>\": {\n                \"<option-name>\": \"<value-category>\",\n\n                \"<subcategory>\": {\n                    \"<option-name>\": \"<value-subcategory>\"\n                }\n            }\n        }\n    }\n\nA value in a \"deeper\" level hereby\noverrides a value of the same name on a lower level.\nFor example, setting a value for ``extractor.pixiv.filename``\nlets you specify a general filename pattern\nfor all the different ``pixiv`` extractors.\nSetting the ``extractor.pixiv.user.filename`` value lets you override this\ngeneral pattern specifically for ``PixivUserExtractor`` instances.\n\nSpecifying an option on the top level, next to ``extractor``,\nacts as a *global* setting,\noverriding *all* other values with the same option name,\nregardless of their position.\n\n.. code:: json\n\n    {\n        \"extractor\": {\n            \"<option-name>\": \"<value-base (overridden)>\"\n        },\n        \"<option-name>\": \"<value-global>\"\n    }\n\n\n\nextractor.*.filename\n--------------------\nType\n    * `Format String`_\n    * ``object`` (Condition_ → `Format String`_)\nExample\n    .. code:: json\n\n        \"{manga}_c{chapter}_{page:>03}.{extension}\"\n\n    .. code:: json\n\n        {\n            \"extension == 'mp4'\": \"{id}_video.{extension}\",\n            \"'nature' in title\" : \"{id}_{title}.{extension}\",\n            \"\"                  : \"{id}_default.{extension}\"\n        }\n\nDescription\n    A `Format String`_ to generate filenames for downloaded files.\n\n    If this is an ``object``,\n    it must contain Conditions_ mapping to the\n    `Format String`_ to use.\n    These Conditions_ are evaluated in the specified order\n    until one evaluates to ``True``.\n    When none match, the ``\"\"`` entry or\n    the extractor's default filename `Format String`_ is used.\n\n    The available replacement keys depend on the extractor used. A list\n    of keys for a specific one can be acquired by calling *gallery-dl*\n    with the ``-K``/``--list-keywords`` command-line option.\n    For example:\n\n    .. code::\n\n        $ gallery-dl -K http://seiga.nicovideo.jp/seiga/im5977527\n        Keywords for directory names:\n        -----------------------------\n        category\n          seiga\n        subcategory\n          image\n\n        Keywords for filenames:\n        -----------------------\n        category\n          seiga\n        extension\n          None\n        image-id\n          5977527\n        subcategory\n          image\nNote\n    Even if the value of the ``extension`` key is missing or\n    ``None``, it will be filled in later when the file download is\n    starting. This key is therefore always available to provide\n    a valid filename extension.\n\n\nextractor.*.directory\n---------------------\nType\n    * ``list`` of `Format Strings`_\n    * ``object`` (Condition_ → `Format Strings`_)\nExample\n    .. code:: json\n\n        [\"{category}\", \"{manga}\", \"c{chapter} - {title}\"]\n\n    .. code:: json\n\n        {\n            \"'nature' in content\": [\"Nature Pictures\"],\n            \"retweet_id != 0\"    : [\"{category}\", \"{user[name]}\", \"Retweets\"],\n            \"\"                   : [\"{category}\", \"{user[name]}\"]\n        }\n\nDescription\n    A list of `Format String(s)`_ to generate the target directory path.\n\n    If this is an ``object``,\n    it must contain Conditions_ mapping to the\n    list of `Format Strings`_ to use.\n\n    Each individual string in such a list represents a single path\n    segment, which will be joined together and appended to the\n    base-directory_ to form the complete target directory path.\n\n\nextractor.*.base-directory\n--------------------------\nType\n    * |Path|_\n    * ``object`` (Condition_ → |Path|_)\nDefault\n    ``\"./gallery-dl/\"``\nExample\n    .. code:: json\n\n        \"~/Downloads/gallery-dl\"\n\n    .. code:: json\n\n        {\n            \"score >= 100\": \"$DL\",\n            \"duration\"    : \"$DL/video\",\n            \"\"            : \"/tmp/files/\"\n        }\nDescription\n    Directory path used as base for all download destinations.\n\n    If this is an ``object``,\n    it must contain Conditions_ mapping to the |Path|_ to use.\n    Specifying a default |Path|_ with ``\"\"`` is required.\n\n\nextractor.*.follow\n------------------\nType\n    `Format String`_\nDefault\n    ``null``\nExample\n    * ``\"{content}\"``\n    * ``\"\\fE body or html or text\"``\nDescription\n    Follow URLs in the given `Format String`_'s result and\n    process them with child extractors.\n\n\nextractor.*.parent\n------------------\nType\n    ``bool``\nDefault\n    ``true``\n        ``[chevereto]``  |\n        ``erome``        |\n        ``[imagehost]``  |\n        ``urlgalleries``\n    ``false``\n        otherwise\nDescription\n    Mark an extractor as a `parent` and enable\n\n    * `parent-directory <extractor.*.parent-directory_>`__\n    * `parent-metadata  <extractor.*.parent-metadata_>`__\n    * `parent-session   <extractor.*.parent-session_>`__\n    * `parent-skip      <extractor.*.parent-skip_>`__\n\n    for it by default.\n\n\nextractor.*.parent-directory\n----------------------------\nType\n    ``bool``\nDefault\n    `extractor.parent <extractor.*.parent_>`__\nDescription\n    Use an extractor's current target directory as\n    base-directory_ for any spawned child extractors.\n\n\n.. _extractor.*.metadata-parent:\n\nextractor.*.parent-metadata\n---------------------------\nType\n    * ``bool``\n    * ``string``\nDefault\n    ``true``\n        `extractor.parent <extractor.*.parent_>`__\n    ``\"_parent\"``\n        otherwise\nDescription\n    Forward a parent's metadata to its child extractors.\n\n    | If this is a ``string``, add a parent's metadata to its children's\n      to a field named after said string.\n    | For example with ``\"parent-metadata\": \"_p_\"``:\n\n    .. code:: json\n\n        {\n            \"id\": \"child-id\",\n            \"_p_\": {\"id\": \"parent-id\"}\n        }\n\n\nextractor.*.parent-session\n--------------------------\nType\n    ``bool``\nDefault\n    `extractor.parent <extractor.*.parent_>`__\nDescription\n    Share a parent's\n    `session <https://requests.readthedocs.io/en/latest/user/advanced/#session-objects>`__\n    with its child extractors, including\n    `cookies <extractor.*.cookies_>`__,\n    `headers <extractor.*.headers_>`__,\n    and other networking settings.\n\n\nextractor.*.parent-skip\n-----------------------\nType\n    ``bool``\nDefault\n    `extractor.parent <extractor.*.parent_>`__\nDescription\n    Share number of skipped downloads between parent and child extractors.\n\n\nextractor.*.path-restrict\n-------------------------\nType\n    * ``string``\n    * ``object`` (`character` → `replacement character(s)`)\nDefault\n    ``\"auto\"``\nExample\n    * ``\"/!? (){}\"``\n    * ``{\"/\": \"_\", \"+\": \"_+_\", \"({[\": \"(\", \"]})\": \")\", \"a-z\": \"*\"}``\nDescription\n    | A ``string`` of characters to be replaced with the value of\n      `path-replace <extractor.*.path-replace_>`__\n    | or an ``object`` mapping invalid/unwanted characters, character sets,\n      or character ranges to their replacements\n    | for generated path segment names.\nSpecial Values\n    ``\"auto\"``\n        Use characters from ``\"unix\"`` or ``\"windows\"``\n        depending on the local operating system\n    ``\"unix\"``\n        ``\"/\"``\n    ``\"windows\"``\n        | ``\"\\\\\\\\|/<>:\\\"?*\"``\n        | (https://learn.microsoft.com/en-us/windows/win32/fileio/naming-a-file)\n    ``\"windows+\"``\n        | ``{\"\\\\\": \"⧹\", \"|\" : \"\", \"/\" : \"⧸\", \"<\" : \"＜\", \">\" : \"＞\", \":\" : \"：\", \"\\\"\" : \"＂\", \"?\" : \"？\", \"*\" : \"＊\"}``\n        | (replace characters not allowed by Windows with Unicode alternatives)\n    ``\"ascii\"``\n        | ``\"^0-9A-Za-z_.\"``\n        | (only ASCII digits, letters, underscores, and dots)\n    ``\"ascii+\"``\n        | ``\"^0-9@-[\\\\]-{ #-)+-.;=!}~\"``\n        | (all ASCII characters except the ones not allowed by Windows)\nImplementation Detail\n    For ``strings`` with length >= 2, this option uses a\n    `Regular Expression Character Set <https://www.regular-expressions.info/charclass.html>`__,\n    meaning that:\n\n    * Using a caret ``^`` as first character inverts the set\n      (``\"^...\"``)\n    * Character ranges are supported\n      (``\"0-9a-z\"``)\n    * ``]``, ``-``, and ``\\`` need to be escaped as\n      ``\\\\]``, ``\\\\-``, and ``\\\\\\\\`` respectively\n      to use them as literal characters\n\n\nextractor.*.path-replace\n------------------------\nType\n    ``string``\nDefault\n    ``\"_\"``\nDescription\n    The replacement character(s) for\n    `path-restrict <extractor.*.path-restrict_>`__\n\n\nextractor.*.path-remove\n-----------------------\nType\n    ``string``\nDefault\n    ``\"\\u0000-\\u001f\\u007f\"`` (ASCII control characters)\nDescription\n    Set of characters to remove from generated path names.\nNote\n    In a string with 2 or more characters, ``[]^-\\`` need to be\n    escaped with backslashes, e.g. ``\"\\\\[\\\\]\"``\n\n\nextractor.*.path-strip\n----------------------\nType\n    ``string``\nDefault\n    ``\"auto\"``\nDescription\n    Set of characters to remove from the end of generated path segment names\n    using `str.rstrip() <https://docs.python.org/3/library/stdtypes.html#str.rstrip>`_\nSpecial Values\n    ``\"auto\"``\n        Use characters from ``\"unix\"`` or ``\"windows\"``\n        depending on the local operating system\n    ``\"unix\"``\n        ``\"\"``\n    ``\"windows\"``\n        ``\". \"``\n\n\nextractor.*.path-convert\n------------------------\nType\n    `Conversion(s)`_\nExample\n    * ``\"g\"``\n    * ``\"Wl\"``\nDescription\n    `Conversion(s)`_ to apply to each path segment after\n    `path-restrict <extractor.*.path-restrict_>`__\n    replacements.\n\n\nextractor.*.path-extended\n-------------------------\nType\n    ``bool``\nDefault\n    ``true``\nDescription\n    On Windows, use `extended-length paths <https://learn.microsoft.com/en-us/windows/win32/fileio/maximum-file-path-limitation>`__\n    prefixed with ``\\\\?\\`` to work around the 260 characters path length limit.\n\n\nextractor.*.extension-map\n-------------------------\nType\n    ``object`` (`extension` → `replacement`)\nDefault\n    .. code:: json\n\n        {\n            \"jpeg\": \"jpg\",\n            \"jpe\" : \"jpg\",\n            \"jfif\": \"jpg\",\n            \"jif\" : \"jpg\",\n            \"jfi\" : \"jpg\"\n        }\nDescription\n    A JSON ``object`` mapping filename extensions to their replacements.\n\n\nextractor.*.skip\n----------------\nType\n    * ``bool``\n    * ``string``\nDefault\n    ``true``\nExample\n    * ``\"abort:5\"``\n    * ``\"abort:5:2\"``\n    * ``\"abort:5:manga\"``\n    * ``\"terminate:3\"``\nDescription\n    Controls the behavior when downloading files that have been\n    downloaded before, i.e. a file with the same filename already\n    exists or its ID is in a `download archive <extractor.*.archive_>`__.\n\n    ``true``\n        Skip downloads\n    ``false``\n        Overwrite already existing files\n\n    ``\"abort\"``\n        Stop the current extractor\n    ``\"abort:N\"``\n        Skip downloads and\n        stop the current extractor after ``N`` consecutive skips\n    ``\"abort:N:L\"``\n        | Skip downloads and\n          stop the current extractor after ``N`` consecutive skips\n        | Ascend ``L`` levels in the extractor hierarchy\n    ``\"abort:N:SC\"``\n        | Skip downloads and\n          stop the current extractor after ``N`` consecutive skips\n        | Ascend to an extractor with subcategory ``SC`` in the extractor hierarchy\n\n    ``\"terminate\"``\n        Stop the current extractor, including parent extractors\n    ``\"terminate:N\"``\n        Skip downloads and\n        stop the current extractor, including parent extractors,\n        after ``N`` consecutive skips\n\n    ``\"exit\"``\n        Exit the program altogether\n    ``\"exit:N\"``\n        Skip downloads and\n        exit the program after ``N`` consecutive skips\n\n    ``\"enumerate\"``\n        Add an enumeration index to the beginning of the\n        filename extension (``file.1.ext``, ``file.2.ext``, etc.)\n\n\nextractor.*.skip-filter\n-----------------------\nType\n    Condition_\nDescription\n    Python Expression_ controlling which skipped files to count towards\n    ``\"abort\"`` / ``\"terminate\"`` / ``\"exit\"``.\n\n\nextractor.*.sleep\n-----------------\nType\n    |Duration|_\nDefault\n    ``0``\nDescription\n    Number of seconds to sleep before each download.\n\n\nextractor.*.sleep-skip\n----------------------\nType\n    |Duration|_\nDefault\n    ``0``\nDescription\n    Number of seconds to sleep after\n    `skipping <extractor.*.skip_>`__\n    a file download.\n\n\nextractor.*.sleep-extractor\n---------------------------\nType\n    |Duration|_\nDefault\n    ``0``\nDescription\n    Number of seconds to sleep before handling an input URL,\n    i.e. before starting a new extractor.\n\n\nextractor.*.sleep-retries\n-------------------------\nType\n    |Duration+|_\nDefault\n    ``\"lin=1\"``\nExample\n    * ``\"30-50\"``\n    * ``\"exp=40\"``\n    * ``\"lin:20=30-60\"``\nDescription\n    Number of seconds to sleep before\n    `retrying <extractor.*.retries_>`__\n    an HTTP request.\n\n    If this is a ``string``, its |Duration|_ value can be prefixed with\n    ``lin[:START[:MAX]]`` for `linear` or\n    ``exp[:BASE[:START[:MAX]]]`` for `exponential` growth.\nNote\n    | ``lin`` and ``exp`` can be any starting characters of\n      ``linear`` and ``exponential``.\n    | For example ``l``, ``li``, ``lin``, ``line``, ``linea``, or ``linear``.\n\n\nextractor.*.sleep-429\n---------------------\nType\n    |Duration+|_\nDefault\n    ``60``\nExample\n    * ``\"30-50\"``\n    * ``\"e=40\"``\n    * ``\"linear:20=30-60\"``\nDescription\n    Number of seconds to sleep when receiving a\n    `429 Too Many Requests <https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Status/429>`__\n    response before `retrying <extractor.*.retries_>`__ the request.\n\n    If this is a ``string``, its |Duration|_ value can be prefixed with\n    ``lin[:START[:MAX]]`` for `linear` or\n    ``exp[:BASE[:START[:MAX]]]`` for `exponential` backoff.\n\n\nextractor.*.sleep-request\n-------------------------\nType\n    |Duration|_\nDefault\n    ``\"0.5-1.5\"``\n        ``ao3``             |\n        ``arcalive``        |\n        ``booth``           |\n        ``civitai``         |\n        ``[Danbooru]``      |\n        ``[E621]``          |\n        ``[foolfuuka]:search`` |\n        ``hdoujin``         |\n        ``itaku``           |\n        ``newgrounds``      |\n        ``[nitter]``        |\n        ``[philomena]``     |\n        ``pixiv-novel``     |\n        ``plurk``           |\n        ``poipiku``         |\n        ``pornpics``        |\n        ``schalenetwork``   |\n        ``scrolller``       |\n        ``sizebooru``       |\n        ``soundgasm``       |\n        ``thehentaiworld``  |\n        ``urlgalleries``    |\n        ``vk``              |\n        ``webtoons``        |\n        ``weebcentral``     |\n        ``xfolio``          |\n        ``zerochan``\n    ``\"1.0\"``\n        ``furaffinity``     |\n        ``rule34``\n    ``\"1.0-2.0\"``\n        ``flickr``          |\n        ``pexels``          |\n        ``weibo``           |\n        ``[wikimedia]``\n    ``\"1.4\"``\n        ``wallhaven``\n    ``\"2.0-4.0\"``\n        ``behance``         |\n        ``imagefap``        |\n        ``[Nijie]``\n    ``\"3.0-6.0\"``\n        ``bilibili``        |\n        ``exhentai``        |\n        ``[reactor]``       |\n        ``readcomiconline``\n    ``\"6.0-6.1\"``\n        ``twibooru``\n    ``\"6.0-12.0\"``\n        ``instagram``\n    ``0``\n        otherwise\nDescription\n    Minimal time interval in seconds between each HTTP request\n    during data extraction.\n\n\nextractor.*.username & .password\n--------------------------------\nType\n    ``string``\nDefault\n    ``null``\nDescription\n    The username and password to use when attempting to log in to\n    another site.\n\n    This is supported for\n\n    * ``aibooru`` (`* <pw-apikey_>`__)\n    * ``ao3``\n    * ``aryion``\n    * ``atfbooru`` (`* <pw-apikey_>`__)\n    * ``bluesky``\n    * ``booruvar`` (`* <pw-apikey_>`__)\n    * ``coomer``\n    * ``danbooru`` (`* <pw-apikey_>`__)\n    * ``deviantart``\n    * ``e621`` (`* <pw-apikey_>`__)\n    * ``e6ai`` (`* <pw-apikey_>`__)\n    * ``e926`` (`* <pw-apikey_>`__)\n    * ``exhentai``\n    * ``girlswithmuscle``\n    * ``horne`` (`R <pw-required_>`__)\n    * ``idolcomplex``\n    * ``imgbb``\n    * ``inkbunny``\n    * ``iwara``\n    * ``kemono``\n    * ``madokami`` (`R <pw-required_>`__)\n    * ``mangadex``\n    * ``mangoxo``\n    * ``newgrounds``\n    * ``nijie`` (`R <pw-required_>`__)\n    * ``nudostarforum``\n    * ``pillowfort``\n    * ``rule34xyz``\n    * ``sankaku``\n    * ``scrolller``\n    * ``seiga``\n    * ``simpcity``\n    * ``subscribestar``\n    * ``tapas``\n    * ``vipergirls``\n    * ``zerochan``\n\n    These values can also be specified via the\n    ``-u/--username`` and ``-p/--password`` command-line options or\n    by using a |.netrc|_ file. (see Authentication_)\n\nNote\n    Leave the ``password`` value empty or undefined\n    to be prompted for a password when performing a login\n    (see `getpass() <https://docs.python.org/3/library/getpass.html#getpass.getpass>`__).\n\n    .. _pw-apikey:\n\n    (*) The ``password`` value for these sites should be\n    the API key found in your user profile, not the actual account password.\n\n    .. _pw-required:\n\n    (R) Login with username & password or\n    supplying authenticated\n    `cookies <extractor.*.cookies_>`__\n    is *required*\n\n\nextractor.*.init\n----------------\nType\n    * ``bool``\n    * ``string``\nDefault\n    ``false``\nDescription\n    | Controls when to initialize extractor internals.\n    | (\n      `postprocessors <extractor.*.postprocessors_>`_,\n      `archives <extractor.*.archive_>`_,\n      `path-*` options, etc\n      )\n\n    ``true``\n        Initialize everything immediately upon extractor start\n    ``false`` | ``\"lazy\"``\n        Initialize data structures when processing the first ``post``\n        or not at all when an extractor never yields a ``post``.\n\n\nextractor.*.input\n-----------------\nType\n    ``bool``\nDefault\n    ``true`` if `stdin` is attached to a terminal,\n    ``false`` otherwise\nDescription\n    Allow prompting the user for interactive input.\n\n\nextractor.*.netrc\n-----------------\nType\n    ``bool``\nDefault\n    ``false``\nDescription\n    Enable the use of |.netrc|_ authentication data.\n\n\nextractor.*.cookies\n-------------------\nType\n    * |Path|_\n    * ``object`` (`name` → `value`)\n    * ``list``\nDescription\n    Source to read additional cookies from. This can be\n\n    * The |Path|_ to a Mozilla/Netscape format cookies.txt file\n\n      .. code:: json\n\n        \"~/.local/share/cookies-instagram-com.txt\"\n\n    * An ``object`` specifying cookies as name-value pairs\n\n      .. code:: json\n\n        {\n            \"cookie-name\": \"cookie-value\",\n            \"sessionid\"  : \"14313336321%3AsabDFvuASDnlpb%3A31\",\n            \"isAdult\"    : \"1\"\n        }\n\n    * A ``list`` with up to 5 entries specifying a browser profile.\n\n      * The first entry is the browser name\n      * The optional second entry is a profile name or an absolute path to a profile directory\n      * The optional third entry is the keyring to retrieve passwords for decrypting cookies from\n      * The optional fourth entry is a (Firefox) container name (``\"none\"`` for only cookies with no container (default))\n      * The optional fifth entry is the domain to extract cookies for. Prefix it with a dot ``.`` to include cookies for subdomains.\n\n      .. code:: json\n\n        [\"firefox\"]\n        [\"firefox\", null, null, \"Personal\"]\n        [\"chromium\", \"Private\", \"kwallet\", null, \".twitter.com\"]\n\n\nextractor.*.cookies-select\n--------------------------\nType\n    ``string``\nDefault\n    ``null``\nDescription\n    Interpret `extractor.cookies <extractor.*.cookies_>`__\n    as a list of cookie sources and select one of them for each extractor run.\n\n    .. code:: json\n\n      [\n          \"~/.local/share/cookies-instagram-com-1.txt\",\n          \"~/.local/share/cookies-instagram-com-2.txt\",\n          \"~/.local/share/cookies-instagram-com-3.txt\",\n          [\"firefox\", null, null, \"c1\", \".instagram-com\"],\n      ]\n\nSupported Values\n    ``\"random\"``\n        Select cookies `randomly <https://docs.python.org/3.10/library/random.html#random.choice>`__.\n    ``\"rotate\"``\n        Select cookies in sequence. Start over from the beginning after reaching the end of the list.\n\n\nextractor.*.cookies-update\n--------------------------\nType\n    * ``bool``\n    * |Path|_\nDefault\n    ``true``\nDescription\n    Export session cookies in cookies.txt format.\n\n    * If this is a |Path|_, write cookies to the given file path.\n\n    * If this is ``true`` and `extractor.*.cookies`_ specifies the |Path|_\n      of a valid cookies.txt file, update its contents.\n\n\nextractor.*.proxy\n-----------------\nType\n    * ``string``\n    * ``object`` (`scheme` → `proxy`)\nExample\n    .. code:: json\n\n      \"http://10.10.1.10:3128\"\n\n    .. code:: json\n\n      {\n          \"http\" : \"http://10.10.1.10:3128\",\n          \"https\": \"http://10.10.1.10:1080\",\n          \"http://10.20.1.128\": \"http://10.10.1.10:5323\"\n      }\n\nDescription\n    Proxy (or proxies) to be used for remote connections.\n\n    * If this is a ``string``, it is the proxy URL for all\n      outgoing requests.\n    * If this is an ``object``, it is a scheme-to-proxy mapping to\n      specify different proxy URLs for each scheme.\n      It is also possible to set a proxy for a specific host by using\n      ``scheme://host`` as key.\n      See `Requests' proxy documentation`_ for more details.\nNote\n    If a proxy URL does not include a scheme, ``http://`` is assumed.\n\n\nextractor.*.proxy-env\n---------------------\nType\n    ``bool``\nDefault\n    ``true``\nDescription\n    Collect proxy configuration information from environment variables\n    (``HTTP_PROXY``, ``HTTPS_PROXY``, ``NO_PROXY``)\n    and Windows Registry settings.\n\n\nextractor.*.source-address\n--------------------------\nType\n    * ``string``\n    * ``list`` with 1 ``string`` and 1 ``integer`` as elements\nExample\n    * ``\"192.168.178.20\"``\n    * ``[\"192.168.178.20\", 8080]``\nDescription\n    Client-side IP address to bind to.\n\n    | Can be either a simple ``string`` with just the local IP address\n    | or a ``list`` with IP and explicit port number as elements.\n\n\nextractor.*.user-agent\n----------------------\nType\n    ``string``\nDefault\n    ``\"gallery-dl/VERSION\"``\n        * ``[Danbooru]``\n        * ``mangadex``\n        * ``[nitter]``\n        * ``weasyl``\n        * ``[wikimedia]``\n        * ``zerochan``\n    ``\"gallery-dl/VERSION (by mikf)\"``\n        * ``[E621]``\n    ``\"net.umanle.arca.android.playstore/0.9.75\"``\n        * ``arcalive``\n    ``\"Patreon/126.9.0.15 (Android; Android 14; Scale/2.10)\"``\n        * ``patreon``\n    ``\"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/LATEST.0.0.0 Safari/537.36\"``\n        * ``instagram``\n    ``\"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:LATEST) Gecko/20100101 Firefox/LATEST\"``\n        * otherwise\nExample\n    * ``\"curl/8.14.1\"``\n    * ``\"browser\"``\n    * ``\"+chrome\"``\n    * ``\"@/opt/ChromeBrowser/bin/chrome\"``\nDescription\n    User-Agent header value used for HTTP requests.\n\n    Setting this value to ``\"browser\"`` will try to automatically detect\n    and use the ``User-Agent`` header of the system's default browser.\n\n    | Starting this value with a ``+``\n      will use the latest ``User-Agent`` header of this preset target,\n      e.g. ``\"+ff\"``.\n    | (Supported values:\n      ``firefox`` | ``ff`` |\n      ``chrome`` | ``cr`` |\n      ``gallery-dl`` | ``gdl`` |\n      ``google-bot`` | ``bot``\n      )\n\n    | Starting this value with an ``@``\n     will try to automatically detect and use the ``User-Agent`` header\n     of this installed browser,\n    | e.g. ``\"@C:/Program Files/Zen Browser/zen-browser.exe\"``.\n\n\nextractor.*.browser\n-------------------\nType\n    ``string``\nDefault\n    ``\"firefox\"``\n        ``artstation`` |\n        ``behance``    |\n        ``fanbox``     |\n        ``simplyhentai`` |\n        ``twitter``    |\n        ``vsco``\n    ``null``\n        otherwise\nExample\n    * ``\"firefox\"``\n    * ``\"firefox/128\"``\n    * ``\"chrome:macos\"``\n    * ``\"chrome/138:macos\"``\nDescription\n    | Try to emulate a real browser (``firefox`` or ``chrome``)\n    | by using its HTTP headers and TLS ciphers for HTTP requests\n      by setting specialized defaults for\n\n    * `user-agent <extractor.*.user-agent_>`__\n    * `headers <extractor.*.headers_>`__\n    * `ciphers <extractor.*.ciphers_>`__\n\n    Supported browsers:\n\n    * ``firefox``\n    * ``firefox/140``\n    * ``firefox/128``\n    * ``chrome``\n    * ``chrome/138``\n    * ``chrome/111``\n\n    The operating system used in the ``User-Agent`` header can be\n    specified after a colon ``:`` (``windows``, ``linux``, ``macos``),\n    for example ``chrome:windows``.\nNote\n    Any value *not* matching a supported browser\n    will fall back to ``\"firefox\"``.\n\n    ``requests`` and ``urllib3`` only support HTTP/1.1, while a real\n    browser would use HTTP/2 and HTTP/3.\n\n\nextractor.*.referer\n-------------------\nType\n    * ``bool``\n    * ``string``\nDefault\n    ``false``\n        ``4archive``      |\n        ``4chanarchives`` |\n        ``archivedmoe``   |\n        ``nsfwalbum``     |\n        ``pholder``       |\n        ``tumblrgallery``\n    ``true``\n        otherwise\nDescription\n    Send `Referer <https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Referer>`__\n    headers with all outgoing HTTP requests.\n\n    If this is a ``string``, send it as Referer\n    instead of the extractor's ``root`` domain.\n\n\nextractor.*.geo-bypass\n----------------------\nType\n    * ``string``\n    * ``list`` of ``string``\nDefault\n    ``\"auto\"``\nExample\n    * ``\"JP\"``\n    * ``\"105.48.0.0/12\"``\n    * ``\"JP,CN,105.48.0.0/12\"``\n    * ``[\"JP\", \"CN\", \"105.48.0.0/12\"]``\nDescription\n    Use a random IP as fake\n    `X-Forwarded-For <https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/X-Forwarded-For>`__\n    header to try bypassing geographic restrictions.\n\n    | Can be either\n      `ISO 3166-2 <https://en.wikipedia.org/wiki/ISO_3166-2>`__\n      country codes\n    | or IP blocks in CIDR notation.\n\n\nextractor.*.headers\n-------------------\nType\n    * ``\"string\"``\n    * ``object`` (`name` → `value`)\nDefault\n    .. code:: json\n\n      {\n          \"User-Agent\"     : \"<extractor.*.user-agent>\",\n          \"Accept\"         : \"*/*\",\n          \"Accept-Language\": \"en-US,en;q=0.5\",\n          \"Accept-Encoding\": \"gzip, deflate\",\n          \"Referer\"        : \"<extractor.*.referer>\"\n      }\n\nDescription\n    Additional `HTTP headers <https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers>`__\n    to be sent with each HTTP request,\n\n    To disable sending a header, set its value to ``null``.\n\n    Set this option to ``\"firefox\"`` or ``\"chrome\"``\n    to use these browser's default headers.\n\n\nextractor.*.ciphers\n-------------------\nType\n    * ``string``\n    * ``list`` of ``strings``\nExample\n    * ``\"firefox\"``\n    * .. code:: json\n\n        [\"ECDHE-ECDSA-AES128-GCM-SHA256\",\n         \"ECDHE-RSA-AES128-GCM-SHA256\",\n         \"ECDHE-ECDSA-CHACHA20-POLY1305\",\n         \"ECDHE-RSA-CHACHA20-POLY1305\"]\n\nDescription\n    List of TLS/SSL cipher suites in\n    `OpenSSL cipher list format <https://docs.openssl.org/master/man1/openssl-ciphers/#cipher-list-format>`__\n    to be passed to\n    `ssl.SSLContext.set_ciphers() <https://docs.python.org/3/library/ssl.html#ssl.SSLContext.set_ciphers>`__\n\n    Set this option to ``\"firefox\"`` or ``\"chrome\"``\n    to use these browser's default ciphers.\n\n\nextractor.*.tls12\n-----------------\nType\n    ``bool``\nDefault\n    ``false``\n        ``artstation`` |\n        ``behance``\n    ``true``\n        otherwise\nDescription\n    Allow selecting TLS 1.2 cipher suites.\n\n    Can be disabled to alter TLS fingerprints\n    and potentially bypass Cloudflare blocks.\n\n\nextractor.*.keywords\n--------------------\nType\n    ``object`` (`name` → `value`)\nExample\n    ``{\"type\": \"Pixel Art\", \"type_id\": 123}``\nDescription\n    Additional name-value pairs to be added to each metadata dictionary.\n\n\nextractor.*.keywords-default\n----------------------------\nType\n    any\nDefault\n    ``\"None\"``\nDescription\n    Default value used for missing or undefined keyword names in a\n    `Format String`_.\n\n\nextractor.*.keywords-eval\n-------------------------\nType\n    ``bool``\nDefault\n    ``false``\nDescription\n    Evaluate each\n    `keywords <extractor.*.keywords_>`__\n    and\n    `keywords-global <extractor.*.keywords-global_>`__\n    ``string`` value as a `Format String`_.\n\n\nextractor.*.keywords-global\n---------------------------\nType\n    ``object`` (`name` → `value`)\nExample\n    ``{\"type\": \"Original\", \"type_id\": 1, \"type_category\": \"meta\"}``\nDescription\n    Global name-value pairs to be added to each metadata dictionary.\nNote\n    Keywords defined here will be overwritten by keywords from\n    `extractor.keywords <extractor.*.keywords_>`__\n    with the same name.\n\n\n.. _extractor.*.url-metadata:\n\nextractor.*.metadata-url\n------------------------\nType\n    ``string``\nDefault\n    ``\"_url\"``\nDescription\n    Insert a file's download URL into its metadata dictionary as the given name.\n\n    For example, setting this option to ``\"gdl_file_url\"`` will cause a new\n    metadata field with name ``gdl_file_url`` to appear, which contains the\n    current file's download URL.\n    This can then be used in `filenames <extractor.*.filename_>`_,\n    with a ``metadata`` post processor, etc.\n\n\n.. _extractor.*.path-metadata:\n\nextractor.*.metadata-path\n-------------------------\nType\n    ``string``\nDefault\n    ``\"_path\"``\nDescription\n    Insert a reference to the current\n    `PathFormat <https://github.com/mikf/gallery-dl/blob/v1.27.0/gallery_dl/path.py#L27>`__\n    data structure into metadata dictionaries as the given name.\n\n    For example, setting this option to ``\"gdl_path\"`` would make it possible\n    to access the current file's filename as ``\"{gdl_path.filename}\"``.\n\n\n.. _extractor.*.extractor-metadata:\n\nextractor.*.metadata-extractor\n------------------------------\nType\n    ``string``\nDefault\n    ``\"_extr\"``\nDescription\n    Insert a reference to the current\n    `Extractor <https://github.com/mikf/gallery-dl/blob/v1.27.0/gallery_dl/extractor/common.py#L28>`__\n    object into metadata dictionaries as the given name.\n\n\n.. _extractor.*.http-metadata:\n\nextractor.*.metadata-http\n-------------------------\nType\n    ``string``\nDefault\n    ``null``\nDescription\n    Insert an ``object`` containing a file's HTTP headers and\n    ``filename``, ``extension``, and ``date`` parsed from them\n    into metadata dictionaries as the given name.\n\n    For example, setting this option to ``\"gdl_http\"`` would make it possible\n    to access the current file's ``Last-Modified`` header as ``\"{gdl_http[Last-Modified]}\"``\n    and its parsed form as ``\"{gdl_http[date]}\"``.\n\n\n.. _extractor.*.version-metadata:\n\nextractor.*.metadata-version\n----------------------------\nType\n    ``string``\nDefault\n    ``null``\nDescription\n    Insert an ``object`` containing gallery-dl's version info into\n    metadata dictionaries as the given name.\n\n    The content of the object is as follows:\n\n    .. code:: json\n\n        {\n            \"version\"         : \"string\",\n            \"is_executable\"   : \"bool\",\n            \"current_git_head\": \"string or null\"\n        }\n\n\nextractor.*.category-transfer\n-----------------------------\nType\n    ``bool``\nDefault\n    Extractor-specific\nDescription\n    Transfer an extractor's (sub)category values to all child\n    extractors spawned by it, to let them inherit their parent's\n    config options.\n\n\nextractor.*.blacklist & .whitelist\n----------------------------------\nType\n    ``list`` of ``strings``\nDefault\n    ``[\"oauth\", \"recursive\", \"test\"]`` + current extractor category\nExample\n    ``[\"imgur\", \"redgifs:user\", \"*:image\"]``\nDescription\n    A list of extractor identifiers to ignore (or allow)\n    when spawning child extractors for unknown URLs,\n    e.g. from ``reddit`` or ``plurk``.\n\n    Each identifier can be\n\n    * A category or basecategory name (``\"imgur\"``, ``\"mastodon\"``)\n    * | A (base)category-subcategory pair, where both names are separated by a colon (``\"redgifs:user\"``).\n      | Both names can be a `*` or left empty, matching all possible names (``\"*:image\"``, ``\":user\"``).\nNote\n    Any ``blacklist`` setting will automatically include\n    ``\"oauth\"``, ``\"recursive\"``, and ``\"test\"``.\n\n\n.. _extractor.*.blacklist-tags:\n.. _extractor.*.whitelist-tags:\n\nextractor.*.tags-blacklist & .tags-whitelist\n--------------------------------------------\nType\n    * |Path|_\n    * ``string``\n    * ``list`` of ``strings``\nExample\n    * ``\"/path/to/file.txt\"``\n    * ``\"1girl,long_hair,  highres,commentary_request\"``\n    * ``[\"1girl\", \"long_hair\", \"highres\", \"commentary_request\"]``\nDescription\n    A list of tags to exclude/include for processed posts.\n\n    Posts containing a blacklisted tag or *not* containing any whitelisted tag\n    and all of their files will be ignored and not processed any further.\n\n    This can be\n\n    * The |Path|_ of a plaintext file\n      containing black-/whitelisted tag names separated by newlines\n    * A ``string`` with tag names separated by commas (``\"tag1,tag2,tag3\"``)\n    * A ``list`` of ``string`` tag names (``[\"tag1\", \"tag2\", \"tag3\"]``)\n\n\nextractor.*.archive\n-------------------\nType\n    * ``string``\n    * |Path+|_\nDefault\n    ``null``\nExample\n    * ``\"$HOME/.archives/{category}.sqlite3\"``\n    * ``\"postgresql://user:pass@host/database\"``\nDescription\n    File to store IDs of downloaded files in. Downloads of files\n    already recorded in this archive file will be\n    `skipped <extractor.*.skip_>`__.\n\n    The resulting archive file is not a plain text file but an SQLite3\n    database, as either lookup operations are significantly faster or\n    memory requirements are significantly lower when the\n    amount of stored IDs gets reasonably large.\n\n    If this value is a\n    `PostgreSQL Connection URI <https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNSTRING-URIS>`__,\n    the archive will use this PostgreSQL database as backend (requires\n    `Psycopg <https://www.psycopg.org/>`__).\nNote\n    Archive files that do not already exist get generated automatically.\n\n    Archive paths support basic `Format String`_ replacements,\n    but be aware that using external inputs for building local paths\n    may pose a security risk.\n\n\nextractor.*.archive-event\n-------------------------\nType\n     + ``string``\n     + ``list`` of ``strings``\nDefault\n    ``\"file\"``\nExample\n    * ``\"after,skip\"``\n    * ``[\"after\", \"skip\"]``\nDescription\n    `Event(s) <metadata.event_>`__\n    for which IDs get written to an\n    `archive <extractor.*.archive_>`__.\nAvailable Events\n    * ``file``\n    * ``after``\n    * ``skip``\n\n\nextractor.*.archive-format\n--------------------------\nType\n    `Format String`_\nExample\n    ``\"{id}_{offset}\"``\nDescription\n    An alternative `Format String`_ to build archive IDs with.\n\n\nextractor.*.archive-mode\n------------------------\nType\n    ``string``\nDefault\n    ``\"file\"``\nDescription\n    Controls when to write `archive IDs <extractor.*.archive-format_>`__\n    to the archive database.\n\n    ``\"file\"``\n        Write IDs immediately\n        after completing or skipping a file download.\n    ``\"memory\"``\n        Keep IDs in memory\n        and only write them after successful job completion.\n\n\nextractor.*.archive-prefix\n--------------------------\nType\n    `Format String`_\nDefault\n    * ``\"\"`` when `archive-table <extractor.*.archive-table_>`__ is set\n    * ``\"{category}\"`` otherwise\nDescription\n    Prefix for archive IDs.\n\n\nextractor.*.archive-pragma\n--------------------------\nType\n    ``list`` of ``strings``\nExample\n    ``[\"journal_mode=WAL\", \"synchronous=NORMAL\"]``\nDescription\n    A list of SQLite ``PRAGMA`` statements to run during archive initialization.\n\n    See `<https://www.sqlite.org/pragma.html#toc>`__\n    for available ``PRAGMA`` statements and further details.\n\n\nextractor.*.archive-table\n-------------------------\nType\n    `Format String`_\nDefault\n    ``\"archive\"``\nExample\n    ``\"{category}\"``\nDescription\n    `Format String`_ selecting the archive database table name.\n\n\nextractor.*.actions\n-------------------\nType\n    * ``object`` (`pattern` → `Action(s)`_)\n    * ``list`` of [`pattern`, `Action(s)`_] pairs\nExample\n    .. code:: json\n\n        {\n            \"info:Logging in as .+\"   : \"level = debug\",\n            \"warning:(?i)unable to .+\": \"exit 127\",\n            \"error\"                   : [\n                \"status |= 1\",\n                \"exec notify.sh 'gdl error'\",\n                \"abort\"\n            ]\n        }\n\n    .. code:: json\n\n        [\n            [\"info:Logging in as .+\"   , \"level = debug\"],\n            [\"warning:(?i)unable to .+\", \"exit 127\"     ],\n            [\"error\"                   , [\n                \"status |= 1\",\n                \"exec notify.sh 'gdl error'\",\n                \"abort\"\n            ]]\n        ]\n\nDescription\n    Perform an Action_ when logging a message matched by ``pattern``.\n\n    ``pattern`` is parsed as severity level (``debug``, ``info``, ``warning``, ``error``, or integer value)\n    followed by an optional\n    `Python Regular Expression <https://docs.python.org/3/library/re.html#regular-expression-syntax>`__\n    separated by a colon:\n    ``<level>:<re>``\n\n    Using ``*`` as `level` or leaving it empty\n    matches logging messages of all levels:\n    ``*:<re>`` or ``:<re>``\n\n\nextractor.*.postprocessors\n--------------------------\nType\n    * |Postprocessor Configuration|_ object\n    * ``list`` of |Postprocessor Configuration|_ objects\nExample\n    .. code:: json\n\n        [\n            {\n                \"name\": \"zip\" ,\n                \"compression\": \"store\"\n            },\n            {\n                \"name\": \"exec\",\n                \"command\": [\"/home/foobar/script\", \"{category}\", \"{image_id}\"]\n            }\n        ]\n\nDescription\n    A list of `post processors <Postprocessor Configuration_>`__\n    to be applied to each downloaded file in the specified order.\n\n    | Unlike other options, a |postprocessors|_ setting at a deeper level\n      does not override any |postprocessors|_ setting at a lower level.\n    | Instead, all post processors from all applicable |postprocessors|_\n      settings get combined into a single list.\n\n    For example\n\n    * an ``mtime`` post processor at ``extractor.postprocessors``,\n    * a ``zip`` post processor at ``extractor.pixiv.postprocessors``,\n    * and using ``--exec``\n\n    will run all three post processors - ``mtime``, ``zip``, ``exec`` -\n    for each downloaded ``pixiv`` file.\n\n\nextractor.*.postprocessor-options\n---------------------------------\nType\n    ``object`` (`name` → `value`)\nExample\n    .. code:: json\n\n        {\n            \"archive\": null,\n            \"keep-files\": true\n        }\n\nDescription\n    Additional `Postprocessor Options`_ that get added to each individual\n    `post processor object <Postprocessor Configuration_>`__\n    before initializing it and evaluating filters.\n\n\nextractor.*.retries\n-------------------\nType\n    ``integer``\nDefault\n    ``4``\nDescription\n    Maximum number of times a failed HTTP request is retried before\n    giving up, or ``-1`` for infinite retries.\n\n\nextractor.*.retry-codes\n-----------------------\nType\n    ``list`` of ``integers``\nExample\n    ``[404, 429, 430]``\nDescription\n    Additional `HTTP response status codes <https://developer.mozilla.org/en-US/docs/Web/HTTP/Status>`__\n    to retry an HTTP request on.\n\n    ``2xx`` codes (success responses) and\n    ``3xx`` codes (redirection messages)\n    will never be retried and always count as success,\n    regardless of this option.\n\n    ``5xx`` codes (server error responses)  will always be retried,\n    regardless of this option.\n\n\nextractor.*.timeout\n-------------------\nType\n    ``float``\nDefault\n    ``30.0``\nDescription\n    Amount of time (in seconds) to wait for a successful connection\n    and response from a remote server.\n\n    This value gets internally used as the |timeout|_ parameter for the\n    |requests.request()|_ method.\n\n\nextractor.*.verify\n------------------\nType\n    * ``bool``\n    * ``string``\nDefault\n    ``true``\nDescription\n    Controls whether to verify SSL/TLS certificates for HTTPS requests.\n\n    If this is a ``string``, it must be the path to a CA bundle to use\n    instead of the default certificates.\n\n    This value gets internally used as the |verify|_ parameter for the\n    |requests.request()|_ method.\n\n\nextractor.*.truststore\n----------------------\nType\n    ``bool``\nDefault\n    ``false``\nDescription\n    | Use a\n      `truststore <https://truststore.readthedocs.io/en/latest/>`__\n      ``SSLContext`` for verifying SSL/TLS certificates\n    | to make use of your system's native certificate stores\n      instead of relying on\n      `certifi <https://pypi.org/project/certifi/>`__\n      certificates.\n\n\nextractor.*.download\n--------------------\nType\n    ``bool``\nDefault\n    ``true``\nDescription\n    Controls whether to download media files.\n\n    Setting this to ``false`` won't download any files, but all other\n    functions (`postprocessors`_, `download archive`_, etc.)\n    will be executed as normal.\n\n\nextractor.*.fallback\n--------------------\nType\n    ``bool``\nDefault\n    ``true``\nDescription\n    Use fallback download URLs when a download fails.\n\n\n.. _extractor.*.image-range:\n\nextractor.*.file-range\n----------------------\nType\n    * ``string``\n    * ``list`` of ``strings``\nExample\n    * ``\"10-20\"``\n    * ``\"-5, 10, 30-50, 100-\"``\n    * ``\"10:21, 30:51:2, :5, 100:\"``\n    * ``[\"-5\", \"10\", \"30-50\", \"100-\"]``\nDescription\n    Index range(s) selecting which files to download.\n\n    These can be specified as\n\n    * index: ``3`` (file number 3)\n    * range: ``2-4`` (files 2, 3, and 4)\n    * `slice <https://docs.python.org/3/library/functions.html#slice>`__: ``3:8:2`` (files 3, 5, and 7)\n\n    | Arguments for range and slice notation are optional\n      and will default to begin (``1``) or end (``sys.maxsize``) if omitted.\n    | For example ``5-``, ``5:``, and ``5::`` all mean \"Start at file number 5\".\nNote\n    The index of the first file is ``1``.\n\n\nextractor.*.post-range\n----------------------\nType\n    ``string``\nDescription\n    Like `file-range <extractor.*.file-range_>`__,\n    but for posts.\n\n\n.. _extractor.*.chapter-range:\n\nextractor.*.child-range\n-----------------------\nType\n    ``string``\nDescription\n    Like `file-range <extractor.*.file-range_>`__,\n    but for child extractors handling manga chapters, external URLs, etc.\n\n\n.. _extractor.*.image-filter:\n\nextractor.*.file-filter\n-----------------------\nType\n    * Condition_\n    * ``list`` of Conditions_\nExample\n    * ``\"re.search(r'foo(bar)+', description)\"``\n    * ``[\"width >= 1200\", \"width/height > 1.2\"]``\nDescription\n    Python Expression_ controlling which files to download.\n\n    A file only gets downloaded when *all* of the given Expressions_ evaluate to ``True``.\n\n    Available values are the filename-specific ones listed by ``-K`` or ``-j``.\n\n\nextractor.*.post-filter\n-----------------------\nType\n    * Condition_\n    * ``list`` of Conditions_\nExample\n    * ``\"post['id'] > 12345\"``\n    * ``[\"date >= datetime(2025, 5, 1)\", \"print(post_id)\"]``\nDescription\n    Like `file-filter <extractor.*.file-filter_>`__,\n    but for posts.\n\n    Available values are the directory-specific ones listed by ``-K`` or ``-j``.\n\n\n.. _extractor.*.chapter-filter:\n\nextractor.*.child-filter\n------------------------\nType\n    * Condition_\n    * ``list`` of Conditions_\nExample\n    * ``\"lang == 'en'\"``\n    * ``[\"language == 'French'\", \"10 <= chapter < 20\"]``\nDescription\n    Like `file-filter <extractor.*.file-filter_>`__,\n    but for child extractors handling manga chapters, external URLs, etc.\n\n\n.. _extractor.*.image-unique:\n\nextractor.*.file-unique\n-----------------------\nType\n    ``bool``\nDefault\n    ``false``\nDescription\n    Ignore file URLs that have been encountered before during the\n    current extractor run.\n\n\n.. _extractor.*.chapter-unique:\n\nextractor.*.child-unique\n------------------------\nType\n    ``bool``\nDefault\n    ``false``\nDescription\n    Like `file-unique <extractor.*.file-unique_>`__,\n    but applies to delegated URLs like manga chapters, etc.\n\n\nextractor.*.date-before\n-----------------------\nType\n    |Date|_\nDefault\n    ``null``\nExample\n    * ``\"2025-10-31\"``\n    * ``\"2026-01-09 15:30:00\"``\n    * ``\"2026-01-09T15:30:00Z\"``\n    * ``1767972600``\nDescription\n    Process only posts created `before` this |Date|_.\n\n    Accepted values are |ISO 8601| dates and Unix timestamps.\n\n\nextractor.*.date-after\n----------------------\nType\n    |Date|_\nDefault\n    ``null``\nDescription\n    | Process only posts created `after` this |Date|_.\n    | Stop extraction when encountering\n      a post created before or equal to this |Date|_.\n\n    Accepted values are |ISO 8601| dates and Unix timestamps.\n\n\nextractor.*.write-pages\n-----------------------\nType\n    * ``bool``\n    * ``string``\nDefault\n    ``false``\nDescription\n    During data extraction,\n    write received HTTP request data\n    to enumerated files in the current working directory.\nSpecial Values\n    ``\"all\"``\n        | Include HTTP request and response headers.\n        | Hide ``Authorization``, ``Cookie``, and ``Set-Cookie`` values.\n    ``\"ALL\"``\n        Include all HTTP request and response headers.\n\n\n\nExtractor-specific Options\n==========================\n\n\nextractor.ao3.formats\n---------------------\nType\n    * ``string``\n    * ``list`` of ``strings``\nDefault\n    ``\"pdf\"``\nExample\n    * ``\"azw3,epub,mobi,pdf,html\"``\n    * ``[\"azw3\", \"epub\", \"mobi\", \"pdf\", \"html\"]``\nDescription\n    Format(s) to download.\n\n\nextractor.arcalive.emoticons\n----------------------------\nType\n    ``bool``\nDefault\n    ``false``\nDescription\n    Download emoticon images.\n\n\nextractor.arcalive.gifs\n-----------------------\nType\n    * ``bool``\n    * ``string``\nDefault\n    ``true``\nDescription\n    Try to download ``.gif`` versions of ``.mp4`` videos.\n\n    ``true`` | ``\"fallback``\n        Use the ``.gif`` version as primary URL\n        and provide the ``.mp4`` one as\n        `fallback <extractor.*.fallback_>`__.\n    ``\"check\"``\n        Check whether a ``.gif`` version is available\n        by sending an extra HEAD request.\n    ``false``\n        Always download the ``.mp4`` version.\n\n\nextractor.artstation.external\n-----------------------------\nType\n    ``bool``\nDefault\n    ``false``\nDescription\n    Try to follow external URLs of embedded players.\n\n\nextractor.artstation.max-posts\n------------------------------\nType\n    ``integer``\nDefault\n    ``null``\nDescription\n    Limit the number of posts/projects to download.\n\n\nextractor.artstation.mviews\n---------------------------\nType\n    ``bool``\nDefault\n    ``true``\nDescription\n    Download ``.mview`` files.\n\n\nextractor.artstation.previews\n-----------------------------\nType\n    ``bool``\nDefault\n    ``false``\nDescription\n    Download embed previews.\n\n\nextractor.artstation.videos\n---------------------------\nType\n    ``bool``\nDefault\n    ``true``\nDescription\n    Download video clips.\n\n\nextractor.artstation.search.pro-first\n-------------------------------------\nType\n    ``bool``\nDefault\n    ``true``\nDescription\n    Enable the \"Show Studio and Pro member artwork first\" checkbox\n    when retrieving search results.\n\n\nextractor.aryion.recursive\n--------------------------\nType\n    ``bool``\nDefault\n    ``true``\nDescription\n    Controls the post extraction strategy.\n\n    ``true``\n        Start on users' main gallery pages and\n        recursively descend into subfolders\n    ``false``\n        Get posts from \"Latest Updates\" pages\n\n\n\nextractor.bbc.width\n-------------------\nType\n    ``integer``\nDefault\n    ``1920``\nDescription\n    Specifies the requested image width.\n\n    This value must be divisble by 16 and gets rounded down otherwise.\n    The maximum possible value appears to be ``1920``.\n\n\nextractor.behance.modules\n-------------------------\nType\n    ``list`` of ``strings``\nDefault\n    ``[\"image\", \"video\", \"mediacollection\", \"embed\"]``\nDescription\n    Selects which gallery modules to download from.\nSupported Types\n    * ``\"image\"``\n    * ``\"video\"``\n    * ``\"mediacollection\"``\n    * ``\"embed\"``\n    * ``\"text\"``\n\n\nextractor.bellazon.order-posts\n------------------------------\nType\n    ``string``\nDefault\n    ``\"desc\"``\nDescription\n    Controls the order in which\n    posts of a ``thread`` are processed.\n\n    ``\"asc\"``\n        Ascending order (oldest first)\n    ``\"desc\"`` | ``\"reverse\"``\n        Descending order (newest first)\n\n\nextractor.bellazon.quoted\n-------------------------\nType\n    ``bool``\nDefault\n    ``false``\nDescription\n    Extract files from quoted content.\n\n\nextractor.bilibili.livephoto\n----------------------------\nType\n    ``bool``\nDefault\n    ``true``\nDescription\n    Download ``livephoto`` files.\n\n\nextractor.[blogger].api-key\n---------------------------\nType\n    ``string``\nDescription\n    Custom Blogger API key.\n\n    https://developers.google.com/blogger/docs/3.0/using#APIKey\n\n\nextractor.[blogger].videos\n--------------------------\nType\n    ``bool``\nDefault\n    ``true``\nDescription\n    Download embedded videos hosted on https://www.blogger.com/\n\n\nextractor.bluesky.api-server\n----------------------------\nType\n    ``string``\nDefault\n    | ``\"https://bsky.social\"`` if a\n      `username <extractor.*.username & .password_>`__\n      is provided\n    | ``\"https://api.bsky.app\"`` otherwise\nDescription\n    Server address for API requests.\n\n    Can be used when self-hosting a\n    `PDS <https://github.com/bluesky-social/pds>`__\n\n\nextractor.bluesky.include\n-------------------------\nType\n    * ``string``\n    * ``list`` of ``strings``\nDefault\n    * ``\"posts\"`` if\n      `reposts <extractor.bluesky.reposts_>`__ or\n      `quoted <extractor.bluesky.quoted_>`__ is enabled\n    * ``\"media\"`` otherwise\nExample\n    * ``\"avatar,background,posts\"``\n    * ``[\"avatar\", \"background\", \"posts\"]``\nDescription\n    A (comma-separated) list of subcategories to include\n    when processing a user profile.\nSupported Values\n    * ``info``\n    * ``avatar``\n    * ``background``\n    * ``posts``\n    * ``replies``\n    * ``media``\n    * ``video``\n    * ``likes``\nNote\n    It is possible to use ``\"all\"`` instead of listing all values separately.\n\n\nextractor.bluesky.likes.endpoint\n--------------------------------\nType\n    ``string``\nDefault\n    ``\"listRecords\"``\nDescription\n    API endpoint to use for retrieving liked posts.\n\n    ``\"listRecords\"``\n        | Use the results from\n          `com.atproto.repo.listRecords <https://docs.bsky.app/docs/api/com-atproto-repo-list-records>`__\n        | Requires no login and alows accessing likes of all users,\n          but uses one request to\n          `getPostThread <https://docs.bsky.app/docs/api/app-bsky-feed-get-post-thread>`__\n          per post,\n    ``\"getActorLikes\"``\n        | Use the results from\n          `app.bsky.feed.getActorLikes <https://docs.bsky.app/docs/api/app-bsky-feed-get-actor-likes>`__\n        | Requires login and only allows accessing your own likes.\n\n\nextractor.bluesky.metadata\n--------------------------\nType\n    * ``bool``\n    * ``string``\n    * ``list`` of ``strings``\nDefault\n    ``false``\nExample\n    * ``\"facets,user\"``\n    * ``[\"facets\", \"user\"]``\nDescription\n    Extract additional metadata.\n\n    ``facets``\n        ``hashtags``, ``mentions``, ``uris``\n    ``user``\n        | Detailed ``user`` metadata for the user referenced in the input URL.\n        | (`app.bsky.actor.getProfile <https://docs.bsky.app/docs/api/app-bsky-actor-get-profile>`__)\n\n\nextractor.bluesky.post.depth\n----------------------------\nextractor.bluesky.likes.depth\n-----------------------------\nType\n    ``integer``\nDefault\n    ``0``\nDescription\n    Sets the maximum depth of returned reply posts.\n\n    (See the ``depth`` parameter of `app.bsky.feed.getPostThread <https://docs.bsky.app/docs/api/app-bsky-feed-get-post-thread>`__)\n\n\nextractor.bluesky.quoted\n------------------------\nType\n    ``bool``\nDefault\n    ``false``\nDescription\n    Fetch media from quoted posts.\n\n\nextractor.bluesky.reposts\n-------------------------\nType\n    ``bool``\nDefault\n    ``false``\nDescription\n    Process reposts.\n\n\nextractor.bluesky.videos\n------------------------\nType\n    ``bool``\nDefault\n    ``true``\nDescription\n    Download videos.\n\n\nextractor.boosty.allowed\n------------------------\nType\n    ``bool``\nDefault\n    ``true``\nDescription\n    Request only available posts.\n\n\nextractor.boosty.bought\n-----------------------\nType\n    ``bool``\nDefault\n    ``false``\nDescription\n    Request only purchased posts for ``feed`` results.\n\n\nextractor.boosty.metadata\n-------------------------\nType\n    ``bool``\nDefault\n    ``false``\nDescription\n    Provide detailed ``user`` metadata.\n\n\nextractor.boosty.videos\n-----------------------\nType\n    * ``bool``\n    * ``list`` of ``strings``\nDefault\n    ``true``\nExample\n    ``[\"full_hd\", \"high\", \"medium\"]``\nDescription\n    Download videos.\n\n    If this is a ``list``, it selects which format to try to download.\nPossible Formats\n    * ``ultra_hd`` (2160p)\n    * ``quad_hd``  (1440p)\n    * ``full_hd``  (1080p)\n    * ``high``      (720p)\n    * ``medium``    (480p)\n    * ``low``       (360p)\n    * ``lowest``    (240p)\n    * ``tiny``      (144p)\n\n\nextractor.booth.strategy\n------------------------\nType\n    ``string``\nDefault\n    ``\"webpage\"``\nDescription\n    Selects how to handle and extract file URLs.\n\n    ``\"webpage\"``\n        Retrieve the full HTML page\n        and extract file URLs from it\n    ``\"fallback\"``\n        Use `fallback <extractor.*.fallback_>`__ URLs\n        to `guess` each file's correct filename extension\n\n\nextractor.bunkr.endpoint\n------------------------\nType\n    ``string``\nDefault\n    ``\"/api/_001_v2\"``\nDescription\n    API endpoint for retrieving file URLs.\n\n\nextractor.bunkr.tlds\n--------------------\nType\n    ``bool``\nDefault\n    ``false``\nDescription\n    Controls which ``bunkr`` TLDs to accept.\n\n    ``true``\n        Match URLs with *all* possible TLDs (e.g. ``bunkr.xyz`` or ``bunkrrr.duck``)\n    ``false``\n        Match only URLs with known TLDs\n\n\nextractor.[chevereto].password\n------------------------------\nType\n    * ``string``\n    * ``list`` of ``strings``\nDefault\n    ``null``\nExample\n    * ``pw1,pw2,foobar``\n    * ``[\"pw1\", \"pw2\", \"foobar\"]``\nDescription\n    Password value(s) used to access protected albums.\n\n\nextractor.cien.files\n--------------------\nType\n    ``list`` of ``strings``\nDefault\n    ``[\"image\", \"video\", \"download\", \"gallery\"]``\nDescription\n    Determines the type and order of files to download.\nAvailable Types\n    * ``image``\n    * ``video``\n    * ``download``\n    * ``gallery``\n\n\nextractor.civitai.api\n---------------------\nType\n    ``string``\nDefault\n    ``\"trpc\"``\nDescription\n    Selects which API endpoints to use.\n\n    ``\"rest\"``\n        `Public REST API <https://developer.civitai.com/docs/api/public-rest>`__\n    ``\"trpc\"``\n        Internal tRPC API\n\n\nextractor.civitai.api-key\n-------------------------\nType\n    ``string``\nDescription\n    The API Key value generated in your\n    `User Account Settings <https://civitai.com/user/account>`__\n    to make authorized API requests.\n\n    See `API/Authorization <https://developer.civitai.com/docs/api/public-rest#authorization>`__\n    for details.\n\n\nextractor.civitai.files\n-----------------------\nType\n    ``list`` of ``strings``\nDefault\n    ``[\"image\"]``\nDescription\n    Determines the type and order of files to download when processing models.\nAvailable Types\n    * ``model``\n    * ``image``\n    * ``gallery``\n\n\nextractor.civitai.include\n-------------------------\nType\n    * ``string``\n    * ``list`` of ``strings``\nDefault\n    ``[\"user-images\", \"user-videos\"]``\nDescription\n    A (comma-separated) list of subcategories to include\n    when processing a user profile.\nSupported Values\n    * ``user-models``\n    * ``user-posts``\n    * ``user-images``\n    * ``user-videos``\n    * ``user-collections``\nNote\n    It is possible to use ``\"all\"`` instead of listing all values separately.\n\n    To get a more complete set of metadata\n    like ``model['name']`` and ``post['title']``,\n    include ``user-models`` and ``user-posts``\n    as well as the default ``user-images`` and ``user-videos``:\n\n    ``[\"user-models\", \"user-posts\", \"user-images\", \"user-videos\"]``\n\n\nextractor.civitai.metadata\n--------------------------\nType\n    * ``bool``\n    * ``string``\n    * ``list`` of ``strings``\nDefault\n    ``false``\nExample\n    * ``\"generation,tags,post,version\"``\n    * ``[\"version\", \"generation\"]``\nDescription\n    Extract additional metadata.\nSupported Values\n    * ``generation``\n    * ``post``\n    * ``tags``\n    * ``version``\nNote\n    This requires 1 additional API request\n    for each selected value per image or video.\n\n\nextractor.civitai.nsfw\n----------------------\nType\n    * ``bool``\n    * ``string`` (``\"api\": \"rest\"``)\n    * ``integer`` (``\"api\": \"trpc\"``)\nDefault\n    ``true``\nDescription\n    Download NSFW-rated images.\n\n    * For ``\"api\": \"rest\"``, this can be one of\n      ``\"None\"``, ``\"Soft\"``, ``\"Mature\"``, ``\"X\"``\n      to set the highest returned mature content flag.\n\n    * For ``\"api\": \"trpc\"``, this can be an ``integer``\n      whose bits select the returned mature content flags.\n\n      For example, ``28`` (``4|8|16``)  would return only\n      ``R``, ``X``, and ``XXX`` rated images,\n      while ``3`` (``1|2``) would return only\n      ``None`` and ``Soft`` rated images,\n\n\nextractor.civitai.period\n------------------------\nType\n    ``string``\nDefault\n    ``\"AllTime\"``\nDescription\n    Sets the ``period`` parameter\n    when paginating over results.\nSupported Values\n    * ``\"AllTime\"``\n    * ``\"Year\"``\n    * ``\"Month\"``\n    * ``\"Week\"``\n    * ``\"Day\"``\n\n\nextractor.civitai.sort\n----------------------\nType\n    ``string``\nDefault\n    ``\"Newest\"``\nDescription\n    Sets the ``sort`` parameter\n    when paginating over results.\nSupported Values\n    * ``\"Newest\"``\n    * ``\"Oldest\"``\n    * ``\"Most Reactions\"``\n    * ``\"Most Comments\"``\n    * ``\"Most Collected\"``\nSpecial Values\n    ``\"asc\"``\n        Ascending order (``\"Oldest\"``)\n    ``\"desc\"`` | ``\"reverse\"``\n        Descending order (``\"Newest\"``)\n\n\nextractor.civitai.quality\n-------------------------\nType\n    * ``string``\n    * ``list`` of ``strings``\nDefault\n    ``\"original=true\"``\nExample\n    * ``\"width=1280,quality=90\"``\n    * ``[\"width=1280\", \"quality=90\"]``\nDescription\n    A (comma-separated) list of image quality options\n    to pass with every image URL.\n\n    Known available options include ``original``, ``quality``, ``width``\nNote\n    Set this option to an arbitrary letter, e.g., ``\"w\"``,\n    to download images in JPEG format at their original resolution.\n\n\nextractor.civitai.quality-videos\n--------------------------------\nType\n    * ``string``\n    * ``list`` of ``strings``\nDefault\n    ``\"original=true,quality=100\"``\nExample\n    * ``\"+transcode=true,quality=100\"``\n    * ``[\"+\", \"transcode=true\", \"quality=100\"]``\nDescription\n    A (comma-separated) list of video quality options\n    to pass with every video URL.\n\n    Known available options include ``original``, ``quality``, ``transcode``\n\n    Use ``+`` as first character to `add` the given options to the\n    `quality <extractor.civitai.quality_>`__ ones.\n\n\nextractor.civitai.search-models.token\n-------------------------------------\nextractor.civitai.search-images.token\n-------------------------------------\nType\n    ``string``\nDefault\n    ``\"8c46eb2508e21db1e9828a97968d91ab1ca1caa5f70a00e88a2ba1e286603b61\"``\nDescription\n    ``Authorization`` header value used for `/multi-search` queries.\n\n\nextractor.comick.lang\n---------------------\nType\n    * ``string``\n    * ``list`` of ``strings``\nExample\n    * ``\"en\"``\n    * ``\"fr,it,pl\"``\n    * ``[\"fr\", \"it\", \"pl\"]``\nDescription\n    |ISO 639-1| code(s) to filter chapters by.\n\n\nextractor.coomer.files\n----------------------\nType\n    ``list`` of ``strings``\nDefault\n    ``[\"file\", \"attachments\", \"inline\"]``\nDescription\n    Determines the type and order of files to be downloaded.\nAvailable Types\n    * ``file``\n    * ``attachments``\n    * ``inline``\n\n\nextractor.cyberdrop.domain\n--------------------------\nType\n    ``string``\nDefault\n    ``null``\nExample\n    ``\"cyberdrop.to\"``\nDescription\n    Specifies the domain used by ``cyberdrop`` regardless of input URL.\n\n    Setting this option to ``\"auto\"``\n    uses the same domain as a given input URL.\n\n\nextractor.cyberfile.password\n----------------------------\nType\n    ``string``\nDefault\n    ``\"\"``\nDescription\n    Password value used to access protected files and folders.\n\n    Leave this value empty or undefined\n    to be interactively prompted for a password when needed\n    (see `getpass() <https://docs.python.org/3/library/getpass.html#getpass.getpass>`__).\n\n\nextractor.cyberfile.recursive\n-----------------------------\nType\n    ``bool``\nDefault\n    ``true``\nDescription\n    Recursively download files from subfolders.\n\n\nextractor.[Danbooru].external\n-----------------------------\nType\n    ``bool``\nDefault\n    ``false``\nDescription\n    For unavailable or restricted posts,\n    follow the ``source`` and download from there if possible.\n\n\nextractor.[Danbooru].pool.order-posts\n-------------------------------------\nextractor.[Danbooru].favgroup.order-posts\n-----------------------------------------\nType\n    ``string``\nDefault\n    ``\"pool\"``\nDescription\n    Controls the order in which ``pool``/``favgroup`` posts are returned.\n\n    ``\"pool\"`` | ``\"pool_asc\"`` | ``\"asc\"`` | ``\"asc_pool\"``\n        Pool order\n    ``\"pool_desc\"`` | ``\"desc_pool\"`` | ``\"desc\"``\n        Reverse Pool order\n    ``\"id\"`` | ``\"id_desc\"`` | ``\"desc_id\"``\n        Descending Post ID order\n    ``\"id_asc\"`` | ``\"asc_id\"``\n        Ascending Post ID order\n\n\nextractor.[Danbooru].ugoira\n---------------------------\nType\n    ``bool``\nDefault\n    ``false``\nDescription\n    Controls the download target for Ugoira posts.\n\n    ``true``\n        ZIP archives\n    ``false``\n        Converted video files\n\n\nextractor.[Danbooru].metadata\n-----------------------------\nType\n    * ``bool``\n    * ``string``\n    * ``list`` of ``strings``\nDefault\n    ``false``\nExample\n    * ``\"replacements,comments,ai_tags\"``\n    * ``[\"replacements\", \"comments\", \"ai_tags\"]``\nDescription\n    Extract additional metadata\n    (notes, artist commentary, parent, children, uploader)\n\n    It is possible to specify a custom list of metadata includes.\n    See `available_includes <https://github.com/danbooru/danbooru/blob/2cf7baaf6c5003c1a174a8f2d53db010cf05dca7/app/models/post.rb#L1842-L1849>`__\n    for possible field names. ``aibooru`` also supports ``ai_metadata``.\nNote\n    This requires 1 additional HTTP request per 200-post batch.\n\n\nextractor.[Danbooru].threshold\n------------------------------\nType\n    * ``string``\n    * ``integer``\nDefault\n    ``\"auto\"``\nDescription\n    Stop paginating over API results if the length of a batch of returned\n    posts is less than the specified number. Defaults to the per-page limit\n    of the current instance, which is 200.\nNote\n    Changing this setting is normally not necessary. When the value is\n    greater than the per-page limit, gallery-dl will stop after the first\n    batch. The value cannot be less than 1.\n\n\nextractor.dankefuerslesen.zip\n-----------------------------\nType\n    ``bool``\nDefault\n    ``false``\nDescription\n    Download each chapter as a single ZIP archive instead of individual images.\n\n\nextractor.deviantart.auto-watch\n-------------------------------\nType\n    ``bool``\nDefault\n    ``false``\nDescription\n    Automatically watch users when encountering \"Watchers-Only Deviations\"\n    (requires a `refresh-token <extractor.deviantart.refresh-token_>`_).\n\n\nextractor.deviantart.auto-unwatch\n---------------------------------\nType\n    ``bool``\nDefault\n    ``false``\nDescription\n    After watching a user through `auto-watch <extractor.deviantart.auto-watch_>`_,\n    unwatch that user at the end of the current extractor run.\n\n\nextractor.deviantart.comments\n-----------------------------\nType\n    ``bool``\nDefault\n    ``false``\nDescription\n    Extract ``comments`` metadata.\n\n\nextractor.deviantart.comments-avatars\n-------------------------------------\nType\n    ``bool``\nDefault\n    ``false``\nDescription\n    Download the avatar of each commenting user.\nNote\n    Enabling this option also enables deviantart.comments_.\n\n\nextractor.deviantart.extra\n--------------------------\nType\n    ``bool``\nDefault\n    ``false``\nDescription\n    Download extra Sta.sh resources from\n    description texts and journals.\nNote\n    Enabling this option also enables deviantart.metadata_.\n\n\nextractor.deviantart.flat\n-------------------------\nType\n    ``bool``\nDefault\n    ``true``\nDescription\n    Select the directory structure created by the Gallery- and\n    Favorite-Extractors.\n\n    ``true``\n        Use a flat directory structure.\n    ``false``\n            Collect a list of all gallery ``folders`` or\n            favorites ``collections`` and transfer any further work to other\n            extractors (``folder`` or ``collection``), which will then\n            create individual subdirectories for each of them.\nNote\n    Going through all gallery folders won't\n    fetch deviations not contained in any folder.\n\n\nextractor.deviantart.folders\n----------------------------\nType\n    ``bool``\nDefault\n    ``false``\nDescription\n    Provide a ``folders`` metadata field that contains the names of all\n    folders a deviation is present in.\nNote\n    Gathering this information requires a lot of API calls.\n    Use with caution.\n\n\nextractor.deviantart.group\n--------------------------\nType\n    * ``bool``\n    * ``string``\nDefault\n    ``true``\nDescription\n    Check whether the profile name in a given URL\n    belongs to a group or a regular user.\n\n    When disabled, assume every given profile name\n    belongs to a regular user.\nSpecial Values\n    ``\"skip\"``\n        Skip groups\n\n\nextractor.deviantart.include\n----------------------------\nType\n    * ``string``\n    * ``list`` of ``strings``\nDefault\n    ``\"gallery\"``\nExample\n    * ``\"favorite,journal,scraps\"``\n    * ``[\"favorite\", \"journal\", \"scraps\"]``\nDescription\n    A (comma-separated) list of subcategories to include\n    when processing a user profile.\nSupported Values\n    * ``avatar``\n    * ``background``\n    * ``gallery``\n    * ``scraps``\n    * ``journal``\n    * ``favorite``\n    * ``status``\nNote\n    It is possible to use ``\"all\"`` instead of listing all values separately.\n\n\nextractor.deviantart.intermediary\n---------------------------------\nType\n    ``bool``\nDefault\n    ``true``\nDescription\n    For older non-downloadable images,\n    download a higher-quality ``/intermediary/`` version.\n\n\nextractor.deviantart.journals\n-----------------------------\nType\n    ``string``\nDefault\n    ``\"html\"``\nDescription\n    Selects the output format for textual content. This includes journals,\n    literature and status updates.\n\n    ``\"html\"``\n        HTML with (roughly) the same layout as on DeviantArt.\n    ``\"text\"``\n        Plain text with image references and HTML tags removed.\n    ``\"none\"``\n        Don't download textual content.\n\n\nextractor.deviantart.mature\n---------------------------\nType\n    ``bool``\nDefault\n    ``true``\nDescription\n    Enable mature content.\n\n    This option simply sets the |mature_content|_ parameter for API\n    calls to either ``\"true\"`` or ``\"false\"`` and does not do any other\n    form of content filtering.\n\n\nextractor.deviantart.metadata\n-----------------------------\nType\n    * ``bool``\n    * ``string``\n    * ``list`` of ``strings``\nDefault\n    ``false``\nExample\n    * ``\"stats,submission\"``\n    * ``[\"camera\", \"stats\", \"submission\"]``\nDescription\n    Extract additional metadata for deviation objects.\n\n    Provides\n    ``description``, ``tags``, ``license``, and ``is_watching``\n    fields when enabled.\n\n    It is possible to request extended metadata by specifying a list of\n\n    ``camera``\n        EXIF information if available\n    ``stats``\n        Deviation statistics\n    ``submission``\n        Submission information\n    ``collection``\n        Favourited folder information (requires a `refresh token <extractor.deviantart.refresh-token_>`__)\n    ``gallery``\n        Gallery folder information (requires a `refresh token <extractor.deviantart.refresh-token_>`__)\nNote\n    Set this option to ``\"all\"`` to request all extended metadata categories.\n\n    See `/deviation/metadata <https://www.deviantart.com/developers/http/v1/20210526/deviation_metadata/7824fc14d6fba6acbacca1cf38c24158>`__\n    for official documentation.\n\n\nextractor.deviantart.original\n-----------------------------\nType\n    * ``bool``\n    * ``string``\nDefault\n    ``false``\nDescription\n    Download original files if available.\n\n    Setting this option to ``\"images\"`` only downloads original\n    files if they are images and falls back to preview versions for\n    everything else (archives, videos, etc.).\nNote\n    https://www.deviantart.com/team/status-update/An-adjustments-being-made-to-1307747979 ::\n\n    > The New Weekly Limits:\n    >   Non-Core & Core Basic:  10 downloads per week\n    >   Core+ and higher     : 150 downloads per week\n\n\nextractor.deviantart.pagination\n-------------------------------\nType\n    ``string``\nDefault\n    ``\"api\"``\nDescription\n    Controls when to stop paginating over API results.\n\n    ``\"api\"``\n        Trust the API and stop when ``has_more`` is ``false``.\n    ``\"manual\"``\n        Disregard ``has_more`` and only stop when a batch of results is empty.\n\n\nextractor.deviantart.previews\n-----------------------------\nType\n    ``bool``\nDefault\n    ``false``\nDescription\n    For non-image files (archives, videos, etc.),\n    also download the file's preview image.\n\n    Set this option to ``\"all\"`` to download previews for all files.\n\n\nextractor.deviantart.public\n---------------------------\nType\n    ``bool``\nDefault\n    ``true``\nDescription\n    Use a public access token for API requests.\n\n    Disable this option to *force* using a private token for all requests\n    when a `refresh token <extractor.deviantart.refresh-token_>`__ is provided.\n\n\nextractor.deviantart.quality\n----------------------------\nType\n    * ``integer``\n    * ``string``\nDefault\n    ``100``\nDescription\n    JPEG quality level of images for which\n    an original file download is not available.\n\n    Set this to ``\"png\"`` to download a PNG version of these images instead.\n\n\nextractor.deviantart.refresh-token\n----------------------------------\nType\n    ``string``\nDefault\n    ``null``\nDescription\n    The ``refresh-token`` value you get from\n    `linking your DeviantArt account to gallery-dl <OAuth_>`__.\n\n    Using a ``refresh-token`` allows you to access private or otherwise\n    not publicly available deviations.\nNote\n    The ``refresh-token`` becomes invalid\n    `after 3 months <https://www.deviantart.com/developers/authentication#refresh>`__\n    or whenever your `cache file <cache.file_>`__ is deleted or cleared.\n\n\nextractor.deviantart.wait-min\n-----------------------------\nType\n    ``integer``\nDefault\n    ``0``\nDescription\n    Minimum wait time in seconds before API requests.\n\n\nextractor.deviantart.avatar.formats\n-----------------------------------\nType\n    ``list`` of ``strings``\nExample\n    ``[\"original.jpg\", \"big.jpg\", \"big.gif\", \".png\"]``\nDescription\n    Avatar URL formats to return.\n\n    | Each format is parsed as ``SIZE.EXT``.\n    | Leave ``SIZE`` empty to download the regular, small avatar format.\nNote\n    | Consider updating\n      `archive-format <extractor.*.archive-format_>`__\n      for ``avatar`` results to\n    | ``\"a_{_username}_{index}{title[6:]}.{extension}\"``\n    | or similar when using an\n      `archive <extractor.*.archive_>`__\n      to be able to handle different formats.\n\n\nextractor.deviantart.folder.subfolders\n--------------------------------------\nType\n    ``bool``\nDefault\n    ``true``\nDescription\n    Also extract subfolder content.\n\n\nextractor.discord.embeds\n------------------------\nType\n    ``list`` of ``strings``\nDefault\n    ``[\"image\", \"gifv\", \"video\"]``\nDescription\n    Selects which embed types to download from.\n\n    Supported embed types are\n    ``image``, ``gifv``, ``video``, ``rich``, ``article``, ``link``.\n\n\nextractor.discord.threads\n-------------------------\nType\n    ``bool``\nDefault\n    ``true``\nDescription\n    Extract threads from Discord text channels.\n\n\nextractor.discord.token\n-----------------------\nType\n    ``string``\nDescription\n    Discord Bot Token for API requests.\n\n    You can follow `this guide <https://github.com/Tyrrrz/DiscordChatExporter/blob/master/.docs/Token-and-IDs.md#how-to-get-a-user-token>`__ to get a token.\n\n\nextractor.dynastyscans.anthology.metadata\n-----------------------------------------\nType\n    ``bool``\nDefault\n    ``false``\nDescription\n    Extract ``alert``, ``description``, and ``status`` metadata\n    from an anthology's HTML page.\n\n\nextractor.[E621].metadata\n-------------------------\nType\n    * ``bool``\n    * ``string``\n    * ``list`` of ``strings``\nDefault\n    ``false``\nExample\n    * ``\"notes,pools\"``\n    * ``[\"notes\", \"pools\"]``\nDescription\n    Extract additional metadata (notes, pool metadata) if available.\nNote\n    This requires 0-2 additional HTTP requests per post.\n\n\nextractor.[E621].threshold\n--------------------------\nType\n    * ``string``\n    * ``integer``\nDefault\n    ``\"auto\"``\nDescription\n    Stop paginating over API results if the length of a batch of returned\n    posts is less than the specified number. Defaults to the per-page limit\n    of the current instance, which is 320.\nNote\n    Changing this setting is normally not necessary. When the value is\n    greater than the per-page limit, gallery-dl will stop after the first\n    batch. The value cannot be less than 1.\n\n\nextractor.erome.user.reposts\n----------------------------\nType\n    ``bool``\nDefault\n    ``false``\nDescription\n    Include reposts when extracting albums from a user profile.\n\n\nextractor.exhentai.domain\n-------------------------\nType\n    ``string``\nDefault\n    ``\"auto\"``\nDescription\n    ``\"auto\"``\n        Use ``e-hentai.org`` or ``exhentai.org``\n        depending on the input URL\n    ``\"e-hentai.org\"``\n        Use ``e-hentai.org`` for all URLs\n    ``\"exhentai.org\"``\n        Use ``exhentai.org`` for all URLs\n\n\nextractor.exhentai.fallback-retries\n-----------------------------------\nType\n    ``integer``\nDefault\n    ``2``\nDescription\n    Number of times a failed image gets retried\n    or ``-1`` for infinite retries.\n\n\nextractor.exhentai.fav\n----------------------\nType\n    ``string``\nExample\n    ``\"4\"``\nDescription\n    After downloading a gallery,\n    add it to your account's favorites as the given category number.\nNote\n    Set this to `\"favdel\"` to remove galleries from your favorites.\n\n    This will remove any Favorite Notes when applied\n    to already favorited galleries.\n\n\nextractor.exhentai.gp\n---------------------\nType\n    ``string``\nDefault\n    ``\"resized\"``\nDescription\n    Selects how to handle \"you do not have enough GP\" errors.\n\n    * `\"resized\"`: Continue downloading `non-original <extractor.exhentai.original_>`__ images.\n    * `\"stop\"`: Stop the current extractor run.\n    * `\"wait\"`: Wait for user input before retrying the current image.\n\n\nextractor.exhentai.limits\n-------------------------\nType\n    ``integer``\nDefault\n    ``null``\nDescription\n    Set a custom image download limit and perform\n    `limits-action <extractor.exhentai.limits-action_>`__\n    when it gets exceeded.\n\n\nextractor.exhentai.limits-action\n--------------------------------\nType\n    ``string``\nDefault\n    ``\"stop\"``\nDescription\n    Action to perform when the image limit is exceeded.\n\n    * `\"stop\"`: Stop the current extractor run.\n    * `\"wait\"`: Wait for user input.\n    * `\"reset\"`: Spend GP to reset your account's image limits.\n\n\nextractor.exhentai.metadata\n---------------------------\nType\n    ``bool``\nDefault\n    ``false``\nDescription\n    Load extended gallery metadata from the\n    `API <https://ehwiki.org/wiki/API#Gallery_Metadata>`_.\n\n    * Adds ``archiver_key``, ``posted``, and ``torrents``\n    * Provides exact ``date`` and ``filesize``\n\n\nextractor.exhentai.original\n---------------------------\nType\n    ``bool``\nDefault\n    ``true``\nDescription\n    Download full-sized original images if available.\n\n\nextractor.exhentai.source\n-------------------------\nType\n    ``string``\nDefault\n    ``\"gallery\"``\nDescription\n    Selects an alternative source to download files from.\n\n    ``\"hitomi\"``\n         Download the corresponding gallery from ``hitomi.la``\n    ``\"metadata\"``\n        Load only a gallery's metadata from the\n        `API <https://ehwiki.org/wiki/API#Gallery_Metadata>`_\n\n\nextractor.exhentai.tags\n-----------------------\nType\n    ``bool``\nDefault\n    ``true``\nDescription\n    Group ``tags`` by type and\n    provide them as ``tags_<type>`` metadata fields,\n    for example ``tags_artist`` or ``tags_character``.\n\n\nextractor.facebook.author-followups\n-----------------------------------\nType\n    ``bool``\nDefault\n    ``false``\ndescription\n    Extract comments that include photo attachments made by the author of the post.\n\n\nextractor.facebook.include\n--------------------------\nType\n    * ``string``\n    * ``list`` of ``strings``\nDefault\n    ``\"photos\"``\nExample\n    * ``\"avatar,photos\"``\n    * ``[\"avatar\", \"photos\"]``\nDescription\n    A (comma-separated) list of subcategories to include\n    when processing a user profile.\nSupported Values\n    * ``info``\n    * ``avatar``\n    * ``photos``\n    * ``albums``\nNote\n    It is possible to use ``\"all\"`` instead of listing all values separately.\n\n\nextractor.facebook.loop\n-----------------------\nType\n    ``bool``\nDefault\n    ``false``\nDescription\n    Continue when detecting a jump to a set's beginning.\n\n\nextractor.facebook.videos\n-------------------------\nType\n    * ``bool``\n    * ``string``\nDefault\n    ``true``\nDescription\n    Control video download behavior.\n\n    ``true``\n        Extract and download video & audio separately.\n    ``\"ytdl\"``\n        Let |ytdl| handle video extraction and download, and merge video & audio streams.\n    ``false``\n        Ignore videos.\n\n\nextractor.fanbox.comments\n-------------------------\nType\n    ``bool``\nDefault\n    ``false``\nDescription\n    Extract ``comments`` metadata.\nNote\n    This requires 1 or more additional API requests per post,\n    depending on the number of comments.\n\n\nextractor.fanbox.embeds\n-----------------------\nType\n    * ``bool``\n    * ``string``\nDefault\n    ``true``\nDescription\n    Control behavior on embedded content from external sites.\n\n    ``true``\n        Extract embed URLs and download them if supported\n        (videos are not downloaded).\n    ``\"ytdl\"``\n        Like ``true``, but let |ytdl| handle video\n        extraction and download for YouTube, Vimeo, and SoundCloud embeds.\n    ``false``\n        Ignore embeds.\n\n\nextractor.fanbox.fee-max\n------------------------\nType\n    ``integer``\nDescription\n    Do not request API data or extract files from posts\n    that require a fee (``feeRequired``) greater than the specified amount.\nNote\n    This option has no effect on individual post URLs.\n\n\nextractor.fanbox.metadata\n-------------------------\nType\n    * ``bool``\n    * ``string``\n    * ``list`` of ``strings``\nDefault\n    ``false``\nExample\n    * ``user,plan,comments``\n    * ``[\"user\", \"plan\", \"comments\"]``\nDescription\n    Extract ``plan`` and extended ``user`` metadata.\nSupported Fields\n    * ``comments``\n    * ``plan``\n    * ``user``\nNote\n    ``comments`` can also be enabled via\n    `fanbox.comments <extractor.fanbox.comments_>`__\n\n\nextractor.fanbox.creator.offset\n-------------------------------\nType\n    ``integer``\nDefault\n    ``0``\nDescription\n    Custom ``offset`` starting value when paginating over posts.\n\n\nextractor.fansly.formats\n------------------------\nType\n    ``list`` of ``integers``\nDefault\n    ``null``\nExample\n    ``[1, 2, 3, 4, 302, 303]``\nDescription\n    List of file formats to consider during format selection.\n\n\nextractor.fansly.previews\n-------------------------\nType\n    ``bool``\nDefault\n    ``true``\nDescription\n    Download `previews` if no other format is available.\n\n\nextractor.fansly.token\n----------------------\nType\n    ``string``\nExample\n    ``\"kX7pL9qW3zT2rY8mB5nJ4vC6xF1tA0hD8uE2wG9yR3sQ7iZ4oM5jN6cP8lV0bK2tU9aL1eW\"``\nDescription\n    ``authorization`` header value\n    used for requests to ``https://apiv3.fansly.com/api``\n    to access locked content.\n\n\nextractor.flickr.access-token & .access-token-secret\n----------------------------------------------------\nType\n    ``string``\nDefault\n    ``null``\nDescription\n    The ``access_token`` and ``access_token_secret`` values you get\n    from `linking your Flickr account to gallery-dl <OAuth_>`__.\n\n\nextractor.flickr.contexts\n-------------------------\nType\n    ``bool``\nDefault\n    ``false``\nDescription\n    For each photo, return the albums and pools it belongs to\n    as ``set`` and ``pool`` metadata.\nNote\n    This requires 1 additional API call per photo.\n    See `flickr.photos.getAllContexts <https://www.flickr.com/services/api/flickr.photos.getAllContexts.html>`__ for details.\n\n\nextractor.flickr.exif\n---------------------\nType\n    ``bool``\nDefault\n    ``false``\nDescription\n    For each photo, return its EXIF/TIFF/GPS tags\n    as ``exif`` and ``camera`` metadata.\nNote\n    This requires 1 additional API call per photo.\n    See `flickr.photos.getExif <https://www.flickr.com/services/api/flickr.photos.getExif.html>`__ for details.\n\n\nextractor.flickr.info\n---------------------\nType\n    ``bool``\nDefault\n    ``false``\nDescription\n    For each photo, retrieve its \"full\" metadata as provided by\n    `flickr.photos.getInfo <https://www.flickr.com/services/api/flickr.photos.getInfo.html>`__\nNote\n    This requires 1 additional API call per photo.\n\n\nextractor.flickr.metadata\n-------------------------\nType\n    * ``bool``\n    * ``string``\n    * ``list`` of ``strings``\nDefault\n    ``false``\nExample\n    * ``license,last_update,machine_tags``\n    * ``[\"license\", \"last_update\", \"machine_tags\"]``\nDescription\n    Extract additional metadata\n    (license, date_taken, original_format, last_update, geo, machine_tags, o_dims)\n\n    It is possible to specify a custom list of metadata includes.\n    See `the extras parameter <https://www.flickr.com/services/api/flickr.people.getPhotos.html>`__\n    in `Flickr's API docs <https://www.flickr.com/services/api/>`__\n    for possible field names.\n\n\nextractor.flickr.profile\n------------------------\nType\n    ``bool``\nDefault\n    ``false``\nDescription\n    Extract additional ``user`` profile metadata.\nNote\n    This requires 1 additional API call per user profile.\n    See `flickr.people.getInfo <https://www.flickr.com/services/api/flickr.people.getInfo.html>`__ for details.\n\n\nextractor.flickr.videos\n-----------------------\nType\n    ``bool``\nDefault\n    ``true``\nDescription\n    Extract and download videos.\n\n\nextractor.flickr.size-max\n--------------------------\nType\n    * ``integer``\n    * ``string``\nDefault\n    ``null``\nDescription\n    Sets the maximum allowed size for downloaded images.\n\n    * If this is an ``integer``, it specifies the maximum image dimension\n      (width and height) in pixels.\n    * If this is a ``string``, it should be one of Flickr's format specifiers\n      (``\"Original\"``, ``\"Large\"``, ... or ``\"o\"``, ``\"k\"``, ``\"h\"``,\n      ``\"l\"``, ...) to use as an upper limit.\n\n\nextractor.foriio.audio\n----------------------\nType\n    ``bool``\nDefault\n    ``true``\nDescription\n    Download embedded audio files using |ytdl|\n    in ``sound`` works\n\n\nextractor.foriio.external\n-------------------------\nType\n    ``bool``\nDefault\n    ``true``\nDescription\n    Follow external URLs\n    in ``web_article`` works\n\n\nextractor.foriio.user.posts\n---------------------------\nType\n    * ``string``\n    * ``list`` of ``strings``\nDefault\n    ``null``\nExample\n    * ``\"image,video,sound\"``\n    * ``[\"web_article\", \"copy_writing\"]``\nDescription\n    Only process works of the given types.\n\n\nextractor.foriio.previews\n-------------------------\nType\n    ``bool``\nDefault\n    ``false``\nDescription\n    Download preview images of embedded media.\n\n\nextractor.foriio.videos\n-----------------------\nType\n    ``bool``\nDefault\n    ``true``\nDescription\n    Download embedded (YouTube) videos using |ytdl|\n    in ``video`` works\n\n\nextractor.furaffinity.descriptions\n----------------------------------\nType\n    ``string``\nDefault\n    ``\"text\"``\nDescription\n    Controls the format of ``description`` metadata fields.\n\n    ``\"text\"``\n        Plain text with HTML tags removed\n    ``\"html\"``\n        Raw HTML content\n\n\nextractor.furaffinity.external\n------------------------------\nType\n    ``bool``\nDefault\n    ``false``\nDescription\n    Follow external URLs linked in descriptions.\n\n\nextractor.furaffinity.include\n-----------------------------\nType\n    * ``string``\n    * ``list`` of ``strings``\nDefault\n    ``\"gallery\"``\nExample\n    * ``\"scraps,favorite\"``\n    * ``[\"scraps\", \"favorite\"]``\nDescription\n    A (comma-separated) list of subcategories to include\n    when processing a user profile.\nSupported Values\n    * ``gallery``\n    * ``scraps``\n    * ``favorite``\nNote\n    It is possible to use ``\"all\"`` instead of listing all values separately.\n\n\nextractor.furaffinity.layout\n----------------------------\nType\n    ``string``\nDefault\n    ``\"auto\"``\nDescription\n    Selects which site layout to expect when parsing posts.\n\n    ``\"auto\"``\n        Automatically differentiate between ``\"old\"`` and ``\"new\"``\n    ``\"old\"``\n        Expect the *old* site layout\n    ``\"new\"``\n        Expect the *new* site layout\n\n\nextractor.gelbooru.api-key & .user-id\n-------------------------------------\nType\n    ``string``\nDefault\n    ``null``\nDescription\n    Values from the `API Access Credentials` section\n    found at the bottom of your account's\n    `Options <https://gelbooru.com/index.php?page=account&s=options>`__\n    page.\n\n\nextractor.gelbooru.favorite.order-posts\n---------------------------------------\nType\n    ``string``\nDefault\n    ``\"desc\"``\nDescription\n    Controls the order in which favorited posts are returned.\n\n    ``\"asc\"``\n        Ascending favorite date order (oldest first)\n    ``\"desc\"`` | ``\"reverse\"``\n        Descending favorite date order (newest first)\n\n\nextractor.generic.enabled\n-------------------------\nType\n    ``bool``\nDefault\n    ``false``\nDescription\n    Match **all** URLs not otherwise supported by gallery-dl,\n    even ones without a ``generic:`` prefix.\n\n\nextractor.gofile.api-token\n--------------------------\nType\n    ``string``\nDefault\n    ``null``\nDescription\n    API token value found at the bottom of your `profile page <https://gofile.io/myProfile>`__.\n\n    If not set, a temporary guest token will be used.\n\n\nextractor.gofile.website-token\n------------------------------\nType\n    ``string``\nDescription\n    API token value used during API requests.\n\n    An invalid or not up-to-date value\n    will result in ``401 Unauthorized`` errors.\n\n    Keeping this option unset will use an extra HTTP request\n    to attempt to fetch the current value used by gofile.\n\n\nextractor.gofile.recursive\n--------------------------\nType\n    ``bool``\nDefault\n    ``true``\nDescription\n    Recursively download files from subfolders.\n\n\nextractor.hdoujin.cbz\n---------------------\nType\n    ``bool``\nDefault\n    ``false``\nDescription\n    Download each gallery as a single ``.cbz`` file.\nNote\n    Requires a\n    `token <extractor.hdoujin.token_>`__\n\n\nextractor.hdoujin.crt\n---------------------\nType\n    ``string``\nExample\n    * ``\"0542daa9-352c-4fd5-a497-6c6d5cf07423\"``\n    * ``\"/12345/a1b2c3d4e5f6?crt=0542daa9-352c-4fd5-a497-6c6d5cf07423\"``\nDescription\n    The ``crt`` query parameter value\n    sent when fetching gallery data.\n\n    To get this value:\n\n    * Open your browser's Developer Tools (F12)\n    * Select `Network` → `XHR`\n    * Open a gallery page\n    * Select the last `Network` entry and copy its ``crt`` value\nNote\n    You will also need your browser's\n    `user-agent <extractor.*.user-agent_>`__\n\n\nextractor.hdoujin.format\n------------------------\nType\n    * ``string``\n    * ``list`` of ``strings``\nDefault\n    ``[\"0\", \"1600\", \"1280\", \"980\", \"780\"]``\nDescription\n    Name(s) of the image format to download.\n\n    When more than one format is given, the first available one is selected.\n\n    | Possible formats are\n    | ``\"780\"``, ``\"980\"``, ``\"1280\"``, ``\"1600\"``, ``\"0\"`` (original)\n\n\nextractor.hdoujin.tags\n----------------------\nType\n    ``bool``\nDefault\n    ``false``\nDescription\n    Group ``tags`` by type and\n    provide them as ``tags_<type>`` metadata fields,\n    for example ``tags_artist`` or ``tags_character``.\n\n\nextractor.hdoujin.token\n-----------------------\nType\n    ``string``\nExample\n    * ``\"3f1a9b72-4e4d-4f4e-9e5d-4a2b99f7c893\"``\n    * ``\"Bearer 3f1a9b72-4e4d-4f4e-9e5d-4a2b99f7c893\"``\n    * ``\"Authorization: Bearer 3f1a9b72-4e4d-4f4e-9e5d-4a2b99f7c893\"``\nDescription\n    ``Authorization`` header value\n    used for requests to ``https://api.hdoujin.org``\n    to access ``favorite`` galleries\n    or download\n    `.cbz <extractor.hdoujin.cbz_>`__\n    archives.\n\n\nextractor.hentaifoundry.descriptions\n------------------------------------\nType\n    ``string``\nDefault\n    ``\"text\"``\nDescription\n    Controls the format of ``description`` metadata fields.\n\n    ``\"text\"``\n        Plain text with HTML tags removed\n    ``\"html\"``\n        Raw HTML content\n\n\nextractor.hentaifoundry.include\n-------------------------------\nType\n    * ``string``\n    * ``list`` of ``strings``\nDefault\n    ``\"pictures\"``\nExample\n    * ``\"scraps,stories\"``\n    * ``[\"scraps\", \"stories\"]``\nDescription\n    A (comma-separated) list of subcategories to include\n    when processing a user profile.\nSupported Values\n    * ``pictures``\n    * ``scraps``\n    * ``stories``\n    * ``favorite``\nNote\n    It is possible to use ``\"all\"`` instead of listing all values separately.\n\n\nextractor.hitomi.format\n-----------------------\nType\n    ``string``\nDefault\n    ``\"webp\"``\nDescription\n    Selects which image format to download.\nAvailable Formats\n    * ``\"webp\"``\n    * ``\"avif\"``\n\n\nextractor.imagechest.access-token\n---------------------------------\nType\n    ``string``\nDescription\n    Your personal Image Chest access token.\n\n    These tokens allow using the API instead of having to scrape HTML pages,\n    providing more detailed metadata.\n    (``date``, ``description``, etc)\n\n    See https://imgchest.com/docs/api/1.0/general/authorization\n    for instructions on how to generate such a token.\n\n\nextractor.imgur.client-id\n-------------------------\nType\n    ``string``\nDescription\n    Custom Client ID value for API requests.\n\n\nextractor.imgur.mp4\n-------------------\nType\n    * ``bool``\n    * ``string``\nDefault\n    ``true``\nDescription\n    Controls whether to choose the GIF or MP4 version of an animation.\n\n    ``true``\n        Follow Imgur's advice and choose MP4 if the\n        ``prefer_video`` flag in an image's metadata is set.\n    ``false``\n        Always choose GIF.\n    ``\"always\"``\n        Always choose MP4.\n\n\nextractor.inkbunny.orderby\n--------------------------\nType\n    ``string``\nDefault\n    ``\"create_datetime\"``\nDescription\n    Value of the ``orderby`` parameter for submission searches.\n\n    (See `API#Search <https://wiki.inkbunny.net/wiki/API#Search>`__\n    for details)\n\n\nextractor.instagram.api\n-----------------------\nType\n    ``string``\nDefault\n    ``\"rest\"``\nDescription\n    Selects which API endpoints to use.\n\n    ``\"rest\"``\n        REST API - higher-resolution media\n    ``\"graphql\"``\n        GraphQL API - lower-resolution media\n\n\nextractor.instagram.cursor\n--------------------------\nType\n    * ``bool``\n    * ``string``\nDefault\n    ``true``\nExample\n    ``\"3414259811154179155_25025320\"``\nDescription\n    Controls from which position to start the extraction process from.\n\n    ``true``\n        | Start from the beginning.\n        | Log the most recent ``cursor`` value when interrupted before reaching the end.\n    ``false``\n        Start from the beginning.\n    any ``string``\n        Start from the position defined by this value.\n\n\nextractor.instagram.include\n---------------------------\nType\n    * ``string``\n    * ``list`` of ``strings``\nDefault\n    ``\"posts\"``\nExample\n    * ``\"stories,highlights,posts\"``\n    * ``[\"stories\", \"highlights\", \"posts\"]``\nDescription\n    A (comma-separated) list of subcategories to include\n    when processing a user profile.\nSupported Values\n    * ``posts``\n    * ``reels``\n    * ``tagged``\n    * ``stories``\n    * ``highlights``\n    * ``info``\n    * ``avatar``\nNote\n    It is possible to use ``\"all\"`` instead of listing all values separately.\n\n\nextractor.instagram.max-posts\n-----------------------------\nType\n    ``integer``\nDefault\n    ``null``\nDescription\n    Limit the number of posts to download.\n\n\nextractor.instagram.metadata\n----------------------------\nType\n    ``bool``\nDefault\n    ``false``\nDescription\n    Provide extended ``user`` metadata even when referring to a user by ID,\n    e.g. ``instagram.com/id:12345678``.\nNote\n    This metadata is always available when referring to a user by name,\n    e.g. ``instagram.com/USERNAME``.\n\n\nextractor.instagram.order-files\n-------------------------------\nType\n    ``string``\nDefault\n    ``\"asc\"``\nDescription\n    Controls the order in which files of each post are returned.\n\n    ``\"asc\"``\n        Same order as displayed in a post\n    ``\"desc\"`` | ``\"reverse\"``\n        Reverse order as displayed in a post\nNote\n    This option does *not* affect ``{num}``.\n    To enumerate files in reverse order, use ``count - num + 1``.\n\n\nextractor.instagram.order-posts\n-------------------------------\nType\n    ``string``\nDefault\n    ``\"asc\"``\nDescription\n    Controls the order in which posts are returned.\n\n    ``\"asc\"``\n        Same order as displayed\n    ``\"desc\"`` | ``\"reverse\"``\n        Reverse order as displayed\n    ``\"id\"`` or ``\"id_asc\"``\n        Ascending order by ID\n    ``\"id_desc\"``\n        Descending order by ID\nNote\n    This option only affects ``highlights``.\n\n\nextractor.instagram.previews\n----------------------------\nType\n    ``bool``\nDefault\n    ``false``\nDescription\n    Download video previews.\n\n\nextractor.instagram.static-videos\n---------------------------------\nType\n    ``bool``\nDefault\n    ``true``\nDescription\n    Download static story/highlight \"videos\" generated from a photo.\n\n    Disabling this option downloads the photo version instead.\n\n\nextractor.instagram.user-cache\n------------------------------\nType\n    * ``bool``\n    * ``string``\nDefault\n    ``\"disk\"``\nDescription\n    Selects how to cache user profile data.\n\n    ``true`` | ``\"disk\"``\n        Cache user data on disk\n    ``false`` | ``\"memory\"``\n        Cache user data in memory\n\n\nextractor.instagram.user-strategy\n---------------------------------\nType\n    * ``string``\n    * ``list`` of ``strings``\nDefault\n    ``[\"search\", \"web\"]``\nExample\n    * ``\"web,info,search\"``\n    * ``[\"web\", \"info\", \"search\"]``\nDescription\n    Selects how to retrieve user IDs and profile data.\n\n    ``\"search\"`` | ``\"topsearch\"``\n        Use `topsearch` API results\n    ``\"info\"`` | ``\"web_profile_info\"``\n        | Use `web_profile_info` API results\n        | (high liklyhood of ``429 Too Many Requests`` errors)\n    ``\"web\"`` | ``\"webpage\"``\n        Extract minimal user information from profile webpage\n\n\nextractor.instagram.videos\n--------------------------\nType\n    * ``bool``\n    * ``string``\nDefault\n    ``true``\nDescription\n    Controls video download behavior.\n\n    ``true`` | ``\"dash\"`` | ``\"ytdl\"``\n        Download videos from ``video_dash_manifest`` data using |ytdl|\n    ``\"merged\"``\n        Download pre-merged video formats\n    ``false``\n        Do not download videos\n\n\nextractor.instagram.warn-images\n-------------------------------\nType\n    * ``bool``\n    * ``string``\nDefault\n    ``true``\nDescription\n    Show a warning when downloading images\n    with a resolution smaller than the `original`.\n\n    ``true``\n        Show a warning when at least one dimension\n        is smaller than the reported `original` resolution\n    ``\"all\"`` | ``\"both\"``\n        Show a warning only when both ``width`` and ``height``\n        are smaller than the reported `original` resolution\n    ``false``\n        Do not show a warning\n\n\nextractor.instagram.warn-videos\n-------------------------------\nType\n    ``bool``\nDefault\n    ``true``\nDescription\n    Show a warning when downloading videos with a\n    `User-Agent <extractor.*.user-agent_>`__\n    header causing potentially lowered video quality.\n\n\nextractor.instagram.stories.split\n---------------------------------\nType\n    * ``bool``\nDefault\n    ``false``\nDescription\n    Split ``stories`` elements into separate posts.\n\n\nextractor.itaku.include\n-----------------------\nType\n    * ``string``\n    * ``list`` of ``strings``\nDefault\n    ``\"gallery\"``\nExample\n    * ``\"stars,gallery\"``\n    * ``[\"stars\", \"gallery\"]``\nDescription\n    A (comma-separated) list of subcategories to include\n    when processing a user profile.\nSupported Values\n    * ``gallery``\n    * ``posts``\n    * ``followers``\n    * ``following``\n    * ``stars``\nNote\n    It is possible to use ``\"all\"`` instead of listing all values separately.\n\n\nextractor.itaku.order\n---------------------\nType\n    ``string``\nDefault\n    ``\"desc\"``\nDescription\n    Controls the order in which\n    images/posts/users are returned.\n\n    ``\"asc\"`` | ``\"reverse\"``\n        Ascending order (oldest first)\n    ``\"desc\"``\n        Descending order (newest first)\n    any other ``string``\n        Custom result order\n\n\nextractor.itaku.videos\n----------------------\nType\n    ``bool``\nDefault\n    ``true``\nDescription\n    Download video files.\n\n\nextractor.iwara.format\n----------------------\nType\n    * ``string``\n    * ``list`` of ``strings``\nExample\n    * ``\"Source\"``\n    * ``\"360,540,Source\"``\n    * ``[\"360\", \"540\", \"Source\"]``\nDescription\n    Selects the preferred format for video downloads.\n\n    When more than one format is given, the first available one is selected.\n\n\nextractor.iwara.include\n-----------------------\nType\n    * ``string``\n    * ``list`` of ``strings``\nDefault\n    ``[\"user-images\", \"user-videos\"]``\nDescription\n    A (comma-separated) list of subcategories to include\n    when processing a user profile.\nSupported Values\n    * ``user-images``\n    * ``user-videos``\n    * ``user-playlists``\nNote\n    It is possible to use ``\"all\"`` instead of listing all values separately.\n\n\nextractor.joyreactor.embeds\n---------------------------\nType\n    ``bool``\nDefault\n    ``false``\nDescription\n    Downlload embedded external videos with |ytdl|.\n\n\nextractor.joyreactor.formats\n----------------------------\nType\n    * ``string``\n    * ``list`` of ``strings``\nDefault\n    ``[\"webm\"]``\nExample\n    ``\"gif,mp4,webm\"``\nDescription\n    A (comma-separated) list of video formats to download.\nSupported Values\n    * ``webm``\n    * ``mp4``\n    * ``gif``\n\n\nextractor.joyreactor.metadata\n-----------------------------\nType\n    ``bool``\nDefault\n    ``false``\nDescription\n    Extract additional metadata.\n\n\nextractor.joyreactor.videos\n---------------------------\nType\n    ``bool``\nDefault\n    ``true``\nDescription\n    Download videos.\n\n\nextractor.kemono.archives\n-------------------------\nType\n    ``bool``\nDefault\n    ``false``\nDescription\n    Extract additional metadata for ``archives`` files, including\n    ``file``, ``file_list``, and ``password``.\nNote\n    This requires 1 additional HTTP request per ``archives`` file.\n\n\nextractor.kemono.archives-format\n--------------------------------\nType\n    ``string``\nDefault\n    ``\"list\"``\nDescription\n    Determines the format/type of the\n    `archives <extractor.kemono.archives_>`__\n    metadata field.\n\n    ``\"list\"`` | ``\"array\"``\n        Plain ``list`` with archive files as elements\n    ``\"dict\"`` | ``\"object\"``\n        A ``dict`` with each archive file's ``hash`` as key.\n\n\nextractor.kemono.comments\n-------------------------\nType\n    ``bool``\nDefault\n    ``false``\nDescription\n    Extract ``comments`` metadata.\nNote\n    This requires 1 additional HTTP request per post.\n\n\nextractor.kemono.duplicates\n---------------------------\nType\n    * ``bool``\n    * ``string``\n    * ``list`` of ``strings``\nDefault\n    ``false``\nExample\n    * ``\"attachment,inline\"``\n    * ``[\"file\", \"attachment\"]``\nDescription\n    Controls how to handle duplicate files in a post.\n\n    ``true``\n        Download duplicates\n    ``false``\n        Ignore duplicates\n    any ``list`` or ``string``\n        | Download a duplicate file if its ``type`` is in the given list\n        | Ignore it otherwise\n\n\nextractor.kemono.dms\n--------------------\nType\n    ``bool``\nDefault\n    ``false``\nDescription\n    Extract a user's direct messages as ``dms`` metadata.\n\n\nextractor.kemono.announcements\n------------------------------\nType\n    ``bool``\nDefault\n    ``false``\nDescription\n    Extract a user's announcements as ``announcements`` metadata.\n\n\nextractor.kemono.endpoint\n-------------------------\nType\n    ``string``\nDefault\n    ``\"posts\"``\nDescription\n    API endpoint to use for retrieving creator posts.\n\n    ``\"posts\"`` | ``\"legacy\"``\n        Provides only limited metadata.\n    ``\"posts+\"`` | ``\"legacy+\"``\n        Provides full metadata,\n        but requires an additional API request for each post.\n\n\nextractor.kemono.favorites\n--------------------------\nType\n    ``string``\nDefault\n    ``\"artist\"``\nDescription\n    Determines the type of favorites to be downloaded.\n\n    Available types are ``artist``, and ``post``.\n\n\nextractor.kemono.files\n----------------------\nType\n    ``list`` of ``strings``\nDefault\n    ``[\"attachments\", \"file\", \"inline\"]``\nDescription\n    Determines the type and order of files to be downloaded.\nAvailable Types\n    * ``file``\n    * ``attachments``\n    * ``inline``\n\n\nextractor.kemono.max-posts\n--------------------------\nType\n    ``integer``\nDefault\n    ``null``\nDescription\n    Limit the number of posts to download.\n\n\nextractor.kemono.metadata\n-------------------------\nType\n    ``bool``\nDefault\n    ``true``\nDescription\n    Extract ``username`` and ``user_profile`` metadata.\n\n\nextractor.kemono.revisions\n--------------------------\nType\n    * ``bool``\n    * ``string``\nDefault\n    ``false``\nDescription\n    Extract post revisions.\n\n    Set this to ``\"unique\"`` to filter out duplicate revisions.\nNote\n    This requires 1 additional HTTP request per post.\n\n\nextractor.kemono.order-revisions\n--------------------------------\nType\n    ``string``\nDefault\n    ``\"desc\"``\nDescription\n    Controls the order in which\n    `revisions <extractor.kemono.revisions_>`__\n    are returned.\n\n    ``\"asc\"`` | ``\"reverse\"``\n        Ascending order (oldest first)\n    ``\"desc\"``\n        Descending order (newest first)\n\n\nextractor.kemono.discord.order-posts\n------------------------------------\nType\n    ``string``\nDefault\n    ``\"asc\"``\nDescription\n    Controls the order in which\n    ``discord`` posts\n    are returned.\n\n    ``\"asc\"``\n        Ascending order (oldest first)\n    ``\"desc\"`` | ``\"reverse\"``\n        Descending order (newest first)\n\n\nextractor.khinsider.covers\n--------------------------\nType\n    ``bool``\nDefault\n    ``false``\nDescription\n    Download album cover images.\n\n\nextractor.khinsider.format\n--------------------------\nType\n    ``string``\nDefault\n    ``\"mp3\"``\nDescription\n    The name of the preferred file format to download.\n\n    Use ``\"all\"`` to download all available formats,\n    or a (comma-separated) list to select multiple formats.\n\n    If the selected format is not available,\n    the first in the list gets chosen (usually `mp3`).\n\n\nextractor.koofr.recursive\n-------------------------\nType\n    ``bool``\nDefault\n    ``true``\nDescription\n    ``true``\n        Recursively descent into subfolders\n        while downloading individual files.\n    ``false``\n        Download shared `/links/` with multiple files as a single `.zip` file.\n\n\nextractor.lolisafe.domain\n-------------------------\nType\n    ``string``\nDefault\n    ``null``\nDescription\n    Specifies the domain used by a ``lolisafe`` extractor\n    regardless of input URL.\n\n    Setting this option to ``\"auto\"``\n    uses the same domain as a given input URL.\n\n\nextractor.luscious.gif\n----------------------\nType\n    ``bool``\nDefault\n    ``false``\nDescription\n    Format in which to download animated images.\n\n    Use ``true`` to download animated images as gifs and ``false``\n    to download as mp4 videos.\n\n\nextractor.mangadex.api-server\n-----------------------------\nType\n    ``string``\nDefault\n    ``\"https://api.mangadex.org\"``\nDescription\n    The server to use for API requests.\n\n\nextractor.mangadex.api-parameters\n---------------------------------\nType\n    ``object`` (`name` → `value`)\nExample\n    ``{\"order[updatedAt]\": \"desc\"}``\nDescription\n    Additional query parameters to send when fetching manga chapters.\n\n    (See `/manga/{id}/feed <https://api.mangadex.org/docs/swagger.html#/Manga/get-manga-id-feed>`__\n    and `/user/follows/manga/feed <https://api.mangadex.org/docs/swagger.html#/Feed/get-user-follows-manga-feed>`__)\n\n\nextractor.mangadex.data-saver\n-----------------------------\nType\n    ``bool``\nDefault\n    ``false``\nDescription\n    Enable `Data Saver` mode and download lower quality versions of chapters.\n\n\nextractor.mangadex.lang\n-----------------------\nType\n    * ``string``\n    * ``list`` of ``strings``\nExample\n    * ``\"en\"``\n    * ``\"fr,it\"``\n    * ``[\"fr\", \"it\"]``\nDescription\n    |ISO 639-1| code(s) to filter chapters by.\n\n\nextractor.mangadex.ratings\n--------------------------\nType\n    * ``string``\n    * ``list`` of ``strings``\nDefault\n    ``[\"safe\", \"suggestive\", \"erotica\", \"pornographic\"]``\nExample\n    * ``\"safe\"``\n    * ``\"erotica,suggestive\"``\n    * ``[\"erotica\", \"suggestive\"]``\nDescription\n    List of acceptable content ratings for returned chapters.\n\n\nextractor.mangadex.manga.covers\n-------------------------------\nType\n    ``bool``\nDefault\n    ``false``\nDescription\n    Download `manga` `cover` images.\n\n\nextractor.mangafire.manga.lang\n------------------------------\nType\n    ``string``\nDefault\n    ``\"en\"``\nDescription\n    |ISO 639-1| code selecting which chapters to download.\n\n\nextractor.mangareader.manga.lang\n--------------------------------\nType\n    ``string``\nDefault\n    ``\"en\"``\nExample\n    ``\"pt-br\"``\nDescription\n    |ISO 639-1| code selecting which chapters to download.\n\n\nextractor.mangapark.source\n--------------------------\nType\n    * ``string``\n    * ``integer``\nExample\n    * ``\"koala:en\"``\n    * ``15150116``\nDescription\n    Select chapter source and language for a manga.\n\n    | The general syntax is ``\"<source name>:<ISO 639-1 language code>\"``.\n    | Both are optional, meaning ``\"koala\"``, ``\"koala:\"``, ``\":en\"``,\n      or even just ``\":\"`` are possible as well.\n\n    Specifying the numeric ``ID`` of a source is also supported.\n\n\nextractor.[mastodon].access-token\n---------------------------------\nType\n    ``string``\nDefault\n    ``null``\nDescription\n    The ``access-token`` value you get from `linking your account to\n    gallery-dl <OAuth_>`__.\nNote\n    gallery-dl comes with built-in tokens for\n    ``mastodon.social``, ``pawoo``, and ``baraag``.\n\n\nextractor.[mastodon].cards\n--------------------------\nType\n    ``bool``\nDefault\n    ``false``\nDescription\n    Fetch media from cards.\n\n\nextractor.[mastodon].reblogs\n----------------------------\nType\n    ``bool``\nDefault\n    ``false``\nDescription\n    Fetch media from reblogged posts.\n\n\nextractor.[mastodon].replies\n----------------------------\nType\n    ``bool``\nDefault\n    ``true``\nDescription\n    Fetch media from replies to other posts.\n\n\nextractor.[mastodon].text-posts\n-------------------------------\nType\n    ``bool``\nDefault\n    ``false``\nDescription\n    Also emit metadata for text-only posts without media content.\n\n\nextractor.[misskey].access-token\n--------------------------------\nType\n    ``string``\nDescription\n    Your access token, necessary to fetch favorited notes.\n\n\nextractor.[misskey].date-min & .date-max\n----------------------------------------\nType\n    |Date|_\nDefault\n    ``null``\nDescription\n    Retrieve only notes posted after/before this |Date|_\n\n\nextractor.[misskey].include\n---------------------------\nType\n    * ``string``\n    * ``list`` of ``strings``\nDefault\n    ``\"notes\"``\nExample\n    * ``\"avatar,background,notes\"``\n    * ``[\"avatar\", \"background\", \"notes\"]``\nDescription\n    A (comma-separated) list of subcategories to include\n    when processing a user profile.\nSupported Values\n    * ``info``\n    * ``avatar``\n    * ``background``\n    * ``notes``\nNote\n    It is possible to use ``\"all\"`` instead of listing all values separately.\n\n\nextractor.[misskey].order-posts\n-------------------------------\nType\n    ``string``\nDefault\n    ``\"desc\"``\nDescription\n    Controls the order in which posts are processed.\n\n    ``\"asc\"`` | ``\"reverse\"``\n        Ascending order (oldest first)\n    ``\"desc\"``\n        Descending order (newest first)\n\n\nextractor.[misskey].renotes\n---------------------------\nType\n    ``bool``\nDefault\n    ``false``\nDescription\n    Fetch media from renoted notes.\n\n\nextractor.[misskey].replies\n---------------------------\nType\n    ``bool``\nDefault\n    ``true``\nDescription\n    Fetch media from replies to other notes.\n\n\nextractor.[misskey].text-posts\n------------------------------\nType\n    ``bool``\nDefault\n    ``false``\nDescription\n    Also retrieve text-only notes without media content.\n\n\nextractor.[moebooru].pool.metadata\n----------------------------------\nType\n    ``bool``\nDefault\n    ``false``\nDescription\n    Extract extended ``pool`` metadata.\nNote\n    Not supported by all ``moebooru`` instances.\n\n\nextractor.naver-blog.videos\n---------------------------\nType\n    ``bool``\nDefault\n    ``true``\nDescription\n    Download videos.\n\n\nextractor.naver-chzzk.offset\n----------------------------\nType\n    ``integer``\nDefault\n    ``0``\nDescription\n    Custom ``offset`` starting value when paginating over comments.\n\n\nextractor.newgrounds.flash\n--------------------------\nType\n    ``bool``\nDefault\n    ``true``\nDescription\n    Download original Adobe Flash animations instead of pre-rendered videos.\n\n\nextractor.newgrounds.format\n---------------------------\nType\n    * ``string``\n    * ``list`` of ``string``\nDefault\n    ``\"original\"``\nExample\n    * ``\"720p\"``\n    * ``[\"mp4\", \"mov\", \"1080p\", \"720p\"]``\nDescription\n    Selects the preferred format for video downloads.\n\n    If the selected format is not available,\n    the next smaller one gets chosen.\n\n    If this is a ``list``, try each given\n    filename extension in original resolution or recoded format\n    until an available format is found.\n\n\nextractor.newgrounds.include\n----------------------------\nType\n    * ``string``\n    * ``list`` of ``strings``\nDefault\n    ``\"art\"``\nExample\n    * ``\"movies,audio\"``\n    * ``[\"movies\", \"audio\"]``\nDescription\n    A (comma-separated) list of subcategories to include\n    when processing a user profile.\nSupported Values\n    * ``art``\n    * ``audio``\n    * ``games``\n    * ``movies``\nNote\n    It is possible to use ``\"all\"`` instead of listing all values separately.\n\n\nextractor.nijie.include\n-----------------------\nType\n    * ``string``\n    * ``list`` of ``strings``\nDefault\n    ``\"illustration,doujin\"``\nDescription\n    A (comma-separated) list of subcategories to include\n    when processing a user profile.\nSupported Values\n    * ``illustration``\n    * ``doujin``\n    * ``favorite``\n    * ``nuita``\nNote\n    It is possible to use ``\"all\"`` instead of listing all values separately.\n\n\nextractor.[nitter].quoted\n-------------------------\nType\n    ``bool``\nDefault\n    ``false``\nDescription\n    Fetch media from quoted Tweets.\n\n\nextractor.[nitter].retweets\n---------------------------\nType\n    ``bool``\nDefault\n    ``false``\nDescription\n    Fetch media from Retweets.\n\n\nextractor.[nitter].videos\n-------------------------\nType\n    * ``bool``\n    * ``string``\nDefault\n    ``true``\nDescription\n    Control video download behavior.\n\n    ``true``\n        Download videos\n    ``\"ytdl\"``\n        Download videos using |ytdl|\n    ``false``\n        Skip video Tweets\n\n\nextractor.oauth.browser\n-----------------------\nType\n    ``bool``\nDefault\n    ``true``\nDescription\n    Controls how a user is directed to an OAuth authorization page.\n\n    ``true``\n        Use Python's |webbrowser.open()|_ method to automatically\n        open the URL in the user's default browser.\n    ``false``\n        Ask the user to copy & paste an URL from the terminal.\n\n\nextractor.oauth.cache\n---------------------\nType\n    ``bool``\nDefault\n    ``true``\nDescription\n    Store tokens received during OAuth authorizations\n    in `cache <cache.file_>`__.\n\n\nextractor.oauth.host\n--------------------\nType\n    ``string``\nDefault\n    ``\"localhost\"``\nDescription\n    Host name / IP address to bind to during OAuth authorization.\n\n\nextractor.oauth.port\n--------------------\nType\n    ``integer``\nDefault\n    ``6414``\nDescription\n    Port number to listen on during OAuth authorization.\nNote\n    All redirects will go to port ``6414``, regardless\n    of the port specified here. You'll have to manually adjust the\n    port number in your browser's address bar when using a different\n    port than the default.\n\n\nextractor.paheal.metadata\n-------------------------\nType\n    ``bool``\nDefault\n    ``false``\nDescription\n    Extract additional metadata (``source``, ``uploader``)\nNote\n    This requires 1 additional HTTP request per post.\n\n\nextractor.patreon.cursor\n------------------------\nType\n    * ``bool``\n    * ``string``\nDefault\n    ``true``\nExample\n    ``\"03:eyJ2IjoxLCJjIjoiMzU0NDQ1MjAiLCJ0IjoiIn0=:DTcmjBoVj01o_492YBYqHhqx\"``\nDescription\n    Controls from which position to start the extraction process from.\n\n    ``true``\n        | Start from the beginning.\n        | Log the most recent ``cursor`` value when interrupted before reaching the end.\n    ``false``\n        Start from the beginning.\n    any ``string``\n        Start from the position defined by this value.\n\n\nextractor.patreon.files\n-----------------------\nType\n    ``list`` of ``strings``\nDefault\n    ``[\"images\", \"image_large\", \"attachments\", \"postfile\", \"content\"]``\nDescription\n    Determines types and order of files to download.\nAvailable Types\n    * ``postfile``\n    * ``images``\n    * ``image_large``\n    * ``attachments``\n    * ``content``\n\n\nextractor.patreon.format-images\n-------------------------------\nType\n    ``string``\nDefault\n    ``\"download_url\"``\nDescription\n    Selects the format of ``images`` `files <extractor.patreon.files_>`__.\nAvailable Formats\n    * ``download_url`` (``\"a\":1,\"p\":1``)\n    * ``url`` (``\"w\":620``)\n    * ``original`` (``\"q\":100,\"webp\":0``)\n    * ``default`` (``\"w\":620``)\n    * ``default_small`` (``\"w\":360``)\n    * ``default_blurred`` (``\"w\":620``)\n    * ``default_blurred_small`` (``\"w\":360``)\n    * ``thumbnail`` (``\"h\":360,\"w\":360``)\n    * ``thumbnail_large`` (``\"h\":1080,\"w\":1080``)\n    * ``thumbnail_small`` (``\"h\":100,\"w\":100``)\n\n\nextractor.patreon.order-posts\n-----------------------------\nType\n    ``string``\nDefault\n    ``collection``\n        ``\"asc\"``\n    otherwise\n        ``\"desc\"``\nExample\n    * ``\"-published_at\"``\n    * ``\"collection_order\"``\nDescription\n    Controls the order in which\n    posts are returned and processed.\n\n    ``\"asc\"``\n        Ascending order (oldest first)\n    ``\"desc\"``\n        Descending order (newest first)\n    ``\"reverse\"``\n        Reverse order\n    any other ``string``\n        Custom ``sort`` order\n\n\nextractor.patreon.user.date-max\n-------------------------------\nType\n    |Date|_\nDefault\n    ``0``\nDescription\n    Sets the |Date|_ to start from.\n\n\nextractor.[philomena].api-key\n-----------------------------\nType\n    ``string``\nDefault\n    ``null``\nDescription\n    Your account's API Key,\n    to use your personal browsing settings and filters.\n\n\nextractor.[philomena].filter\n----------------------------\nType\n    ``integer``\nDefault\n    :``derpibooru``:\n        ``56027`` (`Everything <https://derpibooru.org/filters/56027>`__ filter)\n    :``ponybooru``:\n        ``3`` (`Nah. <https://ponybooru.org/filters/3>`__ filter)\n    :otherwise:\n        ``2``\n\nDescription\n    The content filter ID to use.\n\n    Setting an explicit filter ID overrides any default filters and can be used\n    to access 18+ content without `API Key <extractor.[philomena].api-key_>`_.\n\n    See `Filters <https://derpibooru.org/filters>`_ for details.\n\n\nextractor.[philomena].svg\n-------------------------\nType\n    ``bool``\nDefault\n    ``true``\nDescription\n    Download SVG versions of images when available.\n\n    Try to download the ``view_url`` version of these posts\n    when this option is disabled.\n\n\nextractor.pillowfort.external\n-----------------------------\nType\n    ``bool``\nDefault\n    ``false``\nDescription\n    Follow links to external sites, e.g. Twitter,\n\n\nextractor.pillowfort.inline\n---------------------------\nType\n    ``bool``\nDefault\n    ``true``\nDescription\n    Extract inline images.\n\n\nextractor.pillowfort.reblogs\n----------------------------\nType\n    ``bool``\nDefault\n    ``false``\nDescription\n    Extract media from reblogged posts.\n\n\nextractor.pinterest.domain\n--------------------------\nType\n    ``string``\nDefault\n    ``\"auto\"``\nDescription\n    Specifies the domain used by ``pinterest`` extractors.\n\n    Setting this option to ``\"auto\"``\n    uses the same domain as a given input URL.\n\n\nextractor.pinterest.sections\n----------------------------\nType\n    ``bool``\nDefault\n    ``true``\nDescription\n    Include pins from board sections.\n\n\nextractor.pinterest.stories\n---------------------------\nType\n    ``bool``\nDefault\n    ``true``\nDescription\n    Extract files from story pins.\n\n\nextractor.pinterest.videos\n--------------------------\nType\n    ``bool``\nDefault\n    ``true``\nDescription\n    Download from video pins.\n\n\nextractor.pixeldrain.api-key\n----------------------------\nType\n    ``string``\nDescription\n    Your account's `API key <https://pixeldrain.com/user/api_keys>`__\n\n\nextractor.pixeldrain.recursive\n------------------------------\nType\n    ``bool``\nDefault\n    ``true``\nDescription\n    Recursively download files from subfolders.\n\n\nextractor.pixeldrain.zip\n------------------------\nType\n    ``bool``\nDefault\n    ``false``\nDescription\n    Download albums as a single `.zip` file.\n\n\nextractor.pixiv.include\n-----------------------\nType\n    * ``string``\n    * ``list`` of ``strings``\nDefault\n    ``\"artworks\"``\nExample\n    * ``\"avatar,background,artworks\"``\n    * ``[\"avatar\", \"background\", \"artworks\"]``\nDescription\n    A (comma-separated) list of subcategories to include\n    when processing a user profile.\nSupported Values\n    * ``artworks``\n    * ``avatar``\n    * ``background``\n    * ``favorite``\n    * ``novel-user``\n    * ``novel-bookmark``\n    * ``sketch``\nNote\n    It is possible to use ``\"all\"`` instead of listing all values separately.\n\n\nextractor.pixiv.refresh-token\n-----------------------------\nType\n    ``string``\nDescription\n    The ``refresh-token`` value you get\n    from running ``gallery-dl oauth:pixiv`` (see OAuth_) or\n    by using a third-party tool like\n    `gppt <https://github.com/eggplants/get-pixivpy-token>`__.\n\n\nextractor.pixiv.metadata\n------------------------\nType\n    ``bool``\nDefault\n    ``false``\nDescription\n    Fetch extended ``user`` metadata.\n\n\nextractor.pixiv.metadata-bookmark\n---------------------------------\nType\n    ``bool``\nDefault\n    ``false``\nDescription\n    For works bookmarked by\n    `your own account <extractor.pixiv.refresh-token_>`__,\n    fetch bookmark tags as ``tags_bookmark`` metadata.\nNote\n    This requires 1 additional API request per bookmarked post.\n\n\nextractor.pixiv.captions\n------------------------\nType\n    ``bool``\nDefault\n    ``false``\nDescription\n    For works with seemingly empty ``caption`` metadata,\n    try to grab the actual ``caption`` value using the AJAX API.\n\n\nextractor.pixiv.comments\n------------------------\nType\n    ``bool``\nDefault\n    ``false``\nDescription\n    Fetch ``comments`` metadata.\nNote\n    This requires 1 or more additional API requests per post,\n    depending on the number of comments.\n\n\nextractor.pixiv.work.related\n----------------------------\nType\n    ``bool``\nDefault\n    ``false``\nDescription\n    Also download related artworks.\n\n\nextractor.pixiv.tags\n--------------------\nType\n    ``string``\nDefault\n    ``\"japanese\"``\nDescription\n    Controls the ``tags`` metadata field.\n\n    * `\"japanese\"`: List of Japanese tags\n    * `\"translated\"`: List of translated tags\n    * `\"original\"`: Unmodified list with both Japanese and translated tags\n\n\nextractor.pixiv.ugoira\n----------------------\nType\n    * ``bool``\n    * ``string``\nDefault\n    ``true``\nDescription\n    Download Pixiv's Ugoira animations.\n\n    These animations come as a ``.zip`` archive containing all\n    animation frames in JPEG format by default.\n\n    Set this option to ``\"original\"``\n    to download them as individual, higher-quality frames.\n\n    Use an `ugoira` post processor to convert them\n    to watchable animations. (Example__)\n\n.. __: https://github.com/mikf/gallery-dl/blob/v1.12.3/docs/gallery-dl-example.conf#L9-L14\n\n\nextractor.pixiv.max-posts\n-------------------------\nType\n    ``integer``\nDefault\n    ``0``\nDescription\n    When downloading galleries, this sets the maximum number of posts to get.\n    A value of ``0`` means no limit.\n\n\nextractor.pixiv.sanity\n----------------------\nType\n    ``bool``\nDefault\n    ``true``\nDescription\n    Try to fetch ``limit_sanity_level`` works via web API.\n\n\nextractor.pixiv-novel.comments\n------------------------------\nType\n    ``bool``\nDefault\n    ``false``\nDescription\n    Fetch ``comments`` metadata.\nNote\n    This requires 1 or more additional API requests per novel,\n    depending on the number of comments.\n\n\nextractor.pixiv-novel.covers\n----------------------------\nType\n    ``bool``\nDefault\n    ``false``\nDescription\n    Download cover images.\n\n\nextractor.pixiv-novel.embeds\n----------------------------\nType\n    ``bool``\nDefault\n    ``false``\nDescription\n    Download embedded images.\n\n\nextractor.pixiv-novel.full-series\n---------------------------------\nType\n    ``bool``\nDefault\n    ``false``\nDescription\n    When downloading a novel being part of a series,\n    download all novels of that series.\n\n\nextractor.pixiv-novel.max-posts\n-------------------------------\nType\n    ``integer``\nDefault\n    ``0``\nDescription\n    When downloading multiple novels,\n    this sets the maximum number of novels to get.\n\n    A value of ``0`` means no limit.\n\n\nextractor.pixiv-novel.metadata\n------------------------------\nType\n    ``bool``\nDefault\n    ``false``\nDescription\n    Fetch extended ``user`` metadata.\n\n\nextractor.pixiv-novel.metadata-bookmark\n---------------------------------------\nType\n    ``bool``\nDefault\n    ``false``\nDescription\n    For novels bookmarked by\n    `your own account <extractor.pixiv-novel.refresh-token_>`__,\n    fetch bookmark tags as ``tags_bookmark`` metadata.\nNote\n    This requires 1 additional API request per bookmarked post.\n\n\nextractor.pixiv-novel.refresh-token\n-----------------------------------\nType\n    ``string``\nDescription\n    The ``refresh-token`` value you get\n    from running ``gallery-dl oauth:pixiv`` (see OAuth_) or\n    by using a third-party tool like\n    `gppt <https://github.com/eggplants/get-pixivpy-token>`__.\n\n    This can be the same value as `extractor.pixiv.refresh-token`_\n\n\nextractor.pixiv-novel.tags\n--------------------------\nType\n    ``string``\nDefault\n    ``\"japanese\"``\nDescription\n    Controls the ``tags`` metadata field.\n\n    * `\"japanese\"`: List of Japanese tags\n    * `\"translated\"`: List of translated tags\n    * `\"original\"`: Unmodified list with both Japanese and translated tags\n\n\nextractor.plurk.comments\n------------------------\nType\n    ``bool``\nDefault\n    ``false``\nDescription\n    Also search Plurk comments for URLs.\n\n\nextractor.[postmill].save-link-post-body\n----------------------------------------\nType\n    ``bool``\nDefault\n    ``false``\nDescription\n    Whether or not to save the body for link/image posts.\n\n\nextractor.reactor.gif\n---------------------\nType\n    ``bool``\nDefault\n    ``false``\nDescription\n    Format in which to download animated images.\n\n    Use ``true`` to download animated images as gifs and ``false``\n    to download as mp4 videos.\n\n\nextractor.readcomiconline.captcha\n---------------------------------\nType\n    ``string``\nDefault\n    ``\"stop\"``\nDescription\n    Controls how to handle redirects to CAPTCHA pages.\n\n    ``\"stop``\n        Stop the current extractor run.\n    ``\"wait``\n        Ask the user to solve the CAPTCHA and wait.\n\n\nextractor.readcomiconline.quality\n---------------------------------\nType\n    ``string``\nDefault\n    ``\"auto\"``\nDescription\n    Sets the ``quality`` query parameter of issue pages. (``\"lq\"`` or ``\"hq\"``)\n\n    ``\"auto\"`` uses the quality parameter of the input URL\n    or ``\"hq\"`` if not present.\n\n\nextractor.reddit.api\n--------------------\nType\n    ``string``\nDefault\n    ``\"rest\"``\nDescription\n    Selects which API endpoints to use.\n\n    ``\"oauth\"``\n        Use the OAuth API at ``https://oauth.reddit.com``\n\n        Requires\n        `client-id & user-agent <extractor.reddit.client-id & .user-agent_>`__\n        and uses a\n        `refresh token <extractor.reddit.refresh-token_>`__\n        for authentication.\n\n    ``\"rest\"``\n        Use the REST API at ``https://www.reddit.com``\n\n        Uses\n        `cookies <extractor.*.cookies_>`__\n        for authentication.\n\n\nextractor.reddit.comments\n-------------------------\nType\n    ``integer``\nDefault\n    ``0``\nDescription\n    The value of the ``limit`` parameter when loading\n    a submission and its comments.\n    This number (roughly) specifies the total amount of comments\n    being retrieved with the first API call.\n\n    Reddit's internal default and maximum values for this parameter\n    appear to be 200 and 500 respectively.\n\n    The value ``0`` ignores all comments and significantly reduces the\n    time required when scanning a subreddit.\n\n\nextractor.reddit.morecomments\n-----------------------------\nType\n    ``bool``\nDefault\n    ``false``\nDescription\n    Retrieve additional comments by resolving the ``more`` comment\n    stubs in the base comment tree.\nNote\n    This requires 1 additional API call for every 100 extra comments.\n\n\nextractor.reddit.embeds\n-----------------------\nType\n    ``bool``\nDefault\n    ``true``\nDescription\n    Download embedded comments media.\n\n\nextractor.reddit.date-min & .date-max\n-------------------------------------\nType\n    |Date|_\nDefault\n    ``0`` and ``253402210800`` (timestamp of |datetime.max|_)\nDescription\n    Ignore all submissions posted before/after this date.\n\n\nextractor.reddit.id-min & .id-max\n---------------------------------\nType\n    ``string``\nExample\n    ``\"6kmzv2\"``\nDescription\n    Ignore all submissions posted before/after the submission with this ID.\n\n\nextractor.reddit.limit\n----------------------\nType\n    ``integer``\nDefault\n    ``null``\nDescription\n    Number of results to return in a single API query.\n\n    This value specifies the ``limit`` parameter\n    used for API requests when retrieving paginated results.\n\n    ``null`` means not including this parameter at all\n    and letting Reddit chose a default.\n\n\nextractor.reddit.previews\n-------------------------\nType\n    ``bool``\nDefault\n    ``true``\nDescription\n    For failed downloads from external URLs / child extractors,\n    download Reddit's preview image/video if available.\n\n\nextractor.reddit.recursion\n--------------------------\nType\n    ``integer``\nDefault\n    ``0``\nDescription\n    Reddit extractors can recursively visit other submissions\n    linked to in the initial set of submissions.\n    This value sets the maximum recursion depth.\nSpecial Values\n    ``0``\n        Recursion is disabled\n    ``-1``\n        Infinite recursion (don't do this)\n\n\nextractor.reddit.refresh-token\n------------------------------\nType\n    ``string``\nDefault\n    ``null``\nDescription\n    The ``refresh-token`` value you get from\n    `linking your Reddit account to gallery-dl <OAuth_>`__.\n\n    Using a ``refresh-token`` allows you to access private or otherwise\n    not publicly available subreddits, given that your account is\n    authorized to do so,\n    but requests to the reddit API are going to be rate limited\n    at 600 requests every 10 minutes/600 seconds.\n\n\nextractor.reddit.selftext\n-------------------------\nType\n    ``bool``\nDefault\n    * ``true`` if `comments <extractor.reddit.comments_>`__ are enabled\n    * ``false`` otherwise\nDescription\n    Follow links in the original post's ``selftext``.\n\n\nextractor.reddit.videos\n-----------------------\nType\n    * ``bool``\n    * ``string``\nDefault\n    ``\"dash\"``\nDescription\n    Control video download behavior.\n\n    ``true``\n        Download videos and use |ytdl| to handle\n        HLS and DASH manifests\n    ``\"ytdl\"``\n        Download videos and let |ytdl| handle all of\n        video extraction and download\n    ``\"dash\"``\n        Extract DASH manifest URLs and use |ytdl|\n        to download and merge them. (*)\n    ``false``\n        Ignore videos\nNote\n    (*)\n    This saves 1 HTTP request per video\n    and might potentially be able to download otherwise deleted videos,\n    but it will not always get the best video quality available.\n\n\nextractor.reddit.user.only\n--------------------------\nType\n    ``bool``\nDefault\n    ``user-saved`` | ``user-upvoted`` | ``user-downvoted``\n        ``false``\n    otherwise\n        ``true``\nDescription\n    Only process and return posts from the user specified in the input URL.\n\n\nextractor.redgifs.format\n------------------------\nType\n    * ``string``\n    * ``list`` of ``strings``\nDefault\n    ``[\"hd\", \"sd\", \"gif\"]``\nDescription\n    List of names of the preferred animation format.`\n\n    If a selected format is not available, the next one in the list will be\n    tried until an available format is found.\n\n    If the format is given as ``string``, it will be extended with\n    ``[\"hd\", \"sd\", \"gif\"]``. Use a list with one element to\n    restrict it to only one possible format.\nAvailable Formats\n    * ``\"hd\"``\n    * ``\"sd\"``\n    * ``\"gif\"``\n    * ``\"thumbnail\"``\n    * ``\"vthumbnail\"``\n    * ``\"poster\"``\n\n\nextractor.rule34.api-key & .user-id\n-----------------------------------\nType\n    ``string``\nDefault\n    ``null``\nDescription\n    Values from the `API Access Credentials` section\n    found near the bottom of your account's\n    `Options <https://rule34.xxx/index.php?page=account&s=options>`__\n    page.\n\n    Enable `Generate New Key?` and click `Save`\n    if the value after ``&api_key=`` is empty,\n    e.g. ``&api_key=&user_id=12345``\n\n\nextractor.rule34xyz.format\n--------------------------\nType\n    * ``string``\n    * ``list`` of ``strings``\nDefault\n    ``[\"10\", \"40\", \"41\", \"2\"]``\nExample\n    ``\"33,34,4\"``\nDescription\n    Selects the file format to extract.\n\n    When more than one format is given, the first available one is selected.\n\n\nextractor.sankaku.refresh\n-------------------------\nType\n    ``bool``\nDefault\n    ``false``\nDescription\n    Refresh download URLs before they expire.\n\n\nextractor.sankaku.tags\n----------------------\nType\n    * ``bool``\n    * ``string``\nDefault\n    ``false``\nDescription\n    | Group ``tags`` by type and\n      provide them as ``tags_<type>`` and ``tag_string_TYPE`` metadata fields,\n    | for example ``tags_artist`` and ``tags_character``.\n\n    ``true``\n        Enable general ``tags`` categories\n\n        Requires:\n\n        * 1 additional API request per 100 tags per post\n\n    ``\"extended\"``\n        Group ``tags`` by the new, extended tag category system\n        used on ``chan.sankakucomplex.com``\n\n        Requires:\n\n        * 1 additional HTTP request per post\n        * authenticated `cookies <extractor.*.cookies_>`__\n          to fetch full ``tags`` category data\n\n    ``false``\n        Disable ``tags`` categories\n\n\nextractor.sankakucomplex.embeds\n-------------------------------\nType\n    ``bool``\nDefault\n    ``false``\nDescription\n    Download video embeds from external sites.\n\n\nextractor.sankakucomplex.videos\n-------------------------------\nType\n    ``bool``\nDefault\n    ``true``\nDescription\n    Download videos.\n\n\nextractor.schalenetwork.cbz\n---------------------------\nType\n    ``bool``\nDefault\n    ``false``\nDescription\n    Download each gallery as a single ``.cbz`` file.\nNote\n    Requires a\n    `token <extractor.schalenetwork.token_>`__\n\n\nextractor.schalenetwork.crt\n---------------------------\nType\n    ``string``\nExample\n    * ``\"0542daa9-352c-4fd5-a497-6c6d5cf07423\"``\n    * ``\"/12345/a1b2c3d4e5f6?crt=0542daa9-352c-4fd5-a497-6c6d5cf07423\"``\nDescription\n    The ``crt`` query parameter value\n    sent when fetching gallery data.\n\n    To get this value:\n\n    * Open your browser's Developer Tools (F12)\n    * Select `Network` → `XHR`\n    * Open a gallery page\n    * Select the last `Network` entry and copy its ``crt`` value\nNote\n    You will also need your browser's\n    `user-agent <extractor.*.user-agent_>`__\n\n\nextractor.schalenetwork.format\n------------------------------\nType\n    * ``string``\n    * ``list`` of ``strings``\nDefault\n    ``[\"0\", \"1600\", \"1280\", \"980\", \"780\"]``\nDescription\n    Name(s) of the image format to download.\n\n    When more than one format is given, the first available one is selected.\nFormats\n    * ``\"780\"``\n    * ``\"980\"``\n    * ``\"1280\"``\n    * ``\"1600\"``\n    * ``\"0\"`` (original)\n\n\nextractor.schalenetwork.tags\n----------------------------\nType\n    ``bool``\nDefault\n    ``true``\nDescription\n    Group ``tags`` by type and\n    provide them as ``tags_<type>`` metadata fields,\n    for example ``tags_artist`` or ``tags_character``.\n\n\nextractor.schalenetwork.token\n-----------------------------\nType\n    ``string``\nExample\n    * ``\"3f1a9b72-4e4d-4f4e-9e5d-4a2b99f7c893\"``\n    * ``\"Bearer 3f1a9b72-4e4d-4f4e-9e5d-4a2b99f7c893\"``\n    * ``\"Authorization: Bearer 3f1a9b72-4e4d-4f4e-9e5d-4a2b99f7c893\"``\nDescription\n    ``Authorization`` header value\n    used for requests to ``https://api.schale.network``\n    to access ``favorite`` galleries\n    or download\n    `.cbz <extractor.schalenetwork.cbz_>`__\n    archives.\n\n\nextractor.sexcom.gifs\n---------------------\nType\n    ``bool``\nDefault\n    ``true``\nDescription\n    Download animated images as ``.gif`` instead of ``.webp``\n\n\nextractor.sizebooru.metadata\n----------------------------\nType\n    ``bool``\nDefault\n    ``false``\nDescription\n    Extract additional metadata:\n\n    * ``approver``\n    * ``artist``\n    * ``date``\n    * ``date_approved``\n    * ``favorite``\n    * ``source``\n    * ``tags``\n    * ``uploader``\n    * ``views``\nNote\n    This requires 1 additional HTTP request per post.\n\n\nextractor.skeb.article\n----------------------\nType\n    ``bool``\nDefault\n    ``false``\nDescription\n    Download article images.\n\n\nextractor.skeb.include\n----------------------\nType\n    * ``string``\n    * ``list`` of ``strings``\nDefault\n    * ``[\"works\", \"sentrequests\"]``\n      if `sent-requests <extractor.skeb.sent-requests_>`__ are enabled\n    * ``[\"works\"]`` otherwise\nExample\n    * ``\"works,sentrequests\"``\n    * ``[\"works\", \"sentrequests\"]``\nDescription\n    A (comma-separated) list of subcategories to include\n    when processing a user profile.\nSupported Values\n    * ``works``\n    * ``sentrequests``\nNote\n    It is possible to use ``\"all\"`` instead of listing all values separately.\n\n\nextractor.skeb.sent-requests\n----------------------------\nType\n    ``bool``\nDefault\n    ``false``\nDescription\n    Download sent requests.\n\n\nextractor.skeb.thumbnails\n-------------------------\nType\n    ``bool``\nDefault\n    ``false``\nDescription\n    Download thumbnails.\n\n\nextractor.skeb.search.filters\n-----------------------------\nType\n    * ``string``\n    * ``list`` of ``strings``\nDefault\n    ``[\"genre:art\", \"genre:voice\", \"genre:novel\", \"genre:video\", \"genre:music\", \"genre:correction\"]``\nExample\n    ``\"genre:music OR genre:voice\"``\nDescription\n    Filters used during searches.\n\n\nextractor.smugmug.videos\n------------------------\nType\n    ``bool``\nDefault\n    ``true``\nDescription\n    Download video files.\n\n\nextractor.steamgriddb.animated\n------------------------------\nType\n    ``bool``\nDefault\n    ``true``\nDescription\n    Include ``animated`` assets\n    when downloading from a list of assets.\n\n\nextractor.steamgriddb.epilepsy\n------------------------------\nType\n    ``bool``\nDefault\n    ``true``\nDescription\n    Include assets tagged with ``epilepsy``\n    when downloading from a list of assets.\n\n\nextractor.steamgriddb.dimensions\n--------------------------------\nType\n    * ``string``\n    * ``list`` of ``strings``\nDefault\n    ``\"all\"``\nExample\n    * ``\"1024x512,512x512\"``\n    * ``[\"460x215\", \"920x430\"]``\nDescription\n    Only include assets that are in the specified dimensions. ``all`` can be\n    used to specify all dimensions.\nValid Values\n    Grids\n        ``460x215`` |\n        ``920x430`` |\n        ``342x482`` |\n        ``600x900`` |\n        ``660x930`` |\n        ``512x512`` |\n        ``1024x1024``\n    Heroes\n        ``1600x650`` |\n        ``1920x620`` |\n        ``3840x1240``\n    Logos\n        N/A (will be ignored)\n    Icons\n        ``8x8`` | ``10x10`` | ``14x14`` | ``16x16`` | ``20x20`` | ``24x24`` |\n        ``28x28`` | ``32x32`` | ``35x35`` | ``40x40`` | ``48x48`` | ``54x54`` |\n        ``56x56`` | ``57x57`` | ``60x60`` | ``64x64`` | ``72x72`` | ``76x76`` |\n        ``80x80`` | ``90x90`` | ``96x96`` | ``100x100`` | ``114x114`` | ``120x120`` |\n        ``128x128`` | ``144x144`` | ``150x150`` | ``152x152`` | ``160x160`` |\n        ``180x180`` | ``192x192`` | ``194x194`` | ``256x256`` | ``310x310`` |\n        ``512x512`` | ``768x768`` | ``1024x1024``\n\n\nextractor.steamgriddb.file-types\n--------------------------------\nType\n    * ``string``\n    * ``list`` of ``strings``\nDefault\n    ``\"all\"``\nExample\n    * ``\"png,jpeg\"``\n    * ``[\"jpeg\", \"webp\"]``\nDescription\n    Only include assets that are in the specified file types. ``all`` can be\n    used to specify all file types.\nValid Values\n    Grids\n        ``png`` | ``jpeg`` | ``jpg`` | ``webp``\n    Heroes\n        ``png`` | ``jpeg`` | ``jpg`` | ``webp``\n    Logos\n        ``png`` | ``webp``\n    Icons\n        ``png`` | ``ico``\n\n\nextractor.steamgriddb.download-fake-png\n---------------------------------------\nType\n    ``bool``\nDefault\n    ``true``\nDescription\n    Download fake PNGs alongside the real file.\n\n\nextractor.steamgriddb.humor\n---------------------------\nType\n    ``bool``\nDefault\n    ``true``\nDescription\n    Include assets tagged with ``humor``\n    when downloading from a list of assets.\n\n\nextractor.steamgriddb.languages\n-------------------------------\nType\n    * ``string``\n    * ``list`` of ``strings``\nDefault\n    ``\"all\"``\nExample\n    * ``\"en,km\"``\n    * ``[\"fr\", \"it\"]``\nDescription\n    Only include assets that are in the specified languages.\nValid Values\n    |ISO 639-1| codes\nNote\n    ``all`` can be used to specify all languages.\n\n\nextractor.steamgriddb.nsfw\n--------------------------\nType\n    ``bool``\nDefault\n    ``true``\nDescription\n    Include assets tagged with adult content when downloading from a list of assets.\n\n\nextractor.steamgriddb.sort\n--------------------------\nType\n    ``string``\nDefault\n    ``\"score_desc\"``\nDescription\n    Set the chosen sorting method when downloading from a list of assets.\nSupported Values\n    * ``score_desc``     (Highest Score (Beta))\n    * ``score_asc``      (Lowest Score (Beta))\n    * ``score_old_desc`` (Highest Score (Old))\n    * ``score_old_asc``  (Lowest Score (Old))\n    * ``age_desc``       (Newest First)\n    * ``age_asc``        (Oldest First)\n\n\nextractor.steamgriddb.static\n----------------------------\nType\n    ``bool``\nDefault\n    ``true``\nDescription\n    Include static assets when downloading from a list of assets.\n\n\nextractor.steamgriddb.styles\n----------------------------\nType\n    * ``string``\n    * ``list`` of ``strings``\nDefault\n    ``\"all\"``\nExample\n    * ``\"white,black\"``\n    * ``[\"no_logo\", \"white_logo\"]``\nDescription\n    Only include assets that are in the specified styles.\nValid Values\n    Grids\n        ``alternate`` | ``blurred`` | ``no_logo`` | ``material`` | ``white_logo``\n    Heroes\n        ``alternate`` | ``blurred`` | ``material``\n    Logos\n        ``official`` | ``white`` | ``black`` | ``custom``\n    Icons\n        ``official`` | ``custom``\nNote\n    ``\"all\"`` can be used to specify all styles.\n\nextractor.steamgriddb.untagged\n------------------------------\nType\n    ``bool``\nDefault\n    ``true``\nDescription\n    Include untagged assets when downloading from a list of assets.\n\n\nextractor.[szurubooru].username & .token\n----------------------------------------\nType\n    ``string``\nDescription\n    Username and login token of your account to access private resources.\n\n    To generate a token, visit ``/user/USERNAME/list-tokens``\n    and click ``Create Token``.\n\n\nextractor.tenor.format\n----------------------\nType\n    * ``string``\n    * ``list`` of ``strings``\nDefault\n    ``[\"gif\", \"mp4\", \"webm\", \"webp\"]``\nDescription\n    List of names of the preferred animation format.\n\n    If a selected format is not available, the next one in the list will be\n    tried until a format is found.\nAvailable Formats\n    * ``gif``\n    * ``gif_transparent``\n    * ``mediumgif``\n    * ``gifpreview``\n    * ``tinygif``\n    * ``tinygif_transparent``\n    * ``mp4``\n    * ``tinymp4``\n    * ``webm``\n    * ``webp``\n    * ``webp_transparent``\n    * ``tinywebp``\n    * ``tinywebp_transparent``\n\n\nextractor.tiktok.audio\n----------------------\nType\n    * ``bool``\n    * ``string``\nDefault\n    ``true``\nDescription\n    Controls audio download behavior.\n\n    ``true``\n        Download audio tracks\n    ``\"ytdl\"``\n        Download audio tracks using |ytdl|\n    ``false``\n        Ignore audio tracks\n\n\nextractor.tiktok.covers\n-----------------------\nType\n    * ``bool``\n    * ``string``\nDefault\n    ``false``\nDescription\n    Download video covers.\n\n    ``true``\n        Download the first cover found in the following order:\n\n        * ``thumbnail``\n        * ``cover``\n        * ``originCover``\n        * ``dynamicCover``\n    ``false``\n        Do not download covers\n    ``\"all\"``\n        Download all available covers\n\n\nextractor.tiktok.photos\n-----------------------\nType\n    ``bool``\nDefault\n    ``true``\nDescription\n    Download photos.\n\n\nextractor.tiktok.subtitles\n--------------------------\nType\n    * ``bool``\n    * ``string``\nDefault\n    ``false``\nExample\n    * ``\"all\"``\n    * ``\"ASR,MT,LC\"``\n    * ``\"ASR,eng-US\"``\nDescription\n    Download video subtitles.\n    The subtitles can be filtered by source or language.\n    The following source types can be filtered:\n\n    * ``ASR`` - Automatic Speech Recognition\n    * ``MT`` - Machine Translation\n    * ``LC`` - Local Captions / Creator Captions\n\n    If both source types and language codes are provided,\n    only subtitles matching both are downloaded.\n\n    ``true``\n        Download all subtitles tagged ``ASR``\n    ``false``\n        Do not download subtitles\n    ``\"all\"``\n        Download all available subtitles.\n    ``\"ASR,MT,eng-US,cmn-Hans-CN\"``\n        Download english and simplified chinese subtitles\n        that are either automatically recognized or machine translated.\n\n        The source types and languages can be listed in any order.\nNote\n    It is not possible to filter all subtitles of a specific source type,\n    while also filtering for additional languages of another source type.\n    (e.g. any ASR subtitle + fra-FR of any source type)\n    For this, refer to `extractor.*.file-filter`_.\n\n\nextractor.tiktok.videos\n-----------------------\nType\n    ``bool``\nDefault\n    ``true``\nDescription\n    Download videos using |ytdl|.\n\n\nextractor.tiktok.tiktok-range\n-----------------------------\nType\n    ``string``\nDefault\n    ``\"\"``\nExample\n    ``\"1-20\"``\nDescription\n    Range or playlist indices of ``tiktok`` posts to extract.\n\n    When using `ytdl`, see\n    `ytdl/playlist_items <https://github.com/yt-dlp/yt-dlp/blob/3042afb5fe342d3a00de76704cd7de611acc350e/yt_dlp/YoutubeDL.py#L289>`__\n    for details.\n\n\nextractor.tiktok.posts.order-posts\n----------------------------------\nType\n    ``string``\nDefault\n    ``\"desc\"``\nDescription\n    Controls the order in which\n    posts are processed.\n\n    ``\"asc\"`` | ``\"reverse\"``\n        Ascending order (oldest first)\n    ``\"desc\"``\n        Descending order (newest first)\n    ``\"popular\"``\n        *Popular* order\n\n\nextractor.tiktok.posts.ytdl\n---------------------------\nType\n    ``bool``\nDefault\n    ``false``\nDescription\n    Extract user posts with |ytdl|\n\n\nextractor.tiktok.posts.module\n-----------------------------\nType\n    |Module|_\nDefault\n    ``null``\nDescription\n    The |ytdl| |Module|_\n    to extract posts from a ``tiktok`` user profile with.\n\n    See `extractor.ytdl.module`_.\n\n\nextractor.tiktok.user.include\n-----------------------------\nType\n    * ``string``\n    * ``list`` of ``strings``\nDefault\n    ``[\"avatar\", \"posts\"]``\nDescription\n    A (comma-separated) list of subcategories to include\n    when processing a user profile.\nSupported Values\n    * ``avatar``\n    * ``posts``\n    * ``reposts``\n    * ``stories``\n    * ``likes``\n    * ``saved``\nNote\n    It is possible to use ``\"all\"`` instead of listing all values separately.\n\n\nextractor.tumblr.avatar\n-----------------------\nType\n    ``bool``\nDefault\n    ``false``\nDescription\n    Download blog avatars.\n\n\nextractor.tumblr.date-min & .date-max\n-------------------------------------\nType\n    |Date|_\nDefault\n    ``0`` and ``null``\nDescription\n    Ignore all posts published before/after this date.\n\n\nextractor.tumblr.external\n-------------------------\nType\n    ``bool``\nDefault\n    ``false``\nDescription\n    Follow external URLs (e.g. from \"Link\" posts) and try to extract\n    images from them.\n\n\nextractor.tumblr.inline\n-----------------------\nType\n    ``bool``\nDefault\n    ``true``\nDescription\n    Search posts for inline images and videos.\n\n\nextractor.tumblr.offset\n-----------------------\nType\n    ``integer``\nDefault\n    ``0``\nDescription\n    Custom ``offset`` starting value when paginating over blog posts.\n\n    Allows skipping over posts without having to waste API calls.\n\n\nextractor.tumblr.original\n-------------------------\nType\n    ``bool``\nDefault\n    ``true``\nDescription\n    Download full-resolution ``photo`` and ``inline`` images.\n\n    For each photo with \"maximum\" resolution\n    (width equal to 2048 or height equal to 3072)\n    or each inline image,\n    use an extra HTTP request to find the URL to its full-resolution version.\n\n\nextractor.tumblr.pagination\n---------------------------\nType\n    ``string``\nDefault\n    * ``\"before\"`` if `date-max <extractor.tumblr.date-min & .date-max_>`__ is set\n    * ``\"offset\"`` otherwise\nDescription\n    Controls how to paginate over blog posts.\n\n    ``\"api\"``\n        ``next`` parameter provided by the API\n        (potentially misses posts due to a\n        `bug <https://github.com/tumblr/docs/issues/76>`__\n        in Tumblr's API)\n    ``\"before\"``\n        Timestamp of last post\n    ``\"offset\"``\n        Post offset number\n\n\nextractor.tumblr.ratelimit\n--------------------------\nType\n    ``string``\nDefault\n    ``\"abort\"``\nDescription\n    Selects how to handle exceeding the daily API rate limit.\n\n    ``\"abort\"``\n        Raise an error and stop extraction\n    ``\"wait\"``\n        Wait until rate limit reset\n\n\nextractor.tumblr.reblogs\n------------------------\nType\n    * ``bool``\n    * ``string``\nDefault\n    ``true``\nDescription\n    ``true``\n        Extract media from reblogged posts\n    ``false``\n        Skip reblogged posts\n    ``\"same-blog\"``\n        Skip reblogged posts unless the original post\n        is from the same blog\n\n\nextractor.tumblr.posts\n----------------------\nType\n    * ``string``\n    * ``list`` of ``strings``\nDefault\n    ``\"all\"``\nExample\n    * ``\"video,audio,link\"``\n    * ``[\"video\", \"audio\", \"link\"]``\nDescription\n    A (comma-separated) list of post types to extract images, etc. from.\n\n    Possible types are ``text``, ``quote``, ``link``, ``answer``,\n    ``video``, ``audio``, ``photo``, ``chat``.\n\n    It is possible to use ``\"all\"`` instead of listing all types separately.\n\n\nextractor.tumblr.fallback-delay\n-------------------------------\nType\n    ``float``\nDefault\n    ``120.0``\nDescription\n    Number of seconds to wait between retries\n    for fetching full-resolution images.\n\n\nextractor.tumblr.fallback-retries\n---------------------------------\nType\n    ``integer``\nDefault\n    ``2``\nDescription\n    Number of retries for fetching full-resolution images\n    or ``-1`` for infinite retries.\n\n\nextractor.twibooru.api-key\n--------------------------\nType\n    ``string``\nDefault\n    ``null``\nDescription\n    Your `Twibooru API Key <https://twibooru.org/users/edit>`__,\n    to use your account's browsing settings and filters.\n\n\nextractor.twibooru.filter\n-------------------------\nType\n    ``integer``\nDefault\n    ``2`` (`Everything <https://twibooru.org/filters/2>`__ filter)\nDescription\n    The content filter ID to use.\n\n    Setting an explicit filter ID overrides any default filters and can be used\n    to access 18+ content without `API Key <extractor.twibooru.api-key_>`__.\n\n    See `Filters <https://twibooru.org/filters>`__ for details.\n\n\nextractor.twibooru.svg\n----------------------\nType\n    ``bool``\nDefault\n    ``true``\nDescription\n    Download SVG versions of images when available.\n\n    Try to download the ``view_url`` version of these posts\n    when this option is disabled.\n\n\nextractor.twitter.ads\n---------------------\nType\n    ``bool``\nDefault\n    ``false``\nDescription\n    Fetch media from promoted Tweets.\n\n\nextractor.twitter.articles\n--------------------------\nType\n    ``bool``\nDefault\n    ``true``\nDescription\n    Download media embedded in articles.\n\n\nextractor.twitter.cards\n-----------------------\nType\n    * ``bool``\n    * ``string``\nDefault\n    ``false``\nDescription\n    Controls how to handle `Twitter Cards <https://developer.twitter.com/en/docs/twitter-for-websites/cards/overview/abouts-cards>`__.\n\n    ``false``\n        Ignore cards\n    ``true``\n        Download image content from supported cards\n    ``\"ytdl\"``\n        Additionally download video content from unsupported cards using |ytdl|\n\n\nextractor.twitter.cards-blacklist\n---------------------------------\nType\n    ``list`` of ``strings``\nExample\n    ``[\"summary\", \"youtube.com\", \"player:twitch.tv\"]``\nDescription\n    List of card types to ignore.\n\n    Possible values are\n\n    * card names\n    * card domains\n    * ``<card name>:<card domain>``\n\n\nextractor.twitter.conversations\n-------------------------------\nType\n    * ``bool``\n    * ``string``\nDefault\n    ``false``\nDescription\n    For input URLs pointing to a single Tweet,\n    e.g. `https://twitter.com/i/web/status/<TweetID>`,\n    fetch media from all Tweets and replies in this `conversation\n    <https://help.twitter.com/en/using-twitter/twitter-conversations>`__.\n\n    If this option is equal to ``\"accessible\"``,\n    only download from conversation Tweets\n    if the given initial Tweet is accessible.\n\n\nextractor.twitter.csrf\n----------------------\nType\n    ``string``\nDefault\n    ``\"cookies\"``\nDescription\n    Controls how to handle Cross Site Request Forgery (CSRF) tokens.\n\n    ``\"auto\"``\n        Always auto-generate a token.\n    ``\"cookies\"``\n        Use token given by the ``ct0`` cookie if present.\n\n\nextractor.twitter.cursor\n------------------------\nType\n    * ``bool``\n    * ``string``\nDefault\n    ``true``\nExample\n    ``\"1/DAABCgABGVKi5lE___oKAAIYbfYNcxrQLggAAwAAAAIAAA\"``\nDescription\n    Controls from which position to start the extraction process from.\n\n    ``true``\n        | Start from the beginning.\n        | Log the most recent ``cursor`` value when interrupted before reaching the end.\n    ``false``\n        Start from the beginning.\n    any ``string``\n        Start from the position defined by this value.\nNote\n    A ``cursor`` value from one timeline cannot be used with another.\n\n\nextractor.twitter.expand\n------------------------\nType\n    ``bool``\nDefault\n    ``false``\nDescription\n    For each Tweet, return *all* Tweets from that initial Tweet's\n    conversation or thread, i.e. *expand* all Twitter threads.\n\n    Going through a timeline with this option enabled is essentially the same\n    as running ``gallery-dl https://twitter.com/i/web/status/<TweetID>``\n    with enabled `conversations <extractor.twitter.conversations_>`__ option\n    for each Tweet in said timeline.\nNote\n    This requires at least 1 additional API call per initial Tweet.\n\n\nextractor.twitter.unavailable\n-----------------------------\nType\n    ``bool``\nDefault\n    ``false``\nDescription\n    Try to download media marked as ``Unavailable``,\n    e.g. ``Geoblocked`` videos.\n\n\nextractor.twitter.include\n-------------------------\nType\n    * ``string``\n    * ``list`` of ``strings``\nDefault\n    ``\"timeline\"``\nExample\n    * ``\"avatar,background,media\"``\n    * ``[\"avatar\", \"background\", \"media\"]``\nDescription\n    A (comma-separated) list of subcategories to include\n    when processing a user profile.\nSupported Values\n    * ``info``\n    * ``avatar``\n    * ``background``\n    * ``timeline``\n    * ``tweets``\n    * ``media``\n    * ``with-replies``\n    * ``highlights``\n    * ``likes``\nNote\n    It is possible to use ``\"all\"`` instead of listing all values separately.\n\n\nextractor.twitter.transform\n---------------------------\nType\n    ``bool``\nDefault\n    ``true``\nDescription\n    Transform Tweet and User metadata into a simpler, uniform format.\n\n\nextractor.twitter.tweet-endpoint\n--------------------------------\nType\n    ``string``\nDefault\n    ``\"auto\"``\nDescription\n    Selects the API endpoint used to retrieve single Tweets.\n\n    ``\"restid\"``\n        ``/TweetResultByRestId`` - accessible to guest users\n    ``\"detail\"``\n        ``/TweetDetail`` - more stable\n    ``\"auto\"``\n        ``\"detail\"`` when logged in, ``\"restid\"`` otherwise\n\n\nextractor.twitter.size\n----------------------\nType\n    ``list`` of ``strings``\nDefault\n    ``[\"orig\", \"4096x4096\", \"large\", \"medium\", \"small\"]``\nDescription\n    The image version to download.\n    Any entries after the first one will be used for potential\n    `fallback <extractor.*.fallback_>`_ URLs.\n\n    Known available sizes are\n\n    * ``orig``\n    * ``large``\n    * ``medium``\n    * ``small``\n    * ``4096x4096``\n    * ``900x900``\n    * ``360x360``\n\n\nextractor.twitter.limit\n-----------------------\nType\n    * ``integer``\n    * ``list`` of ``integers``\nDefault\n    ``50``\nExample\n    ``[40, 30, 20, 10, 5]``\nDescription\n    Number of requested results per API query.\n\n    When given as a ``list``,\n    start with the first element as ``count`` parameter\n    and switch to the next element whenever no results are returned.\n\n\nextractor.twitter.logout\n------------------------\nType\n    ``bool``\nDefault\n    ``false``\nDescription\n    Logout and retry as guest when access to another user's Tweets is blocked.\n\n\nextractor.twitter.metadata-user\n-------------------------------\nType\n    ``bool``\nDefault\n    ``false``\nDescription\n    Extract additional metadata for user accounts (``author``, ``user``)\n\n    * ``based_in``\n    * ``location_accurate``\n    * ``name_changes``\n    * ``source``\nNote\n    This requires 1 additional HTTP request per user.\n\n\nextractor.twitter.pinned\n------------------------\nType\n    ``bool``\nDefault\n    ``false``\nDescription\n    Fetch media from pinned Tweets.\n\n\nextractor.twitter.previews\n--------------------------\nType\n    ``bool``\nDefault\n    ``false``\nDescription\n    Download video previews.\n\n\nextractor.twitter.quoted\n------------------------\nType\n    ``bool``\nDefault\n    ``false``\nDescription\n    Fetch media from quoted Tweets.\n\n    If this option is enabled, gallery-dl will try to fetch\n    a quoted (original) Tweet when it sees the Tweet which quotes it.\n\n\nextractor.twitter.ratelimit\n---------------------------\nType\n    ``string``\nDefault\n    ``\"wait\"``\nDescription\n    Selects how to handle exceeding the API rate limit.\n\n    ``\"abort\"``\n        Raise an error and stop extraction\n    ``\"abort:N\"``\n        Raise an error and stop extraction\n        after waiting ``N`` times until rate limit reset\n    ``\"wait\"``\n        Wait until rate limit reset\n    ``\"wait:N\"``\n        Wait for ``N`` seconds\n\n\nextractor.twitter.locked\n------------------------\nType\n    ``string``\nDefault\n    ``\"abort\"``\nDescription\n    Selects how to handle \"account is temporarily locked\" errors.\n\n    ``\"abort\"``\n        Raise an error and stop extraction\n    ``\"wait\"``\n        Wait until the account is unlocked and retry\n\n\nextractor.twitter.replies\n-------------------------\nType\n    ``bool``\nDefault\n    ``true``\nDescription\n    Fetch media from replies to other Tweets.\n\n    If this value is ``\"self\"``, only consider replies where\n    reply and original Tweet are from the same user.\nNote\n    Twitter will automatically expand conversations if you\n    use the ``/with_replies`` timeline while logged in. For example,\n    media from Tweets which the user replied to will also be downloaded.\n\n    It is possible to exclude unwanted Tweets using `file-filter\n    <extractor.*.file-filter_>`__.\n\n\nextractor.twitter.retries-api\n-----------------------------\nType\n    ``integer``\nDefault\n    ``9``\nDescription\n    Maximum number of retries\n    for API requests when encountering server ``errors``,\n    or ``-1`` for infinite retries.\n\n\nextractor.twitter.retweets\n--------------------------\nType\n    ``bool``\nDefault\n    ``false``\nDescription\n    Fetch media from Retweets.\n\n    If this value is ``\"original\"``, metadata for these files\n    will be taken from the original Tweets, not the Retweets.\n\n\nextractor.twitter.search-limit\n------------------------------\nType\n    * ``integer``\n    * ``list`` of ``integers``\nDefault\n    ``20``\nExample\n    ``[50, 20, 10, 5, 2]``\nDescription\n    Number of requested results per search query.\n\n    When given as a ``list``,\n    start with the first element as ``count`` parameter\n    and switch to the next element when\n    `search-stop <extractor.twitter.search-stop_>`__\n    is reached.\n\n\nextractor.twitter.search-pagination\n-----------------------------------\nType\n    ``string``\nDefault\n    ``\"max_id\"``\nDescription\n    Selects how to paginate over search results.\n\n    ``\"cursor\"``\n        Use ``cursor`` values provided by the API\n    ``\"max_id\"`` | ``\"maxid\"`` | ``\"id\"``\n        Update the ``max_id`` search query parameter\n        to the Tweet ID value of the last retrieved Tweet.\n    ``\"until\"`` | ``\"date\"`` | ``\"datetime\"`` | ``\"dt\"``\n        Update the ``until`` search query parameter\n        to the date value of the last retrieved Tweet.\n\n\nextractor.twitter.search-results\n--------------------------------\nType\n    ``string``\nDefault\n    ``\"latest\"``\nDescription\n    Determines the target of search results.\nSupported Values\n    * ``\"top\"``\n    * ``\"media\"``\n    * ``\"latest\"`` | ``\"live\"``\n\n\nextractor.twitter.search-stop\n-----------------------------\nType\n    ``integer``\nDefault\n    ``3``\nDescription\n    Number of empty search result batches\n    to accept before stopping.\n\n\nextractor.twitter.timeline.strategy\n-----------------------------------\nType\n    ``string``\nDefault\n    ``\"auto\"``\nDescription\n    Controls the strategy / tweet source used for timeline URLs\n    (``https://twitter.com/USER/timeline``).\n\n    ``\"tweets\"``\n        `/tweets <https://twitter.com/USER/tweets>`__ timeline + search\n    ``\"media\"``\n        `/media <https://twitter.com/USER/media>`__ timeline + search\n    ``\"with_replies\"``\n        `/with_replies <https://twitter.com/USER/with_replies>`__ timeline + search\n    ``\"auto\"``\n        ``\"tweets\"`` or ``\"media\"``, depending on `retweets <extractor.twitter.retweets_>`__ and `text-tweets <extractor.twitter.text-tweets_>`__ settings\n\n\nextractor.twitter.text-tweets\n-----------------------------\nType\n    ``bool``\nDefault\n    ``false``\nDescription\n    Also emit metadata for text-only Tweets without media content.\n\n    This only has an effect with a ``metadata`` (or ``exec``) post processor\n    with `\"event\": \"post\" <metadata.event_>`_\n    and appropriate `filename <metadata.filename_>`_.\n\n\nextractor.twitter.twitpic\n-------------------------\nType\n    ``bool``\nDefault\n    ``false``\nDescription\n    Extract `TwitPic <https://twitpic.com/>`__ embeds.\n\n\nextractor.twitter.unique\n------------------------\nType\n    ``bool``\nDefault\n    ``true``\nDescription\n    Ignore previously seen Tweets.\n\n\nextractor.twitter.users\n-----------------------\nType\n    ``string``\nDefault\n    ``\"user\"``\nExample\n    ``\"https://twitter.com/search?q=from:{core[screen_name]}\"``\nDescription\n    | Basic format string for user URLs generated from\n      ``following`` and ``list-members`` queries,\n    | whose replacement field values come from Twitter ``user`` objects\n      (`Example <https://gist.githubusercontent.com/mikf/99d2719b3845023326c7a4b6fb88dd04/raw/01b5324cf2367bcd437730186ec0f36d5c8c683c/github.json>`_)\nSpecial Values\n    ``\"user\"``\n        ``https://twitter.com/i/user/{rest_id}``\n    ``\"timeline\"``\n        ``https://twitter.com/id:{rest_id}/timeline``\n    ``\"tweets\"``\n        ``https://twitter.com/id:{rest_id}/tweets``\n    ``\"media\"``\n        ``https://twitter.com/id:{rest_id}/media``\nNote\n    To allow gallery-dl to follow custom URL formats, set the blacklist__\n    for ``twitter`` to a non-default value, e.g. an empty string ``\"\"``.\n\n.. __: `extractor.*.blacklist & .whitelist`_\n\n\nextractor.twitter.videos\n------------------------\nType\n    * ``bool``\n    * ``string``\nDefault\n    ``true``\nDescription\n    Control video download behavior.\n\n    ``true``\n        Download videos\n    ``\"ytdl\"``\n        Download videos using |ytdl|\n    ``false``\n        Skip video Tweets\n\n\nextractor.unsplash.format\n-------------------------\nType\n    ``string``\nDefault\n    ``\"raw\"``\nDescription\n    Name of the image format to download.\nAvailable Formats\n    * ``\"raw\"``\n    * ``\"full\"``\n    * ``\"regular\"``\n    * ``\"small\"``\n    * ``\"thumb\"``\n\n\nextractor.vipergirls.domain\n---------------------------\nType\n    ``string``\nDefault\n    ``\"viper.click\"``\nDescription\n    Specifies the domain used by ``vipergirls`` extractors.\n\n    For example ``\"viper.click\"`` if the main domain is blocked or to bypass Cloudflare,\n\n\nextractor.vipergirls.like\n-------------------------\nType\n    ``bool``\nDefault\n    ``false``\nDescription\n    Automatically `like` posts after downloading their images.\nNote\n    Requires `login <extractor.*.username & .password_>`__\n    or `cookies <extractor.*.cookies_>`__\n\n\nextractor.vipergirls.order-posts\n--------------------------------\nType\n    ``string``\nDefault\n    ``\"desc\"``\nDescription\n    Controls the order in which\n    posts of a ``thread`` are processed.\n\n    ``\"asc\"``\n        Ascending order (oldest first)\n    ``\"desc\"`` | ``\"reverse\"``\n        Descending order (newest first)\n\n\nextractor.vk.offset\n-------------------\nType\n    ``integer``\nDefault\n    ``0``\nDescription\n    Custom ``offset`` starting value when paginating over image results.\n\n\nextractor.vsco.include\n----------------------\nType\n    * ``string``\n    * ``list`` of ``strings``\nDefault\n    ``\"gallery\"``\nExample\n    * ``\"avatar,collection\"``\n    * ``[\"avatar\", \"collection\"]``\nDescription\n    A (comma-separated) list of subcategories to include\n    when processing a user profile.\nSupported Values\n    * ``avatar``\n    * ``gallery``\n    * ``spaces``\n    * ``collection``\nNote\n    It is possible to use ``\"all\"`` instead of listing all values separately.\n\n\nextractor.vsco.videos\n---------------------\nType\n    ``bool``\nDefault\n    ``true``\nDescription\n    Download video files.\n\n\nextractor.wallhaven.api-key\n---------------------------\nType\n    ``string``\nDefault\n    ``null``\nDescription\n    Your `Wallhaven API Key <https://wallhaven.cc/settings/account>`__,\n    to use your account's browsing settings and default filters when searching.\n\n    See https://wallhaven.cc/help/api for more information.\n\n\nextractor.wallhaven.include\n---------------------------\nType\n    * ``string``\n    * ``list`` of ``strings``\nDefault\n    ``\"uploads\"``\nExample\n    * ``\"uploads,collections\"``\n    * ``[\"uploads\", \"collections\"]``\nDescription\n    A (comma-separated) list of subcategories to include\n    when processing a user profile.\nSupported Values\n    * ``uploads``\n    * ``collections``\nNote\n    It is possible to use ``\"all\"`` instead of listing all values separately.\n\n\nextractor.wallhaven.metadata\n----------------------------\nType\n    ``bool``\nDefault\n    ``false``\nDescription\n    Extract additional metadata (tags, uploader)\nNote\n    This requires 1 additional HTTP request per post.\n\n\nextractor.weasyl.api-key\n------------------------\nType\n    ``string``\nDefault\n    ``null``\nDescription\n    Your `Weasyl API Key <https://www.weasyl.com/control/apikeys>`__,\n    to use your account's browsing settings and filters.\n\n\nextractor.weasyl.metadata\n-------------------------\nType\n    ``bool``\nDefault\n    ``false``\nDescription\n    | Fetch extra submission metadata during gallery downloads.\n    | (``comments``, ``description``, ``favorites``, ``folder_name``,\n      ``tags``, ``views``)\nNote\n    This requires 1 additional HTTP request per submission.\n\n\nextractor.webtoons.bgm\n----------------------\nType\n    * ``bool``\n    * ``string``\nDefault\n    ``true``\nExample\n    ``\"aac\"``\nDescription\n    Download an episode's `background music` if available.\n\n    If this is a ``string``, remux the downloaded `background music` file\n    into the given format.\nNote\n    Requires |ytdl| for downloads\n    and |ffmpeg| for remuxing\n\n\nextractor.webtoons.quality\n--------------------------\nType\n    * ``integer``\n    * ``string``\n    * ``object`` (`ext` → `type`)\nDefault\n    ``\"original\"``\nExample\n    * ``90``\n    * ``\"q50\"``\n    * ``{\"jpg\": \"q80\", \"jpeg\": \"q80\", \"png\": false}``\nDescription\n    Controls the quality of downloaded files by modifying URLs' ``type`` parameter.\n\n    ``\"original\"``\n        Download minimally compressed versions of JPG files\n    any ``integer``\n        Use ``\"q<VALUE>\"`` as ``type`` parameter for JPEG files\n    any ``string``\n        Use this value as ``type`` parameter for JPEG files\n    any ``object``\n        | Use the given values as ``type`` parameter for URLs with the specified extensions\n        | - Set a value to ``false`` to completely remove these extension's ``type`` parameter\n        | - Omit an extension to leave its URLs unchanged\n\n\nextractor.webtoons.banners\n--------------------------\nType\n    ``bool``\nDefault\n    ``false``\nDescription\n    Download the active comic's ``banner``.\n\n\nextractor.webtoons.thumbnails\n-----------------------------\nType\n    ``bool``\nDefault\n    ``false``\nDescription\n    Download the active episode's ``thumbnail``.\n\n    Useful for creating CBZ archives with actual source thumbnails.\n\n\nextractor.weebdex.data-saver\n----------------------------\nType\n    ``bool``\nDefault\n    ``false``\nDescription\n    Enable `Data Saver` mode and download lower quality versions of chapters.\n\n\nextractor.weebdex.manga.lang\n----------------------------\nType\n    ``string``\nDefault\n    ``\"en\"``\nDescription\n    |ISO 639-1| code selecting which chapters to download.\n\n\nextractor.weibo.gifs\n--------------------\nType\n    * ``bool``\n    * ``string``\nDefault\n    ``true``\nDescription\n    Download ``gif`` files.\n\n    Set this to ``\"video\"`` to download GIFs as video files.\n\n\nextractor.weibo.include\n-----------------------\nType\n    * ``string``\n    * ``list`` of ``strings``\nDefault\n    ``\"feed\"``\nDescription\n    A (comma-separated) list of subcategories to include\n    when processing a user profile.\nSupported Values\n    * ``home``\n    * ``feed``\n    * ``videos``\n    * ``newvideo``\n    * ``article``\n    * ``album``\n    * ``subalbums``\nNote\n    It is possible to use ``\"all\"`` instead of listing all values separately.\n\n\nextractor.weibo.livephoto\n-------------------------\nType\n    ``bool``\nDefault\n    ``true``\nDescription\n    Download ``livephoto`` files.\n\n\nextractor.weibo.movies\n----------------------\nType\n    ``bool``\nDefault\n    ``false``\nDescription\n    Download ``movie`` videos.\n\n\nextractor.weibo.retweets\n------------------------\nType\n    ``bool``\nDefault\n    ``false``\nDescription\n    Fetch media from retweeted posts.\n\n    If this value is ``\"original\"``, metadata for these files\n    will be taken from the original posts, not the retweeted posts.\n\n\nextractor.weibo.text\n--------------------\nType\n    ``bool``\nDefault\n    ``false``\nDescription\n    Extract full ``text`` & ``text_raw`` metadata\n    for statuses with truncated ``text``.\n\n\nextractor.weibo.videos\n----------------------\nType\n    ``bool``\nDefault\n    ``true``\nDescription\n    Download video files.\n\n\nextractor.weibo.album.subalbums\n-------------------------------\nType\n    ``bool``\nDefault\n    ``false``\nDescription\n    Extract subalbum media.\n\n\nextractor.wikimedia.format\n--------------------------\nType\n    ``string``\nDefault\n    ``fandom`` | ``wikigg``\n        ``\"original\"``\n    otherwise\n        ``\"\"``\nDescription\n    Sets the `format` query parameter value\n    added to all download URLs.\n\n\nextractor.wikimedia.image-revisions\n-----------------------------------\nType\n    ``integer``\nDefault\n    ``1``\nDescription\n    Number of revisions to return for a single image.\n\n    The dafault value of 1 only returns the latest revision.\n\n    The value must be between 1 and 500.\nNote\n    The API sometimes returns image revisions on article pages even when this option is\n    set to 1. However, setting it to a higher value may reduce the number of API requests.\n\n\nextractor.wikimedia.limit\n-------------------------\nType\n    ``integer``\nDefault\n    ``50``\nDescription\n    Number of results to return in a single API query.\n\n    The value must be between 10 and 500.\n\n\nextractor.wikimedia.subcategories\n---------------------------------\nType\n    ``bool``\nDefault\n    ``true``\nDescription\n    For ``Category:`` pages, recursively descent into subcategories.\n\n\nextractor.[xenforo].attachments\n-------------------------------\nType\n    ``bool``\nDefault\n    ``true``\nDescription\n    Extract forum post attachments.\n\n\nextractor.[xenforo].embeds\n--------------------------\nType\n    ``bool``\nDefault\n    ``true``\nDescription\n    Extract URLs of forum post embeds.\n\n\nextractor.[xenforo].metadata\n----------------------------\nType\n    ``bool``\nDefault\n    ``false``\nDescription\n    Extract detailed metadata for `media` files.\nNote\n    This requires 1 additional HTTP request per file.\n\n\nextractor.[xenforo].order-posts\n-------------------------------\nType\n    ``string``\nDefault\n    ``thread``\n        ``\"desc\"``\n    otherwise\n        ``\"asc\"``\nDescription\n    Controls the order in which\n    posts of a ``thread`` or `media` files are processed.\n\n    ``\"asc\"``\n        Ascending order (oldest first)\n    ``\"desc\"`` | ``\"reverse\"``\n        Descending order (newest first)\n    ``\"reaction\"`` | ``\"score\"``\n        Reaction Score order (``threads`` only)\n\n\nextractor.[xenforo].quoted\n--------------------------\nType\n    ``bool``\nDefault\n    ``false``\nDescription\n    Extract files from quoted content.\n\n\nextractor.ytdl.cmdline-args\n---------------------------\nType\n    * ``string``\n    * ``list`` of ``strings``\nExample\n    * ``\"--quiet --write-sub --merge-output-format mkv\"``\n    * ``[\"--quiet\", \"--write-sub\", \"--merge-output-format\", \"mkv\"]``\nDescription\n    Additional ``ytdl`` options specified as command-line arguments.\n\n    See\n    `yt-dlp options <https://github.com/yt-dlp/yt-dlp#usage-and-options>`__\n    /\n    `youtube-dl options <https://github.com/ytdl-org/youtube-dl#options>`__\n\n\nextractor.ytdl.config-file\n--------------------------\nType\n    |Path|_\nExample\n    ``\"~/.config/yt-dlp/config\"``\nDescription\n    Location of a |ytdl| configuration file to load options from.\n\n\nextractor.ytdl.deprecations\n---------------------------\nType\n    ´´bool´´\nDefault\n    ``false``\nDescription\n    Allow |ytdl| to warn about deprecated options and features.\n\n\nextractor.ytdl.enabled\n----------------------\nType\n    ``bool``\nDefault\n    ``false``\nDescription\n    Process URLs otherwise unsupported by gallery-dl with |ytdl|.\n\n\nextractor.ytdl.format\n---------------------\nType\n    ``string``\nDefault\n    | Default of the ``ytdl`` `module <extractor.ytdl.module_>`__ used.\n    | (``\"bestvideo*+bestaudio/best\"`` for ``yt_dlp``,\n       ``\"bestvideo+bestaudio/best\"`` for ``youtube_dl``)\nDescription\n    ``ytdl`` format selection string.\n\n    See\n    `yt-dlp format selection <https://github.com/yt-dlp/yt-dlp#format-selection>`__\n    /\n    `youtube-dl format selection <https://github.com/ytdl-org/youtube-dl#format-selection>`__\n\n\nextractor.ytdl.generic\n----------------------\nType\n    * ``bool``\n    * ``string``\nDefault\n    ``true``\nDescription\n    Enables the use of |ytdl's| ``Generic`` extractor.\n\n    Set this option to ``\"force\"`` for the same effect as\n    ``--force-generic-extractor``.\n\n\nextractor.ytdl.generic-category\n-------------------------------\nType\n    ``bool``\nDefault\n    ``true``\nDescription\n    When using |ytdl's| ``Generic`` extractor,\n    change `category` to ``\"ytdl-generic\"`` and\n    set `subcategory` to the input URL's domain.\n\n\nextractor.ytdl.logging\n----------------------\nType\n    ``bool``\nDefault\n    ``true``\nDescription\n    Route |ytdl's| output through gallery-dl's logging system.\n    Otherwise it will be written directly to stdout/stderr.\nNote\n    Set ``quiet`` and ``no_warnings`` in\n    `extractor.ytdl.raw-options`_ to ``true`` to suppress all output.\n\n\nextractor.ytdl.module\n---------------------\nType\n    |Module|_\nDefault\n    ``null``\nExample\n    * ``\"yt-dlp\"``\n    * ``\"/home/user/.local/lib/python3.13/site-packages/youtube_dl\"``\nDescription\n    The ``ytdl`` |Module|_ to import.\n\n    Setting this to ``null`` will try to import ``\"yt_dlp\"``\n    followed by ``\"youtube_dl\"`` as fallback.\n\n\nextractor.ytdl.raw-options\n--------------------------\nType\n    ``object`` (`name` → `value`)\nExample\n    .. code:: json\n\n        {\n            \"quiet\": true,\n            \"writesubtitles\": true,\n            \"merge_output_format\": \"mkv\"\n        }\nDescription\n    Additional options passed directly to the ``YoutubeDL`` constructor.\n\n    Available options can be found in\n    `yt-dlp's docstrings <https://github.com/yt-dlp/yt-dlp/blob/2024.05.27/yt_dlp/YoutubeDL.py#L200>`__\n    /\n    `youtube-dl's docstrings <https://github.com/ytdl-org/youtube-dl/blob/0153b387e57e0bb8e580f1869f85596d2767fb0d/youtube_dl/YoutubeDL.py#L157>`__\n\n\nextractor.zerochan.extensions\n-----------------------------\nType\n    * ``string``\n    * ``list`` of ``strings``\nDefault\n    ``[\"jpg\", \"png\", \"webp\", \"gif\"]``\nExample\n    * ``\"gif\"``\n    * ``[\"webp\", \"gif\", \"jpg\"}``\nDescription\n    List of filename extensions to try when dynamically building download URLs\n    (`\"pagination\": \"api\" <extractor.zerochan.pagination_>`__ +\n    `\"metadata\": false <extractor.zerochan.metadata_>`__)\n\n\nextractor.zerochan.metadata\n---------------------------\nType\n    ``bool``\nDefault\n    ``false``\nDescription\n    Extract additional metadata (date, md5, tags, ...)\nNote\n    This requires 1-2 additional HTTP requests per post.\n\n\nextractor.zerochan.pagination\n-----------------------------\nType\n    ``string``\nDefault\n    ``\"api\"``\nDescription\n    Controls how to paginate over tag search results.\n\n    ``\"api\"``\n        Use the `JSON API <https://www.zerochan.net/api>`__\n        (no ``extension`` metadata)\n    ``\"html\"``\n        Parse HTML pages\n        (limited to 100 pages * 24 posts)\n\n\nextractor.zerochan.redirects\n----------------------------\nType\n    ``bool``\nDefault\n    ``false``\nDescription\n    Automatically follow tag redirects.\n\n\nextractor.[booru].tags\n----------------------\nType\n    ``bool``\nDefault\n    ``false``\nDescription\n    Group ``tags`` by type and\n    provide them as ``tags_<type>`` metadata fields,\n    for example ``tags_artist`` or ``tags_character``.\nNote\n    This requires 1 additional HTTP request per post.\n\n\nextractor.[booru].notes\n-----------------------\nType\n    ``bool``\nDefault\n    ``false``\nDescription\n    Extract overlay notes (position and text).\nNote\n    This requires 1 additional HTTP request per post.\n\n\nextractor.[booru].url\n---------------------\nType\n    * ``string``\n    * ``list`` of ``strings``\nDefault\n    ``\"file_url\"``\nExample\n    * ``\"preview_url\"``\n    * ``[\"sample_url\", \"preview_url\", \"file_url\"]``\nDescription\n    Alternate field name to retrieve download URLs from.\n\n    When multiple names are given, download the first available one.\n\n\nextractor.[manga-extractor].chapter-reverse\n-------------------------------------------\nType\n    ``bool``\nDefault\n    ``false``\nDescription\n    Reverse the order of chapter URLs extracted from manga pages.\n\n    ``true``\n        Start with the latest chapter\n    ``false``\n        Start with the first chapter\n\n\nextractor.[manga-extractor].page-reverse\n----------------------------------------\nType\n    ``bool``\nDefault\n    ``false``\nDescription\n    Download manga chapter pages in reverse order.\n\n\nDownloader Options\n==================\n\n\ndownloader.*.enabled\n--------------------\nType\n    ``bool``\nDefault\n    ``true``\nDescription\n    Enable/Disable this downloader module.\n\n\ndownloader.*.filesize-min & .filesize-max\n-----------------------------------------\nType\n    ``string``\nDefault\n    ``null``\nExample\n    ``\"32000\"``, ``\"500k\"``, ``\"2.5M\"``\nDescription\n    Minimum/Maximum allowed file size in bytes.\n    Any file smaller/larger than this limit will not be downloaded.\n\n    Possible values are valid integer or floating-point numbers\n    optionally followed by one of ``k``, ``m``. ``g``, ``t``, or ``p``.\n    These suffixes are case-insensitive.\n\n\ndownloader.*.mtime\n------------------\nType\n    ``bool``\nDefault\n    ``true``\nDescription\n    Use |Last-Modified|_ HTTP response headers\n    to set file modification times.\n\n\ndownloader.*.part\n-----------------\nType\n    ``bool``\nDefault\n    ``true``\nDescription\n    Controls the use of ``.part`` files during file downloads.\n\n    ``true``\n        Write downloaded data into ``.part`` files and rename\n        them upon download completion. This mode additionally supports\n        resuming incomplete downloads.\n    ``false``\n        Do not use ``.part`` files and write data directly\n        into the actual output files.\n\n\ndownloader.*.part-directory\n---------------------------\nType\n    * |Path|_\n    * ``object`` (Condition_ → |Path|_)\nDefault\n    ``null``\nExample\n    .. code:: json\n\n        \"/tmp/.gdl\"\n\n    .. code:: json\n\n        {\n            \"size > 100000\": \"~/.gdl/part\",\n            \"duration\"     : \"/tmp/.gdl/video\",\n        }\n\nDescription\n    Alternate location(s) for ``.part`` files.\nNote\n    If this value is ``null`` or no Conditions_ apply,\n    ``.part`` files are stored alongside the actual output files.\n\n    For a single |Path|_, missing directories will be created as needed\n\n\ndownloader.*.progress\n---------------------\nType\n    ``float``\nDefault\n    ``3.0``\nDescription\n    Number of seconds until a download progress indicator\n    for the current download is displayed.\n\n    Set this option to ``null`` to disable this indicator.\n\n\ndownloader.*.rate\n-----------------\nType\n    * ``string``\n    * ``list`` with 2 ``strings``\nDefault\n    ``null``\nExample\n    * ``\"32000\"``\n    * ``\"500k\"``\n    * ``\"1M - 2.5M\"``\n    * ``[\"1M\", \"2.5M\"]``\nDescription\n    Maximum download rate in bytes per second.\n\n    Possible values are valid integer or floating-point numbers\n    optionally followed by one of ``k``, ``m``. ``g``, ``t``, or ``p``.\n    These suffixes are case-insensitive.\n\n    If given as a range, the maximum download rate\n    will be randomly chosen before each download.\n    (see `random.randint() <https://docs.python.org/3/library/random.html#random.randint>`_)\n\n\ndownloader.*.retries\n--------------------\nType\n    ``integer``\nDefault\n    `extractor.*.retries`_\nDescription\n    Maximum number of retries during file downloads,\n    or ``-1`` for infinite retries.\n\n\ndownloader.*.timeout\n--------------------\nType\n    ``float``\nDefault\n    `extractor.*.timeout`_\nDescription\n    Connection timeout during file downloads.\n\n\ndownloader.*.verify\n-------------------\nType\n    * ``bool``\n    * ``string``\nDefault\n    `extractor.*.verify`_\nDescription\n    Certificate validation during file downloads.\n\n\ndownloader.*.proxy\n------------------\nType\n    * ``string``\n    * ``object`` (`scheme` → `proxy`)\nDefault\n    `extractor.*.proxy`_\nDescription\n    Proxy server used for file downloads.\n\n    Disable the use of a proxy for file downloads\n    by explicitly setting this option to ``null``.\n\n\ndownloader.http.adjust-extensions\n---------------------------------\nType\n    ``bool``\nDefault\n    ``true``\nDescription\n    Check file headers of downloaded files\n    and adjust their filename extensions if they do not match.\n\n    For example, this will change the filename extension (``{extension}``)\n    of a file called ``example.png`` from ``png`` to ``jpg`` when said file\n    contains JPEG/JFIF data.\n\n\ndownloader.http.consume-content\n-------------------------------\nType\n    ``bool``\nDefault\n    ``false``\nDescription\n    Controls the behavior when an HTTP response is considered\n    unsuccessful\n\n    If the value is ``true``, consume the response body. This\n    avoids closing the connection and therefore improves connection\n    reuse.\n\n    If the value is ``false``, immediately close the connection\n    without reading the response. This can be useful if the server\n    is known to send large bodies for error responses.\n\n\ndownloader.http.chunk-size\n--------------------------\nType\n    * ``integer``\n    * ``string``\nDefault\n    ``32768``\nExample\n    ``\"50k\"``, ``\"0.8M\"``\nDescription\n    Number of bytes per downloaded chunk.\n\n    Possible values are integer numbers\n    optionally followed by one of ``k``, ``m``. ``g``, ``t``, or ``p``.\n    These suffixes are case-insensitive.\n\n\ndownloader.http.headers\n-----------------------\nType\n    ``object`` (`name` → `value`)\nExample\n    ``{\"Accept\": \"image/webp,*/*\", \"Referer\": \"https://example.org/\"}``\nDescription\n    Additional HTTP headers to send when downloading files,\n\n\ndownloader.http.retry-codes\n---------------------------\nType\n    ``list`` of ``integers``\nDefault\n    `extractor.*.retry-codes`_\nDescription\n    Additional `HTTP response status codes <https://developer.mozilla.org/en-US/docs/Web/HTTP/Status>`__\n    to retry a download on.\n\n    Codes ``200``, ``206``, and ``416`` (when resuming a `partial <downloader.*.part_>`__\n    download) will never be retried and always count as success,\n    regardless of this option.\n\n    ``5xx`` codes (server error responses)  will always be retried,\n    regardless of this option.\n\n\ndownloader.http.sleep-429\n-------------------------\nType\n    |Duration|_\nDefault\n    `extractor.*.sleep-429`_\nDescription\n    Number of seconds to sleep when receiving a `429 Too Many Requests`\n    response before `retrying <downloader.*.retries_>`__ the request.\nNote\n    Requires\n    `retry-codes <downloader.http.retry-codes_>`__\n    to include ``429``.\n\n\ndownloader.http.validate\n------------------------\nType\n    ``bool``\nDefault\n    ``true``\nDescription\n    Check for invalid responses.\n\n    Fail a download when a file does not pass\n    instead of downloading a potentially broken file.\n\n\ndownloader.http.validate-html\n-----------------------------\nType\n    ``bool``\nDefault\n    ``true``\nDescription\n    Check for unexpected HTML responses.\n\n    Fail file downloads with a ``text/html``\n    `Content-Type header <https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Type>`__\n    when expecting a media file instead.\n\n\ndownloader.ytdl.cmdline-args\n----------------------------\nType\n    * ``string``\n    * ``list`` of ``strings``\nExample\n    * ``\"--quiet --write-sub --merge-output-format mkv\"``\n    * ``[\"--quiet\", \"--write-sub\", \"--merge-output-format\", \"mkv\"]``\nDescription\n    Additional ``ytdl`` options specified as command-line arguments.\n\n    See\n    `yt-dlp options <https://github.com/yt-dlp/yt-dlp#usage-and-options>`__\n    /\n    `youtube-dl options <https://github.com/ytdl-org/youtube-dl#options>`__\n\n\ndownloader.ytdl.config-file\n---------------------------\nType\n    |Path|_\nExample\n    ``\"~/.config/yt-dlp/config\"``\nDescription\n    Location of a |ytdl| configuration file to load options from.\n\n\ndownloader.ytdl.deprecations\n----------------------------\nType\n    ´´bool´´\nDefault\n    ``false``\nDescription\n    Allow |ytdl| to warn about deprecated options and features.\n\n\ndownloader.ytdl.format\n----------------------\nType\n    ``string``\nDefault\n    | Default of the ``ytdl`` `module <downloader.ytdl.module_>`__ used.\n    | (``\"bestvideo*+bestaudio/best\"`` for ``yt_dlp``,\n       ``\"bestvideo+bestaudio/best\"`` for ``youtube_dl``)\nDescription\n    ``ytdl`` format selection string.\n\n    See\n    `yt-dlp format selection <https://github.com/yt-dlp/yt-dlp#format-selection>`__\n    /\n    `youtube-dl format selection <https://github.com/ytdl-org/youtube-dl#format-selection>`__\n\n\ndownloader.ytdl.forward-cookies\n-------------------------------\nType\n    ``bool``\nDefault\n    ``true``\nDescription\n    Forward gallery-dl's cookies to |ytdl|.\n\n\ndownloader.ytdl.logging\n-----------------------\nType\n    ``bool``\nDefault\n    ``true``\nDescription\n    Route |ytdl's| output through gallery-dl's logging system.\n    Otherwise it will be written directly to stdout/stderr.\nNote\n    Set ``quiet`` and ``no_warnings`` in\n    `downloader.ytdl.raw-options`_ to ``true`` to suppress all output.\n\n\ndownloader.ytdl.module\n----------------------\nType\n    |Module|_\nDefault\n    ``null``\nExample\n    * ``\"yt-dlp\"``\n    * ``\"/home/user/.local/lib/python3.13/site-packages/youtube_dl\"``\nDescription\n    The ``ytdl`` |Module|_ to import.\n\n    Setting this to ``null`` will try to import ``\"yt_dlp\"``\n    followed by ``\"youtube_dl\"`` as fallback.\n\n\ndownloader.ytdl.outtmpl\n-----------------------\nType\n    ``string``\nDefault\n    ``null``\nDescription\n    The `Output Template`\n    used to generate filenames for files downloaded with ``ytdl``.\n\n    See\n    `yt-dlp output template <https://github.com/yt-dlp/yt-dlp#output-template>`__\n    /\n    `youtube-dl output template <https://github.com/ytdl-org/youtube-dl#output-template>`__.\nSpecial Values\n    ``null``\n        generate filenames with `extractor.*.filename`_\n    ``\"default\"``\n        use |ytdl's| default, currently\n        ``\"%(title)s [%(id)s].%(ext)s\"`` for yt-dlp_ /\n        ``\"%(title)s-%(id)s.%(ext)s\"`` for youtube-dl_\nNote\n    An output template other than ``null`` might\n    cause unexpected results in combination with certain options\n    (e.g. ``\"skip\": \"enumerate\"``)\n\n\ndownloader.ytdl.raw-options\n---------------------------\nType\n    ``object`` (`name` → `value`)\nExample\n    .. code:: json\n\n        {\n            \"quiet\": true,\n            \"writesubtitles\": true,\n            \"merge_output_format\": \"mkv\"\n        }\n\nDescription\n    Additional options passed directly to the ``YoutubeDL`` constructor.\n\n    Available options can be found in\n    `yt-dlp's docstrings <https://github.com/yt-dlp/yt-dlp/blob/2024.05.27/yt_dlp/YoutubeDL.py#L200>`__\n    /\n    `youtube-dl's docstrings <https://github.com/ytdl-org/youtube-dl/blob/0153b387e57e0bb8e580f1869f85596d2767fb0d/youtube_dl/YoutubeDL.py#L157>`__\n\n\n\nOutput Options\n==============\n\n\noutput.mode\n-----------\nType\n    * ``string``\n    * ``object`` (`key` → `format string`)\nDefault\n    ``\"auto\"``\nDescription\n    Controls the output string format and status indicators.\n\n    ``\"null\"``\n        No output\n    ``\"pipe\"``\n        Suitable for piping to other processes or files\n    ``\"terminal\"``\n        Suitable for the standard Windows console\n    ``\"color\"``\n        Suitable for terminals that understand ANSI escape codes and colors\n    ``\"auto\"``\n        ``\"pipe\"`` if not on a TTY,\n        ``\"terminal\"`` on Windows with `output.ansi`_ disabled,\n        ``\"color\"`` otherwise.\n\n    | It is possible to use custom output format strings\n      by setting this option to an ``object`` and specifying\n    | ``start``, ``success``, ``skip``, ``progress``, and ``progress-total``.\n\n    For example, the following will replicate the same output as |mode: color|:\n\n    .. code:: json\n\n        {\n            \"start\"  : \"{}\",\n            \"success\": \"\\r\\u001b[1;32m{}\\u001b[0m\\n\",\n            \"skip\"   : \"\\u001b[2m{}\\u001b[0m\\n\",\n            \"progress\"      : \"\\r{0:>7}B {1:>7}B/s \",\n            \"progress-total\": \"\\r{3:>3}% {0:>7}B {1:>7}B/s \"\n        }\n\n    ``start``, ``success``, and ``skip`` are used to output the current\n    filename, where ``{}`` or ``{0}`` is replaced with said filename.\n    If a given format string contains printable characters other than that,\n    their number needs to be specified as ``[<number>, <format string>]``\n    to get the correct results for `output.shorten`_. For example\n\n    .. code:: json\n\n            \"start\"  : [12, \"Downloading {}\"]\n\n    | ``progress`` and ``progress-total`` are used when displaying the\n      `download progress indicator <downloader.*.progress_>`__,\n    | ``progress`` when the total number of bytes to download is unknown,\n      ``progress-total`` otherwise.\n\n    For these format strings\n\n    * ``{0}`` is number of bytes downloaded\n    * ``{1}`` is number of downloaded bytes per second\n    * ``{2}`` is total number of bytes\n    * ``{3}`` is percent of bytes downloaded to total bytes\n\n\noutput.stdout & .stdin & .stderr\n--------------------------------\nType\n    * ``string``\n    * ``object``\nExample\n    .. code:: json\n\n        \"utf-8\"\n\n    .. code:: json\n\n        {\n            \"encoding\": \"utf-8\",\n            \"errors\": \"replace\",\n            \"line_buffering\": true\n        }\n\nDescription\n    `Reconfigure <https://docs.python.org/3/library/io.html#io.TextIOWrapper.reconfigure>`__\n    a `standard stream <https://docs.python.org/3/library/sys.html#sys.stdin>`__.\n\n    Possible options are\n\n    * ``encoding``\n    * ``errors``\n    * ``newline``\n    * ``line_buffering``\n    * ``write_through``\n\n    When this option is specified as a simple ``string``,\n    it is interpreted as ``{\"encoding\": \"<string-value>\", \"errors\": \"replace\"}``\nNote\n    ``errors`` always defaults to ``\"replace\"``\n\n\noutput.shorten\n--------------\nType\n    ``bool``\nDefault\n    ``true``\nDescription\n    Controls whether the output strings should be shortened to fit\n    on one console line.\n\n    Set this option to ``\"eaw\"`` to also work with east-asian characters\n    with a display width greater than 1.\n\n\noutput.colors\n-------------\nType\n    * ``bool``\n    * ``object`` (`key` → `ANSI color`)\nDefault\n    .. code:: json\n\n        {\n            \"success\": \"1;32\",\n            \"skip\"   : \"2\",\n            \"debug\"  : \"0;37\",\n            \"info\"   : \"1;37\",\n            \"warning\": \"1;33\",\n            \"error\"  : \"1;31\"\n        }\n\nDescription\n    Controls the\n    `ANSI colors <https://gist.github.com/fnky/458719343aabd01cfb17a3a4f7296797#color-codes>`__\n    used for various outputs.\n\n    ``true``\n        Use default ANSI colors.\n    ``false``\n        Disable ANSI colors.\n    ``object``\n        Use custom ANSI colors.\n\n        Keys for |mode: color|__\n\n        * ``success``: successfully downloaded files\n        * ``skip``: skipped files\n\n        Keys for Logging Messages\n\n        * ``debug``: debug logging messages\n        * ``info``: info logging messages\n        * ``warning``: warning logging messages\n        * ``error``: error logging messages\n\n.. __: `output.mode`_\n\n\noutput.ansi\n-----------\nType\n    ``bool``\nDefault\n    ``true``\nDescription\n    | On Windows, enable ANSI escape sequences and colored output\n    | by setting the ``ENABLE_VIRTUAL_TERMINAL_PROCESSING`` flag for stdout and stderr.\nNote\n    To disable colored output, set `output.colors`_ to ``false``.\n\n\noutput.skip\n-----------\nType\n    ``bool``\nDefault\n    ``true``\nDescription\n    Show skipped file downloads.\n\n\noutput.fallback\n---------------\nType\n    ``bool``\nDefault\n    ``true``\nDescription\n    Include fallback URLs in the output of ``-g/--get-urls``.\n\n\noutput.jsonl\n------------\nType\n    ``bool``\nDefault\n    ``false``\nDescription\n    Output ``-j/--dump-json`` & ``-J/--resolve-json``\n    data in `JSON Lines <https://jsonlines.org/>`__ format.\n\n\noutput.private\n--------------\nType\n    ``bool``\nDefault\n    ``false``\nDescription\n    Include private fields,\n    i.e. fields whose name starts with an underscore,\n    in the output of ``-K/--list-keywords`` and ``-j/--dump-json``.\n\n\noutput.progress\n---------------\nType\n    * ``bool``\n    * ``string``\nDefault\n    ``true``\nDescription\n    Controls the progress indicator when *gallery-dl* is run with\n    multiple URLs as arguments.\n\n    ``true``\n        Show the default progress indicator\n        (``\"[{current}/{total}] {url}\"``)\n    ``false``\n        Do not show any progress indicator\n    Any ``string``\n        Show the progress indicator using this\n        as a custom `format string`_. Possible replacement keys are\n        ``current``, ``total``  and ``url``.\n\n\noutput.log\n----------\nType\n    * `Format String`_\n    * |Logging Configuration|_\nDefault\n    ``\"[{name}][{levelname}] {message}\"``\nDescription\n    Configuration for logging output to stderr.\n\n\noutput.logfile\n--------------\nType\n    * |Path|_\n    * |Logging Configuration|_\nDescription\n    File to write logging output to.\n\n\noutput.unsupportedfile\n----------------------\nType\n    * |Path|_\n    * |Logging Configuration|_\nDescription\n    File to write external URLs unsupported by *gallery-dl* to.\n\n\noutput.errorfile\n----------------\nType\n    * |Path|_\n    * |Logging Configuration|_\nDescription\n    File to write input URLs which returned an error to.\n\n    When combined with\n    ``-I``/``--input-file-comment`` or\n    ``-x``/``--input-file-delete``,\n    this option will cause *all* input URLs from these files\n    to be commented/deleted after processing them\n    and not just successful ones.\n\n\noutput.num-to-str\n-----------------\nType\n    ``bool``\nDefault\n    ``false``\nDescription\n    Convert numeric values (``integer`` or ``float``) to ``string``\n    before outputting them as JSON.\n\n\n\nPostprocessor Options\n=====================\n\nThis section lists all options available inside\n`Postprocessor Configuration`_ objects.\n\nEach option is titled as ``<name>.<option>``, meaning a post processor\nof type ``<name>`` will look for an ``<option>`` field inside its \"body\".\nFor example an ``exec`` post processor will recognize\nan `async <exec.async_>`__,  `command <exec.command_>`__,\nand `event <exec.event_>`__ field:\n\n.. code:: json\n\n    {\n        \"name\"   : \"exec\",\n        \"async\"  : false,\n        \"command\": \"...\",\n        \"event\"  : \"after\"\n    }\n\n\nactions.action\n--------------\nType\n    `Action(s)`_\nDescription\n    The `Action(s)`_ to perform.\nNote\n    This option can also be set as ``mode``,\n    making it possible to use ``\"name\": \"actions/<action>@<event>\"``\n\n\nactions.event\n-------------\nType\n    * ``string``\n    * ``list`` of ``strings``\nDefault\n    ``\"prepare\"``\nDescription\n    The event(s) for which `actions.action`_ is triggered.\n\n    See `metadata.event`_ for a list of available events.\n\n\nclassify.mapping\n----------------\nType\n    ``object`` (`directory` → `extensions`)\nDefault\n    .. code:: json\n\n        {\n            \"Pictures\" : [\"jpg\", \"jpeg\", \"png\", \"gif\", \"bmp\", \"svg\", \"webp\",\n                          \"avif\", \"heic\", \"heif\", \"ico\", \"psd\"],\n            \"Video\"    : [\"flv\", \"ogv\", \"avi\", \"mp4\", \"mpg\", \"mpeg\", \"3gp\", \"mkv\",\n                          \"webm\", \"vob\", \"wmv\", \"m4v\", \"mov\"],\n            \"Music\"    : [\"mp3\", \"aac\", \"flac\", \"ogg\", \"wma\", \"m4a\", \"wav\"],\n            \"Archives\" : [\"zip\", \"rar\", \"7z\", \"tar\", \"gz\", \"bz2\"],\n            \"Documents\": [\"txt\", \"pdf\"]\n        }\n\nDescription\n    A mapping from directory names to filename extensions that should\n    be stored in them.\n\n    Files with an extension not listed will be ignored and stored\n    in their default location.\n\n\ncompare.action\n--------------\nType\n    ``string``\nDefault\n    ``\"replace\"``\nDescription\n    The action to take when files do **not** compare as equal.\n\n    ``\"replace\"``\n        Replace/Overwrite the old version with the new one\n    ``\"enumerate\"``\n        Add an enumeration index to the filename of the new\n        version like `skip = \"enumerate\" <extractor.*.skip_>`__\n\n\ncompare.equal\n-------------\nType\n    ``string``\nDefault\n    ``\"null\"``\nDescription\n    The action to take when files do compare as equal.\n\n    ``\"abort:N\"``\n        Stop the current extractor run\n        after ``N`` consecutive files compared as equal.\n    ``\"terminate:N\"``\n        Stop the current extractor run,\n        including parent extractors,\n        after ``N`` consecutive files compared as equal.\n    ``\"exit:N\"``\n        Exit the program\n        after ``N`` consecutive files compared as equal.\n\n\ncompare.shallow\n---------------\nType\n    ``bool``\nDefault\n    ``false``\nDescription\n    Only compare file sizes. Do not read and compare their content.\n\n\ndirectory.event\n---------------\nType\n    * ``string``\n    * ``list`` of ``strings``\nDefault\n    ``\"prepare\"``\nDescription\n    The event(s) for which directory_ `Format Strings`_ are (re)evaluated.\n\n    See `metadata.event`_ for a list of available events.\n\n\nexec.archive\n------------\nType\n    * ``string``\n    * |Path|_\nDescription\n    Database to store IDs of executed commands in,\n    similar to `extractor.*.archive`_.\n\n    The following archive options are also supported:\n\n    * `archive-format <extractor.*.archive-format_>`__\n    * `archive-prefix <extractor.*.archive-prefix_>`__\n    * `archive-pragma <extractor.*.archive-pragma_>`__\n    * `archive-table  <extractor.*.archive-table_>`__\n\n\nexec.async\n----------\nType\n    ``bool``\nDefault\n    ``false``\nDescription\n    Controls whether to wait for a subprocess to finish\n    or to let it run asynchronously.\n\n\nexec.command\n------------\nType\n    * ``string``\n    * ``list`` of ``strings``\nExample\n    * ``\"convert {} {}.png && rm {}\"``\n    * ``[\"echo\", \"{user[account]}\", \"{id}\"]``\nDescription\n    The command to run.\n\n    * If this is a ``string``, it will be executed using the system's\n      shell, e.g. ``/bin/sh``. Any ``{}`` will be replaced\n      with the full path of a file or target directory, depending on\n      `exec.event`_\n\n    * If this is a ``list``, the first element specifies the program\n      name and any further elements its arguments.\n\n      Each element of this list is evaluated as a `Format String`_ using\n      the files' metadata as well as\n      ``{_path}``, ``{_temppath}``, ``{_directory}``, and ``{_filename}``.\n\n\nexec.commands\n-------------\nType\n    ``list`` of `commands <exec.command_>`__\nExample\n    .. code:: json\n\n        [\n            [\"echo\", \"{user[account]}\", \"{id}\"]\n            [\"magick\", \"convert\" \"{_path}\",  \"\\fF {_path.rpartition('.')[0]}.png\"],\n            \"rm {}\",\n        ]\nDescription\n    Multiple `commands <exec.command_>`__ to run in succession.\n\n    All `commands <exec.command_>`__ after the first returning with a non-zero\n    exit status will not be run.\n\n\nexec.event\n----------\nType\n    * ``string``\n    * ``list`` of ``strings``\nDefault\n    ``\"after\"``\nDescription\n    The event(s) for which `exec.command`_ is run.\n\n    See `metadata.event`_ for a list of available events.\n\n\nexec.output\n-----------\nType\n    ``boolean``\nDefault\n    ``true``\nDescription\n    Show output of spawned subprocesses.\n\n\nexec.session\n------------\nType\n    ``bool``\nDefault\n    ``false``\nDescription\n    Start subprocesses in a new session.\n\n    On Windows, this means passing\n    `CREATE_NEW_PROCESS_GROUP <https://docs.python.org/3/library/subprocess.html#subprocess.CREATE_NEW_PROCESS_GROUP>`__\n    as a ``creationflags`` argument to\n    `subprocess.Popen <https://docs.python.org/3/library/subprocess.html#subprocess.Popen>`__\n\n    On POSIX systems, this means enabling the\n    ``start_new_session`` argument of\n    `subprocess.Popen <https://docs.python.org/3/library/subprocess.html#subprocess.Popen>`__\n    to have it call ``setsid()``.\n\n\nexec.verbose\n------------\nType\n    ``bool``\nDefault\n    ``true``\nDescription\n    Include `command <exec.command_>`__\n    arguments in logging messages.\n\n\nexec.success\n------------\nType\n    `Action(s)`_\nDescription\n    Run these `Action(s)`_ when `command <exec.command_>`__\n    succeeds and returns with exit status `0`.\n\n\nexec.error\n----------\nType\n    `Action(s)`_\nDescription\n    Run these `Action(s)`_ when `command <exec.command_>`__\n    fails and returns with a non-zero exit status.\n\n\nhash.chunk-size\n---------------\nType\n    ``integer``\nDefault\n    ``32768``\nDescription\n    Number of bytes read per chunk during file hash computation.\n\n\nhash.event\n----------\nType\n    * ``string``\n    * ``list`` of ``strings``\nDefault\n    ``\"file\"``\nDescription\n    The event(s) for which `file hashes <hash.hashes_>`__ are computed.\n\n    See `metadata.event`_ for a list of available events.\n\n\nhash.filename\n-------------\nType\n    * ``bool``\nDefault\n    ``false``\nDescription\n    Rebuild `filenames <extractor.*.filename_>`__ after computing\n    `hash digests <hash.hashes_>`__ and adding them to the metadata dict.\n\n\nhash.hashes\n-----------\nType\n    * ``string``\n    * ``object`` (`field name` → `hash algorithm`)\nDefault\n    ``\"md5,sha1\"``\nExample\n    .. code:: json\n\n        \"sha256:hash_sha,sha3_512:hash_sha3\"\n\n    .. code:: json\n\n        {\n            \"hash_sha\" : \"sha256\",\n            \"hash_sha3\": \"sha3_512\"\n        }\n\nDescription\n    Hash digests to compute.\n\n    For a list of available hash algorithms, run\n\n    .. code::\n\n        python -c \"import hashlib; print('\\n'.join(hashlib.algorithms_available))\"\n\n    or see `python/hashlib <https://docs.python.org/3/library/hashlib.html>`__.\n\n    * If this is a ``string``,\n      it is parsed as a a comma-separated list of algorthm-fieldname pairs:\n\n      .. code::\n\n          [<hash algorithm> \":\"] <field name> [\",\" ...]\n\n      When ``<hash algorithm>`` is omitted,\n      ``<field name>`` is used as algorithm name.\n\n    * If this is an ``object``,\n      it is a ``<field name>`` to ``<algorithm name>`` mapping\n      for hash digests to compute.\nNote\n    This option can also be set as ``mode``,\n    making it possible to use ``\"name\": \"hash/<fieldname>@<event>\"``\n\n\nmetadata.mode\n-------------\nType\n    ``string``\nDefault\n    ``\"json\"``\nDescription\n    Selects how to process metadata.\n\n    ``\"json\"``\n        Write metadata using |json.dump()|_\n    ``\"jsonl\"``\n        Write metadata in `JSON Lines <https://jsonlines.org/>`__ format\n    ``\"tags\"``\n        Write ``tags`` separated by newlines\n    ``\"print\"``\n        Write the result of applying\n        `content-format <metadata.content-format_>`__\n        to ``stdout``\n    ``\"custom\"``\n        Write the result of applying\n        `content-format <metadata.content-format_>`__\n        to `a file <metadata.filename_>`__\n    ``\"modify\"``\n        Add or modify metadata entries\n    ``\"delete\"``\n        Remove metadata entries\n\n\nmetadata.filename\n-----------------\nType\n    `Format String`_\nDefault\n    ``null``\nExample\n    ``\"{id}.data.json\"``\nDescription\n    A `Format String`_ to generate filenames for metadata files.\n    (see `extractor.filename <extractor.*.filename_>`__)\n\n    Using ``\"-\"`` as filename will write all output to ``stdout``.\n\n    If this option is set, `metadata.extension`_ and\n    `metadata.extension-format`_ will be ignored.\n\n\nmetadata.directory\n------------------\nType\n    * ``string``\n    * ``list`` of ``strings``\nDefault\n    ``\".\"``\nExample\n    * ``\"metadata\"``\n    * ``[\"..\", \"metadata\", \"\\fF {id // 500 * 500}\"]``\nDescription\n    Directory where metadata files are stored in\n    relative to `metadata.base-directory`_.\n\n\nmetadata.base-directory\n-----------------------\nType\n    * ``bool``\n    * |Path|_\nDefault\n    ``false``\nDescription\n    Selects the relative location for metadata files.\n\n    ``false``\n        Current target location for file downloads (base-directory_ + directory_)\n    ``true``\n        Current base-directory_ location\n    any |Path|_\n        Custom location\n\n\nmetadata.extension\n------------------\nType\n    ``string``\nDefault\n    ``\"json\"`` or ``\"txt\"``\nDescription\n    Filename extension for metadata files that will be appended to the\n    original file names.\n\n\nmetadata.extension-format\n-------------------------\nType\n    `Format String`_\nExample\n    * ``\"{extension}.json\"``\n    * ``\"json\"``\nDescription\n    Custom `Format String`_ to generate filename extensions\n    for metadata files, which will replace the original filename extension.\nNote\n    When this option is set, `metadata.extension`_ is ignored.\n\n\nmetadata.metadata-path\n----------------------\nType\n    ``string``\nExample\n    ``\"_meta_path\"``\nDescription\n    Insert the path of generated files\n    into metadata dictionaries as the given name.\n\n\nmetadata.event\n--------------\nType\n    * ``string``\n    * ``list`` of ``strings``\nDefault\n    ``\"file\"``\nExample\n    * ``\"prepare,file,after\"``\n    * ``[\"prepare-after\", \"skip\"]``\nDescription\n    The event(s) for which metadata gets written to a file.\n\n    Available events are:\n\n    ``init``\n        After post processor initialization\n        and before the first file download\n    ``finalize``\n        On extractor shutdown, e.g. after all files were downloaded\n    ``finalize-success``\n        On extractor shutdown when no error occurred\n    ``finalize-error``\n        On extractor shutdown when at least one error occurred\n    ``prepare``\n        Before a file download\n    ``prepare-after``\n        Before a file download,\n        but after building and checking file paths\n    ``file``\n        When completing a file download,\n        but before it gets moved to its target location\n    ``after``\n        After a file got moved to its target location\n    ``skip``\n        When skipping a file download\n    ``error``\n        After a file download failed\n    ``post``\n        When starting to download all files of a `post`,\n        e.g. a Tweet on Twitter or a post on Patreon.\n    ``post-after``\n        After downloading all files of a `post`\n    ``child``\n        When spawning a new `child` extractor\n    ``child-after``\n        After a `child` extractor ran\n\n\nmetadata.include\n----------------\nType\n    ``list`` of ``strings``\nExample\n    ``[\"id\", \"width\", \"height\", \"description\"]``\nDescription\n    Include only the given top-level keys when writing JSON data.\nNote\n    Missing or undefined fields will be silently ignored.\n\n\nmetadata.exclude\n----------------\nType\n    ``list`` of ``strings``\nExample\n    ``[\"blocked\", \"watching\", \"status\"]``\nDescription\n    Exclude all given keys from written JSON data.\nNote\n    Cannot be used with `metadata.include`_.\n\n\nmetadata.fields\n---------------\nType\n    * ``list`` of ``strings``\n    * ``object`` (`field name` → `Format String`_)\nExample\n    .. code:: json\n\n        [\"blocked\", \"watching\", \"status[creator][name]\"]\n\n    .. code:: json\n\n        {\n            \"blocked\"         : \"***\",\n            \"watching\"        : \"\\fE 'yes' if watching else 'no'\",\n            \"status[username]\": \"{status[creator][name]!l}\"\n        }\n\nDescription\n    ``\"mode\": \"delete\"``\n        A list of metadata field names to remove.\n    ``\"mode\": \"modify\"``\n        An object with metadata field names mapping to a `Format String`_\n        whose result is assigned to that field name.\nNote:\n    Unlike standard `Format Strings`_, replacement fields here\n    preserve the original type of their value\n    instead of automatically converting it to |type-str|_.\n\n\nmetadata.content-format\n-----------------------\nType\n    * `Format String`_\n    * ``list`` of `Format Strings`_\nExample\n    * ``\"tags:\\n\\n{tags:J\\n}\\n\"``\n    * ``[\"tags:\", \"\", \"{tags:J\\n}\"]``\nDescription\n    Custom `Format String(s)`_ to build the content of metadata files with.\nNote\n    Only applies to ``\"mode\": \"custom\"``.\n\n\nmetadata.ascii\n--------------\nType\n    ``bool``\nDefault\n    ``false``\nDescription\n    Escape all non-ASCII characters.\n\n    See the ``ensure_ascii`` argument of |json.dump()|_ for further details.\nNote\n    Only applies to ``\"mode\": \"json\"`` and ``\"jsonl\"``.\n\n\nmetadata.indent\n---------------\nType\n    * ``integer``\n    * ``string``\nDefault\n    ``4``\nDescription\n    Indentation level of JSON output.\n\n    See the ``indent`` argument of |json.dump()|_ for further details.\nNote\n    Only applies to ``\"mode\": \"json\"``.\n\n\nmetadata.separators\n-------------------\nType\n    ``list`` with two ``string`` elements\nDefault\n    ``[\", \", \": \"]``\nDescription\n    ``<item separator>`` - ``<key separator>`` pair\n    to separate JSON keys and values with.\n\n    See the ``separators`` argument of |json.dump()|_ for further details.\nNote\n    Only applies to ``\"mode\": \"json\"`` and ``\"jsonl\"``.\n\n\nmetadata.sort\n-------------\nType\n    ``bool``\nDefault\n    ``false``\nDescription\n    Sort output by `key`.\n\n    See the ``sort_keys`` argument of |json.dump()|_ for further details.\nNote\n    Only applies to ``\"mode\": \"json\"`` and ``\"jsonl\"``.\n\n\nmetadata.open\n-------------\nType\n    ``string``\nDefault\n    ``\"w\"``\nDescription\n    The ``mode`` in which metadata files get opened.\n\n    For example,\n    use ``\"a\"`` to append to a file's content\n    or ``\"w\"`` to truncate it.\n\n    See the ``mode`` argument of |open()|_ for further details.\n\n\nmetadata.encoding\n-----------------\nType\n    ``string``\nDefault\n    ``\"utf-8\"``\nDescription\n    Name of the encoding used to encode a file's content.\n\n    See the ``encoding`` argument of |open()|_ for further details.\n\n\nmetadata.newline\n-----------------\nType\n    ``string``\nDefault\n    ``null``\nDescription\n    The newline sequence used in metadata files.\n\n    If ``null``, any ``\\n`` characters\n    written are translated to the system default line separator.\n\n    See the ``newline`` argument of |open()|_ for further details.\nSupported Values\n    ``null``\n        Any ``\\n`` characters\n        written are translated to the system default line separator.\n    ``\"\"`` | ``\"\\n\"``\n        Don't replace newline characters.\n    ``\"\\r\"`` | ``\"\\r\\n\"``\n        Replace newline characters with the given sequence.\n\n\nmetadata.private\n----------------\nType\n    ``bool``\nDefault\n    ``false``\nDescription\n    Include private fields,\n    i.e. fields whose name starts with an underscore.\n\n\nmetadata.skip\n-------------\nType\n    ``bool``\nDefault\n    ``false``\nDescription\n    Do not overwrite already existing files.\n\n\nmetadata.archive\n----------------\nType\n    * ``string``\n    * |Path|_\nDescription\n    Database to store IDs of generated metadata files in,\n    similar to `extractor.*.archive`_.\n\n    The following archive options are also supported:\n\n    * `archive-format <extractor.*.archive-format_>`__\n    * `archive-prefix <extractor.*.archive-prefix_>`__\n    * `archive-pragma <extractor.*.archive-pragma_>`__\n    * `archive-table  <extractor.*.archive-table_>`__\n\n\nmetadata.mtime\n--------------\nType\n    ``bool``\nDefault\n    ``false``\nDescription\n    Set modification times of generated metadata files\n    according to the accompanying downloaded file.\n\n    Enabling this option will only have an effect\n    *if* there is actual ``mtime`` metadata available, that is\n\n    * after a file download (``\"event\": \"file\"`` (default), ``\"event\": \"after\"``)\n    * when running *after* an ``mtime`` post processes for the same `event <metadata.event_>`__\n\n    For example, a ``metadata`` post processor for ``\"event\": \"post\"`` will\n    *not* be able to set its file's modification time unless an ``mtime``\n    post processor with ``\"event\": \"post\"`` runs *before* it.\n\n\nmtime.event\n-----------\nType\n    * ``string``\n    * ``list`` of ``strings``\nDefault\n    ``\"file\"``\nDescription\n    The event(s) for which `mtime.key`_ or `mtime.value`_ get evaluated.\n\n    See `metadata.event`_ for a list of available events.\n\n\nmtime.key\n---------\nType\n    ``string``\nDefault\n    ``\"date\"``\nDescription\n    Name of the metadata field whose value should be used.\n\n    This value must be either a Unix timestamp or a\n    |type-datetime|_ object.\nNote\n    This option is ignored if `mtime.value`_ is set.\n\n\nmtime.value\n-----------\nType\n    `Format String`_\nDefault\n    ``null``\nExample\n    * ``\"{status[date]}\"``\n    * ``\"{content[0:6]:R22/2022/D%Y%m%d/}\"``\nDescription\n    The `Format String`_ whose value should be used.\n\n    The resulting value must be either a Unix timestamp or a\n    |type-datetime|_ object.\nNote:\n    Unlike standard `Format Strings`_, replacement fields here\n    preserve the original type of their value\n    instead of automatically converting it to |type-str|_.\n\n\npython.archive\n--------------\nType\n    * ``string``\n    * |Path|_\nDescription\n    Database to store IDs of called Python functions in,\n    similar to `extractor.*.archive`_.\n\n    The following archive options are also supported:\n\n    * `archive-format <extractor.*.archive-format_>`__\n    * `archive-prefix <extractor.*.archive-prefix_>`__\n    * `archive-pragma <extractor.*.archive-pragma_>`__\n    * `archive-table  <extractor.*.archive-table_>`__\n\n\npython.event\n------------\nType\n    * ``string``\n    * ``list`` of ``strings``\nDefault\n    ``\"file\"``\nDescription\n    The event(s) for which `python.function`_ gets called.\n\n    See `metadata.event`_ for a list of available events.\n\n\npython.expression\n-----------------\nType\n    Expression_\nExample\n    * ``\"print('Foo Bar')\"``\n    * ``\"terminate()\"``\nDescription\n    A Python Expression_ to\n    `evaluate <https://docs.python.org/3/library/functions.html#eval>`__.\nNote\n    Only used with `\"mode\": \"eval\" <python.mode_>`__\n\n\npython.function\n---------------\nType\n    ``string``\nExample\n    * ``\"my_module:generate_text\"``\n    * ``\"~/.local/share/gdl_utils.py:resize\"``\nDescription\n    The Python function to call.\n\n    | This function is specified as ``<module>:<function name>``, where\n    | ``<module>`` is a |Module|_ and\n      ``<function name>`` is the name of the function in that module.\n\n    It gets called with the current metadata dict as argument.\n\n\npython.mode\n-----------\nType\n    ``string``\nDefault\n    ``\"function\"``\nDescription\n    Selects what Python code to run.\n\n    ``\"eval\"``\n        Evaluate an\n        `expression <python.expression_>`__\n    ``function\"``\n        Call a\n        `function <python.function_>`__\n\n\nrename.from\n-----------\nType\n    `Format String`_\nDescription\n    The `Format String`_ for filenames to rename.\n\n    When no value is given, `extractor.*.filename`_ is used.\n\n\nrename.to\n---------\nType\n    `Format String`_\nDescription\n    The `Format String`_ for target filenames.\n\n    When no value is given, `extractor.*.filename`_ is used.\n\n\nrename.skip\n-----------\nType\n    ``bool``\nDefault\n    ``true``\nDescription\n    Do not rename a file when another file with the target name already exists.\n\n\nugoira.extension\n----------------\nType\n    ``string``\nDefault\n    ``\"webm\"``\nDescription\n    Filename extension for the resulting video files.\n\n\nugoira.ffmpeg-args\n------------------\nType\n    ``list`` of ``strings``\nDefault\n    ``null``\nExample\n    ``[\"-c:v\", \"libvpx-vp9\", \"-an\", \"-b:v\", \"2M\"]``\nDescription\n    Additional |ffmpeg| command-line arguments.\n\n\n.. _ugoira.ffmpeg-demuxer:\n\nugoira.mode\n-----------\nType\n    ``string``\nDefault\n    ``\"auto\"``\nDescription\n    |ffmpeg| demuxer to read and process input files with.\nSupported Values\n    ``\"auto\"``\n        use ``mkvmerge`` if available, fall back to ``concat`` otherwise\n    ``\"concat\"``\n        | https://ffmpeg.org/ffmpeg-formats.html#concat-1\n        | Inaccurate frame timecodes for non-uniform frame delays\n    ``\"image2\"``\n        | https://ffmpeg.org/ffmpeg-formats.html#image2-1\n        | Accurate timecodes, requires nanosecond file timestamps, i.e. no Windows or macOS)\n    ``\"mkvmerge\"``\n        Accurate timecodes, only WebM or MKV, requires `mkvmerge <ugoira.mkvmerge-location_>`__)\n    ``\"archive\"``\n        Store \"original\" frames in a ``.zip`` archive\n\n\n\nugoira.ffmpeg-location\n----------------------\nType\n    |Path|_\nDefault\n    ``\"ffmpeg\"``\nDescription\n    Location of the ``ffmpeg`` (or ``avconv``) executable to use.\n\n\nugoira.mkvmerge-args\n--------------------\nType\n    ``list`` of ``strings``\nDefault\n    ``null``\nExample\n    ``[\"--no-date\", \"--disable-lacing\"]``\nDescription\n    Additional ``mkvmerge`` command-line arguments.\n\n\nugoira.mkvmerge-location\n------------------------\nType\n    |Path|_\nDefault\n    ``\"mkvmerge\"``\nDescription\n    Location of the ``mkvmerge`` executable for use with the\n    `mkvmerge demuxer <ugoira.ffmpeg-demuxer_>`__.\n\n\nugoira.mkvmerge-metadata\n------------------------\nType\n    ``bool``\nDefault\n    ``true``\nDescription\n    Let ``mkvmerge`` write ``BPS``, ``DURATION``, ``NUMBER_OF_BYTES``,\n    and ``NUMBER_OF_FRAMES`` metadata tags.\nImplementation Detail\n    Disabling this option passes\n    ``--disable-track-statistics-tags`` to ``mkvmerge``\n\n\nugoira.mkvmerge-mtime\n---------------------\nType\n    ``bool``\nDefault\n    ``true``\nDescription\n    Set the `date` segment information field\n    of files processed with ``mkvmerge``.\n\n\nugoira.mkvmerge-output\n----------------------\nType\n    ``bool``\nDefault\n    ``false``\nDescription\n    Enable ``mkvmerge`` output.\n\n\nugoira.ffmpeg-output\n--------------------\nType\n    * ``bool``\n    * ``string``\nDefault\n    ``\"error\"``\nDescription\n    Controls |ffmpeg| output.\n\n    ``true``\n        Enable |ffmpeg| output\n    ``false``\n        Disable all |ffmpeg| output\n    any ``string``\n        Pass ``-hide_banner`` and ``-loglevel``\n        with this value as argument to |ffmpeg|\n\n\nugoira.ffmpeg-twopass\n---------------------\nType\n    ``bool``\nDefault\n    ``false``\nDescription\n    Enable Two-Pass encoding.\n\n\nugoira.framerate\n----------------\nType\n    ``string``\nDefault\n    ``\"auto\"``\nDescription\n    Controls the frame rate argument (``-r``) for |ffmpeg|\n\n    ``\"auto\"``\n        Automatically assign a fitting frame rate\n        based on delays between frames.\n    ``\"uniform\"``\n        Like ``auto``, but assign an explicit frame rate\n        only to Ugoira with uniform frame delays.\n    any other ``string``\n        Use this value as argument for ``-r``.\n    ``null`` or an empty ``string``\n        Don't set an explicit frame rate.\n\n\nugoira.keep-files\n-----------------\nType\n    ``bool``\nDefault\n    ``false``\nDescription\n    Keep ZIP archives after conversion.\n\n\nugoira.libx264-prevent-odd\n--------------------------\nType\n    ``bool``\nDefault\n    ``true``\nDescription\n    Prevent ``\"width/height not divisible by 2\"`` errors\n    when using ``libx264`` or ``libx265`` encoders\n    by applying a simple cropping filter. See this `Stack Overflow\n    thread <https://stackoverflow.com/questions/20847674>`__\n    for more information.\n\n    This option, when ``libx264/5`` is used, automatically\n    adds ``[\"-vf\", \"crop=iw-mod(iw\\\\,2):ih-mod(ih\\\\,2)\"]``\n    to the list of |ffmpeg| command-line arguments\n    to reduce an odd width/height by 1 pixel and make them even.\n\n\nugoira.metadata\n---------------\nType\n    * ``bool``\n    * ``string``\nDefault\n    ``true``\nDescription\n    When using ``\"mode\": \"archive\"``, save Ugoira frame delay data as\n    ``animation.json`` within the archive file.\n\n    If this is a ``string``,\n    use it as alternate filename for frame delay files.\n\n\nugoira.mtime\n------------\nType\n    ``bool``\nDefault\n    ``true``\nDescription\n    Set modification times of generated ugoira animations.\n\n\nugoira.repeat-last-frame\n------------------------\nType\n    ``bool``\nDefault\n    ``true``\nDescription\n    Allow repeating the last frame when necessary\n    to prevent it from only being displayed for a very short amount of time.\n\n\nugoira.skip\n-----------\nType\n    ``bool``\nDefault\n    ``true``\nDescription\n    Do not convert frames if target file already exists.\n\n\nzip.compression\n---------------\nType\n    ``string``\nDefault\n    ``\"store\"``\nDescription\n    Compression method to use when writing the archive.\nSupported Values\n    * ``\"store\"``\n    * ``\"zip\"``\n    * ``\"bzip2\"``\n    * ``\"lzma\"``\n\n\nzip.extension\n-------------\nType\n    ``string``\nDefault\n    ``\"zip\"``\nDescription\n    Filename extension for the created ZIP archive.\n\n\nzip.files\n---------\nType\n    ``list`` of |Path|\nExample\n    ``[\"info.json\"]``\nDescription\n    List of extra files to be added to a ZIP archive.\nNote\n    Relative paths are relative to the current\n    `download directory <extractor.*.directory_>`__.\n\n\nzip.keep-files\n--------------\nType\n    ``bool``\nDefault\n    ``false``\nDescription\n    Keep the actual files after writing them to a ZIP archive.\n\n\nzip.mode\n--------\nType\n    ``string``\nDefault\n    ``\"default\"``\nDescription\n    ``\"default\"``\n        Write the central directory file header\n        once after everything is done or an exception is raised.\n\n    ``\"safe\"``\n        Update the central directory file header\n        each time a file is stored in a ZIP archive.\n\n        This greatly reduces the chance a ZIP archive gets corrupted in\n        case the Python interpreter gets shut down unexpectedly\n        (power outage, SIGKILL) but is also a lot slower.\n\n\n\nMiscellaneous Options\n=====================\n\n\nextractor.modules\n-----------------\nType\n    ``list`` of ``strings``\nDefault\n    The ``modules`` list in\n    `extractor/__init__.py <https://github.com/mikf/gallery-dl/blob/master/gallery_dl/extractor/__init__.py#L12>`__\nExample\n    ``[\"reddit\", \"danbooru\", \"mangadex\"]``\nDescription\n    List of internal modules to load when searching for a suitable\n    extractor class. Useful to reduce startup time and memory usage.\n\n\nextractor.module-sources\n------------------------\nType\n    ``list`` of |Path|_ instances\nExample\n    ``[\"~/.config/gallery-dl/modules\", null]``\nDescription\n    List of directories to load external extractor modules from.\n\n    Any file in a specified directory with a ``.py`` filename extension\n    gets `imported <https://docs.python.org/3/reference/import.html>`__\n    and searched for potential extractors,\n    i.e. classes with a ``pattern`` attribute.\nNote\n    ``null`` references internal extractors defined in\n    `extractor/__init__.py <https://github.com/mikf/gallery-dl/blob/master/gallery_dl/extractor/__init__.py#L12>`__\n    or by `extractor.modules`_.\n\n\nextractor.category-map\n----------------------\nType\n    * ``object`` (`category` → `category`)\n    * ``string``\nExample\n    .. code:: json\n\n        {\n            \"danbooru\": \"booru\",\n            \"gelbooru\": \"booru\"\n        }\nDescription\n    A JSON object mapping category names to their replacements.\nSpecial Values\n    * ``\"compat\"``\n        .. code:: json\n\n            {\n                \"coomer\"       : \"coomerparty\",\n                \"kemono\"       : \"kemonoparty\",\n                \"turbo\"        : \"saint\",\n                \"schalenetwork\": \"koharu\",\n                \"naver-chzzk\"  : \"chzzk\",\n                \"naver-blog\"   : \"naver\",\n                \"naver-webtoon\": \"naverwebtoon\",\n                \"pixiv-novel\"  : \"pixiv\",\n                \"pixiv-novel:novel\"   : [\"pixiv\", \"novel\"],\n                \"pixiv-novel:user\"    : [\"pixiv\", \"novel-user\"],\n                \"pixiv-novel:series\"  : [\"pixiv\", \"novel-series\"],\n                \"pixiv-novel:bookmark\": [\"pixiv\", \"novel-bookmark\"]\n            }\n\n\nextractor.config-map\n--------------------\nType\n    ``object`` (`category` → `category`)\nDefault\n    .. code:: json\n\n        {\n            \"coomerparty\"  : \"coomer\",\n            \"kemonoparty\"  : \"kemono\",\n            \"giantessbooru\": \"sizebooru\",\n            \"koharu\"       : \"schalenetwork\",\n            \"chzzk\"        : \"naver-chzzk\",\n            \"naver\"        : \"naver-blog\",\n            \"naverwebtoon\" : \"naver-webtoon\",\n            \"pixiv\"        : \"pixiv-novel\",\n            \"saint\"        : \"turbo\"\n        }\nDescription\n    Duplicate the configuration settings of extractor `categories`\n    to other names.\n\n    For example, a ``\"naver\": \"naver-blog\"`` key-value pair will make all\n    ``naver`` config settings available for ``naver-blog`` extractors as well.\n\n\njinja.environment\n-----------------\nType\n    ``object`` (`name` → `value`)\nExample\n    .. code:: json\n\n        {\n            \"variable_start_string\": \"(((\",\n            \"variable_end_string\"  : \")))\",\n            \"keep_trailing_newline\": true\n        }\nDescription\n    Initialization parameters for the |jinja|\n    `Environment <https://jinja.palletsprojects.com/en/stable/api/#jinja2.Environment>`__\n    object.\n\n\njinja.policies\n--------------\nType\n    ``object`` (`name` → `value`)\nExample\n    .. code:: json\n\n        {\n            \"urlize.rel\": \"nofollow noopener\",\n            \"ext.i18n.trimmed\": true\n        }\nDescription\n    |jinja|\n    `Policies <https://jinja.palletsprojects.com/en/stable/api/#policies>`__\n\n\njinja.filters\n-------------\nType\n    |Module|_\nDescription\n    A Python |Module|_ containing custom |jinja|\n    `filters <https://jinja.palletsprojects.com/en/stable/api/#custom-filters>`__\n\n\njinja.tests\n-----------\nType\n    |Module|_\nDescription\n    A Python |Module|_ containing custom |jinja|\n    `tests <https://jinja.palletsprojects.com/en/stable/api/#custom-tests>`__\n\n\nglobals\n-------\nType\n    |Module|_\nDescription\n    A Python |Module|_ whose namespace,\n    in addition to the ``GLOBALS`` dict in\n    `util.py <https://github.com/mikf/gallery-dl/blob/v1.27.0/gallery_dl/util.py#L566-L578>`__,\n    is used as |globals parameter|__ for compiled Expressions_.\n\n.. |globals parameter| replace:: ``globals`` parameter\n.. __: https://docs.python.org/3/library/functions.html#eval\n\n\n\ncache.file\n----------\nType\n    |Path|_\nDefault\n    * (``%APPDATA%`` or ``\"~\"``) + ``\"/gallery-dl/cache.sqlite3\"`` on Windows\n    * (``$XDG_CACHE_HOME`` or ``\"~/.cache\"``) + ``\"/gallery-dl/cache.sqlite3\"`` on all other platforms\nDescription\n    Path of the SQLite3 database used to cache login sessions,\n    cookies and API tokens across `gallery-dl` invocations.\n\n    Set this option to ``\":memory:\"``, ``null``, or an invalid path\n    to disable creating a file for this cache.\n\n\nfilters-environment\n-------------------\nType\n    * ``bool``\n    * ``string``\nDefault\n    ``true``\nDescription\n    Evaluate Expressions_ in a special environment\n    preventing them from raising fatal exceptions.\n\n    ``true`` | ``\"tryexcept\"``\n        Wrap expressions in a `try`/`except` block;\n        Evaluate expressions raising an exception as ``false``\n    ``false`` | ``\"raw\"``\n        Do not wrap expressions in a special environment\n    ``\"defaultdict\"``\n        Prevent exceptions when accessing undefined variables\n        by using a `defaultdict <https://docs.python.org/3/library/collections.html#collections.defaultdict>`__\n\n\nformat-operator-dot\n-------------------\nType\n    ``bool``\nDefault\n    ``true``\nDescription\n    In standard `Format Strings`_, allow the `dot` operator ``.``\n    to function as a general access operator\n    in addition to regular attribute access.\n\n    * ``obj.attribute``\n    * ``dict.fieldname``\n    * ``list.123``\n\n\nformat-separator\n----------------\nType\n    ``string``\nDefault\n    ``\"/\"``\nDescription\n    Character(s) used as argument separator in `Format String`_\n    `format specifiers <formatting.md#format-specifiers>`__.\n\n    For example, setting this option to ``\"#\"`` would allow a replacement\n    operation to be ``Rold#new#`` instead of the default ``Rold/new/``\n\n\ninput-files\n-----------\nType\n    ``list`` of |Path|_\nExample\n    ``[\"~/urls.txt\", \"$HOME/input\"]``\nDescription\n    Additional input files.\n\n\nsignals-ignore\n--------------\nType\n    ``list`` of ``strings``\nExample\n    ``[\"SIGTTOU\", \"SIGTTIN\", \"SIGTERM\"]``\nDescription\n    The list of signal names to ignore, i.e. set\n    `SIG_IGN <https://docs.python.org/3/library/signal.html#signal.SIG_IGN>`_\n    as signal handler for.\n\n\nsignals-actions\n---------------\nType\n    ``object`` (`signal` → `Action(s)`_)\nExample\n    .. code:: json\n\n        {\n            \"SIGINT\" : \"flag download = stop\",\n            \"SIGUSR1\": [\n                \"print Received SIGUSR1\",\n                \"exec notify.sh\",\n                \"exit 127\"\n            ]\n        }\nDescription\n    `Action(s)`_ to perform when a\n    `signal <https://docs.python.org/3/library/signal.html>`__\n    is received.\n\n\nsubconfigs\n----------\nType\n    ``list`` of |Path|_\nExample\n    ``[\"~/cfg-twitter.json\", \"~/cfg-reddit.json\"]``\nDescription\n    Additional configuration files to load.\n\n\nwarnings\n--------\nType\n    ``string``\nDefault\n    ``\"default\"``\nDescription\n    The `Warnings Filter action <https://docs.python.org/3/library/warnings.html#the-warnings-filter>`__\n    used for (urllib3) warnings.\n\n\n\nAPI Tokens & IDs\n================\n\nAll configuration keys listed in this section have fully functional default\nvalues embedded into *gallery-dl* itself, but if things unexpectedly break\nor you want to use your own personal client credentials, you can follow these\ninstructions to get an alternative set of API tokens and IDs.\n\n\nextractor.deviantart.client-id & .client-secret\n-----------------------------------------------\nType\n    ``string``\nHow To\n    * login and visit DeviantArt's\n      `Applications & Keys <https://www.deviantart.com/developers/apps>`__\n      section\n    * click \"Register Application\"\n    * scroll to \"OAuth2 Redirect URI Whitelist (Required)\"\n      and enter \"https://mikf.github.io/gallery-dl/oauth-redirect.html\"\n    * scroll to the bottom and agree to the API License Agreement.\n      Submission Policy, and Terms of Service.\n    * click \"Save\"\n    * copy ``client_id`` and ``client_secret`` of your new\n      application and put them in your configuration file\n      as ``\"client-id\"`` and ``\"client-secret\"``\n    * clear your `cache <cache.file_>`__ to delete any remaining\n      ``access-token`` entries. (``gallery-dl --clear-cache deviantart``)\n    * get a new `refresh-token <extractor.deviantart.refresh-token_>`__ for the\n      new ``client-id`` (``gallery-dl oauth:deviantart``)\n\n\nextractor.flickr.api-key & .api-secret\n--------------------------------------\nType\n    ``string``\nHow To\n    * login and `Create an App <https://www.flickr.com/services/apps/create/apply/>`__\n      in Flickr's `App Garden <https://www.flickr.com/services/>`__\n    * click \"APPLY FOR A NON-COMMERCIAL KEY\"\n    * fill out the form with a random name and description\n      and click \"SUBMIT\"\n    * copy ``Key`` and ``Secret`` and put them in your configuration file\n      as ``\"api-key\"`` and ``\"api-secret\"``\n\n\nextractor.mangadex.client-id & .client-secret\n---------------------------------------------\nType\n    ``string``\nHow To\n    * login and go to your `User Settings <https://mangadex.org/settings>`__\n    * open the \"API Clients\" section\n    * click \"``+ Create``\"\n    * choose a name\n    * click \"``✔️ Create``\"\n    * wait for approval / reload the page\n    * copy the value after \"AUTOAPPROVED ACTIVE\" in the form \"personal-client-...\"\n      and put it in your configuration file as ``\"client-id\"``\n    * click \"``Get Secret``\", then \"``Copy Secret``\",\n      and paste it into your configuration file as ``\"client-secret\"``\n\n\nextractor.reddit.client-id & .user-agent\n----------------------------------------\nType\n    ``string``\nHow To\n    * login and visit the `apps <https://www.reddit.com/prefs/apps/>`__\n      section of your account's preferences\n    * click the \"are you a developer? create an app...\" button\n    * fill out the form:\n\n      * choose a name\n      * select \"installed app\"\n      * set \"redirect uri\" to http://localhost:6414/\n      * solve the \"I'm not a robot\" challenge if needed\n      * click \"create app\"\n\n    * copy the client id (third line, under your application's name and\n      \"installed app\") and put it in your configuration file\n      as ``\"client-id\"``\n    * use \"``Python:<application name>:v1.0 (by /u/<username>)``\" as\n      ``user-agent`` and replace ``<application name>`` and ``<username>``\n      accordingly (see Reddit's\n      `API access rules <https://github.com/reddit/reddit/wiki/API>`__)\n    * clear your `cache <cache.file_>`__ to delete any remaining\n      ``access-token`` entries. (``gallery-dl --clear-cache reddit``)\n    * get a `refresh-token <extractor.reddit.refresh-token_>`__ for the\n      new ``client-id`` (``gallery-dl oauth:reddit``)\n\n\nextractor.smugmug.api-key & .api-secret\n---------------------------------------\nType\n    ``string``\nHow To\n    * login and `Apply for an API Key <https://api.smugmug.com/api/developer/apply>`__\n    * fill out the form:\n\n      * choose a random name and description\n      * set \"Type\" to \"Application\"\n      * set \"Platform\" to \"All\"\n      * set \"Use\" to \"Non-Commercial\"\n      * tick the two checkboxes at the bottom\n      * click \"Apply\"\n\n    * copy ``API Key`` and ``API Secret``\n      and put them in your configuration file\n      as ``\"api-key\"`` and ``\"api-secret\"``\n\n\nextractor.tumblr.api-key & .api-secret\n--------------------------------------\nType\n    ``string``\nHow To\n    * login and visit Tumblr's\n      `Applications <https://www.tumblr.com/oauth/apps>`__ section\n    * click \"Register application\"\n    * fill out the form:\n\n      * choose a random name and description\n      * set \"Application Website\" to https://example.org/\n      * set \"Default callback URL\" to https://example.org/\n      * solve the \"I'm not a robot\" challenge\n      * click \"Register\"\n\n    * click \"Show secret key\" (below \"OAuth Consumer Key\")\n    * copy your ``OAuth Consumer Key`` and ``Secret Key``\n      and put them in your configuration file\n      as ``\"api-key\"`` and ``\"api-secret\"``\n\n\n\nCustom Types\n============\n\n\nDate\n----\nType\n    * ``string``\n    * ``integer``\nExample\n    * ``\"2019-01-01\"``\n    * ``\"2019-01-01 03:00:00\"``\n    * ``\"2019-03-08T12:30:00Z\"``\n    * ``1546297200``\nDescription\n    A |Date|_ value represents a specific point in time.\n\n    * If given as ``string``, it is parsed according to |ISO 8601|.\n    * If given as ``integer``, it is interpreted as UTC timestamp.\n\n\nDuration\n--------\nType\n    * ``float``\n    * ``list`` with 2 ``floats``\n    * ``string``\nExample\n    * ``2.85``\n    * ``[1.5, 3.0]``\n    * ``\"2.85\"``, ``\"1.5-3.0\"``\nDescription\n    A |Duration|_ represents a span of time in seconds.\n\n    * If given as a single ``float``, it will be used as that exact value.\n    * If given as a ``list`` with 2 floating-point numbers ``a`` & ``b`` ,\n      it will be randomly chosen with uniform distribution such that ``a <= N <= b``.\n      (see `random.uniform() <https://docs.python.org/3/library/random.html#random.uniform>`_)\n    * If given as a ``string``, it can either represent a single ``float``\n      value (``\"2.85\"``) or a range  (``\"1.5-3.0\"``).\n\n\nDuration+\n---------\nType\n    * |Duration|_\n    * ``string``\nExample\n    * ``\"1.5-3.0\"``\n    * ``\"lin=5\"``\n    * ``\"lin:20=30-60\"``\n    * ``\"exp:1.8=40\"``\nDescription\n    A |Duration|_ value.\n\n    When given as ``string``, it can optionally be prefixed with\n    ``lin[:START[:MAX]]=`` for `linear` or\n    ``exp[:BASE[:START[:MAX]]]=`` for `exponential` growth.\n\n\nModule\n------\nType\n    * ``string``\n    * |Path|_\nExample\n    * ``\"gdl_utils\"``\n    * ``\"~/.local/share/gdl/\"``\n    * ``\"~/.local/share/gdl_utils.py\"``\nDescription\n    A Python\n    `Module <https://docs.python.org/3/glossary.html#term-module>`__\n\n    This can be one of\n\n    * the name of an\n      `importable <https://docs.python.org/3/reference/import.html>`__\n      Python module\n    * the |Path|_ to a Python\n      `package <https://docs.python.org/3/glossary.html#term-package>`__\n    * the |Path|_ to a `.py` file\n\n    See\n    `Python/Modules <https://docs.python.org/3/tutorial/modules.html>`__\n    for details.\n\n\nPath\n----\nType\n    ``string``\nExample\n    * ``\"file.ext\"``\n    * ``\"~/path/to/file.ext\"``\n    * ``\"$HOME/path/to/file.ext\"``\n    * ``\"C:\\\\path\\\\to\\\\file.ext\"``\nDescription\n    A |Path|_ is a ``string`` representing the location\n    of a file or directory.\n\n    Simple `tilde expansion <https://docs.python.org/3/library/os.path.html#os.path.expanduser>`__\n    and `environment variable expansion <https://docs.python.org/3/library/os.path.html#os.path.expandvars>`__\n    is supported.\nNote\n    In Windows environments,\n    both backslashes ``\\`` as well as forward slashes ``/``\n    can be used as path separators.\n\n    However, since backslashes are JSON's escape character,\n    they themselves must be escaped as ``\\\\``.\n\n    For example, a path like ``C:\\path\\to\\file.ext`` has to be specified as\n\n    * ``\"C:\\\\path\\\\to\\\\file.ext\"`` when using backslashes\n    * ``\"C:/path/to/file.ext\"`` when using forward slashes\n\n    in a JSON file.\n\n\nPath+\n-----\nType\n    * |Path|_\n    * ``list`` of `Format Strings`_\nExample\n    * ``\"file.ext\"``\n    * ``[\":b\", \"{category}\", \"{user}.sqlite3\"]``\n    * ``[\":~\", \"gdl\", \"{category}\", \"{user}.sqlite3\"]``\n    * ``[\":$HOME\", \"gdl\", \"{category}\", \"{user}.sqlite3\"]``\n    * ``[\"/opt\", \"archives\", \"{category}\", \"{user}.sqlite3\"]``\n    * ``[\"C:\", \"archives\", \"{category}\", \"{user}.sqlite3\"]``\n    * ``[\"\\\\\\\\server\\\\archives\", \"{category}\", \"{user}.sqlite3\"]``\nDescription\n    A |Path|_ that supports\n    `path-restricted <extractor.*.path-restrict_>`__\n    `Format String`_ expansion\n    when given as a ``list`` of ``string`` values.\n\n    Use a string starting with ``:`` as first list element\n    to prefix the path with one of the following:\n\n    ``\":\"`` | ``\":b\"`` | ``\":base\"``\n        `base-directory <extractor.*.base-directory_>`__\n    ``\":d\"`` | ``\":dir\"``\n        `base-directory <extractor.*.base-directory_>`__ +\n        `directory <extractor.*.directory_>`__\n    ``\":~\"`` | ``\":~USER\"``\n        home directory\n        (`os.path.expanduser <https://docs.python.org/3/library/os.path.html#os.path.expanduser>`__)\n    ``\":$ENV\"`` (``$`` + environment variable name)\n        value of environment variable\n\n    Use ``<drive-letter>:`` or ``\\\\`` on Windows\n    or ``/`` on other platforms\n    as starting characters of the first list element\n    to interpret this as an absolute path.\n\n    Otherwise it is interpreted as a path\n    relative to the current working directory.\n\n\nLogging Configuration\n---------------------\nType\n    ``object``\nExample\n    .. code:: json\n\n        {\n            \"format\"     : \"{asctime} {name}: {message}\",\n            \"format-date\": \"%H:%M:%S\",\n            \"path\"       : \"~/log.txt\",\n            \"encoding\"   : \"ascii\",\n            \"defer\"      : true\n        }\n\n    .. code:: json\n\n        {\n            \"level\" : \"debug\",\n            \"format\": {\n                \"debug\"  : \"debug: {message}\",\n                \"info\"   : \"[{name}] {message}\",\n                \"warning\": \"Warning: {message}\",\n                \"error\"  : \"ERROR: {message}\"\n            }\n        }\n\nDescription\n    Extended logging output configuration.\n\n    * format\n        * General `Format String`_ for logging messages\n          or an ``object`` with `Format Strings`_ for each loglevel.\n\n          In addition to the default\n          `LogRecord attributes <https://docs.python.org/3/library/logging.html#logrecord-attributes>`__,\n          it is also possible to access the current\n          `extractor <https://github.com/mikf/gallery-dl/blob/v1.27.0/gallery_dl/extractor/common.py#L28>`__,\n          `job <https://github.com/mikf/gallery-dl/blob/v1.27.0/gallery_dl/job.py#L33>`__,\n          `path <https://github.com/mikf/gallery-dl/blob/v1.27.0/gallery_dl/path.py#L27>`__,\n          and `keywords` objects and their attributes, for example\n          ``\"{extractor.url}\"``, ``\"{path.filename}\"``, ``\"{keywords.title}\"``\n        * Default:\n          ``\"[{name}][{levelname}] {message}\"`` for\n          `logfile <output.logfile_>`__,\n          ``\"{message}\"`` for\n          `unsupportedfile <output.unsupportedfile_>`__ and\n          `errorfile <output.errorfile_>`__\n    * format-date\n        * Format string for ``{asctime}`` fields in logging messages\n          (see `strftime() directives <https://docs.python.org/3/library/time.html#time.strftime>`__)\n        * Default: ``\"%Y-%m-%d %H:%M:%S\"``\n    * level\n        * Minimum logging message level\n          (one of ``\"debug\"``, ``\"info\"``, ``\"warning\"``, ``\"error\"``, ``\"exception\"``)\n        * Default: ``\"info\"``\n    * path\n        * |Path|_ to the output file\n    * mode\n        * Mode in which the file is opened;\n          use ``\"w\"`` to truncate or ``\"a\"`` to append\n          (see |open()|_)\n        * Default:\n          ``\"w\"`` for\n          `logfile <output.logfile_>`__ and\n          `unsupportedfile <output.unsupportedfile_>`__,\n          ``\"a\"`` for\n          `errorfile <output.errorfile_>`__\n    * encoding\n        * File encoding\n        * Default: ``\"utf-8\"``\n    * defer\n        * Defer file opening/creation until writing the first logging message\n        * Default:\n          ``false`` for\n          `logfile <output.logfile_>`__,\n          ``true`` for\n          `unsupportedfile <output.unsupportedfile_>`__ and\n          `errorfile <output.errorfile_>`__\n\nNote\n    path, mode, encoding, and defer\n    are only applied when configuring logging output to a file.\n    (See `logging.FileHandler <https://docs.python.org/3/library/logging.handlers.html#filehandler>`__)\n\n\nPostprocessor Configuration\n---------------------------\nType\n    ``object``\nExample\n    .. code:: json\n\n        { \"name\": \"mtime\" }\n\n    .. code:: json\n\n        {\n            \"name\"  : \"metadata/print@prepare\",\n            \"format\": \"{id}: {date}\"\n        }\n\n    .. code:: json\n\n        {\n            \"name\"       : \"zip\",\n            \"compression\": \"store\",\n            \"extension\"  : \"cbz\",\n            \"filter\"     : \"extension not in ('zip', 'rar')\",\n            \"whitelist\"  : [\"mangadex\", \"exhentai\", \"nhentai\"]\n        }\nDescription\n    An ``object`` containing a ``\"name\"`` attribute specifying the\n    post-processor type, as well as any of its `options <Postprocessor Options_>`__.\n\n    * It is possible to set a ``\"filter\"`` Condition_ similar to\n      `file-filter <extractor.*.file-filter_>`_\n      to only run a post-processor conditionally.\n\n    * It is possible set a ``\"whitelist\"`` or ``\"blacklist\"`` to\n      only enable or disable a post-processor for the specified\n      extractor categories.\n\n    * It is possible to specify a post-processor's ``mode`` & ``event``\n      as part of its ``name`` by adding ``/MODE`` & ``@EVENT``.\n      For example\n\n        * ``\"name\": \"metadata/jsonl@post\"``\n        * ``\"name\": \"ugoira/archive\"``\n        * ``\"name\": \"exec@error\"``\n\n    Available postprocessor types are\n\n    ``actions``\n        Perform `Action(s)`_\n    ``classify``\n        Categorize files by filename extension\n    ``compare``\n        | Compare versions of the same file and replace/enumerate them on mismatch\n        | (requires `downloader.*.part`_ = ``true`` and `extractor.*.skip`_ = ``false``)\n    ``directory``\n        Reevaluate directory_ `Format Strings`_\n    ``exec``\n        Execute external commands\n    ``hash``\n        Compute file hash digests\n    ``metadata``\n        Write metadata to separate files\n    ``mtime``\n        Set file modification time according to its metadata\n    ``python``\n        Call Python functions\n    ``rename``\n        Rename previously downloaded files\n    ``ugoira``\n        Convert Pixiv Ugoira to WebM using |ffmpeg|\n    ``zip``\n        Store files in a ZIP archive\n\n\nAction\n------\nType\n    ``string``\nExample\n    * ``\"exit\"``\n    * ``\"print Hello World\"``\n    * ``\"raise AbortExtraction an error occured\"``\n    * ``\"flag file = terminate\"``\n    * ``\"keyword title Hello World\"``\n    * ``[\"print Exiting\", \"exit 1\"]``\nDescription\n    An Action_ is parsed as `Action Type`\n    followed by (optional) arguments:\n    ``<type> <arg1> <arg2> …``\n\n    It is possible to specify more than one ``action``\n    by providing them as a ``list``: ``[\"<action1>\", \"<action2>\", …]``\n\n    Supported `Action Types`:\n\n    ``status``:\n        | Modify job exit status.\n        | Expected syntax is ``<operator> <value>`` (e.g. ``= 100``).\n\n        Supported operators are\n        ``=`` (assignment),\n        ``&`` (bitwise AND),\n        ``|`` (bitwise OR),\n        ``^`` (bitwise XOR).\n    ``level``:\n        | Modify severity level of the current logging message.\n        | Can be one of ``debug``, ``info``, ``warning``, ``error`` or an integer value.\n        | Use ``0`` to ignore a message (``level = 0``).\n    ``print``:\n        Write argument to stdout.\n    ``exec``:\n        Run a shell command.\n    ``abort``:\n        Stop the current extractor run.\n    ``terminate``:\n        Stop the current extractor run, including parent extractors.\n    ``restart``:\n        Restart the current extractor run.\n    ``raise``:\n        Raise an exception.\n\n        This can be an exception defined in\n        `exception.py <https://github.com/mikf/gallery-dl/blob/master/gallery_dl/exception.py>`_\n        or a\n        `built-in exception <https://docs.python.org/3/library/exceptions.html#exception-hierarchy>`_\n        (e.g. ``ZeroDivisionError``)\n    ``flag``:\n        Set a ``flag``.\n\n        | Expected syntax is ``<flag>[ = <value>]`` (e.g. ``post = stop``)\n        | ``<flag>`` can be one of ``file``, ``post``, ``child``, ``download``\n        | ``<value>`` can be one of ``stop``, ``abort``, ``terminate``, ``restart``, ``skip`` (default ``stop``)\n    ``keyword``:\n        Set a `keyword <extractor.*.keywords_>`__ value\n    ``wait``:\n        | Sleep for a given Duration_ or\n        | wait until Enter is pressed when no argument was given.\n    ``exit``:\n        Exit the program with the given argument as exit status.\n\n\nExpression\n----------\nType\n    ``string``\nExample\n    * ``\"1 + 2 + 3\"``\n    * ``\"str(id) + '_' + title\"``\n    * ``\"' - '.join(tags[:3]) if tags else 'no tags'\"``\nDescription\n    A Python Expression_ is a combination of\n    values, variables, operators, and function calls\n    that evaluate to a single value.\nReference\n    * https://docs.python.org/3/reference/expressions.html\n\n\nCondition\n---------\nType\n    * Expression_\n    * ``list`` of `Expressions`_\nExample\n    * ``\"not is_watching\"``\n    * ``\"locals().get('optional')\"``\n    * ``\"date >= datetime(2025, 7, 1) or abort()\"``\n    * ``[\"width > 800\", \"0.9 < width/height < 1.1\"]``\nDescription\n    A Condition_ is an Expression_\n    whose result is evaluated as a |type-bool|_ value.\n\n\nFormat String\n-------------\nType\n    ``string``\nExample\n    * ``\"foo\"``\n    * ``\"{username}\"``\n    * ``\"{title} ({id}).{extension}\"``\n    * ``\"\\fF {title.title()} ({num:>0{len(str(count))}} / {count}).{extension}\"``\nDescription\n    A `Format String`_ allows creating dynamic text\n    by embedding metadata values directly into replacement fields\n    marked by curly braces ``{...}``.\nReference\n    * `docs/formatting <formatting_>`__\n    * https://docs.python.org/3/library/string.html#formatstrings\n    * https://docs.python.org/3/library/string.html#formatspec\n\n.. _formatting: formatting.md\n\n\n.. |ytdl| replace:: `yt-dlp`_/`youtube-dl`_\n.. |ytdl's| replace:: yt-dlp's/youtube-dl's\n.. |ffmpeg| replace:: FFmpeg_\n.. |jinja| replace:: Jinja\n\n.. |.netrc| replace:: ``.netrc``\n.. |requests.request()| replace:: ``requests.request()``\n.. |timeout| replace:: ``timeout``\n.. |verify| replace:: ``verify``\n.. |mature_content| replace:: ``mature_content``\n.. |webbrowser.open()| replace:: ``webbrowser.open()``\n.. |type-str| replace:: ``str``\n.. |type-bool| replace:: ``boolean``\n.. |type-datetime| replace:: ``datetime``\n.. |datetime.max| replace:: ``datetime.max``\n.. |Date| replace:: ``Date``\n.. |Duration| replace:: ``Duration``\n.. |Duration+| replace:: ``Duration+``\n.. |Module| replace:: ``Module``\n.. |Path| replace:: ``Path``\n.. |Path+| replace:: ``Path+``\n.. |Last-Modified| replace:: ``Last-Modified``\n.. |Logging Configuration| replace:: ``Logging Configuration``\n.. |Postprocessor Configuration| replace:: ``Postprocessor Configuration``\n.. |strptime| replace:: strftime() and strptime() Behavior\n.. |postprocessors| replace:: ``postprocessors``\n.. |mode: color| replace:: ``\"mode\": \"color\"``\n.. |open()| replace:: the built-in ``open()`` function\n.. |json.dump()| replace:: ``json.dump()``\n.. |ISO 639-1| replace:: `ISO 639-1 <https://en.wikipedia.org/wiki/ISO_639-1>`__ language\n.. |ISO 8601| replace:: `ISO 8601 <https://en.wikipedia.org/wiki/ISO_8601>`__\n\n.. _directory: `extractor.*.directory`_\n.. _base-directory: `extractor.*.base-directory`_\n.. _deviantart.metadata: `extractor.deviantart.metadata`_\n.. _deviantart.comments: `extractor.deviantart.comments`_\n.. _postprocessors: `extractor.*.postprocessors`_\n.. _download archive: `extractor.*.archive`_\n.. _Action(s): Action_\n.. _Conditions: Condition_\n.. _Condition(s): Condition_\n.. _Expressions: Expression_\n.. _Expression(s): Expression_\n.. _Format Strings: `Format String`_\n.. _Format String(s): `Format String`_\n\n.. _Conversion(s):      https://gdl-org.github.io/docs/formatting.html#conversions\n.. _.netrc:             https://stackoverflow.com/tags/.netrc/info\n.. _Last-Modified:      https://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.29\n.. _type-str:           https://docs.python.org/3/library/stdtypes.html#text-sequence-type-str\n.. _type-bool:          https://docs.python.org/3/library/stdtypes.html#boolean-type-bool\n.. _type-datetime:      https://docs.python.org/3/library/datetime.html#datetime-objects\n.. _datetime.max:       https://docs.python.org/3/library/datetime.html#datetime.datetime.max\n.. _strptime:           https://docs.python.org/3/library/datetime.html#strftime-strptime-behavior\n.. _webbrowser.open():  https://docs.python.org/3/library/webbrowser.html\n.. _open():             https://docs.python.org/3/library/functions.html#open\n.. _json.dump():        https://docs.python.org/3/library/json.html#json.dump\n.. _mature_content:     https://www.deviantart.com/developers/http/v1/20160316/object/deviation\n.. _Authentication:     https://github.com/mikf/gallery-dl#authentication\n.. _OAuth:              https://github.com/mikf/gallery-dl#oauth\n.. _youtube-dl:         https://github.com/ytdl-org/youtube-dl\n.. _yt-dlp:             https://github.com/yt-dlp/yt-dlp\n.. _FFmpeg:             https://www.ffmpeg.org/\n.. _requests.request(): https://requests.readthedocs.io/en/master/api/#requests.request\n.. _timeout:            https://requests.readthedocs.io/en/master/user/advanced/#timeouts\n.. _verify:             https://requests.readthedocs.io/en/master/user/advanced/#ssl-cert-verification\n.. _`Requests' proxy documentation`: https://requests.readthedocs.io/en/master/user/advanced/#proxies\n"
  },
  {
    "path": "docs/formatting.md",
    "content": "# String Formatting\n\n\n## Table of Contents\n\n* [Basics](#basics)\n* [Field Names](#field-names)\n* [Conversions](#conversions)\n* [Format Specifiers](#format-specifiers)\n* [Global Replacement Fields](#global-replacement-fields)\n* [Special Type Format Strings](#special-type-format-strings)\n\n\n## Basics\n\nFormat strings in gallery-dl follow the general rules of [`str.format()`](https://docs.python.org/3/library/string.html#format-string-syntax) ([PEP 3101](https://www.python.org/dev/peps/pep-3101/)) plus several extras.\n\nThe syntax for replacement fields is\n```\n{<field-name>!<conversion>:<format-specifiers>}\n```\nwhere\n[`<field-name>`](#field-names)\nselects a value\n<br>\nand the optional\n[`!<conversion>`](#conversions)\n&amp;\n[`:<format-specifiers>`](#format-specifiers)\nspecify how to transform it.\n\nExamples:\n* `{title}`\n* `{content!W}`\n* `{date:Olocal/%Y%m%d %H%M}`\n\n\n## Field Names\n\nField names select the metadata value to use in a replacement field.\n\nWhile simple names are usually enough, more complex forms like accessing values by attribute, element index, or slicing are also supported.\n\n<table>\n<thead>\n<tr>\n    <th></th>\n    <th>Example</th>\n    <th>Result</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n    <td>Name</td>\n    <td><code>{title}</code></td>\n    <td><code>Hello World</code></td>\n</tr>\n<tr>\n    <td>Element Index</td>\n    <td><code>{title[6]}</code></td>\n    <td><code>W</code></td>\n</tr>\n<tr>\n    <td>Slicing</td>\n    <td><code>{title[3:8]}</code></td>\n    <td><code>lo Wo</code></td>\n</tr>\n<tr>\n    <td>Slicing (Bytes)</td>\n    <td><code>{title_ja[b3:18]}</code></td>\n    <td><code>ロー・ワー</code></td>\n</tr>\n<tr>\n    <td>Alternatives</td>\n    <td><code>{empty|title}</code></td>\n    <td><code>Hello World</code></td>\n</tr>\n<tr>\n    <td>Attribute Access</td>\n    <td><code>{extractor.url}</code></td>\n    <td><code>https://example.org/</code></td>\n</tr>\n<tr>\n    <td rowspan=\"2\">Element Access</td>\n    <td><code>{user[name]}</code></td>\n    <td><code>John Doe</code></td>\n</tr>\n<tr>\n    <td><code>{user['name']}</code></td>\n    <td><code>John Doe</code></td>\n</tr>\n</tbody>\n</table>\n\nAll of these methods can be combined.\n<br>\nFor example `{title[24]|empty|extractor.url[15:-1]}` would result in `.org`.\n\n\n## Conversions\n\nConversion specifiers allow to *convert* the value to a different form or type. Such a specifier must only consist of 1 character. gallery-dl supports the default three (`s`, `r`, `a`) as well as several others:\n\n<table>\n<thead>\n<tr>\n    <th>Conversion</th>\n    <th>Description</th>\n    <th>Example</th>\n    <th>Result</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n    <td align=\"center\"><code>l</code></td>\n    <td>Convert a string to lowercase</td>\n    <td><code>{foo!l}</code></td>\n    <td><code>foo bar</code></td>\n</tr>\n<tr>\n    <td align=\"center\"><code>u</code></td>\n    <td>Convert a string to uppercase</td>\n    <td><code>{foo!u}</code></td>\n    <td><code>FOO BAR</code></td>\n</tr>\n<tr>\n    <td align=\"center\"><code>c</code></td>\n    <td>Capitalize a string, i.e. convert the first character to uppercase and all others to lowercase</td>\n    <td><code>{foo!c}</code></td>\n    <td><code>Foo bar</code></td>\n</tr>\n<tr>\n    <td align=\"center\"><code>C</code></td>\n    <td>Capitalize each word in a string</td>\n    <td><code>{foo!C}</code></td>\n    <td><code>Foo Bar</code></td>\n</tr>\n<tr>\n    <td align=\"center\"><code>g</code></td>\n    <td>Slugify a value</td>\n    <td><code>{foo!g}</code></td>\n    <td><code>foo-bar</code></td>\n</tr>\n<tr>\n    <td align=\"center\"><code>j</code></td>\n    <td>Serialize value to a JSON formatted string</td>\n    <td><code>{tags!j}</code></td>\n    <td><code>[\"sun\", \"tree\", \"water\"]</code></td>\n</tr>\n<tr>\n    <td align=\"center\"><code>L</code></td>\n    <td>Convert an <a href=\"https://en.wikipedia.org/wiki/ISO_639-1\">ISO 639-1</a> language code to its full name</td>\n    <td><code>{lang!L}</code></td>\n    <td><code>English</code></td>\n</tr>\n<tr>\n    <td align=\"center\"><code>n</code></td>\n    <td>Return the <a href=\"https://docs.python.org/3/library/functions.html#len\" rel=\"nofollow\">length</a> of a value</td>\n    <td><code>{foo!n}</code></td>\n    <td><code>7</code></td>\n</tr>\n<tr>\n    <td align=\"center\"><code>W</code></td>\n    <td>Sanitize whitespace - Remove leading and trailing whitespace characters and replace <em>all</em> whitespace (sequences) with a single space <code> </code> character</td>\n    <td><code>{space!W}</code></td>\n    <td><code>Foo Bar</code></td>\n</tr>\n<tr>\n    <td align=\"center\"><code>t</code></td>\n    <td>Trim a string, i.e. remove leading and trailing whitespace characters</td>\n    <td><code>{bar!t}</code></td>\n    <td><code>FooBar</code></td>\n</tr>\n<tr>\n    <td align=\"center\"><code>T</code></td>\n    <td>Convert a <code>datetime</code> object to a Unix timestamp</td>\n    <td><code>{date!T}</code></td>\n    <td><code>1262304000</code></td>\n</tr>\n<tr>\n    <td align=\"center\"><code>d</code></td>\n    <td>Convert a Unix timestamp to a <code>datetime</code> object</td>\n    <td><code>{created!d}</code></td>\n    <td><code>2010-01-01 00:00:00</code></td>\n</tr>\n<tr>\n    <td align=\"center\"><code>D</code></td>\n    <td>Convert a Unix timestamp or <a href=\"https://en.wikipedia.org/wiki/ISO_8601\">ISO 8601</a> string to a <code>datetime</code> object</td>\n    <td><code>{created!D}</code></td>\n    <td><code>2010-01-01 00:00:00</code></td>\n</tr>\n<tr>\n    <td align=\"center\"><code>q</code></td>\n    <td><a href=\"https://docs.python.org/3/library/urllib.parse.html#urllib.parse.quote\">URL-encode</a> a value</td>\n    <td><code>{jpn!q}</code></td>\n    <td><code>%E6%A3%AE</code></td>\n</tr>\n<tr>\n    <td align=\"center\"><code>Q</code></td>\n    <td><a href=\"https://docs.python.org/3/library/urllib.parse.html#urllib.parse.unquote\">URL-decode</a> a value</td>\n    <td><code>{jpn_url!Q}</code></td>\n    <td><code>森</code></td>\n</tr>\n<tr>\n    <td align=\"center\"><code>U</code></td>\n    <td>Convert HTML entities</td>\n    <td><code>{html!U}</code></td>\n    <td><code>&lt;p&gt;foo &amp; bar&lt;/p&gt;</code></td>\n</tr>\n<tr>\n    <td align=\"center\"><code>H</code></td>\n    <td>Convert HTML entities &amp; remove HTML tags</td>\n    <td><code>{html!H}</code></td>\n    <td><code>foo &amp; bar</code></td>\n</tr>\n<tr>\n    <td align=\"center\"><code>R</code></td>\n    <td>Extract URLs</td>\n    <td><code>{lorem!R}</code></td>\n    <td><code>[\"https://example.org/\"]</code></td>\n</tr>\n<tr>\n    <td align=\"center\"><code>s</code></td>\n    <td>Convert value to <a href=\"https://docs.python.org/3/library/stdtypes.html#text-sequence-type-str\" rel=\"nofollow\"><code>str</code></a></td>\n    <td><code>{tags!s}</code></td>\n    <td><code>['sun', 'tree', 'water']</code></td>\n</tr>\n<tr>\n    <td align=\"center\"><code>S</code></td>\n    <td>Convert value to <a href=\"https://docs.python.org/3/library/stdtypes.html#text-sequence-type-str\" rel=\"nofollow\"><code>str</code></a> while providing a human-readable representation for lists</td>\n    <td><code>{tags!S}</code></td>\n    <td><code>sun, tree, water</code></td>\n</tr>\n<tr>\n    <td align=\"center\"><code>r</code></td>\n    <td>Convert value to <a href=\"https://docs.python.org/3/library/stdtypes.html#text-sequence-type-str\" rel=\"nofollow\"><code>str</code></a> using <a href=\"https://docs.python.org/3/library/functions.html#repr\" rel=\"nofollow\"><code>repr()</code></a></td>\n    <td></td>\n    <td></td>\n</tr>\n<tr>\n    <td align=\"center\"><code>a</code></td>\n    <td>Convert value to <a href=\"https://docs.python.org/3/library/stdtypes.html#text-sequence-type-str\" rel=\"nofollow\"><code>str</code></a> using <a href=\"https://docs.python.org/3/library/functions.html#ascii\" rel=\"nofollow\"><code>ascii()</code></a></td>\n    <td></td>\n    <td></td>\n</tr>\n<tr>\n    <td align=\"center\"><code>i</code></td>\n    <td>Convert value to <a href=\"https://docs.python.org/3/library/functions.html#int\"><code>int</code></a></td>\n    <td></td>\n    <td></td>\n</tr>\n<tr>\n    <td align=\"center\"><code>f</code></td>\n    <td>Convert value to <a href=\"https://docs.python.org/3/library/functions.html#float\"><code>float</code></a></td>\n    <td></td>\n    <td></td>\n</tr>\n</tbody>\n</table>\n\n\n## Format Specifiers\n\nFormat specifiers can be used for advanced formatting by using the options provided by Python (see [Format Specification Mini-Language](https://docs.python.org/3/library/string.html#format-specification-mini-language)) like zero-filling a number (`{num:>03}`) or formatting a [`datetime`](https://docs.python.org/3/library/datetime.html#datetime.datetime) object (`{date:%Y%m%d}`), or with gallery-dl's extra formatting specifiers:\n\n<table>\n<thead>\n<tr>\n    <th>Format Specifier</th>\n    <th>Description</th>\n    <th>Example</th>\n    <th>Result</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n    <td rowspan=\"2\"><code>?&lt;start&gt;/&lt;end&gt;/</code></td>\n    <td rowspan=\"2\">Adds <code>&lt;start&gt;</code> and <code>&lt;end&gt;</code> to the actual value if it evaluates to <code>True</code>. Otherwise the whole replacement field becomes an empty string.</td>\n    <td><code>{foo:?[/]/}</code></td>\n    <td><code>[Foo&nbsp;Bar]</code></td>\n</tr>\n<tr>\n    <td><code>{empty:?[/]/}</code></td>\n    <td><code></code></td>\n</tr>\n<tr>\n    <td><code>[&lt;start&gt;:&lt;stop&gt;]</code></td>\n    <td>Applies a <a href=\"https://python-reference.readthedocs.io/en/latest/docs/brackets/slicing.html\">Slicing</a> operation to the current value, similar to <a href=\"#field-names\">Field Names</a></td>\n    <td><code>{foo:[1:-1]}</code></td>\n    <td><code>oo&nbsp;Ba</code></td>\n</tr>\n<tr>\n    <td><code>[b&lt;start&gt;:&lt;stop&gt;]</code></td>\n    <td>Same as above, but applies to the <a href=\"https://docs.python.org/3/library/stdtypes.html#bytes\"><code>bytes()</code></a> representation of a string in <a href=\"https://docs.python.org/3/library/sys.html#sys.getfilesystemencoding\">filesystem encoding</a></td>\n    <td><code>{foo_ja:[b3:-1]}</code></td>\n    <td><code>ー・バ</code></td>\n</tr>\n<tr>\n    <td rowspan=\"2\"><code>L&lt;maxlen&gt;/&lt;repl&gt;/</code></td>\n    <td rowspan=\"2\">Replaces the entire output with <code>&lt;repl&gt;</code> if its length exceeds <code>&lt;maxlen&gt;</code></td>\n    <td><code>{foo:L15/long/}</code></td>\n    <td><code>Foo&nbsp;Bar</code></td>\n</tr>\n<tr>\n    <td><code>{foo:L3/long/}</code></td>\n    <td><code>long</code></td>\n</tr>\n<tr>\n    <td rowspan=\"2\"><code>Lb&lt;maxlen&gt;/&lt;ext&gt;/</code></td>\n    <td rowspan=\"2\">Same as <code>L</code>, but applies to the <a href=\"https://docs.python.org/3/library/stdtypes.html#bytes\"><code>bytes()</code></a> representation of a string in <a href=\"https://docs.python.org/3/library/sys.html#sys.getfilesystemencoding\">filesystem encoding</a></td>\n    <td><code>{foo_ja:Lb15/長い/}</code></td>\n    <td><code>フー・バー</code></td>\n</tr>\n<tr>\n    <td><code>{foo_ja:Lb8/長い/}</code></td>\n    <td><code>長い</code></td>\n</tr>\n<tr>\n    <td rowspan=\"2\"><code>X&lt;maxlen&gt;/&lt;ext&gt;/</code></td>\n    <td rowspan=\"2\">Limit output to <code>&lt;maxlen&gt;</code> characters. Cut output and add <code>&lt;ext&gt;</code> to its end if its length exceeds <code>&lt;maxlen&gt;</code></td>\n    <td><code>{foo:X15/&nbsp;.../}</code></td>\n    <td><code>Foo&nbsp;Bar</code></td>\n</tr>\n<tr>\n    <td><code>{foo:X6/&nbsp;.../}</code></td>\n    <td><code>Fo&nbsp;...</code></td>\n</tr>\n<tr>\n    <td rowspan=\"2\"><code>Xb&lt;maxlen&gt;/&lt;ext&gt;/</code></td>\n    <td rowspan=\"2\">Same as <code>X</code>, but applies to the <a href=\"https://docs.python.org/3/library/stdtypes.html#bytes\"><code>bytes()</code></a> representation of a string in <a href=\"https://docs.python.org/3/library/sys.html#sys.getfilesystemencoding\">filesystem encoding</a></td>\n    <td><code>{foo_ja:Xb15/〜/}</code></td>\n    <td><code>フー・バー</code></td>\n</tr>\n<tr>\n    <td><code>{foo_ja:Xb8/〜/}</code></td>\n    <td><code>フ〜</code></td>\n</tr>\n<tr>\n    <td><code>J&lt;separator&gt;/</code></td>\n    <td>Concatenates elements of a list with <code>&lt;separator&gt;</code> using <a href=\"https://docs.python.org/3/library/stdtypes.html#str.join\" rel=\"nofollow\"><code>str.join()</code></a></td>\n    <td><code>{tags:J - /}</code></td>\n    <td><code>sun - tree - water</code></td>\n</tr>\n<tr>\n    <td><code>M&lt;key&gt;/</code></td>\n    <td>Maps a list of objects to a list of corresponding values by looking up <code>&lt;key&gt;</code> in each object</td>\n    <td><code>{users:Mname/}</code></td>\n    <td><code>[\"John\", \"David\", \"Max\"]</code></td>\n</tr>\n<tr>\n    <td><code>R&lt;old&gt;/&lt;new&gt;/</code></td>\n    <td>Replaces all occurrences of <code>&lt;old&gt;</code> with <code>&lt;new&gt;</code> using <a href=\"https://docs.python.org/3/library/stdtypes.html#str.replace\" rel=\"nofollow\"><code>str.replace()</code></a></td>\n    <td><code>{foo:Ro/()/}</code></td>\n    <td><code>F()()&nbsp;Bar</code></td>\n</tr>\n<tr>\n    <td><code>A&lt;op&gt;&lt;value&gt;/</code></td>\n    <td>Apply arithmetic operation <code>&lt;op&gt;</code> (<code>+</code>, <code>-</code>, <code>*</code>) to the current value</td>\n    <td><code>{num:A+1/}</code></td>\n    <td><code>\"2\"</code></td>\n</tr>\n<tr>\n    <td><code>C&lt;conversion(s)&gt;/</code></td>\n    <td>Apply <a href=\"#conversions\">Conversions</a> to the current value</td>\n    <td><code>{tags:CSgc/}</code></td>\n    <td><code>\"Sun-tree-water\"</code></td>\n</tr>\n<tr>\n    <td><code>S&lt;order&gt;/</code></td>\n    <td>Sort a list. <code>&lt;order&gt;</code> can be either <strong>a</strong>scending or <strong>d</strong>escending/<strong>r</strong>everse. (default: <strong>a</strong>)</td>\n    <td><code>{tags:Sd}</code></td>\n    <td><code>['water', 'tree', 'sun']</code></td>\n</tr>\n<tr>\n    <td><code>D&lt;format&gt;/</code></td>\n    <td>Parse a string value to a <code>datetime</code> object according to <a href=\"https://docs.python.org/3/library/datetime.html#strftime-and-strptime-format-codes\"><code>&lt;format&gt;</code></a></td>\n    <td><code>{updated:D%b %d %Y %I:%M %p/}</code></td>\n    <td><code>2010-01-01 00:00:00</code></td>\n</tr>\n<tr>\n    <td rowspan=\"2\"><code>O&lt;offset&gt;/</code></td>\n    <td rowspan=\"2\">Apply <code>&lt;offset&gt;</code> to a <code>datetime</code> object, either as <code>±HH:MM</code> or <code>local</code> for local UTC offset</td>\n    <td><code>{date:O-06:30/}</code></td>\n    <td><code>2009-12-31 17:30:00</code></td>\n</tr>\n<tr>\n    <td><code>{date:Olocal/}</code></td>\n    <td><code>2010-01-01 01:00:00</code></td>\n</tr>\n<tr>\n    <td><code>I</code></td>\n    <td>Return the current value as is.<br>Do not convert it to <code>str</code></td>\n    <td><code>{num:I}</code></td>\n    <td><code>1</code></td>\n</tr>\n</tbody>\n</table>\n\nAll special format specifiers (`?`, `L`, `J`, `R`, `D`, `O`, etc)\ncan be chained and combined with one another,\nbut must always appear before any standard format specifiers:\n\nFor example `{foo:?//RF/B/Ro/e/> 10}` -> `   Bee Bar`\n- `?//` - Tests if `foo` has a value\n- `RF/B/` - Replaces `F` with `B`\n- `Ro/e/` - Replaces `o` with `e`\n- `> 10` - Left-fills the string with spaces until it is 10 characters long\n\n\n## Global Replacement Fields\n\nReplacement field names that are available in all format strings.\n\n<table>\n<thead>\n<tr>\n    <th>Field Name</th>\n    <th>Description</th>\n    <th>Example</th>\n    <th>Result</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n    <td><code>_env</code></td>\n    <td>Environment variables</td>\n    <td><code>{_env[HOME]}</code></td>\n    <td><code>/home/john</code></td>\n</tr>\n<tr>\n    <td><code>_now</code></td>\n    <td>Current local date and time</td>\n    <td><code>{_now:%Y-%m}</code></td>\n    <td><code>2022-08</code></td>\n</tr>\n<tr>\n    <td><code>_nul</code></td>\n    <td>Universal <code>null</code> value</td>\n    <td><code>{date|_nul:%Y-%m}</code></td>\n    <td><code>None</code></td>\n</tr>\n<tr>\n    <td rowspan=\"2\"><code>_lit</code></td>\n    <td rowspan=\"2\">String literals</td>\n    <td><code>{_lit[foo]}</code></td>\n    <td><code>foo</code></td>\n</tr>\n<tr>\n    <td><code>{'bar'}</code></td>\n    <td><code>bar</code></td>\n</tr>\n</tbody>\n</table>\n\n\n## Special Type Format Strings\n\nStarting a format string with `\\f<Type> ` allows to set a different format string type than the default. Available ones are:\n\n<table>\n<thead>\n<tr>\n    <th>Type</th>\n    <th>Description</th>\n    <th width=\"32%\">Usage</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n    <td align=\"center\"><code>E</code></td>\n    <td>An arbitrary Python expression</td>\n    <td><code>\\fE title.upper().replace(' ', '-')</code></td>\n</tr>\n<tr>\n    <td align=\"center\"><code>F</code></td>\n    <td>An <a href=\"https://docs.python.org/3/tutorial/inputoutput.html#formatted-string-literals\">f-string</a> literal</td>\n    <td><code>\\fF '{title.strip()}' by {artist.capitalize()}</code></td>\n</tr>\n<tr>\n    <td align=\"center\"><code>J</code></td>\n    <td>A <a href=\"https://jinja.palletsprojects.com/\">Jinja</a> template</td>\n    <td><code>\\fJ '&#123;&#123;title | trim&#125;&#125;' by &#123;&#123;artist | capitalize&#125;&#125;</code></td>\n</tr>\n<tr>\n    <td align=\"center\"><code>T</code></td>\n    <td>Path to a template file containing a regular format string</td>\n    <td><code>\\fT ~/.templates/booru.txt</code></td>\n</tr>\n<tr>\n    <td align=\"center\"><code>TF</code></td>\n    <td>Path to a template file containing an <a href=\"https://docs.python.org/3/tutorial/inputoutput.html#formatted-string-literals\">f-string</a> literal</td>\n    <td><code>\\fTF ~/.templates/fstr.txt</code></td>\n</tr>\n<tr>\n    <td align=\"center\"><code>TJ</code></td>\n    <td>Path to a template file containing a <a href=\"https://jinja.palletsprojects.com/\">Jinja</a> template</td>\n    <td><code>\\fTF ~/.templates/jinja.txt</code></td>\n</tr>\n<tr>\n    <td align=\"center\"><code>M</code></td>\n    <td>Path or name of a Python module\n        followed by the name of one of its functions.\n        This function gets called with the current metadata dict as\n        argument and should return a string.</td>\n    <td><code>\\fM my_module:generate_text</code></td>\n</tr>\n</tbody>\n</table>\n"
  },
  {
    "path": "docs/gallery-dl-example.conf",
    "content": "{\n    \"extractor\":\n    {\n        \"base-directory\": \"~/gallery-dl/\",\n\n        \"#\": \"set global archive file for all extractors\",\n        \"archive\": \"~/gallery-dl/archive.sqlite3\",\n        \"archive-pragma\": [\"journal_mode=WAL\", \"synchronous=NORMAL\"],\n\n        \"#\": \"add two custom keywords into the metadata dictionary\",\n        \"#\": \"these can be used to further refine your output directories or filenames\",\n        \"keywords\": {\"bkey\": \"\", \"ckey\": \"\"},\n        \"#\": \"make sure that custom keywords are empty, i.e. they don't appear unless specified by the user\",\n        \"keywords-default\": \"\",\n\n        \"#\": \"replace invalid path characters with unicode alternatives\",\n        \"path-restrict\": {\n            \"\\\\\": \"⧹\",\n            \"/\" : \"⧸\",\n            \"|\" : \"￨\",\n            \":\" : \"꞉\",\n            \"*\" : \"∗\",\n            \"?\" : \"？\",\n            \"\\\"\": \"″\",\n            \"<\" : \"﹤\",\n            \">\" : \"﹥\"\n        },\n\n        \"#\": \"write tags for several *booru sites\",\n        \"postprocessors\": [\n            {\n                \"name\": \"metadata\",\n                \"mode\": \"tags\",\n                \"whitelist\": [\"danbooru\", \"moebooru\", \"sankaku\"]\n            }\n        ],\n\n        \"pixiv\":\n        {\n            \"#\": \"override global archive path for pixiv\",\n            \"archive\": \"~/gallery-dl/archive-pixiv.sqlite3\",\n\n            \"#\": \"set custom directory and filename format strings for all pixiv downloads\",\n            \"filename\": \"{id}{num}.{extension}\",\n            \"directory\": [\"Pixiv\", \"Works\", \"{user[id]}\"],\n            \"refresh-token\": \"aBcDeFgHiJkLmNoPqRsTuVwXyZ01234567890-FedC9\",\n\n            \"#\": \"transform ugoira into lossless MKVs\",\n            \"ugoira\": true,\n            \"postprocessors\": [\"ugoira-copy\"],\n\n            \"#\": \"use special settings for favorites and bookmarks\",\n            \"favorite\":\n            {\n                \"directory\": [\"Pixiv\", \"Favorites\", \"{user[id]}\"]\n            },\n            \"bookmark\":\n            {\n                \"directory\": [\"Pixiv\", \"My Bookmarks\"],\n                \"refresh-token\": \"01234567890aBcDeFgHiJkLmNoPqRsTuVwXyZ-ZyxW1\"\n            }\n        },\n\n        \"danbooru\":\n        {\n            \"ugoira\": true,\n            \"postprocessors\": [\"ugoira-webm\"]\n        },\n\n        \"exhentai\":\n        {\n            \"#\": \"use cookies instead of logging in with username and password\",\n            \"cookies\":\n            {\n                \"ipb_member_id\": \"12345\",\n                \"ipb_pass_hash\": \"1234567890abcdef\",\n                \"igneous\"      : \"123456789\",\n                \"hath_perks\"   : \"m1.m2.m3.a-123456789a\",\n                \"sk\"           : \"n4m34tv3574m2c4e22c35zgeehiw\",\n                \"sl\"           : \"dm_2\"\n            },\n\n            \"#\": \"wait 2 to 4.8 seconds between HTTP requests\",\n            \"sleep-request\": [2.0, 4.8],\n\n            \"filename\": \"{num:>04}_{name}.{extension}\",\n            \"directory\": [\"{category!c}\", \"{title}\"]\n        },\n\n        \"sankaku\":\n        {\n            \"#\": \"authentication with cookies is not possible for sankaku\",\n            \"username\": \"user\",\n            \"password\": \"#secret#\"\n        },\n\n        \"furaffinity\": {\n            \"#\": \"authentication with username and password is not possible due to CAPTCHA\",\n            \"cookies\": {\n                \"a\": \"01234567-89ab-cdef-fedc-ba9876543210\",\n                \"b\": \"fedcba98-7654-3210-0123-456789abcdef\"\n            },\n\n            \"descriptions\": \"html\",\n            \"postprocessors\": [\"content\"]\n        },\n\n        \"deviantart\":\n        {\n            \"#\": \"download 'gallery' and 'scraps' images for user profile URLs\",\n            \"include\": \"gallery,scraps\",\n\n            \"#\": \"use custom API credentials to avoid 429 errors\",\n            \"client-id\": \"98765\",\n            \"client-secret\": \"0123456789abcdef0123456789abcdef\",\n            \"refresh-token\": \"0123456789abcdef0123456789abcdef01234567\",\n\n            \"#\": \"put description texts into a separate directory\",\n            \"metadata\": true,\n            \"postprocessors\": [\n                {\n                    \"name\": \"metadata\",\n                    \"mode\": \"custom\",\n                    \"directory\"       : \"Descriptions\",\n                    \"content-format\"  : \"{description}\\n\",\n                    \"extension-format\": \"descr.txt\"\n                }\n            ]\n        },\n\n        \"kemonoparty\": {\n            \"postprocessors\": [\n                {\n                    \"name\": \"metadata\",\n                    \"event\": \"post\",\n                    \"filename\": \"{id} {title}.txt\",\n\n                    \"#\": \"write text content and external URLs\",\n                    \"mode\": \"custom\",\n                    \"format\": \"{content}\\n{embed[url]:?/\\n/}\",\n\n                    \"#\": \"onlx write file if there is an external link present\",\n                    \"filter\": \"embed.get('url') or re.search(r'(?i)(gigafile|xgf|1drv|mediafire|mega|google|drive)', content)\"\n                }\n            ]\n        },\n\n        \"flickr\":\n        {\n            \"access-token\": \"1234567890-abcdef\",\n            \"access-token-secret\": \"1234567890abcdef\",\n            \"size-max\": 1920\n        },\n\n        \"mangadex\":\n        {\n            \"#\": \"only download safe/suggestive chapters translated to English\",\n            \"lang\": \"en\",\n            \"ratings\": [\"safe\", \"suggestive\"],\n\n            \"#\": \"put chapters into '.cbz' archives\",\n            \"postprocessors\": [\"cbz\"]\n        },\n\n        \"reddit\":\n        {\n            \"#\": \"only spawn child extractors for links to specific sites\",\n            \"whitelist\": [\"imgur\", \"redgifs\"],\n\n            \"#\": \"put files from child extractors into the reddit directory\",\n            \"parent-directory\": true,\n\n            \"#\": \"transfer metadata to any child extractor as '_reddit'\",\n            \"parent-metadata\": \"_reddit\"\n        },\n\n        \"imgur\":\n        {\n            \"#\": \"general imgur settings\",\n            \"filename\": \"{id}.{extension}\"\n        },\n\n        \"reddit>imgur\":\n        {\n            \"#\": \"special settings for imgur URLs found in reddit posts\",\n            \"directory\": [],\n            \"filename\": \"{_reddit[id]} {_reddit[title]} {id}.{extension}\"\n        },\n\n        \"tumblr\":\n        {\n            \"posts\"   : \"all\",\n            \"external\": false,\n            \"reblogs\" : false,\n            \"inline\"  : true,\n\n            \"#\": \"use special settings when downloading liked posts\",\n            \"likes\":\n            {\n                \"posts\"   : \"video,photo,link\",\n                \"external\": true,\n                \"reblogs\" : true\n            }\n        },\n\n        \"twitter\":\n        {\n            \"#\": \"write text content for *all* tweets\",\n            \"postprocessors\": [\"content\"],\n            \"text-tweets\": true\n        },\n\n        \"ytdl\":\n        {\n            \"#\": \"enable 'ytdl' extractor\",\n            \"#\": \"i.e. invoke ytdl on all otherwise unsupported input URLs\",\n            \"enabled\": true,\n\n            \"#\": \"use yt-dlp instead of youtube-dl\",\n            \"module\": \"yt_dlp\",\n\n            \"#\": \"load ytdl options from config file\",\n            \"config-file\": \"~/yt-dlp.conf\"\n        },\n\n        \"mastodon\":\n        {\n            \"#\": \"add 'tabletop.social' as recognized mastodon instance\",\n            \"#\": \"(run 'gallery-dl oauth:mastodon:tabletop.social to get an access token')\",\n            \"tabletop.social\":\n            {\n                \"root\": \"https://tabletop.social\",\n                \"access-token\": \"513a36c6...\"\n            },\n\n            \"#\": \"set filename format strings for all 'mastodon' instances\",\n            \"directory\": [\"mastodon\", \"{instance}\", \"{account[username]!l}\"],\n            \"filename\" : \"{id}_{media[id]}.{extension}\"\n        },\n\n        \"foolslide\": {\n            \"#\": \"add two more foolslide instances\",\n            \"otscans\"  : {\"root\": \"https://otscans.com/foolslide\"},\n            \"helvetica\": {\"root\": \"https://helveticascans.com/r\" }\n        },\n\n        \"foolfuuka\": {\n            \"#\": \"add two other foolfuuka 4chan archives\",\n            \"fireden-onion\": {\"root\": \"http://ydt6jy2ng3s3xg2e.onion\"},\n            \"scalearchive\" : {\"root\": \"https://archive.scaled.team\"  }\n        },\n\n        \"gelbooru_v01\":\n        {\n            \"#\": \"add a custom gelbooru_v01 instance\",\n            \"#\": \"this is just an example, this specific instance is already included!\",\n            \"allgirlbooru\": {\"root\": \"https://allgirl.booru.org\"},\n\n            \"#\": \"the following options are used for all gelbooru_v01 instances\",\n            \"tag\":\n            {\n                \"directory\": {\n                    \"locals().get('bkey')\": [\"Booru\", \"AllGirlBooru\", \"Tags\", \"{bkey}\", \"{ckey}\", \"{search_tags}\"],\n                    \"\"                    : [\"Booru\", \"AllGirlBooru\", \"Tags\", \"_Unsorted\", \"{search_tags}\"]\n                }\n            },\n            \"post\":\n            {\n                \"directory\": [\"Booru\", \"AllGirlBooru\", \"Posts\"]\n            },\n            \"archive\": \"~/gallery-dl/custom-archive-file-for-gelbooru_v01_instances.db\",\n            \"filename\": \"{tags}_{id}_{md5}.{extension}\",\n            \"sleep-request\": [0, 1.2]\n        },\n\n        \"gelbooru_v02\":\n        {\n            \"#\": \"add a custom gelbooru_v02 instance\",\n            \"#\": \"this is just an example, this specific instance is already included!\",\n            \"tbib\":\n            {\n                \"root\": \"https://tbib.org\",\n                \"#\": \"some sites have different domains for API access\",\n                \"#\": \"use the 'api_root' option in addition to the 'root' setting here\"\n            }\n        },\n\n        \"tbib\": {\n            \"#\": \"the following options are only used for TBIB\",\n            \"#\": \"gelbooru_v02 has four subcategories at the moment, use custom directory settings for all of these\",\n            \"tag\":\n            {\n                \"directory\": {\n                    \"locals().get('bkey')\": [\"Other Boorus\", \"TBIB\", \"Tags\", \"{bkey}\", \"{ckey}\", \"{search_tags}\"],\n                    \"\"                    : [\"Other Boorus\", \"TBIB\", \"Tags\", \"_Unsorted\", \"{search_tags}\"]\n                }\n            },\n            \"pool\":\n            {\n                \"directory\": {\n                    \"locals().get('bkey')\": [\"Other Boorus\", \"TBIB\", \"Pools\", \"{bkey}\", \"{ckey}\", \"{pool}\"],\n                    \"\"                    : [\"Other Boorus\", \"TBIB\", \"Pools\", \"_Unsorted\", \"{pool}\"]\n                }\n            },\n            \"favorite\":\n            {\n                \"directory\": {\n                    \"locals().get('bkey')\": [\"Other Boorus\", \"TBIB\", \"Favorites\", \"{bkey}\", \"{ckey}\", \"{favorite_id}\"],\n                    \"\"                    : [\"Other Boorus\", \"TBIB\", \"Favorites\", \"_Unsorted\", \"{favorite_id}\"]\n                }\n            },\n            \"post\":\n            {\n                \"directory\": [\"Other Boorus\", \"TBIB\", \"Posts\"]\n            },\n            \"archive\": \"~/gallery-dl/custom-archive-file-for-TBIB.db\",\n            \"filename\": \"{id}_{md5}.{extension}\",\n            \"sleep-request\": [0, 1.2]\n        },\n\n        \"urlshortener\": {\n            \"tinyurl\": {\"root\": \"https://tinyurl.com\"}\n        }\n    },\n\n    \"downloader\":\n    {\n        \"#\": \"restrict download speed to 1 MB/s\",\n        \"rate\": \"1M\",\n\n        \"#\": \"show download progress indicator after 2 seconds\",\n        \"progress\": 2.0,\n\n        \"#\": \"retry failed downloads up to 3 times\",\n        \"retries\": 3,\n\n        \"#\": \"consider a download 'failed' after 8 seconds of inactivity\",\n        \"timeout\": 8.0,\n\n        \"#\": \"write '.part' files into a special directory\",\n        \"part-directory\": \"/tmp/.download/\",\n\n        \"#\": \"do not update file modification times\",\n        \"mtime\": false,\n\n        \"ytdl\":\n        {\n            \"#\": \"use yt-dlp instead of youtube-dl\",\n            \"module\": \"yt_dlp\"\n        }\n    },\n\n    \"output\":\n    {\n        \"log\": {\n            \"level\": \"info\",\n\n            \"#\": \"use different ANSI colors for each log level\",\n            \"format\": {\n                \"debug\"  : \"\\u001b[0;37m{name}: {message}\\u001b[0m\",\n                \"info\"   : \"\\u001b[1;37m{name}: {message}\\u001b[0m\",\n                \"warning\": \"\\u001b[1;33m{name}: {message}\\u001b[0m\",\n                \"error\"  : \"\\u001b[1;31m{name}: {message}\\u001b[0m\"\n            }\n        },\n\n        \"#\": \"shorten filenames to fit into one terminal line\",\n        \"#\": \"while also considering wider East-Asian characters\",\n        \"shorten\": \"eaw\",\n\n        \"#\": \"enable ANSI escape sequences on Windows\",\n        \"ansi\": true,\n\n        \"#\": \"write logging messages to a separate file\",\n        \"logfile\": {\n            \"path\": \"~/gallery-dl/log.txt\",\n            \"mode\": \"w\",\n            \"level\": \"debug\"\n        },\n\n        \"#\": \"write unrecognized URLs to a separate file\",\n        \"unsupportedfile\": {\n            \"path\": \"~/gallery-dl/unsupported.txt\",\n            \"mode\": \"a\",\n            \"format\": \"{asctime} {message}\",\n            \"format-date\": \"%Y-%m-%d-%H-%M-%S\"\n        }\n    },\n\n    \"postprocessor\":\n    {\n        \"#\": \"write 'content' metadata into separate files\",\n        \"content\":\n        {\n            \"name\" : \"metadata\",\n\n            \"#\": \"write data for every post instead of each individual file\",\n            \"event\": \"post\",\n            \"filename\": \"{post_id|tweet_id|id}.txt\",\n\n            \"#\": \"write only the values for 'content' or 'description'\",\n            \"mode\" : \"custom\",\n            \"format\": \"{content|description}\\n\"\n        },\n\n        \"#\": \"put files into a '.cbz' archive\",\n        \"cbz\":\n        {\n            \"name\": \"zip\",\n            \"extension\": \"cbz\"\n        },\n\n        \"#\": \"various ugoira post processor configurations to create different file formats\",\n        \"ugoira-webm\":\n        {\n            \"name\": \"ugoira\",\n            \"extension\": \"webm\",\n            \"ffmpeg-args\": [\"-c:v\", \"libvpx-vp9\", \"-an\", \"-b:v\", \"0\", \"-crf\", \"30\"],\n            \"ffmpeg-twopass\": true,\n            \"ffmpeg-demuxer\": \"image2\"\n        },\n        \"ugoira-mp4\":\n        {\n            \"name\": \"ugoira\",\n            \"extension\": \"mp4\",\n            \"ffmpeg-args\": [\"-c:v\", \"libx264\", \"-an\", \"-b:v\", \"4M\", \"-preset\", \"veryslow\"],\n            \"ffmpeg-twopass\": true,\n            \"libx264-prevent-odd\": true\n        },\n        \"ugoira-gif\":\n        {\n            \"name\": \"ugoira\",\n            \"extension\": \"gif\",\n            \"ffmpeg-args\": [\"-filter_complex\", \"[0:v] split [a][b];[a] palettegen [p];[b][p] paletteuse\"]\n        },\n        \"ugoira-copy\": {\n            \"name\": \"ugoira\",\n            \"extension\": \"mkv\",\n            \"ffmpeg-args\": [\"-c\", \"copy\"],\n            \"libx264-prevent-odd\": false,\n            \"repeat-last-frame\": false\n        }\n    },\n\n    \"#\": \"use a custom cache file location\",\n    \"cache\": {\n        \"file\": \"~/gallery-dl/cache.sqlite3\"\n    }\n}\n"
  },
  {
    "path": "docs/gallery-dl.conf",
    "content": "{\n    \"#\": \"gallery-dl default configuration file\",\n\n    \"#\": \"full documentation at\",\n    \"#\": \"https://gdl-org.github.io/docs/configuration.html\",\n\n    \"extractor\":\n    {\n        \"#\": \"===============================================================\",\n        \"#\": \"====    General Extractor Options    ==========================\",\n        \"#\": \"(these can be set as site-specific extractor options as well)  \",\n\n        \"base-directory\": \"./gallery-dl/\",\n        \"postprocessors\": null,\n        \"skip\"          : true,\n        \"skip-filter\"   : null,\n        \"follow\"        : null,\n\n        \"user-agent\"    : \"auto\",\n        \"referer\"       : true,\n        \"headers\"       : {},\n        \"ciphers\"       : null,\n        \"tls12\"         : true,\n        \"browser\"       : null,\n        \"geo-bypass\"    : null,\n        \"proxy\"         : null,\n        \"proxy-env\"     : true,\n        \"source-address\": null,\n        \"retries\"       : 4,\n        \"retry-codes\"   : [],\n        \"timeout\"       : 30.0,\n        \"verify\"        : true,\n        \"truststore\"    : false,\n        \"download\"      : true,\n        \"fallback\"      : true,\n\n        \"archive\"       : null,\n        \"archive-format\": null,\n        \"archive-prefix\": null,\n        \"archive-pragma\": [],\n        \"archive-event\" : [\"file\"],\n        \"archive-mode\"  : \"file\",\n        \"archive-table\" : null,\n\n        \"cookies\": null,\n        \"cookies-select\": null,\n        \"cookies-update\": true,\n\n        \"file-filter\" : null,\n        \"file-range\"  : null,\n        \"file-unique\" : false,\n        \"post-filter\" : null,\n        \"post-range\"  : null,\n        \"child-filter\": null,\n        \"child-range\" : null,\n        \"child-unique\": false,\n\n        \"blacklist\"     : null,\n        \"whitelist\"     : null,\n        \"tags-blacklist\": null,\n        \"tags-whitelist\": null,\n        \"date-before\"   : null,\n        \"date-after\"    : null,\n\n        \"keywords\"          : {},\n        \"keywords-default\"  : null,\n        \"keywords-eval\"     : false,\n        \"keywords-global\"   : {},\n\n        \"parent\"          : false,\n        \"parent-directory\": false,\n        \"parent-metadata\" : false,\n        \"parent-session\"  : false,\n        \"parent-skip\"     : false,\n\n        \"path-restrict\": \"auto\",\n        \"path-replace\" : \"_\",\n        \"path-remove\"  : \"\\\\u0000-\\\\u001f\\\\u007f\",\n        \"path-strip\"   : \"auto\",\n        \"path-convert\" : null,\n        \"path-extended\": true,\n\n        \"metadata-extractor\": \"_extr\",\n        \"metadata-parent\"   : \"_parent\",\n        \"metadata-path\"     : \"_path\",\n        \"metadata-url\"      : \"_url\",\n        \"metadata-http\"     : null,\n        \"metadata-version\"  : null,\n\n        \"sleep\"          : 0,\n        \"sleep-skip\"     : 0,\n        \"sleep-request\"  : 0,\n        \"sleep-extractor\": 0,\n        \"sleep-retries\"  : \"lin=1\",\n        \"sleep-429\"      : 60.0,\n\n        \"actions\": [],\n        \"init\"   : \"lazy\",\n        \"input\"  : null,\n        \"netrc\"  : false,\n        \"extension-map\": {\n            \"jpeg\": \"jpg\",\n            \"jpe\" : \"jpg\",\n            \"jfif\": \"jpg\",\n            \"jif\" : \"jpg\",\n            \"jfi\" : \"jpg\"\n        },\n\n        \"category-map\": {},\n        \"config-map\": {\n            \"coomerparty\"  : \"coomer\",\n            \"kemonoparty\"  : \"kemono\",\n            \"giantessbooru\": \"sizebooru\",\n            \"koharu\"       : \"schalenetwork\",\n            \"chzzk\"        : \"naver-chzzk\",\n            \"naver\"        : \"naver-blog\",\n            \"naverwebtoon\" : \"naver-webtoon\",\n            \"pixiv\"        : \"pixiv-novel\",\n            \"saint\"        : \"turbo\"\n        },\n\n\n\n        \"#\": \"===============================================================\",\n        \"#\": \"====    Site-specific Extractor Options    ====================\",\n\n        \"ao3\":\n        {\n            \"username\": \"\",\n            \"password\": \"\",\n            \"sleep-request\": \"0.5-1.5\",\n\n            \"formats\": [\"pdf\"]\n        },\n        \"arcalive\":\n        {\n            \"sleep-request\": \"0.5-1.5\",\n\n            \"emoticons\": false,\n            \"gifs\"     : true\n        },\n        \"artstation\":\n        {\n            \"external\" : false,\n            \"max-posts\": null,\n            \"mviews\"   : true,\n            \"previews\" : false,\n            \"videos\"   : true,\n\n            \"search\": {\n                \"pro-first\": true\n            }\n        },\n        \"aryion\":\n        {\n            \"username\": \"\",\n            \"password\": \"\",\n\n            \"recursive\": true\n        },\n        \"bbc\":\n        {\n            \"width\": 1920\n        },\n        \"behance\":\n        {\n            \"sleep-request\": \"2.0-4.0\",\n\n            \"modules\": [\"image\", \"video\", \"mediacollection\", \"embed\"]\n        },\n        \"bellazon\":\n        {\n            \"order-posts\": \"desc\",\n            \"quoted\"     : false\n        },\n        \"bilibili\":\n        {\n            \"sleep-request\": \"3.0-6.0\",\n\n            \"livephoto\": true\n        },\n        \"bluesky\":\n        {\n            \"username\": \"\",\n            \"password\": \"\",\n\n            \"api-server\": null,\n            \"include\"   : [\"media\"],\n            \"metadata\"  : false,\n            \"quoted\"    : false,\n            \"reposts\"   : false,\n            \"videos\"    : true,\n\n            \"likes\": {\n                \"depth\"   : 0,\n                \"endpoint\": \"listRecords\"\n            },\n            \"post\": {\n                \"depth\": 0\n            }\n        },\n        \"boosty\":\n        {\n            \"allowed\" : true,\n            \"bought\"  : false,\n            \"metadata\": false,\n            \"videos\"  : true\n        },\n        \"booth\":\n        {\n            \"sleep-request\": \"0.5-1.5\",\n\n            \"strategy\": \"webpage\"\n        },\n        \"bunkr\":\n        {\n            \"endpoint\": \"/api/_001_v2\",\n            \"tlds\": false\n        },\n        \"cien\":\n        {\n            \"sleep-request\": \"1.0-2.0\",\n            \"files\": [\"image\", \"video\", \"download\", \"gallery\"]\n        },\n        \"civitai\":\n        {\n            \"api-key\": null,\n            \"sleep-request\": \"0.5-1.5\",\n\n            \"api\"     : \"trpc\",\n            \"files\"   : [\"image\"],\n            \"include\" : [\"user-images\", \"user-videos\"],\n            \"metadata\": false,\n            \"nsfw\"    : true,\n            \"period\"  : \"AllTime\",\n            \"sort\"    : \"Newest\",\n            \"quality\" : \"original=true\",\n            \"quality-videos\": \"original=true,quality=100\"\n        },\n        \"comick\":\n        {\n            \"lang\": \"\"\n        },\n        \"coomer\":\n        {\n            \"username\": \"\",\n            \"password\": \"\",\n\n            \"announcements\": false,\n            \"comments\"     : false,\n            \"dms\"          : false,\n            \"duplicates\"   : false,\n            \"favorites\"    : \"artist\",\n            \"files\"        : [\"file\", \"attachments\", \"inline\"],\n            \"max-posts\"    : null,\n            \"metadata\"     : false,\n            \"revisions\"    : false,\n            \"order-revisions\": \"desc\"\n        },\n        \"cyberdrop\":\n        {\n            \"domain\": null\n        },\n        \"cyberfile\":\n        {\n            \"password\" : \"\",\n            \"recursive\": true\n        },\n        \"dankefuerslesen\":\n        {\n            \"zip\": false\n        },\n        \"deviantart\":\n        {\n            \"client-id\"    : null,\n            \"client-secret\": null,\n            \"refresh-token\": null,\n\n            \"auto-watch\"      : false,\n            \"auto-unwatch\"    : false,\n            \"comments\"        : false,\n            \"comments-avatars\": false,\n            \"extra\"           : false,\n            \"flat\"            : true,\n            \"folders\"         : false,\n            \"group\"           : true,\n            \"include\"         : \"gallery\",\n            \"intermediary\"    : true,\n            \"journals\"        : \"html\",\n            \"jwt\"             : false,\n            \"mature\"          : true,\n            \"metadata\"        : false,\n            \"original\"        : false,\n            \"pagination\"      : \"api\",\n            \"previews\"        : false,\n            \"public\"          : true,\n            \"quality\"         : 100,\n            \"wait-min\"        : 0,\n\n            \"avatar\": {\n                \"formats\": null\n            },\n            \"folder\": {\n                \"subfolders\": true\n            }\n        },\n        \"discord\":\n        {\n            \"embeds\" : [\"image\", \"gifv\", \"video\"],\n            \"threads\": true,\n            \"token\"  : \"\"\n        },\n        \"dynastyscans\":\n        {\n            \"anthology\": {\n                \"metadata\": false\n            }\n        },\n        \"erome\":\n        {\n            \"user\": {\n                \"parent\" : true,\n                \"reposts\": false\n            }\n        },\n        \"exhentai\":\n        {\n            \"username\": \"\",\n            \"password\": \"\",\n            \"cookies\" : null,\n            \"sleep-request\": \"3.0-6.0\",\n\n            \"domain\"  : \"auto\",\n            \"fav\"     : null,\n            \"gp\"      : \"resized\",\n            \"metadata\": false,\n            \"original\": true,\n            \"source\"  : null,\n            \"tags\"    : true,\n            \"limits\"  : null,\n            \"limits-action\"   : \"stop\",\n            \"fallback-retries\": 2\n        },\n        \"facebook\":\n        {\n            \"cookies\": null,\n\n            \"author-followups\": false,\n            \"include\": \"photos\",\n            \"loop\"   : false,\n            \"videos\" : true\n        },\n        \"fanbox\":\n        {\n            \"cookies\" : null,\n\n            \"comments\": false,\n            \"embeds\"  : true,\n            \"fee-max\" : null,\n            \"metadata\": false,\n\n            \"creator\": {\n                \"offset\": 0\n            }\n        },\n        \"fansly\":\n        {\n            \"token\": \"\",\n\n            \"formats\" : null,\n            \"previews\": true\n        },\n        \"flickr\":\n        {\n            \"access-token\"       : null,\n            \"access-token-secret\": null,\n            \"sleep-request\"      : \"1.0-2.0\",\n\n            \"contexts\": false,\n            \"exif\"    : false,\n            \"info\"    : false,\n            \"metadata\": false,\n            \"profile\" : false,\n            \"size-max\": null,\n            \"videos\"  : true\n        },\n        \"foriio\":\n        {\n            \"audio\"   : true,\n            \"external\": true,\n            \"posts\"   : null,\n            \"previews\": false,\n            \"videos\"  : true\n        },\n        \"furaffinity\":\n        {\n            \"cookies\"      : null,\n            \"sleep-request\": \"1.0\",\n\n            \"descriptions\": \"text\",\n            \"external\"    : false,\n            \"include\"     : [\"gallery\"],\n            \"layout\"      : \"auto\"\n        },\n        \"gelbooru\":\n        {\n            \"api-key\": null,\n            \"user-id\": null,\n\n            \"favorite\": {\n                \"order-posts\": \"desc\"\n            }\n        },\n        \"generic\":\n        {\n            \"enabled\": false\n        },\n        \"girlswithmuscle\":\n        {\n            \"username\": \"\",\n            \"password\": \"\"\n        },\n        \"gofile\":\n        {\n            \"api-token\": null,\n            \"website-token\": null,\n            \"recursive\": true\n        },\n        \"hdoujin\":\n        {\n            \"crt\"  : \"\",\n            \"token\": \"\",\n            \"sleep-request\": \"0.5-1.5\",\n\n            \"cbz\"   : false,\n            \"format\": [\"0\", \"1600\", \"1280\", \"980\", \"780\"],\n            \"tags\"  : true\n        },\n        \"hentaifoundry\":\n        {\n            \"descriptions\": \"text\",\n            \"include\": [\"pictures\"]\n        },\n        \"hitomi\":\n        {\n            \"format\": \"webp\"\n        },\n        \"idolcomplex\":\n        {\n            \"username\": \"\",\n            \"password\": \"\",\n\n            \"refresh\" : false,\n            \"tags\"    : false\n        },\n        \"imagechest\":\n        {\n            \"access-token\": null\n        },\n        \"imagefap\":\n        {\n            \"sleep-request\": \"2.0-4.0\"\n        },\n        \"imgbb\":\n        {\n            \"username\": \"\",\n            \"password\": \"\"\n        },\n        \"imgur\":\n        {\n            \"client-id\": null,\n            \"mp4\": true\n        },\n        \"inkbunny\":\n        {\n            \"username\": \"\",\n            \"password\": \"\",\n            \"orderby\": \"create_datetime\"\n        },\n        \"instagram\":\n        {\n            \"cookies\": null,\n            \"sleep-request\": \"6.0-12.0\",\n\n            \"api\"        : \"rest\",\n            \"cursor\"     : true,\n            \"include\"    : \"posts\",\n            \"max-posts\"  : null,\n            \"metadata\"   : false,\n            \"order-files\": \"asc\",\n            \"order-posts\": \"asc\",\n            \"previews\"   : false,\n            \"static-videos\": true,\n            \"user-cache\"   : \"disk\",\n            \"user-strategy\": [\"search\", \"web\"],\n            \"videos\"     : true,\n            \"warn-images\": true,\n            \"warn-videos\": true,\n\n            \"stories\": {\n                \"split\": false\n            }\n        },\n        \"itaku\":\n        {\n            \"sleep-request\": \"0.5-1.5\",\n            \"include\": \"gallery\",\n            \"order\"  : \"desc\",\n            \"videos\" : true\n        },\n        \"iwara\":\n        {\n            \"username\": \"\",\n            \"password\": \"\",\n\n            \"format\" : null,\n            \"include\": [\"user-images\", \"user-videos\"]\n        },\n        \"joyreactor\":\n        {\n            \"embeds\"  : false,\n            \"formats\" : [\"webm\"],\n            \"metadata\": false,\n            \"videos\"  : true\n        },\n        \"kemono\":\n        {\n            \"username\": \"\",\n            \"password\": \"\",\n\n            \"announcements\": false,\n            \"archives\"     : false,\n            \"archives-format\": \"list\",\n            \"comments\"     : false,\n            \"dms\"          : false,\n            \"duplicates\"   : false,\n            \"endpoint\"     : \"posts\",\n            \"favorites\"    : \"artist\",\n            \"files\"        : [\"attachments\", \"file\", \"inline\"],\n            \"max-posts\"    : null,\n            \"metadata\"     : true,\n            \"revisions\"    : false,\n            \"order-revisions\": \"desc\",\n\n            \"discord\": {\n                \"order-posts\": \"asc\"\n            }\n        },\n        \"khinsider\":\n        {\n            \"covers\": false,\n            \"format\": \"mp3\"\n        },\n        \"koofr\":\n        {\n            \"recursive\": true\n        },\n        \"luscious\":\n        {\n            \"gif\": false\n        },\n        \"madokami\":\n        {\n            \"username\": \"\",\n            \"password\": \"\"\n        },\n        \"mangadex\":\n        {\n            \"client-id\"    : \"\",\n            \"client-secret\": \"\",\n            \"username\": \"\",\n            \"password\": \"\",\n\n            \"api-server\": \"https://api.mangadex.org\",\n            \"api-parameters\": null,\n            \"covers\": false,\n            \"data-saver\": false,\n            \"lang\": null,\n            \"ratings\": [\"safe\", \"suggestive\", \"erotica\", \"pornographic\"]\n        },\n        \"mangafire\":\n        {\n            \"lang\": \"en\"\n        },\n        \"mangareader\":\n        {\n            \"lang\": \"en\"\n        },\n        \"mangoxo\":\n        {\n            \"username\": \"\",\n            \"password\": \"\"\n        },\n        \"naver-blog\":\n        {\n            \"videos\": true\n        },\n        \"naver-chzzk\":\n        {\n            \"offset\": 0\n        },\n        \"newgrounds\":\n        {\n            \"username\": \"\",\n            \"password\": \"\",\n            \"sleep-request\": \"0.5-1.5\",\n\n            \"flash\"  : true,\n            \"format\" : \"original\",\n            \"include\": [\"art\"]\n        },\n        \"nsfwalbum\":\n        {\n            \"referer\": false\n        },\n        \"oauth\":\n        {\n            \"browser\": true,\n            \"cache\"  : true,\n            \"host\"   : \"localhost\",\n            \"port\"   : 6414\n        },\n        \"paheal\":\n        {\n            \"metadata\": false\n        },\n        \"patreon\":\n        {\n            \"cookies\": null,\n\n            \"cursor\"       : true,\n            \"files\"        : [\"images\", \"image_large\", \"attachments\", \"postfile\", \"content\"],\n            \"format-images\": \"download_url\",\n            \"order-posts\"  : \"desc\",\n\n            \"collection\": {\n                \"order-posts\": \"asc\"\n            },\n            \"user\": {\n                \"date-max\" : 0\n            }\n        },\n        \"pexels\":\n        {\n            \"sleep-request\": \"1.0-2.0\"\n        },\n        \"pillowfort\":\n        {\n            \"username\": \"\",\n            \"password\": \"\",\n\n            \"external\": false,\n            \"inline\"  : true,\n            \"reblogs\" : false\n        },\n        \"pinterest\":\n        {\n            \"domain\"  : \"auto\",\n            \"sections\": true,\n            \"stories\" : true,\n            \"videos\"  : true\n        },\n        \"pixeldrain\":\n        {\n            \"api-key\"  : null,\n            \"recursive\": true,\n            \"zip\"      : false\n        },\n        \"pixiv\":\n        {\n            \"refresh-token\": null,\n            \"cookies\"      : null,\n\n            \"captions\" : false,\n            \"comments\" : false,\n            \"include\"  : [\"artworks\"],\n            \"max-posts\": null,\n            \"metadata\" : false,\n            \"metadata-bookmark\": false,\n            \"sanity\"   : true,\n            \"tags\"     : \"japanese\",\n            \"ugoira\"   : true\n        },\n        \"pixiv-novel\":\n        {\n            \"refresh-token\": null,\n\n            \"comments\" : false,\n            \"max-posts\": null,\n            \"metadata\" : false,\n            \"metadata-bookmark\": false,\n            \"tags\"     : \"japanese\",\n\n            \"covers\"     : false,\n            \"embeds\"     : false,\n            \"full-series\": false\n        },\n        \"plurk\":\n        {\n            \"sleep-request\": \"0.5-1.5\",\n            \"comments\": false\n        },\n        \"poipiku\":\n        {\n            \"cookies\": null,\n            \"sleep-request\": \"0.5-1.5\"\n        },\n        \"pornpics\":\n        {\n            \"sleep-request\": \"0.5-1.5\"\n        },\n        \"readcomiconline\":\n        {\n            \"sleep-request\": \"3.0-6.0\",\n\n            \"captcha\": \"stop\",\n            \"quality\": \"auto\"\n        },\n        \"reddit\":\n        {\n            \"cookies\"      : null,\n            \"client-id\"    : null,\n            \"user-agent\"   : null,\n            \"refresh-token\": null,\n\n            \"api\"         : \"rest\",\n            \"comments\"    : 0,\n            \"morecomments\": false,\n            \"embeds\"      : true,\n            \"date-min\"    : 0,\n            \"date-max\"    : 253402210800,\n            \"id-min\"      : null,\n            \"id-max\"      : null,\n            \"limit\"       : null,\n            \"previews\"    : true,\n            \"recursion\"   : 0,\n            \"selftext\"    : null,\n            \"videos\"      : \"dash\",\n\n            \"user\": {\n                \"only\": true\n            },\n            \"user-saved\": {\n                \"only\": false\n            },\n            \"user-upvoted\": {\n                \"only\": false\n            },\n            \"user-downvoted\": {\n                \"only\": false\n            }\n        },\n        \"redgifs\":\n        {\n            \"format\": [\"hd\", \"sd\", \"gif\"]\n        },\n        \"rule34\":\n        {\n            \"api-key\": null,\n            \"user-id\": null,\n            \"sleep-request\": \"1.0\"\n        },\n        \"rule34xyz\":\n        {\n            \"username\": \"\",\n            \"password\": \"\",\n\n            \"format\": [\"10\", \"40\", \"41\", \"2\"]\n        },\n        \"sankaku\":\n        {\n            \"username\": \"\",\n            \"password\": \"\",\n\n            \"refresh\" : false,\n            \"tags\"    : false\n        },\n        \"sankakucomplex\":\n        {\n            \"embeds\": false,\n            \"videos\": true\n        },\n        \"schalenetwork\":\n        {\n            \"crt\"  : \"\",\n            \"token\": \"\",\n            \"sleep-request\": \"0.5-1.5\",\n\n            \"cbz\"   : false,\n            \"format\": [\"0\", \"1600\", \"1280\", \"980\", \"780\"],\n            \"tags\"  : true\n        },\n        \"scrolller\":\n        {\n            \"username\": \"\",\n            \"password\": \"\",\n            \"sleep-request\": \"0.5-1.5\"\n        },\n        \"sexcom\":\n        {\n            \"gifs\": true\n        },\n        \"sizebooru\":\n        {\n            \"sleep-request\": \"0.5-1.5\",\n\n            \"metadata\": false\n        },\n        \"skeb\":\n        {\n            \"article\"      : false,\n            \"include\"      : [\"works\"],\n            \"sent-requests\": false,\n            \"thumbnails\"   : false,\n\n            \"search\": {\n                \"filters\": null\n            }\n        },\n        \"smugmug\":\n        {\n            \"access-token\"       : null,\n            \"access-token-secret\": null,\n\n            \"videos\": true\n        },\n        \"soundgasm\":\n        {\n            \"sleep-request\": \"0.5-1.5\"\n        },\n        \"steamgriddb\":\n        {\n            \"animated\"  : true,\n            \"epilepsy\"  : true,\n            \"humor\"     : true,\n            \"dimensions\": \"all\",\n            \"file-types\": \"all\",\n            \"languages\" : \"all,\",\n            \"nsfw\"      : true,\n            \"sort\"      : \"score_desc\",\n            \"static\"    : true,\n            \"styles\"    : \"all\",\n            \"untagged\"  : true,\n            \"download-fake-png\": true\n        },\n        \"seiga\":\n        {\n            \"username\": \"\",\n            \"password\": \"\",\n            \"cookies\" : null\n        },\n        \"subscribestar\":\n        {\n            \"username\": \"\",\n            \"password\": \"\"\n        },\n        \"tapas\":\n        {\n            \"username\": \"\",\n            \"password\": \"\"\n        },\n        \"tenor\":\n        {\n            \"format\": [\"gif\", \"mp4\", \"webm\", \"webp\"]\n        },\n        \"thehentaiworld\":\n        {\n            \"sleep-request\": \"0.5-1.5\"\n        },\n        \"tiktok\":\n        {\n            \"audio\"    : true,\n            \"covers\"   : false,\n            \"photos\"   : true,\n            \"subtitles\": false,\n            \"videos\"   : true,\n            \"tiktok-range\": \"\",\n\n            \"posts\": {\n                \"order-posts\": \"desc\",\n                \"ytdl\"  : false,\n                \"module\": null\n            },\n            \"user\": {\n                \"include\": [\"avatar\", \"posts\"]\n            }\n        },\n        \"tumblr\":\n        {\n            \"access-token\"       : null,\n            \"access-token-secret\": null,\n\n            \"avatar\"    : false,\n            \"date-min\"  : 0,\n            \"date-max\"  : null,\n            \"external\"  : false,\n            \"inline\"    : true,\n            \"offset\"    : 0,\n            \"original\"  : true,\n            \"pagination\": null,\n            \"posts\"     : \"all\",\n            \"ratelimit\" : \"abort\",\n            \"reblogs\"   : true,\n            \"fallback-delay\"  : 120.0,\n            \"fallback-retries\": 2\n        },\n        \"tumblrgallery\":\n        {\n            \"referer\": false\n        },\n        \"twitter\":\n        {\n            \"username\"    : \"\",\n            \"password\"    : \"\",\n            \"cookies\"     : null,\n\n            \"ads\"         : false,\n            \"articles\"    : true,\n            \"cards\"       : false,\n            \"cards-blacklist\": [],\n            \"csrf\"        : \"cookies\",\n            \"cursor\"      : true,\n            \"expand\"      : false,\n            \"include\"     : [\"timeline\"],\n            \"limit\"       : 50,\n            \"locked\"      : \"abort\",\n            \"logout\"      : true,\n            \"metadata-user\": false,\n            \"pinned\"      : false,\n            \"previews\"    : false,\n            \"quoted\"      : false,\n            \"ratelimit\"   : \"wait\",\n            \"replies\"     : true,\n            \"retries-api\" : 9,\n            \"retweets\"    : false,\n            \"search-limit\": 20,\n            \"search-pagination\": \"max_id\",\n            \"search-results\"   : \"latest\",\n            \"search-stop\" : 3,\n            \"size\"        : [\"orig\", \"4096x4096\", \"large\", \"medium\", \"small\"],\n            \"text-tweets\" : false,\n            \"tweet-endpoint\": \"auto\",\n            \"transform\"   : true,\n            \"twitpic\"     : false,\n            \"unavailable\" : false,\n            \"unique\"      : true,\n            \"users\"       : \"user\",\n            \"videos\"      : true,\n\n            \"timeline\": {\n                \"strategy\": \"auto\"\n            },\n            \"tweet\": {\n                \"conversations\": false\n            }\n        },\n        \"unsplash\":\n        {\n            \"format\": \"raw\"\n        },\n        \"urlgalleries\":\n        {\n            \"sleep-request\": \"0.5-1.5\",\n            \"parent\": true\n        },\n        \"vipergirls\":\n        {\n            \"username\": \"\",\n            \"password\": \"\",\n            \"sleep-request\": \"0.5\",\n\n            \"domain\"     : \"viper.click\",\n            \"like\"       : false,\n            \"order-posts\": \"desc\"\n        },\n        \"vk\":\n        {\n            \"sleep-request\": \"0.5-1.5\",\n            \"offset\": 0\n        },\n        \"vsco\":\n        {\n            \"include\": [\"gallery\"],\n            \"videos\" : true\n        },\n        \"wallhaven\":\n        {\n            \"api-key\" : null,\n            \"sleep-request\": \"1.4\",\n\n            \"include\" : [\"uploads\"],\n            \"metadata\": false\n        },\n        \"weasyl\":\n        {\n            \"api-key\" : null,\n            \"metadata\": false\n        },\n        \"webtoons\":\n        {\n            \"sleep-request\": \"0.5-1.5\",\n\n            \"bgm\"       : true,\n            \"quality\"   : \"original\",\n            \"banners\"   : false,\n            \"thumbnails\": false\n        },\n        \"weebcentral\":\n        {\n            \"sleep-request\": \"0.5-1.5\"\n        },\n        \"weebdex\":\n        {\n            \"data-saver\": false,\n            \"lang\"      : \"en\"\n        },\n        \"weibo\":\n        {\n            \"sleep-request\": \"1.0-2.0\",\n\n            \"gifs\"     : true,\n            \"include\"  : [\"feed\"],\n            \"livephoto\": true,\n            \"movies\"   : false,\n            \"retweets\" : false,\n            \"text\"     : false,\n            \"videos\"   : true,\n\n            \"album\": {\n                \"subalbums\": false\n            }\n        },\n        \"xfolio\":\n        {\n            \"sleep-request\": \"0.5-1.5\"\n        },\n        \"ytdl\":\n        {\n            \"cmdline-args\": null,\n            \"config-file\" : null,\n            \"deprecations\": false,\n            \"enabled\"     : false,\n            \"format\"      : null,\n            \"generic\"     : true,\n            \"generic-category\": true,\n            \"logging\"     : true,\n            \"module\"      : null,\n            \"raw-options\" : null\n        },\n        \"zerochan\":\n        {\n            \"username\": \"\",\n            \"password\": \"\",\n            \"sleep-request\": \"0.5-1.5\",\n\n            \"metadata\"  : false,\n            \"pagination\": \"api\",\n            \"redirects\" : false\n        },\n\n\n        \"#\": \"===============================================================\",\n        \"#\": \"====    Base-Extractor and Instance Options    ================\",\n\n        \"blogger\":\n        {\n            \"api-key\": null,\n            \"videos\" : true\n        },\n\n        \"chevereto\":\n        {\n            \"parent\"  : true,\n            \"password\": \"\"\n        },\n\n        \"Danbooru\":\n        {\n            \"sleep-request\": \"0.5-1.5\",\n\n            \"external\" : false,\n            \"metadata\" : false,\n            \"threshold\": \"auto\",\n            \"ugoira\"   : false,\n\n            \"favgroup\": {\n                \"order-posts\": \"pool\"\n            },\n            \"pool\": {\n                \"order-posts\": \"pool\"\n            }\n        },\n        \"danbooru\":\n        {\n            \"username\": \"\",\n            \"password\": \"\"\n        },\n        \"atfbooru\":\n        {\n            \"username\": \"\",\n            \"password\": \"\"\n        },\n        \"aibooru\":\n        {\n            \"username\": \"\",\n            \"password\": \"\"\n        },\n        \"booruvar\":\n        {\n            \"username\": \"\",\n            \"password\": \"\"\n        },\n\n        \"E621\":\n        {\n            \"sleep-request\": \"0.5-1.5\",\n\n            \"metadata\" : false,\n            \"threshold\": \"auto\"\n        },\n        \"e621\":\n        {\n            \"username\": \"\",\n            \"password\": \"\"\n        },\n        \"e926\":\n        {\n            \"username\": \"\",\n            \"password\": \"\"\n        },\n        \"e6ai\":\n        {\n            \"username\": \"\",\n            \"password\": \"\"\n        },\n\n        \"foolfuuka\":\n        {\n            \"sleep-request\": \"0.5-1.5\"\n        },\n        \"archivedmoe\":\n        {\n            \"referer\": false\n        },\n\n        \"imagehost\":\n        {\n            \"parent\": true\n        },\n\n        \"mastodon\":\n        {\n            \"access-token\": null,\n            \"cards\"       : false,\n            \"reblogs\"     : false,\n            \"replies\"     : true,\n            \"text-posts\"  : false\n        },\n\n        \"misskey\":\n        {\n            \"access-token\": null,\n            \"date-min\"    : null,\n            \"date-max\"    : null,\n            \"include\"     : [\"notes\"],\n            \"order-posts\" : \"desc\",\n            \"renotes\"     : false,\n            \"replies\"     : true,\n            \"text-posts\"  : false\n        },\n\n        \"Nijie\":\n        {\n            \"sleep-request\": \"2.0-4.0\",\n            \"include\" : [\"illustration\", \"doujin\"]\n        },\n        \"nijie\":\n        {\n            \"username\": \"\",\n            \"password\": \"\"\n        },\n        \"horne\":\n        {\n            \"username\": \"\",\n            \"password\": \"\"\n        },\n\n        \"nitter\":\n        {\n            \"sleep-request\": \"0.5-1.5\",\n\n            \"quoted\"  : false,\n            \"retweets\": false,\n            \"videos\"  : true\n        },\n\n        \"philomena\":\n        {\n            \"api-key\": null,\n            \"sleep-request\": \"0.5-1.5\",\n\n            \"svg\"   : true,\n            \"filter\": 2\n        },\n        \"derpibooru\": {\n            \"filter\": 56027\n        },\n        \"ponybooru\": {\n            \"filter\": 3\n        },\n        \"twibooru\": {\n            \"sleep-request\": \"6.0-6.1\"\n        },\n\n        \"postmill\":\n        {\n            \"save-link-post-body\": false\n        },\n\n        \"reactor\":\n        {\n            \"sleep-request\": \"3.0-6.0\",\n            \"gif\": false\n        },\n\n        \"wikimedia\":\n        {\n            \"sleep-request\": \"1.0-2.0\",\n            \"format\": \"\",\n            \"image-revisions\": 1,\n            \"limit\": 50,\n            \"subcategories\": true\n        },\n        \"fandom\":\n        {\n            \"format\": \"original\"\n        },\n        \"wikigg\":\n        {\n            \"format\": \"original\"\n        },\n\n        \"xenforo\":\n        {\n            \"attachments\": true,\n            \"embeds\"     : true,\n            \"metadata\"   : false,\n            \"order-posts\": \"desc\",\n            \"quoted\"     : false\n        },\n        \"nudostarforum\":\n        {\n            \"username\": \"\",\n            \"password\": \"\",\n            \"cookies\" : null\n        },\n        \"simpcity\":\n        {\n            \"username\": \"\",\n            \"password\": \"\",\n            \"cookies\" : null\n        },\n\n        \"booru\":\n        {\n            \"tags\" : false,\n            \"notes\": false,\n            \"url\"  : \"file_url\"\n        }\n    },\n\n\n    \"#\": \"===================================================================\",\n    \"#\": \"====    Downloader Options    =====================================\",\n\n    \"downloader\":\n    {\n        \"filesize-min\"  : null,\n        \"filesize-max\"  : null,\n        \"mtime\"         : true,\n        \"part\"          : true,\n        \"part-directory\": null,\n        \"progress\"      : 3.0,\n        \"proxy\"         : null,\n        \"rate\"          : null,\n        \"retries\"       : 4,\n        \"timeout\"       : 30.0,\n        \"verify\"        : true,\n\n        \"http\":\n        {\n            \"adjust-extensions\": true,\n            \"chunk-size\"       : 32768,\n            \"consume-content\"  : false,\n            \"enabled\"          : true,\n            \"headers\"          : null,\n            \"retry-codes\"      : [],\n            \"sleep-429\"        : 60.0,\n            \"validate\"         : true,\n            \"validate-html\"    : true\n        },\n\n        \"ytdl\":\n        {\n            \"cmdline-args\"   : null,\n            \"config-file\"    : null,\n            \"deprecations\"   : false,\n            \"enabled\"        : true,\n            \"format\"         : null,\n            \"forward-cookies\": true,\n            \"logging\"        : true,\n            \"module\"         : null,\n            \"outtmpl\"        : null,\n            \"raw-options\"    : null\n        }\n    },\n\n\n    \"#\": \"===================================================================\",\n    \"#\": \"====    Output Options    =========================================\",\n\n    \"output\":\n    {\n        \"ansi\"     : true,\n        \"fallback\" : true,\n        \"jsonl\"    : false,\n        \"mode\"     : \"auto\",\n        \"private\"  : false,\n        \"progress\" : true,\n        \"shorten\"  : true,\n        \"skip\"     : true,\n\n        \"stdin\"    : null,\n        \"stdout\"   : null,\n        \"stderr\"   : null,\n\n        \"log\"      : \"[{name}][{levelname}] {message}\",\n        \"logfile\"  : null,\n        \"errorfile\": null,\n        \"unsupportedfile\": null,\n\n        \"colors\"   :\n        {\n            \"success\": \"1;32\",\n            \"skip\"   : \"2\",\n            \"debug\"  : \"0;37\",\n            \"info\"   : \"1;37\",\n            \"warning\": \"1;33\",\n            \"error\"  : \"1;31\"\n        }\n    }\n}\n"
  },
  {
    "path": "docs/index.md",
    "content": "# gallery-dl Documentation\n\n- ## [Supported Sites](supportedsites.md)\n- ## [Command Line Options](options.md)\n- ## [Configuration File Options](configuration.rst)\n  - ### [gallery-dl.conf](gallery-dl.conf)\n  - ### [gallery-dl-example.conf](gallery-dl-example.conf)\n- ## [String Formatting](formatting.md)\n"
  },
  {
    "path": "docs/links.js",
    "content": "\"use strict\";\n\n\nfunction add_header_links()\n{\n    let style = document.createElement(\"style\");\n    style.id = \"headerlinks\"\n    document.head.appendChild(style);\n    style.sheet.insertRule(\n        \"a.headerlink {\"           +\n        \"  visibility: hidden;\"    +\n        \"  text-decoration: none;\" +\n        \"  font-size: 0.8em;\"      +\n        \"  padding: 0 4px 0 4px;\"  +\n        \"}\");\n    style.sheet.insertRule(\n        \":hover > a.headerlink {\"  +\n        \"  visibility: visible;\"   +\n        \"}\");\n\n    let headers = document.querySelectorAll(\"h2, h3, h4, h5, h6\");\n    for (let i = 0, len = headers.length; i < len; ++i)\n    {\n        let header = headers[i];\n\n        let id = header.id || header.parentNode.id;\n        if (!id)\n            continue;\n\n        let link = document.createElement(\"a\");\n        link.href = \"#\" + id;\n        link.className = \"headerlink\";\n        link.textContent = \"¶\";\n\n        header.appendChild(link);\n    }\n}\n\n\nif (document.readyState !== \"loading\") {\n    add_header_links();\n} else {\n    document.addEventListener(\"DOMContentLoaded\", add_header_links);\n}\n"
  },
  {
    "path": "docs/oauth-redirect.html",
    "content": "<!DOCTYPE html>\n<html>\n<head>\n    <meta charset=\"utf-8\">\n    <title>gallery-dl - OAuth Redirect</title>\n    <script>\n        window.location.href = \"http://localhost:6414/\" + window.location.search;\n    </script>\n</head>\n<body>\n</body>\n</html>\n"
  },
  {
    "path": "docs/options.md",
    "content": "# Command-Line Options\n\n<!-- auto-generated by scripts/options.py -->\n\n\n## Table of Contents\n\n* [General Options](#general-options)\n* [Update Options](#update-options)\n* [Input Options](#input-options)\n* [Output Options](#output-options)\n* [Networking Options](#networking-options)\n* [Downloader Options](#downloader-options)\n* [Sleep Options](#sleep-options)\n* [Configuration Options](#configuration-options)\n* [Cache Options](#cache-options)\n* [Authentication Options](#authentication-options)\n* [Cookie Options](#cookie-options)\n* [Selection Options](#selection-options)\n* [Post-processing Options](#post-processing-options)\n\n## General Options:\n    -h, --help                  Print this help message and exit\n    --version                   Print program version and exit\n    -f, --filename FORMAT       Filename format string for downloaded files\n                                ('/O' for \"original\" filenames)\n    -d, --destination PATH      Target location for file downloads\n    -D, --directory PATH        Exact location for file downloads\n    --restrict-filenames VALUE  Replace restricted filename characters with\n                                underscores. One of 'windows', 'windows+',\n                                'unix', 'ascii', 'ascii+', or a custom set of\n                                characters\n    --windows-filenames         Force filenames to be Windows-compatible\n    -X, --extractors PATH       Load external extractors from PATH\n    --compat                    Restore legacy 'category' names\n\n## Update Options:\n    -U, --update                Update to the latest version\n    --update-to CHANNEL[@TAG]   Switch to a dfferent release channel (stable or\n                                dev) or upgrade/downgrade to a specific version\n    --update-check              Check if a newer version is available\n\n## Input Options:\n    -i, --input-file FILE       Download URLs found in FILE ('-' for stdin).\n                                More than one --input-file can be specified\n    -I, --input-file-comment FILE\n                                Download URLs found in FILE. Comment them out\n                                after they were downloaded successfully.\n    -x, --input-file-delete FILE\n                                Download URLs found in FILE. Delete them after\n                                they were downloaded successfully.\n    --no-input                  Do not prompt for passwords/tokens\n\n## Output Options:\n    -q, --quiet                 Activate quiet mode\n    -w, --warning               Print only warnings and errors\n    -v, --verbose               Print various debugging information\n    -g, --get-urls              Print URLs instead of downloading\n    -G, --resolve-urls          Print URLs instead of downloading; resolve\n                                intermediary URLs\n    -j, --dump-json             Print JSON information\n    -J, --resolve-json          Print JSON information; resolve intermediary\n                                URLs\n    -s, --simulate              Simulate data extraction; do not download\n                                anything\n    -E, --extractor-info        Print extractor defaults and settings\n    -K, --list-keywords         Print a list of available keywords and example\n                                values for the given URLs\n    -e, --error-file FILE       Add input URLs which returned an error to FILE\n    -N, --print [EVENT:]FORMAT  Write FORMAT during EVENT (default 'prepare')\n                                to standard output instead of downloading\n                                files. Can be used multiple times. Examples:\n                                'id' or 'post:{md5[:8]}'\n    --Print [EVENT:]FORMAT      Like --print, but downloads files as well\n    --print-to-file [EVENT:]FORMAT FILE\n                                Append FORMAT during EVENT to FILE instead of\n                                downloading files. Can be used multiple times\n    --Print-to-file [EVENT:]FORMAT FILE\n                                Like --print-to-file, but downloads files as\n                                well\n    --list-modules              Print a list of available extractor modules\n    --list-extractors [CATEGORIES]\n                                Print a list of extractor classes with\n                                description, (sub)category and example URL\n    --write-log FILE            Write logging output to FILE\n    --write-unsupported FILE    Write URLs, which get emitted by other\n                                extractors but cannot be handled, to FILE\n    --write-pages               Write downloaded intermediary pages to files in\n                                the current directory to debug problems\n    --print-traffic             Display sent and read HTTP traffic\n    --no-colors                 Do not emit ANSI color codes in output\n\n## Networking Options:\n    -R, --retries N             Maximum number of retries for failed HTTP\n                                requests or -1 for infinite retries (default:\n                                4)\n    -a, --user-agent UA         User-Agent request header\n    --http-timeout SECONDS      Timeout for HTTP connections (default: 30.0)\n    --proxy URL                 Use the specified proxy\n    --xff VALUE                 Use a fake 'X-Forwarded-For' HTTP header to try\n                                bypassing geographic restrictions. Can be IP\n                                blocks in CIDR notation or two-letter ISO\n                                3166-2 country codes (12.0.0.0/8,FR,CN)\n    --source-address IP         Client-side IP address to bind to\n    -4, --force-ipv4            Make all connections via IPv4\n    -6, --force-ipv6            Make all connections via IPv6\n    --no-check-certificate      Disable HTTPS certificate validation\n\n## Downloader Options:\n    -r, --limit-rate RATE       Maximum download rate (e.g. 500k, 2.5M, or\n                                800k-2M)\n    --chunk-size SIZE           Size of in-memory data chunks (default: 32k)\n    --no-part                   Do not use .part files\n    --no-skip                   Do not skip downloads; overwrite existing files\n    --no-mtime                  Do not set file modification times according to\n                                Last-Modified HTTP response headers\n    --no-download               Do not download any files\n\n## Sleep Options:\n    --sleep SECONDS             Number of seconds to wait before each download.\n                                This can be either a constant value or a range\n                                (e.g. 2.7 or 2.0-3.5)\n    --sleep-skip SECONDS        Number of seconds to wait after skipping a file\n                                download\n    --sleep-extractor SECONDS   Number of seconds to wait before starting data\n                                extraction for an input URL\n    --sleep-request SECONDS     Number of seconds to wait between HTTP requests\n                                during data extraction\n    --sleep-retries [TYPE=]SECONDS\n                                Number of seconds to wait before retrying an\n                                HTTP request. Can be prefixed with\n                                'lin[:START[:MAX]]' or\n                                'exp[:BASE[:START[:MAX]]]' for linear or\n                                exponential growth between consecutive retries\n                                (e.g. '30', 'exp=40', 'lin:20=30-60'\n    --sleep-429 [TYPE=]SECONDS  Number of seconds to wait when receiving a '429\n                                Too Many Requests' response\n\n## Configuration Options:\n    -o, --option KEY=VALUE      Additional options. Example: -o browser=firefox\n    -c, --config FILE           Additional configuration files in default\n                                format\n    --config-json FILE          Additional configuration files in JSON format\n    --config-yaml FILE          Additional configuration files in YAML format\n    --config-toml FILE          Additional configuration files in TOML format\n    --config-type TYPE          Set filetype of default configuration files\n                                (json, yaml, toml)\n    --config-ignore             Do not load default configuration files\n    --config-create             Create a basic configuration file\n    --config-status             Show configuration file status\n    --config-open               Open configuration file in external application\n\n## Cache Options:\n    --cache-file PATH           Use PATH as cache file\n    --cache-status              Show cache file information\n    --cache-show MODULE         Show cached values for MODULE (ALL to show all\n                                entries, EXP to show only expired entries, VAL\n                                to show only valid entries)\n    --cache-clear MODULE        Delete cached login sessions, cookies, etc. for\n                                MODULE (ALL to delete everything, EXP to delete\n                                only expired values)\n    --cache-vacuum              Clean up the cache database by removing unused\n                                space and reorganizing the data to improve\n                                performance\n\n## Authentication Options:\n    -u, --username USER         Username to login with\n    -p, --password PASS         Password belonging to the given username\n    --netrc                     Enable .netrc authentication data\n\n## Cookie Options:\n    -C, --cookies FILE          File to load additional cookies from\n    --cookies-export FILE       Export session cookies to FILE\n    --cookies-from-browser BROWSER[/DOMAIN][+KEYRING][:PROFILE][::CONTAINER]\n                                Name of the browser to load cookies from, with\n                                optional domain prefixed with '/', keyring name\n                                prefixed with '+', profile prefixed with ':',\n                                and container prefixed with '::' ('none' for no\n                                container (default), 'all' for all containers)\n\n## Selection Options:\n    -A, --abort N[:TARGET]      Stop current extractor(s) after N consecutive\n                                file downloads were skipped. Specify a TARGET\n                                to set how many levels to ascend or to which\n                                subcategory to jump to. Examples: '-A 3', '-A\n                                3:2', '-A 3:manga'\n    -T, --terminate N           Stop current & parent extractors and proceed\n                                with the next input URL after N consecutive\n                                file downloads were skipped\n    --filesize-min SIZE         Do not download files smaller than SIZE (e.g.\n                                500k or 2.5M)\n    --filesize-max SIZE         Do not download files larger than SIZE (e.g.\n                                500k or 2.5M)\n    --download-archive FILE     Record successfully downloaded files in FILE\n                                and skip downloading any file already in it\n    --date-before DATE          Process only posts created before this date\n                                given in ISO 8601 format or as Unix timestamp\n                                (e.g. '2025-10-31', '2026-01-09T15:30:00',\n                                '1767972600')\n    --date-after DATE           Process only posts created after this date.\n                                Stop extraction when an older post is\n                                encountered\n    --blacklist CATEGORIES      Ignore the given comma-separated category names\n                                or category:subcategory pairs when spawning\n                                child extractors for external URLs (e.g.\n                                'pixiv', 'pixiv:user,*:artist')\n    --whitelist CATEGORIES      Allow only the given comma-separated category\n                                names or category:subcategory pairs to allow\n                                when spawning child extractors for external\n                                URLs\n    --tags-blacklist TAGS       Ignore posts tagged with any of the tags given\n                                as comma-separated list or path to a file\n                                containing them (e.g. '1girl',\n                                'shirt,highres,smile', 'C:\\path\\to\\list.txt')\n    --tags-whitelist TAGS       Allow only posts tagged with at least one of\n                                the tags given as comma-separated list or path\n                                to a file containing them\n    --range RANGE               Index range(s) specifying which files to\n                                download. These can be either a constant value,\n                                range, or slice (e.g. '5', '8-20', or '1:24:3')\n    --post-range RANGE          Like '--range', but for posts\n    --child-range RANGE         Like '--range', but for child extractors\n                                handling manga chapters, external URLs, etc.\n    --filter EXPR               Python expression controlling which files to\n                                download. Files for which the expression\n                                evaluates to False are ignored. Available keys\n                                are the filename-specific ones listed by '-K'.\n                                Example: --filter \"image_width >= 1000 and\n                                rating in ('s', 'q')\"\n    --post-filter EXPR          Like '--filter', but for posts\n    --child-filter EXPR         Like '--filter', but for child extractors\n                                handling manga chapters, external URLs, etc.\n\n## Post-processing Options:\n    -P, --postprocessor NAME    Activate the specified post processor\n    --no-postprocessors         Do not run any post processors\n    -O, --postprocessor-option KEY=VALUE\n                                Additional post processor options\n    --write-metadata            Write metadata to separate JSON files\n    --write-info-json           Write gallery metadata to a info.json file\n    --write-tags                Write image tags to separate text files\n    --zip                       Store downloaded files in a ZIP archive\n    --cbz                       Store downloaded files in a CBZ archive\n    --mtime NAME                Set file modification times according to\n                                metadata selected by NAME. Examples: 'date' or\n                                'status[date]'\n    --rename FORMAT             Rename previously downloaded files from FORMAT\n                                to the current filename format\n    --rename-to FORMAT          Rename previously downloaded files from the\n                                current filename format to FORMAT\n    --ugoira FMT                Convert Pixiv Ugoira to FMT using FFmpeg.\n                                Supported formats are 'webm', 'mp4', 'gif',\n                                'vp8', 'vp9', 'vp9-lossless', 'copy', 'zip'.\n    --exec CMD                  Execute CMD for each downloaded file. Supported\n                                replacement fields are {} or {_path},\n                                {_temppath}, {_directory}, {_filename}. On\n                                Windows, use {_path_unc} or {_directory_unc}\n                                for UNC paths. Example: --exec \"convert {}\n                                {}.png && rm {}\"\n    --exec-after CMD            Execute CMD after all files were downloaded.\n                                Example: --exec-after \"cd {_directory} &&\n                                convert * ../doc.pdf\"\n"
  },
  {
    "path": "docs/supportedsites.md",
    "content": "# Supported Sites\n\n<!-- auto-generated by scripts/supportedsites.py -->\nConsider all listed sites to potentially be NSFW.\n\n<table>\n<thead valign=\"bottom\">\n<tr>\n    <th>Site</th>\n    <th>URL</th>\n    <th>Capabilities</th>\n    <th>Authentication</th>\n</tr>\n</thead>\n<tbody valign=\"top\">\n<tr id=\"2ch\" title=\"2ch\">\n    <td>2ch</td>\n    <td>https://2ch.org/</td>\n    <td>Boards, Threads</td>\n    <td></td>\n</tr>\n<tr id=\"35photo\" title=\"35photo\">\n    <td>35PHOTO</td>\n    <td>https://35photo.pro/</td>\n    <td>Genres, individual Images, Tag Searches, User Profiles</td>\n    <td></td>\n</tr>\n<tr id=\"3dbooru\" title=\"3dbooru\">\n    <td>3dbooru</td>\n    <td>http://behoimi.org/</td>\n    <td>Pools, Popular Images, Posts, Tag Searches</td>\n    <td></td>\n</tr>\n<tr id=\"4archive\" title=\"4archive\">\n    <td>4archive</td>\n    <td>https://4archive.org/</td>\n    <td>Boards, Threads</td>\n    <td></td>\n</tr>\n<tr id=\"4chan\" title=\"4chan\">\n    <td>4chan</td>\n    <td>https://www.4chan.org/</td>\n    <td>Boards, Threads</td>\n    <td></td>\n</tr>\n<tr id=\"4chanarchives\" title=\"4chanarchives\">\n    <td>4chanarchives</td>\n    <td>https://4chanarchives.com/</td>\n    <td>Boards, Threads</td>\n    <td></td>\n</tr>\n<tr id=\"500px\" title=\"500px\">\n    <td>500px</td>\n    <td>https://500px.com/</td>\n    <td>Favorites, Galleries, individual Images, User Profiles</td>\n    <td></td>\n</tr>\n<tr id=\"8chan\" title=\"8chan\">\n    <td>8chan</td>\n    <td>https://8chan.moe/</td>\n    <td>Boards, Threads</td>\n    <td></td>\n</tr>\n<tr id=\"8muses\" title=\"8muses\">\n    <td>8muses</td>\n    <td>https://comics.8muses.com/</td>\n    <td>Albums</td>\n    <td></td>\n</tr>\n<tr id=\"myportfolio\" title=\"myportfolio\">\n    <td>Adobe Portfolio</td>\n    <td>https://www.myportfolio.com/</td>\n    <td>Galleries</td>\n    <td></td>\n</tr>\n<tr id=\"adultempire\" title=\"adultempire\">\n    <td>Adult Empire</td>\n    <td>https://www.adultempire.com/</td>\n    <td>Galleries</td>\n    <td></td>\n</tr>\n<tr id=\"agnph\" title=\"agnph\">\n    <td>AGNPH</td>\n    <td>https://agn.ph/</td>\n    <td>Posts, Tag Searches</td>\n    <td></td>\n</tr>\n<tr id=\"ahottie\" title=\"ahottie\">\n    <td>AHottie</td>\n    <td>https://ahottie.top/</td>\n    <td>Galleries, Search Results, Tag Searches</td>\n    <td></td>\n</tr>\n<tr id=\"allporncomic\" title=\"allporncomic\">\n    <td>AllPornComic</td>\n    <td>https://allporncomic.com/</td>\n    <td>Chapters, Manga, Tag Searches</td>\n    <td></td>\n</tr>\n<tr id=\"arcalive\" title=\"arcalive\">\n    <td>Arcalive</td>\n    <td>https://arca.live/</td>\n    <td>Boards, Posts, User Posts</td>\n    <td></td>\n</tr>\n<tr id=\"architizer\" title=\"architizer\">\n    <td>Architizer</td>\n    <td>https://architizer.com/</td>\n    <td>Firms, Projects</td>\n    <td></td>\n</tr>\n<tr id=\"ao3\" title=\"ao3\">\n    <td>Archive of Our Own</td>\n    <td>https://archiveofourown.org/</td>\n    <td>Search Results, Series, Subscriptions, Tag Searches, User Profiles, User Bookmarks, User Series, User Works, Works</td>\n    <td>Supported</td>\n</tr>\n<tr id=\"arena\" title=\"arena\">\n    <td>Are.na</td>\n    <td>https://are.na/</td>\n    <td>Channels</td>\n    <td></td>\n</tr>\n<tr id=\"artstation\" title=\"artstation\">\n    <td>ArtStation</td>\n    <td>https://www.artstation.com/</td>\n    <td>Albums, Artwork Listings, Challenges, Collections, Followed Users, individual Images, Likes, Search Results, User Profiles</td>\n    <td></td>\n</tr>\n<tr id=\"audiochan\" title=\"audiochan\">\n    <td>Audiochan</td>\n    <td>https://audiochan.com/</td>\n    <td>Audios, Collections, Search Results, User Profiles</td>\n    <td></td>\n</tr>\n<tr id=\"bbc\" title=\"bbc\">\n    <td>BBC</td>\n    <td>https://bbc.co.uk/</td>\n    <td>Galleries, Programmes</td>\n    <td></td>\n</tr>\n<tr id=\"behance\" title=\"behance\">\n    <td>Behance</td>\n    <td>https://www.behance.net/</td>\n    <td>Collections, Galleries, User Profiles</td>\n    <td></td>\n</tr>\n<tr id=\"bellazon\" title=\"bellazon\">\n    <td>Bellazon</td>\n    <td>https://www.bellazon.com/</td>\n    <td>Forums, Posts, Threads</td>\n    <td></td>\n</tr>\n<tr id=\"bilibili\" title=\"bilibili\">\n    <td>Bilibili</td>\n    <td>https://www.bilibili.com/</td>\n    <td>Articles, User Articles, User Article Favorites</td>\n    <td></td>\n</tr>\n<tr id=\"bluesky\" title=\"bluesky\">\n    <td>Bluesky</td>\n    <td>https://bsky.app/</td>\n    <td>Avatars, Backgrounds, Bookmarks, Feeds, Followed Users, Hashtags, User Profile Information, Likes, Lists, Media Files, Posts, Replies, Search Results, User Profiles, Videos</td>\n    <td>Supported</td>\n</tr>\n<tr id=\"boosty\" title=\"boosty\">\n    <td>Boosty</td>\n    <td>https://www.boosty.to/</td>\n    <td>DMs, Subscriptions Feed, Followed Users, Media Files, Posts, User Profiles</td>\n    <td><a href=\"https://github.com/mikf/gallery-dl#cookies\">Cookies</a></td>\n</tr>\n<tr id=\"booth\" title=\"booth\">\n    <td>BOOTH</td>\n    <td>https://booth.pm/</td>\n    <td>Item Categories, Items, Shops</td>\n    <td></td>\n</tr>\n<tr id=\"bunkr\" title=\"bunkr\">\n    <td>Bunkr</td>\n    <td>https://bunkr.si/</td>\n    <td>Albums, Media Files</td>\n    <td></td>\n</tr>\n<tr id=\"catbox\" title=\"catbox\">\n    <td>Catbox</td>\n    <td>https://catbox.moe/</td>\n    <td>Albums, Files</td>\n    <td></td>\n</tr>\n<tr id=\"cfake\" title=\"cfake\">\n    <td>Celebrity Fakes</td>\n    <td>https://cfake.com/</td>\n    <td>Categories, Celebrities, Countries, Created</td>\n    <td></td>\n</tr>\n<tr id=\"naver-chzzk\" title=\"naver-chzzk\">\n    <td>CHZZK</td>\n    <td>https://chzzk.naver.com/</td>\n    <td>Comments, Communities</td>\n    <td></td>\n</tr>\n<tr id=\"cien\" title=\"cien\">\n    <td>Ci-en</td>\n    <td>https://ci-en.net/</td>\n    <td>Articles, Creators, Followed Users, Recent Images</td>\n    <td></td>\n</tr>\n<tr id=\"civitai\" title=\"civitai\">\n    <td>Civitai</td>\n    <td>https://www.civitai.com/</td>\n    <td>Collections, Generated Files, individual Images, Image Listings, Models, Model Listings, Posts, Post Listings, Image Searches, Model Searches, Tag Searches, User Profiles, User Collections, User Images, Image Reactions, User Models, User Posts, User Videos, Video Reactions, Video Listings</td>\n    <td></td>\n</tr>\n<tr id=\"comedywildlifephoto\" title=\"comedywildlifephoto\">\n    <td>Comedy Wildlife Photography Awards</td>\n    <td>https://www.comedywildlifephoto.com/</td>\n    <td>Galleries</td>\n    <td></td>\n</tr>\n<tr id=\"comicvine\" title=\"comicvine\">\n    <td>Comic Vine</td>\n    <td>https://comicvine.gamespot.com/</td>\n    <td>Tag Searches</td>\n    <td></td>\n</tr>\n<tr id=\"comick\" title=\"comick\">\n    <td>Comick</td>\n    <td>https://comick.io/</td>\n    <td>Chapters, Covers, Manga</td>\n    <td></td>\n</tr>\n<tr id=\"coomer\" title=\"coomer\">\n    <td>Coomer</td>\n    <td>https://coomer.st/</td>\n    <td>Artists, Favorites, Posts, User Profiles</td>\n    <td>Supported</td>\n</tr>\n<tr id=\"cyberdrop\" title=\"cyberdrop\">\n    <td>Cyberdrop</td>\n    <td>https://cyberdrop.cr/</td>\n    <td>Albums, Media Files</td>\n    <td></td>\n</tr>\n<tr id=\"cyberfile\" title=\"cyberfile\">\n    <td>CyberFile</td>\n    <td>https://cyberfile.me/</td>\n    <td>Files, Folders, Shares</td>\n    <td></td>\n</tr>\n<tr id=\"dandadan\" title=\"dandadan\">\n    <td>Dandadan</td>\n    <td>https://dandadan.net/</td>\n    <td>Chapters, Manga</td>\n    <td></td>\n</tr>\n<tr id=\"dankefuerslesen\" title=\"dankefuerslesen\">\n    <td>Danke fürs Lesen</td>\n    <td>https://danke.moe/</td>\n    <td>Chapters, Manga</td>\n    <td></td>\n</tr>\n<tr id=\"desktopography\" title=\"desktopography\">\n    <td>Desktopography</td>\n    <td>https://desktopography.net/</td>\n    <td>Entries, Exhibitions</td>\n    <td></td>\n</tr>\n<tr id=\"deviantart\" title=\"deviantart\">\n    <td>DeviantArt</td>\n    <td>https://www.deviantart.com/</td>\n    <td>Avatars, Backgrounds, Collections, Deviations, Favorites, Folders, Followed Users, Galleries, Gallery Searches, Journals, Scraps, Search Results, Sta.sh, Status Updates, Tag Searches, User Profiles, Watches</td>\n    <td><a href=\"https://github.com/mikf/gallery-dl#oauth\">OAuth</a></td>\n</tr>\n<tr id=\"discord\" title=\"discord\">\n    <td>Discord</td>\n    <td>https://discord.com/</td>\n    <td>Channels, DMs, Messages, Servers, Server Assets, Server Searches</td>\n    <td></td>\n</tr>\n<tr id=\"dynastyscans\" title=\"dynastyscans\">\n    <td>Dynasty Reader</td>\n    <td>https://dynasty-scans.com/</td>\n    <td>Anthologies, Chapters, individual Images, Manga, Search Results</td>\n    <td></td>\n</tr>\n<tr id=\"e-hentai\" title=\"e-hentai\">\n    <td>E-Hentai</td>\n    <td>https://e-hentai.org/</td>\n    <td>Favorites, Galleries, Search Results</td>\n    <td>Supported</td>\n</tr>\n<tr id=\"aryion\" title=\"aryion\">\n    <td>Eka's Portal</td>\n    <td>https://aryion.com/</td>\n    <td>Favorites, Galleries, Posts, Search Results, Tag Searches, Watches</td>\n    <td>Supported</td>\n</tr>\n<tr id=\"eporner\" title=\"eporner\">\n    <td>EPORNER</td>\n    <td>https://www.eporner.com/</td>\n    <td>Galleries</td>\n    <td></td>\n</tr>\n<tr id=\"erome\" title=\"erome\">\n    <td>EroMe</td>\n    <td>https://www.erome.com/</td>\n    <td>Albums, Search Results, User Profiles</td>\n    <td></td>\n</tr>\n<tr id=\"everia\" title=\"everia\">\n    <td>EVERIA.CLUB</td>\n    <td>https://everia.club</td>\n    <td>Categories, Dates, Posts, Search Results, Tag Searches</td>\n    <td></td>\n</tr>\n<tr id=\"exhentai\" title=\"exhentai\">\n    <td>ExHentai</td>\n    <td>https://exhentai.org/</td>\n    <td>Favorites, Galleries, Search Results</td>\n    <td>Supported</td>\n</tr>\n<tr id=\"facebook\" title=\"facebook\">\n    <td>Facebook</td>\n    <td>https://www.facebook.com/</td>\n    <td>Albums, Avatars, User Profile Information, Photos, Profile Photos, Sets, User Profiles, Videos</td>\n    <td><a href=\"https://github.com/mikf/gallery-dl#cookies\">Cookies</a></td>\n</tr>\n<tr id=\"fansly\" title=\"fansly\">\n    <td>Fansly</td>\n    <td>https://fansly.com/</td>\n    <td>Creator Media, Creator Posts, Home Feed, Lists, Account Lists, Posts</td>\n    <td></td>\n</tr>\n<tr id=\"fantia\" title=\"fantia\">\n    <td>Fantia</td>\n    <td>https://fantia.jp/</td>\n    <td>Creators, Posts, Supported Creators</td>\n    <td><a href=\"https://github.com/mikf/gallery-dl#cookies\">Cookies</a></td>\n</tr>\n<tr id=\"fapachi\" title=\"fapachi\">\n    <td>Fapachi</td>\n    <td>https://fapachi.com/</td>\n    <td>Posts, User Profiles</td>\n    <td></td>\n</tr>\n<tr id=\"fapello\" title=\"fapello\">\n    <td>Fapello</td>\n    <td>https://fapello.com/</td>\n    <td>Models, Videos, Trending Posts, Popular Videos, Top Models, Posts</td>\n    <td></td>\n</tr>\n<tr id=\"fikfap\" title=\"fikfap\">\n    <td>FikFap</td>\n    <td>https://fikfap.com/</td>\n    <td>Hashtags, Posts, User Profiles</td>\n    <td></td>\n</tr>\n<tr id=\"filester\" title=\"filester\">\n    <td>filester.me</td>\n    <td>https://filester.me/</td>\n    <td>Files, Folders</td>\n    <td></td>\n</tr>\n<tr id=\"fitnakedgirls\" title=\"fitnakedgirls\">\n    <td>FitNakedGirls</td>\n    <td>https://fitnakedgirls.com/</td>\n    <td>Blogs, Categories, Galleries, Tag Searches, Videos</td>\n    <td></td>\n</tr>\n<tr id=\"flickr\" title=\"flickr\">\n    <td>Flickr</td>\n    <td>https://www.flickr.com/</td>\n    <td>Albums, Favorites, Galleries, Groups, individual Images, Search Results, User Profiles</td>\n    <td><a href=\"https://github.com/mikf/gallery-dl#oauth\">OAuth</a></td>\n</tr>\n<tr id=\"foriio\" title=\"foriio\">\n    <td>foriio</td>\n    <td>https://foriio.com/</td>\n    <td>User Profiles, Works</td>\n    <td></td>\n</tr>\n<tr id=\"furaffinity\" title=\"furaffinity\">\n    <td>Fur Affinity</td>\n    <td>https://www.furaffinity.net/</td>\n    <td>Favorites, Folders, Followed Users, Galleries, Posts, Scraps, Search Results, New Submissions, User Profiles</td>\n    <td><a href=\"https://github.com/mikf/gallery-dl#cookies\">Cookies</a></td>\n</tr>\n<tr id=\"furry34\" title=\"furry34\">\n    <td>Furry 34 com</td>\n    <td>https://furry34.com/</td>\n    <td>Playlists, Posts, Tag Searches</td>\n    <td></td>\n</tr>\n<tr id=\"fuskator\" title=\"fuskator\">\n    <td>Fuskator</td>\n    <td>https://fuskator.com/</td>\n    <td>Galleries, Search Results</td>\n    <td></td>\n</tr>\n<tr id=\"2chan\" title=\"2chan\">\n    <td>Futaba Channel</td>\n    <td>https://www.2chan.net/</td>\n    <td>Threads</td>\n    <td></td>\n</tr>\n<tr id=\"gelbooru\" title=\"gelbooru\">\n    <td>Gelbooru</td>\n    <td>https://gelbooru.com/</td>\n    <td>Favorites, Pools, Posts, Redirects, Tag Searches</td>\n    <td></td>\n</tr>\n<tr id=\"girlswithmuscle\" title=\"girlswithmuscle\">\n    <td>Girls with Muscle</td>\n    <td>https://www.girlswithmuscle.com/</td>\n    <td>Posts, Search Results</td>\n    <td>Supported</td>\n</tr>\n<tr id=\"girlsreleased\" title=\"girlsreleased\">\n    <td>Girlsreleased</td>\n    <td>https://girlsreleased.com/</td>\n    <td>Models, Sets, Sites</td>\n    <td></td>\n</tr>\n<tr id=\"gofile\" title=\"gofile\">\n    <td>Gofile</td>\n    <td>https://gofile.io/</td>\n    <td>Folders</td>\n    <td></td>\n</tr>\n<tr id=\"hatenablog\" title=\"hatenablog\">\n    <td>HatenaBlog</td>\n    <td>https://hatenablog.com</td>\n    <td>Archive, Individual Posts, Home Feed, Search Results</td>\n    <td></td>\n</tr>\n<tr id=\"hdoujin\" title=\"hdoujin\">\n    <td>HDoujin Galleries</td>\n    <td>https://hdoujin.org/</td>\n    <td>Favorites, Galleries, Search Results</td>\n    <td></td>\n</tr>\n<tr id=\"hentaifoundry\" title=\"hentaifoundry\">\n    <td>Hentai Foundry</td>\n    <td>https://www.hentai-foundry.com/</td>\n    <td>Favorites, individual Images, Pictures, Popular Images, Recent Images, Scraps, Stories, Tag Searches, User Profiles</td>\n    <td></td>\n</tr>\n<tr id=\"hentai2read\" title=\"hentai2read\">\n    <td>Hentai2Read</td>\n    <td>https://hentai2read.com/</td>\n    <td>Chapters, Manga</td>\n    <td></td>\n</tr>\n<tr id=\"hentaihand\" title=\"hentaihand\">\n    <td>HentaiHand</td>\n    <td>https://hentaihand.com/</td>\n    <td>Galleries, Tag Searches</td>\n    <td></td>\n</tr>\n<tr id=\"hentaihere\" title=\"hentaihere\">\n    <td>HentaiHere</td>\n    <td>https://hentaihere.com/</td>\n    <td>Chapters, Manga</td>\n    <td></td>\n</tr>\n<tr id=\"hentainexus\" title=\"hentainexus\">\n    <td>HentaiNexus</td>\n    <td>https://hentainexus.com/</td>\n    <td>Galleries, Search Results</td>\n    <td></td>\n</tr>\n<tr id=\"hiperdex\" title=\"hiperdex\">\n    <td>HiperDEX</td>\n    <td>https://hiperdex.com/</td>\n    <td>Artists, Chapters, Manga</td>\n    <td></td>\n</tr>\n<tr id=\"hitomi\" title=\"hitomi\">\n    <td>Hitomi.la</td>\n    <td>https://hitomi.la/</td>\n    <td>Galleries, Site Index, Search Results, Tag Searches</td>\n    <td></td>\n</tr>\n<tr id=\"hotleak\" title=\"hotleak\">\n    <td>Hotleak</td>\n    <td>https://hotleak.vip/</td>\n    <td>Categories, Creators, Posts, Search Results</td>\n    <td></td>\n</tr>\n<tr id=\"idolcomplex\" title=\"idolcomplex\">\n    <td>Idol Complex</td>\n    <td>https://www.idolcomplex.com/</td>\n    <td>Pools, Posts, Tag Searches</td>\n    <td>Supported</td>\n</tr>\n<tr id=\"imagebam\" title=\"imagebam\">\n    <td>ImageBam</td>\n    <td>https://www.imagebam.com/</td>\n    <td>Galleries, individual Images</td>\n    <td></td>\n</tr>\n<tr id=\"imagechest\" title=\"imagechest\">\n    <td>ImageChest</td>\n    <td>https://imgchest.com/</td>\n    <td>Galleries, User Profiles</td>\n    <td></td>\n</tr>\n<tr id=\"imagefap\" title=\"imagefap\">\n    <td>ImageFap</td>\n    <td>https://www.imagefap.com/</td>\n    <td>Folders, Galleries, individual Images, User Profiles</td>\n    <td></td>\n</tr>\n<tr id=\"imagepond\" title=\"imagepond\">\n    <td>ImagePond</td>\n    <td>https://www.imagepond.net/</td>\n    <td>Albums, Files, User Profiles</td>\n    <td></td>\n</tr>\n<tr id=\"imgbb\" title=\"imgbb\">\n    <td>ImgBB</td>\n    <td>https://imgbb.com/</td>\n    <td>Albums, individual Images, User Profiles</td>\n    <td>Supported</td>\n</tr>\n<tr id=\"imgbox\" title=\"imgbox\">\n    <td>imgbox</td>\n    <td>https://imgbox.com/</td>\n    <td>Galleries, individual Images</td>\n    <td></td>\n</tr>\n<tr id=\"imgpile\" title=\"imgpile\">\n    <td>imgpile</td>\n    <td>https://imgpile.com/</td>\n    <td>Posts, User Profiles</td>\n    <td></td>\n</tr>\n<tr id=\"imgth\" title=\"imgth\">\n    <td>imgth</td>\n    <td>https://imgth.com/</td>\n    <td>Galleries</td>\n    <td></td>\n</tr>\n<tr id=\"imgur\" title=\"imgur\">\n    <td>imgur</td>\n    <td>https://imgur.com/</td>\n    <td>Albums, Favorites, Favorites Folders, Galleries, individual Images, Personal Posts, Search Results, Subreddits, Tag Searches, User Profiles</td>\n    <td></td>\n</tr>\n<tr id=\"inkbunny\" title=\"inkbunny\">\n    <td>Inkbunny</td>\n    <td>https://inkbunny.net/</td>\n    <td>Favorites, Followed Users, Pools, Posts, Search Results, Unread Submissions, User Profiles</td>\n    <td>Supported</td>\n</tr>\n<tr id=\"instagram\" title=\"instagram\">\n    <td>Instagram</td>\n    <td>https://www.instagram.com/</td>\n    <td>Avatars, Collections, Followers, Followed Users, Guides, Highlights, User Profile Information, Posts, Reels, Saved Posts, Stories, Stories Home Tray, Tag Searches, Tagged Posts, User Profiles</td>\n    <td><a href=\"https://github.com/mikf/gallery-dl#cookies\">Cookies</a></td>\n</tr>\n<tr id=\"issuu\" title=\"issuu\">\n    <td>Issuu</td>\n    <td>https://issuu.com/</td>\n    <td>Publications, User Profiles</td>\n    <td></td>\n</tr>\n<tr id=\"itaku\" title=\"itaku\">\n    <td>Itaku</td>\n    <td>https://itaku.ee/</td>\n    <td>Bookmarks, Followers, Followed Users, Galleries, individual Images, Posts, Search Results, Stars, User Profiles</td>\n    <td></td>\n</tr>\n<tr id=\"itchio\" title=\"itchio\">\n    <td>itch.io</td>\n    <td>https://itch.io/</td>\n    <td>Games</td>\n    <td></td>\n</tr>\n<tr id=\"iwara\" title=\"iwara\">\n    <td>Iwara</td>\n    <td>https://www.iwara.tv/</td>\n    <td>Favorites, Followers, Followed Users, individual Images, Playlists, Search Results, Tag Searches, User Profiles, User Images, User Playlists, User Videos, Videos</td>\n    <td>Supported</td>\n</tr>\n<tr id=\"joyreactor\" title=\"joyreactor\">\n    <td>JoyReactor</td>\n    <td>https://joyreactor.com/</td>\n    <td>Posts, Search Results, Tag Searches, User Profiles</td>\n    <td></td>\n</tr>\n<tr id=\"kaliscan\" title=\"kaliscan\">\n    <td>KaliScan</td>\n    <td>https://kaliscan.me/</td>\n    <td>Chapters, Manga</td>\n    <td></td>\n</tr>\n<tr id=\"keenspot\" title=\"keenspot\">\n    <td>Keenspot</td>\n    <td>http://www.keenspot.com/</td>\n    <td>Comics</td>\n    <td></td>\n</tr>\n<tr id=\"kemono\" title=\"kemono\">\n    <td>Kemono</td>\n    <td>https://kemono.cr/</td>\n    <td>Artists, Discord Servers, Favorites, Posts, User Profiles</td>\n    <td>Supported</td>\n</tr>\n<tr id=\"khinsider\" title=\"khinsider\">\n    <td>Khinsider</td>\n    <td>https://downloads.khinsider.com/</td>\n    <td>Soundtracks</td>\n    <td></td>\n</tr>\n<tr id=\"komikcast\" title=\"komikcast\">\n    <td>Komikcast</td>\n    <td>https://komikcast.li/</td>\n    <td>Chapters, Manga</td>\n    <td></td>\n</tr>\n<tr id=\"koofr\" title=\"koofr\">\n    <td>Koofr</td>\n    <td>https://koofr.net/</td>\n    <td>Shared Links</td>\n    <td></td>\n</tr>\n<tr id=\"leakgallery\" title=\"leakgallery\">\n    <td>Leak Gallery</td>\n    <td>https://leakgallery.com</td>\n    <td>Most Liked Posts, Posts, Trending Medias, User Profiles</td>\n    <td></td>\n</tr>\n<tr id=\"lensdump\" title=\"lensdump\">\n    <td>Lensdump</td>\n    <td>https://lensdump.com/</td>\n    <td>Albums, individual Images</td>\n    <td></td>\n</tr>\n<tr id=\"lexica\" title=\"lexica\">\n    <td>Lexica</td>\n    <td>https://lexica.art/</td>\n    <td>Search Results</td>\n    <td></td>\n</tr>\n<tr id=\"lightroom\" title=\"lightroom\">\n    <td>Lightroom</td>\n    <td>https://lightroom.adobe.com/</td>\n    <td>Galleries</td>\n    <td></td>\n</tr>\n<tr id=\"listal\" title=\"listal\">\n    <td>Listal</td>\n    <td>https://listal.com</td>\n    <td>individual Images, People</td>\n    <td></td>\n</tr>\n<tr id=\"livedoor\" title=\"livedoor\">\n    <td>livedoor Blog</td>\n    <td>http://blog.livedoor.jp/</td>\n    <td>Blogs, Posts</td>\n    <td></td>\n</tr>\n<tr id=\"lofter\" title=\"lofter\">\n    <td>LOFTER</td>\n    <td>https://www.lofter.com/</td>\n    <td>Blog Posts, Posts</td>\n    <td></td>\n</tr>\n<tr id=\"luscious\" title=\"luscious\">\n    <td>Luscious</td>\n    <td>https://members.luscious.net/</td>\n    <td>Albums, Search Results</td>\n    <td></td>\n</tr>\n<tr id=\"madokami\" title=\"madokami\">\n    <td>Madokami</td>\n    <td>https://manga.madokami.al/</td>\n    <td>Manga</td>\n    <td>Required</td>\n</tr>\n<tr id=\"mangafox\" title=\"mangafox\">\n    <td>Manga Fox</td>\n    <td>https://fanfox.net/</td>\n    <td>Chapters, Manga</td>\n    <td></td>\n</tr>\n<tr id=\"mangahere\" title=\"mangahere\">\n    <td>Manga Here</td>\n    <td>https://www.mangahere.cc/</td>\n    <td>Chapters, Manga</td>\n    <td></td>\n</tr>\n<tr id=\"mangadex\" title=\"mangadex\">\n    <td>MangaDex</td>\n    <td>https://mangadex.org/</td>\n    <td>Authors, Chapters, Covers, Updates Feed, Library, MDLists, Manga</td>\n    <td>Supported</td>\n</tr>\n<tr id=\"mangafire\" title=\"mangafire\">\n    <td>MangaFire</td>\n    <td>https://mangafire.to/</td>\n    <td>Chapters, Manga</td>\n    <td></td>\n</tr>\n<tr id=\"mangafreak\" title=\"mangafreak\">\n    <td>MangaFreak</td>\n    <td>https://ww2.mangafreak.me/</td>\n    <td>Chapters, Manga</td>\n    <td></td>\n</tr>\n<tr id=\"mangapark\" title=\"mangapark\">\n    <td>MangaPark</td>\n    <td>https://mangapark.net/</td>\n    <td>Chapters, Manga</td>\n    <td></td>\n</tr>\n<tr id=\"mangaread\" title=\"mangaread\">\n    <td>MangaRead</td>\n    <td>https://mangaread.org/</td>\n    <td>Chapters, Manga</td>\n    <td></td>\n</tr>\n<tr id=\"mangareader\" title=\"mangareader\">\n    <td>MangaReader</td>\n    <td>https://mangareader.to/</td>\n    <td>Chapters, Manga</td>\n    <td></td>\n</tr>\n<tr id=\"mangataro\" title=\"mangataro\">\n    <td>MangaTaro</td>\n    <td>https://mangataro.org/</td>\n    <td>Chapters, Manga</td>\n    <td></td>\n</tr>\n<tr id=\"mangatown\" title=\"mangatown\">\n    <td>MangaTown</td>\n    <td>https://www.mangatown.com/</td>\n    <td>Chapters, Manga</td>\n    <td></td>\n</tr>\n<tr id=\"mangoxo\" title=\"mangoxo\">\n    <td>Mangoxo</td>\n    <td>https://www.mangoxo.com/</td>\n    <td>Albums, Channels</td>\n    <td>Supported</td>\n</tr>\n<tr id=\"mixdrop\" title=\"mixdrop\">\n    <td>MixDrop</td>\n    <td>https://mixdrop.ag/</td>\n    <td>Files</td>\n    <td></td>\n</tr>\n<tr id=\"motherless\" title=\"motherless\">\n    <td>Motherless</td>\n    <td>https://motherless.com/</td>\n    <td>Galleries, Groups, Media Files</td>\n    <td></td>\n</tr>\n<tr id=\"myhentaigallery\" title=\"myhentaigallery\">\n    <td>My Hentai Gallery</td>\n    <td>https://myhentaigallery.com/</td>\n    <td>Galleries, Tag Searches</td>\n    <td></td>\n</tr>\n<tr id=\"naver-blog\" title=\"naver-blog\">\n    <td>Naver Blog</td>\n    <td>https://blog.naver.com/</td>\n    <td>Blogs, Posts</td>\n    <td></td>\n</tr>\n<tr id=\"naver-webtoon\" title=\"naver-webtoon\">\n    <td>Naver Webtoon</td>\n    <td>https://comic.naver.com/</td>\n    <td>Comics, Episodes</td>\n    <td></td>\n</tr>\n<tr id=\"nekohouse\" title=\"nekohouse\">\n    <td>Nekohouse</td>\n    <td>https://nekohouse.su/</td>\n    <td>Posts, User Profiles</td>\n    <td></td>\n</tr>\n<tr id=\"newgrounds\" title=\"newgrounds\">\n    <td>Newgrounds</td>\n    <td>https://www.newgrounds.com/</td>\n    <td>Art, Audio, Favorites, Followed Users, Games, individual Images, Media Files, Movies, Search Results, User Profiles</td>\n    <td>Supported</td>\n</tr>\n<tr id=\"nhentai\" title=\"nhentai\">\n    <td>nhentai</td>\n    <td>https://nhentai.net/</td>\n    <td>Favorites, Galleries, Search Results, Tag Searches</td>\n    <td></td>\n</tr>\n<tr id=\"seiga\" title=\"seiga\">\n    <td>Niconico Seiga</td>\n    <td>https://seiga.nicovideo.jp/</td>\n    <td>individual Images, User Profiles</td>\n    <td>Supported</td>\n</tr>\n<tr id=\"nozomi\" title=\"nozomi\">\n    <td>Nozomi.la</td>\n    <td>https://nozomi.la/</td>\n    <td>Site Index, Posts, Search Results, Tag Searches</td>\n    <td></td>\n</tr>\n<tr id=\"nsfwalbum\" title=\"nsfwalbum\">\n    <td>NSFWalbum.com</td>\n    <td>https://nsfwalbum.com/</td>\n    <td>Albums</td>\n    <td></td>\n</tr>\n<tr id=\"nudostar\" title=\"nudostar\">\n    <td>NudoStar.TV</td>\n    <td>https://nudostar.tv/</td>\n    <td>individual Images, Models</td>\n    <td></td>\n</tr>\n<tr id=\"okporn\" title=\"okporn\">\n    <td>OK.PORN</td>\n    <td>https://ok.porn/</td>\n    <td>Galleries</td>\n    <td></td>\n</tr>\n<tr id=\"patreon\" title=\"patreon\">\n    <td>Patreon</td>\n    <td>https://www.patreon.com/</td>\n    <td>Collections, Creators, Posts, User Profiles</td>\n    <td><a href=\"https://github.com/mikf/gallery-dl#cookies\">Cookies</a></td>\n</tr>\n<tr id=\"pexels\" title=\"pexels\">\n    <td>Pexels</td>\n    <td>https://pexels.com/</td>\n    <td>Collections, individual Images, Search Results, User Profiles</td>\n    <td></td>\n</tr>\n<tr id=\"pholder\" title=\"pholder\">\n    <td>pholder</td>\n    <td>https://pholder.com/</td>\n    <td>Search Results, Subreddits, User Profiles</td>\n    <td></td>\n</tr>\n<tr id=\"photovogue\" title=\"photovogue\">\n    <td>PhotoVogue</td>\n    <td>https://www.vogue.com/photovogue/</td>\n    <td>User Profiles</td>\n    <td></td>\n</tr>\n<tr id=\"picarto\" title=\"picarto\">\n    <td>Picarto</td>\n    <td>https://picarto.tv/</td>\n    <td>Galleries</td>\n    <td></td>\n</tr>\n<tr id=\"picazor\" title=\"picazor\">\n    <td>Picazor</td>\n    <td>https://picazor.com/</td>\n    <td>User Profiles</td>\n    <td></td>\n</tr>\n<tr id=\"pictoa\" title=\"pictoa\">\n    <td>Pictoa</td>\n    <td>https://pictoa.com/</td>\n    <td>Albums, individual Images</td>\n    <td></td>\n</tr>\n<tr id=\"piczel\" title=\"piczel\">\n    <td>Piczel</td>\n    <td>https://piczel.tv/</td>\n    <td>Folders, individual Images, User Profiles</td>\n    <td></td>\n</tr>\n<tr id=\"pillowfort\" title=\"pillowfort\">\n    <td>Pillowfort</td>\n    <td>https://www.pillowfort.social/</td>\n    <td>Posts, User Profiles</td>\n    <td>Supported</td>\n</tr>\n<tr id=\"pinterest\" title=\"pinterest\">\n    <td>Pinterest</td>\n    <td>https://www.pinterest.com/</td>\n    <td>All Pins, Created Pins, Pins, pin.it Links, related Pins, Search Results, Sections, User Profiles</td>\n    <td><a href=\"https://github.com/mikf/gallery-dl#cookies\">Cookies</a></td>\n</tr>\n<tr id=\"pixeldrain\" title=\"pixeldrain\">\n    <td>pixeldrain</td>\n    <td>https://pixeldrain.com/</td>\n    <td>Albums, Files, Filesystems</td>\n    <td></td>\n</tr>\n<tr id=\"pixiv\" title=\"pixiv\">\n    <td>[pixiv]</td>\n    <td>https://www.pixiv.net/</td>\n    <td>Artworks, Avatars, Backgrounds, Favorites, Follows, pixiv.me Links, pixivision, Rankings, Search Results, Series, Sketch, Unlisted Works, User Profiles, individual Images</td>\n    <td><a href=\"https://github.com/mikf/gallery-dl#oauth\">OAuth</a></td>\n</tr>\n<tr id=\"pixiv-novel\" title=\"pixiv-novel\">\n    <td>[pixiv] Novels</td>\n    <td>https://www.pixiv.net/novel</td>\n    <td>Bookmarks, Novels, Series, User Profiles</td>\n    <td><a href=\"https://github.com/mikf/gallery-dl#oauth\">OAuth</a></td>\n</tr>\n<tr id=\"fanbox\" title=\"fanbox\">\n    <td>pixivFANBOX</td>\n    <td>https://www.fanbox.cc/</td>\n    <td>Creators, Home Feed, Posts, Pixiv Redirects, Supported User Feed, Tag Searches</td>\n    <td><a href=\"https://github.com/mikf/gallery-dl#cookies\">Cookies</a></td>\n</tr>\n<tr id=\"pixnet\" title=\"pixnet\">\n    <td>Pixnet</td>\n    <td>https://www.pixnet.net/</td>\n    <td>Folders, individual Images, Sets, User Profiles</td>\n    <td></td>\n</tr>\n<tr id=\"plurk\" title=\"plurk\">\n    <td>Plurk</td>\n    <td>https://www.plurk.com/</td>\n    <td>Posts, Timelines</td>\n    <td></td>\n</tr>\n<tr id=\"poipiku\" title=\"poipiku\">\n    <td>Poipiku</td>\n    <td>https://poipiku.com/</td>\n    <td>Posts, User Profiles</td>\n    <td><a href=\"https://github.com/mikf/gallery-dl#cookies\">Cookies</a></td>\n</tr>\n<tr id=\"poringa\" title=\"poringa\">\n    <td>Poringa</td>\n    <td>http://www.poringa.net/</td>\n    <td>Posts Images, Search Results, User Profiles</td>\n    <td></td>\n</tr>\n<tr id=\"pornhub\" title=\"pornhub\">\n    <td>Pornhub</td>\n    <td>https://www.pornhub.com/</td>\n    <td>Galleries, Gifs, Photos, User Profiles</td>\n    <td></td>\n</tr>\n<tr id=\"pornpics\" title=\"pornpics\">\n    <td>PornPics.com</td>\n    <td>https://www.pornpics.com/</td>\n    <td>Categories, Galleries, Listings, Search Results, Tag Searches</td>\n    <td></td>\n</tr>\n<tr id=\"pornstarstube\" title=\"pornstarstube\">\n    <td>PORNSTARS.TUBE</td>\n    <td>https://pornstars.tube/</td>\n    <td>Galleries</td>\n    <td></td>\n</tr>\n<tr id=\"rule34vault\" title=\"rule34vault\">\n    <td>R34 Vault</td>\n    <td>https://rule34vault.com/</td>\n    <td>Playlists, Posts, Tag Searches</td>\n    <td></td>\n</tr>\n<tr id=\"rawkuma\" title=\"rawkuma\">\n    <td>Rawkuma</td>\n    <td>https://rawkuma.net/</td>\n    <td>Chapters, Manga</td>\n    <td></td>\n</tr>\n<tr id=\"readcomiconline\" title=\"readcomiconline\">\n    <td>Read Comic Online</td>\n    <td>https://readcomiconline.li/</td>\n    <td>Comic Issues, Comics, Tag Searches</td>\n    <td></td>\n</tr>\n<tr id=\"realbooru\" title=\"realbooru\">\n    <td>Realbooru</td>\n    <td>https://realbooru.com/</td>\n    <td>Favorites, Pools, Posts, Tag Searches</td>\n    <td></td>\n</tr>\n<tr id=\"reddit\" title=\"reddit\">\n    <td>Reddit</td>\n    <td>https://www.reddit.com/</td>\n    <td>Home Feed, individual Images, Redirects, Submissions, Subreddits, User Profiles</td>\n    <td><a href=\"https://github.com/mikf/gallery-dl#oauth\">OAuth</a></td>\n</tr>\n<tr id=\"redgifs\" title=\"redgifs\">\n    <td>RedGIFs</td>\n    <td>https://redgifs.com/</td>\n    <td>Collections, individual Images, Niches, Search Results, User Profiles</td>\n    <td></td>\n</tr>\n<tr id=\"paheal\" title=\"paheal\">\n    <td>Rule 34</td>\n    <td>https://rule34.paheal.net/</td>\n    <td>Posts, Tag Searches</td>\n    <td></td>\n</tr>\n<tr id=\"rule34us\" title=\"rule34us\">\n    <td>Rule 34</td>\n    <td>https://rule34.us/</td>\n    <td>Posts, Tag Searches</td>\n    <td></td>\n</tr>\n<tr id=\"rule34world\" title=\"rule34world\">\n    <td>Rule 34 World</td>\n    <td>https://rule34.world/</td>\n    <td>Playlists, Posts, Tag Searches</td>\n    <td>Supported</td>\n</tr>\n<tr id=\"rule34xyz\" title=\"rule34xyz\">\n    <td>Rule 34 XYZ</td>\n    <td>https://rule34.xyz/</td>\n    <td>Playlists, Posts, Tag Searches</td>\n    <td>Supported</td>\n</tr>\n<tr id=\"s3ndpics\" title=\"s3ndpics\">\n    <td>S3ND</td>\n    <td>https://s3nd.pics/</td>\n    <td>Posts, Search Results, User Profiles</td>\n    <td></td>\n</tr>\n<tr id=\"sankaku\" title=\"sankaku\">\n    <td>Sankaku Channel</td>\n    <td>https://sankaku.app/</td>\n    <td>Book Searches, Pools, Posts, Tag Searches</td>\n    <td>Supported</td>\n</tr>\n<tr id=\"sankakucomplex\" title=\"sankakucomplex\">\n    <td>Sankaku Complex</td>\n    <td>https://news.sankakucomplex.com/</td>\n    <td>Articles, Tag Searches</td>\n    <td></td>\n</tr>\n<tr id=\"schalenetwork\" title=\"schalenetwork\">\n    <td>Schale Network</td>\n    <td>https://niyaniya.moe/</td>\n    <td>Favorites, Galleries, Search Results</td>\n    <td></td>\n</tr>\n<tr id=\"scrolller\" title=\"scrolller\">\n    <td>Scrolller</td>\n    <td>https://scrolller.com/</td>\n    <td>Followed Subreddits, Posts, Subreddits, Reddit Users</td>\n    <td>Supported</td>\n</tr>\n<tr id=\"senmanga\" title=\"senmanga\">\n    <td>Sen Manga</td>\n    <td>https://raw.senmanga.com/</td>\n    <td>Chapters</td>\n    <td></td>\n</tr>\n<tr id=\"sexcom\" title=\"sexcom\">\n    <td>Sex.com</td>\n    <td>https://www.sex.com/</td>\n    <td>Boards, Feed, Likes, Pins, User Pins, related Pins, Search Results</td>\n    <td></td>\n</tr>\n<tr id=\"simplyhentai\" title=\"simplyhentai\">\n    <td>Simply Hentai</td>\n    <td>https://www.simply-hentai.com/</td>\n    <td>Galleries, Languages, Manga, Series, Tag Searches</td>\n    <td></td>\n</tr>\n<tr id=\"sizebooru\" title=\"sizebooru\">\n    <td>Size Booru</td>\n    <td>https://sizebooru.com/</td>\n    <td>Favorites, Galleries, Posts, Tag Searches, User Uploads</td>\n    <td></td>\n</tr>\n<tr id=\"skeb\" title=\"skeb\">\n    <td>Skeb</td>\n    <td>https://skeb.jp/</td>\n    <td>Followed Creators, Followed Users, Posts, Search Results, Sent Requests, User Profiles, Works</td>\n    <td></td>\n</tr>\n<tr id=\"slickpic\" title=\"slickpic\">\n    <td>SlickPic</td>\n    <td>https://www.slickpic.com/</td>\n    <td>Albums, User Profiles</td>\n    <td></td>\n</tr>\n<tr id=\"slideshare\" title=\"slideshare\">\n    <td>SlideShare</td>\n    <td>https://www.slideshare.net/</td>\n    <td>Presentations</td>\n    <td></td>\n</tr>\n<tr id=\"smugmug\" title=\"smugmug\">\n    <td>SmugMug</td>\n    <td>https://www.smugmug.com/</td>\n    <td>Albums, individual Images, Images from Users and Folders</td>\n    <td><a href=\"https://github.com/mikf/gallery-dl#oauth\">OAuth</a></td>\n</tr>\n<tr id=\"soundgasm\" title=\"soundgasm\">\n    <td>Soundgasm</td>\n    <td>https://soundgasm.net/</td>\n    <td>Audio, User Profiles</td>\n    <td></td>\n</tr>\n<tr id=\"speakerdeck\" title=\"speakerdeck\">\n    <td>Speaker Deck</td>\n    <td>https://speakerdeck.com/</td>\n    <td>Presentations</td>\n    <td></td>\n</tr>\n<tr id=\"steamgriddb\" title=\"steamgriddb\">\n    <td>SteamGridDB</td>\n    <td>https://www.steamgriddb.com</td>\n    <td>Individual Assets, Grids, Heroes, Icons, Logos</td>\n    <td></td>\n</tr>\n<tr id=\"subscribestar\" title=\"subscribestar\">\n    <td>SubscribeStar</td>\n    <td>https://www.subscribestar.com/</td>\n    <td>Posts, User Profiles</td>\n    <td>Supported</td>\n</tr>\n<tr id=\"sxypix\" title=\"sxypix\">\n    <td>Sxypix</td>\n    <td>https://sxypix.com/</td>\n    <td>Galleries</td>\n    <td></td>\n</tr>\n<tr id=\"tapas\" title=\"tapas\">\n    <td>Tapas</td>\n    <td>https://tapas.io/</td>\n    <td>Creators, Episodes, Series</td>\n    <td>Supported</td>\n</tr>\n<tr id=\"tcbscans\" title=\"tcbscans\">\n    <td>TCB Scans</td>\n    <td>https://tcbscans.me/</td>\n    <td>Chapters, Manga</td>\n    <td></td>\n</tr>\n<tr id=\"telegraph\" title=\"telegraph\">\n    <td>Telegraph</td>\n    <td>https://telegra.ph/</td>\n    <td>Galleries</td>\n    <td></td>\n</tr>\n<tr id=\"tenor\" title=\"tenor\">\n    <td>Tenor</td>\n    <td>https://tenor.com/</td>\n    <td>individual Images, Search Results, User Profiles</td>\n    <td></td>\n</tr>\n<tr id=\"thehentaiworld\" title=\"thehentaiworld\">\n    <td>The Hentai World</td>\n    <td>https://thehentaiworld.com/</td>\n    <td>Posts, Tag Searches</td>\n    <td></td>\n</tr>\n<tr id=\"thefap\" title=\"thefap\">\n    <td>TheFap</td>\n    <td>https://thefap.net/</td>\n    <td>Models, Posts</td>\n    <td></td>\n</tr>\n<tr id=\"tiktok\" title=\"tiktok\">\n    <td>TikTok</td>\n    <td>https://www.tiktok.com/</td>\n    <td>Avatars, Followed Users (Stories Only), Likes, Posts, User Posts, Reposts, Saved Posts, Stories, User Profiles, VM Posts</td>\n    <td><a href=\"https://github.com/mikf/gallery-dl#cookies\">Cookies</a></td>\n</tr>\n<tr id=\"tmohentai\" title=\"tmohentai\">\n    <td>TMOHentai</td>\n    <td>https://tmohentai.com/</td>\n    <td>Galleries</td>\n    <td></td>\n</tr>\n<tr id=\"toyhouse\" title=\"toyhouse\">\n    <td>Toyhouse</td>\n    <td>https://toyhou.se/</td>\n    <td>Art, individual Images</td>\n    <td></td>\n</tr>\n<tr id=\"tumblr\" title=\"tumblr\">\n    <td>Tumblr</td>\n    <td>https://www.tumblr.com/</td>\n    <td>Days, Followers, Followed Users, Likes, Posts, Search Results, Tag Searches, User Profiles</td>\n    <td><a href=\"https://github.com/mikf/gallery-dl#oauth\">OAuth</a></td>\n</tr>\n<tr id=\"tumblrgallery\" title=\"tumblrgallery\">\n    <td>TumblrGallery</td>\n    <td>https://tumblrgallery.xyz/</td>\n    <td>Posts, Search Results, Tumblrblogs</td>\n    <td></td>\n</tr>\n<tr id=\"tungsten\" title=\"tungsten\">\n    <td>Tungsten</td>\n    <td>https://tungsten.run/</td>\n    <td>Models, Posts, User Profiles</td>\n    <td></td>\n</tr>\n<tr id=\"turbo\" title=\"turbo\">\n    <td>turbo.cr</td>\n    <td>https://turbo.cr/</td>\n    <td>Albums, Media Files</td>\n    <td></td>\n</tr>\n<tr id=\"twibooru\" title=\"twibooru\">\n    <td>Twibooru</td>\n    <td>https://twibooru.org/</td>\n    <td>Galleries, Posts, Search Results</td>\n    <td></td>\n</tr>\n<tr id=\"twitter\" title=\"twitter\">\n    <td>Twitter</td>\n    <td>https://x.com/</td>\n    <td>Avatars, Backgrounds, Bookmarks, Communities, Events, Followers, Followed Users, Hashtags, Highlights, Home Feed, individual Images, User Profile Information, Likes, Lists, List Members, Media Timelines, Notifications, Quotes, Search Results, Timelines, Tweets, User Profiles</td>\n    <td><a href=\"https://github.com/mikf/gallery-dl#cookies\">Cookies</a></td>\n</tr>\n<tr id=\"unsplash\" title=\"unsplash\">\n    <td>Unsplash</td>\n    <td>https://unsplash.com/</td>\n    <td>Collections, Favorites, individual Images, Search Results, User Profiles</td>\n    <td></td>\n</tr>\n<tr id=\"uploadir\" title=\"uploadir\">\n    <td>Uploadir</td>\n    <td>https://uploadir.com/</td>\n    <td>Files</td>\n    <td></td>\n</tr>\n<tr id=\"urlgalleries\" title=\"urlgalleries\">\n    <td>Urlgalleries</td>\n    <td>https://urlgalleries.com/</td>\n    <td>Galleries</td>\n    <td></td>\n</tr>\n<tr id=\"vipergirls\" title=\"vipergirls\">\n    <td>Vipergirls</td>\n    <td>https://vipergirls.to/</td>\n    <td>Posts, Threads</td>\n    <td>Supported</td>\n</tr>\n<tr id=\"vk\" title=\"vk\">\n    <td>VK</td>\n    <td>https://vk.com/</td>\n    <td>Albums, Photos, Tagged Photos, individual Wall Posts</td>\n    <td></td>\n</tr>\n<tr id=\"vsco\" title=\"vsco\">\n    <td>VSCO</td>\n    <td>https://vsco.co/</td>\n    <td>Avatars, Collections, Galleries, individual Images, Spaces, User Profiles, Videos</td>\n    <td></td>\n</tr>\n<tr id=\"wallhaven\" title=\"wallhaven\">\n    <td>Wallhaven</td>\n    <td>https://wallhaven.cc/</td>\n    <td>Collections, individual Images, Search Results, User Profiles</td>\n    <td><a href=\"https://gdl-org.github.io/docs/configuration.html#extractor-wallhaven-api-key\">API Key</a></td>\n</tr>\n<tr id=\"wallpapercave\" title=\"wallpapercave\">\n    <td>Wallpaper Cave</td>\n    <td>https://wallpapercave.com/</td>\n    <td>individual Images, Search Results</td>\n    <td></td>\n</tr>\n<tr id=\"warosu\" title=\"warosu\">\n    <td>Warosu</td>\n    <td>https://warosu.org/</td>\n    <td>Threads</td>\n    <td></td>\n</tr>\n<tr id=\"weasyl\" title=\"weasyl\">\n    <td>Weasyl</td>\n    <td>https://www.weasyl.com/</td>\n    <td>Favorites, Folders, Journals, Submissions</td>\n    <td><a href=\"https://gdl-org.github.io/docs/configuration.html#extractor-weasyl-api-key\">API Key</a></td>\n</tr>\n<tr id=\"webmshare\" title=\"webmshare\">\n    <td>webmshare</td>\n    <td>https://webmshare.com/</td>\n    <td>Videos</td>\n    <td></td>\n</tr>\n<tr id=\"webtoons\" title=\"webtoons\">\n    <td>WEBTOON</td>\n    <td>https://www.webtoons.com/</td>\n    <td>Artists, Comics, Episodes</td>\n    <td></td>\n</tr>\n<tr id=\"weebcentral\" title=\"weebcentral\">\n    <td>Weeb Central</td>\n    <td>https://weebcentral.com/</td>\n    <td>Chapters, Manga</td>\n    <td></td>\n</tr>\n<tr id=\"weebdex\" title=\"weebdex\">\n    <td>WeebDex</td>\n    <td>https://weebdex.org/</td>\n    <td>Chapters, Manga</td>\n    <td></td>\n</tr>\n<tr id=\"weibo\" title=\"weibo\">\n    <td>Weibo</td>\n    <td>https://www.weibo.com/</td>\n    <td>Albums, Articles, Feeds, Images from Statuses, User Profiles, Videos</td>\n    <td></td>\n</tr>\n<tr id=\"whyp\" title=\"whyp\">\n    <td>Whyp</td>\n    <td>https://whyp.it/</td>\n    <td>Audio, Collections, User Profiles</td>\n    <td></td>\n</tr>\n<tr id=\"wikiart\" title=\"wikiart\">\n    <td>WikiArt.org</td>\n    <td>https://www.wikiart.org/</td>\n    <td>Artists, Artist Listings, Artworks, individual Images</td>\n    <td></td>\n</tr>\n<tr id=\"wikifeet\" title=\"wikifeet\">\n    <td>Wikifeet</td>\n    <td>https://www.wikifeet.com/</td>\n    <td>Galleries</td>\n    <td></td>\n</tr>\n<tr id=\"wikifeetx\" title=\"wikifeetx\">\n    <td>Wikifeetx</td>\n    <td>https://www.wikifeetx.com/</td>\n    <td>Galleries</td>\n    <td></td>\n</tr>\n<tr id=\"xasiat\" title=\"xasiat\">\n    <td>Xasiat</td>\n    <td>https://www.xasiat.com</td>\n    <td>Albums, Categories, Models, Tag Searches</td>\n    <td></td>\n</tr>\n<tr id=\"xfolio\" title=\"xfolio\">\n    <td>Xfolio</td>\n    <td>https://xfolio.jp/</td>\n    <td>Series, User Profiles, Works</td>\n    <td></td>\n</tr>\n<tr id=\"xhamster\" title=\"xhamster\">\n    <td>xHamster</td>\n    <td>https://xhamster.com/</td>\n    <td>Galleries, User Profiles</td>\n    <td></td>\n</tr>\n<tr id=\"xvideos\" title=\"xvideos\">\n    <td>XVideos</td>\n    <td>https://www.xvideos.com/</td>\n    <td>Galleries, User Profiles</td>\n    <td></td>\n</tr>\n<tr id=\"yiffverse\" title=\"yiffverse\">\n    <td>Yiff verse</td>\n    <td>https://yiffverse.com/</td>\n    <td>Playlists, Posts, Tag Searches</td>\n    <td></td>\n</tr>\n<tr id=\"yourlesbians\" title=\"yourlesbians\">\n    <td>YourLesbians</td>\n    <td>https://yourlesbians.com/</td>\n    <td>Albums</td>\n    <td></td>\n</tr>\n<tr id=\"zerochan\" title=\"zerochan\">\n    <td>Zerochan</td>\n    <td>https://www.zerochan.net/</td>\n    <td>individual Images, Tag Searches</td>\n    <td>Supported</td>\n</tr>\n<tr id=\"kabeuchi\" title=\"kabeuchi\">\n    <td>かべうち</td>\n    <td>https://kabe-uchiroom.com/</td>\n    <td>User Profiles</td>\n    <td></td>\n</tr>\n<tr id=\"vanillarock\" title=\"vanillarock\">\n    <td>もえぴりあ</td>\n    <td>https://vanilla-rock.com/</td>\n    <td>Posts, Tag Searches</td>\n    <td></td>\n</tr>\n\n<tr id=\"2chen\" title=\"2chen\">\n    <td colspan=\"4\"><strong>2chen Instances</strong></td>\n</tr>\n<tr id=\"sturdychan\" title=\"sturdychan\">\n    <td>Sturdychan</td>\n    <td>https://sturdychan.help/</td>\n    <td>Boards, Threads</td>\n    <td></td>\n</tr>\n<tr id=\"schan\" title=\"schan\">\n    <td>Schan</td>\n    <td>https://schan.help/</td>\n    <td>Boards, Threads</td>\n    <td></td>\n</tr>\n\n<tr id=\"blogger\" title=\"blogger\">\n    <td colspan=\"4\"><strong>Blogger Instances</strong></td>\n</tr>\n<tr id=\"blogspot\" title=\"blogspot\">\n    <td>Blogspot</td>\n    <td>https://www.blogger.com/</td>\n    <td>Blogs, Labels, Posts, Search Results</td>\n    <td></td>\n</tr>\n\n<tr id=\"chevereto\" title=\"chevereto\">\n    <td colspan=\"4\"><strong>Chevereto Instances</strong></td>\n</tr>\n<tr id=\"jpgfish\" title=\"jpgfish\">\n    <td>JPG Fish</td>\n    <td>https://jpg7.cr/</td>\n    <td>Albums, Categories, Files, User Profiles</td>\n    <td></td>\n</tr>\n<tr id=\"imglike\" title=\"imglike\">\n    <td>Nude Celeb</td>\n    <td>https://imglike.com/</td>\n    <td>Albums, Categories, Files, User Profiles</td>\n    <td></td>\n</tr>\n\n<tr id=\"Danbooru\" title=\"Danbooru\">\n    <td colspan=\"4\"><strong>Danbooru Instances</strong></td>\n</tr>\n<tr id=\"danbooru\" title=\"danbooru\">\n    <td>Danbooru</td>\n    <td>https://danbooru.donmai.us/</td>\n    <td>Artists, Artist Searches, Favorite Groups, Media Assets, Pools, Popular Images, Posts, Random Posts, Tag Searches</td>\n    <td>Supported</td>\n</tr>\n<tr id=\"atfbooru\" title=\"atfbooru\">\n    <td>ATFBooru</td>\n    <td>https://booru.allthefallen.moe/</td>\n    <td>Artists, Artist Searches, Favorite Groups, Media Assets, Pools, Popular Images, Posts, Random Posts, Tag Searches</td>\n    <td>Supported</td>\n</tr>\n<tr id=\"aibooru\" title=\"aibooru\">\n    <td>AIBooru</td>\n    <td>https://aibooru.online/</td>\n    <td>Artists, Artist Searches, Favorite Groups, Media Assets, Pools, Popular Images, Posts, Random Posts, Tag Searches</td>\n    <td>Supported</td>\n</tr>\n<tr id=\"booruvar\" title=\"booruvar\">\n    <td>Booruvar</td>\n    <td>https://booru.borvar.art/</td>\n    <td>Artists, Artist Searches, Favorite Groups, Media Assets, Pools, Popular Images, Posts, Random Posts, Tag Searches</td>\n    <td>Supported</td>\n</tr>\n\n<tr id=\"E621\" title=\"E621\">\n    <td colspan=\"4\"><strong>e621 Instances</strong></td>\n</tr>\n<tr id=\"e621\" title=\"e621\">\n    <td>e621</td>\n    <td>https://e621.net/</td>\n    <td>Artists, Artist Searches, Favorites, Pools, Popular Images, Posts, Tag Searches, Frontends</td>\n    <td>Supported</td>\n</tr>\n<tr id=\"e926\" title=\"e926\">\n    <td>e926</td>\n    <td>https://e926.net/</td>\n    <td>Artists, Artist Searches, Favorites, Pools, Popular Images, Posts, Tag Searches</td>\n    <td>Supported</td>\n</tr>\n<tr id=\"e6ai\" title=\"e6ai\">\n    <td>e6AI</td>\n    <td>https://e6ai.net/</td>\n    <td>Artists, Artist Searches, Favorites, Pools, Popular Images, Posts, Tag Searches</td>\n    <td>Supported</td>\n</tr>\n\n<tr id=\"gelbooru_v01\" title=\"gelbooru_v01\">\n    <td colspan=\"4\"><strong>Gelbooru Beta 0.1.11</strong></td>\n</tr>\n<tr id=\"thecollection\" title=\"thecollection\">\n    <td>The /co/llection</td>\n    <td>https://the-collection.booru.org/</td>\n    <td>Favorites, Posts, Tag Searches</td>\n    <td></td>\n</tr>\n<tr id=\"illusioncardsbooru\" title=\"illusioncardsbooru\">\n    <td>Illusion Game Cards</td>\n    <td>https://illusioncards.booru.org/</td>\n    <td>Favorites, Posts, Tag Searches</td>\n    <td></td>\n</tr>\n<tr id=\"allgirlbooru\" title=\"allgirlbooru\">\n    <td>All girl</td>\n    <td>https://allgirl.booru.org/</td>\n    <td>Favorites, Posts, Tag Searches</td>\n    <td></td>\n</tr>\n<tr id=\"drawfriends\" title=\"drawfriends\">\n    <td>Draw Friends</td>\n    <td>https://drawfriends.booru.org/</td>\n    <td>Favorites, Posts, Tag Searches</td>\n    <td></td>\n</tr>\n<tr id=\"vidyart2\" title=\"vidyart2\">\n    <td>/v/idyart2</td>\n    <td>https://vidyart2.booru.org/</td>\n    <td>Favorites, Posts, Tag Searches</td>\n    <td></td>\n</tr>\n\n<tr id=\"gelbooru_v02\" title=\"gelbooru_v02\">\n    <td colspan=\"4\"><strong>Gelbooru Beta 0.2</strong></td>\n</tr>\n<tr id=\"rule34\" title=\"rule34\">\n    <td>Rule 34</td>\n    <td>https://rule34.xxx/</td>\n    <td>Favorites, Pools, Posts, Tag Searches</td>\n    <td></td>\n</tr>\n<tr id=\"safebooru\" title=\"safebooru\">\n    <td>Safebooru</td>\n    <td>https://safebooru.org/</td>\n    <td>Favorites, Pools, Posts, Tag Searches</td>\n    <td></td>\n</tr>\n<tr id=\"tbib\" title=\"tbib\">\n    <td>The Big ImageBoard</td>\n    <td>https://tbib.org/</td>\n    <td>Favorites, Pools, Posts, Tag Searches</td>\n    <td></td>\n</tr>\n<tr id=\"hypnohub\" title=\"hypnohub\">\n    <td>Hypnohub</td>\n    <td>https://hypnohub.net/</td>\n    <td>Favorites, Pools, Posts, Tag Searches</td>\n    <td></td>\n</tr>\n<tr id=\"xbooru\" title=\"xbooru\">\n    <td>Xbooru</td>\n    <td>https://xbooru.com/</td>\n    <td>Favorites, Pools, Posts, Tag Searches</td>\n    <td></td>\n</tr>\n\n<tr id=\"hentaicosplays\" title=\"hentaicosplays\">\n    <td colspan=\"4\"><strong>Hentai Cosplay Instances</strong></td>\n</tr>\n<tr id=\"hentaicosplay\" title=\"hentaicosplay\">\n    <td>Hentai Cosplay</td>\n    <td>https://hentai-cosplay-xxx.com/</td>\n    <td>Galleries</td>\n    <td></td>\n</tr>\n<tr id=\"hentaiimg\" title=\"hentaiimg\">\n    <td>Hentai Image</td>\n    <td>https://hentai-img-xxx.com/</td>\n    <td>Galleries</td>\n    <td></td>\n</tr>\n<tr id=\"pornimage\" title=\"pornimage\">\n    <td>Porn Image</td>\n    <td>https://porn-image.com/</td>\n    <td>Galleries</td>\n    <td></td>\n</tr>\n\n<tr id=\"IMHentai\" title=\"IMHentai\">\n    <td colspan=\"4\"><strong>IMHentai and Mirror Sites</strong></td>\n</tr>\n<tr id=\"imhentai\" title=\"imhentai\">\n    <td>IMHentai</td>\n    <td>https://imhentai.xxx/</td>\n    <td>Galleries, Search Results, Tag Searches</td>\n    <td></td>\n</tr>\n<tr id=\"hentaiera\" title=\"hentaiera\">\n    <td>HentaiEra</td>\n    <td>https://hentaiera.com/</td>\n    <td>Galleries, Search Results, Tag Searches</td>\n    <td></td>\n</tr>\n<tr id=\"hentairox\" title=\"hentairox\">\n    <td>HentaiRox</td>\n    <td>https://hentairox.com/</td>\n    <td>Galleries, Search Results, Tag Searches</td>\n    <td></td>\n</tr>\n<tr id=\"hentaifox\" title=\"hentaifox\">\n    <td>HentaiFox</td>\n    <td>https://hentaifox.com/</td>\n    <td>Galleries, Search Results, Tag Searches</td>\n    <td></td>\n</tr>\n<tr id=\"hentaienvy\" title=\"hentaienvy\">\n    <td>HentaiEnvy</td>\n    <td>https://hentaienvy.com/</td>\n    <td>Galleries, Search Results, Tag Searches</td>\n    <td></td>\n</tr>\n<tr id=\"hentaizap\" title=\"hentaizap\">\n    <td>HentaiZap</td>\n    <td>https://hentaizap.com/</td>\n    <td>Galleries, Search Results, Tag Searches</td>\n    <td></td>\n</tr>\n\n<tr id=\"jschan\" title=\"jschan\">\n    <td colspan=\"4\"><strong>jschan Imageboards</strong></td>\n</tr>\n<tr id=\"94chan\" title=\"94chan\">\n    <td>94chan</td>\n    <td>https://94chan.org/</td>\n    <td>Boards, Threads</td>\n    <td></td>\n</tr>\n\n<tr id=\"lynxchan\" title=\"lynxchan\">\n    <td colspan=\"4\"><strong>LynxChan Imageboards</strong></td>\n</tr>\n<tr id=\"bbw-chan\" title=\"bbw-chan\">\n    <td>Bbw-chan</td>\n    <td>https://bbw-chan.link/</td>\n    <td>Boards, Threads</td>\n    <td></td>\n</tr>\n<tr id=\"kohlchan\" title=\"kohlchan\">\n    <td>Kohlchan</td>\n    <td>https://kohlchan.net/</td>\n    <td>Boards, Threads</td>\n    <td></td>\n</tr>\n<tr id=\"endchan\" title=\"endchan\">\n    <td>Endchan</td>\n    <td>https://endchan.org/</td>\n    <td>Boards, Threads</td>\n    <td></td>\n</tr>\n\n<tr id=\"manganelo\" title=\"manganelo\">\n    <td colspan=\"4\"><strong>MangaNelo and Mirror Sites</strong></td>\n</tr>\n<tr id=\"nelomanga\" title=\"nelomanga\">\n    <td>MangaNelo</td>\n    <td>https://www.nelomanga.net/</td>\n    <td>Bookmarks, Chapters, Manga</td>\n    <td></td>\n</tr>\n<tr id=\"natomanga\" title=\"natomanga\">\n    <td>MangaNato</td>\n    <td>https://www.natomanga.com/</td>\n    <td>Bookmarks, Chapters, Manga</td>\n    <td></td>\n</tr>\n<tr id=\"manganato\" title=\"manganato\">\n    <td>MangaNato</td>\n    <td>https://www.manganato.gg/</td>\n    <td>Bookmarks, Chapters, Manga</td>\n    <td></td>\n</tr>\n<tr id=\"mangakakalot\" title=\"mangakakalot\">\n    <td>MangaKakalot</td>\n    <td>https://www.mangakakalot.gg/</td>\n    <td>Bookmarks, Chapters, Manga</td>\n    <td></td>\n</tr>\n\n<tr id=\"misskey\" title=\"misskey\">\n    <td colspan=\"4\"><strong>Misskey Instances</strong></td>\n</tr>\n<tr id=\"misskey.io\" title=\"misskey.io\">\n    <td>Misskey.io</td>\n    <td>https://misskey.io/</td>\n    <td>Avatars, Backgrounds, Favorites, Followed Users, User Profile Information, Notes, User Notes, User Profiles</td>\n    <td></td>\n</tr>\n<tr id=\"misskey.design\" title=\"misskey.design\">\n    <td>Misskey.design</td>\n    <td>https://misskey.design/</td>\n    <td>Avatars, Backgrounds, Favorites, Followed Users, User Profile Information, Notes, User Notes, User Profiles</td>\n    <td></td>\n</tr>\n<tr id=\"misskey.art\" title=\"misskey.art\">\n    <td>Misskey.art</td>\n    <td>https://misskey.art/</td>\n    <td>Avatars, Backgrounds, Favorites, Followed Users, User Profile Information, Notes, User Notes, User Profiles</td>\n    <td></td>\n</tr>\n<tr id=\"lesbian.energy\" title=\"lesbian.energy\">\n    <td>Lesbian.energy</td>\n    <td>https://lesbian.energy/</td>\n    <td>Avatars, Backgrounds, Favorites, Followed Users, User Profile Information, Notes, User Notes, User Profiles</td>\n    <td></td>\n</tr>\n<tr id=\"sushi.ski\" title=\"sushi.ski\">\n    <td>Sushi.ski</td>\n    <td>https://sushi.ski/</td>\n    <td>Avatars, Backgrounds, Favorites, Followed Users, User Profile Information, Notes, User Notes, User Profiles</td>\n    <td></td>\n</tr>\n\n<tr id=\"Nijie\" title=\"Nijie\">\n    <td colspan=\"4\"><strong>Nijie Instances</strong></td>\n</tr>\n<tr id=\"nijie\" title=\"nijie\">\n    <td>nijie</td>\n    <td>https://nijie.info/</td>\n    <td>Doujin, Favorites, Feeds, Followed Users, Illustrations, individual Images, Nuita History, User Profiles</td>\n    <td>Required</td>\n</tr>\n<tr id=\"horne\" title=\"horne\">\n    <td>horne</td>\n    <td>https://horne.red/</td>\n    <td>Doujin, Favorites, Feeds, Followeds, Illustrations, individual Images, Nuitas, User Profiles</td>\n    <td>Required</td>\n</tr>\n\n<tr id=\"nitter\" title=\"nitter\">\n    <td colspan=\"4\"><strong>Nitter Instances</strong></td>\n</tr>\n<tr id=\"nitter.net\" title=\"nitter.net\">\n    <td>Nitter.net</td>\n    <td>https://nitter.net/</td>\n    <td>Media Files, Replies, Search Results, Tweets</td>\n    <td></td>\n</tr>\n<tr id=\"nitter.space\" title=\"nitter.space\">\n    <td>Nitter.space</td>\n    <td>https://nitter.space/</td>\n    <td>Media Files, Replies, Search Results, Tweets</td>\n    <td></td>\n</tr>\n<tr id=\"nitter.tiekoetter\" title=\"nitter.tiekoetter\">\n    <td>Nitter.tiekoetter</td>\n    <td>https://nitter.tiekoetter/</td>\n    <td>Media Files, Replies, Search Results, Tweets</td>\n    <td></td>\n</tr>\n<tr id=\"xcancel\" title=\"xcancel\">\n    <td>Xcancel</td>\n    <td>https://xcancel.com/</td>\n    <td>Media Files, Replies, Search Results, Tweets</td>\n    <td></td>\n</tr>\n<tr id=\"lightbrd\" title=\"lightbrd\">\n    <td>Lightbrd</td>\n    <td>https://lightbrd.com/</td>\n    <td>Media Files, Replies, Search Results, Tweets</td>\n    <td></td>\n</tr>\n\n<tr id=\"philomena\" title=\"philomena\">\n    <td colspan=\"4\"><strong>Philomena Instances</strong></td>\n</tr>\n<tr id=\"derpibooru\" title=\"derpibooru\">\n    <td>Derpibooru</td>\n    <td>https://derpibooru.org/</td>\n    <td>Galleries, Posts, Search Results</td>\n    <td><a href=\"https://gdl-org.github.io/docs/configuration.html#extractor-derpibooru-api-key\">API Key</a></td>\n</tr>\n<tr id=\"ponybooru\" title=\"ponybooru\">\n    <td>Ponybooru</td>\n    <td>https://ponybooru.org/</td>\n    <td>Galleries, Posts, Search Results</td>\n    <td>API Key</td>\n</tr>\n<tr id=\"furbooru\" title=\"furbooru\">\n    <td>Furbooru</td>\n    <td>https://furbooru.org/</td>\n    <td>Galleries, Posts, Search Results</td>\n    <td>API Key</td>\n</tr>\n\n<tr id=\"postmill\" title=\"postmill\">\n    <td colspan=\"4\"><strong>Postmill Instances</strong></td>\n</tr>\n<tr id=\"raddle\" title=\"raddle\">\n    <td>Raddle</td>\n    <td>https://raddle.me/</td>\n    <td>Forums, Home Feed, Individual Posts, Search Results, Tag Searches, User Profiles</td>\n    <td></td>\n</tr>\n\n<tr id=\"reactor\" title=\"reactor\">\n    <td colspan=\"4\"><strong>Reactor Instances</strong></td>\n</tr>\n<tr id=\"reactor\" title=\"reactor\">\n    <td>Reactor</td>\n    <td>http://reactor.cc/</td>\n    <td>Posts, Search Results, Tag Searches, User Profiles</td>\n    <td></td>\n</tr>\n<tr id=\"pornreactor\" title=\"pornreactor\">\n    <td>PornReactor</td>\n    <td>http://pornreactor.cc/</td>\n    <td>Posts, Search Results, Tag Searches, User Profiles</td>\n    <td></td>\n</tr>\n<tr id=\"thatpervert\" title=\"thatpervert\">\n    <td>ThatPervert</td>\n    <td>http://thatpervert.com/</td>\n    <td>Posts, Search Results, Tag Searches, User Profiles</td>\n    <td></td>\n</tr>\n\n<tr id=\"shimmie2\" title=\"shimmie2\">\n    <td colspan=\"4\"><strong>Shimmie2 Instances</strong></td>\n</tr>\n<tr id=\"cavemanon\" title=\"cavemanon\">\n    <td>Cavemanon</td>\n    <td>https://booru.cavemanon.xyz/</td>\n    <td>Posts, Tag Searches</td>\n    <td></td>\n</tr>\n<tr id=\"rule34hentai\" title=\"rule34hentai\">\n    <td>Rule34Hentai</td>\n    <td>https://rule34hentai.net/</td>\n    <td>Posts, Tag Searches</td>\n    <td></td>\n</tr>\n<tr id=\"vidyapics\" title=\"vidyapics\">\n    <td>Vidya Booru</td>\n    <td>https://vidya.pics/</td>\n    <td>Posts, Tag Searches</td>\n    <td></td>\n</tr>\n<tr id=\"nozrip\" title=\"nozrip\">\n    <td>GaryC Booru</td>\n    <td>https://noz.rip/booru/</td>\n    <td>Posts, Tag Searches</td>\n    <td></td>\n</tr>\n<tr id=\"thecollectionS\" title=\"thecollectionS\">\n    <td>The /co/llection</td>\n    <td>https://co.llection.pics/</td>\n    <td>Posts, Tag Searches</td>\n    <td></td>\n</tr>\n<tr id=\"soybooru\" title=\"soybooru\">\n    <td>Soybooru</td>\n    <td>https://soybooru.com/</td>\n    <td>Posts, Tag Searches</td>\n    <td></td>\n</tr>\n\n<tr id=\"szurubooru\" title=\"szurubooru\">\n    <td colspan=\"4\"><strong>szurubooru Instances</strong></td>\n</tr>\n<tr id=\"bcbnsfw\" title=\"bcbnsfw\">\n    <td>Bcbnsfw</td>\n    <td>https://booru.bcbnsfw.space/</td>\n    <td>Posts, Tag Searches</td>\n    <td></td>\n</tr>\n<tr id=\"snootbooru\" title=\"snootbooru\">\n    <td>Snootbooru</td>\n    <td>https://snootbooru.com/</td>\n    <td>Posts, Tag Searches</td>\n    <td></td>\n</tr>\n<tr id=\"visuabusters\" title=\"visuabusters\">\n    <td>VISUABUSTERS</td>\n    <td>https://www.visuabusters.com/booru/</td>\n    <td>Posts, Tag Searches</td>\n    <td></td>\n</tr>\n\n<tr id=\"urlshortener\" title=\"urlshortener\">\n    <td colspan=\"4\"><strong>URL Shorteners</strong></td>\n</tr>\n<tr id=\"bitly\" title=\"bitly\">\n    <td>Bitly</td>\n    <td>https://bit.ly/</td>\n    <td>Links</td>\n    <td></td>\n</tr>\n<tr id=\"tco\" title=\"tco\">\n    <td>Twitter t.co</td>\n    <td>https://t.co/</td>\n    <td>Links</td>\n    <td></td>\n</tr>\n\n<tr id=\"vichan\" title=\"vichan\">\n    <td colspan=\"4\"><strong>vichan Imageboards</strong></td>\n</tr>\n<tr id=\"8kun\" title=\"8kun\">\n    <td>8kun</td>\n    <td>https://8kun.top/</td>\n    <td>Boards, Threads</td>\n    <td></td>\n</tr>\n<tr id=\"smugloli\" title=\"smugloli\">\n    <td>Smugloli</td>\n    <td>https://smuglo.li/</td>\n    <td>Boards, Threads</td>\n    <td></td>\n</tr>\n<tr id=\"gurochan\" title=\"gurochan\">\n    <td>Gurochan</td>\n    <td>https://boards.guro.cx/</td>\n    <td>Boards, Threads</td>\n    <td></td>\n</tr>\n\n<tr id=\"wikimedia\" title=\"wikimedia\">\n    <td colspan=\"4\"><strong>Wikimedia Instances</strong></td>\n</tr>\n<tr id=\"wikimedia\" title=\"wikimedia\">\n    <td>Wikimedia</td>\n    <td>https://www.wikimedia.org/</td>\n    <td>Articles, Categories, Files, Wikis</td>\n    <td></td>\n</tr>\n<tr id=\"wikispecies\" title=\"wikispecies\">\n    <td>Wikispecies</td>\n    <td>https://species.wikimedia.org/</td>\n    <td>Articles, Categories, Files, Wikis</td>\n    <td></td>\n</tr>\n<tr id=\"wikimediacommons\" title=\"wikimediacommons\">\n    <td>Wikimedia Commons</td>\n    <td>https://commons.wikimedia.org/</td>\n    <td>Articles, Categories, Files, Wikis</td>\n    <td></td>\n</tr>\n<tr id=\"mediawiki\" title=\"mediawiki\">\n    <td>MediaWiki</td>\n    <td>https://www.mediawiki.org/</td>\n    <td>Articles, Categories, Files, Wikis</td>\n    <td></td>\n</tr>\n<tr id=\"fandom\" title=\"fandom\">\n    <td>Fandom</td>\n    <td>https://www.fandom.com/</td>\n    <td>Articles, Categories, Files, Wikis</td>\n    <td></td>\n</tr>\n<tr id=\"wikigg\" title=\"wikigg\">\n    <td>wiki.gg</td>\n    <td>https://www.wiki.gg/</td>\n    <td>Articles, Categories, Files, Wikis</td>\n    <td></td>\n</tr>\n<tr id=\"mariowiki\" title=\"mariowiki\">\n    <td>Super Mario Wiki</td>\n    <td>https://www.mariowiki.com/</td>\n    <td>Articles, Categories, Files, Wikis</td>\n    <td></td>\n</tr>\n<tr id=\"bulbapedia\" title=\"bulbapedia\">\n    <td>Bulbapedia</td>\n    <td>https://bulbapedia.bulbagarden.net/</td>\n    <td>Articles, Categories, Files, Wikis</td>\n    <td></td>\n</tr>\n<tr id=\"pidgiwiki\" title=\"pidgiwiki\">\n    <td>PidgiWiki</td>\n    <td>https://www.pidgi.net/</td>\n    <td>Articles, Categories, Files, Wikis</td>\n    <td></td>\n</tr>\n<tr id=\"azurlanewiki\" title=\"azurlanewiki\">\n    <td>Azur Lane Wiki</td>\n    <td>https://azurlane.koumakan.jp/</td>\n    <td>Articles, Categories, Files, Wikis</td>\n    <td></td>\n</tr>\n<tr id=\"mgewiki\" title=\"mgewiki\">\n    <td>Monster Girl Encyclopedia Wiki</td>\n    <td>https://mgewiki.moe/</td>\n    <td>Articles, Categories, Files, Wikis</td>\n    <td></td>\n</tr>\n\n<tr id=\"xenforo\" title=\"xenforo\">\n    <td colspan=\"4\"><strong>XenForo Forums</strong></td>\n</tr>\n<tr id=\"simpcity\" title=\"simpcity\">\n    <td>SimpCity Forums</td>\n    <td>https://simpcity.cr/</td>\n    <td>Forums, Albums, Media Categories, Media Files, User Media, Posts, Profiles, Threads</td>\n    <td>Supported</td>\n</tr>\n<tr id=\"nudostarforum\" title=\"nudostarforum\">\n    <td>NudoStar Forums</td>\n    <td>https://nudostar.com/forum/</td>\n    <td>Forums, Albums, Media Categories, Media Files, User Media, Posts, Profiles, Threads</td>\n    <td>Supported</td>\n</tr>\n<tr id=\"atfforum\" title=\"atfforum\">\n    <td>All The Fallen</td>\n    <td>https://allthefallen.moe/forum/</td>\n    <td>Forums, Albums, Media Categories, Media Files, User Media, Posts, Profiles, Threads</td>\n    <td></td>\n</tr>\n<tr id=\"celebforum\" title=\"celebforum\">\n    <td>celebforum</td>\n    <td>https://celebforum.to/</td>\n    <td>Forums, Albums, Media Categories, Media Files, User Media, Posts, Profiles, Threads</td>\n    <td></td>\n</tr>\n<tr id=\"titsintops\" title=\"titsintops\">\n    <td>Tits In Tops Forum</td>\n    <td>https://titsintops.com/phpBB2/</td>\n    <td>Forums, Albums, Media Categories, Media Files, User Media, Posts, Profiles, Threads</td>\n    <td></td>\n</tr>\n<tr id=\"socialmediagirlsforum\" title=\"socialmediagirlsforum\">\n    <td>Social Media Girls Forums</td>\n    <td>https://forums.socialmediagirls.com/</td>\n    <td>Forums, Albums, Media Categories, Media Files, User Media, Posts, Profiles, Threads</td>\n    <td></td>\n</tr>\n<tr id=\"blacktowhite\" title=\"blacktowhite\">\n    <td>BlacktoWhite</td>\n    <td>https://www.blacktowhite.net/</td>\n    <td>Forums, Albums, Media Categories, Media Files, User Media, Posts, Profiles, Threads</td>\n    <td></td>\n</tr>\n\n<tr id=\"moebooru\" title=\"moebooru\">\n    <td colspan=\"4\"><strong>Moebooru and MyImouto</strong></td>\n</tr>\n<tr id=\"yandere\" title=\"yandere\">\n    <td>yande.re</td>\n    <td>https://yande.re/</td>\n    <td>Pools, Popular Images, Posts, Tag Searches</td>\n    <td></td>\n</tr>\n<tr id=\"konachan\" title=\"konachan\">\n    <td>Konachan</td>\n    <td>https://konachan.com/</td>\n    <td>Pools, Popular Images, Posts, Tag Searches</td>\n    <td></td>\n</tr>\n<tr id=\"sakugabooru\" title=\"sakugabooru\">\n    <td>Sakugabooru</td>\n    <td>https://www.sakugabooru.com/</td>\n    <td>Pools, Popular Images, Posts, Tag Searches</td>\n    <td></td>\n</tr>\n<tr id=\"lolibooru\" title=\"lolibooru\">\n    <td>Lolibooru</td>\n    <td>https://lolibooru.moe/</td>\n    <td>Pools, Popular Images, Posts, Tag Searches</td>\n    <td></td>\n</tr>\n\n<tr id=\"foolfuuka\" title=\"foolfuuka\">\n    <td colspan=\"4\"><strong>FoolFuuka 4chan Archives</strong></td>\n</tr>\n<tr id=\"4plebs\" title=\"4plebs\">\n    <td>4plebs</td>\n    <td>https://archive.4plebs.org/</td>\n    <td>Boards, Galleries, Search Results, Threads</td>\n    <td></td>\n</tr>\n<tr id=\"archivedmoe\" title=\"archivedmoe\">\n    <td>Archived.Moe</td>\n    <td>https://archived.moe/</td>\n    <td>Boards, Galleries, Search Results, Threads</td>\n    <td></td>\n</tr>\n<tr id=\"archiveofsins\" title=\"archiveofsins\">\n    <td>Archive of Sins</td>\n    <td>https://archiveofsins.com/</td>\n    <td>Boards, Galleries, Search Results, Threads</td>\n    <td></td>\n</tr>\n<tr id=\"b4k\" title=\"b4k\">\n    <td>arch.b4k.dev</td>\n    <td>https://arch.b4k.dev/</td>\n    <td>Boards, Galleries, Search Results, Threads</td>\n    <td></td>\n</tr>\n<tr id=\"desuarchive\" title=\"desuarchive\">\n    <td>Desuarchive</td>\n    <td>https://desuarchive.org/</td>\n    <td>Boards, Galleries, Search Results, Threads</td>\n    <td></td>\n</tr>\n<tr id=\"fireden\" title=\"fireden\">\n    <td>Fireden</td>\n    <td>https://boards.fireden.net/</td>\n    <td>Boards, Galleries, Search Results, Threads</td>\n    <td></td>\n</tr>\n<tr id=\"palanq\" title=\"palanq\">\n    <td>Palanq</td>\n    <td>https://archive.palanq.win/</td>\n    <td>Boards, Galleries, Search Results, Threads</td>\n    <td></td>\n</tr>\n<tr id=\"rbt\" title=\"rbt\">\n    <td>RebeccaBlackTech</td>\n    <td>https://rbt.asia/</td>\n    <td>Boards, Galleries, Search Results, Threads</td>\n    <td></td>\n</tr>\n<tr id=\"thebarchive\" title=\"thebarchive\">\n    <td>The /b/ Archive</td>\n    <td>https://thebarchive.com/</td>\n    <td>Boards, Galleries, Search Results, Threads</td>\n    <td></td>\n</tr>\n\n<tr id=\"foolslide\" title=\"foolslide\">\n    <td colspan=\"4\"><strong>FoOlSlide Instances</strong></td>\n</tr>\n<tr id=\"\" title=\"\">\n    <td></td>\n    <td></td>\n    <td>Chapters, Manga</td>\n    <td></td>\n</tr>\n\n<tr id=\"mastodon\" title=\"mastodon\">\n    <td colspan=\"4\"><strong>Mastodon Instances</strong></td>\n</tr>\n<tr id=\"mastodon.social\" title=\"mastodon.social\">\n    <td>mastodon.social</td>\n    <td>https://mastodon.social/</td>\n    <td>Bookmarks, Favorites, Followed Users, Hashtags, Lists, Images from Statuses, User Profiles</td>\n    <td><a href=\"https://github.com/mikf/gallery-dl#oauth\">OAuth</a></td>\n</tr>\n<tr id=\"pawoo\" title=\"pawoo\">\n    <td>Pawoo</td>\n    <td>https://pawoo.net/</td>\n    <td>Bookmarks, Favorites, Followed Users, Hashtags, Lists, Images from Statuses, User Profiles</td>\n    <td><a href=\"https://github.com/mikf/gallery-dl#oauth\">OAuth</a></td>\n</tr>\n<tr id=\"baraag\" title=\"baraag\">\n    <td>baraag</td>\n    <td>https://baraag.net/</td>\n    <td>Bookmarks, Favorites, Followed Users, Hashtags, Lists, Images from Statuses, User Profiles</td>\n    <td><a href=\"https://github.com/mikf/gallery-dl#oauth\">OAuth</a></td>\n</tr>\n\n<tr id=\"shopify\" title=\"shopify\">\n    <td colspan=\"4\"><strong>Shopify Instances</strong></td>\n</tr>\n<tr id=\"chelseacrew\" title=\"chelseacrew\">\n    <td>Chelseacrew</td>\n    <td>https://chelseacrew.com/</td>\n    <td>Collections, Products</td>\n    <td></td>\n</tr>\n<tr id=\"fashionnova\" title=\"fashionnova\">\n    <td>Fashion Nova</td>\n    <td>https://www.fashionnova.com/</td>\n    <td>Collections, Products</td>\n    <td></td>\n</tr>\n<tr id=\"loungeunderwear\" title=\"loungeunderwear\">\n    <td>Loungeunderwear</td>\n    <td>https://loungeunderwear.com/</td>\n    <td>Collections, Products</td>\n    <td></td>\n</tr>\n<tr id=\"michaelscameras\" title=\"michaelscameras\">\n    <td>Michaelscameras</td>\n    <td>https://michaels.com.au/</td>\n    <td>Collections, Products</td>\n    <td></td>\n</tr>\n<tr id=\"modcloth\" title=\"modcloth\">\n    <td>Modcloth</td>\n    <td>https://modcloth.com/</td>\n    <td>Collections, Products</td>\n    <td></td>\n</tr>\n<tr id=\"ohpolly\" title=\"ohpolly\">\n    <td>Oh Polly</td>\n    <td>https://www.ohpolly.com/</td>\n    <td>Collections, Products</td>\n    <td></td>\n</tr>\n<tr id=\"omgmiamiswimwear\" title=\"omgmiamiswimwear\">\n    <td>Omg Miami Swimwear</td>\n    <td>https://www.omgmiamiswimwear.com/</td>\n    <td>Collections, Products</td>\n    <td></td>\n</tr>\n<tr id=\"pinupgirlclothing\" title=\"pinupgirlclothing\">\n    <td>Pinupgirlclothing</td>\n    <td>https://pinupgirlclothing.com/</td>\n    <td>Collections, Products</td>\n    <td></td>\n</tr>\n<tr id=\"raidlondon\" title=\"raidlondon\">\n    <td>Raidlondon</td>\n    <td>https://www.raidlondon.com/</td>\n    <td>Collections, Products</td>\n    <td></td>\n</tr>\n<tr id=\"unique-vintage\" title=\"unique-vintage\">\n    <td>Unique-vintage</td>\n    <td>https://www.unique-vintage.com/</td>\n    <td>Collections, Products</td>\n    <td></td>\n</tr>\n<tr id=\"windsorstore\" title=\"windsorstore\">\n    <td>Windsorstore</td>\n    <td>https://www.windsorstore.com/</td>\n    <td>Collections, Products</td>\n    <td></td>\n</tr>\n\n<tr id=\"lolisafe\" title=\"lolisafe\">\n    <td colspan=\"4\"><strong>lolisafe and chibisafe</strong></td>\n</tr>\n<tr id=\"\" title=\"\">\n    <td></td>\n    <td></td>\n    <td>Albums</td>\n    <td></td>\n</tr>\n\n<tr id=\"imagehost\" title=\"imagehost\">\n    <td colspan=\"4\"><strong>Image Hosting Sites</strong></td>\n</tr>\n<tr id=\"acidimg\" title=\"acidimg\">\n    <td>Acidimg</td>\n    <td>https://acidimg.cc/</td>\n    <td>individual Images</td>\n    <td></td>\n</tr>\n<tr id=\"fappic\" title=\"fappic\">\n    <td>Fappic.com</td>\n    <td>https://fappic.com/</td>\n    <td>individual Images</td>\n    <td></td>\n</tr>\n<tr id=\"imagetwist\" title=\"imagetwist\">\n    <td>ImageTwist</td>\n    <td>https://imagetwist.com/</td>\n    <td>Galleries, individual Images</td>\n    <td></td>\n</tr>\n<tr id=\"imagevenue\" title=\"imagevenue\">\n    <td>Imagevenue</td>\n    <td>https://www.imagevenue.com/</td>\n    <td>individual Images</td>\n    <td></td>\n</tr>\n<tr id=\"imgadult\" title=\"imgadult\">\n    <td>ImgAdult</td>\n    <td>https://imgadult.com/</td>\n    <td>individual Images</td>\n    <td></td>\n</tr>\n<tr id=\"imgclick\" title=\"imgclick\">\n    <td>Imgclick</td>\n    <td>http://imgclick.net/</td>\n    <td>individual Images</td>\n    <td></td>\n</tr>\n<tr id=\"imgdrive\" title=\"imgdrive\">\n    <td>ImgDrive.net</td>\n    <td>https://imgdrive.net/</td>\n    <td>individual Images</td>\n    <td></td>\n</tr>\n<tr id=\"imgpv\" title=\"imgpv\">\n    <td>IMGPV</td>\n    <td>https://imgpv.com/</td>\n    <td>individual Images</td>\n    <td></td>\n</tr>\n<tr id=\"imgspice\" title=\"imgspice\">\n    <td>Imgspice</td>\n    <td>https://imgspice.com/</td>\n    <td>individual Images</td>\n    <td></td>\n</tr>\n<tr id=\"imgtaxi\" title=\"imgtaxi\">\n    <td>ImgTaxi.com</td>\n    <td>https://imgtaxi.com/</td>\n    <td>individual Images</td>\n    <td></td>\n</tr>\n<tr id=\"imgwallet\" title=\"imgwallet\">\n    <td>ImgWallet.com</td>\n    <td>https://imgwallet.com/</td>\n    <td>individual Images</td>\n    <td></td>\n</tr>\n<tr id=\"imxto\" title=\"imxto\">\n    <td>IMX.to</td>\n    <td>https://imx.to/</td>\n    <td>Galleries, individual Images</td>\n    <td></td>\n</tr>\n<tr id=\"picstate\" title=\"picstate\">\n    <td>PicState</td>\n    <td>https://picstate.com/</td>\n    <td>individual Images</td>\n    <td></td>\n</tr>\n<tr id=\"pixhost\" title=\"pixhost\">\n    <td>PiXhost</td>\n    <td>https://pixhost.to/</td>\n    <td>Galleries, individual Images</td>\n    <td></td>\n</tr>\n<tr id=\"postimg\" title=\"postimg\">\n    <td>Postimages</td>\n    <td>https://postimages.org/</td>\n    <td>Galleries, individual Images</td>\n    <td></td>\n</tr>\n<tr id=\"silverpic\" title=\"silverpic\">\n    <td>SilverPic.com</td>\n    <td>https://silverpic.net/</td>\n    <td>individual Images</td>\n    <td></td>\n</tr>\n<tr id=\"turboimagehost\" title=\"turboimagehost\">\n    <td>TurboImageHost.com</td>\n    <td>https://www.turboimagehost.com/</td>\n    <td>Galleries, individual Images</td>\n    <td></td>\n</tr>\n<tr id=\"vipr\" title=\"vipr\">\n    <td>Vipr.im</td>\n    <td>https://vipr.im/</td>\n    <td>individual Images</td>\n    <td></td>\n</tr>\n</tbody>\n</table>\n"
  },
  {
    "path": "gallery_dl/__init__.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2014-2026 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nimport os\nimport sys\nimport logging\nfrom . import version, config, option, output, extractor, job, util, exception\n\n__author__ = \"Mike Fährmann\"\n__copyright__ = \"Copyright 2014-2025 Mike Fährmann\"\n__license__ = \"GPLv2\"\n__maintainer__ = \"Mike Fährmann\"\n__email__ = \"mike_faehrmann@web.de\"\n__version__ = version.__version__\n\n\ndef main():\n    try:\n        parser = option.build_parser()\n        args = parser.parse_args()\n        log = output.initialize_logging(args.loglevel)\n\n        # configuration\n        if args.config_type:\n            try:\n                config.default(args.config_type)\n            except Exception as exc:\n                config.log.error(exc)\n        if args.config_load:\n            config.load()\n        if args.configs_extra:\n            config.load(args.configs_extra, strict=True)\n        if args.configs_json:\n            config.load(args.configs_json, strict=True, loads=util.json_loads)\n        if args.configs_yaml:\n            import yaml\n            config.load(args.configs_yaml, strict=True, loads=yaml.safe_load)\n        if args.configs_toml:\n            try:\n                import tomllib as toml\n            except ImportError:\n                import toml\n            config.load(args.configs_toml, strict=True, loads=toml.loads)\n        if not args.colors:\n            output.ANSI = False\n            config.set((), \"colors\", False)\n            if util.WINDOWS:\n                config.set((\"output\",), \"ansi\", False)\n        if args.filename:\n            filename = args.filename\n            if filename == \"/O\":\n                filename = \"{filename}.{extension}\"\n            elif filename.startswith(\"\\\\f\"):\n                filename = f\"\\f{filename[2:]}\"\n            config.set((), \"filename\", filename)\n        if args.directory is not None:\n            config.set((), \"base-directory\", args.directory)\n            config.set((), \"directory\", ())\n        if args.postprocessors:\n            config.set((), \"postprocessors\", args.postprocessors)\n        if args.abort:\n            config.set((), \"skip\", \"abort:\" + args.abort)\n        if args.terminate:\n            config.set((), \"skip\", \"terminate:\" + args.terminate)\n        if args.cookies_from_browser:\n            browser, _, profile = args.cookies_from_browser.partition(\":\")\n            browser, _, keyring = browser.partition(\"+\")\n            browser, _, domain = browser.partition(\"/\")\n            if profile and profile[0] == \":\":\n                container = profile[1:]\n                profile = None\n            else:\n                profile, _, container = profile.partition(\"::\")\n            config.set((), \"cookies\", (\n                browser, profile, keyring, container, domain))\n        if args.options_pp:\n            config.set((), \"postprocessor-options\", args.options_pp)\n        for opts in args.options:\n            config.set(*opts)\n\n        output.configure_standard_streams()\n\n        # signals\n        if signals := config.get((), \"signals-ignore\"):\n            import signal\n            if isinstance(signals, str):\n                signals = signals.split(\",\")\n            for signal_name in signals:\n                signal_num = getattr(signal, signal_name, None)\n                if signal_num is None:\n                    log.warning(\"signal '%s' is not defined\", signal_name)\n                else:\n                    signal.signal(signal_num, signal.SIG_IGN)\n\n        if signals := config.get((), \"signals-actions\"):\n            from . import actions\n            actions.parse_signals(signals)\n\n        # enable ANSI escape sequences on Windows\n        if util.WINDOWS and config.get((\"output\",), \"ansi\", output.COLORS):\n            from ctypes import windll, wintypes, byref\n            kernel32 = windll.kernel32\n            mode = wintypes.DWORD()\n\n            for handle_id in (-11, -12):  # stdout and stderr\n                handle = kernel32.GetStdHandle(handle_id)\n                kernel32.GetConsoleMode(handle, byref(mode))\n                if not mode.value & 0x4:\n                    mode.value |= 0x4\n                    kernel32.SetConsoleMode(handle, mode)\n\n            output.ANSI = True\n\n        # filter environment\n        filterenv = config.get((), \"filters-environment\", True)\n        if filterenv is True:\n            pass\n        elif not filterenv:\n            util.compile_expression = util.compile_expression_raw\n        elif isinstance(filterenv, str):\n            if filterenv == \"raw\":\n                util.compile_expression = util.compile_expression_raw\n            elif filterenv.startswith(\"default\"):\n                util.compile_expression = util.compile_expression_defaultdict\n\n        # format string options\n        if not config.get((), \"format-operator-dot\", True):\n            from . import formatter\n            formatter._attrgetter = formatter.operator.attrgetter\n        if separator := config.get((), \"format-separator\"):\n            from . import formatter\n            formatter._SEPARATOR = separator\n\n        # eval globals\n        if path := config.get((), \"globals\"):\n            util.GLOBALS.update(util.import_file(path).__dict__)\n\n        # loglevels\n        output.configure_logging(args.loglevel)\n        if args.loglevel >= logging.WARNING:\n            config.set((\"output\",), \"mode\", \"null\")\n            config.set((\"downloader\",), \"progress\", None)\n        elif args.loglevel <= logging.DEBUG:\n            import platform\n            import requests\n\n            if util.EXECUTABLE:\n                extra = f\" - Executable ({version.__variant__})\"\n            elif git_head := util.git_head():\n                extra = \" - Git HEAD: \" + git_head\n            else:\n                extra = \"\"\n\n            log.debug(\"Version %s%s\", __version__, extra)\n            log.debug(\"Python %s - %s\",\n                      platform.python_version(), platform.platform())\n            try:\n                log.debug(\"requests %s - urllib3 %s\",\n                          requests.__version__,\n                          requests.packages.urllib3.__version__)\n            except AttributeError:\n                pass\n\n            log.debug(\"Configuration Files %s\", config._files)\n\n        if args.cache_file:\n            config.set((\"cache\",), \"file\", args.cache_file)\n\n        if args.cache_status:\n            from . import cache, text\n            from collections import defaultdict\n\n            path = cache.path()\n            rows = cache.get(\"ALL\")\n            if rows is None:\n                return cache.error()\n\n            try:\n                size = os.stat(path).st_size\n            except Exception:\n                size = 0\n\n            cnts = defaultdict(int)\n            for row in rows:\n                key = row[0].split(\".\")\n                if key[2] == \"utils\":\n                    key = key[3].partition(\"_\")[0]\n                elif key[1] == \"oauth\":\n                    key = text.extr(key[2], \"'\", \"'\")\n                else:\n                    key = key[2]\n                cnts[key] += 1\n                cnts[\"Total\"] += 1\n\n            key_max = 0\n            cnt_max = len(str(cnts[\"Total\"]))\n            for key in cnts:\n                if key_max < (key_len := len(key)):\n                    key_max = key_len\n            if key_max > 24:\n                key_max = 24\n\n            write = sys.stdout.write\n            write(f\"\"\"\\\nFile:\n  {cache.path()}\nSize:\n  {util.format_value(size)}\nEntries:\n\"\"\")\n            for key, cnt in sorted(cnts.items(), key=lambda i: (-i[1], i[0])):\n                write(f\"  {key}{' ' * (key_max-len(key))}: {cnt:>{cnt_max}}\\n\")\n\n            return 0\n\n        if args.cache_show:\n            from . import cache\n            rows = cache.get(args.cache_show)\n            if rows is None:\n                return cache.error()\n            import pickle\n            cut = 2 if args.cache_show in {\"ALL\", \"EXP\", \"VAL\"} else 3\n            for key, value, expires in rows:\n                try:\n                    value = util.json_dumps(pickle.loads(value))\n                except Exception:\n                    value = \"<Invalid Value>\"\n                expires = f\" ({expires})\" if expires else \"\"\n                key = \".\".join(key.split(\".\")[cut:])\n                sys.stdout.write(f\"{key}{expires}:\\n  {value}\\n\\n\")\n            return 0\n\n        if args.cache_clear:\n            from . import cache\n            cnt = cache.clear(args.cache_clear)\n            if cnt is None:\n                return cache.error()\n\n            cache.log.info(\"Deleted %d entr%s from '%s'\",\n                           cnt, \"y\" if cnt == 1 else \"ies\", cache.path())\n            return 0\n\n        if args.cache_vacuum:\n            from . import cache\n            if (db := cache.database()) is None:\n                return cache.error()\n            path = cache.path()\n            before = os.stat(path).st_size\n            cache.log.info(\"Running 'VACUUM'\")\n            db.execute(\"VACUUM\")\n            after = os.stat(path).st_size\n            if before - after:\n                cache.log.info(\"Reduced database size by %s bytes\",\n                               util.format_value(before - after))\n            return 0\n\n        if args.config:\n            if args.config == \"init\":\n                return config.initialize()\n            elif args.config == \"status\":\n                return config.status()\n            else:\n                return config.open_extern()\n\n        if args.print_traffic:\n            import requests\n            requests.packages.urllib3.connection.HTTPConnection.debuglevel = 1\n\n        if args.update:\n            from . import update\n            extr = update.UpdateExtractor.from_url(\"update:\" + args.update)\n            ujob = update.UpdateJob(extr)\n            return ujob.run()\n\n        # category renaming\n        config.remap_categories()\n\n        # extractor modules\n        modules = config.get((\"extractor\",), \"modules\")\n        if modules is not None:\n            if isinstance(modules, str):\n                modules = modules.split(\",\")\n            extractor.modules = modules\n\n        # external modules\n        if args.extractor_sources:\n            sources = args.extractor_sources\n            sources.append(None)\n        else:\n            sources = config.get((\"extractor\",), \"module-sources\")\n\n        if sources:\n            modules = []\n\n            for source in sources:\n                if source:\n                    path = util.expand_path(source)\n                    try:\n                        files = os.listdir(path)\n                        modules.append(extractor._modules_path(path, files))\n                    except Exception as exc:\n                        log.warning(\"Unable to load modules from %s (%s: %s)\",\n                                    path, exc.__class__.__name__, exc)\n                else:\n                    modules.append(extractor._modules_internal())\n\n            if len(modules) > 1:\n                import itertools\n                extractor._module_iter = itertools.chain(*modules)\n            elif not modules:\n                extractor._module_iter = ()\n            else:\n                extractor._module_iter = iter(modules[0])\n\n        if args.list_modules:\n            extractor.modules.append(\"\")\n            sys.stdout.write(\"\\n\".join(extractor.modules))\n\n        elif args.list_extractors is not None:\n            write = sys.stdout.write\n            fmt = (\"{}{}\\nCategory: {} - Subcategory: {}\"\n                   \"\\nExample : {}\\n\\n\").format\n\n            extractors = extractor.extractors()\n            if args.list_extractors:\n                fltr = util.build_extractor_filter(\n                    args.list_extractors, negate=False)\n                extractors = filter(fltr, extractors)\n\n            for extr in extractors:\n                write(fmt(\n                    extr.__name__,\n                    \"\\n\" + extr.__doc__ if extr.__doc__ else \"\",\n                    extr.category, extr.subcategory,\n                    extr.example,\n                ))\n\n        else:\n            if input_files := config.get((), \"input-files\"):\n                for input_file in input_files:\n                    if isinstance(input_file, str):\n                        input_file = (input_file, None)\n                    args.input_files.append(input_file)\n\n            if not args.urls and not args.input_files:\n                if args.cookies_from_browser or config.interpolate(\n                        (\"extractor\",), \"cookies\"):\n                    args.urls.append(\"noop\")\n                else:\n                    parser.error(\n                        \"The following arguments are required: URL\\nUse \"\n                        \"'gallery-dl --help' to get a list of all options.\")\n\n            if args.list_urls:\n                jobtype = job.UrlJob\n                jobtype.maxdepth = args.list_urls\n                if config.get((\"output\",), \"fallback\", True):\n                    jobtype.handle_url = jobtype.handle_url_fallback\n            elif args.dump_json:\n                jobtype = job.DataJob\n                jobtype.resolve = args.dump_json - 1\n            else:\n                jobtype = args.jobtype or job.DownloadJob\n\n            input_manager = InputManager()\n            input_manager.log = input_log = logging.getLogger(\"inputfile\")\n\n            # unsupported file logging handler\n            if handler := output.setup_logging_handler(\n                    \"unsupportedfile\", fmt=\"{message}\", defer=True):\n                ulog = job.Job.ulog = logging.getLogger(\"unsupported\")\n                ulog.addHandler(handler)\n                ulog.propagate = False\n\n            # error file logging handler\n            if handler := output.setup_logging_handler(\n                    \"errorfile\", fmt=\"{message}\", mode=\"a\", defer=True):\n                elog = input_manager.err = logging.getLogger(\"errorfile\")\n                elog.addHandler(handler)\n                elog.propagate = False\n\n            # collect input URLs\n            input_manager.add_list(args.urls)\n\n            if args.input_files:\n                for input_file, action in args.input_files:\n                    try:\n                        path = util.expand_path(input_file)\n                        input_manager.add_file(path, action)\n                    except Exception as exc:\n                        input_log.error(exc)\n                        return getattr(exc, \"code\", 128)\n\n            pformat = config.get((\"output\",), \"progress\", True)\n            if pformat and len(input_manager.urls) > 1 and \\\n                    args.loglevel < logging.ERROR:\n                input_manager.progress(pformat)\n\n            if catmap := config.interpolate((\"extractor\",), \"category-map\"):\n                if catmap == \"compat\":\n                    catmap = {\n                        \"coomer\"       : \"coomerparty\",\n                        \"kemono\"       : \"kemonoparty\",\n                        \"turbo\"        : \"saint\",\n                        \"schalenetwork\": \"koharu\",\n                        \"naver-blog\"   : \"naver\",\n                        \"naver-chzzk\"  : \"chzzk\",\n                        \"naver-webtoon\": \"naverwebtoon\",\n                        \"pixiv-novel\"  : \"pixiv\",\n                        \"pixiv-novel:novel\"   : (\"pixiv\", \"novel\"),\n                        \"pixiv-novel:user\"    : (\"pixiv\", \"novel-user\"),\n                        \"pixiv-novel:series\"  : (\"pixiv\", \"novel-series\"),\n                        \"pixiv-novel:bookmark\": (\"pixiv\", \"novel-bookmark\"),\n                    }\n                from .extractor import common\n                common.CATEGORY_MAP = catmap\n\n            # process input URLs\n            retval = 0\n            for url in input_manager:\n                try:\n                    log.debug(\"Starting %s for '%s'\", jobtype.__name__, url)\n\n                    if isinstance(url, ExtendedUrl):\n                        for opts in url.gconfig:\n                            config.set(*opts)\n                        with config.apply(url.lconfig):\n                            status = jobtype(url.value).run()\n                    else:\n                        status = jobtype(url).run()\n\n                    if status:\n                        retval |= status\n                        input_manager.error()\n                    else:\n                        input_manager.success()\n\n                except exception.RestartExtraction:\n                    log.debug(\"Restarting '%s'\", url)\n                    continue\n                except exception.ControlException:\n                    pass\n                except exception.NoExtractorError:\n                    log.error(\"Unsupported URL '%s'\", url)\n                    retval |= 64\n                    input_manager.error()\n\n                input_manager.next()\n            return retval\n        return 0\n\n    except KeyboardInterrupt:\n        raise SystemExit(\"\\nKeyboardInterrupt\")\n    except BrokenPipeError:\n        pass\n    except OSError as exc:\n        import errno\n        if exc.errno != errno.EPIPE:\n            raise\n    return 1\n\n\nclass InputManager():\n\n    def __init__(self):\n        self.urls = []\n        self.files = ()\n        self.log = self.err = None\n\n        self._url = \"\"\n        self._item = None\n        self._index = 0\n        self._pformat = None\n\n    def add_url(self, url):\n        self.urls.append(url)\n\n    def add_list(self, urls):\n        self.urls += urls\n\n    def add_file(self, path, action=None):\n        \"\"\"Process an input file.\n\n        Lines starting with '#' and empty lines will be ignored.\n        Lines starting with '-' will be interpreted as a key-value pair\n          separated by an '='. where\n          'key' is a dot-separated option name and\n          'value' is a JSON-parsable string.\n          These configuration options will be applied\n          while processing the next URL only.\n        Lines starting with '-G' are the same as above, except these options\n          will be applied for *all* following URLs, i.e. they are Global.\n        Everything else will be used as a potential URL.\n\n        Example input file:\n\n        # settings global options\n        -G base-directory = \"/tmp/\"\n        -G skip = false\n\n        # setting local options for the next URL\n        -filename=\"spaces_are_optional.jpg\"\n        -skip    = true\n\n        https://example.org/\n\n        # next URL uses default filename and 'skip' is false.\n        https://example.com/index.htm # comment1\n        https://example.com/404.htm   # comment2\n        \"\"\"\n        if path == \"-\" and not action:\n            try:\n                lines = sys.stdin.readlines()\n            except Exception:\n                raise exception.InputFileError(\"stdin is not readable\")\n            path = None\n        else:\n            try:\n                with open(path, encoding=\"utf-8\") as fp:\n                    lines = fp.readlines()\n            except Exception as exc:\n                raise exception.InputFileError(str(exc))\n\n            if self.files:\n                self.files[path] = lines\n            else:\n                self.files = {path: lines}\n\n            if action == \"c\":\n                action = self._action_comment\n            elif action == \"d\":\n                action = self._action_delete\n            else:\n                action = None\n\n        gconf = []\n        lconf = []\n        indicies = []\n        strip_comment = None\n        append = self.urls.append\n\n        for n, line in enumerate(lines):\n            line = line.strip()\n\n            if not line or line[0] == \"#\":\n                # empty line or comment\n                continue\n\n            elif line[0] == \"-\":\n                # config spec\n                if len(line) >= 2 and line[1] == \"G\":\n                    conf = gconf\n                    line = line[2:]\n                else:\n                    conf = lconf\n                    line = line[1:]\n                    if action:\n                        indicies.append(n)\n\n                key, sep, value = line.partition(\"=\")\n                if not sep:\n                    raise exception.InputFileError(\n                        f\"Invalid KEY=VALUE pair '{line}' \"\n                        f\"on line {n+1} in {path}\")\n\n                try:\n                    value = util.json_loads(value.strip())\n                except ValueError as exc:\n                    self.log.debug(\"%s: %s\", exc.__class__.__name__, exc)\n                    raise exception.InputFileError(\n                        f\"Unable to parse '{value}' on line {n+1} in {path}\")\n\n                key = key.strip().split(\".\")\n                conf.append((key[:-1], key[-1], value))\n\n            else:\n                # url\n                if \" #\" in line or \"\\t#\" in line:\n                    if strip_comment is None:\n                        strip_comment = util.re(r\"\\s+#.*\").sub\n                    line = strip_comment(\"\", line)\n                if gconf or lconf:\n                    url = ExtendedUrl(line, gconf, lconf)\n                    gconf = []\n                    lconf = []\n                else:\n                    url = line\n\n                if action:\n                    indicies.append(n)\n                    append((url, path, action, indicies))\n                    indicies = []\n                else:\n                    append(url)\n\n    def progress(self, pformat=True):\n        if pformat is True:\n            pformat = \"[{current}/{total}] {url}\\n\"\n        else:\n            pformat += \"\\n\"\n        self._pformat = pformat.format_map\n\n    def next(self):\n        self._index += 1\n\n    def success(self):\n        if self._item:\n            self._rewrite()\n\n    def error(self):\n        if self.err:\n            if self._item:\n                url, path, action, indicies = self._item\n                lines = self.files[path]\n                out = \"\".join(lines[i] for i in indicies)\n                if out and out[-1] == \"\\n\":\n                    out = out[:-1]\n                self._rewrite()\n            else:\n                out = str(self._url)\n            self.err.info(out)\n\n    def _rewrite(self):\n        url, path, action, indicies = self._item\n        path_tmp = path + \".tmp\"\n        lines = self.files[path]\n        action(lines, indicies)\n\n        try:\n            with open(path_tmp, \"w\", encoding=\"utf-8\") as fp:\n                fp.writelines(lines)\n            os.replace(path_tmp, path)\n        except Exception as exc:\n            self.log.warning(\n                \"Unable to update '%s' (%s: %s)\",\n                path, exc.__class__.__name__, exc)\n\n    def _action_comment(self, lines, indicies):\n        for i in indicies:\n            lines[i] = \"# \" + lines[i]\n\n    def _action_delete(self, lines, indicies):\n        for i in indicies:\n            lines[i] = \"\"\n\n    def __iter__(self):\n        self._index = 0\n        return self\n\n    def __next__(self):\n        try:\n            url = self.urls[self._index]\n        except IndexError:\n            raise StopIteration\n\n        if isinstance(url, tuple):\n            self._item = url\n            url = url[0]\n        else:\n            self._item = None\n        self._url = url\n\n        if self._pformat:\n            output.stderr_write(self._pformat({\n                \"total\"  : len(self.urls),\n                \"current\": self._index + 1,\n                \"url\"    : url,\n            }))\n        return url\n\n\nclass ExtendedUrl():\n    \"\"\"URL with attached config key-value pairs\"\"\"\n    __slots__ = (\"value\", \"gconfig\", \"lconfig\")\n\n    def __init__(self, url, gconf, lconf):\n        self.value = url\n        self.gconfig = gconf\n        self.lconfig = lconf\n\n    def __str__(self):\n        return self.value\n"
  },
  {
    "path": "gallery_dl/__main__.py",
    "content": "#!/usr/bin/env python3\n# -*- coding: utf-8 -*-\n\n# Copyright 2017-2023 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nimport sys\n\nif not __package__ and not hasattr(sys, \"frozen\"):\n    import os.path\n    path = os.path.realpath(os.path.abspath(__file__))\n    sys.path.insert(0, os.path.dirname(os.path.dirname(path)))\n\nimport gallery_dl\n\nif __name__ == \"__main__\":\n    raise SystemExit(gallery_dl.main())\n"
  },
  {
    "path": "gallery_dl/actions.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2023-2026 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\" \"\"\"\n\nimport time\nimport logging\nimport operator\nimport functools\nfrom . import util, exception\n\n\ndef parse(spec):\n    if isinstance(spec, str):\n        type, _, args = spec.partition(\" \")\n        before, after = ACTIONS[type](args)\n        return before if after is None else after\n\n    actions_before, actions_after = [], []\n    for s in spec:\n        type, _, args = s.partition(\" \")\n        before, after = ACTIONS[type](args)\n        if before is not None:\n            actions_before.append(before)\n        if after is not None:\n            actions_after.append(after)\n    actions_before.extend(actions_after)\n    return _chain_actions(actions_before)\n\n\ndef parse_logging(actionspec):\n    if isinstance(actionspec, dict):\n        actionspec = actionspec.items()\n\n    actions = {}\n    actions[-logging.DEBUG] = actions_bd = []\n    actions[-logging.INFO] = actions_bi = []\n    actions[-logging.WARNING] = actions_bw = []\n    actions[-logging.ERROR] = actions_be = []\n    actions[logging.DEBUG] = actions_ad = []\n    actions[logging.INFO] = actions_ai = []\n    actions[logging.WARNING] = actions_aw = []\n    actions[logging.ERROR] = actions_ae = []\n\n    for event, spec in actionspec:\n        level, _, pattern = event.partition(\":\")\n        search = util.re(pattern).search if pattern else util.true\n\n        if isinstance(spec, str):\n            type, _, args = spec.partition(\" \")\n            before, after = ACTIONS[type](args)\n        else:\n            actions_before = []\n            actions_after = []\n            for s in spec:\n                type, _, args = s.partition(\" \")\n                before, after = ACTIONS[type](args)\n                if before:\n                    actions_before.append(before)\n                if after:\n                    actions_after.append(after)\n            before = _chain_actions(actions_before)\n            after = _chain_actions(actions_after)\n\n        level = level.strip()\n        if not level or level == \"*\":\n            if before:\n                action = (search, before)\n                actions_bd.append(action)\n                actions_bi.append(action)\n                actions_bw.append(action)\n                actions_be.append(action)\n            if after:\n                action = (search, after)\n                actions_ad.append(action)\n                actions_ai.append(action)\n                actions_aw.append(action)\n                actions_ae.append(action)\n        else:\n            level = _level_to_int(level)\n            if before:\n                actions[-level].append((search, before))\n            if after:\n                actions[level].append((search, after))\n\n    return actions\n\n\ndef parse_signals(actionspec):\n    import signal\n\n    if isinstance(actionspec, dict):\n        actionspec = actionspec.items()\n\n    for signal_name, spec in actionspec:\n        signal_num = getattr(signal, signal_name, None)\n        if signal_num is None:\n            log = logging.getLogger(\"gallery-dl\")\n            log.warning(\"signal '%s' is not defined\", signal_name)\n            continue\n        signal.signal(signal_num, signals_handler(parse(spec)))\n\n\nclass LoggerAdapter():\n\n    def __init__(self, logger, job):\n        self.logger = logger\n        self.extra = job._logger_extra\n        self.actions = job._logger_actions\n\n        self.debug = functools.partial(self.log, logging.DEBUG)\n        self.info = functools.partial(self.log, logging.INFO)\n        self.warning = functools.partial(self.log, logging.WARNING)\n        self.error = functools.partial(self.log, logging.ERROR)\n\n    def log(self, level, msg, *args, **kwargs):\n        msg = str(msg)\n        if args:\n            msg = msg % args\n\n        before = self.actions[-level]\n        after = self.actions[level]\n\n        if before:\n            args = self.extra.copy()\n            args[\"level\"] = level\n\n            for cond, action in before:\n                if cond(msg):\n                    action(args)\n\n            level = args[\"level\"]\n\n        if self.logger.isEnabledFor(level):\n            kwargs[\"extra\"] = self.extra\n            self.logger._log(level, msg, (), **kwargs)\n\n        if after:\n            args = self.extra.copy()\n            for cond, action in after:\n                if cond(msg):\n                    action(args)\n\n    def traceback(self, exc):\n        if self.logger.isEnabledFor(logging.DEBUG):\n            self.logger._log(\n                logging.DEBUG, \"\", None, exc_info=exc, extra=self.extra)\n\n\ndef _level_to_int(level):\n    try:\n        return logging._nameToLevel[level]\n    except KeyError:\n        return int(level)\n\n\ndef _chain_actions(actions):\n    def _chain(args):\n        for action in actions:\n            action(args)\n    return _chain\n\n\ndef signals_handler(action, args={}):\n    def handler(signal_num, frame):\n        action(args)\n    return handler\n\n\n# --------------------------------------------------------------------\n\ndef action_print(opts):\n    def _print(_):\n        print(opts)\n    return None, _print\n\n\ndef action_status(opts):\n    op, value = util.re(r\"\\s*([&|^=])=?\\s*(\\d+)\").match(opts).groups()\n\n    op = {\n        \"&\": operator.and_,\n        \"|\": operator.or_,\n        \"^\": operator.xor,\n        \"=\": lambda x, y: y,\n    }[op]\n\n    value = int(value)\n\n    def _status(args):\n        args[\"job\"].status = op(args[\"job\"].status, value)\n    return _status, None\n\n\ndef action_level(opts):\n    level = _level_to_int(opts.lstrip(\" ~=\"))\n\n    def _level(args):\n        args[\"level\"] = level\n    return _level, None\n\n\ndef action_exec(opts):\n    def _exec(_):\n        util.Popen(opts, shell=True).wait()\n    return None, _exec\n\n\ndef action_wait(opts):\n    if opts:\n        seconds = util.build_duration_func(opts)\n\n        def _wait(args):\n            time.sleep(seconds())\n    else:\n        def _wait(args):\n            input(\"Press Enter to continue\")\n\n    return None, _wait\n\n\ndef action_flag(opts):\n    flag, value = util.re(\n        r\"(?i)(file|post|child|download)(?:\\s*[= ]\\s*(.+))?\"\n    ).match(opts).groups()\n    flag = flag.upper()\n\n    if value is None:\n        value = \"stop\"\n    elif value == \"skip\":\n        value = False\n    else:\n        value = value.lower()\n\n    def _flag(args):\n        util.FLAGS.__dict__[flag] = value\n    return _flag, None\n\n\ndef action_keyword(opts):\n    name, _, value = opts.partition(\" \")\n\n    try:\n        value = value.strip()\n        value = util.json_loads(value)\n    except Exception:\n        pass\n\n    def _keyword(args):\n        args[\"job\"].kwdict[name] = value\n    return _keyword, None\n\n\ndef action_raise(opts):\n    name, _, arg = opts.partition(\" \")\n\n    exc = getattr(exception, name, None)\n    if exc is None:\n        import builtins\n        exc = getattr(builtins, name, Exception)\n\n    if arg:\n        def _raise(args):\n            raise exc(arg)\n    else:\n        def _raise(args):\n            raise exc()\n\n    return None, _raise\n\n\ndef action_abort(opts):\n    def _abort(_):\n        raise exception.StopExtraction(opts or None)\n    return None, _abort\n\n\ndef action_terminate(opts):\n    def _terminate(_):\n        raise exception.TerminateExtraction(opts)\n    return None, _terminate\n\n\ndef action_restart(opts):\n    def _restart(_):\n        raise exception.RestartExtraction(opts)\n    return None, _restart\n\n\ndef action_exit(opts):\n    try:\n        opts = int(opts)\n    except ValueError:\n        pass\n\n    def _exit(_):\n        raise SystemExit(opts)\n    return None, _exit\n\n\nACTIONS = {\n    \"abort\"    : action_abort,\n    \"exec\"     : action_exec,\n    \"exit\"     : action_exit,\n    \"flag\"     : action_flag,\n    \"keyword\"  : action_keyword,\n    \"level\"    : action_level,\n    \"print\"    : action_print,\n    \"raise\"    : action_raise,\n    \"restart\"  : action_restart,\n    \"status\"   : action_status,\n    \"terminate\": action_terminate,\n    \"wait\"     : action_wait,\n}\n"
  },
  {
    "path": "gallery_dl/aes.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This is a slightly modified version of yt-dlp's aes module.\n# https://github.com/yt-dlp/yt-dlp/blob/master/yt_dlp/aes.py\n\nimport struct\nimport binascii\nfrom math import ceil\n\ntry:\n    from Cryptodome.Cipher import AES as Cryptodome_AES\nexcept ImportError:\n    try:\n        from Crypto.Cipher import AES as Cryptodome_AES\n    except ImportError:\n        Cryptodome_AES = None\nexcept Exception as exc:\n    Cryptodome_AES = None\n    import logging\n    logging.getLogger(\"aes\").warning(\n        \"Error when trying to import 'Cryptodome' module (%s: %s)\",\n        exc.__class__.__name__, exc)\n    del logging\n\n\nif Cryptodome_AES:\n    def aes_cbc_decrypt_bytes(data, key, iv):\n        \"\"\"Decrypt bytes with AES-CBC using pycryptodome\"\"\"\n        return Cryptodome_AES.new(\n            key, Cryptodome_AES.MODE_CBC, iv).decrypt(data)\n\n    def aes_gcm_decrypt_and_verify_bytes(data, key, tag, nonce):\n        \"\"\"Decrypt bytes with AES-GCM using pycryptodome\"\"\"\n        return Cryptodome_AES.new(\n            key, Cryptodome_AES.MODE_GCM, nonce).decrypt_and_verify(data, tag)\nelse:\n    def aes_cbc_decrypt_bytes(data, key, iv):\n        \"\"\"Decrypt bytes with AES-CBC using native implementation\"\"\"\n        return intlist_to_bytes(aes_cbc_decrypt(\n            bytes_to_intlist(data),\n            bytes_to_intlist(key),\n            bytes_to_intlist(iv),\n        ))\n\n    def aes_gcm_decrypt_and_verify_bytes(data, key, tag, nonce):\n        \"\"\"Decrypt bytes with AES-GCM using native implementation\"\"\"\n        return intlist_to_bytes(aes_gcm_decrypt_and_verify(\n            bytes_to_intlist(data),\n            bytes_to_intlist(key),\n            bytes_to_intlist(tag),\n            bytes_to_intlist(nonce),\n        ))\n\n\nbytes_to_intlist = list\n\n\ndef intlist_to_bytes(xs):\n    if not xs:\n        return b\"\"\n    return struct.pack(f\"{len(xs)}B\", *xs)\n\n\ndef unpad_pkcs7(data):\n    return data[:-data[-1]]\n\n\nBLOCK_SIZE_BYTES = 16\n\n\ndef aes_ecb_encrypt(data, key, iv=None):\n    \"\"\"\n    Encrypt with aes in ECB mode\n\n    @param {int[]} data        cleartext\n    @param {int[]} key         16/24/32-Byte cipher key\n    @param {int[]} iv          Unused for this mode\n    @returns {int[]}           encrypted data\n    \"\"\"\n    expanded_key = key_expansion(key)\n    block_count = ceil(len(data) / BLOCK_SIZE_BYTES)\n\n    encrypted_data = []\n    for i in range(block_count):\n        block = data[i * BLOCK_SIZE_BYTES: (i + 1) * BLOCK_SIZE_BYTES]\n        encrypted_data += aes_encrypt(block, expanded_key)\n    encrypted_data = encrypted_data[:len(data)]\n\n    return encrypted_data\n\n\ndef aes_ecb_decrypt(data, key, iv=None):\n    \"\"\"\n    Decrypt with aes in ECB mode\n\n    @param {int[]} data        cleartext\n    @param {int[]} key         16/24/32-Byte cipher key\n    @param {int[]} iv          Unused for this mode\n    @returns {int[]}           decrypted data\n    \"\"\"\n    expanded_key = key_expansion(key)\n    block_count = ceil(len(data) / BLOCK_SIZE_BYTES)\n\n    encrypted_data = []\n    for i in range(block_count):\n        block = data[i * BLOCK_SIZE_BYTES: (i + 1) * BLOCK_SIZE_BYTES]\n        encrypted_data += aes_decrypt(block, expanded_key)\n    encrypted_data = encrypted_data[:len(data)]\n\n    return encrypted_data\n\n\ndef aes_ctr_decrypt(data, key, iv):\n    \"\"\"\n    Decrypt with aes in counter mode\n\n    @param {int[]} data        cipher\n    @param {int[]} key         16/24/32-Byte cipher key\n    @param {int[]} iv          16-Byte initialization vector\n    @returns {int[]}           decrypted data\n    \"\"\"\n    return aes_ctr_encrypt(data, key, iv)\n\n\ndef aes_ctr_encrypt(data, key, iv):\n    \"\"\"\n    Encrypt with aes in counter mode\n\n    @param {int[]} data        cleartext\n    @param {int[]} key         16/24/32-Byte cipher key\n    @param {int[]} iv          16-Byte initialization vector\n    @returns {int[]}           encrypted data\n    \"\"\"\n    expanded_key = key_expansion(key)\n    block_count = ceil(len(data) / BLOCK_SIZE_BYTES)\n    counter = iter_vector(iv)\n\n    encrypted_data = []\n    for i in range(block_count):\n        counter_block = next(counter)\n        block = data[i * BLOCK_SIZE_BYTES: (i + 1) * BLOCK_SIZE_BYTES]\n        block += [0] * (BLOCK_SIZE_BYTES - len(block))\n\n        cipher_counter_block = aes_encrypt(counter_block, expanded_key)\n        encrypted_data += xor(block, cipher_counter_block)\n    encrypted_data = encrypted_data[:len(data)]\n\n    return encrypted_data\n\n\ndef aes_cbc_decrypt(data, key, iv):\n    \"\"\"\n    Decrypt with aes in CBC mode\n\n    @param {int[]} data        cipher\n    @param {int[]} key         16/24/32-Byte cipher key\n    @param {int[]} iv          16-Byte IV\n    @returns {int[]}           decrypted data\n    \"\"\"\n    expanded_key = key_expansion(key)\n    block_count = ceil(len(data) / BLOCK_SIZE_BYTES)\n\n    decrypted_data = []\n    previous_cipher_block = iv\n    for i in range(block_count):\n        block = data[i * BLOCK_SIZE_BYTES: (i + 1) * BLOCK_SIZE_BYTES]\n        block += [0] * (BLOCK_SIZE_BYTES - len(block))\n\n        decrypted_block = aes_decrypt(block, expanded_key)\n        decrypted_data += xor(decrypted_block, previous_cipher_block)\n        previous_cipher_block = block\n    decrypted_data = decrypted_data[:len(data)]\n\n    return decrypted_data\n\n\ndef aes_cbc_encrypt(data, key, iv):\n    \"\"\"\n    Encrypt with aes in CBC mode. Using PKCS#7 padding\n\n    @param {int[]} data        cleartext\n    @param {int[]} key         16/24/32-Byte cipher key\n    @param {int[]} iv          16-Byte IV\n    @returns {int[]}           encrypted data\n    \"\"\"\n    expanded_key = key_expansion(key)\n    block_count = ceil(len(data) / BLOCK_SIZE_BYTES)\n\n    encrypted_data = []\n    previous_cipher_block = iv\n    for i in range(block_count):\n        block = data[i * BLOCK_SIZE_BYTES: (i + 1) * BLOCK_SIZE_BYTES]\n        remaining_length = BLOCK_SIZE_BYTES - len(block)\n        block += [remaining_length] * remaining_length\n        mixed_block = xor(block, previous_cipher_block)\n\n        encrypted_block = aes_encrypt(mixed_block, expanded_key)\n        encrypted_data += encrypted_block\n\n        previous_cipher_block = encrypted_block\n\n    return encrypted_data\n\n\ndef aes_gcm_decrypt_and_verify(data, key, tag, nonce):\n    \"\"\"\n    Decrypt with aes in GBM mode and checks authenticity using tag\n\n    @param {int[]} data        cipher\n    @param {int[]} key         16-Byte cipher key\n    @param {int[]} tag         authentication tag\n    @param {int[]} nonce       IV (recommended 12-Byte)\n    @returns {int[]}           decrypted data\n    \"\"\"\n\n    # XXX: check aes, gcm param\n\n    hash_subkey = aes_encrypt([0] * BLOCK_SIZE_BYTES, key_expansion(key))\n\n    if len(nonce) == 12:\n        j0 = nonce + [0, 0, 0, 1]\n    else:\n        fill = (BLOCK_SIZE_BYTES - (len(nonce) % BLOCK_SIZE_BYTES)) % \\\n            BLOCK_SIZE_BYTES + 8\n        ghash_in = nonce + [0] * fill + bytes_to_intlist(\n            (8 * len(nonce)).to_bytes(8, \"big\"))\n        j0 = ghash(hash_subkey, ghash_in)\n\n    # TODO: add nonce support to aes_ctr_decrypt\n\n    # nonce_ctr = j0[:12]\n    iv_ctr = inc(j0)\n\n    decrypted_data = aes_ctr_decrypt(\n        data, key, iv_ctr + [0] * (BLOCK_SIZE_BYTES - len(iv_ctr)))\n\n    pad_len = (\n        (BLOCK_SIZE_BYTES - (len(data) % BLOCK_SIZE_BYTES)) % BLOCK_SIZE_BYTES)\n    s_tag = ghash(\n        hash_subkey,\n        data +\n        [0] * pad_len +                           # pad\n        bytes_to_intlist(\n            (0 * 8).to_bytes(8, \"big\") +          # length of associated data\n            ((len(data) * 8).to_bytes(8, \"big\"))  # length of data\n        )\n    )\n\n    if tag != aes_ctr_encrypt(s_tag, key, j0):\n        raise ValueError(\"Mismatching authentication tag\")\n\n    return decrypted_data\n\n\ndef aes_encrypt(data, expanded_key):\n    \"\"\"\n    Encrypt one block with aes\n\n    @param {int[]} data          16-Byte state\n    @param {int[]} expanded_key  176/208/240-Byte expanded key\n    @returns {int[]}             16-Byte cipher\n    \"\"\"\n    rounds = len(expanded_key) // BLOCK_SIZE_BYTES - 1\n\n    data = xor(data, expanded_key[:BLOCK_SIZE_BYTES])\n    for i in range(1, rounds + 1):\n        data = sub_bytes(data)\n        data = shift_rows(data)\n        if i != rounds:\n            data = list(iter_mix_columns(data, MIX_COLUMN_MATRIX))\n        data = xor(data, expanded_key[\n            i * BLOCK_SIZE_BYTES: (i + 1) * BLOCK_SIZE_BYTES])\n\n    return data\n\n\ndef aes_decrypt(data, expanded_key):\n    \"\"\"\n    Decrypt one block with aes\n\n    @param {int[]} data          16-Byte cipher\n    @param {int[]} expanded_key  176/208/240-Byte expanded key\n    @returns {int[]}             16-Byte state\n    \"\"\"\n    rounds = len(expanded_key) // BLOCK_SIZE_BYTES - 1\n\n    for i in range(rounds, 0, -1):\n        data = xor(data, expanded_key[\n            i * BLOCK_SIZE_BYTES: (i + 1) * BLOCK_SIZE_BYTES])\n        if i != rounds:\n            data = list(iter_mix_columns(data, MIX_COLUMN_MATRIX_INV))\n        data = shift_rows_inv(data)\n        data = sub_bytes_inv(data)\n    data = xor(data, expanded_key[:BLOCK_SIZE_BYTES])\n\n    return data\n\n\ndef aes_decrypt_text(data, password, key_size_bytes):\n    \"\"\"\n    Decrypt text\n    - The first 8 Bytes of decoded 'data' are the 8 high Bytes of the counter\n    - The cipher key is retrieved by encrypting the first 16 Byte of 'password'\n      with the first 'key_size_bytes' Bytes from 'password'\n      (if necessary filled with 0's)\n    - Mode of operation is 'counter'\n\n    @param {str} data                    Base64 encoded string\n    @param {str,unicode} password        Password (will be encoded with utf-8)\n    @param {int} key_size_bytes          Possible values: 16 for 128-Bit,\n                                                          24 for 192-Bit, or\n                                                          32 for 256-Bit\n    @returns {str}                       Decrypted data\n    \"\"\"\n    NONCE_LENGTH_BYTES = 8\n\n    data = bytes_to_intlist(binascii.a2b_base64(data))\n    password = bytes_to_intlist(password.encode(\"utf-8\"))\n\n    key = password[:key_size_bytes] + [0] * (key_size_bytes - len(password))\n    key = aes_encrypt(key[:BLOCK_SIZE_BYTES], key_expansion(key)) * \\\n        (key_size_bytes // BLOCK_SIZE_BYTES)\n\n    nonce = data[:NONCE_LENGTH_BYTES]\n    cipher = data[NONCE_LENGTH_BYTES:]\n\n    return intlist_to_bytes(aes_ctr_decrypt(\n        cipher, key, nonce + [0] * (BLOCK_SIZE_BYTES - NONCE_LENGTH_BYTES)\n    ))\n\n\nRCON = (\n    0x8d, 0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80, 0x1b, 0x36,\n)\n\nSBOX = (\n    0x63, 0x7C, 0x77, 0x7B, 0xF2, 0x6B, 0x6F, 0xC5,\n    0x30, 0x01, 0x67, 0x2B, 0xFE, 0xD7, 0xAB, 0x76,\n    0xCA, 0x82, 0xC9, 0x7D, 0xFA, 0x59, 0x47, 0xF0,\n    0xAD, 0xD4, 0xA2, 0xAF, 0x9C, 0xA4, 0x72, 0xC0,\n    0xB7, 0xFD, 0x93, 0x26, 0x36, 0x3F, 0xF7, 0xCC,\n    0x34, 0xA5, 0xE5, 0xF1, 0x71, 0xD8, 0x31, 0x15,\n    0x04, 0xC7, 0x23, 0xC3, 0x18, 0x96, 0x05, 0x9A,\n    0x07, 0x12, 0x80, 0xE2, 0xEB, 0x27, 0xB2, 0x75,\n    0x09, 0x83, 0x2C, 0x1A, 0x1B, 0x6E, 0x5A, 0xA0,\n    0x52, 0x3B, 0xD6, 0xB3, 0x29, 0xE3, 0x2F, 0x84,\n    0x53, 0xD1, 0x00, 0xED, 0x20, 0xFC, 0xB1, 0x5B,\n    0x6A, 0xCB, 0xBE, 0x39, 0x4A, 0x4C, 0x58, 0xCF,\n    0xD0, 0xEF, 0xAA, 0xFB, 0x43, 0x4D, 0x33, 0x85,\n    0x45, 0xF9, 0x02, 0x7F, 0x50, 0x3C, 0x9F, 0xA8,\n    0x51, 0xA3, 0x40, 0x8F, 0x92, 0x9D, 0x38, 0xF5,\n    0xBC, 0xB6, 0xDA, 0x21, 0x10, 0xFF, 0xF3, 0xD2,\n    0xCD, 0x0C, 0x13, 0xEC, 0x5F, 0x97, 0x44, 0x17,\n    0xC4, 0xA7, 0x7E, 0x3D, 0x64, 0x5D, 0x19, 0x73,\n    0x60, 0x81, 0x4F, 0xDC, 0x22, 0x2A, 0x90, 0x88,\n    0x46, 0xEE, 0xB8, 0x14, 0xDE, 0x5E, 0x0B, 0xDB,\n    0xE0, 0x32, 0x3A, 0x0A, 0x49, 0x06, 0x24, 0x5C,\n    0xC2, 0xD3, 0xAC, 0x62, 0x91, 0x95, 0xE4, 0x79,\n    0xE7, 0xC8, 0x37, 0x6D, 0x8D, 0xD5, 0x4E, 0xA9,\n    0x6C, 0x56, 0xF4, 0xEA, 0x65, 0x7A, 0xAE, 0x08,\n    0xBA, 0x78, 0x25, 0x2E, 0x1C, 0xA6, 0xB4, 0xC6,\n    0xE8, 0xDD, 0x74, 0x1F, 0x4B, 0xBD, 0x8B, 0x8A,\n    0x70, 0x3E, 0xB5, 0x66, 0x48, 0x03, 0xF6, 0x0E,\n    0x61, 0x35, 0x57, 0xB9, 0x86, 0xC1, 0x1D, 0x9E,\n    0xE1, 0xF8, 0x98, 0x11, 0x69, 0xD9, 0x8E, 0x94,\n    0x9B, 0x1E, 0x87, 0xE9, 0xCE, 0x55, 0x28, 0xDF,\n    0x8C, 0xA1, 0x89, 0x0D, 0xBF, 0xE6, 0x42, 0x68,\n    0x41, 0x99, 0x2D, 0x0F, 0xB0, 0x54, 0xBB, 0x16,\n)\n\nSBOX_INV = (\n    0x52, 0x09, 0x6a, 0xd5, 0x30, 0x36, 0xa5, 0x38,\n    0xbf, 0x40, 0xa3, 0x9e, 0x81, 0xf3, 0xd7, 0xfb,\n    0x7c, 0xe3, 0x39, 0x82, 0x9b, 0x2f, 0xff, 0x87,\n    0x34, 0x8e, 0x43, 0x44, 0xc4, 0xde, 0xe9, 0xcb,\n    0x54, 0x7b, 0x94, 0x32, 0xa6, 0xc2, 0x23, 0x3d,\n    0xee, 0x4c, 0x95, 0x0b, 0x42, 0xfa, 0xc3, 0x4e,\n    0x08, 0x2e, 0xa1, 0x66, 0x28, 0xd9, 0x24, 0xb2,\n    0x76, 0x5b, 0xa2, 0x49, 0x6d, 0x8b, 0xd1, 0x25,\n    0x72, 0xf8, 0xf6, 0x64, 0x86, 0x68, 0x98, 0x16,\n    0xd4, 0xa4, 0x5c, 0xcc, 0x5d, 0x65, 0xb6, 0x92,\n    0x6c, 0x70, 0x48, 0x50, 0xfd, 0xed, 0xb9, 0xda,\n    0x5e, 0x15, 0x46, 0x57, 0xa7, 0x8d, 0x9d, 0x84,\n    0x90, 0xd8, 0xab, 0x00, 0x8c, 0xbc, 0xd3, 0x0a,\n    0xf7, 0xe4, 0x58, 0x05, 0xb8, 0xb3, 0x45, 0x06,\n    0xd0, 0x2c, 0x1e, 0x8f, 0xca, 0x3f, 0x0f, 0x02,\n    0xc1, 0xaf, 0xbd, 0x03, 0x01, 0x13, 0x8a, 0x6b,\n    0x3a, 0x91, 0x11, 0x41, 0x4f, 0x67, 0xdc, 0xea,\n    0x97, 0xf2, 0xcf, 0xce, 0xf0, 0xb4, 0xe6, 0x73,\n    0x96, 0xac, 0x74, 0x22, 0xe7, 0xad, 0x35, 0x85,\n    0xe2, 0xf9, 0x37, 0xe8, 0x1c, 0x75, 0xdf, 0x6e,\n    0x47, 0xf1, 0x1a, 0x71, 0x1d, 0x29, 0xc5, 0x89,\n    0x6f, 0xb7, 0x62, 0x0e, 0xaa, 0x18, 0xbe, 0x1b,\n    0xfc, 0x56, 0x3e, 0x4b, 0xc6, 0xd2, 0x79, 0x20,\n    0x9a, 0xdb, 0xc0, 0xfe, 0x78, 0xcd, 0x5a, 0xf4,\n    0x1f, 0xdd, 0xa8, 0x33, 0x88, 0x07, 0xc7, 0x31,\n    0xb1, 0x12, 0x10, 0x59, 0x27, 0x80, 0xec, 0x5f,\n    0x60, 0x51, 0x7f, 0xa9, 0x19, 0xb5, 0x4a, 0x0d,\n    0x2d, 0xe5, 0x7a, 0x9f, 0x93, 0xc9, 0x9c, 0xef,\n    0xa0, 0xe0, 0x3b, 0x4d, 0xae, 0x2a, 0xf5, 0xb0,\n    0xc8, 0xeb, 0xbb, 0x3c, 0x83, 0x53, 0x99, 0x61,\n    0x17, 0x2b, 0x04, 0x7e, 0xba, 0x77, 0xd6, 0x26,\n    0xe1, 0x69, 0x14, 0x63, 0x55, 0x21, 0x0c, 0x7d\n)\n\nMIX_COLUMN_MATRIX = (\n    (0x2, 0x3, 0x1, 0x1),\n    (0x1, 0x2, 0x3, 0x1),\n    (0x1, 0x1, 0x2, 0x3),\n    (0x3, 0x1, 0x1, 0x2),\n)\n\nMIX_COLUMN_MATRIX_INV = (\n    (0xE, 0xB, 0xD, 0x9),\n    (0x9, 0xE, 0xB, 0xD),\n    (0xD, 0x9, 0xE, 0xB),\n    (0xB, 0xD, 0x9, 0xE),\n)\n\nRIJNDAEL_EXP_TABLE = (\n    0x01, 0x03, 0x05, 0x0F, 0x11, 0x33, 0x55, 0xFF,\n    0x1A, 0x2E, 0x72, 0x96, 0xA1, 0xF8, 0x13, 0x35,\n    0x5F, 0xE1, 0x38, 0x48, 0xD8, 0x73, 0x95, 0xA4,\n    0xF7, 0x02, 0x06, 0x0A, 0x1E, 0x22, 0x66, 0xAA,\n    0xE5, 0x34, 0x5C, 0xE4, 0x37, 0x59, 0xEB, 0x26,\n    0x6A, 0xBE, 0xD9, 0x70, 0x90, 0xAB, 0xE6, 0x31,\n    0x53, 0xF5, 0x04, 0x0C, 0x14, 0x3C, 0x44, 0xCC,\n    0x4F, 0xD1, 0x68, 0xB8, 0xD3, 0x6E, 0xB2, 0xCD,\n    0x4C, 0xD4, 0x67, 0xA9, 0xE0, 0x3B, 0x4D, 0xD7,\n    0x62, 0xA6, 0xF1, 0x08, 0x18, 0x28, 0x78, 0x88,\n    0x83, 0x9E, 0xB9, 0xD0, 0x6B, 0xBD, 0xDC, 0x7F,\n    0x81, 0x98, 0xB3, 0xCE, 0x49, 0xDB, 0x76, 0x9A,\n    0xB5, 0xC4, 0x57, 0xF9, 0x10, 0x30, 0x50, 0xF0,\n    0x0B, 0x1D, 0x27, 0x69, 0xBB, 0xD6, 0x61, 0xA3,\n    0xFE, 0x19, 0x2B, 0x7D, 0x87, 0x92, 0xAD, 0xEC,\n    0x2F, 0x71, 0x93, 0xAE, 0xE9, 0x20, 0x60, 0xA0,\n    0xFB, 0x16, 0x3A, 0x4E, 0xD2, 0x6D, 0xB7, 0xC2,\n    0x5D, 0xE7, 0x32, 0x56, 0xFA, 0x15, 0x3F, 0x41,\n    0xC3, 0x5E, 0xE2, 0x3D, 0x47, 0xC9, 0x40, 0xC0,\n    0x5B, 0xED, 0x2C, 0x74, 0x9C, 0xBF, 0xDA, 0x75,\n    0x9F, 0xBA, 0xD5, 0x64, 0xAC, 0xEF, 0x2A, 0x7E,\n    0x82, 0x9D, 0xBC, 0xDF, 0x7A, 0x8E, 0x89, 0x80,\n    0x9B, 0xB6, 0xC1, 0x58, 0xE8, 0x23, 0x65, 0xAF,\n    0xEA, 0x25, 0x6F, 0xB1, 0xC8, 0x43, 0xC5, 0x54,\n    0xFC, 0x1F, 0x21, 0x63, 0xA5, 0xF4, 0x07, 0x09,\n    0x1B, 0x2D, 0x77, 0x99, 0xB0, 0xCB, 0x46, 0xCA,\n    0x45, 0xCF, 0x4A, 0xDE, 0x79, 0x8B, 0x86, 0x91,\n    0xA8, 0xE3, 0x3E, 0x42, 0xC6, 0x51, 0xF3, 0x0E,\n    0x12, 0x36, 0x5A, 0xEE, 0x29, 0x7B, 0x8D, 0x8C,\n    0x8F, 0x8A, 0x85, 0x94, 0xA7, 0xF2, 0x0D, 0x17,\n    0x39, 0x4B, 0xDD, 0x7C, 0x84, 0x97, 0xA2, 0xFD,\n    0x1C, 0x24, 0x6C, 0xB4, 0xC7, 0x52, 0xF6, 0x01,\n)\n\nRIJNDAEL_LOG_TABLE = (\n    0x00, 0x00, 0x19, 0x01, 0x32, 0x02, 0x1a, 0xc6,\n    0x4b, 0xc7, 0x1b, 0x68, 0x33, 0xee, 0xdf, 0x03,\n    0x64, 0x04, 0xe0, 0x0e, 0x34, 0x8d, 0x81, 0xef,\n    0x4c, 0x71, 0x08, 0xc8, 0xf8, 0x69, 0x1c, 0xc1,\n    0x7d, 0xc2, 0x1d, 0xb5, 0xf9, 0xb9, 0x27, 0x6a,\n    0x4d, 0xe4, 0xa6, 0x72, 0x9a, 0xc9, 0x09, 0x78,\n    0x65, 0x2f, 0x8a, 0x05, 0x21, 0x0f, 0xe1, 0x24,\n    0x12, 0xf0, 0x82, 0x45, 0x35, 0x93, 0xda, 0x8e,\n    0x96, 0x8f, 0xdb, 0xbd, 0x36, 0xd0, 0xce, 0x94,\n    0x13, 0x5c, 0xd2, 0xf1, 0x40, 0x46, 0x83, 0x38,\n    0x66, 0xdd, 0xfd, 0x30, 0xbf, 0x06, 0x8b, 0x62,\n    0xb3, 0x25, 0xe2, 0x98, 0x22, 0x88, 0x91, 0x10,\n    0x7e, 0x6e, 0x48, 0xc3, 0xa3, 0xb6, 0x1e, 0x42,\n    0x3a, 0x6b, 0x28, 0x54, 0xfa, 0x85, 0x3d, 0xba,\n    0x2b, 0x79, 0x0a, 0x15, 0x9b, 0x9f, 0x5e, 0xca,\n    0x4e, 0xd4, 0xac, 0xe5, 0xf3, 0x73, 0xa7, 0x57,\n    0xaf, 0x58, 0xa8, 0x50, 0xf4, 0xea, 0xd6, 0x74,\n    0x4f, 0xae, 0xe9, 0xd5, 0xe7, 0xe6, 0xad, 0xe8,\n    0x2c, 0xd7, 0x75, 0x7a, 0xeb, 0x16, 0x0b, 0xf5,\n    0x59, 0xcb, 0x5f, 0xb0, 0x9c, 0xa9, 0x51, 0xa0,\n    0x7f, 0x0c, 0xf6, 0x6f, 0x17, 0xc4, 0x49, 0xec,\n    0xd8, 0x43, 0x1f, 0x2d, 0xa4, 0x76, 0x7b, 0xb7,\n    0xcc, 0xbb, 0x3e, 0x5a, 0xfb, 0x60, 0xb1, 0x86,\n    0x3b, 0x52, 0xa1, 0x6c, 0xaa, 0x55, 0x29, 0x9d,\n    0x97, 0xb2, 0x87, 0x90, 0x61, 0xbe, 0xdc, 0xfc,\n    0xbc, 0x95, 0xcf, 0xcd, 0x37, 0x3f, 0x5b, 0xd1,\n    0x53, 0x39, 0x84, 0x3c, 0x41, 0xa2, 0x6d, 0x47,\n    0x14, 0x2a, 0x9e, 0x5d, 0x56, 0xf2, 0xd3, 0xab,\n    0x44, 0x11, 0x92, 0xd9, 0x23, 0x20, 0x2e, 0x89,\n    0xb4, 0x7c, 0xb8, 0x26, 0x77, 0x99, 0xe3, 0xa5,\n    0x67, 0x4a, 0xed, 0xde, 0xc5, 0x31, 0xfe, 0x18,\n    0x0d, 0x63, 0x8c, 0x80, 0xc0, 0xf7, 0x70, 0x07,\n)\n\n\ndef key_expansion(data):\n    \"\"\"\n    Generate key schedule\n\n    @param {int[]} data  16/24/32-Byte cipher key\n    @returns {int[]}     176/208/240-Byte expanded key\n    \"\"\"\n    data = data[:]  # copy\n    rcon_iteration = 1\n    key_size_bytes = len(data)\n    expanded_key_size_bytes = (key_size_bytes // 4 + 7) * BLOCK_SIZE_BYTES\n\n    while len(data) < expanded_key_size_bytes:\n        temp = data[-4:]\n        temp = key_schedule_core(temp, rcon_iteration)\n        rcon_iteration += 1\n        data += xor(temp, data[-key_size_bytes: 4 - key_size_bytes])\n\n        for _ in range(3):\n            temp = data[-4:]\n            data += xor(temp, data[-key_size_bytes: 4 - key_size_bytes])\n\n        if key_size_bytes == 32:\n            temp = data[-4:]\n            temp = sub_bytes(temp)\n            data += xor(temp, data[-key_size_bytes: 4 - key_size_bytes])\n\n        for _ in range(3 if key_size_bytes == 32 else\n                       2 if key_size_bytes == 24 else 0):\n            temp = data[-4:]\n            data += xor(temp, data[-key_size_bytes: 4 - key_size_bytes])\n    data = data[:expanded_key_size_bytes]\n\n    return data\n\n\ndef iter_vector(iv):\n    while True:\n        yield iv\n        iv = inc(iv)\n\n\ndef sub_bytes(data):\n    return [SBOX[x] for x in data]\n\n\ndef sub_bytes_inv(data):\n    return [SBOX_INV[x] for x in data]\n\n\ndef rotate(data):\n    return data[1:] + [data[0]]\n\n\ndef key_schedule_core(data, rcon_iteration):\n    data = rotate(data)\n    data = sub_bytes(data)\n    data[0] = data[0] ^ RCON[rcon_iteration]\n\n    return data\n\n\ndef xor(data1, data2):\n    return [x ^ y for x, y in zip(data1, data2)]\n\n\ndef iter_mix_columns(data, matrix):\n    for i in (0, 4, 8, 12):\n        for row in matrix:\n            mixed = 0\n            for j in range(4):\n                if data[i:i + 4][j] == 0 or row[j] == 0:\n                    mixed ^= 0\n                else:\n                    mixed ^= RIJNDAEL_EXP_TABLE[\n                        (RIJNDAEL_LOG_TABLE[data[i + j]] +\n                         RIJNDAEL_LOG_TABLE[row[j]]) % 0xFF\n                    ]\n            yield mixed\n\n\ndef shift_rows(data):\n    return [\n        data[((column + row) & 0b11) * 4 + row]\n        for column in range(4)\n        for row in range(4)\n    ]\n\n\ndef shift_rows_inv(data):\n    return [\n        data[((column - row) & 0b11) * 4 + row]\n        for column in range(4)\n        for row in range(4)\n    ]\n\n\ndef shift_block(data):\n    data_shifted = []\n\n    bit = 0\n    for n in data:\n        if bit:\n            n |= 0x100\n        bit = n & 1\n        n >>= 1\n        data_shifted.append(n)\n\n    return data_shifted\n\n\ndef inc(data):\n    data = data[:]  # copy\n    for i in range(len(data) - 1, -1, -1):\n        if data[i] == 255:\n            data[i] = 0\n        else:\n            data[i] = data[i] + 1\n            break\n    return data\n\n\ndef block_product(block_x, block_y):\n    # NIST SP 800-38D, Algorithm 1\n\n    if len(block_x) != BLOCK_SIZE_BYTES or len(block_y) != BLOCK_SIZE_BYTES:\n        raise ValueError(\n            f\"Length of blocks need to be {BLOCK_SIZE_BYTES} bytes\")\n\n    block_r = [0xE1] + [0] * (BLOCK_SIZE_BYTES - 1)\n    block_v = block_y[:]\n    block_z = [0] * BLOCK_SIZE_BYTES\n\n    for i in block_x:\n        for bit in range(7, -1, -1):\n            if i & (1 << bit):\n                block_z = xor(block_z, block_v)\n\n            do_xor = block_v[-1] & 1\n            block_v = shift_block(block_v)\n            if do_xor:\n                block_v = xor(block_v, block_r)\n\n    return block_z\n\n\ndef ghash(subkey, data):\n    # NIST SP 800-38D, Algorithm 2\n\n    if len(data) % BLOCK_SIZE_BYTES:\n        raise ValueError(\n            f\"Length of data should be {BLOCK_SIZE_BYTES} bytes\")\n\n    last_y = [0] * BLOCK_SIZE_BYTES\n    for i in range(0, len(data), BLOCK_SIZE_BYTES):\n        block = data[i: i + BLOCK_SIZE_BYTES]\n        last_y = block_product(xor(last_y, block), subkey)\n\n    return last_y\n"
  },
  {
    "path": "gallery_dl/archive.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2024-2026 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Download Archives\"\"\"\n\nimport os\nimport logging\nfrom . import util, formatter\n\nlog = logging.getLogger(\"archive\")\n\n\ndef connect(path, prefix, format,\n            table=None, mode=None, pragma=None, pathfmt=None, cache_key=None):\n    keygen = formatter.parse(prefix + format).format_map\n\n    if isinstance(path, str) and path.startswith(\n            (\"postgres://\", \"postgresql://\")):\n        cls = (DownloadArchivePostgresqlMemory if mode == \"memory\" else\n               DownloadArchivePostgresql)\n    else:\n        if isinstance(path, list):\n            path = pathfmt.generate_path(path)\n        else:\n            if \"{\" in path:\n                log.error(\"Replacement fields in 'string' archive paths are \"\n                          \"no longer supported. Use a list of strings \"\n                          \"instead.\")\n            path = util.expand_path(path)\n        cls = DownloadArchiveMemory if mode == \"memory\" else DownloadArchive\n\n    if pathfmt is not None and table:\n        table = formatter.parse(table).format_map(pathfmt.kwdict)\n\n    return cls(path, keygen, table, pragma, cache_key)\n\n\ndef sanitize(name):\n    return f'''\"{name.replace('\"', '_')}\"'''\n\n\nclass DownloadArchive():\n    _sqlite3 = None\n\n    def __init__(self, path, keygen, table=None, pragma=None, cache_key=None):\n        if self._sqlite3 is None:\n            DownloadArchive._sqlite3 = __import__(\"sqlite3\")\n\n        try:\n            con = self._sqlite3.connect(\n                path, timeout=60, check_same_thread=False)\n        except self._sqlite3.OperationalError:\n            os.makedirs(os.path.dirname(path))\n            con = self._sqlite3.connect(\n                path, timeout=60, check_same_thread=False)\n        con.isolation_level = None\n\n        self.keygen = keygen\n        self.connection = con\n        self.close = con.close\n        self.cursor = cursor = con.cursor()\n        self._cache_key = cache_key or \"_archive_key\"\n\n        table = \"archive\" if table is None else sanitize(table)\n        self._stmt_select = (\n            f\"SELECT 1 \"\n            f\"FROM {table} \"\n            f\"WHERE entry=? \"\n            f\"LIMIT 1\")\n        self._stmt_insert = (\n            f\"INSERT OR IGNORE INTO {table} \"\n            f\"(entry) VALUES (?)\")\n\n        if pragma:\n            for stmt in pragma:\n                cursor.execute(f\"PRAGMA {stmt}\")\n\n        try:\n            cursor.execute(f\"CREATE TABLE IF NOT EXISTS {table} \"\n                           f\"(entry TEXT PRIMARY KEY) WITHOUT ROWID\")\n        except self._sqlite3.OperationalError:\n            # fallback for missing WITHOUT ROWID support (#553)\n            cursor.execute(f\"CREATE TABLE IF NOT EXISTS {table} \"\n                           f\"(entry TEXT PRIMARY KEY)\")\n\n    def add(self, kwdict):\n        \"\"\"Add item described by 'kwdict' to archive\"\"\"\n        key = kwdict.get(self._cache_key) or self.keygen(kwdict)\n        self.cursor.execute(self._stmt_insert, (key,))\n\n    def check(self, kwdict):\n        \"\"\"Return True if the item described by 'kwdict' exists in archive\"\"\"\n        key = kwdict[self._cache_key] = self.keygen(kwdict)\n        self.cursor.execute(self._stmt_select, (key,))\n        return self.cursor.fetchone()\n\n    def finalize(self):\n        pass\n\n\nclass DownloadArchiveMemory(DownloadArchive):\n\n    def __init__(self, path, keygen, table=None, pragma=None, cache_key=None):\n        DownloadArchive.__init__(\n            self, path, keygen, table, pragma, cache_key)\n        self.keys = set()\n\n    def add(self, kwdict):\n        self.keys.add(\n            kwdict.get(self._cache_key) or\n            self.keygen(kwdict))\n\n    def check(self, kwdict):\n        key = kwdict[self._cache_key] = self.keygen(kwdict)\n        if key in self.keys:\n            return True\n        self.cursor.execute(self._stmt_select, (key,))\n        return self.cursor.fetchone()\n\n    def finalize(self):\n        if not self.keys:\n            return\n\n        cursor = self.cursor\n        with self.connection:\n            try:\n                cursor.execute(\"BEGIN\")\n            except self._sqlite3.OperationalError:\n                pass\n\n            stmt = self._stmt_insert\n            if len(self.keys) < 100:\n                for key in self.keys:\n                    cursor.execute(stmt, (key,))\n            else:\n                cursor.executemany(stmt, ((key,) for key in self.keys))\n\n\nclass DownloadArchivePostgresql():\n    _psycopg = None\n\n    def __init__(self, uri, keygen, table=None, pragma=None, cache_key=None):\n        if self._psycopg is None:\n            DownloadArchivePostgresql._psycopg = __import__(\"psycopg\")\n\n        self.connection = con = self._psycopg.connect(uri)\n        self.cursor = cursor = con.cursor()\n        self.close = con.close\n        self.keygen = keygen\n        self._cache_key = cache_key or \"_archive_key\"\n\n        table = \"archive\" if table is None else sanitize(table)\n        self._stmt_select = (\n            f\"SELECT true \"\n            f\"FROM {table} \"\n            f\"WHERE entry=%s \"\n            f\"LIMIT 1\")\n        self._stmt_insert = (\n            f\"INSERT INTO {table} (entry) \"\n            f\"VALUES (%s) \"\n            f\"ON CONFLICT DO NOTHING\")\n\n        try:\n            cursor.execute(f\"CREATE TABLE IF NOT EXISTS {table} \"\n                           f\"(entry TEXT PRIMARY KEY)\")\n            con.commit()\n        except Exception as exc:\n            log.error(\"%s: %s when creating '%s' table: %s\",\n                      con, exc.__class__.__name__, table, exc)\n            con.rollback()\n            raise\n\n    def add(self, kwdict):\n        key = kwdict.get(self._cache_key) or self.keygen(kwdict)\n        try:\n            self.cursor.execute(self._stmt_insert, (key,))\n            self.connection.commit()\n        except Exception as exc:\n            log.error(\"%s: %s when writing entry: %s\",\n                      self.connection, exc.__class__.__name__, exc)\n            self.connection.rollback()\n\n    def check(self, kwdict):\n        key = kwdict[self._cache_key] = self.keygen(kwdict)\n        try:\n            self.cursor.execute(self._stmt_select, (key,))\n            return self.cursor.fetchone()\n        except Exception as exc:\n            log.error(\"%s: %s when checking entry: %s\",\n                      self.connection, exc.__class__.__name__, exc)\n            self.connection.rollback()\n            return False\n\n    def finalize(self):\n        pass\n\n\nclass DownloadArchivePostgresqlMemory(DownloadArchivePostgresql):\n\n    def __init__(self, path, keygen, table=None, pragma=None, cache_key=None):\n        DownloadArchivePostgresql.__init__(\n            self, path, keygen, table, pragma, cache_key)\n        self.keys = set()\n\n    def add(self, kwdict):\n        self.keys.add(\n            kwdict.get(self._cache_key) or\n            self.keygen(kwdict))\n\n    def check(self, kwdict):\n        key = kwdict[self._cache_key] = self.keygen(kwdict)\n        if key in self.keys:\n            return True\n        try:\n            self.cursor.execute(self._stmt_select, (key,))\n            return self.cursor.fetchone()\n        except Exception as exc:\n            log.error(\"%s: %s when checking entry: %s\",\n                      self.connection, exc.__class__.__name__, exc)\n            self.connection.rollback()\n            return False\n\n    def finalize(self):\n        if not self.keys:\n            return\n        try:\n            self.cursor.executemany(\n                self._stmt_insert,\n                ((key,) for key in self.keys))\n            self.connection.commit()\n        except Exception as exc:\n            log.error(\"%s: %s when writing entries: %s\",\n                      self.connection, exc.__class__.__name__, exc)\n            self.connection.rollback()\n"
  },
  {
    "path": "gallery_dl/cache.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2016-2026 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Utilities for caching function results\"\"\"\n\nimport os\nimport time\nimport logging\nfrom . import config, util\n\nlog = logging.getLogger(\"cache\")\nDATABASE = PATH = ERR = None\n\n\ndef database():\n    global DATABASE, ERR\n\n    try:\n        path_ = path()\n\n        if path_ != \":memory:\":\n            # restrict access permissions for new db files\n            os.close(os.open(path_, os.O_CREAT | os.O_RDONLY, 0o600))\n\n        import sqlite3\n        DATABASE = sqlite3.connect(path_, timeout=60, check_same_thread=False)\n    except Exception as exc:\n        log.debug(\"Failed to connect to SQLite3 database (%s: %s)\",\n                  exc.__class__.__name__, exc)\n        ERR = exc\n    else:\n        log.debug(\"Connected to SQLite3 database '%s'\", path_)\n        DATABASE.execute(\n            \"CREATE TABLE IF NOT EXISTS data \"\n            \"(key TEXT PRIMARY KEY, value TEXT, expires INTEGER)\")\n\n    globals()[\"database\"] = lambda: DATABASE\n    return DATABASE\n\n\ndef path():\n    global PATH\n\n    path = config.get((\"cache\",), \"file\", util.SENTINEL)\n    if path is not util.SENTINEL:\n        return util.expand_path(path)\n\n    if util.WINDOWS:\n        cachedir = os.environ.get(\"APPDATA\", \"~\")\n    else:\n        cachedir = os.environ.get(\"XDG_CACHE_HOME\", \"~/.cache\")\n\n    cachedir = util.expand_path(os.path.join(cachedir, \"gallery-dl\"))\n    os.makedirs(cachedir, exist_ok=True)\n    PATH = os.path.join(cachedir, \"cache.sqlite3\")\n\n    globals()[\"path\"] = lambda: PATH\n    return PATH\n\n\ndef error(ret=1):\n    log.error(\"No database connection (%s: %s)\", ERR.__class__.__name__, ERR)\n    return ret\n\n\ndef get(module):\n    if (db := database()) is None:\n        return\n\n    try:\n        if module == \"ALL\":\n            return db.execute(\n                \"SELECT * FROM data\")\n        if module == \"VAL\":\n            return db.execute(\n                \"SELECT * FROM data WHERE expires > ?\",\n                (int(time.time()),))\n        if module == \"EXP\":\n            return db.execute(\n                \"SELECT * FROM data WHERE expires < ? AND expires <> 0\",\n                (int(time.time()),))\n\n        module = module.lower()\n        return db.execute(\n            \"SELECT * FROM data \"\n            \"WHERE key LIKE 'gallery_dl.extractor.' || ? || '.%'\"\n            \"OR    key LIKE 'gallery_dl.extractor.utils.' || ? || '_%'\",\n            (module, module))\n    except Exception:\n        pass  # database not initialized, cannot be modified, etc.\n    return\n\n\ndef clear(module):\n    \"\"\"Delete database entries for 'module'\"\"\"\n    if (db := database()) is None:\n        return\n\n    rowcount = 0\n    cursor = db.cursor()\n\n    try:\n        if module == \"ALL\":\n            cursor.execute(\"DELETE FROM data\")\n        elif module == \"EXP\":\n            cursor.execute(\n                \"DELETE FROM data WHERE expires < ? AND expires <> 0\",\n                (int(time.time()),))\n        else:\n            module = module.lower()\n            cursor.execute(\n                \"DELETE FROM data \"\n                \"WHERE key LIKE 'gallery_dl.extractor.' || ? || '.%'\"\n                \"OR    key LIKE 'gallery_dl.extractor.utils.' || ? || '_%'\",\n                (module, module))\n    except Exception:\n        pass  # database not initialized, cannot be modified, etc.\n    else:\n        rowcount = cursor.rowcount\n        db.commit()\n    return rowcount\n"
  },
  {
    "path": "gallery_dl/config.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2015-2026 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Global configuration module\"\"\"\n\nimport sys\nimport os.path\nimport logging\nfrom . import util\n\nlog = logging.getLogger(\"config\")\n\n\n# --------------------------------------------------------------------\n# internals\n\n_config = {}\n_files = []\n_type = \"json\"\n_load = util.json_loads\n_default_configs = ()\n\n\n# --------------------------------------------------------------------\n# public interface\n\n\ndef default(type=None):\n    global _type\n    global _load\n    global _default_configs\n\n    if not type or (type := type.lower()) == \"json\":\n        _type = type = \"json\"\n        _load = util.json_loads\n    elif type == \"yaml\":\n        _type = \"yaml\"\n        from yaml import safe_load as _load\n    elif type == \"toml\":\n        _type = \"toml\"\n        try:\n            from tomllib import loads as _load\n        except ImportError:\n            from toml import loads as _load\n    else:\n        raise ValueError(f\"Unsupported config file type '{type}'\")\n\n    if util.WINDOWS:\n        _default_configs = [\n            r\"%APPDATA%\\gallery-dl\\config.\" + type,\n            r\"%USERPROFILE%\\gallery-dl\\config.\" + type,\n            r\"%USERPROFILE%\\gallery-dl.conf\",\n        ]\n    else:\n        _default_configs = [\n            \"/etc/gallery-dl.conf\",\n            \"${XDG_CONFIG_HOME}/gallery-dl/config.\" + type\n            if os.environ.get(\"XDG_CONFIG_HOME\") else\n            \"${HOME}/.config/gallery-dl/config.\" + type,\n            \"${HOME}/.gallery-dl.conf\",\n        ]\n\n    if util.EXECUTABLE:\n        # look for config file in PyInstaller executable directory (#682)\n        _default_configs.append(os.path.join(\n            os.path.dirname(sys.executable),\n            \"gallery-dl.conf\",\n        ))\n\n\ndefault(os.environ.get(\"GDL_CONFIG_TYPE\"))\n\n\ndef initialize():\n    paths = list(map(util.expand_path, _default_configs))\n\n    for path in paths:\n        if os.access(path, os.R_OK | os.W_OK):\n            log.error(\"There is already a configuration file at '%s'\", path)\n            return 1\n\n    for path in paths:\n        try:\n            os.makedirs(os.path.dirname(path), exist_ok=True)\n            with open(path, \"x\", encoding=\"utf-8\") as fp:\n                fp.write(\"\"\"\\\n{\n    \"extractor\": {\n\n    },\n    \"downloader\": {\n\n    },\n    \"output\": {\n\n    },\n    \"postprocessor\": {\n\n    }\n}\n\"\"\")\n            break\n        except OSError as exc:\n            log.debug(\"%s: %s\", exc.__class__.__name__, exc)\n    else:\n        log.error(\"Unable to create a new configuration file \"\n                  \"at any of the default paths\")\n        return 1\n\n    log.info(\"Created a basic configuration file at '%s'\", path)\n    return 0\n\n\ndef open_extern():\n    for path in _default_configs:\n        path = util.expand_path(path)\n        if os.access(path, os.R_OK | os.W_OK):\n            break\n    else:\n        log.warning(\"Unable to find any writable configuration file\")\n        return 1\n\n    if util.WINDOWS:\n        openers = (\"explorer\", \"notepad\")\n    else:\n        openers = (\"xdg-open\", \"open\")\n        if editor := os.environ.get(\"EDITOR\"):\n            openers = (editor,) + openers\n\n    import shutil\n    for opener in openers:\n        if opener := shutil.which(opener):\n            break\n    else:\n        log.warning(\"Unable to find a program to open '%s' with\", path)\n        return 1\n\n    log.info(\"Running '%s %s'\", opener, path)\n    retcode = util.Popen((opener, path)).wait()\n\n    if not retcode:\n        try:\n            with open(path, encoding=\"utf-8\") as fp:\n                _load(fp.read())\n        except Exception as exc:\n            log.warning(\"%s when parsing '%s': %s\",\n                        exc.__class__.__name__, path, exc)\n            return 2\n\n    return retcode\n\n\ndef status():\n    from .output import stdout_write\n\n    paths = []\n    for path in _default_configs:\n        path = util.expand_path(path)\n\n        try:\n            with open(path, encoding=\"utf-8\") as fp:\n                _load(fp.read())\n        except FileNotFoundError:\n            status = \"\"\n        except OSError as exc:\n            log.debug(\"%s: %s\", exc.__class__.__name__, exc)\n            status = \"Inaccessible\"\n        except ValueError as exc:\n            log.debug(\"%s: %s\", exc.__class__.__name__, exc)\n            status = \"Invalid \" + _type.upper()\n        except Exception as exc:\n            log.debug(\"%s: %s\", exc.__class__.__name__, exc)\n            status = \"Unknown\"\n        else:\n            status = \"OK\"\n\n        paths.append((path, status))\n\n    fmt = f\"{{:<{max(len(p[0]) for p in paths)}}} : {{}}\\n\".format\n    for path, status in paths:\n        stdout_write(fmt(path, status))\n\n\ndef remap_categories():\n    opts = _config.get(\"extractor\")\n    if not opts:\n        return\n\n    cmap = opts.get(\"config-map\")\n    if cmap is None:\n        cmap = (\n            (\"coomerparty\" , \"coomer\"),\n            (\"kemonoparty\" , \"kemono\"),\n            (\"giantessbooru\", \"sizebooru\"),\n            (\"koharu\"      , \"schalenetwork\"),\n            (\"naver\"       , \"naver-blog\"),\n            (\"chzzk\"       , \"naver-chzzk\"),\n            (\"naverwebtoon\", \"naver-webtoon\"),\n            (\"pixiv\"       , \"pixiv-novel\"),\n            (\"saint\"       , \"turbo\"),\n        )\n    elif not cmap:\n        return\n    elif isinstance(cmap, dict):\n        cmap = cmap.items()\n\n    for old, new in cmap:\n        if old in opts and new not in opts:\n            opts[new] = opts[old]\n\n\ndef load(files=None, strict=False, loads=None, conf=_config):\n    \"\"\"Load configuration files\"\"\"\n    if loads is None:\n        loads = _load\n\n    for pathfmt in files or _default_configs:\n        path = util.expand_path(pathfmt)\n        try:\n            with open(path, encoding=\"utf-8\") as fp:\n                config = loads(fp.read())\n        except OSError as exc:\n            if strict:\n                log.error(exc)\n                raise SystemExit(1)\n        except Exception as exc:\n            log.error(\"%s when loading '%s': %s\",\n                      exc.__class__.__name__, path, exc)\n            if strict:\n                raise SystemExit(2)\n        else:\n            if not conf:\n                conf.update(config)\n            else:\n                util.combine_dict(conf, config)\n            _files.append(pathfmt)\n\n            if \"subconfigs\" in config:\n                if subconfigs := config[\"subconfigs\"]:\n                    if isinstance(subconfigs, str):\n                        subconfigs = (subconfigs,)\n                    load(subconfigs, strict, loads, conf)\n\n\ndef clear():\n    \"\"\"Reset configuration to an empty state\"\"\"\n    _config.clear()\n\n\ndef get(path, key, default=None, conf=_config):\n    \"\"\"Get the value of property 'key' or a default value\"\"\"\n    try:\n        for p in path:\n            conf = conf[p]\n        return conf[key]\n    except Exception:\n        return default\n\n\ndef interpolate(path, key, default=None, conf=_config):\n    \"\"\"Interpolate the value of 'key'\"\"\"\n    if key in conf:\n        return conf[key]\n    try:\n        for p in path:\n            conf = conf[p]\n            if key in conf:\n                default = conf[key]\n    except Exception:\n        pass\n    return default\n\n\ndef interpolate_common(common, paths, key, default=None, conf=_config):\n    \"\"\"Interpolate the value of 'key'\n    using multiple 'paths' along a 'common' ancestor\n    \"\"\"\n    if key in conf:\n        return conf[key]\n\n    # follow the common path\n    try:\n        for p in common:\n            conf = conf[p]\n            if key in conf:\n                default = conf[key]\n    except Exception:\n        return default\n\n    # try all paths until a value is found\n    value = util.SENTINEL\n    for path in paths:\n        c = conf\n        try:\n            for p in path:\n                c = c[p]\n                if key in c:\n                    value = c[key]\n        except Exception:\n            pass\n        if value is not util.SENTINEL:\n            return value\n    return default\n\n\ndef accumulate(path, key, conf=_config):\n    \"\"\"Accumulate the values of 'key' along 'path'\"\"\"\n    result = []\n    try:\n        if key in conf:\n            if value := conf[key]:\n                if isinstance(value, list):\n                    result.extend(value)\n                else:\n                    result.append(value)\n        for p in path:\n            conf = conf[p]\n            if key in conf:\n                if value := conf[key]:\n                    if isinstance(value, list):\n                        result[:0] = value\n                    else:\n                        result.insert(0, value)\n    except Exception:\n        pass\n    return result\n\n\ndef set(path, key, value, conf=_config):\n    \"\"\"Set the value of property 'key' for this session\"\"\"\n    for p in path:\n        try:\n            conf = conf[p]\n        except KeyError:\n            conf[p] = conf = {}\n    conf[key] = value\n\n\ndef setdefault(path, key, value, conf=_config):\n    \"\"\"Set the value of property 'key' if it doesn't exist\"\"\"\n    for p in path:\n        try:\n            conf = conf[p]\n        except KeyError:\n            conf[p] = conf = {}\n    return conf.setdefault(key, value)\n\n\ndef unset(path, key, conf=_config):\n    \"\"\"Unset the value of property 'key'\"\"\"\n    try:\n        for p in path:\n            conf = conf[p]\n        del conf[key]\n    except Exception:\n        pass\n\n\nclass apply():\n    \"\"\"Context Manager: apply a collection of key-value pairs\"\"\"\n\n    def __init__(self, kvlist):\n        self.original = []\n        self.kvlist = kvlist\n\n    def __enter__(self):\n        for path, key, value in self.kvlist:\n            self.original.append((path, key, get(path, key, util.SENTINEL)))\n            set(path, key, value)\n\n    def __exit__(self, exc_type, exc_value, traceback):\n        self.original.reverse()\n        for path, key, value in self.original:\n            if value is util.SENTINEL:\n                unset(path, key)\n            else:\n                set(path, key, value)\n"
  },
  {
    "path": "gallery_dl/cookies.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2022-2025 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n# Adapted from yt-dlp's cookies module.\n# https://github.com/yt-dlp/yt-dlp/blob/master/yt_dlp/cookies.py\n\nimport binascii\nimport ctypes\nimport logging\nimport os\nimport shutil\nimport sqlite3\nimport struct\nimport subprocess\nimport sys\nimport tempfile\nfrom hashlib import pbkdf2_hmac\nfrom http.cookiejar import Cookie\nfrom . import aes, text, util\n\n\nSUPPORTED_BROWSERS_CHROMIUM = {\n    \"brave\", \"chrome\", \"chromium\", \"edge\", \"opera\", \"thorium\", \"vivaldi\"}\nSUPPORTED_BROWSERS_FIREFOX = {\"firefox\", \"librewolf\", \"zen\", \"floorp\"}\nSUPPORTED_BROWSERS_WEBKIT = {\"safari\", \"orion\"}\nSUPPORTED_BROWSERS = \\\n    SUPPORTED_BROWSERS_CHROMIUM \\\n    | SUPPORTED_BROWSERS_FIREFOX \\\n    | SUPPORTED_BROWSERS_WEBKIT\n\nlogger = logging.getLogger(\"cookies\")\n\n\ndef load_cookies(browser_specification):\n    browser_name, profile, keyring, container, domain = \\\n        _parse_browser_specification(*browser_specification)\n    if browser_name in SUPPORTED_BROWSERS_FIREFOX:\n        return load_cookies_firefox(browser_name, profile, container, domain)\n    elif browser_name in SUPPORTED_BROWSERS_WEBKIT:\n        return load_cookies_webkit(browser_name, profile, domain)\n    elif browser_name in SUPPORTED_BROWSERS_CHROMIUM:\n        return load_cookies_chromium(browser_name, profile, keyring, domain)\n    else:\n        raise ValueError(f\"unknown browser '{browser_name}'\")\n\n\ndef load_cookies_firefox(browser_name, profile=None,\n                         container=None, domain=None):\n    path, container_id = _firefox_cookies_database(\n        browser_name, profile, container)\n\n    sql = (\"SELECT name, value, host, path, isSecure, expiry \"\n           \"FROM moz_cookies\")\n    conditions = []\n    parameters = []\n\n    if container_id is False:\n        conditions.append(\"NOT INSTR(originAttributes,'userContextId=')\")\n    elif container_id:\n        uid = f\"%userContextId={container_id}\"\n        conditions.append(\"originAttributes LIKE ? OR originAttributes LIKE ?\")\n        parameters += (uid, uid + \"&%\")\n\n    if domain:\n        if domain[0] == \".\":\n            conditions.append(\"host == ? OR host LIKE ?\")\n            parameters += (domain[1:], \"%\" + domain)\n        else:\n            conditions.append(\"host == ? OR host == ?\")\n            parameters += (domain, \".\" + domain)\n\n    if conditions:\n        sql = f\"{sql} WHERE ( {' ) AND ( '.join(conditions)} )\"\n\n    with DatabaseConnection(path) as db:\n        cookies = [\n            Cookie(\n                0, name, value, None, False,\n                domain, True if domain else False,\n                domain[0] == \".\" if domain else False,\n                path, True if path else False, secure, expires,\n                False, None, None, {},\n            )\n            for name, value, domain, path, secure, expires in db.execute(\n                sql, parameters)\n        ]\n\n    _log_info(\"Extracted %s cookies from %s\",\n              len(cookies), browser_name.capitalize())\n    return cookies\n\n\ndef load_cookies_webkit(browser_name, profile=None, domain=None):\n    \"\"\"Ref.: https://github.com/libyal/dtformats/blob\n             /main/documentation/Safari%20Cookies.asciidoc\n    - This data appears to be out of date\n      but the important parts of the database structure is the same\n    - There are a few bytes here and there\n      which are skipped during parsing\n    \"\"\"\n    if browser_name == \"safari\":\n        with _safari_cookies_database() as fp:\n            data = fp.read()\n    elif browser_name == \"orion\":\n        with _orion_cookies_database() as fp:\n            data = fp.read()\n    else:\n        raise ValueError(f\"unknown webkit browser '{browser_name}'\")\n\n    page_sizes, body_start = _webkit_parse_cookies_header(data)\n    p = DataParser(data[body_start:])\n\n    cookies = []\n    for page_size in page_sizes:\n        _webkit_parse_cookies_page(p.read_bytes(page_size), cookies)\n    _log_info(\"Extracted %s cookies from %s\",\n              len(cookies), browser_name.capitalize())\n\n    return cookies\n\n\ndef load_cookies_chromium(browser_name, profile=None,\n                          keyring=None, domain=None):\n    config = _chromium_browser_settings(browser_name)\n    path = _chromium_cookies_database(profile, config)\n    _log_debug(\"Extracting cookies from %s\", path)\n\n    if domain:\n        if domain[0] == \".\":\n            condition = \" WHERE host_key == ? OR host_key LIKE ?\"\n            parameters = (domain[1:], \"%\" + domain)\n        else:\n            condition = \" WHERE host_key == ? OR host_key == ?\"\n            parameters = (domain, \".\" + domain)\n    else:\n        condition = \"\"\n        parameters = ()\n\n    with DatabaseConnection(path) as db:\n        db.text_factory = bytes\n        cursor = db.cursor()\n\n        try:\n            meta_version = int(cursor.execute(\n                \"SELECT value FROM meta WHERE key = 'version'\").fetchone()[0])\n        except Exception as exc:\n            _log_warning(\"Failed to get cookie database meta version (%s: %s)\",\n                         exc.__class__.__name__, exc)\n            meta_version = 0\n\n        try:\n            rows = cursor.execute(\n                \"SELECT host_key, name, value, encrypted_value, path, \"\n                \"expires_utc, is_secure FROM cookies\" + condition, parameters)\n        except sqlite3.OperationalError:\n            rows = cursor.execute(\n                \"SELECT host_key, name, value, encrypted_value, path, \"\n                \"expires_utc, secure FROM cookies\" + condition, parameters)\n\n        failed_cookies = 0\n        unencrypted_cookies = 0\n        decryptor = _chromium_cookie_decryptor(\n            config[\"directory\"], config[\"keyring\"], keyring, meta_version)\n\n        cookies = []\n        for domain, name, value, enc_value, path, expires, secure in rows:\n\n            if not value and enc_value:  # encrypted\n                value = decryptor.decrypt(enc_value)\n                if value is None:\n                    failed_cookies += 1\n                    continue\n            else:\n                value = value.decode()\n                unencrypted_cookies += 1\n\n            if expires:\n                # https://stackoverflow.com/a/43520042\n                expires = int(expires) // 1000000 - 11644473600\n            else:\n                expires = None\n\n            domain = domain.decode()\n            path = path.decode()\n            name = name.decode()\n\n            cookies.append(Cookie(\n                0, name, value, None, False,\n                domain, True if domain else False,\n                domain[0] == \".\" if domain else False,\n                path, True if path else False, secure, expires,\n                False, None, None, {},\n            ))\n\n    if failed_cookies > 0:\n        failed_message = f\" ({failed_cookies} could not be decrypted)\"\n    else:\n        failed_message = \"\"\n\n    _log_info(\"Extracted %s cookies from %s%s\",\n              len(cookies), browser_name.capitalize(), failed_message)\n    counts = decryptor.cookie_counts\n    counts[\"unencrypted\"] = unencrypted_cookies\n    _log_debug(\"version breakdown: %s\", counts)\n    return cookies\n\n\n# --------------------------------------------------------------------\n# firefox\n\ndef _firefox_cookies_database(browser_name, profile=None, container=None):\n    if not profile:\n        search_root = _firefox_browser_directory(browser_name)\n    elif _is_path(profile):\n        search_root = profile\n    else:\n        search_root = _firefox_browser_directory(browser_name)\n        if isinstance(search_root, str):\n            search_root = os.path.join(search_root, profile)\n        else:\n            search_root = [os.path.join(dir, profile) for dir in search_root]\n\n    path = _find_most_recently_used_file(search_root, \"cookies.sqlite\")\n    if path is None:\n        raise FileNotFoundError(f\"Unable to find {browser_name.capitalize()} \"\n                                f\"cookies database\")\n\n    _log_debug(\"Extracting cookies from %s\", path)\n\n    if not container or container == \"none\":\n        container_id = False\n        _log_debug(\"Only loading cookies not belonging to any container\")\n\n    elif container == \"all\":\n        container_id = None\n\n    else:\n        containers_path = os.path.join(\n            os.path.dirname(path), \"containers.json\")\n\n        try:\n            with open(containers_path, encoding=\"utf-8\") as fp:\n                identities = util.json_loads(fp.read())[\"identities\"]\n        except OSError:\n            _log_error(\"Unable to read Firefox container database at '%s'\",\n                       containers_path)\n            raise\n        except KeyError:\n            identities = ()\n\n        for context in identities:\n            if container == context.get(\"name\") or container == text.extr(\n                    context.get(\"l10nID\", \"\"), \"userContext\", \".label\"):\n                container_id = context[\"userContextId\"]\n                break\n        else:\n            raise ValueError(f\"Unable to find Firefox container '{container}'\")\n        _log_debug(\"Only loading cookies from container '%s' (ID %s)\",\n                   container, container_id)\n\n    return path, container_id\n\n\ndef _firefox_browser_directory(browser_name):\n    join = os.path.join\n\n    if sys.platform in (\"win32\", \"cygwin\"):\n        appdata = os.path.expandvars(\"%APPDATA%\")\n        return {\n            \"firefox\"  : join(appdata, R\"Mozilla\\Firefox\\Profiles\"),\n            \"librewolf\": join(appdata, R\"librewolf\\Profiles\"),\n            \"zen\"      : join(appdata, R\"zen\\Profiles\"),\n            \"floorp\"   : join(appdata, R\"Floorp\\Profiles\")\n        }[browser_name]\n    elif sys.platform == \"darwin\":\n        appdata = os.path.expanduser(\"~/Library/Application Support\")\n        return {\n            \"firefox\"  : join(appdata, R\"Firefox/Profiles\"),\n            \"librewolf\": join(appdata, R\"librewolf/Profiles\"),\n            \"zen\"      : join(appdata, R\"zen/Profiles\"),\n            \"floorp\"   : join(appdata, R\"Floorp/Profiles\")\n        }[browser_name]\n    else:\n        home = os.path.expanduser(\"~\")\n        if browser_name == \"firefox\":\n            config = (os.environ.get(\"XDG_CONFIG_HOME\") or\n                      os.path.expanduser(\"~/.config\"))\n            return (\n                # versions >= 147\n                join(config, \"mozilla/firefox\"),\n                # versions <= 146\n                home + \"/.mozilla/firefox\",\n                # Flatpak\n                home + \"/.var/app/org.mozilla.firefox/config/mozilla/firefox\",\n                home + \"/.var/app/org.mozilla.firefox/.mozilla/firefox\",\n                # Snap\n                home + \"/snap/firefox/common/.mozilla/firefox\",\n            )\n        return f\"{home}/.{browser_name}\"\n\n\n# --------------------------------------------------------------------\n# safari/orion/webkit\n\n\ndef _safari_cookies_database():\n    try:\n        path = os.path.expanduser(\"~/Library/Cookies/Cookies.binarycookies\")\n        return open(path, \"rb\")\n    except FileNotFoundError:\n        _log_debug(\"Trying secondary cookie location\")\n        path = os.path.expanduser(\"~/Library/Containers/com.apple.Safari/Data\"\n                                  \"/Library/Cookies/Cookies.binarycookies\")\n        return open(path, \"rb\")\n\n\ndef _orion_cookies_database():\n    path = os.path.expanduser(\n        \"~/Library/HTTPStorages/com.kagi.kagimacOS.binarycookies\")\n    return open(path, \"rb\")\n\n\ndef _webkit_parse_cookies_header(data):\n    p = DataParser(data)\n    p.expect_bytes(b\"cook\", \"database signature\")\n    number_of_pages = p.read_uint(big_endian=True)\n    page_sizes = [p.read_uint(big_endian=True)\n                  for _ in range(number_of_pages)]\n    return page_sizes, p.cursor\n\n\ndef _webkit_parse_cookies_page(data, cookies, domain=None):\n    p = DataParser(data)\n    p.expect_bytes(b\"\\x00\\x00\\x01\\x00\", \"page signature\")\n    number_of_cookies = p.read_uint()\n    record_offsets = [p.read_uint() for _ in range(number_of_cookies)]\n    if number_of_cookies == 0:\n        _log_debug(\"Cookies page of size %s has no cookies\", len(data))\n        return\n\n    p.skip_to(record_offsets[0], \"unknown page header field\")\n\n    for i, record_offset in enumerate(record_offsets):\n        p.skip_to(record_offset, \"space between records\")\n        record_length = _webkit_parse_cookies_record(\n            data[record_offset:], cookies, domain)\n        p.read_bytes(record_length)\n    p.skip_to_end(\"space in between pages\")\n\n\ndef _webkit_parse_cookies_record(data, cookies, host=None):\n    p = DataParser(data)\n    record_size = p.read_uint()\n    p.skip(4, \"unknown record field 1\")\n    flags = p.read_uint()\n    is_secure = True if (flags & 0x0001) else False\n    p.skip(4, \"unknown record field 2\")\n    domain_offset = p.read_uint()\n    name_offset = p.read_uint()\n    path_offset = p.read_uint()\n    value_offset = p.read_uint()\n    p.skip(8, \"unknown record field 3\")\n    expiration_date = _mac_absolute_time_to_posix(p.read_double())\n    _creation_date = _mac_absolute_time_to_posix(p.read_double())  # noqa: F841\n\n    try:\n        p.skip_to(domain_offset)\n        domain = p.read_cstring()\n\n        if host:\n            if host[0] == \".\":\n                if host[1:] != domain and not domain.endswith(host):\n                    return record_size\n            else:\n                if host != domain and (\".\" + host) != domain:\n                    return record_size\n\n        p.skip_to(name_offset)\n        name = p.read_cstring()\n\n        p.skip_to(path_offset)\n        path = p.read_cstring()\n\n        p.skip_to(value_offset)\n        value = p.read_cstring()\n    except UnicodeDecodeError:\n        _log_warning(\"Failed to parse WebKit cookie\")\n        return record_size\n\n    p.skip_to(record_size, \"space at the end of the record\")\n\n    cookies.append(Cookie(\n        0, name, value, None, False,\n        domain, True if domain else False,\n        domain[0] == \".\" if domain else False,\n        path, True if path else False, is_secure, expiration_date,\n        False, None, None, {},\n    ))\n\n    return record_size\n\n\n# --------------------------------------------------------------------\n# chromium\n\ndef _chromium_cookies_database(profile, config):\n    if profile is None:\n        search_root = config[\"directory\"]\n    elif _is_path(profile):\n        search_root = profile\n        config[\"directory\"] = (os.path.dirname(profile)\n                               if config[\"profiles\"] else profile)\n    elif config[\"profiles\"]:\n        search_root = os.path.join(config[\"directory\"], profile)\n    else:\n        _log_warning(\"%s does not support profiles\", config[\"browser\"])\n        search_root = config[\"directory\"]\n\n    path = _find_most_recently_used_file(search_root, \"Cookies\")\n    if path is None:\n        raise FileNotFoundError(f\"Unable to find {config['browser']} cookies \"\n                                f\"database in '{search_root}'\")\n    return path\n\n\ndef _chromium_browser_settings(browser_name):\n    # https://chromium.googlesource.com/chromium\n    # /src/+/HEAD/docs/user_data_dir.md\n    join = os.path.join\n\n    if sys.platform in (\"win32\", \"cygwin\"):\n        appdata_local = os.path.expandvars(\"%LOCALAPPDATA%\")\n        appdata_roaming = os.path.expandvars(\"%APPDATA%\")\n        browser_dir = {\n            \"brave\"   : join(appdata_local,\n                             R\"BraveSoftware\\Brave-Browser\\User Data\"),\n            \"chrome\"  : join(appdata_local, R\"Google\\Chrome\\User Data\"),\n            \"chromium\": join(appdata_local, R\"Chromium\\User Data\"),\n            \"edge\"    : join(appdata_local, R\"Microsoft\\Edge\\User Data\"),\n            \"opera\"   : join(appdata_roaming, R\"Opera Software\\Opera Stable\"),\n            \"thorium\" : join(appdata_local, R\"Thorium\\User Data\"),\n            \"vivaldi\" : join(appdata_local, R\"Vivaldi\\User Data\"),\n        }[browser_name]\n\n    elif sys.platform == \"darwin\":\n        appdata = os.path.expanduser(\"~/Library/Application Support\")\n        browser_dir = {\n            \"brave\"   : join(appdata, \"BraveSoftware/Brave-Browser\"),\n            \"chrome\"  : join(appdata, \"Google/Chrome\"),\n            \"chromium\": join(appdata, \"Chromium\"),\n            \"edge\"    : join(appdata, \"Microsoft Edge\"),\n            \"opera\"   : join(appdata, \"com.operasoftware.Opera\"),\n            \"thorium\" : join(appdata, \"Thorium\"),\n            \"vivaldi\" : join(appdata, \"Vivaldi\"),\n        }[browser_name]\n\n    else:\n        config = (os.environ.get(\"XDG_CONFIG_HOME\") or\n                  os.path.expanduser(\"~/.config\"))\n        browser_dir = {\n            \"brave\"   : join(config, \"BraveSoftware/Brave-Browser\"),\n            \"chrome\"  : join(config, \"google-chrome\"),\n            \"chromium\": join(config, \"chromium\"),\n            \"edge\"    : join(config, \"microsoft-edge\"),\n            \"opera\"   : join(config, \"opera\"),\n            \"thorium\" : join(config, \"Thorium\"),\n            \"vivaldi\" : join(config, \"vivaldi\"),\n        }[browser_name]\n\n    # Linux keyring names can be determined by snooping on dbus\n    # while opening the browser in KDE:\n    # dbus-monitor \"interface=\"org.kde.KWallet\"\" \"type=method_return\"\n    keyring_name = {\n        \"brave\"   : \"Brave\",\n        \"chrome\"  : \"Chrome\",\n        \"chromium\": \"Chromium\",\n        \"edge\"    : \"Microsoft Edge\" if sys.platform == \"darwin\" else\n                    \"Chromium\",\n        \"opera\"   : \"Opera\" if sys.platform == \"darwin\" else \"Chromium\",\n        \"thorium\" : \"Thorium\",\n        \"vivaldi\" : \"Vivaldi\" if sys.platform == \"darwin\" else \"Chrome\",\n    }[browser_name]\n\n    browsers_without_profiles = {\"opera\"}\n\n    return {\n        \"browser\"  : browser_name,\n        \"directory\": browser_dir,\n        \"keyring\"  : keyring_name,\n        \"profiles\" : browser_name not in browsers_without_profiles\n    }\n\n\ndef _chromium_cookie_decryptor(\n        browser_root, browser_keyring_name, keyring=None, meta_version=0):\n    if sys.platform in (\"win32\", \"cygwin\"):\n        return WindowsChromiumCookieDecryptor(\n            browser_root, meta_version)\n    elif sys.platform == \"darwin\":\n        return MacChromiumCookieDecryptor(\n            browser_keyring_name, meta_version)\n    else:\n        return LinuxChromiumCookieDecryptor(\n            browser_keyring_name, keyring, meta_version)\n\n\nclass ChromiumCookieDecryptor:\n    \"\"\"\n    Overview:\n\n        Linux:\n        - cookies are either v10 or v11\n            - v10: AES-CBC encrypted with a fixed key\n            - v11: AES-CBC encrypted with an OS protected key (keyring)\n            - v11 keys can be stored in various places depending on the\n              activate desktop environment [2]\n\n        Mac:\n        - cookies are either v10 or not v10\n            - v10: AES-CBC encrypted with an OS protected key (keyring)\n              and more key derivation iterations than linux\n            - not v10: \"old data\" stored as plaintext\n\n        Windows:\n        - cookies are either v10 or not v10\n            - v10: AES-GCM encrypted with a key which is encrypted with DPAPI\n            - not v10: encrypted with DPAPI\n\n    Sources:\n    - [1] https://chromium.googlesource.com/chromium/src/+/refs/heads\n          /main/components/os_crypt/\n    - [2] https://chromium.googlesource.com/chromium/src/+/refs/heads\n          /main/components/os_crypt/key_storage_linux.cc\n        - KeyStorageLinux::CreateService\n    \"\"\"\n\n    def decrypt(self, encrypted_value):\n        raise NotImplementedError(\"Must be implemented by sub classes\")\n\n    @property\n    def cookie_counts(self):\n        raise NotImplementedError(\"Must be implemented by sub classes\")\n\n\nclass LinuxChromiumCookieDecryptor(ChromiumCookieDecryptor):\n    def __init__(self, browser_keyring_name, keyring=None, meta_version=0):\n        password = _get_linux_keyring_password(browser_keyring_name, keyring)\n        self._empty_key = self.derive_key(b\"\")\n        self._v10_key = self.derive_key(b\"peanuts\")\n        self._v11_key = None if password is None else self.derive_key(password)\n        self._cookie_counts = {\"v10\": 0, \"v11\": 0, \"other\": 0}\n        self._offset = (32 if meta_version >= 24 else 0)\n\n    def derive_key(self, password):\n        # values from\n        # https://chromium.googlesource.com/chromium/src/+/refs/heads\n        # /main/components/os_crypt/os_crypt_linux.cc\n        return pbkdf2_sha1(password, salt=b\"saltysalt\",\n                           iterations=1, key_length=16)\n\n    @property\n    def cookie_counts(self):\n        return self._cookie_counts\n\n    def decrypt(self, encrypted_value):\n        version = encrypted_value[:3]\n        ciphertext = encrypted_value[3:]\n\n        if version == b\"v10\":\n            self._cookie_counts[\"v10\"] += 1\n            value = _decrypt_aes_cbc(ciphertext, self._v10_key, self._offset)\n\n        elif version == b\"v11\":\n            self._cookie_counts[\"v11\"] += 1\n            if self._v11_key is None:\n                _log_warning(\"Unable to decrypt v11 cookies: no key found\")\n                return None\n            value = _decrypt_aes_cbc(ciphertext, self._v11_key, self._offset)\n\n        else:\n            self._cookie_counts[\"other\"] += 1\n            return None\n\n        if value is None:\n            value = _decrypt_aes_cbc(ciphertext, self._empty_key, self._offset)\n            if value is None:\n                _log_warning(\"Failed to decrypt cookie (AES-CBC)\")\n        return value\n\n\nclass MacChromiumCookieDecryptor(ChromiumCookieDecryptor):\n    def __init__(self, browser_keyring_name, meta_version=0):\n        password = _get_mac_keyring_password(browser_keyring_name)\n        self._v10_key = None if password is None else self.derive_key(password)\n        self._cookie_counts = {\"v10\": 0, \"other\": 0}\n        self._offset = (32 if meta_version >= 24 else 0)\n\n    def derive_key(self, password):\n        # values from\n        # https://chromium.googlesource.com/chromium/src/+/refs/heads\n        # /main/components/os_crypt/os_crypt_mac.mm\n        return pbkdf2_sha1(password, salt=b\"saltysalt\",\n                           iterations=1003, key_length=16)\n\n    @property\n    def cookie_counts(self):\n        return self._cookie_counts\n\n    def decrypt(self, encrypted_value):\n        version = encrypted_value[:3]\n        ciphertext = encrypted_value[3:]\n\n        if version == b\"v10\":\n            self._cookie_counts[\"v10\"] += 1\n            if self._v10_key is None:\n                _log_warning(\"Unable to decrypt v10 cookies: no key found\")\n                return None\n            return _decrypt_aes_cbc(ciphertext, self._v10_key, self._offset)\n\n        else:\n            self._cookie_counts[\"other\"] += 1\n            # other prefixes are considered \"old data\",\n            # which were stored as plaintext\n            # https://chromium.googlesource.com/chromium/src/+/refs/heads\n            # /main/components/os_crypt/os_crypt_mac.mm\n            return encrypted_value\n\n\nclass WindowsChromiumCookieDecryptor(ChromiumCookieDecryptor):\n    def __init__(self, browser_root, meta_version=0):\n        self._v10_key = _get_windows_v10_key(browser_root)\n        self._cookie_counts = {\"v10\": 0, \"other\": 0}\n        self._offset = (32 if meta_version >= 24 else 0)\n\n    @property\n    def cookie_counts(self):\n        return self._cookie_counts\n\n    def decrypt(self, encrypted_value):\n        version = encrypted_value[:3]\n        ciphertext = encrypted_value[3:]\n\n        if version == b\"v10\":\n            self._cookie_counts[\"v10\"] += 1\n            if self._v10_key is None:\n                _log_warning(\"Unable to decrypt v10 cookies: no key found\")\n                return None\n\n            # https://chromium.googlesource.com/chromium/src/+/refs/heads\n            # /main/components/os_crypt/os_crypt_win.cc\n            #   kNonceLength\n            nonce_length = 96 // 8\n            # boringssl\n            #   EVP_AEAD_AES_GCM_TAG_LEN\n            authentication_tag_length = 16\n\n            raw_ciphertext = ciphertext\n            nonce = raw_ciphertext[:nonce_length]\n            ciphertext = raw_ciphertext[\n                nonce_length:-authentication_tag_length]\n            authentication_tag = raw_ciphertext[-authentication_tag_length:]\n\n            return _decrypt_aes_gcm(\n                ciphertext, self._v10_key, nonce, authentication_tag,\n                self._offset)\n\n        else:\n            self._cookie_counts[\"other\"] += 1\n            # any other prefix means the data is DPAPI encrypted\n            # https://chromium.googlesource.com/chromium/src/+/refs/heads\n            # /main/components/os_crypt/os_crypt_win.cc\n            return _decrypt_windows_dpapi(encrypted_value).decode()\n\n\n# --------------------------------------------------------------------\n# keyring\n\ndef _choose_linux_keyring():\n    \"\"\"\n    https://chromium.googlesource.com/chromium/src/+/refs/heads\n    /main/components/os_crypt/key_storage_util_linux.cc\n    SelectBackend\n    \"\"\"\n    desktop_environment = _get_linux_desktop_environment(os.environ)\n    _log_debug(\"Detected desktop environment: %s\", desktop_environment)\n    if desktop_environment == DE_KDE:\n        return KEYRING_KWALLET\n    if desktop_environment == DE_OTHER:\n        return KEYRING_BASICTEXT\n    return KEYRING_GNOMEKEYRING\n\n\ndef _get_kwallet_network_wallet():\n    \"\"\" The name of the wallet used to store network passwords.\n\n    https://chromium.googlesource.com/chromium/src/+/refs/heads\n    /main/components/os_crypt/kwallet_dbus.cc\n    KWalletDBus::NetworkWallet\n    which does a dbus call to the following function:\n    https://api.kde.org/frameworks/kwallet/html/classKWallet_1_1Wallet.html\n    Wallet::NetworkWallet\n    \"\"\"\n    default_wallet = \"kdewallet\"\n    try:\n        proc, stdout = Popen_communicate(\n            \"dbus-send\", \"--session\", \"--print-reply=literal\",\n            \"--dest=org.kde.kwalletd5\",\n            \"/modules/kwalletd5\",\n            \"org.kde.KWallet.networkWallet\"\n        )\n\n        if proc.returncode != 0:\n            _log_warning(\"Failed to read NetworkWallet\")\n            return default_wallet\n        else:\n            network_wallet = stdout.decode().strip()\n            _log_debug(\"NetworkWallet = '%s'\", network_wallet)\n            return network_wallet\n    except Exception as exc:\n        _log_warning(\"Error while obtaining NetworkWallet (%s: %s)\",\n                     exc.__class__.__name__, exc)\n        return default_wallet\n\n\ndef _get_kwallet_password(browser_keyring_name):\n    _log_debug(\"Using kwallet-query to obtain password from kwallet\")\n\n    if shutil.which(\"kwallet-query\") is None:\n        _log_error(\n            \"kwallet-query command not found. KWallet and kwallet-query \"\n            \"must be installed to read from KWallet. kwallet-query should be \"\n            \"included in the kwallet package for your distribution\")\n        return b\"\"\n\n    network_wallet = _get_kwallet_network_wallet()\n\n    try:\n        proc, stdout = Popen_communicate(\n            \"kwallet-query\",\n            \"--read-password\", browser_keyring_name + \" Safe Storage\",\n            \"--folder\", browser_keyring_name + \" Keys\",\n            network_wallet,\n        )\n\n        if proc.returncode != 0:\n            _log_error(f\"kwallet-query failed with return code \"\n                       f\"{proc.returncode}. Please consult the kwallet-query \"\n                       f\"man page for details\")\n            return b\"\"\n\n        if stdout.lower().startswith(b\"failed to read\"):\n            _log_debug(\"Failed to read password from kwallet. \"\n                       \"Using empty string instead\")\n            # This sometimes occurs in KDE because chrome does not check\n            # hasEntry and instead just tries to read the value (which\n            # kwallet returns \"\") whereas kwallet-query checks hasEntry.\n            # To verify this:\n            # dbus-monitor \"interface=\"org.kde.KWallet\"\" \"type=method_return\"\n            # while starting chrome.\n            # This may be a bug, as the intended behaviour is to generate a\n            # random password and store it, but that doesn't matter here.\n            return b\"\"\n        else:\n            if stdout[-1:] == b\"\\n\":\n                stdout = stdout[:-1]\n            return stdout\n    except Exception as exc:\n        _log_warning(\"Error when running kwallet-query (%s: %s)\",\n                     exc.__class__.__name__, exc)\n        return b\"\"\n\n\ndef _get_gnome_keyring_password(browser_keyring_name):\n    try:\n        import secretstorage\n    except ImportError:\n        _log_error(\"'secretstorage' Python package not available\")\n        return b\"\"\n\n    # Gnome keyring does not seem to organise keys in the same way as KWallet,\n    # using `dbus-monitor` during startup, it can be observed that chromium\n    # lists all keys and presumably searches for its key in the list.\n    # It appears that we must do the same.\n    # https://github.com/jaraco/keyring/issues/556\n    con = secretstorage.dbus_init()\n    try:\n        col = secretstorage.get_default_collection(con)\n        label = browser_keyring_name + \" Safe Storage\"\n        for item in col.get_all_items():\n            if item.get_label() == label:\n                return item.get_secret()\n        else:\n            _log_error(\"Failed to read from GNOME keyring\")\n            return b\"\"\n    finally:\n        con.close()\n\n\ndef _get_linux_keyring_password(browser_keyring_name, keyring):\n    # Note: chrome/chromium can be run with the following flags\n    # to determine which keyring backend it has chosen to use\n    # - chromium --enable-logging=stderr --v=1 2>&1 | grep key_storage_\n    #\n    # Chromium supports --password-store=<basic|gnome|kwallet>\n    # so the automatic detection will not be sufficient in all cases.\n\n    if not keyring:\n        keyring = _choose_linux_keyring()\n    _log_debug(\"Chosen keyring: %s\", keyring)\n\n    if keyring == KEYRING_KWALLET:\n        return _get_kwallet_password(browser_keyring_name)\n    elif keyring == KEYRING_GNOMEKEYRING:\n        return _get_gnome_keyring_password(browser_keyring_name)\n    elif keyring == KEYRING_BASICTEXT:\n        # when basic text is chosen, all cookies are stored as v10\n        # so no keyring password is required\n        return None\n    assert False, \"Unknown keyring \" + keyring\n\n\ndef _get_mac_keyring_password(browser_keyring_name):\n    _log_debug(\"Using find-generic-password to obtain \"\n               \"password from OSX keychain\")\n    try:\n        proc, stdout = Popen_communicate(\n            \"security\", \"find-generic-password\",\n            \"-w\",  # write password to stdout\n            \"-a\", browser_keyring_name,  # match \"account\"\n            \"-s\", browser_keyring_name + \" Safe Storage\",  # match \"service\"\n        )\n\n        if stdout[-1:] == b\"\\n\":\n            stdout = stdout[:-1]\n        return stdout\n    except Exception as exc:\n        _log_warning(\"Error when using find-generic-password (%s: %s)\",\n                     exc.__class__.__name__, exc)\n        return None\n\n\ndef _get_windows_v10_key(browser_root):\n    path = _find_most_recently_used_file(browser_root, \"Local State\")\n    if path is None:\n        _log_error(\"Unable to find Local State file\")\n        return None\n    _log_debug(\"Found Local State file at '%s'\", path)\n    with open(path, encoding=\"utf-8\") as fp:\n        data = util.json_loads(fp.read())\n    try:\n        base64_key = data[\"os_crypt\"][\"encrypted_key\"]\n    except KeyError:\n        _log_error(\"Unable to find encrypted key in Local State\")\n        return None\n    encrypted_key = binascii.a2b_base64(base64_key)\n    prefix = b\"DPAPI\"\n    if not encrypted_key.startswith(prefix):\n        _log_error(\"Invalid Local State key\")\n        return None\n    return _decrypt_windows_dpapi(encrypted_key[len(prefix):])\n\n\n# --------------------------------------------------------------------\n# utility\n\nclass ParserError(Exception):\n    pass\n\n\nclass DataParser:\n    def __init__(self, data):\n        self.cursor = 0\n        self._data = data\n\n    def read_bytes(self, num_bytes):\n        if num_bytes < 0:\n            raise ParserError(f\"invalid read of {num_bytes} bytes\")\n        end = self.cursor + num_bytes\n        if end > len(self._data):\n            raise ParserError(\"reached end of input\")\n        data = self._data[self.cursor:end]\n        self.cursor = end\n        return data\n\n    def expect_bytes(self, expected_value, message):\n        value = self.read_bytes(len(expected_value))\n        if value != expected_value:\n            raise ParserError(f\"unexpected value: {value} != {expected_value} \"\n                              f\"({message})\")\n\n    def read_uint(self, big_endian=False):\n        data_format = \">I\" if big_endian else \"<I\"\n        return struct.unpack(data_format, self.read_bytes(4))[0]\n\n    def read_double(self, big_endian=False):\n        data_format = \">d\" if big_endian else \"<d\"\n        return struct.unpack(data_format, self.read_bytes(8))[0]\n\n    def read_cstring(self):\n        buffer = []\n        while True:\n            c = self.read_bytes(1)\n            if c == b\"\\x00\":\n                return b\"\".join(buffer).decode()\n            else:\n                buffer.append(c)\n\n    def skip(self, num_bytes, description=\"unknown\"):\n        if num_bytes > 0:\n            _log_debug(f\"Skipping {num_bytes} bytes ({description}): \"\n                       f\"{self.read_bytes(num_bytes)!r}\")\n        elif num_bytes < 0:\n            raise ParserError(f\"Invalid skip of {num_bytes} bytes\")\n\n    def skip_to(self, offset, description=\"unknown\"):\n        self.skip(offset - self.cursor, description)\n\n    def skip_to_end(self, description=\"unknown\"):\n        self.skip_to(len(self._data), description)\n\n\nclass DatabaseConnection():\n\n    def __init__(self, path):\n        self.path = path\n        self.database = None\n        self.directory = None\n\n    def __enter__(self):\n        try:\n            # https://www.sqlite.org/uri.html#the_uri_path\n            path = self.path.replace(\"?\", \"%3f\").replace(\"#\", \"%23\")\n            if util.WINDOWS:\n                path = \"/\" + os.path.abspath(path)\n\n            uri = f\"file:{path}?mode=ro&immutable=1\"\n            self.database = sqlite3.connect(\n                uri, uri=True, isolation_level=None, check_same_thread=False)\n            return self.database\n        except Exception as exc:\n            _log_debug(\"Falling back to temporary database copy (%s: %s)\",\n                       exc.__class__.__name__, exc)\n\n        try:\n            self.directory = tempfile.TemporaryDirectory(prefix=\"gallery-dl-\")\n            path_copy = os.path.join(self.directory.name, \"copy.sqlite\")\n            shutil.copyfile(self.path, path_copy)\n            self.database = sqlite3.connect(\n                path_copy, isolation_level=None, check_same_thread=False)\n            return self.database\n        except BaseException:\n            if self.directory:\n                self.directory.cleanup()\n            raise\n\n    def __exit__(self, exc_type, exc_value, traceback):\n        self.database.close()\n        if self.directory:\n            self.directory.cleanup()\n\n\ndef Popen_communicate(*args):\n    proc = util.Popen(\n        args, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)\n    try:\n        stdout, stderr = proc.communicate()\n    except BaseException:  # Including KeyboardInterrupt\n        proc.kill()\n        proc.wait()\n        raise\n    return proc, stdout\n\n\n\"\"\"\nhttps://chromium.googlesource.com/chromium/src/+/refs/heads\n/main/base/nix/xdg_util.h - DesktopEnvironment\n\"\"\"\nDE_OTHER = \"other\"\nDE_CINNAMON = \"cinnamon\"\nDE_GNOME = \"gnome\"\nDE_KDE = \"kde\"\nDE_PANTHEON = \"pantheon\"\nDE_UNITY = \"unity\"\nDE_XFCE = \"xfce\"\n\n\n\"\"\"\nhttps://chromium.googlesource.com/chromium/src/+/refs/heads\n/main/components/os_crypt/key_storage_util_linux.h - SelectedLinuxBackend\n\"\"\"\nKEYRING_KWALLET = \"kwallet\"\nKEYRING_GNOMEKEYRING = \"gnomekeyring\"\nKEYRING_BASICTEXT = \"basictext\"\nSUPPORTED_KEYRINGS = {\"kwallet\", \"gnomekeyring\", \"basictext\"}\n\n\ndef _get_linux_desktop_environment(env):\n    \"\"\"\n    Ref: https://chromium.googlesource.com/chromium/src/+/refs/heads\n         /main/base/nix/xdg_util.cc - GetDesktopEnvironment\n    \"\"\"\n    xdg_current_desktop = env.get(\"XDG_CURRENT_DESKTOP\")\n    desktop_session = env.get(\"DESKTOP_SESSION\")\n\n    if xdg_current_desktop:\n        xdg_current_desktop = (xdg_current_desktop.partition(\":\")[0]\n                               .strip().lower())\n\n        if xdg_current_desktop == \"unity\":\n            if desktop_session and \"gnome-fallback\" in desktop_session:\n                return DE_GNOME\n            else:\n                return DE_UNITY\n        elif xdg_current_desktop == \"gnome\":\n            return DE_GNOME\n        elif xdg_current_desktop == \"x-cinnamon\":\n            return DE_CINNAMON\n        elif xdg_current_desktop == \"kde\":\n            return DE_KDE\n        elif xdg_current_desktop == \"pantheon\":\n            return DE_PANTHEON\n        elif xdg_current_desktop == \"xfce\":\n            return DE_XFCE\n\n    if desktop_session:\n        if desktop_session in (\"mate\", \"gnome\"):\n            return DE_GNOME\n        if \"kde\" in desktop_session:\n            return DE_KDE\n        if \"xfce\" in desktop_session:\n            return DE_XFCE\n\n    if \"GNOME_DESKTOP_SESSION_ID\" in env:\n        return DE_GNOME\n    if \"KDE_FULL_SESSION\" in env:\n        return DE_KDE\n    return DE_OTHER\n\n\ndef _mac_absolute_time_to_posix(timestamp):\n    # 978307200 is timestamp of 2001-01-01 00:00:00\n    return 978307200 + int(timestamp)\n\n\ndef pbkdf2_sha1(password, salt, iterations, key_length):\n    return pbkdf2_hmac(\"sha1\", password, salt, iterations, key_length)\n\n\ndef _decrypt_aes_cbc(ciphertext, key, offset=0,\n                     initialization_vector=b\" \" * 16):\n    plaintext = aes.unpad_pkcs7(aes.aes_cbc_decrypt_bytes(\n        ciphertext, key, initialization_vector))\n    if offset:\n        plaintext = plaintext[offset:]\n    try:\n        return plaintext.decode()\n    except UnicodeDecodeError:\n        return None\n\n\ndef _decrypt_aes_gcm(ciphertext, key, nonce, authentication_tag, offset=0):\n    try:\n        plaintext = aes.aes_gcm_decrypt_and_verify_bytes(\n            ciphertext, key, authentication_tag, nonce)\n        if offset:\n            plaintext = plaintext[offset:]\n        return plaintext.decode()\n    except UnicodeDecodeError:\n        _log_warning(\"Failed to decrypt cookie (AES-GCM Unicode)\")\n    except ValueError:\n        _log_warning(\"Failed to decrypt cookie (AES-GCM MAC)\")\n    return None\n\n\ndef _decrypt_windows_dpapi(ciphertext):\n    \"\"\"\n    References:\n        - https://docs.microsoft.com/en-us/windows\n          /win32/api/dpapi/nf-dpapi-cryptunprotectdata\n    \"\"\"\n    from ctypes.wintypes import DWORD\n\n    class DATA_BLOB(ctypes.Structure):\n        _fields_ = [(\"cbData\", DWORD),\n                    (\"pbData\", ctypes.POINTER(ctypes.c_char))]\n\n    buffer = ctypes.create_string_buffer(ciphertext)\n    blob_in = DATA_BLOB(ctypes.sizeof(buffer), buffer)\n    blob_out = DATA_BLOB()\n    ret = ctypes.windll.crypt32.CryptUnprotectData(\n        ctypes.byref(blob_in),  # pDataIn\n        None,  # ppszDataDescr: human readable description of pDataIn\n        None,  # pOptionalEntropy: salt?\n        None,  # pvReserved: must be NULL\n        None,  # pPromptStruct: information about prompts to display\n        0,  # dwFlags\n        ctypes.byref(blob_out)  # pDataOut\n    )\n    if not ret:\n        _log_warning(\"Failed to decrypt cookie (DPAPI)\")\n        return None\n\n    result = ctypes.string_at(blob_out.pbData, blob_out.cbData)\n    ctypes.windll.kernel32.LocalFree(blob_out.pbData)\n    return result\n\n\ndef _find_most_recently_used_file(roots, filename):\n    if isinstance(roots, str):\n        roots = (roots,)\n\n    # if the provided root points to an exact profile path\n    # check if it contains the wanted filename\n    for root in roots:\n        first_choice = os.path.join(root, filename)\n        if os.path.exists(first_choice):\n            return first_choice\n\n    # if there are multiple browser profiles, take the most recently used one\n    paths = []\n    for root in roots:\n        for curr_root, dirs, files in os.walk(root):\n            for file in files:\n                if file == filename:\n                    paths.append(os.path.join(curr_root, file))\n    if not paths:\n        return None\n    return max(paths, key=lambda path: os.lstat(path).st_mtime)\n\n\ndef _is_path(value):\n    return os.path.sep in value\n\n\ndef _parse_browser_specification(\n        browser, profile=None, keyring=None, container=None, domain=None):\n    browser = browser.lower()\n    if browser not in SUPPORTED_BROWSERS:\n        raise ValueError(f\"Unsupported browser '{browser}'\")\n    if keyring and keyring not in SUPPORTED_KEYRINGS:\n        raise ValueError(f\"Unsupported keyring '{keyring}'\")\n    if profile and _is_path(profile):\n        profile = os.path.expanduser(profile)\n    return browser, profile, keyring, container, domain\n\n\n_log_cache = set()\n_log_debug = logger.debug\n_log_info = logger.info\n\n\ndef _log_warning(msg, *args):\n    if msg not in _log_cache:\n        _log_cache.add(msg)\n        logger.warning(msg, *args)\n\n\ndef _log_error(msg, *args):\n    if msg not in _log_cache:\n        _log_cache.add(msg)\n        logger.error(msg, *args)\n"
  },
  {
    "path": "gallery_dl/downloader/__init__.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2015-2021 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Downloader modules\"\"\"\n\nmodules = [\n    \"http\",\n    \"text\",\n    \"ytdl\",\n]\n\n\ndef find(scheme):\n    \"\"\"Return downloader class suitable for handling the given scheme\"\"\"\n    try:\n        return _cache[scheme]\n    except KeyError:\n        pass\n\n    cls = None\n    if scheme == \"https\":\n        scheme = \"http\"\n    if scheme in modules:  # prevent unwanted imports\n        try:\n            module = __import__(scheme, globals(), None, None, 1)\n        except ImportError:\n            pass\n        else:\n            cls = module.__downloader__\n\n    if scheme == \"http\":\n        _cache[\"http\"] = _cache[\"https\"] = cls\n    else:\n        _cache[scheme] = cls\n    return cls\n\n\n# --------------------------------------------------------------------\n# internals\n\n_cache = {}\n"
  },
  {
    "path": "gallery_dl/downloader/common.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2014-2025 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Common classes and constants used by downloader modules.\"\"\"\n\nimport os\nfrom .. import config, util\n_config = config._config\n\n\nclass DownloaderBase():\n    \"\"\"Base class for downloaders\"\"\"\n    scheme = \"\"\n\n    def __init__(self, job):\n        extractor = job.extractor\n        self.log = job.get_logger(\"downloader.\" + self.scheme)\n\n        if opts := self._extractor_config(extractor):\n            self.opts = opts\n            self.config = self.config_opts\n\n        self.out = job.out\n        self.session = extractor.session\n        self.part = self.config(\"part\", True)\n        self.partdir = self.config(\"part-directory\")\n\n        if self.partdir:\n            if isinstance(self.partdir, dict):\n                self.partdir = [\n                    (util.compile_filter(expr) if expr else util.true,\n                     util.expand_path(pdir))\n                    for expr, pdir in self.partdir.items()\n                ]\n            else:\n                self.partdir = util.expand_path(self.partdir)\n                os.makedirs(self.partdir, exist_ok=True)\n\n        proxies = self.config(\"proxy\", util.SENTINEL)\n        if proxies is util.SENTINEL:\n            self.proxies = extractor._proxies\n        else:\n            self.proxies = util.build_proxy_map(proxies, self.log)\n\n    def config(self, key, default=None):\n        \"\"\"Interpolate downloader config value for 'key'\"\"\"\n        return config.interpolate((\"downloader\", self.scheme), key, default)\n\n    def config_opts(self, key, default=None, conf=_config):\n        if key in conf:\n            return conf[key]\n        value = self.opts.get(key, util.SENTINEL)\n        if value is not util.SENTINEL:\n            return value\n        return config.interpolate((\"downloader\", self.scheme), key, default)\n\n    def _extractor_config(self, extractor):\n        path = extractor._cfgpath\n        if not isinstance(path, list):\n            return self._extractor_opts(path[1], path[2])\n\n        opts = {}\n        for cat, sub in reversed(path):\n            if popts := self._extractor_opts(cat, sub):\n                opts.update(popts)\n        return opts\n\n    def _extractor_opts(self, category, subcategory):\n        cfg = config.get((\"extractor\",), category)\n        if not cfg:\n            return None\n\n        if copts := cfg.get(self.scheme):\n            if subcategory in cfg:\n                try:\n                    if sopts := cfg[subcategory].get(self.scheme):\n                        opts = copts.copy()\n                        opts.update(sopts)\n                        return opts\n                except Exception:\n                    self._report_config_error(subcategory, cfg[subcategory])\n            return copts\n\n        if subcategory in cfg:\n            try:\n                return cfg[subcategory].get(self.scheme)\n            except Exception:\n                self._report_config_error(subcategory, cfg[subcategory])\n\n        return None\n\n    def _report_config_error(self, subcategory, value):\n        config.log.warning(\"Subcategory '%s' set to '%s' instead of object\",\n                           subcategory, util.json_dumps(value).strip('\"'))\n\n    def download(self, url, pathfmt):\n        \"\"\"Write data from 'url' into the file specified by 'pathfmt'\"\"\"\n"
  },
  {
    "path": "gallery_dl/downloader/http.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2014-2026 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Downloader module for http:// and https:// URLs\"\"\"\n\nimport time\nimport mimetypes\nfrom requests.exceptions import RequestException, ConnectionError, Timeout\nfrom .common import DownloaderBase\nfrom .. import text, util, output, exception\nfrom ssl import SSLError\nFLAGS = util.FLAGS\n\n\nclass HttpDownloader(DownloaderBase):\n    scheme = \"http\"\n\n    def __init__(self, job):\n        DownloaderBase.__init__(self, job)\n        extractor = job.extractor\n        self.downloading = False\n\n        self.adjust_extension = self.config(\"adjust-extensions\", True)\n        self.chunk_size = self.config(\"chunk-size\", 32768)\n        self.metadata = extractor.config(\"http-metadata\")\n        self.progress = self.config(\"progress\", 3.0)\n        self.validate = self.config(\"validate\", True)\n        self.validate_html = self.config(\"validate-html\", True)\n        self.headers = self.config(\"headers\")\n        self.minsize = self.config(\"filesize-min\")\n        self.maxsize = self.config(\"filesize-max\")\n        self.retries = self.config(\"retries\", extractor._retries)\n        self.retry_codes = self.config(\"retry-codes\", extractor._retry_codes)\n        self.timeout = self.config(\"timeout\", extractor._timeout)\n        self.verify = self.config(\"verify\", extractor._verify)\n        self.mtime = self.config(\"mtime\", True)\n        self.rate = self.config(\"rate\")\n        interval_429 = self.config(\"sleep-429\")\n\n        if self.config(\"consume-content\", False):\n            self.release_conn = self._release_conn_impl\n        else:\n            # this resets the underlying TCP connection, and therefore\n            # if the program makes another request to the same domain,\n            # a new connection (either TLS or plain TCP) must be made\n            self.release_conn = lambda resp: resp.close()\n\n        if self.retries < 0:\n            self.retries = float(\"inf\")\n        if self.minsize:\n            minsize = text.parse_bytes(self.minsize)\n            if not minsize:\n                self.log.warning(\n                    \"Invalid minimum file size (%r)\", self.minsize)\n            self.minsize = minsize\n        if self.maxsize:\n            maxsize = text.parse_bytes(self.maxsize)\n            if not maxsize:\n                self.log.warning(\n                    \"Invalid maximum file size (%r)\", self.maxsize)\n            self.maxsize = maxsize\n        if isinstance(self.chunk_size, str):\n            chunk_size = text.parse_bytes(self.chunk_size)\n            if not chunk_size:\n                self.log.warning(\n                    \"Invalid chunk size (%r)\", self.chunk_size)\n                chunk_size = 32768\n            self.chunk_size = chunk_size\n        if self.rate:\n            func = util.build_selection_func(self.rate, 0, text.parse_bytes)\n            if rmax := func.args[1] if hasattr(func, \"args\") else func():\n                if rmax < self.chunk_size:\n                    # reduce chunk_size to allow for one iteration each second\n                    self.chunk_size = rmax\n                self.rate = func\n                self.receive = self._receive_rate\n            else:\n                self.log.warning(\"Invalid rate limit (%r)\", self.rate)\n                self.rate = False\n        if self.progress is not None:\n            self.receive = self._receive_rate\n            if self.progress < 0.0:\n                self.progress = 0.0\n        if interval_429 is None:\n            self.interval_429 = extractor._interval_429\n        else:\n            try:\n                self.interval_429 = util.build_duration_func_ex(interval_429)\n            except Exception as exc:\n                self.log.error(\"Invalid 'sleep-429' value '%s' (%s: %s)\",\n                               interval_429, exc.__class__.__name__, exc)\n                self.interval_429 = extractor._interval_429\n\n    def download(self, url, pathfmt):\n        try:\n            return self._download_impl(url, pathfmt)\n        except Exception as exc:\n            if self.downloading:\n                output.stderr_write(\"\\n\")\n            self.log.traceback(exc)\n            raise\n        finally:\n            # remove file from incomplete downloads\n            if self.downloading and not self.part:\n                util.remove_file(pathfmt.temppath)\n\n    def _download_impl(self, url, pathfmt):\n        response = None\n        tries = code = 0\n        msg = \"\"\n\n        metadata = self.metadata\n        kwdict = pathfmt.kwdict\n        expected_status = kwdict.get(\n            \"_http_expected_status\", ())\n        adjust_extension = kwdict.get(\n            \"_http_adjust_extension\", self.adjust_extension)\n\n        if self.part and not metadata:\n            pathfmt.part_enable(self.partdir)\n\n        while True:\n            if FLAGS.DOWNLOAD is not None:\n                return FLAGS.process(\"DOWNLOAD\")\n\n            if tries:\n                if response:\n                    self.release_conn(response)\n                    response = None\n\n                self.log.warning(\"%s (%s/%s)\", msg, tries, self.retries+1)\n                if tries > self.retries:\n                    return False\n\n                if code == 429 and self.interval_429:\n                    s = self.interval_429(tries)\n                    time.sleep(s if s > tries else tries)\n                else:\n                    time.sleep(tries)\n                code = 0\n\n            tries += 1\n            file_header = None\n\n            # collect HTTP headers\n            headers = {\"Accept\": \"*/*\"}\n            #   file-specific headers\n            if extra := kwdict.get(\"_http_headers\"):\n                headers.update(extra)\n            #   general headers\n            if self.headers:\n                headers.update(self.headers)\n            #   partial content\n            if file_size := pathfmt.part_size():\n                headers[\"Range\"] = f\"bytes={file_size}-\"\n\n            # connect to (remote) source\n            try:\n                response = self.session.request(\n                    kwdict.get(\"_http_method\", \"GET\"), url,\n                    stream=True,\n                    headers=headers,\n                    data=kwdict.get(\"_http_data\"),\n                    timeout=self.timeout,\n                    proxies=self.proxies,\n                    verify=self.verify,\n                )\n            except ConnectionError as exc:\n                try:\n                    reason = exc.args[0].reason\n                    cls = reason.__class__.__name__\n                    pre, _, err = str(reason.args[-1]).partition(\":\")\n                    msg = f\"{cls}: {(err or pre).lstrip()}\"\n                except Exception:\n                    msg = str(exc)\n                continue\n            except Timeout as exc:\n                msg = str(exc)\n                continue\n            except Exception as exc:\n                self.log.warning(exc)\n                return False\n\n            # check response\n            code = response.status_code\n            if code == 200 or code in expected_status:  # OK\n                offset = 0\n                size = response.headers.get(\"Content-Length\")\n            elif code == 206:  # Partial Content\n                offset = file_size\n                size = response.headers[\"Content-Range\"].rpartition(\"/\")[2]\n            elif code == 416 and file_size:  # Range Not Satisfiable\n                self._release_conn_impl(response)\n                break\n            else:\n                msg = f\"'{code} {response.reason}' for '{url}'\"\n\n                challenge = util.detect_challenge(response)\n                if challenge is not None:\n                    self.log.warning(challenge)\n\n                if code in self.retry_codes or 500 <= code < 600:\n                    continue\n                retry = kwdict.get(\"_http_retry\")\n                if retry and retry(response):\n                    continue\n                self.release_conn(response)\n                self.log.warning(msg)\n                return False\n\n            # check for invalid responses\n            if self.validate and \\\n                    (validate := kwdict.get(\"_http_validate\")) is not None:\n                try:\n                    result = validate(response)\n                except Exception:\n                    self.release_conn(response)\n                    raise\n                if isinstance(result, str):\n                    url = result\n                    tries -= 1\n                    continue\n                if not result:\n                    self.release_conn(response)\n                    self.log.warning(\"Invalid response\")\n                    return False\n            if self.validate_html and response.headers.get(\n                    \"content-type\", \"\").startswith(\"text/html\") and \\\n                    pathfmt.extension not in (\"html\", \"htm\"):\n                if response.history:\n                    self.log.warning(\"HTTP redirect to '%s'\", response.url)\n                else:\n                    self.log.warning(\"HTML response\")\n                return False\n\n            # check file size\n            size = text.parse_int(size, None)\n            if size is not None:\n                if not size:\n                    self.release_conn(response)\n                    self.log.warning(\"Empty file\")\n                    return False\n                if self.minsize and size < self.minsize:\n                    self.release_conn(response)\n                    self.log.warning(\n                        \"File size smaller than allowed minimum (%s < %s)\",\n                        size, self.minsize)\n                    pathfmt.temppath = \"\"\n                    return True\n                if self.maxsize and size > self.maxsize:\n                    self.release_conn(response)\n                    self.log.warning(\n                        \"File size larger than allowed maximum (%s > %s)\",\n                        size, self.maxsize)\n                    pathfmt.temppath = \"\"\n                    return True\n\n            build_path = False\n\n            # set missing filename extension from MIME type\n            if not pathfmt.extension:\n                pathfmt.set_extension(self._find_extension(response))\n                build_path = True\n\n            # set metadata from HTTP headers\n            if metadata:\n                kwdict[metadata] = util.extract_headers(response)\n                build_path = True\n\n            # build and check file path\n            if build_path:\n                pathfmt.build_path()\n                if pathfmt.exists():\n                    pathfmt.temppath = \"\"\n                    # release the connection back to pool by explicitly\n                    # calling .close()\n                    # see https://requests.readthedocs.io/en/latest/user\n                    # /advanced/#body-content-workflow\n                    # when the image size is on the order of megabytes,\n                    # re-establishing a TLS connection will typically be faster\n                    # than consuming the whole response\n                    response.close()\n                    return True\n                if self.part and metadata:\n                    pathfmt.part_enable(self.partdir)\n                metadata = False\n\n            content = response.iter_content(self.chunk_size)\n\n            validate_sig = kwdict.get(\"_http_signature\")\n            validate_ext = (adjust_extension and\n                            pathfmt.extension in SIGNATURE_CHECKS)\n\n            # check filename extension against file header\n            if not offset and (validate_ext or validate_sig):\n                try:\n                    file_header = next(\n                        content if response.raw.chunked\n                        else response.iter_content(16), b\"\")\n                except (RequestException, SSLError) as exc:\n                    msg = str(exc)\n                    continue\n                if validate_sig:\n                    result = validate_sig(file_header)\n                    if result is not True:\n                        self.release_conn(response)\n                        self.log.warning(\n                            result or \"Invalid file signature bytes\")\n                        return False\n                if validate_ext and self._adjust_extension(\n                        pathfmt, file_header) and pathfmt.exists():\n                    pathfmt.temppath = \"\"\n                    response.close()\n                    return True\n\n            # set open mode\n            if not offset:\n                mode = \"w+b\"\n                if file_size:\n                    self.log.debug(\"Unable to resume partial download\")\n            else:\n                mode = \"r+b\"\n                self.log.debug(\"Resuming download at byte %d\", offset)\n\n            # download content\n            self.downloading = True\n            with pathfmt.open(mode) as fp:\n                if fp is None:\n                    # '.part' file no longer exists\n                    break\n                if file_header:\n                    fp.write(file_header)\n                    offset += len(file_header)\n                elif offset:\n                    if adjust_extension and \\\n                            pathfmt.extension in SIGNATURE_CHECKS:\n                        self._adjust_extension(pathfmt, fp.read(16))\n                    fp.seek(offset)\n\n                self.out.start(pathfmt.path)\n                try:\n                    self.receive(fp, content, size, offset)\n                except (RequestException, SSLError) as exc:\n                    msg = str(exc)\n                    output.stderr_write(\"\\n\")\n                    continue\n                except exception.StopExtraction:\n                    response.close()\n                    return False\n                except exception.ControlException:\n                    response.close()\n                    raise\n\n                # check file size\n                if size and (fsize := fp.tell()) < size:\n                    if (segmented := kwdict.get(\"_http_segmented\")) and \\\n                            segmented is True or segmented == fsize:\n                        tries -= 1\n                        msg = \"Resuming segmented download\"\n                        output.stdout_write(\"\\r\")\n                    else:\n                        msg = f\"file size mismatch ({fsize} < {size})\"\n                        output.stderr_write(\"\\n\")\n                    continue\n\n            break\n\n        self.downloading = False\n        if self.mtime:\n            if \"_http_lastmodified\" in kwdict:\n                kwdict[\"_mtime_http\"] = kwdict[\"_http_lastmodified\"]\n            else:\n                kwdict[\"_mtime_http\"] = response.headers.get(\"Last-Modified\")\n        else:\n            kwdict[\"_mtime_http\"] = None\n\n        return True\n\n    def _release_conn_impl(self, response):\n        \"\"\"Release connection back to pool by consuming response body\"\"\"\n        try:\n            for _ in response.iter_content(self.chunk_size):\n                pass\n        except (RequestException, SSLError) as exc:\n            output.stderr_write(\"\\n\")\n            self.log.debug(\n                \"Unable to consume response body (%s: %s); \"\n                \"closing the connection anyway\", exc.__class__.__name__, exc)\n            response.close()\n\n    def receive(self, fp, content, bytes_total, bytes_start):\n        write = fp.write\n        for data in content:\n            if FLAGS.DOWNLOAD is not None:\n                return FLAGS.process(\"DOWNLOAD\")\n            write(data)\n\n    def _receive_rate(self, fp, content, bytes_total, bytes_start):\n        rate = self.rate() if self.rate else None\n        write = fp.write\n        progress = self.progress\n\n        bytes_downloaded = 0\n        time_start = time.monotonic()\n\n        for data in content:\n            if FLAGS.DOWNLOAD is not None:\n                return FLAGS.process(\"DOWNLOAD\")\n            time_elapsed = time.monotonic() - time_start\n            bytes_downloaded += len(data)\n\n            write(data)\n\n            if progress is not None:\n                if time_elapsed > progress:\n                    self.out.progress(\n                        bytes_total,\n                        bytes_start + bytes_downloaded,\n                        int(bytes_downloaded / time_elapsed),\n                    )\n\n            if rate is not None:\n                time_expected = bytes_downloaded / rate\n                if time_expected > time_elapsed:\n                    time.sleep(time_expected - time_elapsed)\n\n    def _find_extension(self, response):\n        \"\"\"Get filename extension from MIME type\"\"\"\n        mtype = response.headers.get(\"Content-Type\", \"image/jpeg\")\n        mtype = mtype.partition(\";\")[0].lower()\n\n        if \"/\" not in mtype:\n            mtype = \"image/\" + mtype\n\n        if mtype in MIME_TYPES:\n            return MIME_TYPES[mtype]\n\n        if ext := mimetypes.guess_extension(mtype, strict=False):\n            return ext[1:]\n\n        self.log.warning(\"Unknown MIME type '%s'\", mtype)\n        return \"bin\"\n\n    def _adjust_extension(self, pathfmt, file_header):\n        \"\"\"Check filename extension against file header\"\"\"\n        if not SIGNATURE_CHECKS[pathfmt.extension](file_header):\n            for ext, check in SIGNATURE_CHECKS.items():\n                if check(file_header):\n                    self.log.debug(\n                        \"Adjusting filename extension of '%s' to '%s'\",\n                        pathfmt.filename, ext)\n                    pathfmt.set_extension(ext)\n                    pathfmt.build_path()\n                    return True\n        return False\n\n\nMIME_TYPES = {\n    \"image/jpeg\"    : \"jpg\",\n    \"image/jpg\"     : \"jpg\",\n    \"image/png\"     : \"png\",\n    \"image/gif\"     : \"gif\",\n    \"image/bmp\"     : \"bmp\",\n    \"image/x-bmp\"   : \"bmp\",\n    \"image/x-ms-bmp\": \"bmp\",\n    \"image/webp\"    : \"webp\",\n    \"image/avif\"    : \"avif\",\n    \"image/heic\"    : \"heic\",\n    \"image/heif\"    : \"heif\",\n    \"image/svg+xml\" : \"svg\",\n    \"image/ico\"     : \"ico\",\n    \"image/icon\"    : \"ico\",\n    \"image/x-icon\"  : \"ico\",\n    \"image/vnd.microsoft.icon\" : \"ico\",\n    \"image/x-photoshop\"        : \"psd\",\n    \"application/x-photoshop\"  : \"psd\",\n    \"image/vnd.adobe.photoshop\": \"psd\",\n\n    \"video/webm\": \"webm\",\n    \"video/ogg\" : \"ogg\",\n    \"video/mp4\" : \"mp4\",\n    \"video/m4v\" : \"m4v\",\n    \"video/x-m4v\": \"m4v\",\n    \"video/quicktime\": \"mov\",\n\n    \"audio/wav\"  : \"wav\",\n    \"audio/x-wav\": \"wav\",\n    \"audio/webm\" : \"webm\",\n    \"audio/ogg\"  : \"ogg\",\n    \"audio/mpeg\" : \"mp3\",\n    \"audio/aac\"  : \"aac\",\n    \"audio/x-aac\": \"aac\",\n\n    \"application/vnd.apple.mpegurl\": \"m3u8\",\n    \"application/x-mpegurl\"        : \"m3u8\",\n    \"application/dash+xml\"         : \"mpd\",\n\n    \"application/zip\"  : \"zip\",\n    \"application/x-zip\": \"zip\",\n    \"application/x-zip-compressed\": \"zip\",\n    \"application/rar\"  : \"rar\",\n    \"application/x-rar\": \"rar\",\n    \"application/x-rar-compressed\": \"rar\",\n    \"application/x-7z-compressed\" : \"7z\",\n\n    \"application/pdf\"  : \"pdf\",\n    \"application/x-pdf\": \"pdf\",\n    \"application/x-shockwave-flash\": \"swf\",\n\n    \"text/html\": \"html\",\n\n    \"application/ogg\": \"ogg\",\n    # https://www.iana.org/assignments/media-types/model/obj\n    \"model/obj\": \"obj\",\n    \"application/octet-stream\": \"bin\",\n}\n\n\ndef _signature_html(s):\n    s = s[:14].lstrip()\n    return s and b\"<!doctype html\".startswith(s.lower())\n\n\n# https://en.wikipedia.org/wiki/List_of_file_signatures\nSIGNATURE_CHECKS = {\n    \"jpg\" : lambda s: s[0:3] == b\"\\xFF\\xD8\\xFF\",\n    \"png\" : lambda s: s[0:8] == b\"\\x89PNG\\r\\n\\x1A\\n\",\n    \"gif\" : lambda s: s[0:6] in (b\"GIF87a\", b\"GIF89a\"),\n    \"bmp\" : lambda s: s[0:2] == b\"BM\",\n    \"webp\": lambda s: (s[0:4] == b\"RIFF\" and\n                       s[8:12] == b\"WEBP\"),\n    \"avif\": lambda s: s[4:11] == b\"ftypavi\" and s[11] in b\"fs\",\n    \"heic\": lambda s: (s[4:10] == b\"ftyphe\" and s[10:12] in (\n                       b\"ic\", b\"im\", b\"is\", b\"ix\", b\"vc\", b\"vm\", b\"vs\")),\n    \"svg\" : lambda s: s[0:5] == b\"<?xml\",\n    \"ico\" : lambda s: s[0:4] == b\"\\x00\\x00\\x01\\x00\",\n    \"cur\" : lambda s: s[0:4] == b\"\\x00\\x00\\x02\\x00\",\n    \"psd\" : lambda s: s[0:4] == b\"8BPS\",\n    \"mp4\" : lambda s: (s[4:8] == b\"ftyp\" and s[8:11] in (\n                       b\"mp4\", b\"avc\", b\"iso\")),\n    \"m4v\" : lambda s: s[4:11] == b\"ftypM4V\",\n    \"mov\" : lambda s: s[4:12] == b\"ftypqt  \",\n    \"webm\": lambda s: s[0:4] == b\"\\x1A\\x45\\xDF\\xA3\",\n    \"ogg\" : lambda s: s[0:4] == b\"OggS\",\n    \"wav\" : lambda s: (s[0:4] == b\"RIFF\" and\n                       s[8:12] == b\"WAVE\"),\n    \"mp3\" : lambda s: (s[0:3] == b\"ID3\" or\n                       s[0:2] in (b\"\\xFF\\xFB\", b\"\\xFF\\xF3\", b\"\\xFF\\xF2\")),\n    \"aac\" : lambda s: s[0:2] in (b\"\\xFF\\xF9\", b\"\\xFF\\xF1\"),\n    \"m3u8\": lambda s: s[0:7] == b\"#EXTM3U\",\n    \"mpd\" : lambda s: b\"<MPD\" in s,\n    \"zip\" : lambda s: s[0:4] in (b\"PK\\x03\\x04\", b\"PK\\x05\\x06\", b\"PK\\x07\\x08\"),\n    \"rar\" : lambda s: s[0:6] == b\"Rar!\\x1A\\x07\",\n    \"7z\"  : lambda s: s[0:6] == b\"\\x37\\x7A\\xBC\\xAF\\x27\\x1C\",\n    \"pdf\" : lambda s: s[0:5] == b\"%PDF-\",\n    \"swf\" : lambda s: s[0:3] in (b\"CWS\", b\"FWS\"),\n    \"html\": _signature_html,\n    \"htm\" : _signature_html,\n    \"blend\": lambda s: s[0:7] == b\"BLENDER\",\n    # unfortunately the Wavefront .obj format doesn't have a signature,\n    # so we check for the existence of Blender's comment\n    \"obj\" : lambda s: s[0:11] == b\"# Blender v\",\n    # Celsys Clip Studio Paint format\n    # https://github.com/rasensuihei/cliputils/blob/master/README.md\n    \"clip\": lambda s: s[0:8] == b\"CSFCHUNK\",\n    # check 'bin' files against all other file signatures\n    \"bin\" : lambda s: False,\n}\n\n__downloader__ = HttpDownloader\n"
  },
  {
    "path": "gallery_dl/downloader/text.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2014-2019 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Downloader module for text: URLs\"\"\"\n\nfrom .common import DownloaderBase\n\n\nclass TextDownloader(DownloaderBase):\n    scheme = \"text\"\n\n    def download(self, url, pathfmt):\n        if self.part:\n            pathfmt.part_enable(self.partdir)\n        self.out.start(pathfmt.path)\n        with pathfmt.open(\"wb\") as fp:\n            fp.write(url.encode()[5:])\n        return True\n\n\n__downloader__ = TextDownloader\n"
  },
  {
    "path": "gallery_dl/downloader/ytdl.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2018-2026 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Downloader module for URLs requiring youtube-dl support\"\"\"\n\nfrom .common import DownloaderBase\nfrom .. import ytdl, text, util\nfrom xml.etree import ElementTree\nfrom http.cookiejar import Cookie\nimport os\nFLAGS = util.FLAGS\n\n\nclass YoutubeDLDownloader(DownloaderBase):\n    scheme = \"ytdl\"\n\n    def __init__(self, job):\n        DownloaderBase.__init__(self, job)\n\n        extractor = job.extractor\n        self.retries = self.config(\"retries\", extractor._retries)\n        self.ytdl_opts = {\n            \"retries\": self.retries+1 if self.retries >= 0 else float(\"inf\"),\n            \"socket_timeout\": self.config(\"timeout\", extractor._timeout),\n            \"nocheckcertificate\": not self.config(\"verify\", extractor._verify),\n            \"proxy\": self.proxies.get(\"http\") if self.proxies else None,\n            \"ignoreerrors\": True,\n        }\n\n        self.ytdl_instance = None\n        self.rate_dyn = None\n        self.forward_cookies = self.config(\"forward-cookies\", True)\n        self.progress = self.config(\"progress\", 3.0)\n        self.outtmpl = self.config(\"outtmpl\")\n\n    def download(self, url, pathfmt):\n        kwdict = pathfmt.kwdict\n        tries = 0\n\n        kwdict[\"_mtime_http\"] = None\n        if ytdl_instance := kwdict.pop(\"_ytdl_instance\", None):\n            # 'ytdl' extractor\n            self._prepare(ytdl_instance)\n            info_dict = kwdict.pop(\"_ytdl_info_dict\")\n        else:\n            # other extractors\n            ytdl_instance = self.ytdl_instance\n            if not ytdl_instance:\n                try:\n                    module = ytdl.import_module(self.config(\"module\"))\n                except (ImportError, SyntaxError) as exc:\n                    if exc.__context__:\n                        self.log.error(\"Cannot import yt-dlp or youtube-dl\")\n                    else:\n                        self.log.error(\"Cannot import module '%s'\",\n                                       getattr(exc, \"name\", \"\"))\n                    self.log.traceback(exc)\n                    self.download = lambda u, p: False\n                    return False\n\n                try:\n                    ytdl_version = module.version.__version__\n                except Exception:\n                    ytdl_version = \"\"\n                self.log.debug(\"Using %s version %s\", module, ytdl_version)\n\n                self.ytdl_instance = ytdl_instance = ytdl.construct_YoutubeDL(\n                    module, self, self.ytdl_opts, kwdict.get(\"_ytdl_params\"))\n                self.ytdl_pp = module.postprocessor\n                if self.outtmpl == \"default\":\n                    self.outtmpl = module.DEFAULT_OUTTMPL\n                self._prepare(ytdl_instance)\n\n            if self.forward_cookies:\n                self.log.debug(\"Forwarding cookies to %s\",\n                               ytdl_instance.__module__)\n                set_cookie = ytdl_instance.cookiejar.set_cookie\n                for cookie in self.session.cookies:\n                    set_cookie(cookie)\n\n            url = url[5:]\n            manifest = kwdict.get(\"_ytdl_manifest\")\n            while True:\n                if FLAGS.DOWNLOAD is not None:\n                    return FLAGS.process(\"DOWNLOAD\")\n\n                tries += 1\n                self.error = None\n                try:\n                    if manifest is None:\n                        info_dict = self._extract_url(\n                            ytdl_instance, url)\n                    else:\n                        info_dict = self._extract_manifest(\n                            ytdl_instance, url, kwdict)\n                except Exception as exc:\n                    self.log.traceback(exc)\n                    cls = exc.__class__\n                    if cls.__module__ == \"builtins\":\n                        tries = False\n                    msg = f\"{cls.__name__}: {exc}\"\n                else:\n                    if self.error is not None:\n                        msg = self.error\n                    elif not info_dict:\n                        msg = \"Empty 'info_dict' data\"\n                    else:\n                        break\n\n                if tries:\n                    self.log.error(\"%s (%s/%s)\", msg, tries, self.retries+1)\n                else:\n                    self.log.error(msg)\n                    return False\n                if tries > self.retries:\n                    return False\n\n        if extra := kwdict.get(\"_ytdl_extra\"):\n            info_dict.update(extra)\n\n        while True:\n            if FLAGS.DOWNLOAD is not None:\n                return FLAGS.process(\"DOWNLOAD\")\n\n            tries += 1\n            self.error = None\n            try:\n                if \"entries\" in info_dict:\n                    success = self._download_playlist(\n                        ytdl_instance, pathfmt, info_dict)\n                else:\n                    success = self._download_video(\n                        ytdl_instance, pathfmt, info_dict)\n            except Exception as exc:\n                self.log.traceback(exc)\n                cls = exc.__class__\n                if cls.__module__ == \"builtins\":\n                    tries = False\n                msg = f\"{cls.__name__}: {exc}\"\n            else:\n                if self.error is not None:\n                    msg = self.error\n                elif not success:\n                    msg = \"Error\"\n                else:\n                    break\n\n            if tries:\n                self.log.error(\"%s (%s/%s)\", msg, tries, self.retries+1)\n            else:\n                self.log.error(msg)\n                return False\n            if tries > self.retries:\n                return False\n        return True\n\n    def _extract_url(self, ytdl, url):\n        return ytdl.extract_info(url, download=False)\n\n    def _extract_manifest(self, ytdl, url, kwdict):\n        extr = ytdl.get_info_extractor(\"Generic\")\n        video_id = extr._generic_id(url)\n\n        if cookies := kwdict.get(\"_ytdl_manifest_cookies\"):\n            if isinstance(cookies, dict):\n                cookies = cookies.items()\n            set_cookie = ytdl.cookiejar.set_cookie\n            for name, value in cookies:\n                set_cookie(Cookie(\n                    0, name, value, None, False,\n                    \"\", False, False, \"/\", False,\n                    False, None, False, None, None, {},\n                ))\n\n        type = kwdict[\"_ytdl_manifest\"]\n        data = kwdict.get(\"_ytdl_manifest_data\")\n        remux = kwdict.get(\"_ytdl_manifest_remux\")\n        headers = kwdict.get(\"_ytdl_manifest_headers\")\n        if type == \"hls\":\n            ext = \"ytdl\" if remux else \"mp4\"\n            protocol = \"m3u8_native\"\n\n            if data is None:\n                try:\n                    fmts, subs = extr._extract_m3u8_formats_and_subtitles(\n                        url, video_id, ext, protocol, headers=headers)\n                except AttributeError:\n                    fmts = extr._extract_m3u8_formats(\n                        url, video_id, ext, protocol, headers=headers)\n                    subs = None\n            else:\n                try:\n                    fmts, subs = extr._parse_m3u8_formats_and_subtitles(\n                        data, url, ext, protocol, headers=headers)\n                except AttributeError:\n                    fmts = extr._parse_m3u8_formats(\n                        data, url, ext, protocol, headers=headers)\n                    subs = None\n\n        elif type == \"dash\":\n            if data is None:\n                try:\n                    fmts, subs = extr._extract_mpd_formats_and_subtitles(\n                        url, video_id, headers=headers)\n                except AttributeError:\n                    fmts = extr._extract_mpd_formats(\n                        url, video_id, headers=headers)\n                    subs = None\n            else:\n                if isinstance(data, str):\n                    data = ElementTree.fromstring(data)\n                try:\n                    fmts, subs = extr._parse_mpd_formats_and_subtitles(\n                        data, mpd_id=\"dash\")\n                except AttributeError:\n                    fmts = extr._parse_mpd_formats(\n                        data, mpd_id=\"dash\")\n                    subs = None\n\n        else:\n            raise ValueError(f\"Unsupported manifest type '{type}'\")\n\n        if headers:\n            for fmt in fmts:\n                fmt[\"http_headers\"] = headers\n\n        info_dict = {\n            \"extractor\": \"\",\n            \"id\"       : video_id,\n            \"title\"    : video_id,\n            \"formats\"  : fmts,\n            \"subtitles\": subs,\n        }\n        info_dict = ytdl.process_ie_result(info_dict, download=False)\n\n        if remux:\n            info_dict[\"__postprocessors\"] = [\n                self.ytdl_pp.FFmpegVideoRemuxerPP(self.ytdl_instance, remux)]\n\n        return info_dict\n\n    def _download_video(self, ytdl_instance, pathfmt, info_dict):\n        if \"url\" in info_dict:\n            if \"filename\" in pathfmt.kwdict:\n                pathfmt.kwdict[\"extension\"] = \\\n                    text.ext_from_url(info_dict[\"url\"])\n            else:\n                text.nameext_from_url(info_dict[\"url\"], pathfmt.kwdict)\n\n        formats = info_dict.get(\"requested_formats\")\n        if formats and not compatible_formats(formats):\n            info_dict[\"ext\"] = \"mkv\"\n        elif \"ext\" not in info_dict:\n            try:\n                info_dict[\"ext\"] = info_dict[\"formats\"][0][\"ext\"]\n            except LookupError:\n                info_dict[\"ext\"] = \"mp4\"\n\n        if self.outtmpl:\n            self._set_outtmpl(ytdl_instance, self.outtmpl)\n            pathfmt.filename = filename = \\\n                ytdl_instance.prepare_filename(info_dict)\n            pathfmt.extension = info_dict[\"ext\"]\n            pathfmt.path = pathfmt.directory + filename\n            pathfmt.realpath = pathfmt.temppath = (\n                pathfmt.realdirectory + filename)\n        elif info_dict[\"ext\"] != \"ytdl\":\n            pathfmt.set_extension(info_dict[\"ext\"])\n            pathfmt.build_path()\n\n        if pathfmt.exists():\n            pathfmt.temppath = \"\"\n            return True\n\n        if self.rate_dyn is not None:\n            # static ratelimits are set in ytdl.construct_YoutubeDL\n            ytdl_instance.params[\"ratelimit\"] = self.rate_dyn()\n\n        self.out.start(pathfmt.path)\n        if self.part:\n            pathfmt.kwdict[\"extension\"] = pathfmt.prefix\n            filename = pathfmt.build_filename(pathfmt.kwdict)\n            pathfmt.kwdict[\"extension\"] = info_dict[\"ext\"]\n            if self.partdir:\n                path = os.path.join(self.partdir, filename)\n            else:\n                path = pathfmt.realdirectory + filename\n            path = path.replace(\"%\", \"%%\") + \"%(ext)s\"\n        else:\n            path = pathfmt.realpath.replace(\"%\", \"%%\")\n\n        self._set_outtmpl(ytdl_instance, path)\n        ytdl_instance.process_info(info_dict)\n        pathfmt.temppath = info_dict.get(\"filepath\") or info_dict[\"_filename\"]\n        return True\n\n    def _download_playlist(self, ytdl_instance, pathfmt, info_dict):\n        pathfmt.kwdict[\"extension\"] = pathfmt.prefix\n        filename = pathfmt.build_filename(pathfmt.kwdict)\n        pathfmt.kwdict[\"extension\"] = pathfmt.extension\n        path = pathfmt.realdirectory + filename\n        path = path.replace(\"%\", \"%%\") + \"%(playlist_index)s.%(ext)s\"\n        self._set_outtmpl(ytdl_instance, path)\n\n        status = False\n        for entry in info_dict[\"entries\"]:\n            if not entry:\n                continue\n            if self.rate_dyn is not None:\n                ytdl_instance.params[\"ratelimit\"] = self.rate_dyn()\n            try:\n                ytdl_instance.process_info(entry)\n                status = True\n            except Exception as exc:\n                self.log.traceback(exc)\n                self.log.error(\"%s: %s\", exc.__class__.__name__, exc)\n        return status\n\n    def _prepare(self, ytdl_instance):\n        if \"__gdl_initialize\" not in ytdl_instance.params:\n            return\n\n        del ytdl_instance.params[\"__gdl_initialize\"]\n        if self.progress is not None:\n            ytdl_instance.add_progress_hook(self._progress_hook)\n        if rlf := ytdl_instance.params.pop(\"__gdl_ratelimit_func\", False):\n            self.rate_dyn = rlf\n        ytdl_instance.params[\"logger\"] = LoggerAdapter(self, ytdl_instance)\n\n    def _progress_hook(self, info):\n        if info[\"status\"] == \"downloading\" and \\\n                info[\"elapsed\"] >= self.progress:\n            total = info.get(\"total_bytes\") or info.get(\"total_bytes_estimate\")\n            speed = info.get(\"speed\")\n            self.out.progress(\n                None if total is None else int(total),\n                info[\"downloaded_bytes\"],\n                int(speed) if speed else 0,\n            )\n\n    def _set_outtmpl(self, ytdl_instance, outtmpl):\n        try:\n            ytdl_instance._parse_outtmpl\n        except AttributeError:\n            try:\n                ytdl_instance.outtmpl_dict[\"default\"] = outtmpl\n            except AttributeError:\n                ytdl_instance.params[\"outtmpl\"] = outtmpl\n        else:\n            ytdl_instance.params[\"outtmpl\"] = {\"default\": outtmpl}\n\n\nclass LoggerAdapter():\n    __slots__ = (\"obj\", \"log\")\n\n    def __init__(self, obj, ytdl_instance):\n        self.obj = obj\n        self.log = ytdl_instance.params.get(\"logger\")\n\n    def debug(self, msg):\n        if self.log is not None:\n            if msg[0] == \"[\":\n                msg = msg[msg.find(\"]\")+2:]\n            self.log.debug(msg)\n\n    def warning(self, msg):\n        if self.log is not None:\n            if \"WARNING:\" in msg:\n                msg = msg[msg.find(\" \")+1:]\n            self.log.warning(msg)\n\n    def error(self, msg):\n        if \"ERROR:\" in msg:\n            msg = msg[msg.find(\" \")+1:]\n        self.obj.error = msg\n\n\ndef compatible_formats(formats):\n    \"\"\"Returns True if 'formats' are compatible for merge\"\"\"\n    video_ext = formats[0].get(\"ext\")\n    audio_ext = formats[1].get(\"ext\")\n\n    if video_ext == \"webm\" and audio_ext == \"webm\":\n        return True\n\n    exts = (\"mp3\", \"mp4\", \"m4a\", \"m4p\", \"m4b\", \"m4r\", \"m4v\", \"ismv\", \"isma\")\n    return video_ext in exts and audio_ext in exts\n\n\n__downloader__ = YoutubeDLDownloader\n"
  },
  {
    "path": "gallery_dl/dt.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2025 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Date/Time utilities\"\"\"\n\nimport sys\nimport time\nfrom datetime import datetime, date, timedelta, timezone  # noqa F401\n\n\nclass NullDatetime(datetime):\n\n    def __bool__(self):\n        return False\n\n    def __str__(self):\n        return \"[Invalid DateTime]\"\n\n    def __format__(self, format_spec):\n        return \"[Invalid DateTime]\"\n\n\nNONE = NullDatetime(1, 1, 1)\nEPOCH = datetime(1970, 1, 1)\nSECOND = timedelta(0, 1)\n\n\ndef normalize(dt):\n    #  if (o := dt.utcoffset()) is not None:\n    #      return dt.replace(tzinfo=None, microsecond=0) - o\n    if dt.tzinfo is not None:\n        return dt.astimezone(timezone.utc).replace(tzinfo=None, microsecond=0)\n    if dt.microsecond:\n        return dt.replace(microsecond=0)\n    return dt\n\n\ndef convert(value):\n    \"\"\"Convert 'value' to a naive UTC datetime object\"\"\"\n    if not value:\n        return NONE\n    if isinstance(value, datetime):\n        return normalize(value)\n    if isinstance(value, str) and (dt := parse_iso(value)) is not NONE:\n        return dt\n    return parse_ts(value)\n\n\ndef parse(dt_string, format):\n    \"\"\"Parse 'dt_string' according to 'format'\"\"\"\n    try:\n        return normalize(datetime.strptime(dt_string, format))\n    except Exception:\n        return NONE\n\n\nif sys.hexversion < 0x30c0000:\n    # Python <= 3.11\n    def parse_iso(dt_string):\n        \"\"\"Parse 'dt_string' as ISO 8601 value\"\"\"\n        try:\n            if dt_string[-1] == \"Z\":\n                # compat for Python < 3.11\n                dt_string = dt_string[:-1]\n            elif dt_string[-5] in \"+-\":\n                # compat for Python < 3.11\n                dt_string = f\"{dt_string[:-2]}:{dt_string[-2:]}\"\n            return normalize(datetime.fromisoformat(dt_string))\n        except Exception:\n            return NONE\n\n    from_ts = datetime.utcfromtimestamp\n    now = datetime.utcnow\n\nelse:\n    # Python >= 3.12\n    def parse_iso(dt_string):\n        \"\"\"Parse 'dt_string' as ISO 8601 value\"\"\"\n        try:\n            return normalize(datetime.fromisoformat(dt_string))\n        except Exception:\n            return NONE\n\n    def from_ts(ts=None):\n        \"\"\"Convert Unix timestamp to naive UTC datetime\"\"\"\n        Y, m, d, H, M, S, _, _, _ = time.gmtime(ts)\n        return datetime(Y, m, d, H, M, S)\n\n    now = from_ts\n\n\ndef parse_ts(ts, default=NONE):\n    \"\"\"Create a datetime object from a Unix timestamp\"\"\"\n    try:\n        return from_ts(int(ts))\n    except Exception:\n        return default\n\n\ndef to_ts(dt):\n    \"\"\"Convert naive UTC datetime to Unix timestamp\"\"\"\n    return (dt - EPOCH) / SECOND\n\n\ndef to_ts_string(dt):\n    \"\"\"Convert naive UTC datetime to Unix timestamp string\"\"\"\n    try:\n        return str((dt - EPOCH) // SECOND)\n    except Exception:\n        return \"\"\n"
  },
  {
    "path": "gallery_dl/exception.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2015-2026 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Exception classes used by gallery-dl\n\nClass Hierarchy:\n\nException\n └── GalleryDLException\n      ├── ExtractionError\n      │    ├── HttpError\n      │    │    └── ChallengeError\n      │    ├── AuthorizationError\n      │    │    └── AuthRequired\n      │    ├── AuthenticationError\n      │    └── NotFoundError\n      ├── InputError\n      │    ├── FormatError\n      │    │    ├── FilenameFormatError\n      │    │    └── DirectoryFormatError\n      │    ├── FilterError\n      │    ├── InputFileError\n      │    └── NoExtractorError\n      └── ControlException\n           ├── StopExtraction\n           ├── AbortExtraction\n           ├── TerminateExtraction\n           ├── RestartExtraction\n           └── StopDownload\n\"\"\"\n\n\nclass GalleryDLException(Exception):\n    \"\"\"Base class for GalleryDL exceptions\"\"\"\n    default = None\n    msgfmt = None\n    code = 1\n\n    def __init__(self, message=None, fmt=True):\n        if not message:\n            message = self.default\n        elif isinstance(message, Exception):\n            message = f\"{message.__class__.__name__}: {message}\"\n        if fmt and self.msgfmt is not None:\n            message = self.msgfmt.replace(\"{}\", message)\n        self.message = message\n        Exception.__init__(self, message)\n\n\n###############################################################################\n# Extractor Errors ############################################################\n\nclass ExtractionError(GalleryDLException):\n    \"\"\"Base class for exceptions during information extraction\"\"\"\n    code = 4\n\n\nclass HttpError(ExtractionError):\n    \"\"\"HTTP request during data extraction failed\"\"\"\n    default = \"HTTP request failed\"\n\n    def __init__(self, message=\"\", response=None):\n        self.response = response\n        if response is None:\n            self.status = 0\n        else:\n            self.status = response.status_code\n            if not message:\n                message = (f\"'{response.status_code} {response.reason}' \"\n                           f\"for '{response.url}'\")\n        ExtractionError.__init__(self, message)\n\n\nclass ChallengeError(HttpError):\n    code = 8\n\n    def __init__(self, challenge, response):\n        message = (\n            f\"{challenge} ({response.status_code} {response.reason}) \"\n            f\"for '{response.url}'\")\n        HttpError.__init__(self, message, response)\n\n\nclass AuthenticationError(ExtractionError):\n    \"\"\"Invalid or missing login credentials\"\"\"\n    default = \"Invalid login credentials\"\n    code = 16\n\n\nclass AuthorizationError(ExtractionError):\n    \"\"\"Insufficient privileges to access a resource\"\"\"\n    default = \"Insufficient privileges to access this resource\"\n    code = 16\n\n\nclass AuthRequired(AuthorizationError):\n    default = \"Account credentials required\"\n\n    def __init__(self, auth=None, resource=\"resource\", message=None):\n        if auth:\n            if not isinstance(auth, str):\n                auth = \" or \".join(auth)\n\n            if resource:\n                if \" \" not in resource:\n                    resource = f\"this {resource}\"\n                resource = f\" to access {resource}\"\n            else:\n                resource = \"\"\n\n            message = f\" ('{message}')\" if message else \"\"\n            message = f\"{auth} needed{resource}{message}\"\n        AuthorizationError.__init__(self, message)\n\n\nclass NotFoundError(ExtractionError):\n    \"\"\"Requested resource (gallery/image) could not be found\"\"\"\n    msgfmt = \"Requested {} could not be found\"\n    default = \"resource (gallery/image)\"\n\n\n###############################################################################\n# User Input ##################################################################\n\nclass InputError(GalleryDLException):\n    \"\"\"Error caused by user input and config options\"\"\"\n    code = 32\n\n\nclass FormatError(InputError):\n    \"\"\"Error while building output paths\"\"\"\n\n\nclass FilenameFormatError(FormatError):\n    \"\"\"Error while building output filenames\"\"\"\n    msgfmt = \"Applying filename format string failed ({})\"\n\n\nclass DirectoryFormatError(FormatError):\n    \"\"\"Error while building output directory paths\"\"\"\n    msgfmt = \"Applying directory format string failed ({})\"\n\n\nclass FilterError(InputError):\n    \"\"\"Error while evaluating a filter expression\"\"\"\n    msgfmt = \"Evaluating filter expression failed ({})\"\n\n\nclass InputFileError(InputError):\n    \"\"\"Error when parsing an input file\"\"\"\n\n\nclass NoExtractorError(InputError):\n    \"\"\"No extractor can handle the given URL\"\"\"\n\n\n###############################################################################\n# Control Flow ################################################################\n\nclass ControlException(GalleryDLException):\n    code = 0\n\n\nclass StopExtraction(ControlException):\n    \"\"\"Stop data extraction\"\"\"\n\n    def __init__(self, target=None):\n        ControlException.__init__(self)\n\n        if target is None:\n            self.target = None\n            self.depth = 1\n        elif isinstance(target, int):\n            self.target = None\n            self.depth = target\n        elif target.isdecimal():\n            self.target = None\n            self.depth = int(target)\n        else:\n            self.target = target\n            self.depth = 128\n\n\nclass AbortExtraction(ExtractionError, ControlException):\n    \"\"\"Abort data extraction due to an error\"\"\"\n\n\nclass TerminateExtraction(ControlException):\n    \"\"\"Terminate data extraction\"\"\"\n\n\nclass RestartExtraction(ControlException):\n    \"\"\"Restart data extraction\"\"\"\n\n\nclass StopDownload(ControlException):\n    \"\"\"Cancel a file download\"\"\"\n"
  },
  {
    "path": "gallery_dl/extractor/2ch.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://2ch.org/\"\"\"\n\nfrom .common import Extractor, Message\nfrom .. import text, util\n\nBASE_PATTERN = r\"(?:https?://)?2ch\\.(org|su|life|hk)\"\n\n\nclass _2chThreadExtractor(Extractor):\n    \"\"\"Extractor for 2ch threads\"\"\"\n    category = \"2ch\"\n    subcategory = \"thread\"\n    root = \"https://2ch.org\"\n    directory_fmt = (\"{category}\", \"{board}\", \"{thread} {title}\")\n    filename_fmt = \"{tim}{filename:? //}.{extension}\"\n    archive_fmt = \"{board}_{thread}_{tim}\"\n    pattern = BASE_PATTERN + r\"/([^/?#]+)/res/(\\d+)\"\n    example = \"https://2ch.org/a/res/12345.html\"\n\n    def __init__(self, match):\n        tld = match[1]\n        self.root = \"https://2ch.\" + (\"org\" if tld == \"hk\" else tld)\n        Extractor.__init__(self, match)\n\n    def items(self):\n        _, board, thread = self.groups\n        url = f\"{self.root}/{board}/res/{thread}.json\"\n        posts = self.request_json(url)[\"threads\"][0][\"posts\"]\n\n        op = posts[0]\n        title = op.get(\"subject\") or text.remove_html(op[\"comment\"])\n\n        thread = {\n            \"board\" : board,\n            \"thread\": thread,\n            \"title\" : text.unescape(title)[:50],\n        }\n\n        yield Message.Directory, \"\", thread\n        for post in posts:\n            if files := post.get(\"files\"):\n                post[\"post_name\"] = post[\"name\"]\n                post[\"date\"] = self.parse_timestamp(post[\"timestamp\"])\n                del post[\"files\"]\n                del post[\"name\"]\n\n                for file in files:\n                    file.update(thread)\n                    file.update(post)\n\n                    file[\"filename\"] = file[\"fullname\"].rpartition(\".\")[0]\n                    file[\"tim\"], _, file[\"extension\"] = \\\n                        file[\"name\"].rpartition(\".\")\n\n                    yield Message.Url, self.root + file[\"path\"], file\n\n\nclass _2chBoardExtractor(Extractor):\n    \"\"\"Extractor for 2ch boards\"\"\"\n    category = \"2ch\"\n    subcategory = \"board\"\n    root = \"https://2ch.org\"\n    pattern = BASE_PATTERN + r\"/([^/?#]+)/?$\"\n    example = \"https://2ch.org/a/\"\n\n    def __init__(self, match):\n        tld = match[1]\n        self.root = \"https://2ch.\" + (\"org\" if tld == \"hk\" else tld)\n        Extractor.__init__(self, match)\n\n    def items(self):\n        base = f\"{self.root}/{self.groups[1]}\"\n\n        # index page\n        url = base + \"/index.json\"\n        index = self.request_json(url)\n        index[\"_extractor\"] = _2chThreadExtractor\n        for thread in index[\"threads\"]:\n            url = f\"{base}/res/{thread['thread_num']}.html\"\n            yield Message.Queue, url, index\n\n        # pages 1..n\n        for n in util.advance(index[\"pages\"], 1):\n            url = f\"{base}/{n}.json\"\n            page = self.request_json(url)\n            page[\"_extractor\"] = _2chThreadExtractor\n            for thread in page[\"threads\"]:\n                url = f\"{base}/res/{thread['thread_num']}.html\"\n                yield Message.Queue, url, page\n"
  },
  {
    "path": "gallery_dl/extractor/2chan.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2017-2025 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://www.2chan.net/\"\"\"\n\nfrom .common import Extractor, Message\nfrom .. import text\n\n\nclass _2chanThreadExtractor(Extractor):\n    \"\"\"Extractor for 2chan threads\"\"\"\n    category = \"2chan\"\n    subcategory = \"thread\"\n    directory_fmt = (\"{category}\", \"{board_name}\", \"{thread}\")\n    filename_fmt = \"{tim}.{extension}\"\n    archive_fmt = \"{board}_{thread}_{tim}\"\n    pattern = r\"(?:https?://)?([\\w-]+)\\.2chan\\.net/([^/?#]+)/res/(\\d+)\"\n    example = \"https://dec.2chan.net/12/res/12345.htm\"\n\n    def __init__(self, match):\n        Extractor.__init__(self, match)\n        self.server, self.board, self.thread = match.groups()\n\n    def items(self):\n        url = (f\"https://{self.server}.2chan.net\"\n               f\"/{self.board}/res/{self.thread}.htm\")\n        page = self.request(url).text\n        data = self.metadata(page)\n        yield Message.Directory, \"\", data\n        for post in self.posts(page):\n            if \"filename\" not in post:\n                continue\n            post.update(data)\n            url = (f\"https://{post['server']}.2chan.net\"\n                   f\"/{post['board']}/src/{post['filename']}\")\n            yield Message.Url, url, post\n\n    def metadata(self, page):\n        \"\"\"Collect metadata for extractor-job\"\"\"\n        title, _, boardname = text.extr(\n            page, \"<title>\", \"</title>\").rpartition(\" - \")\n        return {\n            \"server\": self.server,\n            \"title\": title,\n            \"board\": self.board,\n            \"board_name\": boardname[:-4],\n            \"thread\": self.thread,\n        }\n\n    def posts(self, page):\n        \"\"\"Build a list of all post-objects\"\"\"\n        page = text.extr(\n            page, '<div class=\"thre\"', '<div style=\"clear:left\"></div>')\n        return [\n            self.parse(post)\n            for post in page.split('<table border=0>')\n        ]\n\n    def parse(self, post):\n        \"\"\"Build post-object by extracting data from an HTML post\"\"\"\n        data = self._extract_post(post)\n        if data[\"name\"]:\n            data[\"name\"] = data[\"name\"].strip()\n        path = text.extr(post, '<a href=\"/', '\"')\n        if path and not path.startswith(\"bin/jump\"):\n            self._extract_image(post, data)\n            data[\"tim\"], _, data[\"extension\"] = data[\"filename\"].partition(\".\")\n            data[\"time\"] = data[\"tim\"][:-3]\n            data[\"ext\"] = \".\" + data[\"extension\"]\n        return data\n\n    def _extract_post(self, post):\n        return text.extract_all(post, (\n            (\"post\", 'class=\"csb\">'   , '<'),\n            (\"name\", 'class=\"cnm\">'   , '<'),\n            (\"now\" , 'class=\"cnw\">'   , '<'),\n            (\"no\"  , 'class=\"cno\">No.', '<'),\n            (None  , '<blockquote', ''),\n            (\"com\" , '>', '</blockquote>'),\n        ))[0]\n\n    def _extract_image(self, post, data):\n        text.extract_all(post, (\n            (None      , '_blank', ''),\n            (\"filename\", '>', '<'),\n            (\"fsize\"   , '(', ' '),\n        ), 0, data)\n"
  },
  {
    "path": "gallery_dl/extractor/2chen.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2022-2025 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for 2chen boards\"\"\"\n\nfrom .common import BaseExtractor, Message\nfrom .. import text\n\n\nclass _2chenExtractor(BaseExtractor):\n    basecategory = \"2chen\"\n\n\nBASE_PATTERN = _2chenExtractor.update({\n    \"sturdychan\": {\n        \"root\": \"https://sturdychan.help\",\n        \"pattern\": r\"(?:sturdychan\\.help|2chen\\.(?:moe|club))\",\n    },\n    \"schan\": {\n        \"root\": \"https://schan.help/\",\n        \"pattern\": r\"schan\\.help\",\n    },\n})\n\n\nclass _2chenThreadExtractor(_2chenExtractor):\n    \"\"\"Extractor for 2chen threads\"\"\"\n    subcategory = \"thread\"\n    directory_fmt = (\"{category}\", \"{board}\", \"{thread} {title}\")\n    filename_fmt = \"{time} {filename}.{extension}\"\n    archive_fmt = \"{board}_{thread}_{no}_{time}\"\n    pattern = BASE_PATTERN + r\"/([^/?#]+)/(\\d+)\"\n    example = \"https://sturdychan.help/a/12345/\"\n\n    def items(self):\n        board = self.groups[-2]\n        thread = self.kwdict[\"thread\"] = self.groups[-1]\n        url = f\"{self.root}/{board}/{thread}\"\n        page = self.request(url, encoding=\"utf-8\", notfound=True).text\n\n        self.kwdict[\"board\"], pos = text.extract(\n            page, 'class=\"board\">/', '/<')\n        self.kwdict[\"title\"] = text.unescape(text.extract(\n            page, \"<h3>\", \"</h3>\", pos)[0])\n\n        yield Message.Directory, \"\", {}\n        for post in self.posts(page):\n            url = post[\"url\"]\n            if not url:\n                continue\n            if url[0] == \"/\":\n                url = self.root + url\n            post[\"url\"] = url = url.partition(\"?\")[0]\n\n            post[\"time\"] = text.parse_int(post[\"date\"].timestamp())\n            yield Message.Url, url, text.nameext_from_url(\n                post[\"filename\"], post)\n\n    def posts(self, page):\n        \"\"\"Return iterable with relevant posts\"\"\"\n        return map(self.parse, text.extract_iter(\n            page, 'class=\"glass media', '</article>'))\n\n    def parse(self, post):\n        extr = text.extract_from(post)\n        return {\n            \"name\"    : text.unescape(extr(\"<span>\", \"</span>\")),\n            \"date\"    : self.parse_datetime(\n                extr(\"<time\", \"<\").partition(\">\")[2],\n                \"%d %b %Y (%a) %H:%M:%S\"\n            ),\n            \"no\"      : extr('href=\"#p', '\"'),\n            \"filename\": text.unescape(extr('download=\"', '\"')),\n            \"url\"     : text.extr(extr(\"<figure>\", \"</\"), 'href=\"', '\"'),\n            \"hash\"    : extr('data-hash=\"', '\"'),\n        }\n\n\nclass _2chenBoardExtractor(_2chenExtractor):\n    \"\"\"Extractor for 2chen boards\"\"\"\n    subcategory = \"board\"\n    pattern = BASE_PATTERN + r\"/([^/?#]+)(?:/catalog|/?$)\"\n    example = \"https://sturdychan.help/a/\"\n\n    def items(self):\n        url = f\"{self.root}/{self.groups[-1]}/catalog\"\n        page = self.request(url, notfound=True).text\n        data = {\"_extractor\": _2chenThreadExtractor}\n        for thread in text.extract_iter(\n                page, '<figure><a href=\"', '\"'):\n            yield Message.Queue, self.root + thread, data\n"
  },
  {
    "path": "gallery_dl/extractor/35photo.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2019-2025 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://35photo.pro/\"\"\"\n\nfrom .common import Extractor, Message\nfrom .. import text\n\n\nclass _35photoExtractor(Extractor):\n    category = \"35photo\"\n    directory_fmt = (\"{category}\", \"{user}\")\n    filename_fmt = \"{id}{title:?_//}_{num:>02}.{extension}\"\n    archive_fmt = \"{id}_{num}\"\n    root = \"https://35photo.pro\"\n\n    def items(self):\n        first = True\n        data = self.metadata()\n\n        for photo_id in self.photos():\n            for photo in self._photo_data(photo_id):\n                photo.update(data)\n                url = photo[\"url\"]\n                if first:\n                    first = False\n                    yield Message.Directory, \"\", photo\n                yield Message.Url, url, text.nameext_from_url(url, photo)\n\n    def metadata(self):\n        \"\"\"Returns general metadata\"\"\"\n        return {}\n\n    def photos(self):\n        \"\"\"Returns an iterable containing all relevant photo IDs\"\"\"\n\n    def _pagination(self, params, extra_ids=None):\n        url = \"https://35photo.pro/show_block.php\"\n        headers = {\"Referer\": self.root, \"X-Requested-With\": \"XMLHttpRequest\"}\n        params[\"type\"] = \"getNextPageData\"\n\n        if \"lastId\" not in params:\n            params[\"lastId\"] = \"999999999\"\n        if extra_ids:\n            yield from extra_ids\n        while params[\"lastId\"]:\n            data = self.request_json(url, headers=headers, params=params)\n            yield from self._photo_ids(data[\"data\"])\n            params[\"lastId\"] = data[\"lastId\"]\n\n    def _photo_data(self, photo_id):\n        params = {\"method\": \"photo.getData\", \"photoId\": photo_id}\n        data = self.request_json(\n            \"https://api.35photo.pro/\", params=params)[\"data\"][photo_id]\n        info = {\n            \"url\"        : data[\"src\"],\n            \"id\"         : data[\"photo_id\"],\n            \"title\"      : data[\"photo_name\"],\n            \"description\": data[\"photo_desc\"],\n            \"tags\"       : data[\"tags\"] or [],\n            \"views\"      : data[\"photo_see\"],\n            \"favorites\"  : data[\"photo_fav\"],\n            \"score\"      : data[\"photo_rating\"],\n            \"type\"       : data[\"photo_type\"],\n            \"date\"       : data[\"timeAdd\"],\n            \"user\"       : data[\"user_login\"],\n            \"user_id\"    : data[\"user_id\"],\n            \"user_name\"  : data[\"user_name\"],\n        }\n\n        if \"series\" in data:\n            for info[\"num\"], photo in enumerate(data[\"series\"], 1):\n                info[\"url\"] = photo[\"src\"]\n                info[\"id_series\"] = text.parse_int(photo[\"id\"])\n                info[\"title_series\"] = photo[\"title\"] or \"\"\n                yield info.copy()\n        else:\n            info[\"num\"] = 1\n            yield info\n\n    def _photo_ids(self, page):\n        \"\"\"Extract unique photo IDs and return them as sorted list\"\"\"\n        #  searching for photo-id=\"...\" doesn't always work (see unit tests)\n        if not page:\n            return ()\n        return sorted(\n            set(text.extract_iter(page, \"/photo_\", \"/\")),\n            key=text.parse_int,\n            reverse=True,\n        )\n\n\nclass _35photoUserExtractor(_35photoExtractor):\n    \"\"\"Extractor for all images of a user on 35photo.pro\"\"\"\n    subcategory = \"user\"\n    pattern = (r\"(?:https?://)?(?:[a-z]+\\.)?35photo\\.pro\"\n               r\"/(?!photo_|genre_|tags/|rating/)([^/?#]+)\")\n    example = \"https://35photo.pro/USER\"\n\n    def __init__(self, match):\n        _35photoExtractor.__init__(self, match)\n        self.user = match[1]\n        self.user_id = 0\n\n    def metadata(self):\n        url = f\"{self.root}/{self.user}/\"\n        page = self.request(url).text\n        self.user_id = text.parse_int(text.extr(page, \"/user_\", \".xml\"))\n        return {\n            \"user\": self.user,\n            \"user_id\": self.user_id,\n        }\n\n    def photos(self):\n        return self._pagination({\n            \"page\": \"photoUser\",\n            \"user_id\": self.user_id,\n        })\n\n\nclass _35photoTagExtractor(_35photoExtractor):\n    \"\"\"Extractor for all photos from a tag listing\"\"\"\n    subcategory = \"tag\"\n    directory_fmt = (\"{category}\", \"Tags\", \"{search_tag}\")\n    archive_fmt = \"t{search_tag}_{id}_{num}\"\n    pattern = r\"(?:https?://)?(?:[a-z]+\\.)?35photo\\.pro/tags/([^/?#]+)\"\n    example = \"https://35photo.pro/tags/TAG/\"\n\n    def __init__(self, match):\n        _35photoExtractor.__init__(self, match)\n        self.tag = match[1]\n\n    def metadata(self):\n        return {\"search_tag\": text.unquote(self.tag).lower()}\n\n    def photos(self):\n        num = 1\n\n        while True:\n            url = f\"{self.root}/tags/{self.tag}/list_{num}/\"\n            page = self.request(url).text\n            prev = None\n\n            for photo_id in text.extract_iter(page, \"35photo.pro/photo_\", \"/\"):\n                if photo_id != prev:\n                    prev = photo_id\n                    yield photo_id\n\n            if not prev:\n                return\n            num += 1\n\n\nclass _35photoGenreExtractor(_35photoExtractor):\n    \"\"\"Extractor for images of a specific genre on 35photo.pro\"\"\"\n    subcategory = \"genre\"\n    directory_fmt = (\"{category}\", \"Genre\", \"{genre}\")\n    archive_fmt = \"g{genre_id}_{id}_{num}\"\n    pattern = r\"(?:https?://)?(?:[a-z]+\\.)?35photo\\.pro/genre_(\\d+)(/new/)?\"\n    example = \"https://35photo.pro/genre_12345/\"\n\n    def __init__(self, match):\n        _35photoExtractor.__init__(self, match)\n        self.genre_id, self.new = match.groups()\n        self.photo_ids = None\n\n    def metadata(self):\n        url = f\"{self.root}/genre_{self.genre_id}{self.new or '/'}\"\n        page = self.request(url).text\n        self.photo_ids = self._photo_ids(text.extr(\n            page, ' class=\"photo', '\\n'))\n        return {\n            \"genre\": text.extr(page, \" genre - \", \". \"),\n            \"genre_id\": text.parse_int(self.genre_id),\n        }\n\n    def photos(self):\n        if not self.photo_ids:\n            return ()\n        return self._pagination({\n            \"page\": \"genre\",\n            \"community_id\": self.genre_id,\n            \"photo_rating\": \"0\" if self.new else \"50\",\n            \"lastId\": self.photo_ids[-1],\n        }, self.photo_ids)\n\n\nclass _35photoImageExtractor(_35photoExtractor):\n    \"\"\"Extractor for individual images from 35photo.pro\"\"\"\n    subcategory = \"image\"\n    pattern = r\"(?:https?://)?(?:[a-z]+\\.)?35photo\\.pro/photo_(\\d+)\"\n    example = \"https://35photo.pro/photo_12345/\"\n\n    def __init__(self, match):\n        _35photoExtractor.__init__(self, match)\n        self.photo_id = match[1]\n\n    def photos(self):\n        return (self.photo_id,)\n"
  },
  {
    "path": "gallery_dl/extractor/3dbooru.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2015-2023 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for http://behoimi.org/\"\"\"\n\nfrom . import moebooru\n\n\nclass _3dbooruBase():\n    \"\"\"Base class for 3dbooru extractors\"\"\"\n    category = \"3dbooru\"\n    basecategory = \"booru\"\n    root = \"http://behoimi.org\"\n\n    def _init(self):\n        headers = self.session.headers\n        headers[\"Referer\"] = \"http://behoimi.org/post/show/\"\n        headers[\"Accept-Encoding\"] = \"identity\"\n\n\nclass _3dbooruTagExtractor(_3dbooruBase, moebooru.MoebooruTagExtractor):\n    \"\"\"Extractor for images from behoimi.org based on search-tags\"\"\"\n    pattern = (r\"(?:https?://)?(?:www\\.)?behoimi\\.org/post\"\n               r\"(?:/(?:index)?)?\\?tags=(?P<tags>[^&#]+)\")\n    example = \"http://behoimi.org/post?tags=TAG\"\n\n    def posts(self):\n        params = {\"tags\": self.tags}\n        return self._pagination(self.root + \"/post/index.json\", params)\n\n\nclass _3dbooruPoolExtractor(_3dbooruBase, moebooru.MoebooruPoolExtractor):\n    \"\"\"Extractor for image-pools from behoimi.org\"\"\"\n    pattern = r\"(?:https?://)?(?:www\\.)?behoimi\\.org/pool/show/(?P<pool>\\d+)\"\n    example = \"http://behoimi.org/pool/show/12345\"\n\n    def posts(self):\n        params = {\"tags\": \"pool:\" + self.pool_id}\n        return self._pagination(self.root + \"/post/index.json\", params)\n\n\nclass _3dbooruPostExtractor(_3dbooruBase, moebooru.MoebooruPostExtractor):\n    \"\"\"Extractor for single images from behoimi.org\"\"\"\n    pattern = r\"(?:https?://)?(?:www\\.)?behoimi\\.org/post/show/(?P<post>\\d+)\"\n    example = \"http://behoimi.org/post/show/12345\"\n\n    def posts(self):\n        params = {\"tags\": \"id:\" + self.post_id}\n        return self._pagination(self.root + \"/post/index.json\", params)\n\n\nclass _3dbooruPopularExtractor(\n        _3dbooruBase, moebooru.MoebooruPopularExtractor):\n    \"\"\"Extractor for popular images from behoimi.org\"\"\"\n    pattern = (r\"(?:https?://)?(?:www\\.)?behoimi\\.org\"\n               r\"/post/popular_(?P<scale>by_(?:day|week|month)|recent)\"\n               r\"(?:\\?(?P<query>[^#]*))?\")\n    example = \"http://behoimi.org/post/popular_by_month\"\n"
  },
  {
    "path": "gallery_dl/extractor/4archive.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://4archive.org/\"\"\"\n\nfrom .common import Extractor, Message\nfrom .. import text, dt\n\n\nclass _4archiveThreadExtractor(Extractor):\n    \"\"\"Extractor for 4archive threads\"\"\"\n    category = \"4archive\"\n    subcategory = \"thread\"\n    directory_fmt = (\"{category}\", \"{board}\", \"{thread} {title}\")\n    filename_fmt = \"{no} {filename}.{extension}\"\n    archive_fmt = \"{board}_{thread}_{no}\"\n    root = \"https://4archive.org\"\n    referer = False\n    pattern = r\"(?:https?://)?4archive\\.org/board/([^/?#]+)/thread/(\\d+)\"\n    example = \"https://4archive.org/board/a/thread/12345/\"\n\n    def __init__(self, match):\n        Extractor.__init__(self, match)\n        self.board, self.thread = match.groups()\n\n    def items(self):\n        url = f\"{self.root}/board/{self.board}/thread/{self.thread}\"\n        page = self.request(url).text\n        data = self.metadata(page)\n        posts = self.posts(page)\n\n        if not data[\"title\"]:\n            data[\"title\"] = posts[0][\"com\"][:50]\n\n        for post in posts:\n            post.update(data)\n            post[\"time\"] = int(dt.to_ts(post[\"date\"]))\n            yield Message.Directory, \"\", post\n            if \"url\" in post:\n                yield Message.Url, post[\"url\"], text.nameext_from_url(\n                    post[\"filename\"], post)\n\n    def metadata(self, page):\n        return {\n            \"board\" : self.board,\n            \"thread\": text.parse_int(self.thread),\n            \"title\" : text.unescape(text.extr(\n                page, 'class=\"subject\">', \"</span>\"))\n        }\n\n    def posts(self, page):\n        return [\n            self.parse(post)\n            for post in page.split('class=\"postContainer')[1:]\n        ]\n\n    def parse(self, post):\n        extr = text.extract_from(post)\n        data = {\n            \"name\": extr('class=\"name\">', \"</span>\"),\n            \"date\": self.parse_datetime_iso(\n                (extr('class=\"dateTime\">', \"<\") or\n                 extr('class=\"dateTime postNum\" >', \"<\")).strip()),\n            \"no\"  : text.parse_int(extr(\">Post No.\", \"<\")),\n        }\n        if 'class=\"file\"' in post:\n            extr('class=\"fileText\"', \">File: <a\")\n            data.update({\n                \"url\"     : extr('href=\"', '\"'),\n                \"filename\": extr('alt=\"Image: ', '\"'),\n                \"size\"    : text.parse_bytes(extr(\" (\", \", \")[:-1]),\n                \"width\"   : text.parse_int(extr(\"\", \"x\")),\n                \"height\"  : text.parse_int(extr(\"\", \"px\")),\n            })\n        extr(\"<blockquote \", \"\")\n        data[\"com\"] = text.unescape(text.remove_html(\n            extr(\">\", \"</blockquote>\")))\n        return data\n\n\nclass _4archiveBoardExtractor(Extractor):\n    \"\"\"Extractor for 4archive boards\"\"\"\n    category = \"4archive\"\n    subcategory = \"board\"\n    root = \"https://4archive.org\"\n    pattern = r\"(?:https?://)?4archive\\.org/board/([^/?#]+)(?:/(\\d+))?/?$\"\n    example = \"https://4archive.org/board/a/\"\n\n    def __init__(self, match):\n        Extractor.__init__(self, match)\n        self.board = match[1]\n        self.num = text.parse_int(match[2], 1)\n\n    def items(self):\n        data = {\"_extractor\": _4archiveThreadExtractor}\n        while True:\n            url = f\"{self.root}/board/{self.board}/{self.num}\"\n            page = self.request(url).text\n            if 'class=\"thread\"' not in page:\n                return\n            for thread in text.extract_iter(page, 'class=\"thread\" id=\"t', '\"'):\n                url = f\"{self.root}/board/{self.board}/thread/{thread}\"\n                yield Message.Queue, url, data\n            self.num += 1\n"
  },
  {
    "path": "gallery_dl/extractor/4chan.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2015-2025 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://www.4chan.org/\"\"\"\n\nfrom .common import Extractor, Message\nfrom .. import text\n\n\nclass _4chanThreadExtractor(Extractor):\n    \"\"\"Extractor for 4chan threads\"\"\"\n    category = \"4chan\"\n    subcategory = \"thread\"\n    directory_fmt = (\"{category}\", \"{board}\", \"{thread} {title}\")\n    filename_fmt = \"{tim} {filename}.{extension}\"\n    archive_fmt = \"{board}_{thread}_{tim}\"\n    pattern = (r\"(?:https?://)?boards\\.4chan(?:nel)?\\.org\"\n               r\"/([^/]+)/thread/(\\d+)\")\n    example = \"https://boards.4channel.org/a/thread/12345/\"\n\n    def __init__(self, match):\n        Extractor.__init__(self, match)\n        self.board, self.thread = match.groups()\n\n    def items(self):\n        url = f\"https://a.4cdn.org/{self.board}/thread/{self.thread}.json\"\n        posts = self.request_json(url)[\"posts\"]\n        title = posts[0].get(\"sub\") or text.remove_html(posts[0][\"com\"])\n\n        data = {\n            \"board\" : self.board,\n            \"thread\": self.thread,\n            \"title\" : text.unescape(title)[:50],\n        }\n\n        yield Message.Directory, \"\", data\n        for post in posts:\n            if \"filename\" in post:\n                post.update(data)\n                post[\"extension\"] = post[\"ext\"][1:]\n                post[\"filename\"] = text.unescape(post[\"filename\"])\n                post[\"_http_signature\"] = _detect_null_byte\n                url = (f\"https://i.4cdn.org\"\n                       f\"/{post['board']}/{post['tim']}{post['ext']}\")\n                yield Message.Url, url, post\n\n\ndef _detect_null_byte(signature):\n    \"\"\"Return False if all file signature bytes are null\"\"\"\n    if signature:\n        if signature[0]:\n            return True\n        for byte in signature:\n            if byte:\n                return True\n    return \"File data consists of null bytes\"\n\n\nclass _4chanBoardExtractor(Extractor):\n    \"\"\"Extractor for 4chan boards\"\"\"\n    category = \"4chan\"\n    subcategory = \"board\"\n    pattern = r\"(?:https?://)?boards\\.4chan(?:nel)?\\.org/([^/?#]+)/\\d*$\"\n    example = \"https://boards.4channel.org/a/\"\n\n    def __init__(self, match):\n        Extractor.__init__(self, match)\n        self.board = match[1]\n\n    def items(self):\n        url = f\"https://a.4cdn.org/{self.board}/threads.json\"\n        threads = self.request_json(url)\n\n        for page in threads:\n            for thread in page[\"threads\"]:\n                url = (f\"https://boards.4chan.org\"\n                       f\"/{self.board}/thread/{thread['no']}/\")\n                thread[\"page\"] = page[\"page\"]\n                thread[\"_extractor\"] = _4chanThreadExtractor\n                yield Message.Queue, url, thread\n"
  },
  {
    "path": "gallery_dl/extractor/4chanarchives.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2023-2025 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://4chanarchives.com/\"\"\"\n\nfrom .common import Extractor, Message\nfrom .. import text\n\n\nclass _4chanarchivesThreadExtractor(Extractor):\n    \"\"\"Extractor for threads on 4chanarchives.com\"\"\"\n    category = \"4chanarchives\"\n    subcategory = \"thread\"\n    root = \"https://4chanarchives.com\"\n    directory_fmt = (\"{category}\", \"{board}\", \"{thread} - {title}\")\n    filename_fmt = \"{no}-{filename}.{extension}\"\n    archive_fmt = \"{board}_{thread}_{no}\"\n    referer = False\n    pattern = r\"(?:https?://)?4chanarchives\\.com/board/([^/?#]+)/thread/(\\d+)\"\n    example = \"https://4chanarchives.com/board/a/thread/12345/\"\n\n    def __init__(self, match):\n        Extractor.__init__(self, match)\n        self.board, self.thread = match.groups()\n\n    def items(self):\n        url = f\"{self.root}/board/{self.board}/thread/{self.thread}\"\n        page = self.request(url).text\n        data = self.metadata(page)\n        posts = self.posts(page)\n\n        if not data[\"title\"]:\n            data[\"title\"] = text.unescape(text.remove_html(\n                posts[0][\"com\"]))[:50]\n\n        for post in posts:\n            post.update(data)\n            yield Message.Directory, \"\", post\n            if \"url\" in post:\n                yield Message.Url, post[\"url\"], post\n\n    def metadata(self, page):\n        return {\n            \"board\"     : self.board,\n            \"thread\"    : self.thread,\n            \"title\"     : text.unescape(text.extr(\n                page, 'property=\"og:title\" content=\"', '\"')),\n        }\n\n    def posts(self, page):\n        \"\"\"Build a list of all post objects\"\"\"\n        return [self.parse(html) for html in text.extract_iter(\n            page, 'id=\"pc', '</blockquote>')]\n\n    def parse(self, html):\n        \"\"\"Build post object by extracting data from an HTML post\"\"\"\n        post = self._extract_post(html)\n        if \">File: <\" in html:\n            self._extract_file(html, post)\n            post[\"extension\"] = post[\"url\"].rpartition(\".\")[2]\n        return post\n\n    def _extract_post(self, html):\n        extr = text.extract_from(html)\n        return {\n            \"no\"  : text.parse_int(extr('', '\"')),\n            \"name\": extr('class=\"name\">', '<'),\n            \"time\": extr('class=\"dateTime postNum\" >', '<').rstrip(),\n            \"com\" : text.unescape(\n                html[html.find('<blockquote'):].partition(\">\")[2]),\n        }\n\n    def _extract_file(self, html, post):\n        extr = text.extract_from(html, html.index(\">File: <\"))\n        post[\"url\"] = extr('href=\"', '\"')\n        post[\"filename\"] = text.unquote(extr(\">\", \"<\").rpartition(\".\")[0])\n        post[\"fsize\"] = extr(\"(\", \", \")\n        post[\"w\"] = text.parse_int(extr(\"\", \"x\"))\n        post[\"h\"] = text.parse_int(extr(\"\", \")\"))\n\n\nclass _4chanarchivesBoardExtractor(Extractor):\n    \"\"\"Extractor for boards on 4chanarchives.com\"\"\"\n    category = \"4chanarchives\"\n    subcategory = \"board\"\n    root = \"https://4chanarchives.com\"\n    pattern = r\"(?:https?://)?4chanarchives\\.com/board/([^/?#]+)(?:/(\\d+))?/?$\"\n    example = \"https://4chanarchives.com/board/a/\"\n\n    def __init__(self, match):\n        Extractor.__init__(self, match)\n        self.board, self.page = match.groups()\n\n    def items(self):\n        data = {\"_extractor\": _4chanarchivesThreadExtractor}\n        pnum = text.parse_int(self.page, 1)\n        needle = '''<span class=\"postNum desktop\">\n                        <span><a href=\"'''\n\n        while True:\n            url = f\"{self.root}/board/{self.board}/{pnum}\"\n            page = self.request(url).text\n\n            thread = None\n            for thread in text.extract_iter(page, needle, '\"'):\n                yield Message.Queue, thread, data\n\n            if thread is None:\n                return\n            pnum += 1\n"
  },
  {
    "path": "gallery_dl/extractor/500px.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2019-2026 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://500px.com/\"\"\"\n\nfrom .common import Extractor, Message\nfrom .. import util\n\nBASE_PATTERN = r\"(?:https?://)?(?:web\\.)?500px\\.com\"\n\n\nclass _500pxExtractor(Extractor):\n    \"\"\"Base class for 500px extractors\"\"\"\n    category = \"500px\"\n    directory_fmt = (\"{category}\", \"{user[username]}\")\n    filename_fmt = \"{id}_{name}.{extension}\"\n    archive_fmt = \"{id}\"\n    root = \"https://500px.com\"\n    cookies_domain = \".500px.com\"\n\n    def items(self):\n        data = self.metadata()\n\n        for photo in self.photos():\n            url = photo[\"images\"][-1][\"url\"]\n            photo[\"extension\"] = photo[\"image_format\"]\n            if data:\n                photo.update(data)\n            yield Message.Directory, \"\", photo\n            yield Message.Url, url, photo\n\n    def metadata(self):\n        \"\"\"Returns general metadata\"\"\"\n\n    def photos(self):\n        \"\"\"Returns an iterable containing all relevant photo IDs\"\"\"\n\n    def _extend(self, edges):\n        \"\"\"Extend photos with additional metadata and higher resolution URLs\"\"\"\n        ids = [str(edge[\"node\"][\"legacyId\"]) for edge in edges]\n\n        url = \"https://api.500px.com/v1/photos\"\n        params = {\n            \"expanded_user_info\"    : \"true\",\n            \"include_tags\"          : \"true\",\n            \"include_geo\"           : \"true\",\n            \"include_equipment_info\": \"true\",\n            \"vendor_photos\"         : \"true\",\n            \"include_licensing\"     : \"true\",\n            \"include_releases\"      : \"true\",\n            \"liked_by\"              : \"1\",\n            \"following_sample\"      : \"100\",\n            \"image_size\"            : \"4096\",\n            \"ids\"                   : \",\".join(ids),\n        }\n\n        photos = self._request_api(url, params)[\"photos\"]\n        return [\n            photos[pid] for pid in ids\n            if pid in photos or\n            self.log.warning(\"Unable to fetch photo %s\", pid)\n        ]\n\n    def _request_api(self, url, params):\n        headers = {\n            \"Origin\": self.root,\n            \"x-csrf-token\": self.cookies.get(\n                \"x-csrf-token\", domain=\".500px.com\"),\n        }\n        return self.request_json(url, headers=headers, params=params)\n\n    def _request_graphql(self, opname, variables):\n        url = \"https://api.500px.com/graphql\"\n        headers = {\n            \"x-csrf-token\": self.cookies.get(\n                \"x-csrf-token\", domain=\".500px.com\"),\n        }\n        data = {\n            \"operationName\": opname,\n            \"variables\"    : util.json_dumps(variables),\n            \"query\"        : self.utils(\"graphql\", opname),\n        }\n        return self.request_json(\n            url, method=\"POST\", headers=headers, json=data)[\"data\"]\n\n\nclass _500pxUserExtractor(_500pxExtractor):\n    \"\"\"Extractor for photos from a user's photostream on 500px.com\"\"\"\n    subcategory = \"user\"\n    pattern = BASE_PATTERN + r\"/(?!photo/|liked)(?:p/)?([^/?#]+)/?(?:$|[?#])\"\n    example = \"https://500px.com/USER\"\n\n    def __init__(self, match):\n        _500pxExtractor.__init__(self, match)\n        self.user = match[1]\n\n    def photos(self):\n        variables = {\"username\": self.user, \"pageSize\": 20}\n        photos = self._request_graphql(\n            \"OtherPhotosQuery\", variables,\n        )[\"user\"][\"photos\"]\n\n        while True:\n            yield from self._extend(photos[\"edges\"])\n\n            if not photos[\"pageInfo\"][\"hasNextPage\"]:\n                return\n\n            variables[\"cursor\"] = photos[\"pageInfo\"][\"endCursor\"]\n            photos = self._request_graphql(\n                \"OtherPhotosPaginationContainerQuery\", variables,\n            )[\"userByUsername\"][\"photos\"]\n\n\nclass _500pxGalleryExtractor(_500pxExtractor):\n    \"\"\"Extractor for photo galleries on 500px.com\"\"\"\n    subcategory = \"gallery\"\n    directory_fmt = (\"{category}\", \"{user[username]}\", \"{gallery[name]}\")\n    pattern = (BASE_PATTERN + r\"/(?!photo/)(?:p/)?\"\n               r\"([^/?#]+)/galleries/([^/?#]+)\")\n    example = \"https://500px.com/USER/galleries/GALLERY\"\n\n    def __init__(self, match):\n        _500pxExtractor.__init__(self, match)\n        self.user_name, self.gallery_name = match.groups()\n        self.user_id = self._photos = None\n\n    def metadata(self):\n        user = self._request_graphql(\n            \"ProfileRendererQuery\", {\"username\": self.user_name},\n        )[\"profile\"]\n        self.user_id = str(user[\"legacyId\"])\n\n        variables = {\n            \"galleryOwnerLegacyId\": self.user_id,\n            \"ownerLegacyId\"       : self.user_id,\n            \"slug\"                : self.gallery_name,\n            \"token\"               : None,\n            \"pageSize\"            : 20,\n        }\n        gallery = self._request_graphql(\n            \"GalleriesDetailQueryRendererQuery\", variables,\n        )[\"gallery\"]\n\n        self._photos = gallery[\"photos\"]\n        del gallery[\"photos\"]\n        return {\n            \"gallery\": gallery,\n            \"user\"   : user,\n        }\n\n    def photos(self):\n        photos = self._photos\n        variables = {\n            \"ownerLegacyId\": self.user_id,\n            \"slug\"         : self.gallery_name,\n            \"token\"        : None,\n            \"pageSize\"     : 20,\n        }\n\n        while True:\n            yield from self._extend(photos[\"edges\"])\n\n            if not photos[\"pageInfo\"][\"hasNextPage\"]:\n                return\n\n            variables[\"cursor\"] = photos[\"pageInfo\"][\"endCursor\"]\n            photos = self._request_graphql(\n                \"GalleriesDetailPaginationContainerQuery\", variables,\n            )[\"galleryByOwnerIdAndSlugOrToken\"][\"photos\"]\n\n\nclass _500pxFavoriteExtractor(_500pxExtractor):\n    \"\"\"Extractor for favorite 500px photos\"\"\"\n    subcategory = \"favorite\"\n    pattern = BASE_PATTERN + r\"/liked/?$\"\n    example = \"https://500px.com/liked\"\n\n    def photos(self):\n        variables = {\"pageSize\": 20}\n        photos = self._request_graphql(\n            \"LikedPhotosQueryRendererQuery\", variables,\n        )[\"likedPhotos\"]\n\n        while True:\n            yield from self._extend(photos[\"edges\"])\n\n            if not photos[\"pageInfo\"][\"hasNextPage\"]:\n                return\n\n            variables[\"cursor\"] = photos[\"pageInfo\"][\"endCursor\"]\n            photos = self._request_graphql(\n                \"LikedPhotosPaginationContainerQuery\", variables,\n            )[\"likedPhotos\"]\n\n\nclass _500pxImageExtractor(_500pxExtractor):\n    \"\"\"Extractor for individual images from 500px.com\"\"\"\n    subcategory = \"image\"\n    pattern = BASE_PATTERN + r\"/photo/(\\d+)\"\n    example = \"https://500px.com/photo/12345/TITLE\"\n\n    def __init__(self, match):\n        _500pxExtractor.__init__(self, match)\n        self.photo_id = match[1]\n\n    def photos(self):\n        edges = ({\"node\": {\"legacyId\": self.photo_id}},)\n        return self._extend(edges)\n"
  },
  {
    "path": "gallery_dl/extractor/8chan.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2022-2026 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://8chan.moe/\"\"\"\n\nfrom .common import Extractor, Message\nfrom .. import text, dt\nimport itertools\n\nBASE_PATTERN = r\"(?:https?://)?8chan\\.(moe|se|cc)\"\n\n\nclass _8chanExtractor(Extractor):\n    \"\"\"Base class for 8chan extractors\"\"\"\n    category = \"8chan\"\n    root = \"https://8chan.moe\"\n\n    def __init__(self, match):\n        self.root = \"https://8chan.\" + match[1]\n        Extractor.__init__(self, match)\n\n    def cookies_tos_name(self):\n        domain = \"8chan.\" + self.groups[0]\n        for cookie in self.cookies:\n            if cookie.domain == domain and \\\n                    cookie.name.lower().startswith(\"tos\"):\n                self.log.debug(\"TOS cookie name: %s\", cookie.name)\n                return cookie.name\n\n        url = self.root + \"/.static/pages/confirmed.html\"\n        headers = {\"Referer\": self.root + \"/.static/pages/disclaimer.html\"}\n        response = self.request(url, headers=headers, allow_redirects=False)\n\n        for cookie in response.cookies:\n            if cookie.name.lower().startswith(\"tos\"):\n                self.log.debug(\"TOS cookie name: %s\", cookie.name)\n                return cookie.name\n\n        self.log.error(\"Unable to determin TOS cookie name\")\n        return \"TOS20250418\"\n\n    def cookies_prepare(self):\n        # fetch captcha cookies\n        # (necessary to download without getting interrupted)\n        now = dt.now()\n        url = self.root + \"/captcha.js\"\n        params = {\"d\": now.strftime(\"%a %b %d %Y %H:%M:%S GMT+0000 (UTC)\")}\n        self.request(url, params=params).content\n\n        # adjust cookies\n        # - remove 'expires' timestamp\n        # - move 'captchaexpiration' value forward by 1 month\n        domain = self.root.rpartition(\"/\")[2]\n        for cookie in self.cookies:\n            if cookie.domain.endswith(domain):\n                cookie.expires = None\n                if cookie.name == \"captchaexpiration\":\n                    cookie.value = (now + dt.timedelta(30, 300)).strftime(\n                        \"%a, %d %b %Y %H:%M:%S GMT\")\n\n        return self.cookies\n\n\nclass _8chanThreadExtractor(_8chanExtractor):\n    \"\"\"Extractor for 8chan threads\"\"\"\n    subcategory = \"thread\"\n    directory_fmt = (\"{category}\", \"{boardUri}\",\n                     \"{threadId} {subject[:50]}\")\n    filename_fmt = \"{postId}{num:?-//} {filename[:200]}.{extension}\"\n    archive_fmt = \"{boardUri}_{postId}_{num}\"\n    pattern = BASE_PATTERN + r\"/([^/?#]+)/(?:res|last)/(\\d+)\"\n    example = \"https://8chan.moe/a/res/12345.html\"\n\n    def items(self):\n        _, board, thread = self.groups\n        tos = self.cache(self.cookies_tos_name, _mem=0)\n        self.cookies.set(tos, \"1\", domain=self.root[8:])\n\n        # fetch thread data\n        url = f\"{self.root}/{board}/res/{thread}.\"\n        self.session.headers[\"Referer\"] = url + \"html\"\n        thread = self.request_json(url + \"json\")\n        thread[\"postId\"] = thread[\"threadId\"]\n        thread[\"_http_headers\"] = {\"Referer\": url + \"html\"}\n\n        try:\n            self.cookies = self.cache(self.cookies_prepare)\n        except Exception as exc:\n            self.log.debug(\"Failed to fetch captcha cookies:  %s: %s\",\n                           exc.__class__.__name__, exc, exc_info=exc)\n\n        # download files\n        posts = thread.pop(\"posts\", ())\n        yield Message.Directory, \"\", thread\n        for post in itertools.chain((thread,), posts):\n            files = post.pop(\"files\", ())\n            if not files:\n                continue\n            thread.update(post)\n            for num, file in enumerate(files):\n                file.update(thread)\n                file[\"num\"] = num\n                file[\"_http_validate\"] = _validate\n                text.nameext_from_url(file[\"originalName\"], file)\n                yield Message.Url, self.root + file[\"path\"], file\n\n\nclass _8chanBoardExtractor(_8chanExtractor):\n    \"\"\"Extractor for 8chan boards\"\"\"\n    subcategory = \"board\"\n    pattern = BASE_PATTERN + r\"/([^/?#]+)/(?:(\\d+)\\.html)?$\"\n    example = \"https://8chan.moe/a/\"\n\n    def items(self):\n        _, board, pnum = self.groups\n        tos = self.cache(self.cookies_tos_name)\n        self.cookies.set(tos, \"1\", domain=self.root[8:])\n\n        pnum = text.parse_int(pnum, 1)\n        url = f\"{self.root}/{board}/{pnum}.json\"\n        data = self.request_json(url)\n        threads = data[\"threads\"]\n\n        while True:\n            for thread in threads:\n                thread[\"_extractor\"] = _8chanThreadExtractor\n                url = f\"{self.root}/{board}/res/{thread['threadId']}.html\"\n                yield Message.Queue, url, thread\n\n            pnum += 1\n            if pnum > data[\"pageCount\"]:\n                return\n            url = f\"{self.root}/{board}/{pnum}.json\"\n            threads = self.request_json(url)[\"threads\"]\n\n\ndef _validate(response):\n    hget = response.headers.get\n    return not (\n        hget(\"expires\") == \"0\" and\n        hget(\"content-length\") == \"166\" and\n        hget(\"content-type\") == \"image/png\"\n    )\n"
  },
  {
    "path": "gallery_dl/extractor/8muses.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2019-2025 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://comics.8muses.com/\"\"\"\n\nfrom .common import Extractor, Message\nfrom .. import text, util\n\n\nclass _8musesAlbumExtractor(Extractor):\n    \"\"\"Extractor for image albums on comics.8muses.com\"\"\"\n    category = \"8muses\"\n    subcategory = \"album\"\n    directory_fmt = (\"{category}\", \"{album[path]}\")\n    filename_fmt = \"{page:>03}.{extension}\"\n    archive_fmt = \"{hash}\"\n    root = \"https://comics.8muses.com\"\n    pattern = (r\"(?:https?://)?(?:comics\\.|www\\.)?8muses\\.com\"\n               r\"(/comics/album/[^?#]+)(\\?[^#]+)?\")\n    example = \"https://comics.8muses.com/comics/album/PATH/TITLE\"\n\n    def __init__(self, match):\n        Extractor.__init__(self, match)\n        self.path = match[1]\n        self.params = match[2] or \"\"\n\n    def items(self):\n        url = self.root + self.path + self.params\n\n        while True:\n            data = self._unobfuscate(text.extr(\n                self.request(url).text,\n                'id=\"ractive-public\" type=\"text/plain\">', '</script>'))\n\n            if images := data.get(\"pictures\"):\n                count = len(images)\n                album = self._make_album(data[\"album\"])\n                yield Message.Directory, \"\", {\"album\": album, \"count\": count}\n                for num, image in enumerate(images, 1):\n                    url = self.root + \"/image/fl/\" + image[\"publicUri\"]\n                    img = {\n                        \"url\"      : url,\n                        \"page\"     : num,\n                        \"hash\"     : image[\"publicUri\"],\n                        \"count\"    : count,\n                        \"album\"    : album,\n                        \"extension\": \"jpg\",\n                    }\n                    yield Message.Url, url, img\n\n            if albums := data.get(\"albums\"):\n                for album in albums:\n                    permalink = album.get(\"permalink\")\n                    if not permalink:\n                        self.log.debug(\"Private album\")\n                        continue\n\n                    url = self.root + \"/comics/album/\" + permalink\n                    yield Message.Queue, url, {\n                        \"url\"       : url,\n                        \"name\"      : album[\"name\"],\n                        \"private\"   : album[\"isPrivate\"],\n                        \"_extractor\": _8musesAlbumExtractor,\n                    }\n\n            if data[\"page\"] >= data[\"pages\"]:\n                return\n            path, _, num = self.path.rstrip(\"/\").rpartition(\"/\")\n            path = path if num.isdecimal() else self.path\n            url = f\"{self.root}{path}/{data['page'] + 1}{self.params}\"\n\n    def _make_album(self, album):\n        return {\n            \"id\"     : album[\"id\"],\n            \"path\"   : album[\"path\"],\n            \"parts\"  : album[\"path\"].split(\"/\"),\n            \"title\"  : album[\"name\"],\n            \"private\": album[\"isPrivate\"],\n            \"url\"    : self.root + \"/comics/album/\" + album[\"permalink\"],\n            \"parent\" : text.parse_int(album[\"parentId\"]),\n            \"views\"  : text.parse_int(album[\"numberViews\"]),\n            \"likes\"  : text.parse_int(album[\"numberLikes\"]),\n            \"date\"   : self.parse_datetime_iso(album[\"updatedAt\"]),\n        }\n\n    def _unobfuscate(self, data):\n        return util.json_loads(\"\".join([\n            chr(33 + (ord(c) + 14) % 94) if \"!\" <= c <= \"~\" else c\n            for c in text.unescape(data.strip(\"\\t\\n\\r !\"))\n        ]))\n"
  },
  {
    "path": "gallery_dl/extractor/__init__.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2015-2025 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nimport sys\nfrom ..text import re_compile\n\nmodules = [\n    \"2ch\",\n    \"2chan\",\n    \"2chen\",\n    \"35photo\",\n    \"3dbooru\",\n    \"4chan\",\n    \"4archive\",\n    \"4chanarchives\",\n    \"500px\",\n    \"8chan\",\n    \"8muses\",\n    \"adultempire\",\n    \"agnph\",\n    \"ahottie\",\n    \"allporncomic\",\n    \"ao3\",\n    \"arcalive\",\n    \"architizer\",\n    \"arena\",\n    \"artstation\",\n    \"aryion\",\n    \"audiochan\",\n    \"bbc\",\n    \"behance\",\n    \"bellazon\",\n    \"bilibili\",\n    \"blogger\",\n    \"bluesky\",\n    \"boosty\",\n    \"booth\",\n    \"bunkr\",\n    \"catbox\",\n    \"cfake\",\n    \"chevereto\",\n    \"cien\",\n    \"civitai\",\n    \"comedywildlifephoto\",\n    \"comick\",\n    \"comicvine\",\n    \"cyberdrop\",\n    \"cyberfile\",\n    \"danbooru\",\n    \"dandadan\",\n    \"dankefuerslesen\",\n    \"desktopography\",\n    \"deviantart\",\n    \"discord\",\n    \"dynastyscans\",\n    \"e621\",\n    \"eporner\",\n    \"erome\",\n    \"everia\",\n    \"exhentai\",\n    \"facebook\",\n    \"fanbox\",\n    \"fansly\",\n    \"fantia\",\n    \"fapello\",\n    \"fapachi\",\n    \"fikfap\",\n    \"filester\",\n    \"fitnakedgirls\",\n    \"flickr\",\n    \"foriio\",\n    \"furaffinity\",\n    \"furry34\",\n    \"fuskator\",\n    \"gelbooru\",\n    \"gelbooru_v01\",\n    \"gelbooru_v02\",\n    \"girlsreleased\",\n    \"girlswithmuscle\",\n    \"gofile\",\n    \"hatenablog\",\n    \"hdoujin\",\n    \"hentai2read\",\n    \"hentaicosplays\",\n    \"hentaifoundry\",\n    \"hentaihand\",\n    \"hentaihere\",\n    \"hentainexus\",\n    \"hiperdex\",\n    \"hitomi\",\n    \"hotleak\",\n    \"idolcomplex\",\n    \"imagebam\",\n    \"imagechest\",\n    \"imagefap\",\n    \"imagepond\",\n    \"imgbb\",\n    \"imgbox\",\n    \"imgpile\",\n    \"imgth\",\n    \"imgur\",\n    \"imhentai\",\n    \"inkbunny\",\n    \"instagram\",\n    \"issuu\",\n    \"itaku\",\n    \"itchio\",\n    \"iwara\",\n    \"joyreactor\",\n    \"jschan\",\n    \"kabeuchi\",\n    \"kaliscan\",\n    \"keenspot\",\n    \"kemono\",\n    \"khinsider\",\n    \"komikcast\",\n    \"koofr\",\n    \"leakgallery\",\n    \"lensdump\",\n    \"lexica\",\n    \"lightroom\",\n    \"listal\",\n    \"livedoor\",\n    \"lofter\",\n    \"luscious\",\n    \"lynxchan\",\n    \"madokami\",\n    \"mangadex\",\n    \"mangafire\",\n    \"mangafox\",\n    \"mangafreak\",\n    \"mangahere\",\n    \"manganelo\",\n    \"mangapark\",\n    \"mangaread\",\n    \"mangareader\",\n    \"mangataro\",\n    \"mangatown\",\n    \"mangoxo\",\n    \"misskey\",\n    \"mixdrop\",\n    \"motherless\",\n    \"myhentaigallery\",\n    \"myportfolio\",\n    \"naverblog\",\n    \"naverchzzk\",\n    \"naverwebtoon\",\n    \"nekohouse\",\n    \"newgrounds\",\n    \"nhentai\",\n    \"nijie\",\n    \"nitter\",\n    \"nozomi\",\n    \"nsfwalbum\",\n    \"nudostar\",\n    \"okporn\",\n    \"paheal\",\n    \"patreon\",\n    \"pexels\",\n    \"philomena\",\n    \"pholder\",\n    \"photovogue\",\n    \"picarto\",\n    \"picazor\",\n    \"pictoa\",\n    \"piczel\",\n    \"pillowfort\",\n    \"pinterest\",\n    \"pixeldrain\",\n    \"pixiv\",\n    \"pixnet\",\n    \"plurk\",\n    \"poipiku\",\n    \"poringa\",\n    \"pornhub\",\n    \"pornpics\",\n    \"pornstarstube\",\n    \"postmill\",\n    \"rawkuma\",\n    \"reactor\",\n    \"readcomiconline\",\n    \"realbooru\",\n    \"reddit\",\n    \"redgifs\",\n    \"rule34us\",\n    \"rule34vault\",\n    \"rule34xyz\",\n    \"s3ndpics\",\n    \"sankaku\",\n    \"sankakucomplex\",\n    \"schalenetwork\",\n    \"scrolller\",\n    \"seiga\",\n    \"senmanga\",\n    \"sexcom\",\n    \"shimmie2\",\n    \"simplyhentai\",\n    \"sizebooru\",\n    \"skeb\",\n    \"slickpic\",\n    \"slideshare\",\n    \"smugmug\",\n    \"soundgasm\",\n    \"speakerdeck\",\n    \"steamgriddb\",\n    \"subscribestar\",\n    \"sxypix\",\n    \"szurubooru\",\n    \"tapas\",\n    \"tcbscans\",\n    \"telegraph\",\n    \"tenor\",\n    \"thefap\",\n    \"thehentaiworld\",\n    \"tiktok\",\n    \"tmohentai\",\n    \"toyhouse\",\n    \"tumblr\",\n    \"tumblrgallery\",\n    \"tungsten\",\n    \"turbo\",\n    \"twibooru\",\n    \"twitter\",\n    \"urlgalleries\",\n    \"unsplash\",\n    \"uploadir\",\n    \"urlshortener\",\n    \"vanillarock\",\n    \"vichan\",\n    \"vipergirls\",\n    \"vk\",\n    \"vsco\",\n    \"wallhaven\",\n    \"wallpapercave\",\n    \"warosu\",\n    \"weasyl\",\n    \"webmshare\",\n    \"webtoons\",\n    \"weebcentral\",\n    \"weebdex\",\n    \"weibo\",\n    \"whyp\",\n    \"wikiart\",\n    \"wikifeet\",\n    \"wikimedia\",\n    \"xasiat\",\n    \"xenforo\",\n    \"xfolio\",\n    \"xhamster\",\n    \"xvideos\",\n    \"yiffverse\",\n    \"yourlesbians\",\n    \"zerochan\",\n    \"booru\",\n    \"moebooru\",\n    \"foolfuuka\",\n    \"foolslide\",\n    \"mastodon\",\n    \"shopify\",\n    \"lolisafe\",\n    \"imagehosts\",\n    \"directlink\",\n    \"recursive\",\n    \"oauth\",\n    \"noop\",\n    \"ytdl\",\n    \"generic\",\n]\n\n\ndef find(url):\n    \"\"\"Find a suitable extractor for the given URL\"\"\"\n    for cls in _list_classes():\n        if match := cls.pattern.match(url):\n            return cls(match)\n    return None\n\n\ndef add(cls):\n    \"\"\"Add 'cls' to the list of available extractors\"\"\"\n    if isinstance(cls.pattern, str):\n        cls.pattern = re_compile(cls.pattern)\n    _cache.append(cls)\n    return cls\n\n\ndef add_module(module):\n    \"\"\"Add all extractors in 'module' to the list of available extractors\"\"\"\n    if classes := _get_classes(module):\n        if isinstance(classes[0].pattern, str):\n            for cls in classes:\n                cls.pattern = re_compile(cls.pattern)\n        _cache.extend(classes)\n    return classes\n\n\ndef extractors():\n    \"\"\"Yield all available extractor classes\"\"\"\n    return sorted(\n        _list_classes(),\n        key=lambda x: x.__name__\n    )\n\n\n# --------------------------------------------------------------------\n# internals\n\n\ndef _list_classes():\n    \"\"\"Yield available extractor classes\"\"\"\n    yield from _cache\n\n    for module in _module_iter:\n        yield from add_module(module)\n\n    globals()[\"_list_classes\"] = lambda : _cache\n\n\ndef _modules_internal():\n    globals_ = globals()\n    for module_name in modules:\n        yield __import__(module_name, globals_, None, None, 1)\n\n\ndef _modules_path(path, files):\n    sys.path.insert(0, path)\n    try:\n        return [\n            __import__(name[:-3])\n            for name in files\n            if name.endswith(\".py\")\n        ]\n    finally:\n        del sys.path[0]\n\n\ndef _get_classes(module):\n    \"\"\"Return a list of all extractor classes in a module\"\"\"\n    return [\n        cls for cls in module.__dict__.values() if (\n            hasattr(cls, \"pattern\") and cls.__module__ == module.__name__\n        )\n    ]\n\n\n_cache = []\n_module_iter = _modules_internal()\n"
  },
  {
    "path": "gallery_dl/extractor/adultempire.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2019-2025 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://www.adultempire.com/\"\"\"\n\nfrom .common import GalleryExtractor\nfrom .. import text\n\n\nclass AdultempireGalleryExtractor(GalleryExtractor):\n    \"\"\"Extractor for image galleries from www.adultempire.com\"\"\"\n    category = \"adultempire\"\n    root = \"https://www.adultempire.com\"\n    pattern = (r\"(?:https?://)?(?:www\\.)?adult(?:dvd)?empire\\.com\"\n               r\"(/(\\d+)/gallery\\.html)\")\n    example = \"https://www.adultempire.com/12345/gallery.html\"\n\n    def __init__(self, match):\n        GalleryExtractor.__init__(self, match)\n        self.gallery_id = match[2]\n\n    def _init(self):\n        self.cookies.set(\"ageConfirmed\", \"true\", domain=\"www.adultempire.com\")\n\n    def metadata(self, page):\n        extr = text.extract_from(page, page.index('<div id=\"content\">'))\n        return {\n            \"gallery_id\": text.parse_int(self.gallery_id),\n            \"title\"     : text.unescape(extr('title=\"', '\"')),\n            \"studio\"    : extr(\">studio</small>\", \"<\").strip(),\n            \"date\"      : self.parse_datetime(extr(\n                \">released</small>\", \"<\").strip(), \"%m/%d/%Y\"),\n            \"actors\"    : sorted(text.split_html(extr(\n                '<ul class=\"item-details item-cast-list ', '</ul>'))[1:]),\n        }\n\n    def images(self, page):\n        params = {\"page\": 1}\n        while True:\n            urls = list(text.extract_iter(page, 'rel=\"L\"><img src=\"', '\"'))\n            for url in urls:\n                yield url.replace(\"_200.\", \"_9600.\"), None\n            if len(urls) < 24:\n                return\n            params[\"page\"] += 1\n            page = self.request(self.page_url, params=params).text\n"
  },
  {
    "path": "gallery_dl/extractor/agnph.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2024-2025 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://agn.ph/\"\"\"\n\nfrom . import booru\nfrom .. import text\nimport collections\n\nBASE_PATTERN = r\"(?:https?://)?agn\\.ph\"\n\n\nclass AgnphExtractor(booru.BooruExtractor):\n    category = \"agnph\"\n    root = \"https://agn.ph\"\n    page_start = 1\n    per_page = 45\n\n    TAG_TYPES = {\n        \"a\": \"artist\",\n        \"b\": \"copyright\",\n        \"c\": \"character\",\n        \"d\": \"species\",\n        \"m\": \"general\",\n    }\n\n    def _init(self):\n        self.cookies.set(\"confirmed_age\", \"true\", domain=\"agn.ph\")\n\n    def _prepare(self, post):\n        post[\"date\"] = self.parse_timestamp(post[\"created_at\"])\n        post[\"status\"] = post[\"status\"].strip()\n        post[\"has_children\"] = (\"true\" in post[\"has_children\"])\n\n    def _xml_to_dict(self, xml):\n        return {element.tag: element.text for element in xml}\n\n    def _pagination(self, url, params):\n        params[\"api\"] = \"xml\"\n        if \"page\" in params:\n            params[\"page\"] = \\\n                self.page_start + text.parse_int(params[\"page\"]) - 1\n        else:\n            params[\"page\"] = self.page_start\n\n        while True:\n            root = self.request_xml(url, params=params)\n\n            yield from map(self._xml_to_dict, root)\n\n            attrib = root.attrib\n            if int(attrib[\"offset\"]) + len(root) >= int(attrib[\"count\"]):\n                return\n\n            params[\"page\"] += 1\n\n    def _html(self, post):\n        url = f\"{self.root}/gallery/post/show/{post['id']}/\"\n        return self.request(url).text\n\n    def _tags(self, post, page):\n        tag_container = text.extr(\n            page, '<ul class=\"taglist\">', '<h3>Statistics</h3>')\n        if not tag_container:\n            return\n\n        tags = collections.defaultdict(list)\n        pattern = text.re(r'class=\"(.)typetag\">([^<]+)')\n        for tag_type, tag_name in pattern.findall(tag_container):\n            tags[tag_type].append(text.unquote(tag_name).replace(\" \", \"_\"))\n        for key, value in tags.items():\n            post[\"tags_\" + self.TAG_TYPES[key]] = \" \".join(value)\n\n\nclass AgnphTagExtractor(AgnphExtractor):\n    subcategory = \"tag\"\n    directory_fmt = (\"{category}\", \"{search_tags}\")\n    archive_fmt = \"t_{search_tags}_{id}\"\n    pattern = BASE_PATTERN + r\"/gallery/post/(?:\\?([^#]+))?$\"\n    example = \"https://agn.ph/gallery/post/?search=TAG\"\n\n    def __init__(self, match):\n        AgnphExtractor.__init__(self, match)\n        self.params = text.parse_query(self.groups[0])\n\n    def metadata(self):\n        return {\"search_tags\": self.params.get(\"search\") or \"\"}\n\n    def posts(self):\n        url = self.root + \"/gallery/post/\"\n        return self._pagination(url, self.params.copy())\n\n\nclass AgnphPostExtractor(AgnphExtractor):\n    subcategory = \"post\"\n    archive_fmt = \"{id}\"\n    pattern = BASE_PATTERN + r\"/gallery/post/show/(\\d+)\"\n    example = \"https://agn.ph/gallery/post/show/12345/\"\n\n    def posts(self):\n        url = f\"{self.root}/gallery/post/show/{self.groups[0]}/?api=xml\"\n        post = self.request_xml(url)\n        return (self._xml_to_dict(post),)\n"
  },
  {
    "path": "gallery_dl/extractor/ahottie.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2025 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://ahottie.top/\"\"\"\n\nfrom .common import Extractor, GalleryExtractor, Message\nfrom .. import text\n\nBASE_PATTERN = r\"(?:https?://)?(?:www\\.)?ahottie\\.top\"\n\n\nclass AhottieExtractor(Extractor):\n    \"\"\"Base class for ahottie extractors\"\"\"\n    category = \"ahottie\"\n    root = \"https://ahottie.top\"\n\n    def items(self):\n        for album in self.albums():\n            yield Message.Queue, album[\"url\"], album\n\n    def _pagination(self, url, params):\n        params[\"page\"] = text.parse_int(params.get(\"page\"), 1)\n\n        while True:\n            page = self.request(url, params=params).text\n\n            for album in text.extract_iter(\n                    page, '<div class=\"relative\">', '</div>'):\n                yield {\n                    \"url\"  : text.extr(album, ' href=\"', '\"'),\n                    \"title\": text.unquote(text.extr(\n                        album, ' alt=\"', '\"')),\n                    \"date\" : self.parse_datetime_iso(text.extr(\n                        album, ' datetime=\"', '\"')),\n                    \"_extractor\": AhottieGalleryExtractor,\n                }\n\n            if 'rel=\"next\"' not in page:\n                break\n            params[\"page\"] += 1\n\n\nclass AhottieGalleryExtractor(GalleryExtractor, AhottieExtractor):\n    directory_fmt = (\"{category}\", \"{date:%Y-%m-%d} {title} ({gallery_id})\")\n    filename_fmt = \"{num:>03}.{extension}\"\n    archive_fmt = \"{gallery_id}_{num}_{filename}\"\n    pattern = BASE_PATTERN + r\"(/albums/(\\w+))\"\n    example = \"https://ahottie.top/albums/1234567890\"\n\n    def metadata(self, page):\n        extr = text.extract_from(page)\n        return {\n            \"gallery_id\": self.groups[1],\n            \"title\": text.unescape(extr(\"<title>\", \"<\").rpartition(\" | \")[0]),\n            \"date\" : self.parse_datetime_iso(extr('datetime=\"', '\"')),\n            \"tags\" : text.split_html(extr('<i ', '</div>'))[1:],\n        }\n\n    def images(self, page):\n        data = {\n            \"_http_headers\" : {\"Referer\": None},\n            \"_http_validate\": self._validate,\n        }\n\n        results = []\n        while True:\n            pos = page.find(\"<time \") + 1\n            for url in text.extract_iter(page, '\" src=\"', '\"', pos):\n                results.append((url, data))\n\n            pos = page.find('rel=\"next\"', pos)\n            if pos < 0:\n                break\n            page = self.request(text.rextr(page, 'href=\"', '\"', pos)).text\n        return results\n\n    def _validate(self, response):\n        hget = response.headers.get\n        return not (\n            hget(\"content-length\") == \"2421\" and\n            hget(\"content-type\") == \"image/jpeg\"\n        )\n\n\nclass AhottieTagExtractor(AhottieExtractor):\n    subcategory = \"tag\"\n    pattern = BASE_PATTERN + r\"/tags/([^/?#]+)\"\n    example = \"https://ahottie.top/tags/TAG\"\n\n    def albums(self):\n        tag = self.groups[0]\n        self.kwdict[\"search_tags\"] = text.unquote(tag)\n        return self._pagination(f\"{self.root}/tags/{tag}\", {})\n\n\nclass AhottieSearchExtractor(AhottieExtractor):\n    subcategory = \"search\"\n    pattern = BASE_PATTERN + r\"/search/?\\?([^#]+)\"\n    example = \"https://ahottie.top/search?kw=QUERY\"\n\n    def albums(self):\n        params = text.parse_query(self.groups[0])\n        self.kwdict[\"search_tags\"] = params.get(\"kw\")\n        return self._pagination(f\"{self.root}/search\", params)\n"
  },
  {
    "path": "gallery_dl/extractor/allporncomic.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2026 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://allporncomic.com/\"\"\"\n\nfrom .common import Extractor, ChapterExtractor, MangaExtractor, Message\nfrom .. import text\n\nBASE_PATTERN = r\"(?:https?://)?(?:www\\.)?allporncomic\\.com\"\n\n\nclass AllporncomicBase():\n    \"\"\"Base class for allporncomic extractors\"\"\"\n    category = \"allporncomic\"\n    root = \"https://allporncomic.com\"\n\n    def _manga_info(self, slug, page=None):\n        if page is None:\n            url = f\"{self.root}/porncomic/{slug}/\"\n            page = self.request(url).text\n        extr = text.extract_from(page)\n\n        lang = extr('property=\"og:locale\" content=\"', '\"')\n        title = text.unescape(extr('property=\"og:title\" content=\"', '\"'))\n        manga = text.re(r\"(.+?)( \\([^)]+\\))?( \\[[^\\]]+\\])?\\s*$\").match(title)\n\n        return {\n            \"description\" : text.unescape(extr(\n                'property=\"og:description\" content=\"', '\"')),\n            \"manga\"       : \"\" if manga is None else manga[1],\n            \"manga_slug\"  : slug,\n            \"manga_cover\" : extr('property=\"og:image\" content=\"', '\"'),\n            \"manga_date\"  : self.parse_datetime_iso(extr(\n                '\"datePublished\":\"', '\"')),\n            \"manga_date_updated\": self.parse_datetime_iso(extr(\n                '\"dateModified\":\"', '\"')),\n            \"manga_id\"    : text.parse_int(extr(\" postid-\", \" \")),\n            \"rating\"      : text.parse_float(extr('total_votes\">', \"<\")),\n            \"votes\"       : text.parse_int(extr('id=\"countrate\">', \"<\")),\n            \"characters\"  : text.split_html(extr(\n                'class=\"author-content\">', \"</div>\"))[::2],\n            \"parody\"      : text.split_html(extr(\n                'class=\"author-content\">', \"</div>\"))[::2],\n            \"group\"       : text.split_html(extr(\n                'class=\"author-content\">', \"</div>\"))[::2],\n            \"artist\"      : text.split_html(extr(\n                'class=\"artist-content\">', \"</div>\"))[::2],\n            \"tags\"        : text.split_html(extr(\n                'class=\"genres-content\">', \"</div>\"))[::2],\n            \"type\"        : extr('class=\"summary-content\">', \"<\").strip(),\n            \"status\"      : extr('class=\"summary-content\">', \"<\").strip(),\n            \"comments\"    : text.parse_int(extr('<span>', \" \")),\n            \"bookmarks\"   : text.parse_int(extr(\n                'class=\"action_detail\"><span>', \" \")),\n            \"lang\"        : lang.partition(\"_\")[0],\n        }\n\n\nclass AllporncomicChapterExtractor(AllporncomicBase, ChapterExtractor):\n    \"\"\"Extractor for allporncomic manga chapters\"\"\"\n    directory_fmt = (\"{category}\", \"{path:I}\")\n    filename_fmt = \"{page:>03}.{extension}\"\n    archive_fmt = \"{manga_id}_{chapter_id}_{page}\"\n    pattern = (BASE_PATTERN +\n               r\"(/porncomic/([^/?#]+)/(\\d+(?:-\\d+)?)?([^/?#]+))\")\n    example = \"https://allporncomic.com/porncomic/MANGA/123-TITLE/\"\n\n    def __init__(self, match):\n        url = f\"{self.root}{match[1]}/\"\n        ChapterExtractor.__init__(self, match, url)\n\n    def metadata(self, page):\n        _, manga_slug, chapter, title_slug = self.groups\n        if chapter is None:\n            chapter = sep = minor = \"\"\n        else:\n            chapter, sep, minor = chapter.partition(\"-\")\n\n        return {\n            **self.cache(self._manga_info, manga_slug),\n            \"path\"         : text.split_html(text.extr(\n                page, '<ol class=\"breadcrumb', '</ol>'))[3:],\n            \"chapter\"      : text.parse_int(chapter),\n            \"chapter_id\"   : text.parse_int(text.extr(\n                page, 'manga_chapter_id\" value=\"', '\"')),\n            \"chapter_minor\": \".\" + minor if minor else \"\",\n        }\n\n    def images(self, page):\n        return [\n            (url.strip(), None)\n            for url in text.extract_iter(page, ' data-src=\"', '\"')\n        ]\n\n\nclass AllporncomicMangaExtractor(AllporncomicBase, MangaExtractor):\n    \"\"\"Extractor for allporncomic manga\"\"\"\n    chapterclass = AllporncomicChapterExtractor\n    pattern = BASE_PATTERN + r\"/porncomic/([^/?#]+)\"\n    example = \"https://allporncomic.com/porncomic/MANGA/\"\n\n    def __init__(self, match):\n        url = f\"{self.root}/porncomic/{match[1]}/\"\n        MangaExtractor.__init__(self, match, url)\n\n    def chapters(self, page):\n        slug = text.extr(page, \"/porncomic/\", \"/\")\n        info = self._manga_info(slug, page)\n\n        results = []\n        for ch in text.extract_iter(\n                page, '<li class=\"wp-manga-chapter', '</li>'):\n            url = text.extr(ch, ' href=\"', '\"')\n            data = {\n                **info,\n                \"date\": self.parse_datetime(text.extr(\n                    page, \"<i>\", \"<\"), \"%B %d, %Y\"),\n            }\n            results.append((url, data))\n        return results\n\n\nclass AllporncomicTagExtractor(AllporncomicBase, Extractor):\n    \"\"\"Extractor for allporncomic tag search results\"\"\"\n    subcategory = \"tag\"\n    pattern = (BASE_PATTERN + r\"(/(?:porncomic-)?\"\n               r\"(?:genre|series|group|artist|characters)\"\n               r\"/[^/?#]+(?:/page/\\d+)?)(/?\\?[^#]+)?\")\n    example = \"https://allporncomic.com/porncomic-genre/GENRE/\"\n\n    def items(self):\n        data = {\"_extractor\": AllporncomicMangaExtractor}\n\n        url = f\"{self.root}{self.groups[0]}{self.groups[1] or '/'}\"\n        while url:\n            page = self.request(url).text\n\n            for manga in text.extract_iter(page, 'id=\"manga-item-', \"</div>\"):\n                yield Message.Queue, text.extr(manga, ' href=\"', '\"'), data\n\n            url = text.extr(page, '<link rel=\"next\" href=\"', '\"')\n"
  },
  {
    "path": "gallery_dl/extractor/ao3.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2024-2026 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://archiveofourown.org/\"\"\"\n\nfrom .common import Extractor, Message, Dispatch\nfrom .. import text, util\n\nBASE_PATTERN = (r\"(?:https?://)?(?:www\\.)?\"\n                r\"a(?:rchiveofourown|o3)\\.(?:org|com|net)\")\n\n\nclass Ao3Extractor(Extractor):\n    \"\"\"Base class for ao3 extractors\"\"\"\n    category = \"ao3\"\n    root = \"https://archiveofourown.org\"\n    categorytransfer = True\n    cookies_domain = \".archiveofourown.org\"\n    cookies_names = (\"remember_user_token\",)\n    request_interval = (0.5, 1.5)\n\n    def items(self):\n        self.login()\n\n        base = self.root + \"/works/\"\n        data = {\"_extractor\": Ao3WorkExtractor, \"type\": \"work\"}\n\n        for work_id in self.works():\n            yield Message.Queue, base + work_id, data\n\n    def items_list(self, type, needle, part=True):\n        self.login()\n\n        base = self.root + \"/\"\n        data_work = {\"_extractor\": Ao3WorkExtractor, \"type\": \"work\"}\n        data_series = {\"_extractor\": Ao3SeriesExtractor, \"type\": \"series\"}\n        data_user = {\"_extractor\": Ao3UserExtractor, \"type\": \"user\"}\n\n        for item in self._pagination(self.groups[0], needle):\n            path = item.rpartition(\"/\")[0] if part else item\n            url = base + path\n            if item.startswith(\"works/\"):\n                yield Message.Queue, url, data_work\n            elif item.startswith(\"series/\"):\n                yield Message.Queue, url, data_series\n            elif item.startswith(\"users/\"):\n                yield Message.Queue, url, data_user\n            else:\n                self.log.warning(\"Unsupported %s type '%s'\", type, path)\n\n    def works(self):\n        return self._pagination(self.groups[0])\n\n    def login(self):\n        if self.cookies_check(self.cookies_names):\n            return\n\n        username, password = self._get_auth_info()\n        if username:\n            return self.cookies_update(self.cache(\n                self._login_impl, username, password,\n                _exp=90*86400, _mem=False))\n\n    def _login_impl(self, username, password):\n        self.log.info(\"Logging in as %s\", username)\n\n        url = self.root + \"/users/login\"\n        page = self.request(url).text\n\n        pos = page.find('id=\"loginform\"')\n        token = text.extract(\n            page, ' name=\"authenticity_token\" value=\"', '\"', pos)[0]\n        if not token:\n            self.log.error(\"Unable to extract 'authenticity_token'\")\n\n        data = {\n            \"authenticity_token\": text.unescape(token),\n            \"user[login]\"       : username,\n            \"user[password]\"    : password,\n            \"user[remember_me]\" : \"1\",\n            \"commit\"            : \"Log In\",\n        }\n\n        response = self.request(url, method=\"POST\", data=data)\n        if not response.history:\n            raise self.exc.AuthenticationError()\n\n        remember = response.history[0].cookies.get(\"remember_user_token\")\n        if not remember:\n            raise self.exc.AuthenticationError()\n\n        return {\n            \"remember_user_token\": remember,\n            \"user_credentials\"   : \"1\",\n        }\n\n    def _pagination(self, path, needle='<li id=\"work_'):\n        while True:\n            page = self.request(self.root + path).text\n\n            yield from text.extract_iter(page, needle, '\"')\n\n            path = (text.extr(page, '<a rel=\"next\" href=\"', '\"') or\n                    text.extr(page, '<li class=\"next\"><a href=\"', '\"'))\n            if not path:\n                return\n            path = text.unescape(path)\n\n\nclass Ao3WorkExtractor(Ao3Extractor):\n    \"\"\"Extractor for an AO3 work\"\"\"\n    subcategory = \"work\"\n    directory_fmt = (\"{category}\", \"{author}\")\n    filename_fmt = \"{id} {title}.{extension}\"\n    archive_fmt = \"{id}.{extension}\"\n    pattern = BASE_PATTERN + r\"/works/(\\d+)\"\n    example = \"https://archiveofourown.org/works/12345\"\n\n    def _init(self):\n        formats = self.config(\"formats\")\n        if formats is None:\n            self.formats = (\"pdf\",)\n        elif not formats:\n            self.formats = ()\n        elif isinstance(formats, str):\n            self.formats = formats.lower().replace(\" \", \"\").split(\",\")\n        else:\n            self.formats = formats\n\n        self.cookies.set(\"view_adult\", \"true\", domain=\"archiveofourown.org\")\n\n    def items(self):\n        self.login()\n\n        work_id = self.groups[0]\n        url = f\"{self.root}/works/{work_id}\"\n        response = self.request(url, notfound=True)\n\n        if response.url.endswith(\"/users/login?restricted=true\"):\n            raise self.exc.AuthorizationError(\n                \"Login required to access member-only works\")\n        page = response.text\n        if len(page) < 20000 and \\\n                '<h2 class=\"landmark heading\">Adult Content Warning</' in page:\n            raise self.exc.AbortExtraction(\"Adult Content\")\n\n        extr = text.extract_from(page)\n\n        chapters = {}\n        cindex = extr(' id=\"chapter_index\"', \"</ul>\")\n        for ch in text.extract_iter(cindex, ' value=\"', \"</option>\"):\n            cid, _, cname = ch.partition('\">')\n            chapters[cid] = text.unescape(cname)\n\n        fmts = {}\n        path = \"\"\n        download = extr(' class=\"download\"', \"</ul>\")\n        for dl in text.extract_iter(download, ' href=\"', \"</\"):\n            path, _, type = dl.rpartition('\">')\n            fmts[type.lower()] = path\n\n        data = {\n            \"id\"           : text.parse_int(work_id),\n            \"rating\"       : text.split_html(\n                extr('<dd class=\"rating tags\">', \"</dd>\")),\n            \"warnings\"     : text.split_html(\n                extr('<dd class=\"warning tags\">', \"</dd>\")),\n            \"categories\"   : text.split_html(\n                extr('<dd class=\"category tags\">', \"</dd>\")),\n            \"fandom\"       : text.split_html(\n                extr('<dd class=\"fandom tags\">', \"</dd>\")),\n            \"relationships\": text.split_html(\n                extr('<dd class=\"relationship tags\">', \"</dd>\")),\n            \"characters\"   : text.split_html(\n                extr('<dd class=\"character tags\">', \"</dd>\")),\n            \"tags\"         : text.split_html(\n                extr('<dd class=\"freeform tags\">', \"</dd>\")),\n            \"lang\"         : extr('<dd class=\"language\" lang=\"', '\"'),\n            \"series\"       : extr('<dd class=\"series\">', \"</dd>\"),\n            \"date\"         : self.parse_datetime_iso(extr(\n                '<dd class=\"published\">', \"<\")),\n            \"date_completed\": self.parse_datetime_iso(extr(\n                '>Completed:</dt><dd class=\"status\">', \"<\")),\n            \"date_updated\" : self.parse_timestamp(\n                path.rpartition(\"updated_at=\")[2]),\n            \"words\"        : text.parse_int(\n                extr('<dd class=\"words\">', \"<\").replace(\",\", \"\")),\n            \"chapters\"     : chapters,\n            \"comments\"     : text.parse_int(\n                extr('<dd class=\"comments\">', \"<\").replace(\",\", \"\")),\n            \"likes\"        : text.parse_int(\n                extr('<dd class=\"kudos\">', \"<\").replace(\",\", \"\")),\n            \"bookmarks\"    : text.parse_int(text.remove_html(\n                extr('<dd class=\"bookmarks\">', \"</dd>\")).replace(\",\", \"\")),\n            \"views\"        : text.parse_int(\n                extr('<dd class=\"hits\">', \"<\").replace(\",\", \"\")),\n            \"title\"        : text.unescape(text.remove_html(\n                extr(' class=\"title heading\">', \"</h2>\")).strip()),\n            \"author\"       : text.unescape(text.remove_html(\n                extr(' class=\"byline heading\">', \"</h3>\"))),\n            \"summary\"      : text.split_html(\n                extr(' class=\"heading\">Summary:</h3>', \"</div>\")),\n        }\n        data[\"language\"] = util.code_to_language(data[\"lang\"])\n\n        if series := data[\"series\"]:\n            extr = text.extract_from(series)\n            data[\"series\"] = {\n                \"prev\" : extr(' class=\"previous\" href=\"/works/', '\"'),\n                \"index\": extr(' class=\"position\">Part ', \" \"),\n                \"id\"   : extr(' href=\"/series/', '\"'),\n                \"name\" : text.unescape(extr(\">\", \"<\")),\n                \"next\" : extr(' class=\"next\" href=\"/works/', '\"'),\n            }\n        else:\n            data[\"series\"] = None\n\n        yield Message.Directory, \"\", data\n        for fmt in self.formats:\n            try:\n                url = text.urljoin(self.root, fmts[fmt])\n            except KeyError:\n                self.log.warning(\"%s: Format '%s' not available\", work_id, fmt)\n            else:\n                yield Message.Url, url, text.nameext_from_url(url, data)\n\n\nclass Ao3SeriesExtractor(Ao3Extractor):\n    \"\"\"Extractor for AO3 works of a series\"\"\"\n    subcategory = \"series\"\n    pattern = BASE_PATTERN + r\"(/series/(\\d+))\"\n    example = \"https://archiveofourown.org/series/12345\"\n\n\nclass Ao3TagExtractor(Ao3Extractor):\n    \"\"\"Extractor for AO3 works by tag\"\"\"\n    subcategory = \"tag\"\n    pattern = BASE_PATTERN + r\"(/tags/([^/?#]+)/works(?:/?\\?.+)?)\"\n    example = \"https://archiveofourown.org/tags/TAG/works\"\n\n\nclass Ao3SearchExtractor(Ao3Extractor):\n    \"\"\"Extractor for AO3 search results\"\"\"\n    subcategory = \"search\"\n    pattern = BASE_PATTERN + r\"(/works/search/?\\?.+)\"\n    example = \"https://archiveofourown.org/works/search?work_search[query]=air\"\n\n\nclass Ao3UserExtractor(Dispatch, Ao3Extractor):\n    \"\"\"Extractor for an AO3 user profile\"\"\"\n    pattern = (BASE_PATTERN + r\"/users/([^/?#]+(?:/pseuds/[^/?#]+)?)\"\n               r\"(?:/profile)?/?(?:$|\\?|#)\")\n    example = \"https://archiveofourown.org/users/USER\"\n\n    def items(self):\n        base = f\"{self.root}/users/{self.groups[0]}/\"\n        return self._dispatch_extractors((\n            (Ao3UserWorksExtractor   , base + \"works\"),\n            (Ao3UserSeriesExtractor  , base + \"series\"),\n            (Ao3UserBookmarkExtractor, base + \"bookmarks\"),\n        ), (\"user-works\", \"user-series\"))\n\n\nclass Ao3UserWorksExtractor(Ao3Extractor):\n    \"\"\"Extractor for works of an AO3 user\"\"\"\n    subcategory = \"user-works\"\n    pattern = (BASE_PATTERN + r\"(/users/([^/?#]+)/(?:pseuds/([^/?#]+)/)?\"\n               r\"works(?:/?\\?.+)?)\")\n    example = \"https://archiveofourown.org/users/USER/works\"\n\n\nclass Ao3UserSeriesExtractor(Ao3Extractor):\n    \"\"\"Extractor for series of an AO3 user\"\"\"\n    subcategory = \"user-series\"\n    pattern = (BASE_PATTERN + r\"(/users/([^/?#]+)/(?:pseuds/([^/?#]+)/)?\"\n               r\"series(?:/?\\?.+)?)\")\n    example = \"https://archiveofourown.org/users/USER/series\"\n\n    def items(self):\n        self.login()\n\n        base = self.root + \"/series/\"\n        data = {\"_extractor\": Ao3SeriesExtractor}\n\n        for series_id in self.series():\n            yield Message.Queue, base + series_id, data\n\n    def series(self):\n        return self._pagination(self.groups[0], '<li id=\"series_')\n\n\nclass Ao3UserBookmarkExtractor(Ao3Extractor):\n    \"\"\"Extractor for bookmarked works of an AO3 user\"\"\"\n    subcategory = \"user-bookmark\"\n    pattern = (BASE_PATTERN + r\"(/users/([^/?#]+)/(?:pseuds/([^/?#]+)/)?\"\n               r\"bookmarks(?:/?\\?.+)?)\")\n    example = \"https://archiveofourown.org/users/USER/bookmarks\"\n\n    def items(self):\n        return self.items_list(\"bookmark\", '<span class=\"count\"><a href=\"/')\n\n\nclass Ao3SubscriptionsExtractor(Ao3Extractor):\n    \"\"\"Extractor for your AO3 account's subscriptions\"\"\"\n    subcategory = \"subscriptions\"\n    pattern = BASE_PATTERN + r\"(/users/([^/?#]+)/subscriptions(?:/?\\?.+)?)\"\n    example = \"https://archiveofourown.org/users/USER/subscriptions\"\n\n    def items(self):\n        return self.items_list(\"subscription\", '<dt>\\n<a href=\"/', False)\n"
  },
  {
    "path": "gallery_dl/extractor/arcalive.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://arca.live/\"\"\"\n\nfrom .common import Extractor, Message\nfrom .. import text, util\n\nBASE_PATTERN = r\"(?:https?://)?(?:www\\.)?arca\\.live\"\n\n\nclass ArcaliveExtractor(Extractor):\n    \"\"\"Base class for Arca.live extractors\"\"\"\n    category = \"arcalive\"\n    root = \"https://arca.live\"\n    useragent = \"net.umanle.arca.android.playstore/0.9.75\"\n    request_interval = (0.5, 1.5)\n\n    def _init(self):\n        self.api = ArcaliveAPI(self)\n\n    def items(self):\n        for article in self.articles():\n            article[\"_extractor\"] = ArcalivePostExtractor\n            board = self.board or article.get(\"boardSlug\") or \"breaking\"\n            url = f\"{self.root}/b/{board}/{article['id']}\"\n            yield Message.Queue, url, article\n\n\nclass ArcalivePostExtractor(ArcaliveExtractor):\n    \"\"\"Extractor for an arca.live post\"\"\"\n    subcategory = \"post\"\n    directory_fmt = (\"{category}\", \"{boardSlug}\")\n    filename_fmt = \"{id}_{num}{title:? //[b:230]}.{extension}\"\n    archive_fmt = \"{id}_{num}\"\n    pattern = BASE_PATTERN + r\"/b/(?:\\w+)/(\\d+)\"\n    example = \"https://arca.live/b/breaking/123456789\"\n\n    def items(self):\n        self.emoticons = self.config(\"emoticons\", False)\n        self.gifs = gifs = self.config(\"gifs\", True)\n        if gifs:\n            self.gifs_fallback = (gifs != \"check\")\n\n        post = self.api.post(self.groups[0])\n        files = self._extract_files(post)\n\n        post[\"count\"] = len(files)\n        post[\"date\"] = self.parse_datetime_iso(post[\"createdAt\"][:19])\n        post[\"post_url\"] = post_url = \\\n            f\"{self.root}/b/{post['boardSlug']}/{post['id']}\"\n        post[\"_http_headers\"] = {\"Referer\": post_url + \"?p=1\"}\n\n        yield Message.Directory, \"\", post\n        for post[\"num\"], file in enumerate(files, 1):\n            post.update(file)\n            url = file[\"url\"]\n            yield Message.Url, url, text.nameext_from_url(url, post)\n\n    def _extract_files(self, post):\n        files = []\n\n        for video, media in text.re(r\"<(?:img|vide(o)) ([^>]+)\").findall(\n                post[\"content\"]):\n            if not self.emoticons and 'class=\"arca-emoticon\"' in media:\n                continue\n\n            src = (text.extr(media, 'data-originalurl=\"', '\"') or\n                   text.extr(media, 'src=\"', '\"'))\n            if not src:\n                continue\n\n            src, _, query = text.unescape(src).partition(\"?\")\n            if src[0] == \"/\":\n                if src[1] == \"/\":\n                    url = \"https:\" + src.replace(\n                        \"//ac-p.namu\", \"//ac-o.namu\", 1)\n                else:\n                    url = self.root + src\n            else:\n                url = src\n\n            fallback = ()\n            query = \"?type=orig&\" + query\n            if orig := text.extr(media, 'data-orig=\"', '\"'):\n                path, _, ext = url.rpartition(\".\")\n                if ext != orig:\n                    fallback = (url + query,)\n                    url = path + \".\" + orig\n            elif video and self.gifs:\n                url_gif = url.rpartition(\".\")[0] + \".gif\"\n                if self.gifs_fallback:\n                    fallback = (url + query,)\n                    url = url_gif\n                else:\n                    response = self.request(\n                        url_gif + query, method=\"HEAD\", fatal=False)\n                    if response.status_code < 400:\n                        fallback = (url + query,)\n                        url = url_gif\n\n            files.append({\n                \"url\"   : url + query,\n                \"width\" : text.parse_int(text.extr(media, 'width=\"', '\"')),\n                \"height\": text.parse_int(text.extr(media, 'height=\"', '\"')),\n                \"_fallback\": fallback,\n            })\n\n        return files\n\n\nclass ArcaliveBoardExtractor(ArcaliveExtractor):\n    \"\"\"Extractor for an arca.live board's posts\"\"\"\n    subcategory = \"board\"\n    pattern = BASE_PATTERN + r\"/b/([^/?#]+)/?(?:\\?([^#]+))?$\"\n    example = \"https://arca.live/b/breaking\"\n\n    def articles(self):\n        self.board, query = self.groups\n        params = text.parse_query(query)\n        return self.api.board(self.board, params)\n\n\nclass ArcaliveUserExtractor(ArcaliveExtractor):\n    \"\"\"Extractor for an arca.live users's posts\"\"\"\n    subcategory = \"user\"\n    pattern = BASE_PATTERN + r\"/u/@([^/?#]+)/?(?:\\?([^#]+))?$\"\n    example = \"https://arca.live/u/@USER\"\n\n    def articles(self):\n        self.board = None\n        user, query = self.groups\n        params = text.parse_query(query)\n        return self.api.user_posts(text.unquote(user), params)\n\n\nclass ArcaliveAPI():\n\n    def __init__(self, extractor):\n        self.extractor = extractor\n        self.log = extractor.log\n        self.root = extractor.root + \"/api/app\"\n\n        extractor.session.headers[\"X-Device-Token\"] = util.generate_token(64)\n\n    def board(self, board_slug, params):\n        endpoint = \"/list/channel/\" + board_slug\n        return self._pagination(endpoint, params, \"articles\")\n\n    def post(self, post_id):\n        endpoint = \"/view/article/breaking/\" + str(post_id)\n        return self._call(endpoint)\n\n    def user_posts(self, username, params):\n        endpoint = \"/list/channel/breaking\"\n        params[\"target\"] = \"nickname\"\n        params[\"keyword\"] = username\n        return self._pagination(endpoint, params, \"articles\")\n\n    def _call(self, endpoint, params=None):\n        url = self.root + endpoint\n        response = self.extractor.request(url, params=params)\n\n        data = response.json()\n        if response.status_code == 200:\n            return data\n\n        self.log.debug(\"Server response: %s\", data)\n        if msg := data.get(\"message\"):\n            msg = \"API request failed: \" + msg\n        else:\n            msg = \"API request failed\"\n        raise self.extractor.exc.AbortExtraction(msg)\n\n    def _pagination(self, endpoint, params, key):\n        while True:\n            data = self._call(endpoint, params)\n\n            posts = data.get(key)\n            if not posts:\n                break\n            yield from posts\n\n            params.update(data[\"next\"])\n"
  },
  {
    "path": "gallery_dl/extractor/architizer.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2021-2025 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://architizer.com/\"\"\"\n\nfrom .common import GalleryExtractor, Extractor, Message\nfrom .. import text\n\n\nclass ArchitizerProjectExtractor(GalleryExtractor):\n    \"\"\"Extractor for project pages on architizer.com\"\"\"\n    category = \"architizer\"\n    subcategory = \"project\"\n    root = \"https://architizer.com\"\n    directory_fmt = (\"{category}\", \"{firm}\", \"{title}\")\n    filename_fmt = \"{filename}.{extension}\"\n    archive_fmt = \"{gid}_{num}\"\n    pattern = r\"(?:https?://)?architizer\\.com/projects/([^/?#]+)\"\n    example = \"https://architizer.com/projects/NAME/\"\n\n    def __init__(self, match):\n        url = f\"{self.root}/projects/{match[1]}/\"\n        GalleryExtractor.__init__(self, match, url)\n\n    def metadata(self, page):\n        extr = text.extract_from(page)\n        extr('id=\"Pages\"', \"\")\n\n        return {\n            \"title\"      : extr(\"data-name='\", \"'\"),\n            \"slug\"       : extr(\"data-slug='\", \"'\"),\n            \"gid\"        : extr(\"data-gid='\", \"'\").rpartition(\".\")[2],\n            \"firm\"       : extr(\"data-firm-leaders-str='\", \"'\"),\n            \"location\"   : extr(\"<h2>\", \"<\").strip(),\n            \"type\"       : text.unescape(text.remove_html(extr(\n                '<div class=\"title\">Type</div>', '<br'))),\n            \"status\"     : text.remove_html(extr(\n                '<div class=\"title\">STATUS</div>', '</')),\n            \"year\"       : text.remove_html(extr(\n                '<div class=\"title\">YEAR</div>', '</')),\n            \"size\"       : text.remove_html(extr(\n                '<div class=\"title\">SIZE</div>', '</')),\n            \"description\": text.unescape(extr(\n                '<span class=\"copy js-copy\">', '</span></div>')\n                .replace(\"<br />\", \"\\n\")),\n        }\n\n    def images(self, page):\n        return [\n            (url, None)\n            for url in text.extract_iter(\n                page, 'property=\"og:image:secure_url\" content=\"', \"?\")\n        ]\n\n\nclass ArchitizerFirmExtractor(Extractor):\n    \"\"\"Extractor for all projects of a firm\"\"\"\n    category = \"architizer\"\n    subcategory = \"firm\"\n    root = \"https://architizer.com\"\n    pattern = r\"(?:https?://)?architizer\\.com/firms/([^/?#]+)\"\n    example = \"https://architizer.com/firms/NAME/\"\n\n    def __init__(self, match):\n        Extractor.__init__(self, match)\n        self.firm = match[1]\n\n    def items(self):\n        url = url = f\"{self.root}/firms/{self.firm}/?requesting_merlin=pages\"\n        page = self.request(url).text\n        data = {\"_extractor\": ArchitizerProjectExtractor}\n\n        for project in text.extract_iter(page, '<a href=\"/projects/', '\"'):\n            if not project.startswith(\"q/\"):\n                url = f\"{self.root}/projects/{project}\"\n                yield Message.Queue, url, data\n"
  },
  {
    "path": "gallery_dl/extractor/arena.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2025 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractor for https://are.na/\"\"\"\n\nfrom .common import GalleryExtractor\n\n\nclass ArenaChannelExtractor(GalleryExtractor):\n    \"\"\"Extractor for are.na channels\"\"\"\n    category = \"arena\"\n    subcategory = \"channel\"\n    root = \"https://are.na\"\n    directory_fmt = (\"{category}\", \"{user[full_name]} ({user[id]})\",\n                     \"{channel[title]} ({channel[id]})\")\n    filename_fmt = \"{num:>03}{block[id]:? //}.{extension}\"\n    archive_fmt = \"{channel[id]}/{block[id]}\"\n    pattern = r\"(?:https?://)?(?:www\\.)?are\\.na/[^/?#]+/([^/?#]+)\"\n    example = \"https://are.na/evan-collins-1522646491/cassette-futurism\"\n\n    def metadata(self, page):\n        url = \"https://api.are.na/v2/channels/\" + self.groups[0]\n        channel = self.request_json(url)\n\n        channel[\"date\"] = self.parse_datetime_iso(\n            channel[\"created_at\"])\n        channel[\"date_updated\"] = self.parse_datetime_iso(\n            channel[\"updated_at\"])\n        channel.pop(\"contents\", None)\n\n        return {\n            \"count\"  : channel.get(\"length\"),\n            \"user\"   : channel.pop(\"user\", None),\n            \"owner\"  : channel.pop(\"owner\", None),\n            \"channel\": channel,\n        }\n\n    def images(self, page):\n        api = f\"https://api.are.na/v2/channels/{self.groups[0]}/contents\"\n        limit = 100\n        params = {\"page\": 1, \"per\": limit}\n\n        while True:\n            data = self.request_json(api, params=params)\n\n            contents = data.get(\"contents\")\n            if not contents:\n                return\n\n            for block in contents:\n                url = None\n\n                # Attachments (e.g., PDFs, files)\n                if attachment := block.get(\"attachment\"):\n                    url = attachment.get(\"url\")\n\n                # Images\n                elif image := block.get(\"image\"):\n                    # Prefer original image\n                    if original := image.get(\"original\"):\n                        url = original.get(\"url\")\n                    # Fallback to display/large image if present\n                    elif display := image.get(\"display\"):\n                        url = display.get(\"url\")\n                    elif large := image.get(\"large\"):\n                        url = large.get(\"url\")\n\n                # Some Links/Channels may not have downloadable media\n                if not url:\n                    continue\n\n                block[\"date\"] = self.parse_datetime_iso(\n                    block[\"created_at\"])\n                block[\"date_updated\"] = self.parse_datetime_iso(\n                    block[\"updated_at\"])\n\n                yield url, {\n                    \"block\" : block,\n                    \"source\": block.pop(\"source\", None),\n                }\n\n            if len(contents) < limit:\n                return\n            params[\"page\"] += 1\n"
  },
  {
    "path": "gallery_dl/extractor/artstation.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2018-2026 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://www.artstation.com/\"\"\"\n\nfrom .common import Extractor, Message\nfrom .. import text, util\nimport itertools\n\n\nclass ArtstationExtractor(Extractor):\n    \"\"\"Base class for artstation extractors\"\"\"\n    category = \"artstation\"\n    filename_fmt = \"{category}_{id}_{asset[id]}_{title}.{extension}\"\n    directory_fmt = (\"{category}\", \"{userinfo[username]}\")\n    archive_fmt = \"{asset[id]}\"\n    browser = \"firefox\"\n    tls12 = False\n    root = \"https://www.artstation.com\"\n\n    def __init__(self, match):\n        Extractor.__init__(self, match)\n        self.user = match[1] or match[2]\n\n    def _init(self):\n        self.session.headers[\"Cache-Control\"] = \"max-age=0\"\n        self.mviews = self.config(\"mviews\", True)\n        self.videos = self.config(\"videos\", True)\n        self.external = self.config(\"external\", False)\n        self.previews = self.config(\"previews\", False)\n        self.max_posts = self.config(\"max-posts\")\n\n    def items(self):\n        data = self.metadata()\n        projects = self.projects()\n\n        if self.max_posts:\n            projects = itertools.islice(projects, self.max_posts)\n        for project in projects:\n            for num, asset in enumerate(\n                    self.get_project_assets(project[\"hash_id\"]), 1):\n                asset.update(data)\n                adict = asset[\"asset\"]\n                asset[\"num\"] = num\n                yield Message.Directory, \"\", asset\n\n                if adict[\"has_embedded_player\"]:\n                    if url := self._extract_embed(asset):\n                        text.nameext_from_url(url, asset)\n                        yield Message.Url, url, asset\n                    if not self.previews:\n                        continue\n\n                if adict[\"has_image\"]:\n                    url = adict[\"image_url\"]\n                    text.nameext_from_url(url, asset)\n\n                    url = self._no_cache(url)\n                    if \"/images/images/\" in url:\n                        lhs, _, rhs = url.partition(\"/large/\")\n                        if rhs:\n                            url = f\"{lhs}/8k/{rhs}\"\n                            asset[\"_fallback\"] = self._image_fallback(lhs, rhs)\n\n                    yield Message.Url, url, asset\n\n    def _extract_embed(self, asset):\n        adict = asset[\"asset\"]\n        player = adict[\"player_embedded\"]\n        url = (text.extr(player, 'src=\"', '\"') or\n               text.extr(player, \"src='\", \"'\"))\n\n        if url.startswith(self.root):\n            # embed or video clip hosted on artstation\n            type = text.extr(adict.get(\"image_url\", \"\"), \"/assets/\", \"/\")\n            if type == \"marmosets\":\n                if not self.mviews:\n                    return\n                page = self.request(url).text\n                return text.extr(page, \"marmoset.embed(\", '\",').strip(\"\\\"' \")\n\n            elif type:\n                if not self.videos:\n                    return\n                page = self.request(url).text\n                return text.extract(\n                    page, ' src=\"', '\"', page.find('id=\"video\"')+1)[0]\n\n        if url:\n            # external URL\n            if not self.external:\n                return\n            asset[\"extension\"] = \"mp4\"\n            return \"ytdl:\" + url\n\n        self.log.debug(player)\n        self.log.warning(\"Failed to extract embedded player URL (%s)\",\n                         adict.get(\"id\"))\n\n    def _image_fallback(self, lhs, rhs):\n        yield f\"{lhs}/4k/{rhs}\"\n        yield f\"{lhs}/large/{rhs}\"\n        yield f\"{lhs}/medium/{rhs}\"\n        yield f\"{lhs}/small/{rhs}\"\n\n    def metadata(self):\n        \"\"\"Return general metadata\"\"\"\n        return {\"userinfo\": self.get_user_info(self.user)}\n\n    def projects(self):\n        \"\"\"Return an iterable containing all relevant project IDs\"\"\"\n\n    def get_project_assets(self, project_id):\n        \"\"\"Return all assets associated with 'project_id'\"\"\"\n        url = f\"{self.root}/projects/{project_id}.json\"\n\n        try:\n            data = self.request_json(url)\n        except self.exc.HttpError as exc:\n            self.log.warning(exc)\n            return\n\n        data[\"title\"] = text.unescape(data[\"title\"])\n        data[\"description\"] = text.unescape(text.remove_html(\n            data[\"description\"]))\n        data[\"date\"] = self.parse_datetime_iso(data[\"created_at\"])\n\n        assets = data[\"assets\"]\n        del data[\"assets\"]\n\n        data[\"count\"] = len(assets)\n        if len(assets) == 1:\n            data[\"asset\"] = assets[0]\n            yield data\n        else:\n            for asset in assets:\n                data[\"asset\"] = asset\n                yield data.copy()\n\n    def get_user_info(self, username):\n        \"\"\"Return metadata for a specific user\"\"\"\n        url = f\"{self.root}/users/{username.lower()}/quick.json\"\n        response = self.request(url, notfound=\"user\")\n        return response.json()\n\n    def _pagination(self, url, params=None, json=None):\n        headers = {\n            \"Accept\" : \"application/json, text/plain, */*\",\n            \"Origin\" : self.root,\n        }\n\n        if json:\n            params = json\n            headers[\"PUBLIC-CSRF-TOKEN\"] = self._init_csrf_token()\n            kwargs = {\"method\": \"POST\", \"headers\": headers, \"json\": json}\n        else:\n            if not params:\n                params = {}\n            kwargs = {\"params\": params, \"headers\": headers}\n\n        total = 0\n        params[\"page\"] = 1\n\n        while True:\n            data = self.request_json(url, **kwargs)\n            yield from data[\"data\"]\n\n            total += len(data[\"data\"])\n            if total >= data[\"total_count\"]:\n                return\n\n            params[\"page\"] += 1\n\n    def _init_csrf_token(self):\n        url = self.root + \"/api/v2/csrf_protection/token.json\"\n        headers = {\n            \"Accept\" : \"*/*\",\n            \"Origin\" : self.root,\n        }\n        return self.request_json(\n            url, method=\"POST\", headers=headers, json={})[\"public_csrf_token\"]\n\n    def _no_cache(self, url):\n        \"\"\"Cause a cache miss to prevent Cloudflare 'optimizations'\n\n        Cloudflare's 'Polish' optimization strips image metadata and may even\n        recompress an image as lossy JPEG. This can be prevented by causing\n        a cache miss when requesting an image by adding a random dummy query\n        parameter.\n\n        Ref:\n        https://github.com/r888888888/danbooru/issues/3528\n        https://danbooru.donmai.us/forum_topics/14952\n        \"\"\"\n        sep = \"&\" if \"?\" in url else \"?\"\n        token = util.generate_token(8)\n        return url + sep + token[:4] + \"=\" + token[4:]\n\n\nclass ArtstationUserExtractor(ArtstationExtractor):\n    \"\"\"Extractor for all projects of an artstation user\"\"\"\n    subcategory = \"user\"\n    pattern = (r\"(?:https?://)?(?:(?:www\\.)?artstation\\.com\"\n               r\"/(?!artwork|projects|search)([^/?#]+)(?:/albums/all)?\"\n               r\"|((?!www)[\\w-]+)\\.artstation\\.com(?:/projects)?)/?$\")\n    example = \"https://www.artstation.com/USER\"\n\n    def projects(self):\n        url = f\"{self.root}/users/{self.user}/projects.json\"\n        params = {\"album_id\": \"all\"}\n        return self._pagination(url, params)\n\n\nclass ArtstationAlbumExtractor(ArtstationExtractor):\n    \"\"\"Extractor for all projects in an artstation album\"\"\"\n    subcategory = \"album\"\n    directory_fmt = (\"{category}\", \"{userinfo[username]}\", \"Albums\",\n                     \"{album[id]} - {album[title]}\")\n    archive_fmt = \"a_{album[id]}_{asset[id]}\"\n    pattern = (r\"(?:https?://)?(?:(?:www\\.)?artstation\\.com\"\n               r\"/(?!artwork|projects|search)([^/?#]+)\"\n               r\"|((?!www)[\\w-]+)\\.artstation\\.com)/albums/(\\d+)\")\n    example = \"https://www.artstation.com/USER/albums/12345\"\n\n    def __init__(self, match):\n        ArtstationExtractor.__init__(self, match)\n        self.album_id = text.parse_int(match[3])\n\n    def metadata(self):\n        userinfo = self.get_user_info(self.user)\n        album = None\n\n        for album in userinfo[\"albums_with_community_projects\"]:\n            if album[\"id\"] == self.album_id:\n                break\n        else:\n            raise self.exc.NotFoundError(\"album\")\n\n        return {\n            \"userinfo\": userinfo,\n            \"album\": album\n        }\n\n    def projects(self):\n        url = f\"{self.root}/users/{self.user}/projects.json\"\n        params = {\"album_id\": self.album_id}\n        return self._pagination(url, params)\n\n\nclass ArtstationLikesExtractor(ArtstationExtractor):\n    \"\"\"Extractor for liked projects of an artstation user\"\"\"\n    subcategory = \"likes\"\n    directory_fmt = (\"{category}\", \"{userinfo[username]}\", \"Likes\")\n    archive_fmt = \"f_{userinfo[id]}_{asset[id]}\"\n    pattern = (r\"(?:https?://)?(?:www\\.)?artstation\\.com\"\n               r\"/(?!artwork|projects|search)([^/?#]+)/likes\")\n    example = \"https://www.artstation.com/USER/likes\"\n\n    def projects(self):\n        url = f\"{self.root}/users/{self.user}/likes.json\"\n        return self._pagination(url)\n\n\nclass ArtstationCollectionExtractor(ArtstationExtractor):\n    \"\"\"Extractor for an artstation collection\"\"\"\n    subcategory = \"collection\"\n    directory_fmt = (\"{category}\", \"{user}\",\n                     \"{collection[id]} {collection[name]}\")\n    archive_fmt = \"c_{collection[id]}_{asset[id]}\"\n    pattern = (r\"(?:https?://)?(?:www\\.)?artstation\\.com\"\n               r\"/(?!artwork|projects|search)([^/?#]+)/collections/(\\d+)\")\n    example = \"https://www.artstation.com/USER/collections/12345\"\n\n    def __init__(self, match):\n        ArtstationExtractor.__init__(self, match)\n        self.collection_id = match[2]\n\n    def metadata(self):\n        url = f\"{self.root}/collections/{self.collection_id}.json\"\n        params = {\"username\": self.user}\n        collection = self.request_json(\n            url, params=params, notfound=True)\n        return {\"collection\": collection, \"user\": self.user}\n\n    def projects(self):\n        url = f\"{self.root}/collections/{self.collection_id}/projects.json\"\n        params = {\"collection_id\": self.collection_id}\n        return self._pagination(url, params)\n\n\nclass ArtstationCollectionsExtractor(ArtstationExtractor):\n    \"\"\"Extractor for an artstation user's collections\"\"\"\n    subcategory = \"collections\"\n    pattern = (r\"(?:https?://)?(?:www\\.)?artstation\\.com\"\n               r\"/(?!artwork|projects|search)([^/?#]+)/collections/?$\")\n    example = \"https://www.artstation.com/USER/collections\"\n\n    def items(self):\n        url = self.root + \"/collections.json\"\n        params = {\"username\": self.user}\n\n        for collection in self.request_json(\n                url, params=params, notfound=True):\n            url = f\"{self.root}/{self.user}/collections/{collection['id']}\"\n            collection[\"_extractor\"] = ArtstationCollectionExtractor\n            yield Message.Queue, url, collection\n\n\nclass ArtstationChallengeExtractor(ArtstationExtractor):\n    \"\"\"Extractor for submissions of artstation challenges\"\"\"\n    subcategory = \"challenge\"\n    filename_fmt = \"{submission_id}_{asset_id}_{filename}.{extension}\"\n    directory_fmt = (\"{category}\", \"Challenges\",\n                     \"{challenge[id]} - {challenge[title]}\")\n    archive_fmt = \"c_{challenge[id]}_{asset_id}\"\n    pattern = (r\"(?:https?://)?(?:www\\.)?artstation\\.com\"\n               r\"/c(?:hallenges|ontests)/[^/?#]+/c(?:ategori|halleng)es/(\\d+)\"\n               r\"/?(?:\\?sorting=([a-z]+))?\")\n    example = \"https://www.artstation.com/challenges/NAME/categories/12345\"\n\n    def __init__(self, match):\n        ArtstationExtractor.__init__(self, match)\n        self.challenge_id = match[1]\n        self.sorting = match[2] or \"popular\"\n\n    def items(self):\n        base = self.root + \"/api/v2/competition/\"\n        challenge_url = f\"{base}challenges/{self.challenge_id}.json\"\n        submission_url = base + \"submissions.json\"\n\n        challenge = self.request_json(challenge_url)\n        yield Message.Directory, \"\", {\"challenge\": challenge}\n\n        params = {\n            \"page\"        : 1,\n            \"per_page\"    : 50,\n            \"challenge_id\": self.challenge_id,\n            \"sort_by\"     : self.sorting,\n        }\n\n        for submission in self._pagination(submission_url, params):\n            update_url = (f\"{base}submissions/{submission['id']}\"\n                          f\"/submission_updates.json\")\n            params = {\"page\": 1, \"per_page\": 50}\n            for update in self._pagination(update_url, params=params):\n                update[\"challenge\"] = challenge\n                for url in util.unique_sequence(text.extract_iter(\n                        update[\"body\"], ' href=\"', '\"')):\n                    update[\"asset_id\"] = self._id_from_url(url)\n                    text.nameext_from_url(url, update)\n                    yield Message.Url, self._no_cache(url), update\n\n    def _id_from_url(self, url):\n        \"\"\"Get an image's submission ID from its URL\"\"\"\n        parts = url.split(\"/\")\n        return text.parse_int(\"\".join(parts[7:10]))\n\n\nclass ArtstationSearchExtractor(ArtstationExtractor):\n    \"\"\"Extractor for artstation search results\"\"\"\n    subcategory = \"search\"\n    directory_fmt = (\"{category}\", \"Searches\", \"{search[query]}\")\n    archive_fmt = \"s_{search[query]}_{asset[id]}\"\n    pattern = (r\"(?:https?://)?(?:\\w+\\.)?artstation\\.com\"\n               r\"/search/?\\?([^#]+)\")\n    example = \"https://www.artstation.com/search?query=QUERY\"\n\n    def __init__(self, match):\n        ArtstationExtractor.__init__(self, match)\n        self.params = query = text.parse_query(match[1])\n        self.query = text.unquote(query.get(\"query\") or query.get(\"q\", \"\"))\n        self.sorting = query.get(\"sort_by\", \"relevance\").lower()\n        self.tags = query.get(\"tags\", \"\").split(\",\")\n\n    def metadata(self):\n        return {\"search\": {\n            \"query\"  : self.query,\n            \"sorting\": self.sorting,\n            \"tags\"   : self.tags,\n        }}\n\n    def projects(self):\n        filters = []\n        for key, value in self.params.items():\n            if key.endswith(\"_ids\") or key == \"tags\":\n                filters.append({\n                    \"field\" : key,\n                    \"method\": \"include\",\n                    \"value\" : value.split(\",\"),\n                })\n\n        url = self.root + \"/api/v2/search/projects.json\"\n        data = {\n            \"query\"            : self.query,\n            \"page\"             : None,\n            \"per_page\"         : 50,\n            \"sorting\"          : self.sorting,\n            \"pro_first\"        : (\"1\" if self.config(\"pro-first\", True) else\n                                  \"0\"),\n            \"filters\"          : filters,\n            \"additional_fields\": (),\n        }\n        return self._pagination(url, json=data)\n\n\nclass ArtstationArtworkExtractor(ArtstationExtractor):\n    \"\"\"Extractor for projects on artstation's artwork page\"\"\"\n    subcategory = \"artwork\"\n    directory_fmt = (\"{category}\", \"Artworks\", \"{artwork[sorting]!c}\")\n    archive_fmt = \"A_{asset[id]}\"\n    pattern = (r\"(?:https?://)?(?:\\w+\\.)?artstation\\.com\"\n               r\"/artwork/?\\?([^#]+)\")\n    example = \"https://www.artstation.com/artwork?sorting=SORT\"\n\n    def __init__(self, match):\n        ArtstationExtractor.__init__(self, match)\n        self.query = text.parse_query(match[1])\n\n    def metadata(self):\n        return {\"artwork\": self.query}\n\n    def projects(self):\n        url = self.root + \"/projects.json\"\n        return self._pagination(url, self.query.copy())\n\n\nclass ArtstationImageExtractor(ArtstationExtractor):\n    \"\"\"Extractor for images from a single artstation project\"\"\"\n    subcategory = \"image\"\n    pattern = (r\"(?:https?://)?(?:\"\n               r\"(?:[\\w-]+\\.)?artstation\\.com/(?:artwork|projects|search)\"\n               r\"|artstn\\.co/p)/(\\w+)\")\n    example = \"https://www.artstation.com/artwork/abcde\"\n\n    def __init__(self, match):\n        ArtstationExtractor.__init__(self, match)\n        self.project_id = match[1]\n        self.assets = None\n\n    def metadata(self):\n        self.assets = list(ArtstationExtractor.get_project_assets(\n            self, self.project_id))\n        try:\n            self.user = self.assets[0][\"user\"][\"username\"]\n        except IndexError:\n            self.user = \"\"\n        return ArtstationExtractor.metadata(self)\n\n    def projects(self):\n        return ({\"hash_id\": self.project_id},)\n\n    def get_project_assets(self, project_id):\n        return self.assets\n\n\nclass ArtstationFollowingExtractor(ArtstationExtractor):\n    \"\"\"Extractor for a user's followed users\"\"\"\n    subcategory = \"following\"\n    pattern = (r\"(?:https?://)?(?:www\\.)?artstation\\.com\"\n               r\"/(?!artwork|projects|search)([^/?#]+)/following\")\n    example = \"https://www.artstation.com/USER/following\"\n\n    def items(self):\n        url = f\"{self.root}/users/{self.user}/following.json\"\n        for user in self._pagination(url):\n            url = f\"{self.root}/{user['username']}\"\n            user[\"_extractor\"] = ArtstationUserExtractor\n            yield Message.Queue, url, user\n"
  },
  {
    "path": "gallery_dl/extractor/aryion.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2020-2026 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://aryion.com/\"\"\"\n\nfrom .common import Extractor, Message\nfrom .. import text, util, dt\nfrom email.utils import parsedate_tz\n\nBASE_PATTERN = r\"(?:https?://)?(?:www\\.)?aryion\\.com/g4\"\n\n\nclass AryionExtractor(Extractor):\n    \"\"\"Base class for aryion extractors\"\"\"\n    category = \"aryion\"\n    directory_fmt = (\"{category}\", \"{user!l}\", \"{path:I}\")\n    filename_fmt = \"{id} {title}.{extension}\"\n    archive_fmt = \"{id}\"\n    cookies_domain = \".aryion.com\"\n    cookies_names = (\"phpbb3_rl7a3_sid\",)\n    root = \"https://aryion.com\"\n\n    def __init__(self, match):\n        Extractor.__init__(self, match)\n        self.user = match[1]\n        self.recursive = True\n\n    def login(self):\n        if self.cookies_check(self.cookies_names):\n            return\n\n        username, password = self._get_auth_info()\n        if username:\n            return self.cookies_update(self.cache(\n                self._login_impl, username, password,\n                _exp=14*86400, _mem=False))\n\n    def _login_impl(self, username, password):\n        self.log.info(\"Logging in as %s\", username)\n\n        url = self.root + \"/forum/ucp.php?mode=login\"\n        data = {\n            \"username\": username,\n            \"password\": password,\n            \"login\": \"Login\",\n        }\n\n        response = self.request(url, method=\"POST\", data=data)\n        if b\"You have been successfully logged in.\" not in response.content:\n            raise self.exc.AuthenticationError()\n        return {c: response.cookies[c] for c in self.cookies_names}\n\n    def items(self):\n        self.login()\n        data = self.metadata()\n\n        for post_id in self.posts():\n            if post := self._parse_post(post_id):\n                if data:\n                    post.update(data)\n                yield Message.Directory, \"\", post\n                yield Message.Url, post[\"url\"], post\n            elif post is False and self.recursive:\n                base = self.root + \"/g4/view/\"\n                data = {\"_extractor\": AryionPostExtractor}\n                for post_id in self._pagination_params(base + post_id):\n                    yield Message.Queue, base + post_id, data\n\n    def posts(self):\n        \"\"\"Yield relevant post IDs\"\"\"\n\n    def metadata(self):\n        \"\"\"Return general metadata\"\"\"\n\n    def _pagination_params(self, url, params=None, needle=None, quote=\"'\"):\n        if params is None:\n            params = {\"p\": 1}\n        else:\n            params[\"p\"] = text.parse_int(params.get(\"p\"), 1)\n\n        if needle is None:\n            needle = \"class='gallery-item' id=\" + quote\n\n        while True:\n            page = self.request(url, params=params).text\n\n            cnt = 0\n            for post_id in text.extract_iter(page, needle, quote):\n                cnt += 1\n                yield post_id\n\n            if cnt < 40 and \">Next &gt;&gt;<\" not in page:\n                return\n            params[\"p\"] += 1\n\n    def _pagination_next(self, url):\n        while True:\n            page = self.request(url).text\n            yield from text.extract_iter(page, \"thumb' href='/g4/view/\", \"'\")\n\n            pos = page.find(\"Next &gt;&gt;\")\n            if pos < 0:\n                return\n            url = self.root + text.rextr(page, \"href='\", \"'\", pos)\n\n    def _pagination_folders(self, url, folder=None, seen=None):\n        if folder is None:\n            self.kwdict[\"folder\"] = \"\"\n        else:\n            url = f\"{url}/{folder}\"\n            self.kwdict[\"folder\"] = folder = text.unquote(folder)\n            self.log.debug(\"Descending into folder '%s'\", folder)\n\n        params = {\"p\": 1}\n        while True:\n            page = self.request(url, params=params).text\n\n            cnt = 0\n            for item in text.extract_iter(\n                    page, \"<li class='gallery-item\", \"</li>\"):\n                cnt += 1\n                if text.extr(item, 'data-item-type=\"', '\"') == \"Folders\":\n                    folder = text.extr(item, \"href='\", \"'\").rpartition(\"/\")[2]\n                    if seen is None:\n                        seen = set()\n                    if folder not in seen:\n                        seen.add(folder)\n                        if self.recursive:\n                            yield from self._pagination_folders(\n                                url, folder, seen)\n                        else:\n                            self.log.debug(\"Skipping folder '%s'\", folder)\n                else:\n                    yield text.extr(item, \"data-item-id='\", \"'\")\n\n            if cnt < 40 and \">Next &gt;&gt;<\" not in page:\n                break\n            params[\"p\"] += 1\n\n        self.kwdict[\"folder\"] = \"\"\n\n    def _parse_post(self, post_id):\n        url = f\"{self.root}/g4/data.php?id={post_id}\"\n        with self.request(url, method=\"HEAD\", fatal=False) as response:\n\n            if response.status_code >= 400:\n                self.log.warning(\n                    \"Unable to fetch post %s ('%s %s')\",\n                    post_id, response.status_code, response.reason)\n                return None\n            headers = response.headers\n\n            # folder\n            if headers[\"content-type\"] in {\n                \"application/x-folder\",\n                \"application/x-comic-folder\",\n                \"application/x-comic-folder-nomerge\",\n            }:\n                return False\n\n            # get filename from 'Content-Disposition' header\n            cdis = headers[\"content-disposition\"]\n            fname, _, ext = text.extr(cdis, 'filename=\"', '\"').rpartition(\".\")\n            if not fname:\n                fname, ext = ext, fname\n\n            # get file size from 'Content-Length' header\n            clen = headers.get(\"content-length\")\n\n            # fix 'Last-Modified' header\n            lmod = headers[\"last-modified\"]\n            if lmod[22] != \":\":\n                lmod = f\"{lmod[:22]}:{lmod[22:24]} GMT\"\n\n        post_url = f\"{self.root}/g4/view/{post_id}\"\n        extr = text.extract_from(self.request(post_url).text)\n\n        title, _, artist = text.unescape(extr(\n            \"<title>g4 :: \", \"<\")).rpartition(\" by \")\n\n        return {\n            \"id\"    : text.parse_int(post_id),\n            \"url\"   : url,\n            \"user\"  : self.user or artist,\n            \"title\" : title,\n            \"artist\": artist,\n            \"description\": text.unescape(extr(\n                'property=\"og:description\" content=\"', '\"')),\n            \"path\"  : text.split_html(extr(\n                \"cookiecrumb'>\", '</span'))[4:-1:2],\n            \"date\"  : dt.datetime(*parsedate_tz(lmod)[:6]),\n            \"size\"  : text.parse_int(clen),\n            \"views\" : text.parse_int(extr(\"Views</b>:\", \"<\").replace(\",\", \"\")),\n            \"width\" : text.parse_int(extr(\"Resolution</b>:\", \"x\")),\n            \"height\": text.parse_int(extr(\"\", \"<\")),\n            \"comments\" : text.parse_int(extr(\"Comments</b>:\", \"<\")),\n            \"favorites\": text.parse_int(extr(\"Favorites</b>:\", \"<\")),\n            \"tags\"     : text.split_html(extr(\"class='taglist'>\", \"</span>\")),\n            \"filename\" : fname,\n            \"extension\": ext,\n            \"_http_lastmodified\": lmod,\n        }\n\n\nclass AryionGalleryExtractor(AryionExtractor):\n    \"\"\"Extractor for a user's gallery on eka's portal\"\"\"\n    subcategory = \"gallery\"\n    categorytransfer = True\n    pattern = BASE_PATTERN + r\"/(?:gallery/|user/|latest.php\\?name=)([^/?#]+)\"\n    example = \"https://aryion.com/g4/gallery/USER\"\n\n    def _init(self):\n        self.offset = 0\n        self.recursive = self.config(\"recursive\", True)\n\n    def skip_files(self, num):\n        if self.recursive:\n            return 0\n        self.offset += num\n        return num\n\n    def posts(self):\n        if self.recursive:\n            url = f\"{self.root}/g4/gallery/{self.user}\"\n            return self._pagination_params(url)\n        else:\n            url = f\"{self.root}/g4/latest.php?name={self.user}\"\n            return util.advance(self._pagination_next(url), self.offset)\n\n\nclass AryionFavoriteExtractor(AryionExtractor):\n    \"\"\"Extractor for a user's favorites gallery\"\"\"\n    subcategory = \"favorite\"\n    directory_fmt = (\"{category}\", \"{user!l}\", \"favorites\", \"{folder}\")\n    archive_fmt = \"f_{user}_{id}\"\n    pattern = BASE_PATTERN + r\"/favorites/([^/?#]+)(?:/([^?#]+))?\"\n    example = \"https://aryion.com/g4/favorites/USER\"\n\n    def _init(self):\n        self.recursive = self.config(\"recursive\", True)\n\n    def posts(self):\n        url = f\"{self.root}/g4/favorites/{self.user}\"\n        return self._pagination_folders(url, self.groups[1])\n\n\nclass AryionWatchExtractor(AryionExtractor):\n    \"\"\"Extractor for your watched users and tags\"\"\"\n    subcategory = \"watch\"\n    directory_fmt = (\"{category}\", \"{user!l}\",)\n    pattern = BASE_PATTERN + r\"/messagepage\\.php()\"\n    example = \"https://aryion.com/g4/messagepage.php\"\n\n    def posts(self):\n        if not self.cookies_check(self.cookies_names):\n            raise self.exc.AuthRequired(\n                (\"username & password\", \"authenticated cookies\"),\n                \"watched Submissions\")\n        self.cookies.set(\"g4p_msgpage_style\", \"plain\", domain=\"aryion.com\")\n        url = self.root + \"/g4/messagepage.php\"\n        return self._pagination_params(url, None, 'data-item-id=\"', '\"')\n\n\nclass AryionTagExtractor(AryionExtractor):\n    \"\"\"Extractor for tag searches on eka's portal\"\"\"\n    subcategory = \"tag\"\n    directory_fmt = (\"{category}\", \"tags\", \"{search_tags}\")\n    archive_fmt = \"t_{search_tags}_{id}\"\n    pattern = BASE_PATTERN + r\"/tags\\.php\\?([^#]+)\"\n    example = \"https://aryion.com/g4/tags.php?tag=TAG\"\n\n    def _init(self):\n        self.params = text.parse_query(self.user)\n        self.user = None\n\n    def metadata(self):\n        return {\"search_tags\": self.params.get(\"tag\")}\n\n    def posts(self):\n        url = self.root + \"/g4/tags.php\"\n        return self._pagination_params(url, self.params)\n\n\nclass AryionSearchExtractor(AryionExtractor):\n    \"\"\"Extractor for searches on eka's portal\"\"\"\n    subcategory = \"search\"\n    directory_fmt = (\"{category}\", \"searches\", \"{search[prefix]}\"\n                     \"{search[q]|search[tags]|search[user]}\")\n    archive_fmt = (\"s_{search[prefix]}\"\n                   \"{search[q]|search[tags]|search[user]}_{id}\")\n    pattern = BASE_PATTERN + r\"/search\\.php\\?([^#]+)\"\n    example = \"https://aryion.com/g4/search.php?q=TEXT&tags=TAGS&user=USER\"\n\n    def metadata(self):\n        params = text.parse_query(self.user)\n        return {\"search\": {\n            **params,\n            \"prefix\": (\"\" if params.get(\"q\") else\n                       \"t_\" if params.get(\"tags\") else\n                       \"u_\" if params.get(\"user\") else \"\"),\n        }}\n\n    def posts(self):\n        url = f\"{self.root}/g4/search.php?{self.user}\"\n        return self._pagination_next(url)\n\n\nclass AryionPostExtractor(AryionExtractor):\n    \"\"\"Extractor for individual posts on eka's portal\"\"\"\n    subcategory = \"post\"\n    pattern = BASE_PATTERN + r\"/view/(\\d+)\"\n    example = \"https://aryion.com/g4/view/12345\"\n\n    def posts(self):\n        post_id, self.user = self.user, None\n        return (post_id,)\n"
  },
  {
    "path": "gallery_dl/extractor/audiochan.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2025 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://audiochan.com/\"\"\"\n\nfrom .common import Extractor, Message\nfrom .. import text\n\nBASE_PATTERN = r\"(?:https?://)?(?:www\\.)?audiochan\\.com\"\n\n\nclass AudiochanExtractor(Extractor):\n    \"\"\"Base class for audiochan extractors\"\"\"\n    category = \"audiochan\"\n    root = \"https://audiochan.com\"\n    root_api = \"https://api.audiochan.com\"\n    directory_fmt = (\"{category}\", \"{user[display_name]}\")\n    filename_fmt = \"{title} ({slug}).{extension}\"\n    archive_fmt = \"{audioFile[id]}\"\n\n    def _init(self):\n        self.user = False\n        self.headers_api = {\n            \"content-type\"   : \"application/json\",\n            \"Origin\"         : self.root,\n            \"Sec-Fetch-Dest\" : \"empty\",\n            \"Sec-Fetch-Mode\" : \"cors\",\n            \"Sec-Fetch-Site\" : \"same-site\",\n        }\n        self.headers_dl = {\n            \"Accept\": \"audio/webm,audio/ogg,audio/wav,audio/*;q=0.9,\"\n                      \"application/ogg;q=0.7,video/*;q=0.6,*/*;q=0.5\",\n            \"Sec-Fetch-Dest\" : \"audio\",\n            \"Sec-Fetch-Mode\" : \"no-cors\",\n            \"Sec-Fetch-Site\" : \"same-site\",\n            \"Accept-Encoding\": \"identity\",\n        }\n\n    def items(self):\n        for post in self.posts():\n            file = post[\"audioFile\"]\n\n            post[\"_http_headers\"] = self.headers_dl\n            post[\"date\"] = self.parse_datetime_iso(file[\"created_at\"])\n            post[\"date_updated\"] = self.parse_datetime_iso(file[\"updated_at\"])\n            post[\"description\"] = self._extract_description(\n                post[\"description\"])\n\n            tags = []\n            for tag in post[\"tags\"]:\n                if \"tag\" in tag:\n                    tag = tag[\"tag\"]\n                tags.append(f\"{tag['category']}:{tag['name']}\")\n            post[\"tags\"] = tags\n\n            if self.user:\n                post[\"user\"] = post[\"credits\"][0][\"user\"]\n\n            if not (url := file[\"url\"]):\n                post[\"_http_segmented\"] = 600000\n                url = file[\"stream_url\"]\n\n            yield Message.Directory, \"\", post\n            text.nameext_from_name(file[\"filename\"], post)\n            yield Message.Url, url, post\n\n    def request_api(self, endpoint, params=None):\n        url = self.root_api + endpoint\n        return self.request_json(url, params=params, headers=self.headers_api)\n\n    def _pagination(self, endpoint, params, key=None):\n        params[\"page\"] = 1\n        params[\"limit\"] = \"12\"\n\n        while True:\n            data = self.request_api(endpoint, params)\n            if key is not None:\n                data = data[key]\n\n            yield from data[\"data\"]\n\n            if not data[\"has_more\"]:\n                break\n            params[\"page\"] += 1\n\n    def _extract_description(self, description, texts=None):\n        if texts is None:\n            texts = []\n\n        if \"text\" in description:\n            texts.append(description[\"text\"])\n        elif \"content\" in description:\n            for desc in description[\"content\"]:\n                self._extract_description(desc, texts)\n\n        return texts\n\n\nclass AudiochanAudioExtractor(AudiochanExtractor):\n    subcategory = \"audio\"\n    pattern = BASE_PATTERN + r\"/a/([^/?#]+)\"\n    example = \"https://audiochan.com/a/SLUG\"\n\n    def posts(self):\n        self.user = True\n        audio = self.request_api(\"/audios/slug/\" + self.groups[0])\n        return (audio,)\n\n\nclass AudiochanUserExtractor(AudiochanExtractor):\n    subcategory = \"user\"\n    pattern = BASE_PATTERN + r\"/u/([^/?#]+)\"\n    example = \"https://audiochan.com/u/USER\"\n\n    def posts(self):\n        endpoint = \"/users/\" + self.groups[0]\n        self.kwdict[\"user\"] = self.request_api(endpoint)[\"data\"]\n\n        params = {\n            \"sfw_only\": \"false\",\n            \"sort\"    : \"new\",\n        }\n        return self._pagination(endpoint + \"/audios\", params)\n\n\nclass AudiochanCollectionExtractor(AudiochanExtractor):\n    subcategory = \"collection\"\n    pattern = BASE_PATTERN + r\"/c/([^/?#]+)\"\n    example = \"https://audiochan.com/c/SLUG\"\n\n    def posts(self):\n        slug = self.groups[0]\n        endpoint = \"/collections/\" + slug\n        self.kwdict[\"collection\"] = col = self.request_api(endpoint)\n        col.pop(\"audios\", None)\n        col.pop(\"items\", None)\n\n        endpoint = f\"/collections/slug/{slug}/items\"\n        return self._pagination(endpoint, {})\n\n\nclass AudiochanSearchExtractor(AudiochanExtractor):\n    subcategory = \"search\"\n    pattern = BASE_PATTERN + r\"/search/?\\?([^#]+)\"\n    example = \"https://audiochan.com/search?q=QUERY\"\n\n    def posts(self):\n        self.user = True\n        endpoint = \"/search\"\n        params = text.parse_query(self.groups[0])\n        params[\"sfw_only\"] = \"false\"\n        self.kwdict[\"search_tags\"] = params.get(\"q\")\n        return self._pagination(endpoint, params, \"audios\")\n"
  },
  {
    "path": "gallery_dl/extractor/bbc.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2021-2025 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://bbc.co.uk/\"\"\"\n\nfrom .common import GalleryExtractor, Extractor, Message\nfrom .. import text, util\n\nBASE_PATTERN = r\"(?:https?://)?(?:www\\.)?bbc\\.co\\.uk(/programmes/\"\n\n\nclass BbcGalleryExtractor(GalleryExtractor):\n    \"\"\"Extractor for a programme gallery on bbc.co.uk\"\"\"\n    category = \"bbc\"\n    root = \"https://www.bbc.co.uk\"\n    directory_fmt = (\"{category}\", \"{path:I}\")\n    filename_fmt = \"{num:>02}.{extension}\"\n    archive_fmt = \"{programme}_{num}\"\n    pattern = BASE_PATTERN + r\"[^/?#]+(?!/galleries)(?:/[^/?#]+)?)$\"\n    example = \"https://www.bbc.co.uk/programmes/PATH\"\n\n    def metadata(self, page):\n        data = self._extract_jsonld(page)\n\n        return {\n            \"title\": text.unescape(text.extr(\n                page, \"<h1>\", \"</h1>\").rpartition(\"</span>\")[2]),\n            \"description\": text.unescape(text.extr(\n                page, 'property=\"og:description\" content=\"', '\"')),\n            \"programme\": self.page_url.split(\"/\")[4],\n            \"path\": list(util.unique_sequence(\n                element[\"name\"]\n                for element in data[\"itemListElement\"]\n            )),\n        }\n\n    def images(self, page):\n        width = self.config(\"width\")\n        width = width - width % 16 if width else 1920\n        dimensions = f\"/{width}xn/\"\n\n        results = []\n        for img in text.extract_iter(page, 'class=\"gallery__thumbnail', \">\"):\n            src = text.extr(img, 'data-image-src=\"', '\"')\n            results.append((\n                src.replace(\"/320x180_b/\", dimensions),\n                {\n                    \"title_image\": text.unescape(text.extr(\n                        img, 'data-gallery-title=\"', '\"')),\n                    \"synopsis\": text.unescape(text.extr(\n                        img, 'data-gallery-synopsis=\"', '\"')),\n                    \"_fallback\": self._fallback_urls(src, width),\n                },\n            ))\n        return results\n\n    def _fallback_urls(self, src, max_width):\n        front, _, back = src.partition(\"/320x180_b/\")\n        for width in (1920, 1600, 1280, 976):\n            if width < max_width:\n                yield f\"{front}/{width}xn/{back}\"\n\n\nclass BbcProgrammeExtractor(Extractor):\n    \"\"\"Extractor for all galleries of a bbc programme\"\"\"\n    category = \"bbc\"\n    subcategory = \"programme\"\n    root = \"https://www.bbc.co.uk\"\n    pattern = BASE_PATTERN + r\"[^/?#]+/galleries)(?:/?\\?page=(\\d+))?\"\n    example = \"https://www.bbc.co.uk/programmes/ID/galleries\"\n\n    def items(self):\n        path, pnum = self.groups\n        data = {\"_extractor\": BbcGalleryExtractor}\n        params = {\"page\": text.parse_int(pnum, 1)}\n        galleries_url = self.root + path\n\n        while True:\n            page = self.request(galleries_url, params=params).text\n            for programme_id in text.extract_iter(\n                    page, '<a href=\"https://www.bbc.co.uk/programmes/', '\"'):\n                url = \"https://www.bbc.co.uk/programmes/\" + programme_id\n                yield Message.Queue, url, data\n            if 'rel=\"next\"' not in page:\n                return\n            params[\"page\"] += 1\n"
  },
  {
    "path": "gallery_dl/extractor/behance.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2018-2026 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://www.behance.net/\"\"\"\n\nfrom .common import Extractor, Message\nfrom .. import text, util\n\n\nclass BehanceExtractor(Extractor):\n    \"\"\"Base class for behance extractors\"\"\"\n    category = \"behance\"\n    root = \"https://www.behance.net\"\n    request_interval = (2.0, 4.0)\n    browser = \"firefox\"\n    tls12 = False\n\n    def _init(self):\n        self._bcp = self.cookies.get(\"bcp\", domain=\"www.behance.net\")\n        if not self._bcp:\n            self._bcp = \"4c34489d-914c-46cd-b44c-dfd0e661136d\"\n            self.cookies.set(\"bcp\", self._bcp, domain=\"www.behance.net\")\n\n    def items(self):\n        for gallery in self.galleries():\n            gallery[\"_extractor\"] = BehanceGalleryExtractor\n            yield Message.Queue, gallery[\"url\"], self._update(gallery)\n\n    def galleries(self):\n        \"\"\"Return all relevant gallery URLs\"\"\"\n\n    def _request_graphql(self, endpoint, variables):\n        url = self.root + \"/v3/graphql\"\n        headers = {\n            \"Origin\": self.root,\n            \"X-BCP\" : self._bcp,\n            \"X-Requested-With\": \"XMLHttpRequest\",\n        }\n        data = {\n            \"query\"    : self.utils(\"graphql\", endpoint),\n            \"variables\": variables,\n        }\n\n        return self.request_json(\n            url, method=\"POST\", headers=headers, json=data)[\"data\"]\n\n    def _update(self, data):\n        # compress data to simple lists\n        if (fields := data.get(\"fields\")) and isinstance(fields[0], dict):\n            data[\"fields\"] = [\n                field.get(\"name\") or field.get(\"label\")\n                for field in fields\n            ]\n\n        data[\"owners\"] = [\n            owner.get(\"display_name\") or owner.get(\"displayName\")\n            for owner in data[\"owners\"]\n        ]\n\n        tags = data.get(\"tags\") or ()\n        if tags and isinstance(tags[0], dict):\n            tags = [tag[\"title\"] for tag in tags]\n        data[\"tags\"] = tags\n\n        data[\"date\"] = self.parse_timestamp(\n            data.get(\"publishedOn\") or data.get(\"conceived_on\") or 0)\n\n        if creator := data.get(\"creator\"):\n            creator[\"name\"] = creator[\"url\"].rpartition(\"/\")[2]\n\n        # backwards compatibility\n        data[\"gallery_id\"] = data[\"id\"]\n        data[\"title\"] = data[\"name\"]\n        data[\"user\"] = \", \".join(data[\"owners\"])\n\n        return data\n\n\nclass BehanceGalleryExtractor(BehanceExtractor):\n    \"\"\"Extractor for image galleries from www.behance.net\"\"\"\n    subcategory = \"gallery\"\n    directory_fmt = (\"{category}\", \"{owners:J, }\", \"{id} {name}\")\n    filename_fmt = \"{category}_{id}_{num:>02}.{extension}\"\n    archive_fmt = \"{id}_{num}\"\n    pattern = r\"(?:https?://)?(?:www\\.)?behance\\.net/gallery/(\\d+)\"\n    example = \"https://www.behance.net/gallery/12345/TITLE\"\n\n    def __init__(self, match):\n        BehanceExtractor.__init__(self, match)\n        self.gallery_id = match[1]\n\n    def _init(self):\n        BehanceExtractor._init(self)\n\n        if modules := self.config(\"modules\"):\n            if isinstance(modules, str):\n                modules = modules.split(\",\")\n            self.modules = set(modules)\n        else:\n            self.modules = {\"image\", \"video\", \"mediacollection\", \"embed\"}\n\n    def items(self):\n        data = self.get_gallery_data()\n        imgs = self.get_images(data)\n        data[\"count\"] = len(imgs)\n\n        yield Message.Directory, \"\", data\n        for data[\"num\"], (url, module) in enumerate(imgs, 1):\n            data[\"module\"] = module\n            data[\"extension\"] = (module.get(\"extension\") or\n                                 text.ext_from_url(url))\n            yield Message.Url, url, data\n\n    def get_gallery_data(self):\n        \"\"\"Collect gallery info dict\"\"\"\n        url = f\"{self.root}/gallery/{self.gallery_id}/a\"\n        cookies = {\n            \"gk_suid\": \"14118261\",\n            \"gki\": \"feature_3_in_1_checkout_test:false,hire_browse_get_quote_c\"\n                   \"ta_ab_test:false,feature_hire_dashboard_services_ab_test:f\"\n                   \"alse,feature_show_details_jobs_row_ab_test:false,feature_a\"\n                   \"i_freelance_project_create_flow:false,\",\n            \"ilo0\": \"true\",\n            \"originalReferrer\": \"\",\n        }\n        page = self.request(url, cookies=cookies).text\n\n        data = util.json_loads(text.extr(\n            page, 'id=\"beconfig-store_state\">', '</script>'))\n        return self._update(data[\"project\"][\"project\"])\n\n    def get_images(self, data):\n        \"\"\"Extract image results from an API response\"\"\"\n        if not data[\"modules\"]:\n            access = data.get(\"matureAccess\")\n            if access == \"logged-out\":\n                raise self.exc.AuthorizationError(\n                    \"Mature content galleries require logged-in cookies\")\n            if access == \"restricted-safe\":\n                raise self.exc.AuthorizationError(\n                    \"Mature content blocked in account settings\")\n            if access and access != \"allowed\":\n                raise self.exc.AuthorizationError()\n            return ()\n\n        results = []\n        for module in data[\"modules\"]:\n            mtype = module[\"__typename\"][:-6].lower()\n\n            if mtype not in self.modules:\n                self.log.debug(\"Skipping '%s' module\", mtype)\n                continue\n\n            if mtype == \"image\":\n                sizes = {\n                    size[\"url\"].rsplit(\"/\", 2)[1]: size\n                    for size in module[\"imageSizes\"][\"allAvailable\"]\n                }\n                size = (sizes.get(\"source\") or\n                        sizes.get(\"max_3840\") or\n                        sizes.get(\"fs\") or\n                        sizes.get(\"hd\") or\n                        sizes.get(\"disp\"))\n                results.append((size[\"url\"], module))\n\n            elif mtype == \"video\":\n                try:\n                    url = text.extr(module[\"embed\"], 'src=\"', '\"')\n                    page = self.request(text.unescape(url)).text\n\n                    url = text.extr(page, '<source src=\"', '\"')\n                    if text.ext_from_url(url) == \"m3u8\":\n                        url = \"ytdl:\" + url\n                        module[\"_ytdl_manifest\"] = \"hls\"\n                        module[\"extension\"] = \"mp4\"\n                    results.append((url, module))\n                    continue\n                except Exception as exc:\n                    self.log.debug(\"%s: %s\", exc.__class__.__name__, exc)\n\n                try:\n                    renditions = module[\"videoData\"][\"renditions\"]\n                except Exception:\n                    self.log.warning(\"No download URLs for video %s\",\n                                     module.get(\"id\") or \"???\")\n                    continue\n\n                try:\n                    url = [\n                        r[\"url\"] for r in renditions\n                        if text.ext_from_url(r[\"url\"]) != \"m3u8\"\n                    ][-1]\n                except Exception as exc:\n                    self.log.debug(\"%s: %s\", exc.__class__.__name__, exc)\n                    url = \"ytdl:\" + renditions[-1][\"url\"]\n\n                results.append((url, module))\n\n            elif mtype == \"mediacollection\":\n                for component in module[\"components\"]:\n                    for size in component[\"imageSizes\"].values():\n                        if size:\n                            parts = size[\"url\"].split(\"/\")\n                            parts[4] = \"source\"\n                            results.append((\"/\".join(parts), module))\n                            break\n\n            elif mtype == \"embed\":\n                if embed := (module.get(\"originalEmbed\") or\n                             module.get(\"fluidEmbed\")):\n                    embed = text.unescape(text.extr(embed, 'src=\"', '\"'))\n                    module[\"extension\"] = \"mp4\"\n                    results.append((\"ytdl:\" + embed, module))\n\n            elif mtype == \"text\":\n                module[\"extension\"] = \"txt\"\n                results.append((\"text:\" + module[\"text\"], module))\n\n        return results\n\n\nclass BehanceUserExtractor(BehanceExtractor):\n    \"\"\"Extractor for a user's galleries from www.behance.net\"\"\"\n    subcategory = \"user\"\n    categorytransfer = True\n    pattern = r\"(?:https?://)?(?:www\\.)?behance\\.net/([^/?#]+)/?$\"\n    example = \"https://www.behance.net/USER\"\n\n    def __init__(self, match):\n        BehanceExtractor.__init__(self, match)\n        self.user = match[1]\n\n    def galleries(self):\n        endpoint = \"GetProfileProjects\"\n        variables = {\n            \"username\": self.user,\n            \"after\"   : \"MAo=\",  # \"0\" in base64\n        }\n\n        while True:\n            data = self._request_graphql(endpoint, variables)\n            items = data[\"user\"][\"profileProjects\"]\n            yield from items[\"nodes\"]\n\n            if not items[\"pageInfo\"][\"hasNextPage\"]:\n                return\n            variables[\"after\"] = items[\"pageInfo\"][\"endCursor\"]\n\n\nclass BehanceCollectionExtractor(BehanceExtractor):\n    \"\"\"Extractor for a collection's galleries from www.behance.net\"\"\"\n    subcategory = \"collection\"\n    categorytransfer = True\n    pattern = r\"(?:https?://)?(?:www\\.)?behance\\.net/collection/(\\d+)\"\n    example = \"https://www.behance.net/collection/12345/TITLE\"\n\n    def __init__(self, match):\n        BehanceExtractor.__init__(self, match)\n        self.collection_id = match[1]\n\n    def galleries(self):\n        endpoint = \"GetMoodboardItemsAndRecommendations\"\n        variables = {\n            \"afterItem\": \"MAo=\",  # \"0\" in base64\n            \"firstItem\": 40,\n            \"id\"       : int(self.collection_id),\n            \"shouldGetItems\"          : True,\n            \"shouldGetMoodboardFields\": False,\n            \"shouldGetRecommendations\": False,\n        }\n\n        while True:\n            data = self._request_graphql(endpoint, variables)\n            items = data[\"moodboard\"][\"items\"]\n\n            for node in items[\"nodes\"]:\n                yield node[\"entity\"]\n\n            if not items[\"pageInfo\"][\"hasNextPage\"]:\n                return\n            variables[\"afterItem\"] = items[\"pageInfo\"][\"endCursor\"]\n"
  },
  {
    "path": "gallery_dl/extractor/bellazon.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2025-2026 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://www.bellazon.com/\"\"\"\n\nfrom .common import Extractor, Message\nfrom .. import text\n\nBASE_PATTERN = r\"(?:https?://)?(?:www\\.)?bellazon\\.com/main\"\n\n\nclass BellazonExtractor(Extractor):\n    \"\"\"Base class for bellazon extractors\"\"\"\n    category = \"bellazon\"\n    root = \"https://www.bellazon.com/main\"\n    directory_fmt = (\"{category}\", \"{thread[section]}\",\n                     \"{thread[title]} ({thread[id]})\")\n    filename_fmt = \"{post[id]}_{num:>02}_{id}_{filename}.{extension}\"\n    archive_fmt = \"{post[id]}/{id}_{filename}\"\n\n    def items(self):\n        native = (self.root + \"/\", self.root[6:] + \"/\")\n        quotes = self.config(\"quoted\", False)\n        extract_urls = text.re(\n            r'(?s)<('\n            r'(?:video .*?<source [^>]*?src|a [^>]*?href)=\"([^\"]+).*?</a>'\n            r'|img [^>]*?src=\"([^\"]+)\"[^>]*>'\n            r')'\n        ).findall\n\n        for post in self.posts():\n            if quotes:\n                urls = extract_urls(post[\"content\"])\n            else:\n                urls = extract_urls(self._remove_quotes(post[\"content\"]))\n\n            data = {\"post\": post}\n            post[\"count\"] = data[\"count\"] = len(urls)\n\n            yield Message.Directory, \"\", data\n            data[\"num\"] = data[\"num_internal\"] = data[\"num_external\"] = 0\n            for info, url, url_img in urls:\n                if url_img:\n                    url = text.unescape(\n                        text.extr(info, 'data-full-image=\"', '\"') or url_img)\n                else:\n                    url = text.unescape(url)\n\n                if url.startswith(native):\n                    if (\n                        \"/uploads/emoticons/\" in url or\n                        \"/public/style_\" in url or\n                        \"/profile/\" in url or\n                        \"/topic/\" in url\n                    ):\n                        continue\n                    data[\"num\"] += 1\n                    data[\"num_internal\"] += 1\n                    if not (alt := text.extr(info, ' alt=\"', '\"')) or (\n                            alt.startswith(\"post-\") and \"_thumb.\" in alt):\n                        dc = text.nameext_from_url(url, data.copy())\n                    else:\n                        dc = data.copy()\n                        dc[\"name\"] = name = text.unescape(alt)\n                        dc[\"filename\"] = name.partition(\".\")[0]\n\n                    dc[\"id\"] = text.extr(info, 'data-fileid=\"', '\"')\n                    if ext := text.extr(info, 'data-fileext=\"', '\"'):\n                        dc[\"extension\"] = ext\n                    elif (\"/core/interface/file/attachment.php\" in url or\n                          \"/main/index.php?\" in url):\n                        if not dc[\"id\"]:\n                            params = text.parse_query(url[url.find(\"?\")+1:])\n                            dc[\"id\"] = (params.get(\"id\") or\n                                        params.get(\"attach_id\"))\n                        if name := text.extr(info, \">\", \"<\").strip():\n                            dc[\"name\"] = name = text.unescape(name)\n                            text.nameext_from_name(name, dc)\n                    else:\n                        dc[\"extension\"] = text.ext_from_url(url)\n\n                    if url[0] == \"/\":\n                        url = \"https:\" + url\n                    yield Message.Url, url, dc\n\n                else:\n                    data[\"num\"] += 1\n                    data[\"num_external\"] += 1\n                    yield Message.Queue, url, data\n\n    def _pagination(self, base, pnum=None):\n        base = self.root + base\n\n        if pnum is None:\n            url = base + \"/\"\n            pnum = 1\n        else:\n            url = f\"{base}/page/{pnum}/\"\n            pnum = None\n\n        while True:\n            page = self.request(url).text\n\n            yield page\n\n            if pnum is None or ' rel=\"next\" ' not in page or text.extr(\n                    page, \" rel=\\\"next\\\" data-page='\", \"'\") == str(pnum):\n                return\n            pnum += 1\n            url = f\"{base}/page/{pnum}/\"\n\n    def _pagination_reverse(self, base, pnum=None):\n        base = self.root + base\n\n        url = f\"{base}/page/{'9999' if pnum is None else pnum}/\"\n        with self.request(url) as response:\n            parts = response.url.rsplit(\"/\", 3)\n            pnum = text.parse_int(parts[2]) if parts[1] == \"page\" else 1\n            page = response.text\n\n        while True:\n            yield page\n\n            pnum -= 1\n            if pnum > 1:\n                url = f\"{base}/page/{pnum}/\"\n            elif pnum == 1:\n                url = base + \"/\"\n            else:\n                return\n\n            page = self.request(url).text\n\n    def _parse_thread(self, page):\n        schema = self._extract_jsonld(page)\n        author = schema[\"author\"]\n        stats = schema[\"interactionStatistic\"]\n        url_t = schema[\"url\"]\n        url_a = author.get(\"url\") or \"\"\n\n        path = text.split_html(text.extr(\n            page, '<nav class=\"ipsBreadcrumb', \"</nav>\"))[2:-1]\n\n        thread = {\n            \"url\"  : url_t,\n            \"path\" : path,\n            \"title\": schema[\"headline\"],\n            \"views\": stats[0][\"userInteractionCount\"],\n            \"posts\": stats[1][\"userInteractionCount\"],\n            \"date\" : self.parse_datetime_iso(schema[\"datePublished\"]),\n            \"date_updated\": self.parse_datetime_iso(schema[\"dateModified\"]),\n            \"description\" : text.unescape(schema[\"text\"]).strip(),\n            \"section\"     : path[-2],\n            \"author\"      : author[\"name\"],\n            \"author_url\"  : url_a,\n        }\n\n        thread[\"id\"], _, slug = \\\n            url_t.rsplit(\"/\", 2)[1].partition(\"-\")\n        thread[\"slug\"] = text.unquote(slug)\n\n        if url_a:\n            thread[\"author_id\"], _, thread[\"author_slug\"] = \\\n                url_a.rsplit(\"/\", 2)[1].partition(\"-\")\n        else:\n            thread[\"author_id\"] = thread[\"author_slug\"] = \"\"\n\n        return thread\n\n    def _parse_post(self, html):\n        extr = text.extract_from(html)\n\n        post = {\n            \"id\": extr('id=\"elComment_', '\"'),\n            \"author_url\": extr(\" href='\", \"'\"),\n            \"date\": self.parse_datetime_iso(extr(\"datetime='\", \"'\")),\n            \"content\": extr(\"<!-- Post content -->\", '<menu data-ips-hook='),\n        }\n\n        beg = post[\"content\"].find(\">\")\n        sig = post[\"content\"].rfind('<div data-role=\"memberSignature\"')\n        end = post[\"content\"].rfind(\"\\n\\t\\t</div>\", 0, sig+1 or None)\n        post[\"content\"] = post[\"content\"][beg+1:end+1].strip()\n\n        if url_a := post[\"author_url\"]:\n            post[\"author_id\"], _, post[\"author_slug\"] = \\\n                url_a.rsplit(\"/\", 2)[1].partition(\"-\")\n        else:\n            post[\"author_id\"] = post[\"author_slug\"] = \"\"\n\n        return post\n\n    def _remove_quotes(self, content):\n        while \"<blockquote\" in content:\n            beg = content.index(\"<blockquote\")\n            end = content.index(\"</blockquote\", beg)\n            for _ in range(content.count(\"<blockquote\", beg+11, end)):\n                end = content.index(\"</blockquote\", end+13)\n            content = content[:beg] + content[end+13:]\n        return content\n\n\nclass BellazonPostExtractor(BellazonExtractor):\n    subcategory = \"post\"\n    pattern = (BASE_PATTERN + r\"(/topic/\\d+-[^/?#]+(?:/page/\\d+)?)\"\n               r\"/?#(?:findC|c)omment-(\\d+)\")\n    example = \"https://www.bellazon.com/main/topic/123-SLUG/#findComment-12345\"\n\n    def posts(self):\n        path, post_id = self.groups\n        page = self.request(self.root + path).text\n\n        pos = page.find('id=\"elComment_' + post_id)\n        if pos < 0:\n            raise self.exc.NotFoundError(\"post\")\n        html = text.extract(page, \"<article \", \"</article>\", pos-100)[0]\n\n        self.kwdict[\"thread\"] = self._parse_thread(page)\n        return (self._parse_post(html),)\n\n\nclass BellazonThreadExtractor(BellazonExtractor):\n    subcategory = \"thread\"\n    pattern = BASE_PATTERN + r\"(/topic/\\d+-[^/?#]+)(?:/page/(\\d+))?\"\n    example = \"https://www.bellazon.com/main/topic/123-SLUG/\"\n\n    def posts(self):\n        if (order := self.config(\"order-posts\")) and \\\n                order[0] not in (\"d\", \"r\"):\n            pages = self._pagination(*self.groups)\n            reverse = False\n        else:\n            pages = self._pagination_reverse(*self.groups)\n            reverse = True\n\n        for page in pages:\n            if \"thread\" not in self.kwdict:\n                self.kwdict[\"thread\"] = self._parse_thread(page)\n            posts = text.extract_iter(page, \"<article \", \"</article>\")\n            if reverse:\n                posts = list(posts)\n                posts.reverse()\n            for html in posts:\n                yield self._parse_post(html)\n\n\nclass BellazonForumExtractor(BellazonExtractor):\n    subcategory = \"forum\"\n    pattern = BASE_PATTERN + r\"(/forum/\\d+-[^/?#]+)(?:/page/(\\d+))?\"\n    example = \"https://www.bellazon.com/main/forum/123-SLUG/\"\n\n    def items(self):\n        data = {\"_extractor\": BellazonThreadExtractor}\n        for page in self._pagination(*self.groups):\n            for row in text.extract_iter(\n                    page, '<li data-ips-hook=\"topicRow\"', \"</\"):\n                yield Message.Queue, text.extr(row, 'href=\"', '\"'), data\n"
  },
  {
    "path": "gallery_dl/extractor/bilibili.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://www.bilibili.com/\"\"\"\n\nfrom .common import Extractor, Message\nfrom .. import text, util\n\n\nclass BilibiliExtractor(Extractor):\n    \"\"\"Base class for bilibili extractors\"\"\"\n    category = \"bilibili\"\n    root = \"https://www.bilibili.com\"\n    request_interval = (3.0, 6.0)\n\n    def _init(self):\n        self.api = BilibiliAPI(self)\n\n    def items(self):\n        for article in self.articles():\n            article[\"_extractor\"] = BilibiliArticleExtractor\n            url = f\"{self.root}/opus/{article['opus_id']}\"\n            yield Message.Queue, url, article\n\n    def articles(self):\n        return ()\n\n\nclass BilibiliArticleExtractor(BilibiliExtractor):\n    \"\"\"Extractor for a bilibili article\"\"\"\n    subcategory = \"article\"\n    pattern = (r\"(?:https?://)?\"\n               r\"(?:t\\.bilibili\\.com|(?:www\\.)?bilibili.com/opus)/(\\d+)\")\n    example = \"https://www.bilibili.com/opus/12345\"\n    directory_fmt = (\"{category}\", \"{username}\")\n    filename_fmt = \"{id}_{num}{suffix}.{extension}\"\n    archive_fmt = \"{id}_{num}{suffix}\"\n\n    def items(self):\n        article_id = self.groups[0]\n        article = self.api.article(article_id)\n\n        # Flatten modules list\n        modules = {}\n        for module in article[\"detail\"][\"modules\"]:\n            if module[\"module_type\"] == \"MODULE_TYPE_BLOCKED\":\n                self.log.warning(\"%s: Blocked Article\\n%s\", article_id,\n                                 module[\"module_blocked\"].get(\"hint_message\"))\n            del module[\"module_type\"]\n            modules.update(module)\n        article[\"detail\"][\"modules\"] = modules\n\n        user = modules[\"module_author\"]\n        article[\"username\"] = user.get(\"name\")\n        article[\"user_id\"] = user.get(\"mid\")\n\n        pics = []\n\n        if \"module_top\" in modules:\n            try:\n                pics.extend(modules[\"module_top\"][\"display\"][\"album\"][\"pics\"])\n            except Exception:\n                pass\n\n        if \"module_content\" in modules:\n            for paragraph in modules[\"module_content\"][\"paragraphs\"]:\n                if \"pic\" not in paragraph:\n                    continue\n\n                try:\n                    pics.extend(paragraph[\"pic\"][\"pics\"])\n                except Exception:\n                    pass\n\n        article[\"count\"] = len(pics)\n        yield Message.Directory, \"\", article\n\n        livephoto = self.config(\"livephoto\", True)\n        article[\"suffix\"] = \"\"\n        for article[\"num\"], pic in enumerate(pics, 1):\n            url = pic[\"url\"]\n            article.update(pic)\n            yield Message.Url, url, text.nameext_from_url(url, article)\n\n            if livephoto and (url := pic.get(\"live_url\")):\n                article[\"suffix\"] = \"l\"\n                yield Message.Url, url, text.nameext_from_url(url, article)\n                article[\"suffix\"] = \"\"\n\n\nclass BilibiliUserArticlesExtractor(BilibiliExtractor):\n    \"\"\"Extractor for a bilibili user's articles\"\"\"\n    subcategory = \"user-articles\"\n    pattern = (r\"(?:https?://)?space\\.bilibili\\.com/(\\d+)\"\n               r\"/(?:article|upload/opus|dynamic)\")\n    example = \"https://space.bilibili.com/12345/article\"\n\n    def articles(self):\n        return self.api.user_articles(self.groups[0])\n\n\nclass BilibiliUserArticlesFavoriteExtractor(BilibiliExtractor):\n    subcategory = \"user-articles-favorite\"\n    pattern = (r\"(?:https?://)?space\\.bilibili\\.com\"\n               r\"/(\\d+)/favlist\\?fid=opus\")\n    example = \"https://space.bilibili.com/12345/favlist?fid=opus\"\n    _warning = True\n\n    def articles(self):\n        if self._warning:\n            if not self.cookies_check((\"SESSDATA\",)):\n                self.log.error(\"'SESSDATA' cookie required\")\n            BilibiliUserArticlesFavoriteExtractor._warning = False\n        return self.api.user_favlist()\n\n\nclass BilibiliAPI():\n    def __init__(self, extractor):\n        self.extractor = extractor\n        self.exc = extractor.exc\n\n    def _call(self, endpoint, params):\n        url = \"https://api.bilibili.com/x/polymer/web-dynamic/v1\" + endpoint\n        data = self.extractor.request_json(url, params=params)\n\n        if data[\"code\"]:\n            self.extractor.log.debug(\"Server response: %s\", data)\n            raise self.exc.AbortExtraction(\"API request failed\")\n\n        return data\n\n    def user_articles(self, user_id):\n        endpoint = \"/opus/feed/space\"\n        params = {\"host_mid\": user_id}\n\n        while True:\n            data = self._call(endpoint, params)\n\n            for item in data[\"data\"][\"items\"]:\n                params[\"offset\"] = item[\"opus_id\"]\n                yield item\n\n            if not data[\"data\"][\"has_more\"]:\n                break\n\n    def article(self, article_id):\n        url = \"https://www.bilibili.com/opus/\" + article_id\n\n        while True:\n            page = self.extractor.request(url).text\n            try:\n                return util.json_loads(text.extr(\n                    page, \"window.__INITIAL_STATE__=\", \"};\") + \"}\")\n            except Exception:\n                if \"window._riskdata_\" not in page:\n                    raise self.exc.AbortExtraction(\n                        article_id + \": Unable to extract INITIAL_STATE data\")\n            self.extractor.wait(seconds=300)\n\n    def user_favlist(self):\n        endpoint = \"/opus/feed/fav\"\n        params = {\"page\": 1, \"page_size\": 20}\n\n        while True:\n            data = self._call(endpoint, params)[\"data\"]\n\n            yield from data[\"items\"]\n\n            if not data.get(\"has_more\"):\n                break\n            params[\"page\"] += 1\n\n    def login_user_id(self):\n        url = \"https://api.bilibili.com/x/space/v2/myinfo\"\n        data = self.extractor.request_json(url)\n\n        if data[\"code\"] != 0:\n            self.extractor.log.debug(\"Server response: %s\", data)\n            raise self.exc.AbortExtraction(\n                \"API request failed. Are you logges in?\")\n        try:\n            return data[\"data\"][\"profile\"][\"mid\"]\n        except Exception:\n            raise self.exc.AbortExtraction(\"API request failed\")\n"
  },
  {
    "path": "gallery_dl/extractor/blogger.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2019-2025 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for Blogger blogs\"\"\"\n\nfrom .common import BaseExtractor, Message\nfrom .. import text, util\n\n\ndef original(url):\n    return (text.re(r\"(/|=)(?:[sw]\\d+|w\\d+-h\\d+)(?=/|$)\")\n            .sub(r\"\\1s0\", url)\n            .replace(\"http:\", \"https:\", 1))\n\n\nclass BloggerExtractor(BaseExtractor):\n    \"\"\"Base class for blogger extractors\"\"\"\n    basecategory = \"blogger\"\n    directory_fmt = (\"blogger\", \"{blog[name]}\",\n                     \"{post[date]:%Y-%m-%d} {post[title]}\")\n    filename_fmt = \"{num:>03}.{extension}\"\n    archive_fmt = \"{post[id]}_{num}\"\n\n    def _init(self):\n        self.api = BloggerAPI(self)\n        self.blog = self.root.rpartition(\"/\")[2]\n        self.videos = self.config(\"videos\", True)\n\n        if self.videos:\n            self.findall_video = text.re(\n                r\"\"\"src=[\"'](https?://www\\.blogger\\.com\"\"\"\n                r\"\"\"/video\\.g\\?token=[^\"']+)\"\"\").findall\n\n    def items(self):\n        blog = self.api.blog_by_url(\"http://\" + self.blog)\n        blog[\"pages\"] = blog[\"pages\"][\"totalItems\"]\n        blog[\"posts\"] = blog[\"posts\"][\"totalItems\"]\n        blog[\"date\"] = self.parse_datetime_iso(blog[\"published\"])\n        del blog[\"selfLink\"]\n\n        findall_image = text.re(\n            r'src=\"(https?://(?:'\n            r'blogger\\.googleusercontent\\.com/img|'\n            r'lh\\d+(?:-\\w+)?\\.googleusercontent\\.com|'\n            r'\\d+\\.bp\\.blogspot\\.com)/[^\"]+)').findall\n        metadata = self.metadata()\n\n        for post in self.posts(blog):\n            content = post[\"content\"]\n\n            files = findall_image(content)\n            for idx, url in enumerate(files):\n                files[idx] = original(url)\n\n            if self.videos and (\n                    'id=\"BLOG_video-' in content or\n                    'class=\"BLOG_video_' in content):\n                self._extract_videos(files, post)\n\n            post[\"author\"] = post[\"author\"][\"displayName\"]\n            post[\"replies\"] = post[\"replies\"][\"totalItems\"]\n            post[\"content\"] = text.remove_html(content)\n            post[\"date\"] = self.parse_datetime_iso(post[\"published\"])\n            del post[\"selfLink\"]\n            del post[\"blog\"]\n\n            data = {\"blog\": blog, \"post\": post}\n            if metadata:\n                data.update(metadata)\n            yield Message.Directory, \"\", data\n\n            for data[\"num\"], url in enumerate(files, 1):\n                data[\"url\"] = url\n                yield Message.Url, url, text.nameext_from_url(url, data)\n\n    def posts(self, blog):\n        \"\"\"Return an iterable with all relevant post objects\"\"\"\n\n    def metadata(self):\n        \"\"\"Return additional metadata\"\"\"\n\n    def _extract_videos(self, files, post):\n        url = f\"https://{self.blog}/feeds/posts/default/{post['id']}\"\n        params = {\n            \"alt\"          : \"json\",\n            \"v\"            : \"2\",\n            \"dynamicviews\" : \"1\",\n            \"rewriteforssl\": \"true\",\n        }\n\n        data = self.request_json(url, params=params)\n        html = data[\"entry\"][\"content\"][\"$t\"]\n\n        for url in self.findall_video(html):\n            page = self.request(url).text\n            video_config = util.json_loads(text.extr(\n                page, 'var VIDEO_CONFIG =', '\\n'))\n            files.append(max(\n                video_config[\"streams\"],\n                key=lambda x: x[\"format_id\"],\n            )[\"play_url\"])\n\n\nBASE_PATTERN = BloggerExtractor.update({\n    \"blogspot\": {\n        \"root\": None,\n        \"pattern\": r\"[\\w-]+\\.blogspot\\.com\",\n    },\n})\n\n\nclass BloggerPostExtractor(BloggerExtractor):\n    \"\"\"Extractor for a single blog post\"\"\"\n    subcategory = \"post\"\n    pattern = BASE_PATTERN + r\"(/\\d\\d\\d\\d/\\d\\d/[^/?#]+\\.html)\"\n    example = \"https://BLOG.blogspot.com/1970/01/TITLE.html\"\n\n    def posts(self, blog):\n        return (self.api.post_by_path(blog[\"id\"], self.groups[-1]),)\n\n\nclass BloggerBlogExtractor(BloggerExtractor):\n    \"\"\"Extractor for an entire Blogger blog\"\"\"\n    subcategory = \"blog\"\n    pattern = BASE_PATTERN + r\"/?$\"\n    example = \"https://BLOG.blogspot.com/\"\n\n    def posts(self, blog):\n        return self.api.blog_posts(blog[\"id\"])\n\n\nclass BloggerSearchExtractor(BloggerExtractor):\n    \"\"\"Extractor for Blogger search resuls\"\"\"\n    subcategory = \"search\"\n    pattern = BASE_PATTERN + r\"/search/?\\?q=([^&#]+)\"\n    example = \"https://BLOG.blogspot.com/search?q=QUERY\"\n\n    def metadata(self):\n        self.query = query = text.unquote(self.groups[-1])\n        return {\"query\": query}\n\n    def posts(self, blog):\n        return self.api.blog_search(blog[\"id\"], self.query)\n\n\nclass BloggerLabelExtractor(BloggerExtractor):\n    \"\"\"Extractor for Blogger posts by label\"\"\"\n    subcategory = \"label\"\n    pattern = BASE_PATTERN + r\"/search/label/([^/?#]+)\"\n    example = \"https://BLOG.blogspot.com/search/label/LABEL\"\n\n    def metadata(self):\n        self.label = label = text.unquote(self.groups[-1])\n        return {\"label\": label}\n\n    def posts(self, blog):\n        return self.api.blog_posts(blog[\"id\"], self.label)\n\n\nclass BloggerAPI():\n    \"\"\"Minimal interface for the Blogger API v3\n\n    https://developers.google.com/blogger\n    \"\"\"\n    API_KEY = \"AIzaSyCN9ax34oMMyM07g_M-5pjeDp_312eITK8\"\n\n    def __init__(self, extractor):\n        self.extractor = extractor\n        self.api_key = extractor.config(\"api-key\") or self.API_KEY\n\n    def blog_by_url(self, url):\n        return self._call(\"/blogs/byurl\", {\"url\": url}, \"blog\")\n\n    def blog_posts(self, blog_id, label=None):\n        endpoint = f\"/blogs/{blog_id}/posts\"\n        params = {\"labels\": label}\n        return self._pagination(endpoint, params)\n\n    def blog_search(self, blog_id, query):\n        endpoint = f\"/blogs/{blog_id}/posts/search\"\n        params = {\"q\": query}\n        return self._pagination(endpoint, params)\n\n    def post_by_path(self, blog_id, path):\n        endpoint = f\"/blogs/{blog_id}/posts/bypath\"\n        return self._call(endpoint, {\"path\": path}, \"post\")\n\n    def _call(self, endpoint, params, notfound=None):\n        url = \"https://www.googleapis.com/blogger/v3\" + endpoint\n        params[\"key\"] = self.api_key\n        return self.extractor.request_json(\n            url, params=params, notfound=notfound)\n\n    def _pagination(self, endpoint, params):\n        while True:\n            data = self._call(endpoint, params)\n            if \"items\" in data:\n                yield from data[\"items\"]\n            if \"nextPageToken\" not in data:\n                return\n            params[\"pageToken\"] = data[\"nextPageToken\"]\n"
  },
  {
    "path": "gallery_dl/extractor/bluesky.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2024-2026 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://bsky.app/\"\"\"\n\nfrom .common import Extractor, Message, Dispatch\nfrom .. import text, util\n\nBASE_PATTERN = (r\"(?:https?://)?\"\n                r\"(?:(?:www\\.)?(?:c|[fv]x)?bs[ky]y[ex]?\\.app|main\\.bsky\\.dev)\")\nUSER_PATTERN = BASE_PATTERN + r\"/profile/([^/?#]+)\"\n\n\nclass BlueskyExtractor(Extractor):\n    \"\"\"Base class for bluesky extractors\"\"\"\n    category = \"bluesky\"\n    directory_fmt = (\"{category}\", \"{author[handle]}\")\n    filename_fmt = \"{createdAt[:19]}_{post_id}_{num}.{extension}\"\n    archive_fmt = \"{filename}\"\n    root = \"https://bsky.app\"\n\n    def _init(self):\n        if meta := self.config(\"metadata\") or ():\n            if isinstance(meta, str):\n                meta = meta.replace(\" \", \"\").split(\",\")\n            elif not isinstance(meta, (list, tuple)):\n                meta = (\"user\", \"facets\")\n        self._metadata_user = (\"user\" in meta)\n        self._metadata_facets = (\"facets\" in meta)\n\n        self.api = BlueskyAPI(self)\n        self._user = self._user_did = None\n        self.instance = self.root.partition(\"://\")[2]\n        self.videos = self.config(\"videos\", True)\n        self.quoted = self.config(\"quoted\", False)\n\n    def items(self):\n        for post in self.posts():\n            if \"post\" in post:\n                post = post[\"post\"]\n            elif \"item\" in post:\n                post = post[\"item\"]\n            if self._user_did and post[\"author\"][\"did\"] != self._user_did:\n                self.log.debug(\"Skipping %s (repost)\", self._pid(post))\n                continue\n            embed = post.get(\"embed\")\n            try:\n                post.update(post.pop(\"record\"))\n            except Exception:\n                self.log.debug(\"Skipping %s (no 'record')\", self._pid(post))\n                continue\n\n            while True:\n                self._prepare(post)\n                files = self._extract_files(post)\n\n                yield Message.Directory, \"\", post\n                if files:\n                    did = post[\"author\"][\"did\"]\n                    base = (f\"{self.cache(self.api.service_endpoint, did)}\"\n                            f\"/xrpc/com.atproto.sync.getBlob?did={did}&cid=\")\n                    for post[\"num\"], file in enumerate(files, 1):\n                        post.update(file)\n                        yield Message.Url, base + file[\"filename\"], post\n\n                if not self.quoted or not embed or \"record\" not in embed:\n                    break\n\n                quote = embed[\"record\"]\n                if \"record\" in quote:\n                    quote = quote[\"record\"]\n                value = quote.pop(\"value\", None)\n                if value is None:\n                    break\n                quote[\"quote_id\"] = self._pid(post)\n                quote[\"quote_by\"] = post[\"author\"]\n                embed = quote.get(\"embed\")\n                quote.update(value)\n                post = quote\n\n    def posts(self):\n        return ()\n\n    def _posts_records(self, actor, collection):\n        depth = self.config(\"depth\", \"0\")\n\n        for record in self.api.list_records(actor, collection):\n            uri = None\n            try:\n                uri = record[\"value\"][\"subject\"][\"uri\"]\n                if \"/app.bsky.feed.post/\" in uri:\n                    yield from self.api.get_post_thread_uri(uri, depth)\n            except self.exc.ControlException:\n                pass  # deleted post\n            except Exception as exc:\n                self.log.debug(record, exc_info=exc)\n                self.log.warning(\"Failed to extract %s (%s: %s)\",\n                                 uri or \"record\", exc.__class__.__name__, exc)\n\n    def _pid(self, post):\n        return post[\"uri\"].rpartition(\"/\")[2]\n\n    def _instance(self, handle):\n        return \".\".join(handle.rsplit(\".\", 2)[-2:])\n\n    def _prepare(self, post):\n        author = post[\"author\"]\n        author[\"instance\"] = self._instance(author[\"handle\"])\n\n        if self._metadata_facets:\n            if \"facets\" in post:\n                post[\"hashtags\"] = tags = []\n                post[\"mentions\"] = dids = []\n                post[\"uris\"] = uris = []\n                for facet in post[\"facets\"]:\n                    features = facet[\"features\"][0]\n                    if \"tag\" in features:\n                        tags.append(features[\"tag\"])\n                    elif \"did\" in features:\n                        dids.append(features[\"did\"])\n                    elif \"uri\" in features:\n                        uris.append(features[\"uri\"])\n            else:\n                post[\"hashtags\"] = post[\"mentions\"] = post[\"uris\"] = ()\n\n        if self._metadata_user:\n            post[\"user\"] = self._user or author\n\n        post[\"instance\"] = self.instance\n        post[\"post_id\"] = self._pid(post)\n        post[\"date\"] = self.parse_datetime_iso(post[\"createdAt\"][:19])\n\n    def _extract_files(self, post):\n        if \"embed\" not in post:\n            post[\"count\"] = 0\n            return ()\n\n        files = []\n        media = post[\"embed\"]\n        if \"media\" in media:\n            media = media[\"media\"]\n\n        if \"images\" in media:\n            for image in media[\"images\"]:\n                try:\n                    files.append(self._extract_media(image, \"image\"))\n                except Exception:\n                    pass\n        if \"video\" in media and self.videos:\n            try:\n                files.append(self._extract_media(media, \"video\"))\n            except Exception:\n                pass\n\n        post[\"count\"] = len(files)\n        return files\n\n    def _extract_media(self, media, key):\n        try:\n            aspect = media[\"aspectRatio\"]\n            width = aspect[\"width\"]\n            height = aspect[\"height\"]\n        except KeyError:\n            width = height = 0\n\n        data = media[key]\n        try:\n            cid = data[\"ref\"][\"$link\"]\n        except KeyError:\n            cid = data[\"cid\"]\n\n        return {\n            \"description\": media.get(\"alt\") or \"\",\n            \"width\"      : width,\n            \"height\"     : height,\n            \"filename\"   : cid,\n            \"extension\"  : data[\"mimeType\"].rpartition(\"/\")[2],\n        }\n\n    def _make_post(self, actor, kind):\n        did = self.api._did_from_actor(actor)\n        profile = self.cache(self.api.get_profile, did)\n\n        if kind not in profile:\n            return ()\n        cid = profile[kind].rpartition(\"/\")[2].partition(\"@\")[0]\n\n        return ({\n            \"post\": {\n                \"embed\": {\"images\": [{\n                    \"alt\": kind,\n                    \"image\": {\n                        \"$type\"   : \"blob\",\n                        \"ref\"     : {\"$link\": cid},\n                        \"mimeType\": \"image/jpeg\",\n                        \"size\"    : 0,\n                    },\n                    \"aspectRatio\": {\n                        \"width\" : 1000,\n                        \"height\": 1000,\n                    },\n                }]},\n                \"author\"   : profile,\n                \"record\"   : (),\n                \"createdAt\": \"\",\n                \"uri\"      : cid,\n            },\n        },)\n\n\nclass BlueskyUserExtractor(Dispatch, BlueskyExtractor):\n    pattern = USER_PATTERN + r\"$\"\n    example = \"https://bsky.app/profile/HANDLE\"\n\n    def items(self):\n        base = f\"{self.root}/profile/{self.groups[0]}/\"\n        default = (\"posts\" if self.config(\"quoted\", False) or\n                   self.config(\"reposts\", False) else \"media\")\n        return self._dispatch_extractors((\n            (BlueskyInfoExtractor      , base + \"info\"),\n            (BlueskyAvatarExtractor    , base + \"avatar\"),\n            (BlueskyBackgroundExtractor, base + \"banner\"),\n            (BlueskyPostsExtractor     , base + \"posts\"),\n            (BlueskyRepliesExtractor   , base + \"replies\"),\n            (BlueskyMediaExtractor     , base + \"media\"),\n            (BlueskyVideoExtractor     , base + \"video\"),\n            (BlueskyLikesExtractor     , base + \"likes\"),\n        ), (default,))\n\n\nclass BlueskyPostsExtractor(BlueskyExtractor):\n    subcategory = \"posts\"\n    pattern = USER_PATTERN + r\"/posts\"\n    example = \"https://bsky.app/profile/HANDLE/posts\"\n\n    def posts(self):\n        return self.api.get_author_feed(\n            self.groups[0], \"posts_and_author_threads\")\n\n\nclass BlueskyRepliesExtractor(BlueskyExtractor):\n    subcategory = \"replies\"\n    pattern = USER_PATTERN + r\"/replies\"\n    example = \"https://bsky.app/profile/HANDLE/replies\"\n\n    def posts(self):\n        return self.api.get_author_feed(\n            self.groups[0], \"posts_with_replies\")\n\n\nclass BlueskyMediaExtractor(BlueskyExtractor):\n    subcategory = \"media\"\n    pattern = USER_PATTERN + r\"/media\"\n    example = \"https://bsky.app/profile/HANDLE/media\"\n\n    def posts(self):\n        return self.api.get_author_feed(\n            self.groups[0], \"posts_with_media\")\n\n\nclass BlueskyVideoExtractor(BlueskyExtractor):\n    subcategory = \"video\"\n    pattern = USER_PATTERN + r\"/video\"\n    example = \"https://bsky.app/profile/HANDLE/video\"\n\n    def posts(self):\n        return self.api.get_author_feed(\n            self.groups[0], \"posts_with_video\")\n\n\nclass BlueskyLikesExtractor(BlueskyExtractor):\n    subcategory = \"likes\"\n    pattern = USER_PATTERN + r\"/likes\"\n    example = \"https://bsky.app/profile/HANDLE/likes\"\n\n    def posts(self):\n        if self.config(\"endpoint\") == \"getActorLikes\":\n            return self.api.get_actor_likes(self.groups[0])\n        return self._posts_records(self.groups[0], \"app.bsky.feed.like\")\n\n\nclass BlueskyFeedExtractor(BlueskyExtractor):\n    subcategory = \"feed\"\n    pattern = USER_PATTERN + r\"/feed/([^/?#]+)\"\n    example = \"https://bsky.app/profile/HANDLE/feed/NAME\"\n\n    def posts(self):\n        actor, feed = self.groups\n        return self.api.get_feed(actor, feed)\n\n\nclass BlueskyListExtractor(BlueskyExtractor):\n    subcategory = \"list\"\n    pattern = USER_PATTERN + r\"/lists/([^/?#]+)\"\n    example = \"https://bsky.app/profile/HANDLE/lists/ID\"\n\n    def posts(self):\n        actor, list_id = self.groups\n        return self.api.get_list_feed(actor, list_id)\n\n\nclass BlueskyFollowingExtractor(BlueskyExtractor):\n    subcategory = \"following\"\n    pattern = USER_PATTERN + r\"/follows\"\n    example = \"https://bsky.app/profile/HANDLE/follows\"\n\n    def items(self):\n        for user in self.api.get_follows(self.groups[0]):\n            url = \"https://bsky.app/profile/\" + user[\"did\"]\n            user[\"_extractor\"] = BlueskyUserExtractor\n            yield Message.Queue, url, user\n\n\nclass BlueskyPostExtractor(BlueskyExtractor):\n    subcategory = \"post\"\n    pattern = USER_PATTERN + r\"/post/([^/?#]+)\"\n    example = \"https://bsky.app/profile/HANDLE/post/ID\"\n\n    def posts(self):\n        actor, post_id = self.groups\n        return self.api.get_post_thread(actor, post_id)\n\n\nclass BlueskyInfoExtractor(BlueskyExtractor):\n    subcategory = \"info\"\n    pattern = USER_PATTERN + r\"/info\"\n    example = \"https://bsky.app/profile/HANDLE/info\"\n\n    def items(self):\n        self._metadata_user = True\n        self.api._did_from_actor(self.groups[0])\n        return iter(((Message.Directory, \"\", self._user),))\n\n\nclass BlueskyAvatarExtractor(BlueskyExtractor):\n    subcategory = \"avatar\"\n    filename_fmt = \"avatar_{post_id}.{extension}\"\n    pattern = USER_PATTERN + r\"/avatar\"\n    example = \"https://bsky.app/profile/HANDLE/avatar\"\n\n    def posts(self):\n        return self._make_post(self.groups[0], \"avatar\")\n\n\nclass BlueskyBackgroundExtractor(BlueskyExtractor):\n    subcategory = \"background\"\n    filename_fmt = \"background_{post_id}.{extension}\"\n    pattern = USER_PATTERN + r\"/ba(?:nner|ckground)\"\n    example = \"https://bsky.app/profile/HANDLE/banner\"\n\n    def posts(self):\n        return self._make_post(self.groups[0], \"banner\")\n\n\nclass BlueskySearchExtractor(BlueskyExtractor):\n    subcategory = \"search\"\n    pattern = BASE_PATTERN + r\"/search(?:/|\\?q=)(.+)\"\n    example = \"https://bsky.app/search?q=QUERY\"\n\n    def posts(self):\n        query = text.unquote(self.groups[0].replace(\"+\", \" \"))\n        return self.api.search_posts(query)\n\n\nclass BlueskyHashtagExtractor(BlueskyExtractor):\n    subcategory = \"hashtag\"\n    pattern = BASE_PATTERN + r\"/hashtag/([^/?#]+)(?:/(top|latest))?\"\n    example = \"https://bsky.app/hashtag/NAME\"\n\n    def posts(self):\n        hashtag, order = self.groups\n        return self.api.search_posts(\"#\"+hashtag, order)\n\n\nclass BlueskyBookmarkExtractor(BlueskyExtractor):\n    subcategory = \"bookmark\"\n    pattern = BASE_PATTERN + r\"/saved\"\n    example = \"https://bsky.app/saved\"\n\n    def posts(self):\n        return self.api.get_bookmarks()\n\n\nclass BlueskyAPI():\n    \"\"\"Interface for the Bluesky API\n\n    https://docs.bsky.app/docs/category/http-reference\n    \"\"\"\n\n    def __init__(self, extractor):\n        self.extractor = extractor\n        self.log = extractor.log\n        self.headers = {\"Accept\": \"application/json\"}\n\n        self.username, self.password = extractor._get_auth_info()\n        if srv := extractor.config(\"api-server\", False):\n            self.root = srv.rstrip(\"/\")\n        elif self.username:\n            self.root = \"https://bsky.social\"\n        else:\n            self.root = \"https://api.bsky.app\"\n            self.authenticate = util.noop\n\n    def get_actor_likes(self, actor):\n        endpoint = \"app.bsky.feed.getActorLikes\"\n        params = {\n            \"actor\": self._did_from_actor(actor),\n            \"limit\": \"100\",\n        }\n        return self._pagination(endpoint, params, check_empty=True)\n\n    def get_author_feed(self, actor, filter=\"posts_and_author_threads\"):\n        endpoint = \"app.bsky.feed.getAuthorFeed\"\n        params = {\n            \"actor\" : self._did_from_actor(actor, True),\n            \"filter\": filter,\n            \"limit\" : \"100\",\n        }\n        return self._pagination(endpoint, params)\n\n    def get_bookmarks(self):\n        endpoint = \"app.bsky.bookmark.getBookmarks\"\n        return self._pagination(endpoint, {}, \"bookmarks\", check_empty=True)\n\n    def get_feed(self, actor, feed):\n        endpoint = \"app.bsky.feed.getFeed\"\n        uri = (f\"at://{self._did_from_actor(actor)}\"\n               f\"/app.bsky.feed.generator/{feed}\")\n        params = {\"feed\": uri, \"limit\": \"100\"}\n        return self._pagination(endpoint, params)\n\n    def get_follows(self, actor):\n        endpoint = \"app.bsky.graph.getFollows\"\n        params = {\n            \"actor\": self._did_from_actor(actor),\n            \"limit\": \"100\",\n        }\n        return self._pagination(endpoint, params, \"follows\")\n\n    def get_list_feed(self, actor, list):\n        endpoint = \"app.bsky.feed.getListFeed\"\n        uri = f\"at://{self._did_from_actor(actor)}/app.bsky.graph.list/{list}\"\n        params = {\"list\" : uri, \"limit\": \"100\"}\n        return self._pagination(endpoint, params)\n\n    def get_post_thread(self, actor, post_id):\n        uri = (f\"at://{self._did_from_actor(actor)}\"\n               f\"/app.bsky.feed.post/{post_id}\")\n        depth = self.extractor.config(\"depth\", \"0\")\n        return self.get_post_thread_uri(uri, depth)\n\n    def get_post_thread_uri(self, uri, depth=\"0\"):\n        endpoint = \"app.bsky.feed.getPostThread\"\n        params = {\n            \"uri\"         : uri,\n            \"depth\"       : depth,\n            \"parentHeight\": \"0\",\n        }\n\n        thread = self._call(endpoint, params)[\"thread\"]\n        if \"replies\" not in thread:\n            return (thread,)\n\n        index = 0\n        posts = [thread]\n        while index < len(posts):\n            post = posts[index]\n            if \"replies\" in post:\n                posts.extend(post[\"replies\"])\n            index += 1\n        return posts\n\n    def get_profile(self, did):\n        endpoint = \"app.bsky.actor.getProfile\"\n        params = {\"actor\": did}\n        return self._call(endpoint, params)\n\n    def list_records(self, actor, collection):\n        endpoint = \"com.atproto.repo.listRecords\"\n        actor_did = self._did_from_actor(actor)\n        service = self.extractor.cache(self.service_endpoint, actor_did)\n        params = {\n            \"repo\"      : actor_did,\n            \"collection\": collection,\n            \"limit\"     : \"100\",\n            #  \"reverse\"   : \"false\",\n        }\n        return self._pagination(endpoint, params, \"records\", service)\n\n    def resolve_handle(self, handle):\n        endpoint = \"com.atproto.identity.resolveHandle\"\n        params = {\"handle\": handle}\n        return self._call(endpoint, params)[\"did\"]\n\n    def service_endpoint(self, did):\n        if did.startswith('did:web:'):\n            url = f\"https://{did[8:]}/.well-known/did.json\"\n        else:\n            url = \"https://plc.directory/\" + did\n\n        try:\n            data = self.extractor.request_json(url)\n            for service in data[\"service\"]:\n                if service[\"type\"] == \"AtprotoPersonalDataServer\":\n                    return service[\"serviceEndpoint\"]\n        except Exception:\n            pass\n        return \"https://bsky.social\"\n\n    def search_posts(self, query, sort=None):\n        endpoint = \"app.bsky.feed.searchPosts\"\n        params = {\n            \"q\"    : query,\n            \"limit\": \"100\",\n            \"sort\" : sort,\n        }\n        return self._pagination(endpoint, params, \"posts\")\n\n    def _did_from_actor(self, actor, user_did=False):\n        if actor.startswith(\"did:\"):\n            did = actor\n        else:\n            did = self.extractor.cache(self.resolve_handle, actor)\n\n        extr = self.extractor\n        if user_did and not extr.config(\"reposts\", False):\n            extr._user_did = did\n        if extr._metadata_user:\n            extr._user = user = extr.cache(self.get_profile, did)\n            user[\"instance\"] = extr._instance(user[\"handle\"])\n\n        return did\n\n    def authenticate(self):\n        self.headers[\"Authorization\"] = self.extractor.cache(\n            self._authenticate_impl, self.username, _exp=3600, _mem=False)\n\n    def _authenticate_impl(self, username):\n        if refresh_token := self.extractor.cache(\n                _refresh_token_cache, username, _mem=False):\n            self.log.info(\"Refreshing access token for %s\", username)\n            endpoint = \"com.atproto.server.refreshSession\"\n            headers = {\"Authorization\": \"Bearer \" + refresh_token}\n            data = None\n        else:\n            self.log.info(\"Logging in as %s\", username)\n            endpoint = \"com.atproto.server.createSession\"\n            headers = None\n            data = {\n                \"identifier\": username,\n                \"password\"  : self.password,\n            }\n\n        url = f\"{self.root}/xrpc/{endpoint}\"\n        response = self.extractor.request(\n            url, method=\"POST\", headers=headers, json=data, fatal=None)\n        data = response.json()\n\n        if response.status_code != 200:\n            self.log.debug(\"Server response: %s\", data)\n            raise self.extractor.exc.AuthenticationError(\n                f\"\\\"{data.get('error')}: {data.get('message')}\\\"\")\n\n        self.extractor.cache_update(_refresh_token_cache, self.username,\n                                    data[\"refreshJwt\"], _exp=84*86400)\n        return \"Bearer \" + data[\"accessJwt\"]\n\n    def _call(self, endpoint, params, root=None):\n        if root is None:\n            root = self.root\n        url = f\"{root}/xrpc/{endpoint}\"\n\n        while True:\n            self.authenticate()\n            response = self.extractor.request(\n                url, params=params, headers=self.headers, fatal=None)\n\n            if response.status_code < 400:\n                return response.json()\n            if response.status_code == 429:\n                until = response.headers.get(\"RateLimit-Reset\")\n                self.extractor.wait(until=until)\n                continue\n\n            msg = \"API request failed\"\n            try:\n                data = response.json()\n                msg = f\"{msg} ('{data['error']}: {data['message']}')\"\n            except Exception:\n                msg = f\"{msg} ({response.status_code} {response.reason})\"\n\n            self.extractor.log.debug(\"Server response: %s\", response.text)\n            raise self.extractor.exc.AbortExtraction(msg)\n\n    def _pagination(self, endpoint, params,\n                    key=\"feed\", root=None, check_empty=False):\n        while True:\n            data = self._call(endpoint, params, root)\n\n            if check_empty and not data[key]:\n                return\n            yield from data[key]\n\n            cursor = data.get(\"cursor\")\n            if not cursor:\n                return\n            params[\"cursor\"] = cursor\n\n\ndef _refresh_token_cache(username):\n    return None\n"
  },
  {
    "path": "gallery_dl/extractor/booru.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2015-2023 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for *booru sites\"\"\"\n\nfrom .common import BaseExtractor, Message\nfrom .. import text\nimport operator\n\n\nclass BooruExtractor(BaseExtractor):\n    \"\"\"Base class for *booru extractors\"\"\"\n    basecategory = \"booru\"\n    filename_fmt = \"{category}_{id}_{md5}.{extension}\"\n    page_start = 0\n    per_page = 100\n\n    def items(self):\n        self.login()\n        data = self.metadata()\n        tags = self.config(\"tags\", False)\n        notes = self.config(\"notes\", False)\n        fetch_html = tags or notes\n\n        if url_key := self.config(\"url\"):\n            if isinstance(url_key, (list, tuple)):\n                self._file_url = self._file_url_list\n                self._file_url_keys = url_key\n            else:\n                self._file_url = operator.itemgetter(url_key)\n\n        for post in self.posts():\n            try:\n                url = self._file_url(post)\n                if url[0] == \"/\":\n                    url = self.root + url\n            except Exception as exc:\n                self.log.debug(\"%s: %s\", exc.__class__.__name__, exc)\n                self.log.warning(\"Unable to fetch download URL for post %s \"\n                                 \"(md5: %s)\", post.get(\"id\"), post.get(\"md5\"))\n                continue\n\n            if fetch_html:\n                html = self._html(post)\n                if tags:\n                    self._tags(post, html)\n                if notes:\n                    self._notes(post, html)\n\n            if \"extension\" not in post:\n                text.nameext_from_url(url, post)\n            post.update(data)\n            self._prepare(post)\n\n            yield Message.Directory, \"\", post\n            yield Message.Url, url, post\n\n    def skip_files(self, num):\n        pages = num // self.per_page\n        self.page_start += pages\n        return pages * self.per_page\n\n    def login(self):\n        \"\"\"Login and set necessary cookies\"\"\"\n\n    def metadata(self):\n        \"\"\"Return a dict with general metadata\"\"\"\n        return ()\n\n    def posts(self):\n        \"\"\"Return an iterable with post objects\"\"\"\n        return ()\n\n    _file_url = operator.itemgetter(\"file_url\")\n\n    def _file_url_list(self, post):\n        urls = (post[key] for key in self._file_url_keys if post.get(key))\n        post[\"_fallback\"] = it = iter(urls)\n        return next(it)\n\n    def _prepare(self, post):\n        \"\"\"Prepare a 'post's metadata\"\"\"\n\n    def _html(self, post):\n        \"\"\"Return HTML content of a post\"\"\"\n\n    def _tags(self, post, page):\n        \"\"\"Extract extended tag metadata\"\"\"\n\n    def _notes(self, post, page):\n        \"\"\"Extract notes metadata\"\"\"\n"
  },
  {
    "path": "gallery_dl/extractor/boosty.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://www.boosty.to/\"\"\"\n\nfrom .common import Extractor, Message\nfrom .. import text, util\nimport itertools\n\nBASE_PATTERN = r\"(?:https?://)?boosty\\.to\"\n\n\nclass BoostyExtractor(Extractor):\n    \"\"\"Base class for boosty extractors\"\"\"\n    category = \"boosty\"\n    root = \"https://www.boosty.to\"\n    directory_fmt = (\"{category}\", \"{user[blogUrl]} ({user[id]})\",\n                     \"{post[date]:%Y-%m-%d} {post[int_id]}\")\n    filename_fmt = \"{num:>02} {file[id]}.{extension}\"\n    archive_fmt = \"{file[id]}\"\n    cookies_domain = \".boosty.to\"\n    cookies_names = (\"auth\",)\n\n    def _init(self):\n        self.api = BoostyAPI(self)\n\n        self._user = None if self.config(\"metadata\") else False\n        self.only_allowed = self.config(\"allowed\", True)\n        self.only_bought = self.config(\"bought\")\n\n        videos = self.config(\"videos\")\n        if videos is None or videos:\n            if isinstance(videos, str):\n                videos = videos.split(\",\")\n            elif not isinstance(videos, (list, tuple)):\n                # ultra_hd: 2160p\n                #  quad_hd: 1440p\n                #  full_hd: 1080p\n                #     high:  720p\n                #   medium:  480p\n                #      low:  360p\n                #   lowest:  240p\n                #     tiny:  144p\n                videos = (\"ultra_hd\", \"quad_hd\", \"full_hd\",\n                          \"high\", \"medium\", \"low\", \"lowest\", \"tiny\")\n        self.videos = videos\n\n    def items(self):\n        headers = self.api.headers.copy()\n        del headers[\"Accept\"]\n\n        for post in self.posts():\n            if not post.get(\"hasAccess\"):\n                self.log.warning(\"Not allowed to access post %s\", post[\"id\"])\n                continue\n\n            files = self._extract_files(post)\n            if self._user:\n                post[\"user\"] = self._user\n            data = {\n                \"post\" : post,\n                \"user\" : post.pop(\"user\", None),\n                \"count\": len(files),\n                \"_http_headers\": headers,\n            }\n\n            yield Message.Directory, \"\", data\n            for data[\"num\"], file in enumerate(files, 1):\n                data[\"file\"] = file\n                url = file[\"url\"]\n                yield Message.Url, url, text.nameext_from_url(url, data)\n\n    def posts(self):\n        \"\"\"Yield JSON content of all relevant posts\"\"\"\n\n    def _extract_files(self, post):\n        files = []\n        post[\"content\"] = content = []\n        post[\"links\"] = links = []\n\n        if \"createdAt\" in post:\n            post[\"date\"] = self.parse_timestamp(post[\"createdAt\"])\n\n        for block in post[\"data\"]:\n            try:\n                type = block[\"type\"]\n                if type == \"text\":\n                    if block[\"modificator\"] == \"BLOCK_END\":\n                        continue\n                    c = util.json_loads(block[\"content\"])\n                    content.append(c[0])\n\n                elif type == \"image\":\n                    files.append(self._update_url(post, block))\n\n                elif type == \"ok_video\":\n                    if not self.videos:\n                        self.log.debug(\"%s: Skipping video %s\",\n                                       post[\"id\"], block[\"id\"])\n                        continue\n                    fmts = {\n                        fmt[\"type\"]: fmt[\"url\"]\n                        for fmt in block[\"playerUrls\"]\n                        if fmt[\"url\"]\n                    }\n                    formats = [\n                        fmts[fmt]\n                        for fmt in self.videos\n                        if fmt in fmts\n                    ]\n                    if formats:\n                        formats = iter(formats)\n                        block[\"url\"] = next(formats)\n                        block[\"_fallback\"] = formats\n                        files.append(block)\n                    else:\n                        self.log.warning(\n                            \"%s: Found no suitable video format for %s\",\n                            post[\"id\"], block[\"id\"])\n\n                elif type == \"link\":\n                    url = block[\"url\"]\n                    links.append(url)\n                    content.append(url)\n\n                elif type == \"audio_file\":\n                    files.append(self._update_url(post, block))\n\n                elif type == \"file\":\n                    files.append(self._update_url(post, block))\n\n                elif type == \"smile\":\n                    content.append(\":\" + block[\"name\"] + \":\")\n\n                else:\n                    self.log.debug(\"%s: Unsupported data type '%s'\",\n                                   post[\"id\"], type)\n            except Exception as exc:\n                self.log.debug(\"%s: %s\", exc.__class__.__name__, exc)\n\n        del post[\"data\"]\n        return files\n\n    def _update_url(self, post, block):\n        url = block[\"url\"]\n        sep = \"&\" if \"?\" in url else \"?\"\n\n        if signed_query := post.get(\"signedQuery\"):\n            url += sep + signed_query[1:]\n            sep = \"&\"\n\n        migrated = post.get(\"isMigrated\")\n        if migrated is not None:\n            url += sep + \"is_migrated=\" + str(migrated).lower()\n\n        block[\"url\"] = url\n        return block\n\n\nclass BoostyUserExtractor(BoostyExtractor):\n    \"\"\"Extractor for boosty.to user profiles\"\"\"\n    subcategory = \"user\"\n    pattern = BASE_PATTERN + r\"/([^/?#]+)(?:\\?([^#]+))?$\"\n    example = \"https://boosty.to/USER\"\n\n    def posts(self):\n        user, query = self.groups\n        params = text.parse_query(query)\n        if self._user is None:\n            self._user = self.api.user(user)\n        return self.api.blog_posts(user, params)\n\n\nclass BoostyMediaExtractor(BoostyExtractor):\n    \"\"\"Extractor for boosty.to user media\"\"\"\n    subcategory = \"media\"\n    directory_fmt = \"{category}\", \"{user[blogUrl]} ({user[id]})\", \"media\"\n    filename_fmt = \"{post[id]}_{num}.{extension}\"\n    pattern = BASE_PATTERN + r\"/([^/?#]+)/media/([^/?#]+)(?:\\?([^#]+))?\"\n    example = \"https://boosty.to/USER/media/all\"\n\n    def posts(self):\n        user, media, query = self.groups\n        params = text.parse_query(query)\n        self._user = self.api.user(user)\n        return self.api.blog_media_album(user, media, params)\n\n\nclass BoostyFeedExtractor(BoostyExtractor):\n    \"\"\"Extractor for your boosty.to subscription feed\"\"\"\n    subcategory = \"feed\"\n    pattern = BASE_PATTERN + r\"/(?:\\?([^#]+))?(?:$|#)\"\n    example = \"https://boosty.to/\"\n\n    def posts(self):\n        params = text.parse_query(self.groups[0])\n        return self.api.feed_posts(params)\n\n\nclass BoostyPostExtractor(BoostyExtractor):\n    \"\"\"Extractor for boosty.to posts\"\"\"\n    subcategory = \"post\"\n    pattern = BASE_PATTERN + r\"/([^/?#]+)/posts/([0-9a-f-]+)\"\n    example = \"https://boosty.to/USER/posts/01234567-89ab-cdef-0123-456789abcd\"\n\n    def posts(self):\n        user, post_id = self.groups\n        if self._user is None:\n            self._user = self.api.user(user)\n        return (self.api.post(user, post_id),)\n\n\nclass BoostyFollowingExtractor(BoostyExtractor):\n    \"\"\"Extractor for your boosty.to subscribed users\"\"\"\n    subcategory = \"following\"\n    pattern = BASE_PATTERN + r\"/app/settings/subscriptions\"\n    example = \"https://boosty.to/app/settings/subscriptions\"\n\n    def items(self):\n        for user in self.api.user_subscriptions():\n            url = f\"{self.root}/{user['blog']['blogUrl']}\"\n            user[\"_extractor\"] = BoostyUserExtractor\n            yield Message.Queue, url, user\n\n\nclass BoostyDirectMessagesExtractor(BoostyExtractor):\n    \"\"\"Extractor for boosty.to direct messages\"\"\"\n    subcategory = \"direct-messages\"\n    directory_fmt = (\"{category}\", \"{user[blogUrl]} ({user[id]})\",\n                     \"Direct Messages\")\n    pattern = BASE_PATTERN + r\"/app/messages/?\\?dialogId=(\\d+)\"\n    example = \"https://boosty.to/app/messages?dialogId=12345\"\n\n    def items(self):\n        \"\"\"Yield direct messages from a given dialog ID.\"\"\"\n        dialog_id = self.groups[0]\n        response = self.api.dialog(dialog_id)\n        signed_query = response.get(\"signedQuery\")\n\n        try:\n            messages = response[\"messages\"][\"data\"]\n            offset = messages[0][\"id\"]\n        except Exception:\n            return\n\n        try:\n            user = self.api.user(response[\"chatmate\"][\"url\"])\n        except Exception:\n            user = None\n\n        messages.reverse()\n        for message in itertools.chain(\n            messages,\n            self.api.dialog_messages(dialog_id, offset=offset)\n        ):\n            message[\"signedQuery\"] = signed_query\n            files = self._extract_files(message)\n            data = {\n                \"post\": message,\n                \"user\": user,\n                \"count\": len(files),\n            }\n\n            yield Message.Directory, \"\", data\n            for data[\"num\"], file in enumerate(files, 1):\n                data[\"file\"] = file\n                url = file[\"url\"]\n                yield Message.Url, url, text.nameext_from_url(url, data)\n\n\nclass BoostyAPI():\n    \"\"\"Interface for the Boosty API\"\"\"\n    root = \"https://api.boosty.to\"\n\n    def __init__(self, extractor, access_token=None):\n        self.extractor = extractor\n        self.headers = {\n            \"Accept\": \"application/json, text/plain, */*\",\n            \"Origin\": extractor.root,\n        }\n\n        if not access_token:\n            if auth := self.extractor.cookies.get(\"auth\", domain=\".boosty.to\"):\n                auth = text.unquote(auth)\n                access_token = text.extr(auth, '\"accessToken\":\"', '\"')\n                if expires := text.extr(auth, '\"expiresAt\":', ','):\n                    import time\n                    if text.parse_int(expires) < time.time() * 1000:\n                        extractor.log.warning(\"'auth' cookie tokens expired\")\n        if access_token:\n            self.headers[\"Authorization\"] = \"Bearer \" + access_token\n\n    def blog_posts(self, username, params):\n        endpoint = f\"/v1/blog/{username}/post/\"\n        params = self._merge_params(params, {\n            \"limit\"         : \"5\",\n            \"offset\"        : None,\n            \"comments_limit\": \"2\",\n            \"reply_limit\"   : \"1\",\n        })\n        return self._pagination(endpoint, params)\n\n    def blog_media_album(self, username, type=\"all\", params=()):\n        endpoint = f\"/v1/blog/{username}/media_album/\"\n        params = self._merge_params(params, {\n            \"type\"    : type.rstrip(\"s\"),\n            \"limit\"   : \"15\",\n            \"limit_by\": \"media\",\n            \"offset\"  : None,\n        })\n        return self._pagination(endpoint, params, self._transform_media_posts)\n\n    def _transform_media_posts(self, data):\n        posts = []\n\n        for obj in data[\"mediaPosts\"]:\n            post = obj[\"post\"]\n            post[\"data\"] = obj[\"media\"]\n            posts.append(post)\n\n        return posts\n\n    def post(self, username, post_id):\n        endpoint = f\"/v1/blog/{username}/post/{post_id}\"\n        return self._call(endpoint)\n\n    def feed_posts(self, params=None):\n        endpoint = \"/v1/feed/post/\"\n        params = self._merge_params(params, {\n            \"limit\"         : \"5\",\n            \"offset\"        : None,\n            \"comments_limit\": \"2\",\n        })\n        if \"only_allowed\" not in params and self.extractor.only_allowed:\n            params[\"only_allowed\"] = \"true\"\n        if \"only_bought\" not in params and self.extractor.only_bought:\n            params[\"only_bought\"] = \"true\"\n        return self._pagination(endpoint, params, key=\"posts\")\n\n    def user(self, username):\n        endpoint = \"/v1/blog/\" + username\n        user = self._call(endpoint)\n        user[\"id\"] = user[\"owner\"][\"id\"]\n        return user\n\n    def user_subscriptions(self, params=None):\n        endpoint = \"/v1/user/subscriptions\"\n        params = self._merge_params(params, {\n            \"limit\"      : \"30\",\n            \"with_follow\": \"true\",\n            \"offset\"     : None,\n        })\n        return self._pagination_users(endpoint, params)\n\n    def _merge_params(self, params_web, params_api):\n        if params_web:\n            web_to_api = {\n                \"isOnlyAllowedPosts\": \"is_only_allowed\",\n                \"postsTagsIds\"      : \"tags_ids\",\n                \"postsFrom\"         : \"from_ts\",\n                \"postsTo\"           : \"to_ts\",\n            }\n            for name, value in params_web.items():\n                name = web_to_api.get(name, name)\n                params_api[name] = value\n        return params_api\n\n    def _call(self, endpoint, params=None):\n        url = self.root + endpoint\n\n        while True:\n            response = self.extractor.request(\n                url, params=params, headers=self.headers,\n                fatal=None, allow_redirects=False)\n\n            if response.status_code < 300:\n                return response.json()\n\n            elif response.status_code < 400:\n                raise self.extractor.exc.AuthenticationError(\n                    \"Invalid API access token\")\n\n            elif response.status_code == 429:\n                self.extractor.wait(seconds=600)\n\n            else:\n                self.extractor.log.debug(response.text)\n                raise self.extractor.exc.AbortExtraction(\"API request failed\")\n\n    def _pagination(self, endpoint, params, transform=None, key=None):\n        if \"is_only_allowed\" not in params and self.extractor.only_allowed:\n            params[\"only_allowed\"] = \"true\"\n            params[\"is_only_allowed\"] = \"true\"\n\n        while True:\n            data = self._call(endpoint, params)\n\n            if transform:\n                yield from transform(data[\"data\"])\n            elif key:\n                yield from data[\"data\"][key]\n            else:\n                yield from data[\"data\"]\n\n            extra = data[\"extra\"]\n            if extra.get(\"isLast\"):\n                return\n            offset = extra.get(\"offset\")\n            if not offset:\n                return\n            params[\"offset\"] = offset\n\n    def _pagination_users(self, endpoint, params):\n        while True:\n            data = self._call(endpoint, params)\n\n            yield from data[\"data\"]\n\n            offset = data[\"offset\"] + data[\"limit\"]\n            if offset > data[\"total\"]:\n                return\n            params[\"offset\"] = offset\n\n    def dialog(self, dialog_id):\n        endpoint = \"/v1/dialog/\" + dialog_id\n        return self._call(endpoint)\n\n    def dialog_messages(self, dialog_id, limit=300, offset=None):\n        endpoint = f\"/v1/dialog/{dialog_id}/message/\"\n        params = {\n            \"limit\": limit,\n            \"reverse\": \"true\",\n            \"offset\": offset,\n        }\n        return self._pagination_dialog(endpoint, params)\n\n    def _pagination_dialog(self, endpoint, params):\n        while True:\n            data = self._call(endpoint, params)\n\n            yield from data[\"data\"]\n\n            try:\n                extra = data[\"extra\"]\n                if extra.get(\"isLast\"):\n                    break\n                params[\"offset\"] = offset = extra[\"offset\"]\n                if not offset:\n                    break\n            except Exception:\n                break\n"
  },
  {
    "path": "gallery_dl/extractor/booth.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2025 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://booth.pm/\"\"\"\n\nfrom .common import Extractor, Message\nfrom .. import text, util\n\n\nclass BoothExtractor(Extractor):\n    \"\"\"Base class for booth extractors\"\"\"\n    category = \"booth\"\n    root = \"https://booth.pm\"\n    directory_fmt = (\"{category}\", \"{shop[name]}\", \"{id} {name}\")\n    filename_fmt = \"{num:>02} {filename}.{extension}\"\n    archive_fmt = \"{id}_{filename}\"\n    request_interval = (0.5, 1.5)\n\n    def _init(self):\n        self.cookies.set(\"adult\", \"t\", domain=\".booth.pm\")\n\n    def _pagination(self, url, json=False):\n        while True:\n            page = self.request(url).text\n\n            if json:\n                for item in text.extract_iter(page, ' data-item=\"', '\"'):\n                    yield util.json_loads(text.unescape(item))\n            else:\n                for item in text.extract_iter(\n                        page, \"item-card__title\", \"</div>\"):\n                    yield text.unescape(text.extr(item, 'href=\"', '\"'))\n\n            next = text.extr(page, 'rel=\"next\" class=\"nav-item\" href=\"', '\"')\n            if not next:\n                break\n            url = self.root + text.unescape(next)\n\n\nclass BoothItemExtractor(BoothExtractor):\n    subcategory = \"item\"\n    pattern = (r\"(?:https?://)?(?:[\\w-]+\\.)?booth\\.pm/\"\n               r\"(?:[a-z]{2}(?:-[^/?#]+)?/)?items/(\\d+)\")\n    example = \"https://booth.pm/ja/items/12345\"\n\n    def items(self):\n        url = f\"{self.root}/ja/items/{self.groups[0]}\"\n        headers = {\n            \"Accept\": \"application/json\",\n            \"Content-Type\": \"application/json\",\n            \"X-CSRF-Token\": None,\n            \"Sec-Fetch-Dest\": \"empty\",\n            \"Sec-Fetch-Mode\": \"cors\",\n            \"Sec-Fetch-Site\": \"same-origin\",\n            \"Priority\": \"u=4\",\n        }\n\n        if self.config(\"strategy\") == \"fallback\":\n            page = None\n            item = self.request_json(url + \".json\", headers=headers)\n        else:\n            page = self.request(url).text\n            headers[\"X-CSRF-Token\"] = text.extr(\n                page, 'name=\"csrf-token\" content=\"', '\"')\n            item = self.request_json(\n                url + \".json\", headers=headers, interval=False)\n\n        item[\"booth_category\"] = item.pop(\"category\", None)\n        item[\"date\"] = self.parse_datetime_iso(item[\"published_at\"])\n        item[\"tags\"] = [t[\"name\"] for t in item[\"tags\"]]\n\n        shop = item[\"shop\"]\n        shop[\"id\"] = text.parse_int(shop[\"thumbnail_url\"].rsplit(\"/\", 3)[1])\n\n        if files := self._extract_files(item, page):\n            item[\"count\"] = len(files)\n            shop[\"uuid\"] = files[0][\"url\"].split(\"/\", 4)[3]\n        else:\n            item[\"count\"] = 0\n            shop[\"uuid\"] = util.NONE\n\n        yield Message.Directory, \"\", item\n        for num, file in enumerate(files, 1):\n            url = file[\"url\"]\n            file[\"num\"] = num\n            text.nameext_from_url(url, file)\n            yield Message.Url, url, {**item, **file}\n\n    def _extract_files(self, item, page):\n        if page is None:\n            files = []\n            for image in item.pop(\"images\"):\n                url = image[\"original\"].replace(\"_base_resized\", \"\")\n                files.append({\n                    \"url\"      : url,\n                    \"_fallback\": _fallback(url),\n                })\n            return files\n\n        del item[\"images\"]\n        return [{\"url\": url}\n                for url in text.extract_iter(page, 'data-origin=\"', '\"')]\n\n\nclass BoothShopExtractor(BoothExtractor):\n    subcategory = \"shop\"\n    pattern = r\"(?:https?://)?([\\w-]+\\.)booth\\.pm/\"\n    example = \"https://SHOP.booth.pm/\"\n\n    def __init__(self, match):\n        self.root = text.root_from_url(match[0])\n        BoothExtractor.__init__(self, match)\n\n    def items(self):\n        for item in self._pagination(self.root + \"/items\", json=True):\n            item[\"_extractor\"] = BoothItemExtractor\n            yield Message.Queue, item[\"shop_item_url\"], item\n\n\nclass BoothCategoryExtractor(BoothExtractor):\n    subcategory = \"category\"\n    pattern = r\"(?:https?://)?booth\\.pm(/[a-z]{2}(?:-[^/?#]+)?/browse/.+)\"\n    example = \"https://booth.pm/ja/browse/CATEGORY\"\n\n    def items(self):\n        data = {\"_extractor\": BoothItemExtractor}\n        for url in self._pagination(self.root + self.groups[0]):\n            yield Message.Queue, url, data\n\n\ndef _fallback(url):\n    base = url[:-3]\n    yield base + \"jpeg\"\n    yield base + \"png\"\n    yield base + \"webp\"\n"
  },
  {
    "path": "gallery_dl/extractor/bunkr.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2022-2026 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://bunkr.si/\"\"\"\n\nfrom .common import Extractor\nfrom .lolisafe import LolisafeAlbumExtractor\nfrom .. import text, util, config\nimport random\n\nif config.get((\"extractor\", \"bunkr\"), \"tlds\"):\n    BASE_PATTERN = (\n        r\"(?:bunkr:(?:https?://)?([^/?#]+)|\"\n        r\"(?:https?://)?(?:app\\.)?(bunkr+\\.\\w+))\"\n    )\nelse:\n    BASE_PATTERN = (\n        r\"(?:bunkr:(?:https?://)?([^/?#]+)|\"\n        r\"(?:https?://)?(?:app\\.)?(bunkr+\"\n        r\"\\.(?:s[kiu]|c[ir]|fi|p[hks]|ru|la|is|to|a[cx]\"\n        r\"|black|cat|media|red|site|ws|org)))\"\n    )\n\nDOMAINS = [\n    \"bunkr.ac\",\n    \"bunkr.ci\",\n    \"bunkr.cr\",\n    \"bunkr.fi\",\n    \"bunkr.ph\",\n    \"bunkr.pk\",\n    \"bunkr.ps\",\n    \"bunkr.si\",\n    \"bunkr.sk\",\n    \"bunkr.ws\",\n    \"bunkr.black\",\n    \"bunkr.red\",\n    \"bunkr.media\",\n    \"bunkr.site\",\n]\nLEGACY_DOMAINS = {\n    \"bunkr.ax\",\n    \"bunkr.cat\",\n    \"bunkr.ru\",\n    \"bunkrr.ru\",\n    \"bunkr.su\",\n    \"bunkrr.su\",\n    \"bunkr.la\",\n    \"bunkr.is\",\n    \"bunkr.to\",\n}\nCF_DOMAINS = set()\n\n\nclass BunkrAlbumExtractor(LolisafeAlbumExtractor):\n    \"\"\"Extractor for bunkr.si albums\"\"\"\n    category = \"bunkr\"\n    root = \"https://bunkr.si\"\n    root_dl = \"https://get.bunkrr.su\"\n    root_api = \"https://apidl.bunkr.ru\"\n    archive_fmt = \"{album_id}_{id|id_url|slug}\"\n    pattern = BASE_PATTERN + r\"/a/([^/?#]+)\"\n    example = \"https://bunkr.si/a/ID\"\n\n    def __init__(self, match):\n        LolisafeAlbumExtractor.__init__(self, match)\n        domain = self.groups[0] or self.groups[1]\n        if domain not in LEGACY_DOMAINS:\n            self.root = \"https://\" + domain\n\n    def _init(self):\n        LolisafeAlbumExtractor._init(self)\n\n        endpoint = self.config(\"endpoint\")\n        if not endpoint:\n            endpoint = self.root_api + \"/api/_001_v2\"\n        elif endpoint[0] == \"/\":\n            endpoint = self.root_api + endpoint\n\n        self.endpoint = endpoint\n        self.offset = 0\n\n    def skip_files(self, num):\n        self.offset = num\n        return num\n\n    def request(self, url, **kwargs):\n        kwargs[\"encoding\"] = \"utf-8\"\n        kwargs[\"allow_redirects\"] = False\n\n        while True:\n            try:\n                response = Extractor.request(self, url, **kwargs)\n                if response.status_code < 300:\n                    return response\n\n                # redirect\n                url = response.headers[\"Location\"]\n                if url[0] == \"/\":\n                    url = self.root + url\n                    continue\n                root, path = self._split(url)\n                if root not in CF_DOMAINS:\n                    continue\n                self.log.debug(\"Redirect to known CF challenge domain '%s'\",\n                               root)\n\n            except self.exc.HttpError as exc:\n                if exc.status != 403:\n                    raise\n\n                # CF challenge\n                root, path = self._split(url)\n                CF_DOMAINS.add(root)\n                self.log.debug(\"Added '%s' to CF challenge domains\", root)\n\n                try:\n                    DOMAINS.remove(root.rpartition(\"/\")[2])\n                except ValueError:\n                    pass\n                else:\n                    if not DOMAINS:\n                        raise self.exc.AbortExtraction(\n                            \"All Bunkr domains require solving a CF challenge\")\n\n            # select alternative domain\n            self.root = root = \"https://\" + random.choice(DOMAINS)\n            self.log.debug(\"Trying '%s' as fallback\", root)\n            url = root + path\n\n    def fetch_album(self, album_id):\n        # album metadata\n        page = self.request(f\"{self.root}/a/{album_id}?advanced=1\").text\n        title = text.unescape(text.unescape(text.extr(\n            page, 'property=\"og:title\" content=\"', '\"')))\n\n        # files\n        items = text.extr(\n            page, \"window.albumFiles = [\", \"</script>\").split(\"\\n},\\n\")\n\n        return self._extract_files(items), {\n            \"album_id\"   : album_id,\n            \"album_name\" : title,\n            \"album_size\" : text.extr(\n                page, '<span class=\"font-semibold\">(', ')'),\n            \"count\"      : len(items),\n        }\n\n    def _extract_files(self, items):\n        if self.offset:\n            items = util.advance(items, self.offset)\n\n        for item in items:\n            try:\n                data_id = text.extr(item, \" id: \", \",\").strip()\n                file = self._extract_file(data_id)\n\n                file[\"name\"] = util.json_loads(text.extr(\n                    item, 'original:', ',\\n').replace(\"\\\\'\", \"'\"))\n                file[\"slug\"] = util.json_loads(text.extr(\n                    item, 'slug: ', ',\\n').replace(\"\\\\'\", \"'\"))\n                file[\"uuid\"] = text.extr(\n                    item, 'name: \"', \".\")\n                file[\"size\"] = text.parse_int(text.extr(\n                    item, \"size:  \", \" ,\\n\"))\n                file[\"date\"] = self.parse_datetime(text.extr(\n                    item, 'timestamp: \"', '\"'), \"%H:%M:%S %d/%m/%Y\")\n\n                yield file\n            except self.exc.ControlException:\n                raise\n            except Exception as exc:\n                self.log.error(\"%s: %s\", exc.__class__.__name__, exc)\n                self.log.debug(\"%s\", item, exc_info=exc)\n                if isinstance(exc, self.exc.HttpError) and \\\n                        exc.status == 400 and \\\n                        exc.response.url.startswith(self.root_api):\n                    raise self.exc.AbortExtraction(\"Album deleted\")\n\n    def _extract_file(self, data_id):\n        referer = f\"{self.root_dl}/file/{data_id}\"\n        headers = {\"Referer\": referer, \"Origin\": self.root_dl}\n        data = self.request_json(self.endpoint, method=\"POST\", headers=headers,\n                                 json={\"id\": data_id})\n\n        if data.get(\"encrypted\"):\n            key = \"SECRET_KEY_\" + str(data[\"timestamp\"] // 3600)\n            file_url = util.decrypt_xor(data[\"url\"], key.encode())\n        else:\n            file_url = data[\"url\"]\n\n        return {\n            \"file\"          : file_url,\n            \"id_url\"        : data_id,\n            \"_http_headers\" : {\"Referer\": referer},\n            \"_http_validate\": self._validate,\n        }\n\n    def _validate(self, response):\n        if response.history and response.url.endswith(\n                (\"/maint.mp4\", \"/maintenance-vid.mp4\")):\n            self.log.warning(\"File server in maintenance mode\")\n            return False\n        return True\n\n    def _split(self, url):\n        pos = url.index(\"/\", 8)\n        return url[:pos], url[pos:]\n\n\nclass BunkrMediaExtractor(BunkrAlbumExtractor):\n    \"\"\"Extractor for bunkr.si media links\"\"\"\n    subcategory = \"media\"\n    directory_fmt = (\"{category}\",)\n    pattern = BASE_PATTERN + r\"(/[fvid]/[^/?#]+)\"\n    example = \"https://bunkr.si/f/FILENAME\"\n\n    def fetch_album(self, album_id):\n        try:\n            page = self.request(self.root + album_id).text\n            data_id = text.extr(page, 'data-file-id=\"', '\"')\n            file = self._extract_file(data_id)\n            file[\"name\"] = text.unquote(text.unescape(text.extr(\n                page, \"<h1\", \"<\").rpartition(\">\")[2]))\n            file[\"slug\"] = album_id.rpartition(\"/\")[2]\n            file[\"uuid\"] = text.extr(page, \"/thumbs/\", \".\")\n        except Exception as exc:\n            self.log.error(\"%s: %s\", exc.__class__.__name__, exc)\n            return (), {}\n\n        album_id, album_name, album_size = self.cache(\n            self._album_info, text.extr(page, ' href=\"../a/', '\"'))\n        return (file,), {\n            \"album_id\"  : album_id,\n            \"album_name\": album_name,\n            \"album_size\": album_size,\n            \"count\"     : 1,\n        }\n\n    def _album_info(self, album_id):\n        if album_id:\n            try:\n                page = self.request(f\"{self.root}/a/{album_id}\").text\n                return (\n                    album_id,\n                    text.unescape(text.unescape(text.extr(\n                        page, 'property=\"og:title\" content=\"', '\"'))),\n                    text.extr(page, '<span class=\"font-semibold\">(', ')'),\n                )\n            except Exception:\n                pass\n        return album_id, \"\", -1\n"
  },
  {
    "path": "gallery_dl/extractor/catbox.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2022-2023 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://catbox.moe/\"\"\"\n\nfrom .common import GalleryExtractor, Extractor, Message\nfrom .. import text\n\n\nclass CatboxAlbumExtractor(GalleryExtractor):\n    \"\"\"Extractor for catbox albums\"\"\"\n    category = \"catbox\"\n    subcategory = \"album\"\n    root = \"https://catbox.moe\"\n    filename_fmt = \"{filename}.{extension}\"\n    directory_fmt = (\"{category}\", \"{album_name} ({album_id})\")\n    archive_fmt = \"{album_id}_{filename}\"\n    pattern = r\"(?:https?://)?(?:www\\.)?catbox\\.moe(/c/[^/?#]+)\"\n    example = \"https://catbox.moe/c/ID\"\n\n    def metadata(self, page):\n        extr = text.extract_from(page)\n        return {\n            \"album_id\"   : self.page_url.rpartition(\"/\")[2],\n            \"album_name\" : text.unescape(extr(\"<h1>\", \"<\")),\n            \"date\"       : self.parse_datetime(extr(\n                \"<p>Created \", \"<\"), \"%B %d %Y\"),\n            \"description\": text.unescape(extr(\"<p>\", \"<\")),\n        }\n\n    def images(self, page):\n        return [\n            (\"https://files.catbox.moe/\" + path, None)\n            for path in text.extract_iter(\n                page, \">https://files.catbox.moe/\", \"<\")\n        ]\n\n\nclass CatboxFileExtractor(Extractor):\n    \"\"\"Extractor for catbox files\"\"\"\n    category = \"catbox\"\n    subcategory = \"file\"\n    archive_fmt = \"{filename}\"\n    pattern = r\"(?:https?://)?(?:files|litter|de)\\.catbox\\.moe/([^/?#]+)\"\n    example = \"https://files.catbox.moe/NAME.EXT\"\n\n    def items(self):\n        url = text.ensure_http_scheme(self.url)\n        file = text.nameext_from_url(url, {\"url\": url})\n        yield Message.Directory, \"\", file\n        yield Message.Url, url, file\n"
  },
  {
    "path": "gallery_dl/extractor/cfake.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://cfake.com/\"\"\"\n\nfrom .common import Extractor, Message\nfrom .. import text\n\nBASE_PATTERN = r\"(?:https?://)?(?:www\\.)?cfake\\.com\"\n\n\nclass CfakeExtractor(Extractor):\n    \"\"\"Base class for cfake extractors\"\"\"\n    category = \"cfake\"\n    root = \"https://cfake.com\"\n    directory_fmt = (\"{category}\", \"{type}\", \"{type_name} ({type_id})\")\n    filename_fmt = \"{category}_{type_name}_{id}.{extension}\"\n    archive_fmt = \"{id}\"\n\n    def items(self):\n        type, type_name, type_id, sub_id, pnum = self.groups\n\n        if type.endswith(\"ies\"):\n            type = type[:-3] + \"y\"\n\n        kwdict = self.kwdict\n        kwdict[\"type\"] = type\n        kwdict[\"type_id\"] = text.parse_int(type_id)\n        kwdict[\"type_name\"] = text.unquote(type_name).replace(\"_\", \" \")\n        kwdict[\"sub_id\"] = text.parse_int(sub_id)\n        kwdict[\"page\"] = pnum = text.parse_int(pnum, 1)\n        yield Message.Directory, \"\", {}\n\n        base = f\"{self.root}/images/{type}/{type_name}/{type_id}\"\n        if sub_id:\n            base = f\"{base}/{sub_id}\"\n\n        while True:\n            url = base if pnum < 2 else f\"{base}/p{pnum}\"\n            page = self.request(url).text\n\n            # Extract and yield images\n            num = 0\n            for image in self._extract_images(page):\n                num += 1\n                image[\"num\"] = num + (pnum - 1) * 50\n                url = image[\"url\"]\n                yield Message.Url, url, text.nameext_from_url(url, image)\n\n            # Check for next page\n            if not num or not (pnum := self._check_pagination(page)):\n                return\n            kwdict[\"page\"] = pnum\n\n    def _extract_images(self, page):\n        \"\"\"Extract image URLs and metadata from a gallery page\"\"\"\n        for item in text.extract_iter(\n                page, '<a href=\"javascript:showimage(', '</div></div>'):\n\n            # Extract image path from showimage call\n            # Format: 'big.php?show=2025/filename.jpg&id_picture=...\n            show_param = text.extr(item, \"show=\", \"&\")\n            if not show_param:\n                continue\n\n            # Extract metadata\n            picture_id = text.extr(item, \"id_picture=\", \"&\")\n            name_param = text.extr(item, \"p_name=\", \"'\")\n\n            # Extract date\n            date = text.extr(item, 'id=\"date_vignette\">', '</div>')\n\n            # Extract rating\n            rating_text = text.extr(item, 'class=\"current-rating\"', '</li>')\n            rating = text.extr(rating_text, 'width:', 'px')\n\n            # Convert thumbnail path to full image path\n            # show_param is like \"2025/filename.jpg\"\n            image_url = f\"{self.root}/medias/photos/{show_param}\"\n\n            yield {\n                \"url\": image_url,\n                \"id\": text.parse_int(picture_id) if picture_id else 0,\n                \"name\": text.unescape(name_param) if name_param else \"\",\n                \"date\": date,\n                \"rating\": rating,\n            }\n\n    def _check_pagination(self, page):\n        \"\"\"Check if there are more pages and return next page number\"\"\"\n        # Look for current page indicator\n        # Format: id=\"num_page_current\" ><a href=\".../ p1\">1</a>\n        current_section = text.extr(\n            page, 'id=\"num_page_current\"', '</div>')\n        if not current_section:\n            return None\n\n        # Extract current page number from the link text\n        current_page_str = text.extr(current_section, '\">', '</a>')\n        if not current_page_str:\n            return None\n\n        current_page = text.parse_int(current_page_str)\n        if not current_page:\n            return None\n\n        next_page = current_page + 1\n\n        # Check if next page link exists anywhere in the page\n        # Look for href=\"/images/.../pN\" pattern\n        if f'/p{next_page}\"' in page or f'/p{next_page} ' in page:\n            return next_page\n\n        return None\n\n\nclass CfakeCelebrityExtractor(CfakeExtractor):\n    \"\"\"Extractor for celebrity image galleries from cfake.com\"\"\"\n    subcategory = \"celebrity\"\n    pattern = (BASE_PATTERN + r\"/images/(celebrity)\"\n               r\"/([^/?#]+)/(\\d+)()(?:/p(\\d+))?\")\n    example = \"https://cfake.com/images/celebrity/NAME/123\"\n\n\nclass CfakeCategoryExtractor(CfakeExtractor):\n    \"\"\"Extractor for category image galleries from cfake.com\"\"\"\n    subcategory = \"category\"\n    pattern = (BASE_PATTERN + r\"/images/(categories)\"\n               r\"/([^/?#]+)/(\\d+)()(?:/p(\\d+))?\")\n    example = \"https://cfake.com/images/categories/NAME/123\"\n\n\nclass CfakeCreatedExtractor(CfakeExtractor):\n    \"\"\"Extractor for 'created' image galleries from cfake.com\"\"\"\n    subcategory = \"created\"\n    pattern = (BASE_PATTERN + r\"/images/(created)\"\n               r\"/([^/?#]+)/(\\d+)/(\\d+)(?:/p(\\d+))?\")\n    example = \"https://cfake.com/images/created/NAME/12345/123\"\n\n\nclass CfakeCountryExtractor(CfakeExtractor):\n    \"\"\"Extractor for country image galleries from cfake.com\"\"\"\n    subcategory = \"country\"\n    pattern = (BASE_PATTERN + r\"/images/(country)\"\n               r\"/([^/?#]+)/(\\d+)/(\\d+)(?:/p(\\d+))?\")\n    example = \"https://cfake.com/images/country/NAME/12345/123\"\n"
  },
  {
    "path": "gallery_dl/extractor/chevereto.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2023-2026 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for Chevereto galleries\"\"\"\n\nfrom .common import BaseExtractor, Message\nfrom .. import text, util\n\n\nclass CheveretoExtractor(BaseExtractor):\n    \"\"\"Base class for chevereto extractors\"\"\"\n    basecategory = \"chevereto\"\n    directory_fmt = (\"{category}\", \"{user}\", \"{album}\")\n    archive_fmt = \"{id}\"\n    parent = True\n\n    def _init(self):\n        self.path = self.groups[-1]\n\n    def _pagination(self, url, callback=None):\n        page = self.request(url).text\n\n        if len(page) < 45_000 and \"assword required\" in page and (\n                form := text.extr(page, \"<form \", \"</form\")):\n            page = self._password_submit(url, form) or page\n\n        if callback is not None:\n            callback(page)\n\n        while True:\n            for item in text.extract_iter(\n                    page, '<div class=\"list-item-image ', 'image-container'):\n                yield text.urljoin(self.root, text.extr(\n                    item, '<a href=\"', '\"'))\n\n            url = text.extr(page, 'data-pagination=\"next\" href=\"', '\"')\n            if not url:\n                return\n            url = text.unescape(url).replace(\"+\", \" \")\n            if url[0] == \"/\":\n                url = self.root + url\n            page = self.request(url).text\n\n    def _password_submit(self, url, form):\n        sources = getattr(self, \"_password_sources\", None)\n        if sources is None:\n            sources = self._password_sources = []\n            if pw := getattr(self, \"_password_last\", None):\n                sources.append(pw)\n            if pw := self.config(\"password\"):\n                if isinstance(pw, str):\n                    pw = pw.split(\",\")\n                sources.extend(pw)\n            sources.reverse()\n        sources = sources.copy()\n\n        page = None\n        tried = set()\n        while True:\n            pw = sources.pop() if sources else self.input(\"Password: \")\n            if not pw:\n                break\n            if pw in tried:\n                continue\n            self.log.debug(\"Submitting password '%s'\", pw)\n            data = {\n                \"auth_token\": text.unescape(text.extr(\n                    form, 'name=\"auth_token\" value=\"', '\"')),\n                \"content-password\": pw,\n            }\n            page = self.request(url, method=\"POST\", data=data).text\n            form = text.extr(page, \"<form \", \"</form\")\n            if not form:\n                CheveretoExtractor._password_last = pw\n                break\n            tried.add(pw)\n        return page\n\n\nBASE_PATTERN = CheveretoExtractor.update({\n    \"jpgfish\": {\n        \"root\": \"https://jpg7.cr\",\n        \"pattern\": r\"(?:www\\.)?jpe?g\\d?\\.(?:cr|su|pet|fish(?:ing)?|church)\",\n    },\n    \"imglike\": {\n        \"root\": \"https://imglike.com\",\n        \"pattern\": r\"(?:www\\.)?imglike\\.com\",\n    },\n})\n\n\nclass CheveretoFileExtractor(CheveretoExtractor):\n    \"\"\"Extractor for chevereto files\"\"\"\n    subcategory = \"file\"\n    pattern = BASE_PATTERN + r\"(/(?:im(?:g|age)|video|i)/[^/?#]+)\"\n    example = \"https://jpg7.cr/img/TITLE.ID\"\n\n    def items(self):\n        url = self.root + self.path\n        page = self.request(url).text\n        extr = text.extract_from(page)\n\n        type = text.extr(page, 'property=\"og:type\" content=\"', '\"')\n        title = extr('property=\"og:title\" content=\"', '\"')\n\n        if type == \"video\":\n            file = {\n                \"id\"       : self.path.rpartition(\".\")[2],\n                \"type\"     : \"video\",\n                \"title\"    : text.unescape(extr(\n                    'property=\"og:title\" content=\"', '\"')),\n                \"thumbnail\": extr(\n                    'property=\"og:image\" content=\"', '\"'),\n                \"url\"      : extr(\n                    'property=\"og:video\" content=\"', '\"'),\n                \"width\"    : text.parse_int(extr(\n                    'property=\"video:width\" content=\"', '\"')),\n                \"height\"   : text.parse_int(extr(\n                    'property=\"video:height\" content=\"', '\"')),\n                \"duration\" : extr(\n                    'class=\"far fa-clock\"></i>', \"—\"),\n                \"album\"    : extr(\n                    \"Added to <a\", \"</a>\"),\n                \"date\"     : self.parse_datetime_iso(extr(\n                    '<span title=\"', '\"')),\n                \"user\"     : extr('username: \"', '\"'),\n            }\n\n            album_url, _, album_name = file[\"album\"].rpartition(\">\")\n            file[\"album\"] = text.remove_html(album_name)\n            file[\"album_slug\"], _, file[\"album_id\"] = text.rextr(\n                album_url, \"/\", '\"').rpartition(\".\")\n\n            try:\n                min, _, sec = file[\"duration\"].partition(\":\")\n                file[\"duration\"] = int(min) * 60 + int(sec)\n            except Exception:\n                pass\n        else:\n            url = (extr('<meta property=\"og:image\" content=\"', '\"') or\n                   extr('url: \"', '\"'))\n            if not url or url.endswith(\"/loading.svg\"):\n                pos = page.find(\" download=\")\n                url = text.rextr(page, 'href=\"', '\"', pos)\n                if not url.startswith(\"https://\"):\n                    url = util.decrypt_xor(\n                        url, b\"seltilovessimpcity@simpcityhatesscrapers\",\n                        fromhex=True)\n\n            album_url, _, album_name = extr(\n                \"Added to <a\", \"</a>\").rpartition(\">\")\n            file = {\n                \"id\"   : self.path.rpartition(\"/\")[2].rpartition(\".\")[2],\n                \"url\"  : url,\n                \"type\" : type,\n                \"title\": text.unescape(title),\n                \"album\": text.remove_html(album_name),\n                \"date\" : self.parse_datetime_iso(extr('<span title=\"', '\"')),\n                \"user\" : extr('username: \"', '\"'),\n            }\n\n            file[\"album_slug\"], _, file[\"album_id\"] = text.rextr(\n                album_url, \"/\", '\"').rpartition(\".\")\n\n        text.nameext_from_url(file[\"url\"], file)\n        yield Message.Directory, \"\", file\n        yield Message.Url, file[\"url\"], file\n\n\nclass CheveretoAlbumExtractor(CheveretoExtractor):\n    \"\"\"Extractor for chevereto albums\"\"\"\n    subcategory = \"album\"\n    pattern = BASE_PATTERN + r\"(/a(?:lbum)?/[^/?#]+(?:/sub)?)\"\n    example = \"https://jpg7.cr/album/TITLE.ID\"\n\n    def items(self):\n        url = self.root + self.path\n        data = {\"_extractor\": CheveretoFileExtractor}\n\n        if self.path.endswith(\"/sub\"):\n            albums = self._pagination(url)\n        else:\n            albums = (url,)\n\n        for album in albums:\n            for self.kwdict[\"num\"], item_url in enumerate(self._pagination(\n                    album, self._extract_metadata_album), 1):\n                yield Message.Queue, item_url, data\n\n    def _extract_metadata_album(self, page):\n        url, pos = text.extract(\n            page, 'property=\"og:url\" content=\"', '\"')\n        title, pos = text.extract(\n            page, 'property=\"og:title\" content=\"', '\"', pos)\n\n        kwdict = self.kwdict\n        kwdict[\"album_slug\"], _, kwdict[\"album_id\"] = \\\n            url[url.rfind(\"/\")+1:].rpartition(\".\")\n        kwdict[\"album\"] = text.unescape(title)\n        kwdict[\"count\"] = text.parse_int(text.extract(\n            page, 'data-text=\"image-count\">', \"<\", pos)[0])\n\n\nclass CheveretoCategoryExtractor(CheveretoExtractor):\n    \"\"\"Extractor for chevereto galleries\"\"\"\n    subcategory = \"category\"\n    pattern = BASE_PATTERN + r\"(/category/[^/?#]+)\"\n    example = \"https://imglike.com/category/TITLE\"\n\n    def items(self):\n        data = {\"_extractor\": CheveretoFileExtractor}\n        for image in self._pagination(self.root + self.path):\n            yield Message.Queue, image, data\n\n\nclass CheveretoUserExtractor(CheveretoExtractor):\n    \"\"\"Extractor for chevereto users\"\"\"\n    subcategory = \"user\"\n    pattern = BASE_PATTERN + r\"(/[^/?#]+(?:/albums)?)\"\n    example = \"https://jpg7.cr/USER\"\n\n    def items(self):\n        data_file = {\"_extractor\": CheveretoFileExtractor}\n        data_album = {\"_extractor\": CheveretoAlbumExtractor}\n        for url in self._pagination(self.root + self.path):\n            data = (data_album if \"/album/\" in url or \"/a/\" in url else\n                    data_file)\n            yield Message.Queue, url, data\n"
  },
  {
    "path": "gallery_dl/extractor/cien.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2024-2025 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://ci-en.net/\"\"\"\n\nfrom .common import Extractor, Message\nfrom .. import text\n\nBASE_PATTERN = r\"(?:https?://)?ci-en\\.(?:net|dlsite\\.com)\"\n\n\nclass CienExtractor(Extractor):\n    category = \"cien\"\n    root = \"https://ci-en.net\"\n    request_interval = (1.0, 2.0)\n\n    def __init__(self, match):\n        self.root = text.root_from_url(match[0])\n        Extractor.__init__(self, match)\n\n    def _init(self):\n        self.cookies.set(\"accepted_rating\", \"r18g\", domain=\"ci-en.dlsite.com\")\n\n    def _pagination_articles(self, url, params):\n        data = {\"_extractor\": CienArticleExtractor}\n        params[\"page\"] = text.parse_int(params.get(\"page\"), 1)\n\n        while True:\n            page = self.request(url, params=params).text\n\n            for card in text.extract_iter(\n                    page, ' class=\"c-cardCase-item', '</figure>'):\n                article_url = text.extr(card, ' href=\"', '\"')\n                yield Message.Queue, article_url, data\n\n            if ' rel=\"next\"' not in page:\n                return\n            params[\"page\"] += 1\n\n\nclass CienArticleExtractor(CienExtractor):\n    subcategory = \"article\"\n    filename_fmt = \"{num:>02} {filename}.{extension}\"\n    directory_fmt = (\"{category}\", \"{author[name]}\", \"{post_id} {name}\")\n    archive_fmt = \"{post_id}_{num}\"\n    pattern = BASE_PATTERN + r\"/creator/(\\d+)/article/(\\d+)\"\n    example = \"https://ci-en.net/creator/123/article/12345\"\n\n    def items(self):\n        author_id, post_id = self.groups\n        url = f\"{self.root}/creator/{author_id}/article/{post_id}\"\n        page = self.request(url, notfound=True).text\n\n        files = self._extract_files(page)\n        post = self._extract_jsonld(page)[0]\n        post[\"post_url\"] = url\n        post[\"post_id\"] = text.parse_int(post_id)\n        post[\"count\"] = len(files)\n        post[\"date\"] = self.parse_datetime_iso(post[\"datePublished\"])\n\n        try:\n            post[\"author\"][\"id\"] = text.parse_int(author_id)\n            del post[\"publisher\"]\n            del post[\"sameAs\"]\n        except Exception:\n            pass\n\n        yield Message.Directory, \"\", post\n        for post[\"num\"], file in enumerate(files, 1):\n            post.update(file)\n            if \"extension\" not in file:\n                text.nameext_from_url(file[\"url\"], post)\n            yield Message.Url, file[\"url\"], post\n\n    def _extract_files(self, page):\n        files = []\n\n        filetypes = self.config(\"files\")\n        if filetypes is None:\n            self._extract_files_image(page, files)\n            self._extract_files_video(page, files)\n            self._extract_files_download(page, files)\n            self._extract_files_gallery(page, files)\n        else:\n            generators = {\n                \"image\"   : self._extract_files_image,\n                \"video\"   : self._extract_files_video,\n                \"download\": self._extract_files_download,\n                \"gallery\" : self._extract_files_gallery,\n                \"gallerie\": self._extract_files_gallery,\n            }\n            if isinstance(filetypes, str):\n                filetypes = filetypes.split(\",\")\n            for ft in filetypes:\n                generators[ft.rstrip(\"s\")](page, files)\n\n        return files\n\n    def _extract_files_image(self, page, files):\n        for image in text.extract_iter(\n                page, 'class=\"file-player-image\"', \"</figure>\"):\n            size = text.extr(image, ' data-size=\"', '\"')\n            w, _, h = size.partition(\"x\")\n\n            files.append({\n                \"url\"   : text.extr(image, ' data-raw=\"', '\"'),\n                \"width\" : text.parse_int(w),\n                \"height\": text.parse_int(h),\n                \"type\"  : \"image\",\n            })\n\n    def _extract_files_video(self, page, files):\n        for video in text.extract_iter(\n                page, \"<vue-file-player\", \"</vue-file-player>\"):\n            path = text.extr(video, ' base-path=\"', '\"')\n            name = text.extr(video, ' file-name=\"', '\"')\n            auth = text.extr(video, ' auth-key=\"', '\"')\n\n            file = text.nameext_from_url(name)\n            file[\"url\"] = f\"{path}video-web.mp4?{auth}\"\n            file[\"type\"] = \"video\"\n            files.append(file)\n\n    def _extract_files_download(self, page, files):\n        for download in text.extract_iter(\n                page, 'class=\"downloadBlock', \"</div>\"):\n            name = text.extr(download, \"<p>\", \"<\")\n\n            file = text.nameext_from_url(name.rpartition(\" \")[0])\n            file[\"url\"] = text.extr(download, ' href=\"', '\"')\n            file[\"type\"] = \"download\"\n            files.append(file)\n\n    def _extract_files_gallery(self, page, files):\n        for gallery in text.extract_iter(\n                page, \"<vue-image-gallery\", \"</vue-image-gallery>\"):\n\n            url = self.root + \"/api/creator/gallery/images\"\n            params = {\n                \"hash\"      : text.extr(gallery, ' hash=\"', '\"'),\n                \"gallery_id\": text.extr(gallery, ' gallery-id=\"', '\"'),\n                \"time\"      : text.extr(gallery, ' time=\"', '\"'),\n            }\n            data = self.request_json(url, params=params)\n            url = self.root + \"/api/creator/gallery/imagePath\"\n\n            for params[\"page\"], params[\"file_id\"] in enumerate(\n                    data[\"imgList\"]):\n                path = self.request_json(url, params=params)[\"path\"]\n\n                file = params.copy()\n                file[\"url\"] = path\n                files.append(file)\n\n\nclass CienCreatorExtractor(CienExtractor):\n    subcategory = \"creator\"\n    pattern = BASE_PATTERN + r\"/creator/(\\d+)(?:/article(?:\\?([^#]+))?)?/?$\"\n    example = \"https://ci-en.net/creator/123\"\n\n    def items(self):\n        url = f\"{self.root}/creator/{self.groups[0]}/article\"\n        params = text.parse_query(self.groups[1])\n        params[\"mode\"] = \"list\"\n        return self._pagination_articles(url, params)\n\n\nclass CienRecentExtractor(CienExtractor):\n    subcategory = \"recent\"\n    pattern = BASE_PATTERN + r\"/mypage/recent(?:\\?([^#]+))?\"\n    example = \"https://ci-en.net/mypage/recent\"\n\n    def items(self):\n        url = self.root + \"/mypage/recent\"\n        params = text.parse_query(self.groups[0])\n        return self._pagination_articles(url, params)\n\n\nclass CienFollowingExtractor(CienExtractor):\n    subcategory = \"following\"\n    pattern = BASE_PATTERN + r\"/mypage/subscription(/following)?\"\n    example = \"https://ci-en.net/mypage/subscription\"\n\n    def items(self):\n        url = self.root + \"/mypage/subscription\" + (self.groups[0] or \"\")\n        page = self.request(url).text\n        data = {\"_extractor\": CienCreatorExtractor}\n\n        for subscription in text.extract_iter(\n                page, 'class=\"c-grid-subscriptionInfo', '</figure>'):\n            url = text.extr(subscription, ' href=\"', '\"')\n            yield Message.Queue, url, data\n"
  },
  {
    "path": "gallery_dl/extractor/civitai.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2024-2026 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://www.civitai.com/\"\"\"\n\nfrom .common import Extractor, Message, Dispatch\nfrom .. import text, util\nimport itertools\nimport time\n\nBASE_PATTERN = r\"(?:https?://)?civitai\\.com\"\nUSER_PATTERN = BASE_PATTERN + r\"/user/([^/?#]+)\"\n\n\nclass CivitaiExtractor(Extractor):\n    \"\"\"Base class for civitai extractors\"\"\"\n    category = \"civitai\"\n    root = \"https://civitai.com\"\n    directory_fmt = (\"{category}\", \"{user[username]}\", \"images\")\n    filename_fmt = \"{file[id]}.{extension}\"\n    archive_fmt = \"{file[uuid]}\"\n    request_interval = (0.5, 1.5)\n\n    def _init(self):\n        if self.config(\"api\") == \"rest\":\n            self.log.debug(\"Using REST API\")\n            self.api = CivitaiRestAPI(self)\n        else:\n            self.log.debug(\"Using tRPC API\")\n            self.api = CivitaiTrpcAPI(self)\n\n        if quality := self.config(\"quality\"):\n            if not isinstance(quality, str):\n                quality = \",\".join(quality)\n            self._image_quality = quality\n            self._image_ext = (\"png\" if quality == \"original=true\" else \"jpg\")\n        else:\n            self._image_quality = \"original=true\"\n            self._image_ext = \"png\"\n\n        if quality_video := self.config(\"quality-videos\"):\n            if not isinstance(quality_video, str):\n                quality_video = \",\".join(quality_video)\n            if quality_video[0] == \"+\":\n                quality_video = (self._image_quality + \",\" +\n                                 quality_video.lstrip(\"+,\"))\n            self._video_quality = quality_video\n        elif quality_video is not None and quality:\n            self._video_quality = self._image_quality\n        else:\n            self._video_quality = \"original=true,quality=100\"\n        self._video_ext = \"webm\"\n\n        if metadata := self.config(\"metadata\"):\n            if isinstance(metadata, str):\n                metadata = metadata.split(\",\")\n            elif not isinstance(metadata, (list, tuple)):\n                metadata = {\"generation\", \"version\", \"post\", \"tags\"}\n            self._meta_generation = (\"generation\" in metadata)\n            self._meta_version = (\"version\" in metadata)\n            self._meta_post = (\"post\" in metadata)\n            self._meta_tags = (\"tags\" in metadata)\n        else:\n            self._meta_generation = self._meta_version = self._meta_post = \\\n                self._meta_tags = False\n\n    def items(self):\n        if models := self.models():\n            data = {\"_extractor\": CivitaiModelExtractor}\n            for model in models:\n                url = f\"{self.root}/models/{model['id']}\"\n                yield Message.Queue, url, data\n            return\n\n        if posts := self.posts():\n            for post in posts:\n\n                if \"images\" in post:\n                    images = post[\"images\"]\n                else:\n                    images = self.api.images_post(post[\"id\"])\n\n                post = self.api.post(post[\"id\"])\n                post[\"date\"] = self.parse_datetime_iso(post[\"publishedAt\"])\n                data = {\n                    \"post\": post,\n                    \"user\": post.pop(\"user\"),\n                }\n                if self._meta_version:\n                    data[\"model\"], data[\"version\"] = \\\n                        self._extract_meta_version(post)\n\n                yield Message.Directory, \"\", data\n                for file in self._image_results(images):\n                    file.update(data)\n                    yield Message.Url, file[\"url\"], file\n            return\n\n        if images := self.images():\n            for file in images:\n\n                data = {\n                    \"file\": file,\n                    \"user\": file.pop(\"user\"),\n                }\n\n                if self._meta_generation:\n                    data[\"generation\"] = self._extract_meta_generation(file)\n                if self._meta_tags:\n                    data[\"tags\"] = self._extract_meta_tags(file)\n                if self._meta_version:\n                    data[\"model\"], data[\"version\"] = \\\n                        self._extract_meta_version(file, False)\n                    if \"post\" in file:\n                        data[\"post\"] = file.pop(\"post\")\n                if self._meta_post and \"post\" not in data:\n                    data[\"post\"] = post = self._extract_meta_post(file)\n                    if post:\n                        post.pop(\"user\", None)\n                file[\"date\"] = self.parse_datetime_iso(file[\"createdAt\"])\n\n                data[\"url\"] = url = self._url(file)\n                text.nameext_from_url(url, data)\n                if not data[\"extension\"]:\n                    data[\"extension\"] = (\n                        self._video_ext if file.get(\"type\") == \"video\" else\n                        self._image_ext)\n                yield Message.Directory, \"\", data\n                yield Message.Url, url, data\n            return\n\n    def models(self):\n        return ()\n\n    def posts(self):\n        return ()\n\n    def images(self):\n        return ()\n\n    def _url(self, image):\n        url = image[\"url\"]\n        video = image.get(\"type\") == \"video\"\n        quality = self._video_quality if video else self._image_quality\n\n        if \"/\" in url:\n            parts = url.rsplit(\"/\", 3)\n            image[\"uuid\"] = parts[1]\n            parts[2] = quality\n            return \"/\".join(parts)\n\n        image[\"uuid\"] = url\n        name = image.get(\"name\")\n        if not name:\n            if mime := image.get(\"mimeType\"):\n                name = f\"{image.get('id')}.{mime.rpartition('/')[2]}\"\n            else:\n                ext = self._video_ext if video else self._image_ext\n                name = f\"{image.get('id')}.{ext}\"\n        return (f\"https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA\"\n                f\"/{url}/{quality}/{name}\")\n\n    def _image_results(self, images):\n        for num, file in enumerate(images, 1):\n            data = text.nameext_from_url(file[\"url\"], {\n                \"num\" : num,\n                \"file\": file,\n                \"url\" : self._url(file),\n            })\n            if not data[\"extension\"]:\n                data[\"extension\"] = (\n                    self._video_ext if file.get(\"type\") == \"video\" else\n                    self._image_ext)\n            if \"id\" not in file and data[\"filename\"].isdecimal():\n                file[\"id\"] = text.parse_int(data[\"filename\"])\n            if \"date\" not in file:\n                file[\"date\"] = self.parse_datetime_iso(file[\"createdAt\"])\n            if self._meta_generation:\n                file[\"generation\"] = self._extract_meta_generation(file)\n            if self._meta_tags:\n                file[\"tags\"] = self._extract_meta_tags(file)\n            yield data\n\n    def _image_reactions(self):\n        self._require_auth()\n\n        params = self.params\n        params[\"authed\"] = True\n        params[\"useIndex\"] = False\n        if \"reactions\" not in params:\n            params[\"reactions\"] = (\"Like\", \"Dislike\", \"Heart\", \"Laugh\", \"Cry\")\n        return self.api.images(params)\n\n    def _require_auth(self):\n        if \"Authorization\" not in self.api.headers and \\\n                not self.cookies.get(\n                \"__Secure-civitai-token\", domain=\".civitai.com\"):\n            raise self.extractor.exc.AuthRequired(\n                (\"api-key\", \"authenticated cookies\"))\n\n    def _parse_query(self, value):\n        return text.parse_query_list(\n            value, {\"tags\", \"reactions\", \"baseModels\", \"tools\", \"techniques\",\n                    \"types\", \"fileFormats\"})\n\n    def _extract_meta_generation(self, image):\n        try:\n            return self.api.image_generationdata(image[\"id\"])\n        except Exception as exc:\n            return self.log.traceback(exc)\n\n    def _extract_meta_post(self, image):\n        try:\n            post = self.api.post(image[\"postId\"])\n            post[\"date\"] = self.parse_datetime_iso(post[\"publishedAt\"])\n            return post\n        except Exception as exc:\n            return self.log.traceback(exc)\n\n    def _extract_meta_tags(self, image):\n        try:\n            return self.api.tag_getvotabletags(image[\"id\"])\n        except Exception as exc:\n            return self.log.traceback(exc)\n\n    def _extract_meta_version(self, item, is_post=True):\n        try:\n            if version_id := self._extract_version_id(item, is_post):\n                version = self.cache(self.api.model_version, version_id).copy()\n                return version.pop(\"model\", None), version\n        except Exception as exc:\n            self.log.traceback(exc)\n        return None, None\n\n    def _extract_version_id(self, item, is_post=True):\n        if version_id := item.get(\"modelVersionId\"):\n            return version_id\n        if version_ids := item.get(\"modelVersionIds\"):\n            return version_ids[0]\n        if version_ids := item.get(\"modelVersionIdsManual\"):\n            return version_ids[0]\n\n        if is_post:\n            return None\n\n        item[\"post\"] = post = self.api.post(item[\"postId\"])\n        post.pop(\"user\", None)\n        return self._extract_version_id(post)\n\n\nclass CivitaiModelExtractor(CivitaiExtractor):\n    subcategory = \"model\"\n    directory_fmt = (\"{category}\", \"{user[username]}\",\n                     \"{model[id]}{model[name]:? //}\",\n                     \"{version[id]}{version[name]:? //}\")\n    pattern = BASE_PATTERN + r\"/models/(\\d+)(?:/?\\?modelVersionId=(\\d+))?\"\n    example = \"https://civitai.com/models/12345/TITLE\"\n\n    def items(self):\n        model_id, version_id = self.groups\n        model = self.api.model(model_id)\n\n        if \"user\" in model:\n            user = model[\"user\"]\n            del model[\"user\"]\n        else:\n            user = model[\"creator\"]\n            del model[\"creator\"]\n        versions = model[\"modelVersions\"]\n        del model[\"modelVersions\"]\n\n        if version_id:\n            version_id = int(version_id)\n            for version in versions:\n                if version[\"id\"] == version_id:\n                    break\n            else:\n                version = self.cache(self.api.model_version, version_id)\n            versions = (version,)\n\n        for version in versions:\n            version[\"date\"] = self.parse_datetime_iso(version[\"createdAt\"])\n\n            data = {\n                \"model\"  : model,\n                \"version\": version,\n                \"user\"   : user,\n            }\n\n            yield Message.Directory, \"\", data\n            for file in self._extract_files(model, version, user):\n                file.update(data)\n                yield Message.Url, file[\"url\"], file\n\n    def _extract_files(self, model, version, user):\n        filetypes = self.config(\"files\")\n        if filetypes is None:\n            return self._extract_files_image(model, version, user)\n\n        generators = {\n            \"model\"   : self._extract_files_model,\n            \"image\"   : self._extract_files_image,\n            \"gallery\" : self._extract_files_gallery,\n            \"gallerie\": self._extract_files_gallery,\n        }\n        if isinstance(filetypes, str):\n            filetypes = filetypes.split(\",\")\n\n        return itertools.chain.from_iterable(\n            generators[ft.rstrip(\"s\")](model, version, user)\n            for ft in filetypes\n        )\n\n    def _extract_files_model(self, model, version, user):\n        files = []\n\n        for num, file in enumerate(version[\"files\"], 1):\n            name, sep, ext = file[\"name\"].rpartition(\".\")\n            if not sep:\n                name = ext\n                ext = \"bin\"\n            file[\"uuid\"] = f\"model-{model['id']}-{version['id']}-{file['id']}\"\n            files.append({\n                \"num\"      : num,\n                \"file\"     : file,\n                \"filename\" : name,\n                \"extension\": ext,\n                \"url\"      : (\n                    file.get(\"downloadUrl\") or\n                    f\"{self.root}/api/download/models/{version['id']}\"),\n                \"_http_headers\" : {\n                    \"Authorization\": self.api.headers.get(\"Authorization\")},\n                \"_http_validate\": self._validate_file_model,\n            })\n\n        return files\n\n    def _extract_files_image(self, model, version, user):\n        if \"images\" in version:\n            images = version[\"images\"]\n        else:\n            params = {\n                \"modelVersionId\": version[\"id\"],\n                \"prioritizedUserIds\": (user[\"id\"],),\n                \"period\" : self.api._param_period(),\n                \"sort\"   : self.api._param_sort(),\n                \"limit\"  : 20,\n                \"pending\": True,\n            }\n            images = self.api.images(params, defaults=False)\n\n        return self._image_results(images)\n\n    def _extract_files_gallery(self, model, version, user):\n        images = self.api.images_gallery(model, version, user)\n        return self._image_results(images)\n\n    def _validate_file_model(self, response):\n        if response.headers.get(\"Content-Type\", \"\").startswith(\"text/html\"):\n            alert = text.extr(\n                response.text, 'mantine-Alert-message\">', \"</div></div></div>\")\n            if alert:\n                msg = f\"\\\"{text.remove_html(alert)}\\\" - 'api-key' required\"\n            else:\n                msg = \"'api-key' required to download this file\"\n            self.log.warning(msg)\n            return False\n        return True\n\n\nclass CivitaiImageExtractor(CivitaiExtractor):\n    subcategory = \"image\"\n    pattern = BASE_PATTERN + r\"/images/(\\d+)\"\n    example = \"https://civitai.com/images/12345\"\n\n    def images(self):\n        return self.api.image(self.groups[0])\n\n\nclass CivitaiCollectionExtractor(CivitaiExtractor):\n    subcategory = \"collection\"\n    directory_fmt = (\"{category}\", \"{user_collection[username]}\",\n                     \"collections\", \"{collection[id]}{collection[name]:? //}\")\n    pattern = BASE_PATTERN + r\"/collections/(\\d+)\"\n    example = \"https://civitai.com/collections/12345\"\n\n    def images(self):\n        cid = int(self.groups[0])\n        self.kwdict[\"collection\"] = col = self.api.collection(cid)\n        self.kwdict[\"user_collection\"] = col.pop(\"user\", None)\n\n        params = {\n            \"collectionId\"  : cid,\n            \"period\"        : self.api._param_period(),\n            \"sort\"          : self.api._param_sort(),\n            \"browsingLevel\" : self.api.nsfw,\n            \"include\"       : (\"cosmetics\",),\n        }\n        return self.api.images(params, defaults=False)\n\n\nclass CivitaiPostExtractor(CivitaiExtractor):\n    subcategory = \"post\"\n    directory_fmt = (\"{category}\", \"{username|user[username]}\", \"posts\",\n                     \"{post[id]}{post[title]:? //}\")\n    pattern = BASE_PATTERN + r\"/posts/(\\d+)\"\n    example = \"https://civitai.com/posts/12345\"\n\n    def posts(self):\n        return ({\"id\": int(self.groups[0])},)\n\n\nclass CivitaiTagExtractor(CivitaiExtractor):\n    subcategory = \"tag\"\n    pattern = BASE_PATTERN + r\"/tag/([^/?&#]+)\"\n    example = \"https://civitai.com/tag/TAG\"\n\n    def models(self):\n        tag = text.unquote(self.groups[0])\n        return self.api.models_tag(tag)\n\n\nclass CivitaiSearchModelsExtractor(CivitaiExtractor):\n    subcategory = \"search-models\"\n    pattern = BASE_PATTERN + r\"/search/models\\?([^#]+)\"\n    example = \"https://civitai.com/search/models?query=QUERY\"\n\n    def models(self):\n        params = self._parse_query(self.groups[0])\n        return CivitaiSearchAPI(self).search_models(\n            params.get(\"query\"), params.get(\"sortBy\"), self.api.nsfw)\n\n\nclass CivitaiSearchImagesExtractor(CivitaiExtractor):\n    subcategory = \"search-images\"\n    pattern = BASE_PATTERN + r\"/search/images\\?([^#]+)\"\n    example = \"https://civitai.com/search/images?query=QUERY\"\n\n    def images(self):\n        params = self._parse_query(self.groups[0])\n        return CivitaiSearchAPI(self).search_images(\n            params.get(\"query\"), params.get(\"sortBy\"), self.api.nsfw)\n\n\nclass CivitaiModelsExtractor(CivitaiExtractor):\n    subcategory = \"models\"\n    pattern = BASE_PATTERN + r\"/models(?:/?\\?([^#]+))?(?:$|#)\"\n    example = \"https://civitai.com/models\"\n\n    def models(self):\n        params = self._parse_query(self.groups[0])\n        return self.api.models(params)\n\n\nclass CivitaiImagesExtractor(CivitaiExtractor):\n    subcategory = \"images\"\n    pattern = BASE_PATTERN + r\"/images(?:/?\\?([^#]+))?(?:$|#)\"\n    example = \"https://civitai.com/images\"\n\n    def images(self):\n        params = self._parse_query(self.groups[0])\n        params[\"types\"] = (\"image\",)\n        return self.api.images(params)\n\n\nclass CivitaiVideosExtractor(CivitaiExtractor):\n    subcategory = \"videos\"\n    pattern = BASE_PATTERN + r\"/videos(?:/?\\?([^#]+))?(?:$|#)\"\n    example = \"https://civitai.com/videos\"\n\n    def images(self):\n        params = self._parse_query(self.groups[0])\n        params[\"types\"] = (\"video\",)\n        return self.api.images(params)\n\n\nclass CivitaiPostsExtractor(CivitaiExtractor):\n    subcategory = \"posts\"\n    pattern = BASE_PATTERN + r\"/posts(?:/?\\?([^#]+))?(?:$|#)\"\n    example = \"https://civitai.com/posts\"\n\n    def posts(self):\n        params = self._parse_query(self.groups[0])\n        return self.api.posts(params)\n\n\nclass CivitaiUserExtractor(Dispatch, CivitaiExtractor):\n    pattern = USER_PATTERN + r\"/?(?:$|\\?|#)\"\n    example = \"https://civitai.com/user/USER\"\n\n    def items(self):\n        base = f\"{self.root}/user/{self.groups[0]}/\"\n        return self._dispatch_extractors((\n            (CivitaiUserModelsExtractor, base + \"models\"),\n            (CivitaiUserPostsExtractor , base + \"posts\"),\n            (CivitaiUserImagesExtractor, base + \"images\"),\n            (CivitaiUserVideosExtractor, base + \"videos\"),\n            (CivitaiUserCollectionsExtractor, base + \"collections\"),\n        ), (\"user-images\", \"user-videos\"))\n\n\nclass CivitaiUserModelsExtractor(CivitaiExtractor):\n    subcategory = \"user-models\"\n    pattern = USER_PATTERN + r\"/models/?(?:\\?([^#]+))?\"\n    example = \"https://civitai.com/user/USER/models\"\n\n    def models(self):\n        user, query = self.groups\n        params = self._parse_query(query)\n        params[\"username\"] = text.unquote(user)\n        return self.api.models(params)\n\n\nclass CivitaiUserPostsExtractor(CivitaiExtractor):\n    subcategory = \"user-posts\"\n    directory_fmt = (\"{category}\", \"{username|user[username]}\", \"posts\",\n                     \"{post[id]}{post[title]:? //}\")\n    pattern = USER_PATTERN + r\"/posts/?(?:\\?([^#]+))?\"\n    example = \"https://civitai.com/user/USER/posts\"\n\n    def posts(self):\n        user, query = self.groups\n        params = self._parse_query(query)\n        params[\"username\"] = text.unquote(user)\n        return self.api.posts(params)\n\n\nclass CivitaiUserImagesExtractor(CivitaiExtractor):\n    subcategory = \"user-images\"\n    pattern = USER_PATTERN + r\"/images/?(?:\\?([^#]+))?\"\n    example = \"https://civitai.com/user/USER/images\"\n\n    def __init__(self, match):\n        user, query = match.groups()\n        self.params = self._parse_query(query)\n        self.params[\"types\"] = (\"image\",)\n        if self.params.get(\"section\") == \"reactions\":\n            self.subcategory = \"reactions-images\"\n            self.images = self._image_reactions\n        else:\n            self.params[\"username\"] = text.unquote(user)\n        CivitaiExtractor.__init__(self, match)\n\n    def images(self):\n        return self.api.images(self.params)\n\n\nclass CivitaiUserVideosExtractor(CivitaiExtractor):\n    subcategory = \"user-videos\"\n    directory_fmt = (\"{category}\", \"{username|user[username]}\", \"videos\")\n    pattern = USER_PATTERN + r\"/videos/?(?:\\?([^#]+))?\"\n    example = \"https://civitai.com/user/USER/videos\"\n\n    def __init__(self, match):\n        user, query = match.groups()\n        self.params = self._parse_query(query)\n        self.params[\"types\"] = (\"video\",)\n        if self.params.get(\"section\") == \"reactions\":\n            self.subcategory = \"reactions-videos\"\n            self.images = self._image_reactions\n        else:\n            self.params[\"username\"] = text.unquote(user)\n        CivitaiExtractor.__init__(self, match)\n\n    images = CivitaiUserImagesExtractor.images\n\n\nclass CivitaiUserCollectionsExtractor(CivitaiExtractor):\n    subcategory = \"user-collections\"\n    pattern = USER_PATTERN + r\"/collections/?(?:\\?([^#]+))?\"\n    example = \"https://civitai.com/user/USER/collections\"\n\n    def items(self):\n        user, query = self.groups\n        params = self._parse_query(query)\n        params[\"userId\"] = self.api.user(text.unquote(user))[0][\"id\"]\n\n        base = self.root + \"/collections/\"\n        for collection in self.api.collections(params):\n            collection[\"_extractor\"] = CivitaiCollectionExtractor\n            yield Message.Queue, base + str(collection[\"id\"]), collection\n\n\nclass CivitaiGeneratedExtractor(CivitaiExtractor):\n    \"\"\"Extractor for your generated files feed\"\"\"\n    subcategory = \"generated\"\n    filename_fmt = \"{filename}.{extension}\"\n    directory_fmt = (\"{category}\", \"generated\")\n    pattern = BASE_PATTERN + \"/generate\"\n    example = \"https://civitai.com/generate\"\n\n    def items(self):\n        self._require_auth()\n\n        for gen in self.api.orchestrator_queryGeneratedImages():\n            gen[\"date\"] = self.parse_datetime_iso(gen[\"createdAt\"])\n            yield Message.Directory, \"\", gen\n            for step in gen.pop(\"steps\", ()):\n                for image in step.pop(\"images\", ()):\n                    data = {\"file\": image, **step, **gen}\n                    url = image[\"url\"]\n                    yield Message.Url, url, text.nameext_from_url(url, data)\n\n\nclass CivitaiRestAPI():\n    \"\"\"Interface for the Civitai Public REST API\n\n    https://developer.civitai.com/docs/api/public-rest\n    \"\"\"\n\n    def __init__(self, extractor):\n        self.extractor = extractor\n        self.root = extractor.root + \"/api\"\n        self.headers = {\"Content-Type\": \"application/json\"}\n\n        if api_key := extractor.config(\"api-key\"):\n            extractor.log.debug(\"Using api_key authentication\")\n            self.headers[\"Authorization\"] = \"Bearer \" + api_key\n\n        nsfw = extractor.config(\"nsfw\")\n        if nsfw is None or nsfw is True:\n            nsfw = \"X\"\n        elif not nsfw:\n            nsfw = \"Safe\"\n        self.nsfw = nsfw\n\n    def image(self, image_id):\n        return self.images({\n            \"imageId\": image_id,\n        })\n\n    def images(self, params):\n        endpoint = \"/v1/images\"\n        if \"nsfw\" not in params:\n            params[\"nsfw\"] = self.nsfw\n        return self._pagination(endpoint, params)\n\n    def images_gallery(self, model, version, user):\n        return self.images({\n            \"modelId\"       : model[\"id\"],\n            \"modelVersionId\": version[\"id\"],\n        })\n\n    def model(self, model_id):\n        endpoint = \"/v1/models/\" + str(model_id)\n        return self._call(endpoint)\n\n    def model_version(self, model_version_id):\n        endpoint = \"/v1/model-versions/\" + str(model_version_id)\n        return self._call(endpoint)\n\n    def models(self, params):\n        return self._pagination(\"/v1/models\", params)\n\n    def models_tag(self, tag):\n        return self.models({\"tag\": tag})\n\n    def _call(self, endpoint, params=None):\n        if endpoint[0] == \"/\":\n            url = self.root + endpoint\n        else:\n            url = endpoint\n\n        response = self.extractor.request(\n            url, params=params, headers=self.headers)\n        return response.json()\n\n    def _pagination(self, endpoint, params):\n        while True:\n            data = self._call(endpoint, params)\n            yield from data[\"items\"]\n\n            try:\n                endpoint = data[\"metadata\"][\"nextPage\"]\n            except KeyError:\n                return\n            params = None\n\n\nclass CivitaiTrpcAPI():\n    \"\"\"Interface for the Civitai tRPC API\"\"\"\n\n    def __init__(self, extractor):\n        self.extractor = extractor\n        self.root = extractor.root + \"/api/trpc/\"\n        self.headers = {\n            \"content-type\"    : \"application/json\",\n            \"x-client-version\": \"5.0.1386\",\n            \"x-client-date\"   : \"\",\n            \"x-client\"        : \"web\",\n            \"x-fingerprint\"   : \"undefined\",\n        }\n        if api_key := extractor.config(\"api-key\"):\n            extractor.log.debug(\"Using api_key authentication\")\n            self.headers[\"Authorization\"] = \"Bearer \" + api_key\n\n        nsfw = extractor.config(\"nsfw\")\n        if nsfw is None or nsfw is True:\n            nsfw = 31\n        elif not nsfw:\n            nsfw = 1\n        self.nsfw = nsfw\n\n    def image(self, image_id):\n        endpoint = \"image.get\"\n        params = {\"id\": int(image_id)}\n        return (self._call(endpoint, params),)\n\n    def image_generationdata(self, image_id):\n        endpoint = \"image.getGenerationData\"\n        params = {\"id\": int(image_id)}\n        return self._call(endpoint, params)\n\n    def images(self, params, defaults=True):\n        endpoint = \"image.getInfinite\"\n\n        if defaults:\n            params = self._merge_params(params, {\n                \"useIndex\"     : True,\n                \"period\"       : self._param_period(),\n                \"sort\"         : self._param_sort(),\n                \"withMeta\"     : False,  # Metadata Only\n                \"fromPlatform\" : False,  # Made On-Site\n                \"browsingLevel\": self.nsfw,\n                \"include\"      : (\"cosmetics\",),\n            })\n\n        params = self._type_params(params)\n        return self._pagination(endpoint, params)\n\n    def images_gallery(self, model, version, user):\n        endpoint = \"image.getImagesAsPostsInfinite\"\n        params = {\n            \"period\"        : self._param_period(),\n            \"sort\"          : self._param_sort(),\n            \"modelVersionId\": version[\"id\"],\n            \"modelId\"       : model[\"id\"],\n            \"hidden\"        : False,\n            \"limit\"         : 50,\n            \"browsingLevel\" : self.nsfw,\n        }\n\n        for post in self._pagination(endpoint, params):\n            yield from post[\"images\"]\n\n    def images_post(self, post_id):\n        params = {\n            \"postId\" : int(post_id),\n            \"pending\": True,\n        }\n        return self.images(params)\n\n    def model(self, model_id):\n        endpoint = \"model.getById\"\n        params = {\"id\": int(model_id)}\n        return self._call(endpoint, params)\n\n    def model_version(self, model_version_id):\n        endpoint = \"modelVersion.getById\"\n        params = {\"id\": int(model_version_id)}\n        return self._call(endpoint, params)\n\n    def models(self, params, defaults=True):\n        endpoint = \"model.getAll\"\n\n        if defaults:\n            params = self._merge_params(params, {\n                \"period\"       : self._param_period(),\n                \"periodMode\"   : \"published\",\n                \"sort\"         : self._param_sort(),\n                \"pending\"      : False,\n                \"hidden\"       : False,\n                \"followed\"     : False,\n                \"earlyAccess\"  : False,\n                \"fromPlatform\" : False,\n                \"supportsGeneration\": False,\n                \"browsingLevel\": self.nsfw,\n            })\n\n        return self._pagination(endpoint, params)\n\n    def models_tag(self, tag):\n        return self.models({\"tagname\": tag})\n\n    def post(self, post_id):\n        endpoint = \"post.get\"\n        params = {\"id\": int(post_id)}\n        return self._call(endpoint, params)\n\n    def posts(self, params, defaults=True):\n        endpoint = \"post.getInfinite\"\n\n        if defaults:\n            params = self._merge_params(params, {\n                \"browsingLevel\": self.nsfw,\n                \"period\"       : self._param_period(),\n                \"periodMode\"   : \"published\",\n                \"sort\"         : self._param_sort(),\n                \"followed\"     : False,\n                \"draftOnly\"    : False,\n                \"pending\"      : True,\n                \"include\"      : (\"cosmetics\",),\n            })\n\n        params = self._type_params(params)\n        return self._pagination(endpoint, params, user=(\"username\" in params))\n\n    def collection(self, collection_id):\n        endpoint = \"collection.getById\"\n        params = {\"id\": int(collection_id)}\n        return self._call(endpoint, params)[\"collection\"]\n\n    def collections(self, params, defaults=True):\n        endpoint = \"collection.getInfinite\"\n\n        if defaults:\n            params = self._merge_params(params, {\n                \"browsingLevel\": self.nsfw,\n                \"sort\"         : self._param_sort(),\n            })\n\n        params = self._type_params(params)\n        return self._pagination(endpoint, params)\n\n    def tag_getvotabletags(self, image_id):\n        endpoint = \"tag.getVotableTags\"\n        params = {\"id\": int(image_id), \"type\": \"image\"}\n        return self._call(endpoint, params)\n\n    def user(self, username):\n        endpoint = \"user.getCreator\"\n        params = {\"username\": username}\n        return (self._call(endpoint, params),)\n\n    def orchestrator_queryGeneratedImages(self):\n        endpoint = \"orchestrator.queryGeneratedImages\"\n        params = {\n            \"ascending\": True if self._param_sort() == \"Oldest\" else False,\n            \"tags\"     : (\"gen\",),\n            \"authed\"   : True,\n        }\n        return self._pagination(endpoint, params)\n\n    def _call(self, endpoint, params, meta=None):\n        url = self.root + endpoint\n        headers = self.headers\n\n        if meta:\n            input = {\"json\": params, \"meta\": {\"values\": meta}}\n        else:\n            input = {\"json\": params}\n\n        params = {\"input\": util.json_dumps(input)}\n        headers[\"x-client-date\"] = str(int(time.time() * 1000))\n        return self.extractor.request_json(\n            url, params=params, headers=headers)[\"result\"][\"data\"][\"json\"]\n\n    def _pagination(self, endpoint, params, meta=None, user=False):\n        if \"cursor\" not in params:\n            params[\"cursor\"] = None\n            meta_ = {\"cursor\": (\"undefined\",)}\n\n        data = self._call(endpoint, params, meta_)\n        if user and data[\"items\"] and \\\n                data[\"items\"][0][\"user\"][\"username\"] != params[\"username\"]:\n            return ()\n\n        while True:\n            yield from data[\"items\"]\n\n            try:\n                if not data[\"nextCursor\"]:\n                    return\n            except KeyError:\n                return\n\n            params[\"cursor\"] = data[\"nextCursor\"]\n            meta_ = meta\n            data = self._call(endpoint, params, meta_)\n\n    def _merge_params(self, params_user, params_default):\n        \"\"\"Combine 'params_user' with 'params_default'\"\"\"\n        params_default.update(params_user)\n        return params_default\n\n    def _type_params(self, params):\n        \"\"\"Convert 'params' values to expected types\"\"\"\n        types = {\n            \"tags\"          : int,\n            \"tools\"         : int,\n            \"techniques\"    : int,\n            \"modelId\"       : int,\n            \"modelVersionId\": int,\n            \"remixesOnly\"   : _bool,\n            \"nonRemixesOnly\": _bool,\n            \"withMeta\"      : _bool,\n            \"fromPlatform\"  : _bool,\n            \"supportsGeneration\": _bool,\n        }\n\n        for name, value in params.items():\n            if name not in types:\n                continue\n            elif isinstance(value, str):\n                params[name] = types[name](value)\n            elif isinstance(value, list):\n                type = types[name]\n                params[name] = [type(item) for item in value]\n        return params\n\n    def _param_period(self):\n        if period := self.extractor.config(\"period\"):\n            return period\n        return \"AllTime\"\n\n    def _param_sort(self):\n        if sort := self.extractor.config(\"sort\"):\n            s = sort[0].lower()\n            if s in \"drn\":\n                return \"Newest\"\n            if s in \"ao\":\n                return \"Oldest\"\n            return sort\n        return \"Newest\"\n\n\ndef _bool(value):\n    return value == \"true\"\n\n\nclass CivitaiSearchAPI():\n\n    def __init__(self, extractor):\n        self.extractor = extractor\n        self.root = \"https://search-new.civitai.com\"\n\n        if auth := extractor.config(\"token\"):\n            if \" \" not in auth:\n                auth = \"Bearer \" + auth\n        else:\n            auth = (\"Bearer 8c46eb2508e21db1e9828a97968d\"\n                    \"91ab1ca1caa5f70a00e88a2ba1e286603b61\")\n\n        self.headers = {\n            \"Authorization\": auth,\n            \"Content-Type\": \"application/json\",\n            \"X-Meilisearch-Client\": \"Meilisearch instant-meilisearch (v0.13.5)\"\n                                    \" ; Meilisearch JavaScript (v0.34.0)\",\n            \"Origin\": extractor.root,\n            \"Sec-Fetch-Dest\": \"empty\",\n            \"Sec-Fetch-Mode\": \"cors\",\n            \"Sec-Fetch-Site\": \"same-site\",\n            \"Priority\": \"u=4\",\n        }\n\n    def search(self, query, type, facets, nsfw=31):\n        endpoint = \"/multi-search\"\n\n        query = {\n            \"q\"       : query,\n            \"indexUid\": type,\n            \"facets\"  : facets,\n            \"attributesToHighlight\": (),\n            \"highlightPreTag\" : \"__ais-highlight__\",\n            \"highlightPostTag\": \"__/ais-highlight__\",\n            \"limit\" : 51,\n            \"offset\": 0,\n            \"filter\": (self._generate_filter(nsfw),),\n        }\n\n        return self._pagination(endpoint, query)\n\n    def search_models(self, query, type=None, nsfw=31):\n        facets = (\n            \"category.name\",\n            \"checkpointType\",\n            \"fileFormats\",\n            \"lastVersionAtUnix\",\n            \"tags.name\",\n            \"type\",\n            \"user.username\",\n            \"version.baseModel\",\n        )\n        return self.search(query, type or \"models_v9\", facets, nsfw)\n\n    def search_images(self, query, type=None, nsfw=31):\n        facets = (\n            \"aspectRatio\",\n            \"baseModel\",\n            \"createdAtUnix\",\n            \"tagNames\",\n            \"techniqueNames\",\n            \"toolNames\",\n            \"type\",\n            \"user.username\",\n        )\n        return self.search(query, type or \"images_v6\", facets, nsfw)\n\n    def _call(self, endpoint, query):\n        url = self.root + endpoint\n        params = util.json_dumps({\"queries\": (query,)})\n\n        data = self.extractor.request_json(\n            url, method=\"POST\", headers=self.headers, data=params)\n\n        return data[\"results\"][0]\n\n    def _pagination(self, endpoint, query):\n        limit = query[\"limit\"] - 1\n        threshold = limit // 2\n\n        while True:\n            data = self._call(endpoint, query)\n\n            items = data[\"hits\"]\n            yield from items\n\n            if len(items) < threshold:\n                return\n            query[\"offset\"] += limit\n\n    def _generate_filter(self, level):\n        fltr = []\n\n        if level & 1:\n            fltr.append(\"1\")\n        if level & 2:\n            fltr.append(\"2\")\n        if level & 4:\n            fltr.append(\"4\")\n        if level & 8:\n            fltr.append(\"8\")\n        if level & 16:\n            fltr.append(\"16\")\n\n        if not fltr:\n            return \"()\"\n        return \"(nsfwLevel=\" + \" OR nsfwLevel=\".join(fltr) + \")\"\n"
  },
  {
    "path": "gallery_dl/extractor/comedywildlifephoto.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2025 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://www.comedywildlifephoto.com/\"\"\"\n\nfrom .common import GalleryExtractor\nfrom .. import text\n\n\nclass ComedywildlifephotoGalleryExtractor(GalleryExtractor):\n    \"\"\"Extractor for comedywildlifephoto galleries\"\"\"\n    category = \"comedywildlifephoto\"\n    root = \"https://www.comedywildlifephoto.com\"\n    directory_fmt = (\"{category}\", \"{section}\", \"{title}\")\n    filename_fmt = \"{num:>03} {filename}.{extension}\"\n    archive_fmt = \"{section}/{title}/{num}\"\n    pattern = (r\"(?:https?://)?(?:www\\.)?comedywildlifephoto\\.com\"\n               r\"(/gallery/[^/?#]+/[^/?#]+\\.php)\")\n    example = \"https://www.comedywildlifephoto.com/gallery/SECTION/TITLE.php\"\n\n    def metadata(self, page):\n        extr = text.extract_from(page)\n\n        return {\n            \"section\": extr(\"<h1>\", \"<\").strip(),\n            \"title\"  : extr(\">\", \"<\"),\n            \"description\": text.unescape(extr(\n                'class=\"c1 np\">', \"<div\")),\n        }\n\n    def images(self, page):\n        results = []\n\n        for fig in text.extract_iter(page, \"<figure\", \"</figure>\"):\n            width, _, height = text.extr(\n                fig, 'data-size=\"', '\"').partition(\"x\")\n            results.append((\n                self.root + text.extr(fig, 'href=\"', '\"'), {\n                    \"width\"  : text.parse_int(width),\n                    \"height\" : text.parse_int(height),\n                    \"caption\": text.unescape(text.extr(\n                        fig, \"<figcaption>\", \"<\")),\n                }\n            ))\n\n        return results\n"
  },
  {
    "path": "gallery_dl/extractor/comick.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2025 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://comick.io/\"\"\"\n\nfrom .common import GalleryExtractor, ChapterExtractor, MangaExtractor, Message\nfrom .. import text\n\nBASE_PATTERN = r\"(?:https?://)?(?:www\\.)?comick\\.io\"\n\n\nclass ComickBase():\n    \"\"\"Base class for comick.io extractors\"\"\"\n    category = \"comick\"\n    root = \"https://comick.io\"\n\n    def _manga_info(self, slug):\n        url = f\"{self.root}/comic/{slug}\"\n        page = self.request(url).text\n        data = self._extract_nextdata(page)\n        props = data[\"props\"][\"pageProps\"]\n        comic = props[\"comic\"]\n\n        genre = []\n        theme = []\n        format = \"\"\n        for item in comic[\"md_comic_md_genres\"]:\n            item = item[\"md_genres\"]\n            group = item[\"group\"]\n            if group == \"Genre\":\n                genre.append(item[\"name\"])\n            elif group == \"Theme\":\n                theme.append(item[\"name\"])\n            else:\n                format = item[\"name\"]\n\n        if mu := comic[\"mu_comics\"]:\n            tags = [c[\"mu_categories\"][\"title\"]\n                    for c in mu[\"mu_comic_categories\"]]\n            publisher = [p[\"mu_publishers\"][\"title\"]\n                         for p in mu[\"mu_comic_publishers\"]]\n        else:\n            tags = publisher = ()\n\n        return {\n            \"manga\": comic[\"title\"],\n            \"manga_id\": comic[\"id\"],\n            \"manga_hid\": comic[\"hid\"],\n            \"manga_slug\": comic[\"slug\"],\n            \"manga_titles\": [t[\"title\"] for t in comic[\"md_titles\"]],\n            \"artist\": [a[\"name\"] for a in props[\"artists\"]],\n            \"author\": [a[\"name\"] for a in props[\"authors\"]],\n            \"genre\" : genre,\n            \"theme\" : theme,\n            \"format\": format,\n            \"tags\"  : tags,\n            \"publisher\": publisher,\n            \"published\": text.parse_int(comic[\"year\"]),\n            \"description\": comic[\"desc\"],\n            \"demographic\": props[\"demographic\"],\n            \"origin\": comic[\"iso639_1\"],\n            \"mature\": props[\"matureContent\"],\n            \"rating\": comic[\"content_rating\"],\n            \"rank\"  : comic[\"follow_rank\"],\n            \"score\" : text.parse_float(comic[\"bayesian_rating\"]),\n            \"status\": \"Complete\" if comic[\"status\"] == 2 else \"Ongoing\",\n            \"links\" : comic[\"links\"],\n            \"_build_id\": data[\"buildId\"],\n        }\n\n    def _chapter_info(self, manga, chstr):\n        slug = manga['manga_slug']\n        url = (f\"{self.root}/_next/data/{manga['_build_id']}\"\n               f\"/comic/{slug}/{chstr}.json\")\n        params = {\"slug\": slug, \"chapter\": chstr}\n        return self.request_json(url, params=params)[\"pageProps\"]\n\n\nclass ComickCoversExtractor(ComickBase, GalleryExtractor):\n    \"\"\"Extractor for comick.io manga covers\"\"\"\n    subcategory = \"covers\"\n    directory_fmt = (\"{category}\", \"{manga}\", \"Covers\")\n    filename_fmt = \"{volume:>02}_{lang}.{extension}\"\n    archive_fmt = \"c_{id}\"\n    pattern = BASE_PATTERN + r\"/comic/([\\w-]+)/cover\"\n    example = \"https://comick.io/comic/MANGA/cover\"\n\n    def metadata(self, page):\n        manga = self.cache(self._manga_info, self.groups[0])\n        self.slug = manga['manga_slug']\n        return manga\n\n    def images(self, page):\n        url = f\"{self.root}/comic/{self.slug}/cover\"\n        page = self.request(url).text\n        data = self._extract_nextdata(page)\n\n        covers = data[\"props\"][\"pageProps\"][\"comic\"][\"md_covers\"]\n        covers.reverse()\n\n        return [\n            (\"https://meo.comick.pictures/\" + cover[\"b2key\"], {\n                \"id\"    : cover[\"id\"],\n                \"width\" : cover[\"w\"],\n                \"height\": cover[\"h\"],\n                \"size\"  : cover[\"s\"],\n                \"lang\"  : cover[\"locale\"],\n                \"volume\": text.parse_int(cover[\"vol\"]),\n                \"cover\" : cover,\n            })\n            for cover in covers\n        ]\n\n\nclass ComickChapterExtractor(ComickBase, ChapterExtractor):\n    \"\"\"Extractor for comick.io manga chapters\"\"\"\n    archive_fmt = \"{chapter_hid}_{page}\"\n    pattern = (BASE_PATTERN + r\"/comic/([\\w-]+)\"\n               r\"/(\\w+(?:-(?:chapter|volume)-[^/?#]+)?)\")\n    example = \"https://comick.io/comic/MANGA/ID-chapter-123-en\"\n\n    def metadata(self, page):\n        slug, chstr = self.groups\n        manga = self.cache(self._manga_info, slug)\n\n        while True:\n            try:\n                props = self._chapter_info(self, manga, chstr)\n            except self.exc.HttpError as exc:\n                if exc.response.status_code != 404:\n                    raise\n                if exc.response.headers.get(\n                        \"Content-Type\", \"\").startswith(\"text/html\"):\n                    if locals().get(\"_retry_buildid\"):\n                        raise\n                    self.log.debug(\"Updating Next.js build ID\")\n                    _retry_buildid = True\n                    self.cache_update(self._manga_info, slug, None)\n                    manga = self.cache(self._manga_info, slug)\n                    continue\n                if b'\"notFound\":true' in exc.response.content:\n                    raise self.exc.NotFoundError(\"chapter\")\n                raise\n\n            if \"__N_REDIRECT\" in props:\n                path = props[\"__N_REDIRECT\"]\n                self.log.debug(\"Following redirect to %s\", path)\n                _, slug, chstr = path.rsplit(\"/\", 2)\n                continue\n\n            ch = props[\"chapter\"]\n            break\n\n        self._images = ch[\"md_images\"]\n\n        if chapter := ch[\"chap\"]:\n            chapter, sep, minor = chapter.partition(\".\")\n        else:\n            chapter = 0\n            sep = minor = \"\"\n\n        return {\n            **manga,\n            \"title\"   : props[\"chapTitle\"],\n            \"volume\"  : text.parse_int(ch[\"vol\"]),\n            \"chapter\" : text.parse_int(chapter),\n            \"chapter_minor\" : sep + minor,\n            \"chapter_id\"    : ch[\"id\"],\n            \"chapter_hid\"   : ch[\"hid\"],\n            \"chapter_string\": chstr,\n            \"group\"   : ch[\"group_name\"],\n            \"date\"    : self.parse_datetime_iso(ch[\"created_at\"][:19]),\n            \"date_updated\"  : self.parse_datetime_iso(ch[\"updated_at\"][:19]),\n            \"lang\"    : ch[\"lang\"],\n        }\n\n    def images(self, page):\n        if not self._images[0].get(\"b2key\") and all(\n                not img.get(\"b2key\") for img in self._images):\n            self.log.error(\n                \"%s: Broken Chapter (missing 'b2key' for all pages)\",\n                self.groups[1])\n            return ()\n\n        return [\n            (\"https://meo.comick.pictures/\" + img[\"b2key\"], {\n                \"width\"    : img[\"w\"],\n                \"height\"   : img[\"h\"],\n                \"size\"     : img[\"s\"],\n                \"optimized\": img[\"optimized\"],\n            })\n            for img in self._images\n        ]\n\n\nclass ComickMangaExtractor(ComickBase, MangaExtractor):\n    \"\"\"Extractor for comick.io manga\"\"\"\n    pattern = BASE_PATTERN + r\"/comic/([\\w-]+)/?(?:\\?([^#]+))?\"\n    example = \"https://comick.io/comic/MANGA\"\n\n    def items(self):\n        manga = self.cache(self._manga_info, self.groups[0])\n        slug = manga[\"manga_slug\"]\n        self.cache_update(self._manga_info, slug, manga)\n\n        for ch in self.chapters(manga):\n            ch.update(manga)\n            ch[\"_extractor\"] = ComickChapterExtractor\n\n            if chapter := ch[\"chap\"]:\n                url = (f\"{self.root}/comic/{slug}\"\n                       f\"/{ch['hid']}-chapter-{chapter}-{ch['lang']}\")\n                chapter, sep, minor = chapter.partition(\".\")\n                ch[\"volume\"] = text.parse_int(ch[\"vol\"])\n                ch[\"chapter\"] = text.parse_int(chapter)\n                ch[\"chapter_minor\"] = sep + minor\n            elif volume := ch[\"vol\"]:\n                url = (f\"{self.root}/comic/{slug}\"\n                       f\"/{ch['hid']}-volume-{volume}-{ch['lang']}\")\n                ch[\"volume\"] = text.parse_int(volume)\n                ch[\"chapter\"] = 0\n                ch[\"chapter_minor\"] = \"\"\n            else:\n                url = f\"{self.root}/comic/{slug}/{ch['hid']}\"\n                ch[\"volume\"] = ch[\"chapter\"] = 0\n                ch[\"chapter_minor\"] = \"\"\n\n            yield Message.Queue, url, ch\n\n    def chapters(self, manga):\n        info = True\n        slug, query = self.groups\n\n        url = f\"https://api.comick.io/comic/{manga['manga_hid']}/chapters\"\n        headers = {\n            \"Origin\": \"https://comick.io\",\n            \"Sec-Fetch-Dest\": \"empty\",\n            \"Sec-Fetch-Mode\": \"cors\",\n            \"Sec-Fetch-Site\": \"same-site\",\n        }\n\n        query = text.parse_query_list(query, (\"lang\",))\n\n        if (lang := query.get(\"lang\")) or (lang := self.config(\"lang\")):\n            if not isinstance(lang, str):\n                lang = \",\".join(lang)\n        else:\n            lang = None\n\n        params = {\"lang\": lang}\n        params[\"page\"] = page = text.parse_int(query.get(\"page\"), 1)\n\n        if date_order := query.get(\"date-order\"):\n            params[\"date-order\"] = date_order\n        elif chap_order := query.get(\"chap-order\"):\n            params[\"chap-order\"] = chap_order\n        else:\n            params[\"chap-order\"] = \\\n                \"0\" if self.config(\"chapter-reverse\", False) else \"1\"\n\n        group = query.get(\"group\")\n        if group == \"0\":\n            group = None\n\n        while True:\n            data = self.request_json(url, params=params, headers=headers)\n            limit = data[\"limit\"]\n\n            if info:\n                info = False\n                total = data[\"total\"] - limit * page\n                if total > limit:\n                    self.log.info(\"Collecting %s chapters\", total)\n\n            if group is None:\n                yield from data[\"chapters\"]\n            else:\n                for ch in data[\"chapters\"]:\n                    if (groups := ch[\"group_name\"]) and group in groups:\n                        yield ch\n\n            if data[\"total\"] <= limit * page:\n                return\n            params[\"page\"] = page = page + 1\n"
  },
  {
    "path": "gallery_dl/extractor/comicvine.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2021-2025 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://comicvine.gamespot.com/\"\"\"\n\nfrom .booru import BooruExtractor\nfrom .. import text\nimport operator\n\n\nclass ComicvineTagExtractor(BooruExtractor):\n    \"\"\"Extractor for a gallery on comicvine.gamespot.com\"\"\"\n    category = \"comicvine\"\n    subcategory = \"tag\"\n    basecategory = \"\"\n    root = \"https://comicvine.gamespot.com\"\n    per_page = 1000\n    directory_fmt = (\"{category}\", \"{tag}\")\n    filename_fmt = \"{filename}.{extension}\"\n    archive_fmt = \"{id}\"\n    pattern = (r\"(?:https?://)?comicvine\\.gamespot\\.com\"\n               r\"(/([^/?#]+)/(\\d+-\\d+)/images/.*)\")\n    example = \"https://comicvine.gamespot.com/TAG/123-45/images/\"\n\n    def __init__(self, match):\n        BooruExtractor.__init__(self, match)\n        self.path, self.object_name, self.object_id = match.groups()\n\n    def metadata(self):\n        return {\"tag\": text.unquote(self.object_name)}\n\n    def posts(self):\n        url = self.root + \"/js/image-data.json\"\n        params = {\n            \"images\": text.extract(\n                self.request(self.root + self.path).text,\n                'data-gallery-id=\"', '\"')[0],\n            \"start\" : self.page_start,\n            \"count\" : self.per_page,\n            \"object\": self.object_id,\n        }\n\n        while True:\n            images = self.request_json(url, params=params)[\"images\"]\n            yield from images\n\n            if len(images) < self.per_page:\n                return\n            params[\"start\"] += self.per_page\n\n    def skip_files(self, num):\n        self.page_start = num\n        return num\n\n    _file_url = operator.itemgetter(\"original\")\n\n    def _prepare(self, post):\n        post[\"date\"] = self.parse_datetime(\n            post[\"dateCreated\"], \"%a, %b %d %Y\")\n        post[\"tags\"] = [tag[\"name\"] for tag in post[\"tags\"] if tag[\"name\"]]\n"
  },
  {
    "path": "gallery_dl/extractor/common.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2014-2026 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Common classes and constants used by extractor modules.\"\"\"\n\nimport os\nimport re\nimport ssl\nimport time\nimport netrc\nimport queue\nimport pickle\nimport random\nimport getpass\nimport logging\nimport requests\nimport threading\nfrom xml.etree import ElementTree\nfrom requests.adapters import HTTPAdapter\nfrom .message import Message\nfrom .. import config, output, text, util, dt, cache, exception\nurllib3 = requests.packages.urllib3\n\n\nclass Extractor():\n\n    category = \"\"\n    subcategory = \"\"\n    basecategory = \"\"\n    basesubcategory = \"\"\n    categorytransfer = False\n    parent = False\n    directory_fmt = (\"{category}\",)\n    filename_fmt = \"{filename}.{extension}\"\n    archive_fmt = \"\"\n    status = 0\n    root = \"\"\n    cookies_file = \"\"\n    cookies_index = 0\n    cookies_domain = \"\"\n    session = None\n    referer = True\n    ciphers = None\n    tls12 = True\n    browser = None\n    useragent = util.USERAGENT_FIREFOX\n    geobypass = None\n    request_interval = 0.0\n    request_interval_min = 0.0\n    request_interval_429 = 60.0\n    request_timestamp = 0.0\n    finalize = skip_files = skip_posts = skip_children = skip_date = None\n    exc = exception\n\n    def __init__(self, match):\n        self.log = logging.getLogger(self.category)\n        self.url = match.string\n        self.match = match\n        self.groups = match.groups()\n        self.kwdict = {}\n\n        if self.category in CATEGORY_MAP:\n            catsub = f\"{self.category}:{self.subcategory}\"\n            if catsub in CATEGORY_MAP:\n                self.category, self.subcategory = CATEGORY_MAP[catsub]\n            else:\n                self.category = CATEGORY_MAP[self.category]\n\n        self.parse_datetime = dt.parse\n        self.parse_datetime_iso = dt.parse_iso\n        self.parse_timestamp = dt.parse_ts\n\n        self._cfgpath = (\"extractor\", self.category, self.subcategory)\n        self._parentdir = \"\"\n\n    def __str__(self):\n        return f\"{self.__class__.__name__} <{self.url}>\"\n\n    @classmethod\n    def from_url(cls, url):\n        if isinstance(cls.pattern, str):\n            cls.pattern = util.re_compile(cls.pattern)\n        match = cls.pattern.match(url)\n        return cls(match) if match else None\n\n    def __iter__(self):\n        self.initialize()\n        return self.items()\n\n    def initialize(self):\n        self._init_options()\n\n        if self.session is None:\n            self._init_session()\n            self.cookies = self.session.cookies\n            if self.cookies_domain is not None:\n                self._init_cookies()\n        else:\n            self.cookies = self.session.cookies\n\n        self._init()\n        self.initialize = util.noop\n\n    def items(self):\n        return\n        yield\n\n    def config(self, key, default=None):\n        return config.interpolate(self._cfgpath, key, default)\n\n    def config2(self, key, key2, default=None, sentinel=util.SENTINEL):\n        value = self.config(key, sentinel)\n        if value is not sentinel:\n            return value\n        return self.config(key2, default)\n\n    def config_accumulate(self, key):\n        return config.accumulate(self._cfgpath, key)\n\n    def config_instance(self, key, default=None):\n        return default\n\n    def _config_shared(self, key, default=None):\n        return config.interpolate_common(\n            (\"extractor\",), self._cfgpath, key, default)\n\n    def _config_shared_accumulate(self, key):\n        first = True\n        extr = (\"extractor\",)\n\n        for path in self._cfgpath:\n            if first:\n                first = False\n                values = config.accumulate(extr + path, key)\n            elif conf := config.get(extr, path[0]):\n                values[:0] = config.accumulate(\n                    (self.subcategory,), key, conf=conf)\n\n        return values\n\n    def request(self, url, method=\"GET\", session=None, fatal=True,\n                retries=None, retry_codes=None, expected=(), interval=True,\n                encoding=None, notfound=None, **kwargs):\n        if session is None:\n            session = self.session\n        if retries is None:\n            retries = self._retries\n        if retry_codes is None:\n            retry_codes = self._retry_codes\n        if \"proxies\" not in kwargs:\n            kwargs[\"proxies\"] = self._proxies\n        if \"timeout\" not in kwargs:\n            kwargs[\"timeout\"] = self._timeout\n        if \"verify\" not in kwargs:\n            kwargs[\"verify\"] = self._verify\n\n        if \"json\" in kwargs:\n            if (json := kwargs[\"json\"]) is not None:\n                kwargs[\"data\"] = util.json_dumps(json).encode()\n                del kwargs[\"json\"]\n                if headers := kwargs.get(\"headers\"):\n                    headers[\"Content-Type\"] = \"application/json\"\n                else:\n                    kwargs[\"headers\"] = {\"Content-Type\": \"application/json\"}\n\n        response = challenge = None\n        tries = 1\n\n        if self._interval_request is not None and interval:\n            seconds = (self._interval_request() -\n                       (time.time() - Extractor.request_timestamp))\n            if seconds > 0.0:\n                self.sleep(seconds, \"request\")\n\n        while True:\n            try:\n                response = session.request(method, url, **kwargs)\n            except requests.exceptions.ConnectionError as exc:\n                try:\n                    reason = exc.args[0].reason\n                    cls = reason.__class__.__name__\n                    pre, _, err = str(reason.args[-1]).partition(\":\")\n                    msg = f\" {cls}: {(err or pre).lstrip()}\"\n                except Exception:\n                    msg = exc\n                code = 0\n            except (requests.exceptions.Timeout,\n                    requests.exceptions.ChunkedEncodingError,\n                    requests.exceptions.ContentDecodingError) as exc:\n                msg = exc\n                code = 0\n            except (requests.exceptions.RequestException) as exc:\n                msg = exc\n                break\n            else:\n                code = response.status_code\n                if self._write_pages:\n                    self._dump_response(response)\n                if (\n                    code < 400 or\n                    code in expected or\n                    code < 500 and (\n                        not fatal and code != 429 or fatal is None) or\n                    fatal is ...\n                ):\n                    if encoding:\n                        response.encoding = encoding\n                    return response\n                if notfound is not None and code == 404:\n                    if notfound is True:\n                        notfound = self.__class__.subcategory\n                    self.status |= exception.NotFoundError.code\n                    raise exception.NotFoundError(notfound)\n\n                msg = f\"'{code} {response.reason}' for '{response.url}'\"\n\n                challenge = util.detect_challenge(response)\n                if challenge is not None:\n                    self.log.warning(challenge)\n\n                if code == 429 and self._handle_429(response):\n                    continue\n                elif code == 429 and self._interval_429:\n                    pass\n                elif code not in retry_codes and code < 500:\n                    break\n\n            finally:\n                if interval:\n                    Extractor.request_timestamp = time.time()\n\n            self.log.debug(\"%s (%s/%s)\", msg, tries, retries+1)\n            if tries > retries:\n                break\n\n            seconds = self._interval_retry(tries)\n            if self._interval_request is not None:\n                s = self._interval_request()\n                if seconds < s:\n                    seconds = s\n            if code == 429 and self._interval_429 is not None:\n                s = self._interval_429(tries)\n                if seconds < s:\n                    seconds = s\n                self.wait(seconds=seconds, reason=\"429 Too Many Requests\")\n            else:\n                self.sleep(seconds, \"retry\")\n            tries += 1\n\n        if not fatal or fatal is ...:\n            self.log.warning(msg)\n            return util.NullResponse(url, msg)\n\n        if challenge is None:\n            exc = exception.HttpError(msg, response)\n        else:\n            exc = exception.ChallengeError(challenge, response)\n        self.status |= exc.code\n        raise exc\n\n    def request_location(self, url, **kwargs):\n        kwargs.setdefault(\"method\", \"HEAD\")\n        kwargs.setdefault(\"allow_redirects\", False)\n        kwargs.setdefault(\"interval\", False)\n        return self.request(url, **kwargs).headers.get(\"location\", \"\")\n\n    def request_json(self, url, **kwargs):\n        response = self.request(url, **kwargs)\n\n        try:\n            return util.json_loads(response.text)\n        except Exception as exc:\n            fatal = kwargs.get(\"fatal\", True)\n            if not fatal or fatal is ...:\n                if challenge := util.detect_challenge(response):\n                    self.log.warning(challenge)\n                else:\n                    self.log.warning(\"%s: %s\", exc.__class__.__name__, exc)\n                return {}\n            raise\n\n    def request_xml(self, url, xmlns=True, **kwargs):\n        response = self.request(url, **kwargs)\n\n        if xmlns:\n            text = response.text\n        else:\n            text = response.text.replace(\" xmlns=\", \" ns=\")\n\n        parser = ElementTree.XMLParser()\n        try:\n            parser.feed(text)\n            return parser.close()\n        except Exception as exc:\n            fatal = kwargs.get(\"fatal\", True)\n            if not fatal or fatal is ...:\n                if challenge := util.detect_challenge(response):\n                    self.log.warning(challenge)\n                else:\n                    self.log.warning(\"%s: %s\", exc.__class__.__name__, exc)\n                return ElementTree.Element(\"\")\n            raise\n\n    _handle_429 = util.false\n\n    def wait(self, seconds=None, until=None, adjust=1.0,\n             reason=\"rate limit\"):\n        now = time.time()\n\n        if seconds:\n            seconds = float(seconds)\n            until = now + seconds\n        elif until:\n            if isinstance(until, dt.datetime):\n                # convert to UTC timestamp\n                until = dt.to_ts(until)\n            else:\n                until = float(until)\n            seconds = until - now\n        else:\n            raise ValueError(\"Either 'seconds' or 'until' is required\")\n\n        seconds += adjust\n        if seconds <= 0.0:\n            return\n\n        if reason:\n            if seconds >= 3600.0:\n                h, m = divmod(seconds, 3600.0)\n                dur = f\"{int(h)}h {int(m/60.0)}min\"\n            elif seconds >= 60.0:\n                dur = str(int(seconds/60.0)) + \" minutes\"\n            else:\n                dur = str(int(seconds)) + \" seconds\"\n            t = time.localtime(until)\n            iso = f\"{t.tm_hour:02}:{t.tm_min:02}:{t.tm_sec:02}\"\n            self.log.info(\"Waiting for %s until %s (%s)\", dur, iso, reason)\n        time.sleep(seconds)\n\n    def sleep(self, seconds, reason):\n        self.log.debug(\"Sleeping %.2f seconds (%s)\",\n                       seconds, reason)\n        time.sleep(seconds)\n\n    def utils(self, module=\"\", name=None):\n        module = (self.__class__.category if not module else\n                  module[1:] if module[0] == \"/\" else\n                  f\"{self.__class__.category}_{module}\")\n        if module in CACHE_UTILS:\n            res = CACHE_UTILS[module]\n        else:\n            res = CACHE_UTILS[module] = __import__(\n                \"utils.\" + module, globals(), None, module, 1)\n        return res if name is None else getattr(res, name, None)\n\n    def cache(self, func, *args, _key=0, _exp=0, _mem=True):\n        if _key is None:\n            key = f\"{func.__module__}.{func.__name__}\"\n        else:\n            key = f\"{func.__module__}.{func.__name__}-{args[_key]}\"\n\n        try:\n            value, expires = CACHE_MEMORY[key]\n        except KeyError:\n            expires = 1\n\n        if not expires or expires > (now := int(time.time())):\n            return value\n\n        if not _mem and (db := cache.database()):\n            with db:\n                cursor = db.cursor()\n                try:\n                    cursor.execute(\"BEGIN EXCLUSIVE\")\n                except Exception:\n                    pass  # swallow exception when already in a transaction\n                cursor.execute(\n                    \"SELECT value, expires FROM data WHERE key=? LIMIT 1\",\n                    (key,))\n\n                if (result := cursor.fetchone()) and (\n                        not (expires := result[1]) or expires > now):\n                    value, expires = result\n                    value = pickle.loads(value)\n                else:\n                    value = func(*args)\n                    expires = _exp and _exp+now\n                    cursor.execute(\n                        \"INSERT OR REPLACE INTO data VALUES (?,?,?)\",\n                        (key, pickle.dumps(value), expires))\n        else:\n            value = func(*args)\n            expires = _exp and _exp+now\n\n        CACHE_MEMORY[key] = value, expires\n        return value\n\n    def cache_update(self, func, key=None, value=None, _exp=0, _mem=False):\n        if key is None:\n            key = f\"{func.__module__}.{func.__name__}\"\n        else:\n            key = f\"{func.__module__}.{func.__name__}-{key}\"\n\n        if value is None:\n            # delete cached value\n            try:\n                del CACHE_MEMORY[key]\n            except KeyError:\n                pass\n            if not _mem and (db := cache.database()):\n                with db:\n                    db.execute(\"DELETE FROM data WHERE key=?\", (key,))\n        else:\n            # replace cached value\n            expires = _exp and _exp+int(time.time())\n            CACHE_MEMORY[key] = value, expires\n            if not _mem and (db := cache.database()):\n                with db:\n                    db.execute(\n                        \"INSERT OR REPLACE INTO data VALUES (?,?,?)\",\n                        (key, pickle.dumps(value), expires))\n\n    def input(self, prompt, echo=True):\n        self._check_input_allowed(prompt)\n\n        if echo:\n            try:\n                return input(prompt)\n            except (EOFError, OSError):\n                return None\n        else:\n            return getpass.getpass(prompt)\n\n    def _check_input_allowed(self, prompt=\"\"):\n        input = self.config(\"input\")\n        if input is None:\n            input = output.TTY_STDIN\n        if not input:\n            raise exception.AbortExtraction(\n                f\"User input required ({prompt.strip(' :')})\")\n\n    def _get_auth_info(self, password=None):\n        \"\"\"Return authentication information as (username, password) tuple\"\"\"\n        username = self.config(\"username\")\n\n        if username or password:\n            password = self.config(\"password\")\n            if not password:\n                self._check_input_allowed(\"password\")\n                password = util.LazyPrompt()\n\n        elif self.config(\"netrc\", False):\n            try:\n                info = netrc.netrc().authenticators(self.category)\n                username, _, password = info\n            except (OSError, netrc.NetrcParseError) as exc:\n                self.log.error(\"netrc: %s\", exc)\n            except TypeError:\n                self.log.warning(\"netrc: No authentication info\")\n\n        return username, password\n\n    def _init(self):\n        pass\n\n    def _init_options(self):\n        self._write_pages = self.config(\"write-pages\", False)\n        self._retry_codes = self.config(\"retry-codes\")\n        self._retries = self.config(\"retries\", 4)\n        self._timeout = self.config(\"timeout\", 30)\n        self._verify = self.config(\"verify\", True)\n        self._proxies = util.build_proxy_map(self.config(\"proxy\"), self.log)\n\n        if self._retries < 0:\n            self._retries = float(\"inf\")\n        if not self._retry_codes:\n            self._retry_codes = ()\n\n        self._interval_request = util.build_duration_func(\n            self.config(\"sleep-request\", self.request_interval),\n            self.request_interval_min)\n\n        _interval_retry = self.config(\"sleep-retries\")\n        if _interval_retry is None:\n            self._interval_retry = util.identity\n        else:\n            try:\n                self._interval_retry = util.build_duration_func_ex(\n                    _interval_retry)\n            except Exception as exc:\n                self.log.error(\"Invalid 'sleep-retry' value '%s' (%s: %s)\",\n                               _interval_retry, exc.__class__.__name__, exc)\n                self._interval_retry = util.identity\n\n        _interval_429 = self.config(\"sleep-429\")\n        if _interval_429 is None:\n            _interval_429 = self.request_interval_429\n        try:\n            self._interval_429 = util.build_duration_func_ex(_interval_429)\n        except Exception as exc:\n            self.log.error(\"Invalid 'sleep-429' value '%s' (%s: %s)\",\n                           _interval_429, exc.__class__.__name__, exc)\n            self._interval_429 = util.build_duration_func_ex(\n                self.request_interval_429)\n\n    def _init_session(self):\n        self.session = session = requests.Session()\n        headers = session.headers\n        headers.clear()\n        ssl_options = ssl_ciphers = 0\n\n        # .netrc Authorization headers are alwsays disabled\n        session.trust_env = True if self.config(\"proxy-env\", True) else False\n\n        browser = self.config(\"browser\")\n        if browser is None:\n            browser = self.browser\n        if browser and isinstance(browser, str):\n            browser, _, platform = browser.lower().partition(\":\")\n\n            if not platform or platform == \"auto\":\n                platform = (\"Windows NT 10.0; Win64; x64\"\n                            if util.WINDOWS else \"X11; Linux x86_64\")\n            elif platform == \"windows\":\n                platform = \"Windows NT 10.0; Win64; x64\"\n            elif platform == \"linux\":\n                platform = \"X11; Linux x86_64\"\n            elif platform == \"macos\":\n                platform = \"Macintosh; Intel Mac OS X 15.5\"\n\n            if browser == \"chrome\":\n                if platform.startswith(\"Macintosh\"):\n                    platform = platform.replace(\".\", \"_\")\n            else:\n                browser = \"firefox\"\n\n            for key, value in HEADERS[browser]:\n                if value and \"{}\" in value:\n                    headers[key] = value.replace(\"{}\", platform)\n                else:\n                    headers[key] = value\n\n            ssl_options |= (ssl.OP_NO_SSLv2 | ssl.OP_NO_SSLv3 |\n                            ssl.OP_NO_TLSv1 | ssl.OP_NO_TLSv1_1)\n            ssl_ciphers = CIPHERS[browser]\n        else:\n            headers[\"User-Agent\"] = self.useragent\n            headers[\"Accept\"] = \"*/*\"\n            headers[\"Accept-Language\"] = \"en-US,en;q=0.5\"\n\n            ssl_ciphers = self.ciphers\n            if ssl_ciphers is not None and ssl_ciphers in CIPHERS:\n                ssl_ciphers = CIPHERS[ssl_ciphers]\n\n        if BROTLI:\n            headers[\"Accept-Encoding\"] = \"gzip, deflate, br\"\n        else:\n            headers[\"Accept-Encoding\"] = \"gzip, deflate\"\n        if ZSTD:\n            headers[\"Accept-Encoding\"] += \", zstd\"\n\n        if referer := self.config(\"referer\", self.referer):\n            if isinstance(referer, str):\n                headers[\"Referer\"] = referer\n            elif self.root:\n                headers[\"Referer\"] = self.root + \"/\"\n\n        custom_ua = self.config(\"user-agent\")\n        if not custom_ua or custom_ua == \"auto\":\n            pass\n        elif custom_ua == \"browser\":\n            headers[\"User-Agent\"] = self.cache(\n                _browser_useragent, None, _exp=86400, _mem=False)\n        elif custom_ua[0] == \"@\":\n            headers[\"User-Agent\"] = self.cache(\n                _browser_useragent, custom_ua[1:], _exp=86400, _mem=False)\n        elif custom_ua[0] == \"+\":\n            custom_ua = custom_ua[1:].lower()\n            if custom_ua in {\"firefox\", \"ff\"}:\n                headers[\"User-Agent\"] = util.USERAGENT_FIREFOX\n            elif custom_ua in {\"chrome\", \"cr\"}:\n                headers[\"User-Agent\"] = util.USERAGENT_CHROME\n            elif custom_ua in {\"gallery-dl\", \"gallerydl\", \"gdl\"}:\n                headers[\"User-Agent\"] = util.USERAGENT_GALLERYDL\n            elif custom_ua in {\"google-bot\", \"googlebot\", \"bot\"}:\n                headers[\"User-Agent\"] = \"Googlebot-Image/1.0\"\n            else:\n                self.log.warning(\n                    \"Unsupported User-Agent preset '%s'\", custom_ua)\n        elif self.useragent is Extractor.useragent and not self.browser or \\\n                custom_ua is not config.get((\"extractor\",), \"user-agent\"):\n            headers[\"User-Agent\"] = custom_ua\n\n        custom_xff = self.config(\"geo-bypass\")\n        if custom_xff is None or custom_xff == \"auto\":\n            custom_xff = self.geobypass\n        if custom_xff is not None:\n            if ip := self.utils(\"/geo\").random_ipv4(custom_xff):\n                headers[\"X-Forwarded-For\"] = ip\n                self.log.debug(\"Using fake IP %s as 'X-Forwarded-For'\", ip)\n            else:\n                self.log.warning(\"xff: Invalid ISO 3166 country code '%s'\",\n                                 custom_xff)\n\n        if custom_headers := self.config(\"headers\"):\n            if isinstance(custom_headers, str):\n                if custom_headers in HEADERS:\n                    custom_headers = HEADERS[custom_headers]\n                else:\n                    self.log.error(\"Invalid 'headers' value '%s'\",\n                                   custom_headers)\n                    custom_headers = ()\n            headers.update(custom_headers)\n\n        if custom_ciphers := self.config(\"ciphers\"):\n            if isinstance(custom_ciphers, list):\n                ssl_ciphers = \":\".join(custom_ciphers)\n            elif custom_ciphers in CIPHERS:\n                ssl_ciphers = CIPHERS[custom_ciphers]\n            else:\n                ssl_ciphers = custom_ciphers\n\n        if source_address := self.config(\"source-address\"):\n            if isinstance(source_address, str):\n                source_address = (source_address, 0)\n            else:\n                source_address = (source_address[0], source_address[1])\n\n        tls12 = self.config(\"tls12\")\n        if tls12 is None:\n            tls12 = self.tls12\n        if not tls12:\n            ssl_options |= ssl.OP_NO_TLSv1_2\n            self.log.debug(\"TLS 1.2 disabled.\")\n\n        if self.config(\"truststore\"):\n            try:\n                from truststore import SSLContext as ssl_ctx\n            except ImportError as exc:\n                self.log.error(\"%s: %s\", exc.__class__.__name__, exc)\n                ssl_ctx = None\n        else:\n            ssl_ctx = None\n\n        adapter = _build_requests_adapter(\n            ssl_options, ssl_ciphers, ssl_ctx, source_address)\n        session.mount(\"https://\", adapter)\n        session.mount(\"http://\", adapter)\n\n    def _init_cookies(self):\n        \"\"\"Populate the session's cookiejar\"\"\"\n        if cookies := self.config(\"cookies\"):\n            if select := self.config(\"cookies-select\"):\n                if select == \"rotate\":\n                    cookies = cookies[self.cookies_index % len(cookies)]\n                    Extractor.cookies_index += 1\n                else:\n                    cookies = random.choice(cookies)\n            self.cookies_load(cookies)\n\n    def cookies_load(self, cookies_source):\n        if isinstance(cookies_source, dict):\n            self.cookies_update_dict(cookies_source, self.cookies_domain)\n\n        elif isinstance(cookies_source, str):\n            path = util.expand_path(cookies_source)\n            try:\n                with open(path, encoding=\"utf-8\") as fp:\n                    cookies = util.cookiestxt_load(fp)\n            except ValueError as exc:\n                self.log.warning(\"cookies: Invalid Netscape cookies.txt file \"\n                                 \"'%s' (%s: %s)\",\n                                 cookies_source, exc.__class__.__name__, exc)\n            except Exception as exc:\n                self.log.warning(\"cookies: Failed to load '%s' (%s: %s)\",\n                                 cookies_source, exc.__class__.__name__, exc)\n            else:\n                self.log.debug(\"cookies: Loading cookies from '%s'\",\n                               cookies_source)\n                set_cookie = self.cookies.set_cookie\n                for cookie in cookies:\n                    set_cookie(cookie)\n                self.cookies_file = path\n\n        elif isinstance(cookies_source, (list, tuple)):\n            key = tuple(cookies_source)\n            cookies = CACHE_COOKIES.get(key)\n\n            if cookies is None:\n                from ..cookies import load_cookies\n                try:\n                    cookies = load_cookies(cookies_source)\n                except Exception as exc:\n                    self.log.warning(\"cookies: %s\", exc)\n                    cookies = ()\n                else:\n                    CACHE_COOKIES[key] = cookies\n            else:\n                self.log.debug(\"cookies: Using cached cookies from %s\", key)\n\n            set_cookie = self.cookies.set_cookie\n            for cookie in cookies:\n                set_cookie(cookie)\n\n        else:\n            self.log.error(\n                \"cookies: Expected 'dict', 'list', or 'str' value for \"\n                \"'cookies' option, got '%s' instead (%r)\",\n                cookies_source.__class__.__name__, cookies_source)\n\n    def cookies_store(self):\n        \"\"\"Store the session's cookies in a cookies.txt file\"\"\"\n        export = self.config(\"cookies-update\", True)\n        if not export:\n            return\n\n        if isinstance(export, str):\n            path = util.expand_path(export)\n        else:\n            path = self.cookies_file\n            if not path:\n                return\n\n        path_tmp = path + \".tmp\"\n        try:\n            with open(path_tmp, \"w\", encoding=\"utf-8\") as fp:\n                util.cookiestxt_store(fp, self.cookies)\n            os.replace(path_tmp, path)\n        except OSError as exc:\n            self.log.error(\"cookies: Failed to write to '%s' \"\n                           \"(%s: %s)\", path, exc.__class__.__name__, exc)\n\n    def cookies_update(self, cookies, domain=\"\"):\n        \"\"\"Update the session's cookiejar with 'cookies'\"\"\"\n        if isinstance(cookies, dict):\n            self.cookies_update_dict(cookies, domain or self.cookies_domain)\n        else:\n            set_cookie = self.cookies.set_cookie\n            try:\n                cookies = iter(cookies)\n            except TypeError:\n                set_cookie(cookies)\n            else:\n                for cookie in cookies:\n                    set_cookie(cookie)\n\n    def cookies_update_dict(self, cookiedict, domain):\n        \"\"\"Update cookiejar with name-value pairs from a dict\"\"\"\n        set_cookie = self.cookies.set\n        for name, value in cookiedict.items():\n            set_cookie(name, value, domain=domain)\n\n    def cookies_check(self, cookies_names, domain=None, subdomains=False):\n        \"\"\"Check if all 'cookies_names' are in the session's cookiejar\"\"\"\n        if not self.cookies:\n            return False\n\n        if domain is None:\n            domain = self.cookies_domain\n        names = set(cookies_names)\n        now = time.time()\n\n        for cookie in self.cookies:\n            if cookie.name not in names:\n                continue\n\n            if not domain or cookie.domain == domain:\n                pass\n            elif not subdomains or not cookie.domain.endswith(domain):\n                continue\n\n            if cookie.expires:\n                diff = int(cookie.expires - now)\n\n                if diff <= 0:\n                    self.log.warning(\n                        \"cookies: %s/%s expired at %s\",\n                        cookie.domain.lstrip(\".\"), cookie.name,\n                        dt.datetime.fromtimestamp(cookie.expires))\n                    continue\n\n                elif diff <= 86400:\n                    hours = diff // 3600\n                    self.log.warning(\n                        \"cookies: %s/%s will expire in less than %s hour%s\",\n                        cookie.domain.lstrip(\".\"), cookie.name,\n                        hours + 1, \"s\" if hours else \"\")\n\n            names.discard(cookie.name)\n            if not names:\n                return True\n        return False\n\n    def _extract_jsonld(self, page):\n        return util.json_loads(\n            text.extr(page, '<script type=\"application/ld+json\">',\n                      \"</script>\") or\n            text.extr(page, \"<script type='application/ld+json'>\",\n                      \"</script>\"))\n\n    def _extract_nextdata(self, page):\n        return util.json_loads(\n            text.extr(page, ' id=\"__NEXT_DATA__\" type=\"application/json\">',\n                      \"</script>\") or\n            text.extr(page, \" id='__NEXT_DATA__' type='application/json'>\",\n                      \"</script>\"))\n\n    def _get_date_min_max(self, dmin=None, dmax=None):\n        \"\"\"Retrieve and parse 'date-min' and 'date-max' config values\"\"\"\n        def get(key, default):\n            ts = self.config(key, default)\n            if isinstance(ts, str):\n                dt_obj = dt.parse_iso(ts)\n                if dt_obj is dt.NONE:\n                    self.log.warning(\"Unable to parse '%s': Invalid ISO 8601 \"\n                                     \"date/time value '%s'\", key, ts)\n                    ts = default\n                else:\n                    ts = int(dt.to_ts(dt_obj))\n            return ts\n        if self.config(\"date-format\"):\n            self.log.error(\"'date-format' is no longer supported. \"\n                           \"Use ISO 8601 date/time values instead.\")\n        return get(\"date-min\", dmin), get(\"date-max\", dmax)\n\n    def _dump_response(self, response, history=True):\n        \"\"\"Write the response content to a .txt file in the current directory.\n\n        The file name is derived from the response url,\n        replacing special characters with \"_\"\n        \"\"\"\n        if history:\n            for resp in response.history:\n                self._dump_response(resp, False)\n\n        if hasattr(Extractor, \"_dump_index\"):\n            Extractor._dump_index += 1\n        else:\n            Extractor._dump_index = 1\n            Extractor._dump_sanitize = util.re_compile(\n                r\"[\\\\\\\\|/<>:\\\"?*&=#]+\").sub\n\n        fname = (f\"{Extractor._dump_index:>02}_\"\n                 f\"{Extractor._dump_sanitize('_', response.url)}\")\n\n        if util.WINDOWS:\n            path = os.path.abspath(fname)[:255]\n        else:\n            path = fname[:251]\n\n        try:\n            with open(path + \".txt\", 'wb') as fp:\n                util.dump_response(\n                    response, fp,\n                    headers=(self._write_pages in {\"all\", \"ALL\"}),\n                    hide_auth=(self._write_pages != \"ALL\")\n                )\n            self.log.info(\"Writing '%s' response to '%s'\",\n                          response.url, path + \".txt\")\n        except Exception as e:\n            self.log.warning(\"Failed to dump HTTP request (%s: %s)\",\n                             e.__class__.__name__, e)\n\n\nclass GalleryExtractor(Extractor):\n\n    subcategory = \"gallery\"\n    filename_fmt = \"{category}_{gallery_id}_{num:>03}.{extension}\"\n    directory_fmt = (\"{category}\", \"{gallery_id} {title}\")\n    archive_fmt = \"{gallery_id}_{num}\"\n    enum = \"num\"\n\n    def __init__(self, match, url=None):\n        Extractor.__init__(self, match)\n\n        if url is None and (path := self.groups[0]) and path[0] == \"/\":\n            self.page_url = self.root + path\n        else:\n            self.page_url = url\n\n    def items(self):\n        self.login()\n\n        if self.page_url:\n            page = self.request(\n                self.page_url, notfound=self.subcategory).text\n        else:\n            page = None\n\n        data = self.metadata(page)\n        imgs = self.images(page)\n        assets = self.assets(page)\n\n        if \"count\" in data:\n            if self.config(\"page-reverse\"):\n                images = util.enumerate_reversed(imgs, 1, data[\"count\"])\n            else:\n                images = zip(\n                    range(1, data[\"count\"]+1),\n                    imgs,\n                )\n        else:\n            enum = enumerate\n            try:\n                data[\"count\"] = len(imgs)\n            except TypeError:\n                pass\n            else:\n                if self.config(\"page-reverse\"):\n                    enum = util.enumerate_reversed\n            images = enum(imgs, 1)\n\n        yield Message.Directory, \"\", data\n        enum_key = self.enum\n\n        if assets:\n            for asset in assets:\n                url = asset[\"url\"]\n                asset.update(data)\n                asset[enum_key] = 0\n                if \"extension\" not in asset:\n                    text.nameext_from_url(url, asset)\n                yield Message.Url, url, asset\n\n        for data[enum_key], (url, imgdata) in images:\n            if imgdata:\n                data.update(imgdata)\n                if \"extension\" not in imgdata:\n                    text.nameext_from_url(url, data)\n            else:\n                text.nameext_from_url(url, data)\n            yield Message.Url, url, data\n\n    def login(self):\n        \"\"\"Login and set necessary cookies\"\"\"\n\n    def metadata(self, page):\n        \"\"\"Return a dict with general metadata\"\"\"\n\n    def images(self, page):\n        \"\"\"Return a list or iterable of all (image-url, metadata)-tuples\"\"\"\n\n    def assets(self, page):\n        \"\"\"Return an iterable of additional gallery assets\n\n        Each asset must be a 'dict' containing at least 'url' and 'type'\n        \"\"\"\n\n\nclass ChapterExtractor(GalleryExtractor):\n\n    subcategory = \"chapter\"\n    directory_fmt = (\n        \"{category}\", \"{manga}\",\n        \"{volume:?v/ />02}c{chapter:>03}{chapter_minor:?//}{title:?: //}\")\n    filename_fmt = (\n        \"{manga}_c{chapter:>03}{chapter_minor:?//}_{page:>03}.{extension}\")\n    archive_fmt = (\n        \"{manga}_{chapter}{chapter_minor}_{page}\")\n    enum = \"page\"\n\n\nclass MangaExtractor(Extractor):\n\n    subcategory = \"manga\"\n    categorytransfer = True\n    chapterclass = None\n    reverse = True\n\n    def __init__(self, match, url=None):\n        Extractor.__init__(self, match)\n\n        if url is None and (path := self.groups[0]) and path[0] == \"/\":\n            self.page_url = self.root + path\n        else:\n            self.page_url = url\n\n        if self.config(\"chapter-reverse\", False):\n            self.reverse = not self.reverse\n\n    def items(self):\n        self.login()\n\n        if self.page_url:\n            page = self.request(self.page_url, notfound=self.subcategory).text\n        else:\n            page = None\n\n        chapters = self.chapters(page)\n        if self.reverse:\n            chapters.reverse()\n\n        for chapter, data in chapters:\n            data[\"_extractor\"] = self.chapterclass\n            yield Message.Queue, chapter, data\n\n    def login(self):\n        \"\"\"Login and set necessary cookies\"\"\"\n\n    def chapters(self, page):\n        \"\"\"Return a list of all (chapter-url, metadata)-tuples\"\"\"\n\n\nclass Dispatch():\n    subcategory = \"user\"\n    cookies_domain = None\n    finalize = Extractor.finalize\n    skip_files = None\n\n    def __iter__(self):\n        return self.items()\n\n    def initialize(self):\n        pass\n\n    def _dispatch_extractors(self, extractor_data, default=(), alt=None):\n        extractors = {\n            data[0].subcategory: data\n            for data in extractor_data\n        }\n\n        include = self.config(\"include\", default) or ()\n        if include == \"all\":\n            include = extractors\n        else:\n            if isinstance(include, str):\n                include = include.replace(\" \", \"\").split(\",\")\n            if alt is not None:\n                for sub, sub_alt, url in alt:\n                    extractors[sub_alt] = (extractors[sub] if url is None else\n                                           (extractors[sub][0], url))\n\n        results = []\n        for category in include:\n            try:\n                extr, url = extractors[category]\n            except KeyError:\n                self.log.warning(\"Invalid include '%s'\", category)\n            else:\n                results.append((Message.Queue, url, {\"_extractor\": extr}))\n        return iter(results)\n\n\nclass AsynchronousMixin():\n    \"\"\"Run info extraction in a separate thread\"\"\"\n\n    def __iter__(self):\n        self.initialize()\n\n        messages = queue.Queue(5)\n        thread = threading.Thread(\n            target=self.async_items,\n            args=(messages,),\n            daemon=True,\n        )\n\n        thread.start()\n        while True:\n            msg = messages.get()\n            if msg is None:\n                thread.join()\n                return\n            if isinstance(msg, Exception):\n                thread.join()\n                raise msg\n            yield msg\n            messages.task_done()\n\n    def async_items(self, messages):\n        try:\n            for msg in self.items():\n                messages.put(msg)\n        except Exception as exc:\n            messages.put(exc)\n        messages.put(None)\n\n\nclass BaseExtractor(Extractor):\n    instances = ()\n\n    def __init__(self, match):\n        if not self.category:\n            self._init_category(match)\n        Extractor.__init__(self, match)\n\n    def _init_category(self, match):\n        for index, group in enumerate(match.groups()):\n            if group is not None:\n                if index:\n                    self.category, self.root, info = self.instances[index-1]\n                    if not self.root:\n                        self.root = text.root_from_url(match[0])\n                    self.config_instance = info.get\n                else:\n                    self.root = group\n                    self.category = group.partition(\"://\")[2]\n                break\n\n    @classmethod\n    def update(cls, instances):\n        if extra_instances := config.get((\"extractor\",), cls.basecategory):\n            for category, info in extra_instances.items():\n                if isinstance(info, dict) and \"root\" in info:\n                    instances[category] = info\n\n        pattern_list = []\n        instance_list = cls.instances = []\n        for category, info in instances.items():\n            if root := info[\"root\"]:\n                root = root.rstrip(\"/\")\n            instance_list.append((category, root, info))\n\n            pattern = info.get(\"pattern\")\n            if not pattern:\n                pattern = re.escape(root[root.index(\":\") + 3:])\n            pattern_list.append(pattern + \"()\")\n\n        return (f\"(?:{cls.basecategory}:(https?://[^/?#]+)|\"\n                f\"(?:https?://)?(?:{'|'.join(pattern_list)}))\")\n\n\nclass RequestsAdapter(HTTPAdapter):\n\n    def __init__(self, ssl_context=None, source_address=None):\n        self.ssl_context = ssl_context\n        self.source_address = source_address\n        HTTPAdapter.__init__(self)\n\n    def init_poolmanager(self, *args, **kwargs):\n        kwargs[\"ssl_context\"] = self.ssl_context\n        kwargs[\"source_address\"] = self.source_address\n        return HTTPAdapter.init_poolmanager(self, *args, **kwargs)\n\n    def proxy_manager_for(self, *args, **kwargs):\n        kwargs[\"ssl_context\"] = self.ssl_context\n        kwargs[\"source_address\"] = self.source_address\n        return HTTPAdapter.proxy_manager_for(self, *args, **kwargs)\n\n\ndef _build_requests_adapter(\n        ssl_options, ssl_ciphers, ssl_ctx, source_address):\n\n    key = (ssl_options, ssl_ciphers, ssl_ctx, source_address)\n    try:\n        return CACHE_ADAPTERS[key]\n    except KeyError:\n        pass\n\n    if ssl_options or ssl_ciphers or ssl_ctx:\n        if ssl_ctx is None:\n            ssl_context = urllib3.connection.create_urllib3_context(\n                options=ssl_options or None, ciphers=ssl_ciphers)\n            if not requests.__version__ < \"2.32\":\n                # https://github.com/psf/requests/pull/6731\n                ssl_context.load_verify_locations(requests.certs.where())\n        else:\n            ssl_ctx_orig = urllib3.util.ssl_.SSLContext\n            try:\n                urllib3.util.ssl_.SSLContext = ssl_ctx\n                ssl_context = urllib3.connection.create_urllib3_context(\n                    options=ssl_options or None, ciphers=ssl_ciphers)\n            finally:\n                urllib3.util.ssl_.SSLContext = ssl_ctx_orig\n        ssl_context.check_hostname = False\n    else:\n        ssl_context = None\n\n    adapter = CACHE_ADAPTERS[key] = RequestsAdapter(\n        ssl_context, source_address)\n    return adapter\n\n\ndef _browser_useragent(browser):\n    \"\"\"Get User-Agent header from default browser\"\"\"\n    import webbrowser\n    try:\n        open = webbrowser.get(browser).open\n    except webbrowser.Error:\n        if not browser:\n            raise\n        import shutil\n        if not (browser := shutil.which(browser)):\n            raise\n\n        def open(url):\n            util.Popen((browser, url),\n                       start_new_session=False if util.WINDOWS else True)\n\n    import socket\n    server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)\n    server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)\n    server.bind((\"127.0.0.1\", 0))\n    server.listen(1)\n\n    host, port = server.getsockname()\n    open(f\"http://{host}:{port}/user-agent\")\n\n    client = server.accept()[0]\n    server.close()\n\n    for line in client.recv(1024).split(b\"\\r\\n\"):\n        key, _, value = line.partition(b\":\")\n        if key.strip().lower() == b\"user-agent\":\n            useragent = value.strip()\n            break\n    else:\n        useragent = b\"\"\n\n    client.send(b\"HTTP/1.1 200 OK\\r\\n\\r\\n\" + useragent)\n    client.close()\n\n    return useragent.decode()\n\n\nCACHE_ADAPTERS = {}\nCACHE_COOKIES = {}\nCACHE_MEMORY = {}\nCACHE_UTILS = {}\nCATEGORY_MAP = ()\n\n\nHEADERS_FIREFOX_140 = (\n    (\"User-Agent\", \"Mozilla/5.0 ({}; rv:140.0) Gecko/20100101 Firefox/140.0\"),\n    (\"Accept\", \"text/html,application/xhtml+xml,\"\n               \"application/xml;q=0.9,*/*;q=0.8\"),\n    (\"Accept-Language\", \"en-US,en;q=0.5\"),\n    (\"Accept-Encoding\", None),\n    (\"Connection\", \"keep-alive\"),\n    (\"Content-Type\", None),\n    (\"Content-Length\", None),\n    (\"Referer\", None),\n    (\"Origin\", None),\n    (\"Cookie\", None),\n    (\"Sec-Fetch-Dest\", \"empty\"),\n    (\"Sec-Fetch-Mode\", \"cors\"),\n    (\"Sec-Fetch-Site\", \"same-origin\"),\n    (\"TE\", \"trailers\"),\n)\nHEADERS_FIREFOX_128 = (\n    (\"User-Agent\", \"Mozilla/5.0 ({}; rv:128.0) Gecko/20100101 Firefox/128.0\"),\n    (\"Accept\", \"text/html,application/xhtml+xml,application/xml;q=0.9,\"\n               \"image/avif,image/webp,image/png,image/svg+xml,*/*;q=0.8\"),\n    (\"Accept-Language\", \"en-US,en;q=0.5\"),\n    (\"Accept-Encoding\", None),\n    (\"Referer\", None),\n    (\"Connection\", \"keep-alive\"),\n    (\"Upgrade-Insecure-Requests\", \"1\"),\n    (\"Cookie\", None),\n    (\"Sec-Fetch-Dest\", \"empty\"),\n    (\"Sec-Fetch-Mode\", \"no-cors\"),\n    (\"Sec-Fetch-Site\", \"same-origin\"),\n    (\"TE\", \"trailers\"),\n)\nHEADERS_CHROMIUM_138 = (\n    (\"Connection\", \"keep-alive\"),\n    (\"sec-ch-ua\", '\"Not)A;Brand\";v=\"8\", \"Chromium\";v=\"138\"'),\n    (\"sec-ch-ua-mobile\", \"?0\"),\n    (\"sec-ch-ua-platform\", '\"Linux\"'),\n    (\"Upgrade-Insecure-Requests\", \"1\"),\n    (\"User-Agent\", \"Mozilla/5.0 ({}) AppleWebKit/537.36 (KHTML, \"\n                   \"like Gecko) Chrome/138.0.0.0 Safari/537.36\"),\n    (\"Accept\", \"text/html,application/xhtml+xml,application/xml;q=0.9,\"\n               \"image/avif,image/webp,image/apng,*/*;q=0.8,\"\n               \"application/signed-exchange;v=b3;q=0.7\"),\n    (\"Referer\", None),\n    (\"Sec-Fetch-Site\", \"same-origin\"),\n    (\"Sec-Fetch-Mode\", \"no-cors\"),\n    #  (\"Sec-Fetch-User\", \"?1\"),\n    (\"Sec-Fetch-Dest\", \"empty\"),\n    (\"Accept-Encoding\", None),\n    (\"Accept-Language\", \"en-US,en;q=0.9\"),\n)\nHEADERS_CHROMIUM_111 = (\n    (\"Connection\", \"keep-alive\"),\n    (\"Upgrade-Insecure-Requests\", \"1\"),\n    (\"User-Agent\", \"Mozilla/5.0 ({}) AppleWebKit/537.36 (KHTML, \"\n                   \"like Gecko) Chrome/111.0.0.0 Safari/537.36\"),\n    (\"Accept\", \"text/html,application/xhtml+xml,application/xml;q=0.9,\"\n               \"image/avif,image/webp,image/apng,*/*;q=0.8,\"\n               \"application/signed-exchange;v=b3;q=0.7\"),\n    (\"Referer\", None),\n    (\"Sec-Fetch-Site\", \"same-origin\"),\n    (\"Sec-Fetch-Mode\", \"no-cors\"),\n    (\"Sec-Fetch-Dest\", \"empty\"),\n    (\"Accept-Encoding\", None),\n    (\"Accept-Language\", \"en-US,en;q=0.9\"),\n    (\"cookie\", None),\n    (\"content-length\", None),\n)\nHEADERS = {\n    \"firefox\"    : HEADERS_FIREFOX_140,\n    \"firefox/140\": HEADERS_FIREFOX_140,\n    \"firefox/128\": HEADERS_FIREFOX_128,\n    \"chrome\"     : HEADERS_CHROMIUM_138,\n    \"chrome/138\" : HEADERS_CHROMIUM_138,\n    \"chrome/111\" : HEADERS_CHROMIUM_111,\n}\n\nCIPHERS_FIREFOX = (\n    \"TLS_AES_128_GCM_SHA256:\"\n    \"TLS_CHACHA20_POLY1305_SHA256:\"\n    \"TLS_AES_256_GCM_SHA384:\"\n    \"ECDHE-ECDSA-AES128-GCM-SHA256:\"\n    \"ECDHE-RSA-AES128-GCM-SHA256:\"\n    \"ECDHE-ECDSA-CHACHA20-POLY1305:\"\n    \"ECDHE-RSA-CHACHA20-POLY1305:\"\n    \"ECDHE-ECDSA-AES256-GCM-SHA384:\"\n    \"ECDHE-RSA-AES256-GCM-SHA384:\"\n    \"ECDHE-ECDSA-AES256-SHA:\"\n    \"ECDHE-ECDSA-AES128-SHA:\"\n    \"ECDHE-RSA-AES128-SHA:\"\n    \"ECDHE-RSA-AES256-SHA:\"\n    \"AES128-GCM-SHA256:\"\n    \"AES256-GCM-SHA384:\"\n    \"AES128-SHA:\"\n    \"AES256-SHA\"\n)\nCIPHERS_CHROMIUM = (\n    \"TLS_AES_128_GCM_SHA256:\"\n    \"TLS_AES_256_GCM_SHA384:\"\n    \"TLS_CHACHA20_POLY1305_SHA256:\"\n    \"ECDHE-ECDSA-AES128-GCM-SHA256:\"\n    \"ECDHE-RSA-AES128-GCM-SHA256:\"\n    \"ECDHE-ECDSA-AES256-GCM-SHA384:\"\n    \"ECDHE-RSA-AES256-GCM-SHA384:\"\n    \"ECDHE-ECDSA-CHACHA20-POLY1305:\"\n    \"ECDHE-RSA-CHACHA20-POLY1305:\"\n    \"ECDHE-RSA-AES128-SHA:\"\n    \"ECDHE-RSA-AES256-SHA:\"\n    \"AES128-GCM-SHA256:\"\n    \"AES256-GCM-SHA384:\"\n    \"AES128-SHA:\"\n    \"AES256-SHA\"\n)\nCIPHERS = {\n    \"firefox\"    : CIPHERS_FIREFOX,\n    \"firefox/140\": CIPHERS_FIREFOX,\n    \"firefox/128\": CIPHERS_FIREFOX,\n    \"chrome\"     : CIPHERS_CHROMIUM,\n    \"chrome/138\" : CIPHERS_CHROMIUM,\n    \"chrome/111\" : CIPHERS_CHROMIUM,\n}\n\n\n# disable Basic Authorization header injection from .netrc data\ntry:\n    requests.sessions.get_netrc_auth = lambda _: None\nexcept Exception:\n    pass\n\n# detect brotli support\ntry:\n    BROTLI = urllib3.response.brotli is not None\nexcept AttributeError:\n    BROTLI = False\n\n# detect zstandard support\ntry:\n    ZSTD = urllib3.response.HAS_ZSTD\nexcept AttributeError:\n    ZSTD = False\n\n# set (urllib3) warnings filter\naction = config.get((), \"warnings\", \"default\")\nif action:\n    try:\n        import warnings\n        warnings.simplefilter(action, urllib3.exceptions.HTTPWarning)\n    except Exception:\n        pass\ndel action\n"
  },
  {
    "path": "gallery_dl/extractor/cyberdrop.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://cyberdrop.cr/\"\"\"\n\nfrom . import lolisafe\nfrom .common import Message\nfrom .. import text\n\nBASE_PATTERN = r\"(?:https?://)?(?:www\\.)?cyberdrop\\.(?:cr|me|to)\"\n\n\nclass CyberdropAlbumExtractor(lolisafe.LolisafeAlbumExtractor):\n    \"\"\"Extractor for cyberdrop albums\"\"\"\n    category = \"cyberdrop\"\n    root = \"https://cyberdrop.cr\"\n    root_api = \"https://api.cyberdrop.cr\"\n    pattern = BASE_PATTERN + r\"/a/([^/?#]+)\"\n    example = \"https://cyberdrop.cr/a/ID\"\n\n    def items(self):\n        files, data = self.fetch_album(self.album_id)\n\n        yield Message.Directory, \"\", data\n        for data[\"num\"], file in enumerate(files, 1):\n            file.update(data)\n            text.nameext_from_url(file[\"name\"], file)\n            file[\"name\"], sep, file[\"id\"] = file[\"filename\"].rpartition(\"-\")\n            yield Message.Url, file[\"url\"], file\n\n    def fetch_album(self, album_id):\n        url = f\"{self.root}/a/{album_id}\"\n        page = self.request(url).text\n        extr = text.extract_from(page)\n\n        desc = extr('property=\"og:description\" content=\"', '\"')\n        if desc.startswith(\"A privacy-focused censorship-resistant file \"\n                           \"sharing platform free for everyone.\"):\n            desc = \"\"\n        extr('id=\"title\"', \"\")\n\n        album = {\n            \"album_id\"   : album_id,\n            \"album_name\" : text.unescape(extr('title=\"', '\"')),\n            \"album_size\" : text.parse_bytes(extr(\n                '<p class=\"title\">', \"B\")),\n            \"date\"       : self.parse_datetime(extr(\n                '<p class=\"title\">', '<'), \"%d.%m.%Y\"),\n            \"description\": text.unescape(text.unescape(  # double\n                desc.rpartition(\" [R\")[0])),\n        }\n\n        file_ids = list(text.extract_iter(page, 'id=\"file\" href=\"/f/', '\"'))\n        album[\"count\"] = len(file_ids)\n        return self._extract_files(file_ids), album\n\n    def _extract_files(self, file_ids):\n        for file_id in file_ids:\n            try:\n                url = f\"{self.root_api}/api/file/info/{file_id}\"\n                file = self.request_json(url)\n                auth = self.request_json(file[\"auth_url\"])\n                file[\"url\"] = auth[\"url\"]\n            except Exception as exc:\n                self.log.warning(\"%s (%s: %s)\",\n                                 file_id, exc.__class__.__name__, exc)\n                continue\n\n            yield file\n\n\nclass CyberdropMediaExtractor(CyberdropAlbumExtractor):\n    \"\"\"Extractor for cyberdrop media links\"\"\"\n    subcategory = \"media\"\n    directory_fmt = (\"{category}\",)\n    pattern = BASE_PATTERN + r\"/f/([^/?#]+)\"\n    example = \"https://cyberdrop.cr/f/ID\"\n\n    def fetch_album(self, album_id):\n        return self._extract_files((album_id,)), {\n            \"album_id\"   : \"\",\n            \"album_name\" : \"\",\n            \"album_size\" : -1,\n            \"description\": \"\",\n            \"count\"      : 1,\n        }\n"
  },
  {
    "path": "gallery_dl/extractor/cyberfile.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2025 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://cyberfile.me/\"\"\"\n\nfrom .common import Extractor, Message\nfrom .. import text\n\nBASE_PATTERN = r\"(?:https?://)?(?:www\\.)?cyberfile\\.me\"\n\n\nclass CyberfileExtractor(Extractor):\n    \"\"\"Base class for cyberfile extractors\"\"\"\n    category = \"cyberfile\"\n    root = \"https://cyberfile.me\"\n\n    def request_api(self, endpoint, data):\n        url = self.root + endpoint\n        headers = {\n            \"X-Requested-With\": \"XMLHttpRequest\",\n            \"Origin\": self.root,\n        }\n        resp = self.request_json(\n            url, method=\"POST\", headers=headers, data=data)\n\n        if \"albumPasswordModel\" in resp.get(\"javascript\", \"\"):\n            url_pw = self.root + \"/ajax/folder_password_process\"\n            data_pw = {\n                \"folderPassword\": self._get_auth_info(password=True)[1],\n                \"folderId\": text.extr(\n                    resp[\"html\"], '<input type=\"hidden\" value=\"', '\"'),\n                \"submitme\": \"1\",\n            }\n            resp = self.request_json(\n                url_pw, method=\"POST\", headers=headers, data=data_pw)\n            if not resp.get(\"success\"):\n                raise self.exc.AuthorizationError(f\"'{resp.get('msg')}'\")\n            resp = self.request_json(\n                url, method=\"POST\", headers=headers, data=data)\n\n        return resp\n\n\nclass CyberfileFolderExtractor(CyberfileExtractor):\n    subcategory = \"folder\"\n    pattern = BASE_PATTERN + r\"/folder/([0-9a-f]+)\"\n    example = \"https://cyberfile.me/folder/0123456789abcdef/NAME\"\n\n    def items(self):\n        folder_hash = self.groups[0]\n        url = f\"{self.root}/folder/{folder_hash}\"\n        folder_num = text.extr(self.request(url).text, \"ages('folder', '\", \"'\")\n\n        extract_folders = text.re(r'sharing-url=\"([^\"]+)').findall\n        extract_files = text.re(r'dtfullurl=\"([^\"]+)').findall\n        recursive = self.config(\"recursive\", True)\n        perpage = 600\n\n        data = {\n            \"pageType\" : \"folder\",\n            \"nodeId\"   : folder_num,\n            \"pageStart\": 1,\n            \"perPage\"  : perpage,\n            \"filterOrderBy\": \"\",\n        }\n        resp = self.request_api(\"/account/ajax/load_files\", data)\n        html = resp[\"html\"]\n\n        folder = {\n            \"folder_hash\": folder_hash,\n            \"folder_num\" : text.parse_int(folder_num),\n            \"folder\"     : resp[\"page_title\"],\n        }\n\n        while True:\n            folders = extract_folders(html)\n            if recursive and folders:\n                folder[\"_extractor\"] = CyberfileFolderExtractor\n                for url in folders:\n                    yield Message.Queue, url, folder\n\n            if files := extract_files(html):\n                folder[\"_extractor\"] = CyberfileFileExtractor\n                for url in files:\n                    yield Message.Queue, url, folder\n\n            if len(folders) + len(files) < perpage:\n                return\n            data[\"pageStart\"] += 1\n            resp = self.request_api(\"/account/ajax/load_files\", data)\n\n\nclass CyberfileSharedExtractor(CyberfileExtractor):\n    subcategory = \"shared\"\n    pattern = BASE_PATTERN + r\"/shared/([a-zA-Z0-9]+)\"\n    example = \"https://cyberfile.me/shared/AbCdEfGhIjK\"\n\n    def items(self):\n        # get 'filehosting' cookie\n        url = f\"{self.root}/shared/{self.groups[0]}\"\n        self.request(url, method=\"HEAD\")\n\n        data = {\n            \"pageType\" : \"nonaccountshared\",\n            \"nodeId\"   : \"\",\n            \"pageStart\": \"1\",\n            \"perPage\"  : \"500\",\n            \"filterOrderBy\": \"\",\n        }\n        resp = self.request_api(\"/account/ajax/load_files\", data)\n\n        html = resp[\"html\"]\n        pos = html.find(\"<!-- /.navbar-collapse -->\") + 26\n\n        data = {\"_extractor\": CyberfileFolderExtractor}\n        for url in text.extract_iter(html, 'sharing-url=\"', '\"', pos):\n            yield Message.Queue, url, data\n\n        data = {\"_extractor\": CyberfileFileExtractor}\n        for url in text.extract_iter(html, 'dtfullurl=\"', '\"', pos):\n            yield Message.Queue, url, data\n\n\nclass CyberfileFileExtractor(CyberfileExtractor):\n    subcategory = \"file\"\n    directory_fmt = (\"{category}\", \"{uploader}\", \"{folder}\")\n    pattern = BASE_PATTERN + r\"/([a-zA-Z0-9]+)\"\n    example = \"https://cyberfile.me/AbCdE\"\n\n    def items(self):\n        file_id = self.groups[0]\n        url = f\"{self.root}/{file_id}\"\n        file_num = text.extr(self.request(url).text, \"owFileInformation(\", \")\")\n\n        data = {\"u\": file_num}\n        resp = self.request_api(\"/account/ajax/file_details\", data)\n        extr = text.extract_from(resp[\"html\"])\n        info = text.split_html(extr('class=\"text-section\">', \"</span>\"))\n        folder = info[0] if len(info) > 1 else \"\"\n\n        file = {\n            \"file_id\" : file_id,\n            \"file_num\": text.parse_int(file_num),\n            \"name\"    : resp[\"page_title\"],\n            \"folder\"  : folder,\n            \"uploader\": info[-1][2:].strip(),\n            \"size\"    : text.parse_bytes(text.remove_html(extr(\n                \"Filesize:\", \"</tr>\"))[:-1]),\n            \"tags\"    : text.split_html(extr(\n                \"Keywords:\", \"</tr>\")),\n            \"date\"    : self.parse_datetime(text.remove_html(extr(\n                \"Uploaded:\", \"</tr>\")), \"%d/%m/%Y %H:%M:%S\"),\n            \"permissions\": text.remove_html(extr(\n                \"Permissions:\", \"</tr>\")).split(\" &amp; \"),\n        }\n\n        file[\"file_url\"] = url = extr(\"openUrl('\", \"'\")\n        text.nameext_from_url(file[\"name\"] or url, file)\n        yield Message.Directory, \"\", file\n        yield Message.Url, url, file\n"
  },
  {
    "path": "gallery_dl/extractor/danbooru.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2014-2025 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://danbooru.donmai.us/ and other Danbooru instances\"\"\"\n\nfrom .common import BaseExtractor, Message\nfrom .. import text, util, dt\n\n\nclass DanbooruExtractor(BaseExtractor):\n    \"\"\"Base class for danbooru extractors\"\"\"\n    basecategory = \"Danbooru\"\n    filename_fmt = \"{category}_{id}_{filename}.{extension}\"\n    page_limit = 1000\n    page_start = None\n    per_page = 200\n    useragent = util.USERAGENT_GALLERYDL\n    request_interval = (0.5, 1.5)\n\n    def _init(self):\n        self.ugoira = self.config(\"ugoira\", False)\n        self.external = self.config(\"external\", False)\n        self.includes = False\n\n        threshold = self.config(\"threshold\")\n        if isinstance(threshold, int):\n            self.threshold = 1 if threshold < 1 else threshold\n        else:\n            self.threshold = self.per_page - 20\n\n        username, api_key = self._get_auth_info()\n        if username:\n            self.log.debug(\"Using HTTP Basic Auth for user '%s'\", username)\n            self.session.auth = util.HTTPBasicAuth(username, api_key)\n\n    def skip_files(self, num):\n        pages = num // self.per_page\n        if pages >= self.page_limit:\n            pages = self.page_limit - 1\n        self.page_start = pages + 1\n        return pages * self.per_page\n\n    def items(self):\n        # 'includes' initialization must be done here and not in '_init()'\n        # or it'll cause an exception with e621 when 'metadata' is enabled\n        if includes := self.config(\"metadata\"):\n            if isinstance(includes, (list, tuple)):\n                includes = \",\".join(includes)\n            elif not isinstance(includes, str):\n                includes = \"artist_commentary,children,notes,parent,uploader\"\n            self.includes = includes + \",id\"\n\n        data = self.metadata()\n        for post in self.posts():\n\n            try:\n                url = post[\"file_url\"]\n            except KeyError:\n                if self.external and post[\"source\"]:\n                    post.update(data)\n                    yield Message.Directory, \"\", post\n                    yield Message.Queue, post[\"source\"], post\n                continue\n\n            text.nameext_from_url(url, post)\n            post[\"date\"] = dt.parse_iso(post[\"created_at\"])\n\n            post[\"tags\"] = (\n                post[\"tag_string\"].split(\" \")\n                if post[\"tag_string\"] else ())\n            post[\"tags_artist\"] = (\n                post[\"tag_string_artist\"].split(\" \")\n                if post[\"tag_string_artist\"] else ())\n            post[\"tags_character\"] = (\n                post[\"tag_string_character\"].split(\" \")\n                if post[\"tag_string_character\"] else ())\n            post[\"tags_copyright\"] = (\n                post[\"tag_string_copyright\"].split(\" \")\n                if post[\"tag_string_copyright\"] else ())\n            post[\"tags_general\"] = (\n                post[\"tag_string_general\"].split(\" \")\n                if post[\"tag_string_general\"] else ())\n            post[\"tags_meta\"] = (\n                post[\"tag_string_meta\"].split(\" \")\n                if post[\"tag_string_meta\"] else ())\n\n            if post[\"extension\"] == \"zip\":\n                if self.ugoira:\n                    post[\"_ugoira_original\"] = False\n                    post[\"_ugoira_frame_data\"] = post[\"frames\"] = \\\n                        self._ugoira_frames(post)\n                    post[\"_http_adjust_extension\"] = False\n                else:\n                    url = post[\"large_file_url\"]\n                    post[\"extension\"] = \"webm\"\n\n            if url[0] == \"/\":\n                if url[1] == \"/\":\n                    url = \"https:\" + url\n                else:\n                    url = self.root + url\n\n            post.update(data)\n            yield Message.Directory, \"\", post\n            yield Message.Url, url, post\n\n    def items_artists(self):\n        for artist in self.artists():\n            artist[\"_extractor\"] = DanbooruTagExtractor\n            url = f\"{self.root}/posts?tags={text.quote(artist['name'])}\"\n            yield Message.Queue, url, artist\n\n    def metadata(self):\n        return ()\n\n    def posts(self):\n        return ()\n\n    def _pagination(self, endpoint, params, prefix=None):\n        url = self.root + endpoint\n        params[\"limit\"] = self.per_page\n        params[\"page\"] = self.page_start\n\n        first = True\n        while True:\n            posts = self.request_json(url, params=params)\n            if isinstance(posts, dict):\n                posts = posts[\"posts\"]\n\n            if posts:\n                if self.includes:\n                    params_meta = {\n                        \"only\" : self.includes,\n                        \"limit\": len(posts),\n                        \"tags\" : \"id:\" + \",\".join(str(p[\"id\"]) for p in posts),\n                    }\n                    data = {\n                        meta[\"id\"]: meta\n                        for meta in self.request_json(url, params=params_meta)\n                    }\n                    for post in posts:\n                        post.update(data[post[\"id\"]])\n\n                if prefix == \"a\" and not first:\n                    posts.reverse()\n\n                yield from posts\n\n            if len(posts) < self.threshold:\n                return\n\n            if prefix:\n                params[\"page\"] = prefix + str(posts[-1][\"id\"])\n            elif params[\"page\"]:\n                params[\"page\"] += 1\n            else:\n                params[\"page\"] = 2\n            first = False\n\n    def _ugoira_frames(self, post):\n        data = self.request_json(\n            f\"{self.root}/posts/{post['id']}.json?only=media_metadata\"\n        )[\"media_metadata\"][\"metadata\"]\n\n        if \"Ugoira:FrameMimeType\" in data:\n            ext = data[\"Ugoira:FrameMimeType\"].rpartition(\"/\")[2]\n            if ext == \"jpeg\":\n                ext = \"jpg\"\n        else:\n            ext = data[\"ZIP:ZipFileName\"].rpartition(\".\")[2]\n\n        delays = data[\"Ugoira:FrameDelays\"]\n        return [{\"file\": f\"{index:>06}.{ext}\", \"delay\": delay}\n                for index, delay in enumerate(delays)]\n\n    def _collection_posts(self, cid, ctype):\n        reverse = prefix = None\n\n        order = self.config(\"order-posts\")\n        if not order or order in {\"asc\", \"pool\", \"pool_asc\", \"asc_pool\"}:\n            params = {\"tags\": f\"ord{ctype}:{cid}\"}\n        elif order in {\"id\", \"desc_id\", \"id_desc\"}:\n            params = {\"tags\": f\"{ctype}:{cid}\"}\n            prefix = \"b\"\n        elif order in {\"desc\", \"desc_pool\", \"pool_desc\"}:\n            params = {\"tags\": f\"ord{ctype}:{cid}\"}\n            reverse = True\n        elif order in {\"asc_id\", \"id_asc\"}:\n            params = {\"tags\": f\"{ctype}:{cid}\"}\n            reverse = True\n\n        posts = self._pagination(\"/posts.json\", params, prefix)\n        if reverse:\n            self.log.info(\"Collecting posts of %s %s\", ctype, cid)\n            return self._collection_enumerate_reverse(posts)\n        else:\n            return self._collection_enumerate(posts)\n\n    def _collection_metadata(self, cid, ctype, cname=None):\n        url = f\"{self.root}/{cname or ctype}s/{cid}.json\"\n        collection = self.request_json(url)\n        collection[\"name\"] = collection[\"name\"].replace(\"_\", \" \")\n        self.post_ids = collection.pop(\"post_ids\", ())\n        return {ctype: collection}\n\n    def _collection_enumerate(self, posts):\n        pid_to_num = {pid: num for num, pid in enumerate(self.post_ids, 1)}\n        for post in posts:\n            post[\"num\"] = pid_to_num[post[\"id\"]]\n            yield post\n\n    def _collection_enumerate_reverse(self, posts):\n        posts = list(posts)\n        posts.reverse()\n\n        pid_to_num = {pid: num for num, pid in enumerate(self.post_ids, 1)}\n        for post in posts:\n            post[\"num\"] = pid_to_num[post[\"id\"]]\n        return posts\n\n\nBASE_PATTERN = DanbooruExtractor.update({\n    \"danbooru\": {\n        \"root\": None,\n        \"pattern\": r\"(?:(?:danbooru|hijiribe|sonohara|safebooru)\\.donmai\\.us\"\n                   r\"|donmai\\.moe)\",\n    },\n    \"atfbooru\": {\n        \"root\": \"https://booru.allthefallen.moe\",\n        \"pattern\": r\"booru\\.allthefallen\\.moe\",\n    },\n    \"aibooru\": {\n        \"root\": None,\n        \"pattern\": r\"(?:safe\\.|general\\.)?aibooru\\.(?:online|download)\",\n    },\n    \"booruvar\": {\n        \"root\": \"https://booru.borvar.art\",\n        \"pattern\": r\"booru\\.borvar\\.art\",\n    },\n})\n\n\nclass DanbooruTagExtractor(DanbooruExtractor):\n    \"\"\"Extractor for danbooru posts from tag searches\"\"\"\n    subcategory = \"tag\"\n    directory_fmt = (\"{category}\", \"{search_tags}\")\n    archive_fmt = \"t_{search_tags}_{id}\"\n    pattern = BASE_PATTERN + r\"/posts\\?(?:[^&#]*&)*tags=([^&#]*)\"\n    example = \"https://danbooru.donmai.us/posts?tags=TAG\"\n\n    def metadata(self):\n        self.tags = text.unquote(self.groups[-1].replace(\"+\", \" \"))\n        return {\"search_tags\": self.tags}\n\n    def posts(self):\n        prefix = \"b\"\n        for tag in self.tags.split():\n            if tag.startswith(\"order:\"):\n                if tag == \"order:id\" or tag == \"order:id_asc\":\n                    prefix = \"a\"\n                elif tag == \"order:id_desc\":\n                    prefix = \"b\"\n                else:\n                    prefix = None\n            elif tag.startswith(\n                    (\"id:\", \"md5:\", \"ordfav:\", \"ordfavgroup:\", \"ordpool:\")):\n                prefix = None\n                break\n\n        return self._pagination(\"/posts.json\", {\"tags\": self.tags}, prefix)\n\n\nclass DanbooruRandomExtractor(DanbooruTagExtractor):\n    \"\"\"Extractor for a random danbooru post\"\"\"\n    subcategory = \"random\"\n    pattern = BASE_PATTERN + r\"/posts/random(?:\\?(?:[^&#]*&)*tags=([^&#]*))?\"\n    example = \"https://danbooru.donmai.us/posts/random?tags=TAG\"\n\n    def metadata(self):\n        tags = self.groups[-1] or \"\"\n        self.tags = text.unquote(tags.replace(\"+\", \" \"))\n        return {\"search_tags\": self.tags}\n\n    def posts(self):\n        posts = self.request_json(self.root + \"/posts/random.json\",\n                                  params={\"tags\": self.tags or None})\n        return (posts,) if isinstance(posts, dict) else posts\n\n\nclass DanbooruPoolExtractor(DanbooruExtractor):\n    \"\"\"Extractor for Danbooru pools\"\"\"\n    subcategory = \"pool\"\n    directory_fmt = (\"{category}\", \"pool\", \"{pool[id]} {pool[name]}\")\n    filename_fmt = \"{num:>04}_{id}_{filename}.{extension}\"\n    archive_fmt = \"p_{pool[id]}_{id}\"\n    pattern = BASE_PATTERN + r\"/pool(?:s|/show)/(\\d+)\"\n    example = \"https://danbooru.donmai.us/pools/12345\"\n\n    def metadata(self):\n        self.pool_id = self.groups[-1]\n        return self._collection_metadata(self.pool_id, \"pool\")\n\n    def posts(self):\n        return self._collection_posts(self.pool_id, \"pool\")\n\n\nclass DanbooruFavgroupExtractor(DanbooruExtractor):\n    \"\"\"Extractor for Danbooru favorite groups\"\"\"\n    subcategory = \"favgroup\"\n    directory_fmt = (\"{category}\", \"Favorite Groups\",\n                     \"{favgroup[id]} {favgroup[name]}\")\n    filename_fmt = \"{num:>04}_{id}_{filename}.{extension}\"\n    archive_fmt = \"fg_{favgroup[id]}_{id}\"\n    pattern = BASE_PATTERN + r\"/favorite_group(?:s|/show)/(\\d+)\"\n    example = \"https://danbooru.donmai.us/favorite_groups/12345\"\n\n    def metadata(self):\n        return self._collection_metadata(\n            self.groups[-1], \"favgroup\", \"favorite_group\")\n\n    def posts(self):\n        return self._collection_posts(self.groups[-1], \"favgroup\")\n\n\nclass DanbooruPostExtractor(DanbooruExtractor):\n    \"\"\"Extractor for single danbooru posts\"\"\"\n    subcategory = \"post\"\n    archive_fmt = \"{id}\"\n    pattern = BASE_PATTERN + r\"/post(?:s|/show)/(\\d+)\"\n    example = \"https://danbooru.donmai.us/posts/12345\"\n\n    def posts(self):\n        url = f\"{self.root}/posts/{self.groups[-1]}.json\"\n        post = self.request_json(url)\n        if self.includes:\n            params = {\"only\": self.includes}\n            post.update(self.request_json(url, params=params))\n        return (post,)\n\n\nclass DanbooruMediaassetExtractor(DanbooruExtractor):\n    \"\"\"Extractor for a danbooru media asset\"\"\"\n    subcategory = \"media-asset\"\n    filename_fmt = \"{category}_ma{id}_{filename}.{extension}\"\n    archive_fmt = \"m{id}\"\n    pattern = BASE_PATTERN + r\"/media_assets/(\\d+)\"\n    example = \"https://danbooru.donmai.us/media_assets/12345\"\n\n    def posts(self):\n        url = f\"{self.root}/media_assets/{self.groups[-1]}.json\"\n        asset = self.request_json(url)\n\n        asset[\"file_url\"] = asset[\"variants\"][-1][\"url\"]\n        asset[\"tag_string\"] = \\\n            asset[\"tag_string_artist\"] = \\\n            asset[\"tag_string_character\"] = \\\n            asset[\"tag_string_copyright\"] = \\\n            asset[\"tag_string_general\"] = \\\n            asset[\"tag_string_meta\"] = \"\"\n\n        if self.includes:\n            params = {\"only\": self.includes}\n            asset.update(self.request_json(url, params=params))\n        return (asset,)\n\n\nclass DanbooruPopularExtractor(DanbooruExtractor):\n    \"\"\"Extractor for popular images from danbooru\"\"\"\n    subcategory = \"popular\"\n    directory_fmt = (\"{category}\", \"popular\", \"{scale}\", \"{date}\")\n    archive_fmt = \"P_{scale[0]}_{date}_{id}\"\n    pattern = BASE_PATTERN + r\"/(?:explore/posts/)?popular(?:\\?([^#]*))?\"\n    example = \"https://danbooru.donmai.us/explore/posts/popular\"\n\n    def metadata(self):\n        self.params = params = text.parse_query(self.groups[-1])\n        scale = params.get(\"scale\", \"day\")\n        date = params.get(\"date\") or dt.date.today().isoformat()\n\n        if scale == \"week\":\n            date = dt.date.fromisoformat(date)\n            date = (date - dt.timedelta(days=date.weekday())).isoformat()\n        elif scale == \"month\":\n            date = date[:-3]\n\n        return {\"date\": date, \"scale\": scale}\n\n    def posts(self):\n        return self._pagination(\"/explore/posts/popular.json\", self.params)\n\n\nclass DanbooruArtistExtractor(DanbooruExtractor):\n    \"\"\"Extractor for danbooru artists\"\"\"\n    subcategory = \"artist\"\n    pattern = BASE_PATTERN + r\"/artists/(\\d+)\"\n    example = \"https://danbooru.donmai.us/artists/12345\"\n\n    items = DanbooruExtractor.items_artists\n\n    def artists(self):\n        url = f\"{self.root}/artists/{self.groups[-1]}.json\"\n        return (self.request_json(url),)\n\n\nclass DanbooruArtistSearchExtractor(DanbooruExtractor):\n    \"\"\"Extractor for danbooru artist searches\"\"\"\n    subcategory = \"artist-search\"\n    pattern = BASE_PATTERN + r\"/artists/?\\?([^#]+)\"\n    example = \"https://danbooru.donmai.us/artists?QUERY\"\n\n    items = DanbooruExtractor.items_artists\n\n    def artists(self):\n        url = self.root + \"/artists.json\"\n        params = text.parse_query(self.groups[-1])\n        params[\"page\"] = text.parse_int(params.get(\"page\"), 1)\n\n        while True:\n            artists = self.request_json(url, params=params)\n\n            yield from artists\n\n            if len(artists) < 20:\n                return\n            params[\"page\"] += 1\n"
  },
  {
    "path": "gallery_dl/extractor/dandadan.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2025 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://dandadan.net/\"\"\"\n\nfrom .common import ChapterExtractor, MangaExtractor\nfrom .. import text\n\nBASE_PATTERN = r\"(?:https?://)?(?:www\\.)?dandadan\\.net\"\n\n\nclass DandadanBase():\n    \"\"\"Base class for dandadan extractors\"\"\"\n    category = \"dandadan\"\n    root = \"https://dandadan.net\"\n\n\nclass DandadanChapterExtractor(DandadanBase, ChapterExtractor):\n    \"\"\"Extractor for dandadan manga chapters\"\"\"\n    pattern = BASE_PATTERN + r\"(/manga/dandadan-chapter-([^/?#]+)/?)\"\n    example = \"https://dandadan.net/manga/dandadan-chapter-123/\"\n\n    def metadata(self, page):\n        chapter, sep, minor = text.extr(\n            page, \"hapter \", \" - \").partition(\".\")\n        return {\n            \"manga\"        : \"Dandadan\",\n            \"chapter\"      : text.parse_int(chapter),\n            \"chapter_minor\": sep + minor,\n            \"lang\"         : \"en\",\n        }\n\n    def images(self, page):\n        images = [\n            (text.extr(figure, 'src=\"', '\"'), None)\n            for figure in text.extract_iter(page, \"<figure\", \"</figure>\")\n        ]\n\n        if images:\n            return images\n\n        return [\n            (src, None)\n            for src in text.extract_iter(\n                page, '<img decoding=\"async\" class=\"aligncenter\" src=\"', '\"')\n        ]\n\n\nclass DandadanMangaExtractor(DandadanBase, MangaExtractor):\n    \"\"\"Extractor for dandadan manga\"\"\"\n    chapterclass = DandadanChapterExtractor\n    pattern = BASE_PATTERN + r\"(/)\"\n    example = \"https://dandadan.net/\"\n\n    def chapters(self, page):\n        data = {}\n        return [\n            (text.extr(post, 'href=\"', '\"'), data)\n            for post in text.extract_iter(page, '<li id=\"su-post', \"</li>\")\n        ]\n"
  },
  {
    "path": "gallery_dl/extractor/dankefuerslesen.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2025-2026 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://danke.moe/\"\"\"\n\nfrom .common import ChapterExtractor, MangaExtractor\nfrom .. import text, util\n\nBASE_PATTERN = r\"(?:https?://)?(?:www\\.)?danke\\.moe\"\nMANGA_PATTERN = BASE_PATTERN + r\"/read(?:er)?/(?:manga|series)/([\\w-]+)\"\n\n\nclass DankefuerslesenBase():\n    \"\"\"Base class for dankefuerslesen extractors\"\"\"\n    category = \"dankefuerslesen\"\n    root = \"https://danke.moe\"\n\n    def _manga_info(self, slug):\n        url = f\"{self.root}/api/series/{slug}/\"\n        return self.request_json(url)\n\n\nclass DankefuerslesenChapterExtractor(DankefuerslesenBase, ChapterExtractor):\n    \"\"\"Extractor for Danke fürs Lesen manga chapters\"\"\"\n    pattern = MANGA_PATTERN + r\"/([\\w-]+)\"\n    example = \"https://danke.moe/read/manga/TITLE/123/1/\"\n\n    def _init(self):\n        self.zip = self.config(\"zip\", False)\n        if self.zip:\n            self.filename_fmt = self.directory_fmt[-1] + \".{extension}\"\n            self.directory_fmt = self.directory_fmt[:-1]\n\n    def metadata(self, page):\n        slug, ch = self.groups\n        manga = self.cache(self._manga_info, slug)\n\n        if \"-\" in ch:\n            chapter, sep, minor = ch.rpartition(\"-\")\n            ch = ch.replace(\"-\", \".\")\n            minor = \".\" + minor\n        else:\n            chapter = ch\n            minor = \"\"\n\n        data = manga[\"chapters\"][ch]\n        group_id, self._files = next(iter(data[\"groups\"].items()))\n\n        if not self.zip:\n            self.base = (f\"{self.root}/media/manga/{slug}/chapters\"\n                         f\"/{data['folder']}/{group_id}/\")\n\n        return {\n            \"manga\"     : manga[\"title\"],\n            \"manga_slug\": manga[\"slug\"],\n            \"author\"    : manga[\"author\"],\n            \"artist\"    : manga[\"artist\"],\n            \"description\": manga[\"description\"],\n            \"title\"     : data[\"title\"],\n            \"volume\"    : text.parse_int(data[\"volume\"]),\n            \"chapter\"   : text.parse_int(chapter),\n            \"chapter_minor\": minor,\n            \"group\"     : manga[\"groups\"][group_id].split(\" & \"),\n            \"group_id\"  : text.parse_int(group_id),\n            \"date\"      : self.parse_timestamp(data[\"release_date\"][group_id]),\n            \"lang\"      : util.NONE,\n            \"language\"  : util.NONE,\n        }\n\n    def images(self, page):\n        if self.zip:\n            return ()\n\n        base = self.base\n        return [(base + file, None) for file in self._files]\n\n    def assets(self, page):\n        if self.zip:\n            slug, ch = self.groups\n            url = f\"{self.root}/api/download_chapter/{slug}/{ch}/\"\n            return ({\n                \"type\"     : \"archive\",\n                \"extension\": \"zip\",\n                \"url\"      : url,\n            },)\n\n\nclass DankefuerslesenMangaExtractor(DankefuerslesenBase, MangaExtractor):\n    \"\"\"Extractor for Danke fürs Lesen manga\"\"\"\n    chapterclass = DankefuerslesenChapterExtractor\n    reverse = False\n    pattern = MANGA_PATTERN\n    example = \"https://danke.moe/read/manga/TITLE/\"\n\n    def chapters(self, page):\n        results = []\n\n        manga = self.cache(self._manga_info, self.groups[0]).copy()\n        manga[\"lang\"] = util.NONE\n        manga[\"language\"] = util.NONE\n\n        base = f\"{self.root}/read/manga/{manga['slug']}/\"\n        for ch, data in manga.pop(\"chapters\").items():\n\n            if \".\" in ch:\n                chapter, sep, minor = ch.rpartition(\".\")\n                ch = ch.replace('.', '-')\n                data[\"chapter\"] = text.parse_int(chapter)\n                data[\"chapter_minor\"] = sep + minor\n            else:\n                data[\"chapter\"] = text.parse_int(ch)\n                data[\"chapter_minor\"] = \"\"\n\n            results.append((f\"{base}{ch}/1/\", {**manga, **data}))\n\n        return results\n"
  },
  {
    "path": "gallery_dl/extractor/desktopography.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://desktopography.net/\"\"\"\n\nfrom .common import Extractor, Message\nfrom .. import text\n\nBASE_PATTERN = r\"(?:https?://)?desktopography\\.net\"\n\n\nclass DesktopographyExtractor(Extractor):\n    \"\"\"Base class for desktopography extractors\"\"\"\n    category = \"desktopography\"\n    archive_fmt = \"{filename}\"\n    root = \"https://desktopography.net\"\n\n\nclass DesktopographySiteExtractor(DesktopographyExtractor):\n    \"\"\"Extractor for all desktopography exhibitions \"\"\"\n    subcategory = \"site\"\n    pattern = BASE_PATTERN + r\"/$\"\n    example = \"https://desktopography.net/\"\n\n    def items(self):\n        page = self.request(self.root).text\n        data = {\"_extractor\": DesktopographyExhibitionExtractor}\n\n        for exhibition_year in text.extract_iter(\n                page,\n                '<a href=\"https://desktopography.net/exhibition-',\n                '/\">'):\n\n            url = self.root + \"/exhibition-\" + exhibition_year + \"/\"\n            yield Message.Queue, url, data\n\n\nclass DesktopographyExhibitionExtractor(DesktopographyExtractor):\n    \"\"\"Extractor for a yearly desktopography exhibition\"\"\"\n    subcategory = \"exhibition\"\n    pattern = BASE_PATTERN + r\"/exhibition-([^/?#]+)/\"\n    example = \"https://desktopography.net/exhibition-2020/\"\n\n    def __init__(self, match):\n        DesktopographyExtractor.__init__(self, match)\n        self.year = match[1]\n\n    def items(self):\n        url = f\"{self.root}/exhibition-{self.year}/\"\n        base_entry_url = \"https://desktopography.net/portfolios/\"\n        page = self.request(url).text\n\n        data = {\n            \"_extractor\": DesktopographyEntryExtractor,\n            \"year\": self.year,\n        }\n\n        for entry_url in text.extract_iter(\n                page,\n                '<a class=\"overlay-background\" href=\"' + base_entry_url,\n                '\">'):\n\n            url = base_entry_url + entry_url\n            yield Message.Queue, url, data\n\n\nclass DesktopographyEntryExtractor(DesktopographyExtractor):\n    \"\"\"Extractor for all resolutions of a desktopography wallpaper\"\"\"\n    subcategory = \"entry\"\n    pattern = BASE_PATTERN + r\"/portfolios/([\\w-]+)\"\n    example = \"https://desktopography.net/portfolios/NAME/\"\n\n    def __init__(self, match):\n        DesktopographyExtractor.__init__(self, match)\n        self.entry = match[1]\n\n    def items(self):\n        url = f\"{self.root}/portfolios/{self.entry}\"\n        page = self.request(url).text\n\n        entry_data = {\"entry\": self.entry}\n        yield Message.Directory, \"\", entry_data\n\n        for image_data in text.extract_iter(\n                page,\n                '<a target=\"_blank\" href=\"https://desktopography.net',\n                '\">'):\n\n            path, _, filename = image_data.partition(\n                '\" class=\"wallpaper-button\" download=\"')\n            text.nameext_from_url(filename, entry_data)\n            yield Message.Url, self.root + path, entry_data\n"
  },
  {
    "path": "gallery_dl/extractor/deviantart.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2015-2026 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://www.deviantart.com/\"\"\"\n\nfrom .common import Extractor, Message, Dispatch\nfrom .. import text, util, dt\nimport collections\nimport mimetypes\nimport binascii\nimport time\n\nBASE_PATTERN = (\n    r\"(?:https?://)?(?:\"\n    r\"(?:www\\.)?(?:fx)?deviantart\\.com/(?!watch/)([\\w-]+)|\"\n    r\"(?!www\\.)([\\w-]+)\\.(?:fx)?deviantart\\.com)\"\n)\nDEFAULT_AVATAR = \"https://a.deviantart.net/avatars/default.gif\"\n\n\nclass DeviantartExtractor(Extractor):\n    \"\"\"Base class for deviantart extractors\"\"\"\n    category = \"deviantart\"\n    root = \"https://www.deviantart.com\"\n    directory_fmt = (\"{category}\", \"{username}\")\n    filename_fmt = \"{category}_{index}_{title}.{extension}\"\n    cookies_domain = \".deviantart.com\"\n    cookies_names = (\"auth\", \"auth_secure\", \"userinfo\")\n    _last_request = 0\n\n    def __init__(self, match):\n        Extractor.__init__(self, match)\n        self.user = (match[1] or match[2] or \"\").lower()\n        self.offset = 0\n\n    def _init(self):\n        self.jwt = self.config(\"jwt\", False)\n        self.flat = self.config(\"flat\", True)\n        self.extra = self.config(\"extra\", False)\n        self.quality = self.config(\"quality\", \"100\")\n        self.original = self.config(\"original\", False)\n        self.previews = self.config(\"previews\", False)\n        self.intermediary = self.config(\"intermediary\", True)\n        self.comments_avatars = self.config(\"comments-avatars\", False)\n        self.comments = self.comments_avatars or self.config(\"comments\", False)\n\n        self.api = DeviantartOAuthAPI(self)\n        self.eclipse_api = None\n        self.group = False\n        self._premium_cache = {}\n\n        if self.config(\"auto-unwatch\"):\n            self.unwatch = []\n            self.finalize = self._unwatch_premium\n        else:\n            self.unwatch = None\n\n        if self.quality:\n            if self.quality == \"png\":\n                self.quality = \"-fullview.png?\"\n                self.quality_sub = text.re(r\"-fullview\\.[a-z0-9]+\\?\").sub\n            else:\n                self.quality = \",q_\" + str(self.quality)\n                self.quality_sub = text.re(r\",q_\\d+\").sub\n\n        if self.intermediary:\n            self.intermediary_subn = text.re(r\"(/f/[^/]+/[^/]+)/v\\d+/.*\").subn\n        self.blur_sub = text.re(r\",blur_\\d+\").sub\n\n        if isinstance(self.original, str) and \\\n                self.original.lower().startswith(\"image\"):\n            self.original = True\n            self._update_content = self._update_content_image\n        else:\n            self._update_content = self._update_content_default\n\n        if self.previews == \"all\":\n            self.previews_images = self.previews = True\n        else:\n            self.previews_images = False\n\n        journals = self.config(\"journals\", \"html\")\n        if journals == \"html\":\n            self.commit_journal = self._commit_journal_html\n        elif journals == \"text\":\n            self.commit_journal = self._commit_journal_text\n        else:\n            self.commit_journal = None\n\n    def request(self, url, **kwargs):\n        if \"fatal\" not in kwargs:\n            kwargs[\"fatal\"] = False\n        while True:\n            response = Extractor.request(self, url, **kwargs)\n            if response.status_code != 403 or \\\n                    b\"Request blocked.\" not in response.content:\n                return response\n            self.wait(seconds=300, reason=\"CloudFront block\")\n\n    def skip_files(self, num):\n        self.offset += num\n        return num\n\n    def login(self):\n        if self.cookies_check(self.cookies_names):\n            return True\n\n        username, password = self._get_auth_info()\n        if username:\n            self.cookies_update(self.cache(\n                self._login_impl, username, password,\n                _exp=28*86400, _mem=False))\n            return True\n\n    def _login_impl(self, username, password):\n        self.log.info(\"Logging in as %s\", username)\n\n        url = \"https://www.deviantart.com/users/login\"\n        page = self.request(url).text\n\n        data = {}\n        for item in text.extract_iter(\n                page, '<input type=\"hidden\" name=\"', '\"/>'):\n            name, _, value = item.partition('\" value=\"')\n            data[name] = value\n\n        challenge = data.get(\"challenge\")\n        if challenge and challenge != \"0\":\n            self.log.warning(\"Login requires solving a CAPTCHA\")\n            self.log.debug(challenge)\n\n        data[\"username\"] = username\n        data[\"password\"] = password\n        data[\"remember\"] = \"on\"\n\n        self.sleep(2.0, \"login\")\n        url = \"https://www.deviantart.com/_sisu/do/signin\"\n        response = self.request(url, method=\"POST\", data=data)\n\n        if not response.history:\n            raise self.exc.AuthenticationError()\n\n        return {cookie.name: cookie.value\n                for cookie in self.cookies}\n\n    def items(self):\n        if self.user:\n            if group := self.config(\"group\", True):\n                if user := self.cache(self._user_details, self.user):\n                    self.user = user[\"username\"]\n                    self.group = False\n                elif group == \"skip\":\n                    self.log.info(\"Skipping group '%s'\", self.user)\n                    raise self.exc.AbortExtraction()\n                else:\n                    self.subcategory = \"group-\" + self.subcategory\n                    self.group = True\n\n        for deviation in self.deviations():\n            if isinstance(deviation, tuple):\n                url, data = deviation\n                yield Message.Queue, url, data\n                continue\n\n            if deviation[\"is_deleted\"]:\n                # prevent crashing in case the deviation really is\n                # deleted\n                self.log.debug(\n                    \"Skipping %s (deleted)\", deviation[\"deviationid\"])\n                continue\n\n            tier_access = deviation.get(\"tier_access\")\n            if tier_access == \"locked\":\n                self.log.debug(\n                    \"Skipping %s (access locked)\", deviation[\"deviationid\"])\n                continue\n\n            if \"premium_folder_data\" in deviation:\n                data = self._fetch_premium(deviation)\n                if not data:\n                    continue\n                deviation.update(data)\n\n            self.prepare(deviation)\n            yield Message.Directory, \"\", deviation\n\n            if \"content\" in deviation:\n                content = self._extract_content(deviation)\n                yield self.commit(deviation, content)\n\n            elif self.original and deviation[\"is_downloadable\"]:\n                content = self.api.deviation_download(deviation[\"deviationid\"])\n                deviation[\"is_original\"] = True\n                yield self.commit(deviation, content)\n\n            if \"videos\" in deviation and deviation[\"videos\"]:\n                video = max(deviation[\"videos\"],\n                            key=lambda x: text.parse_int(x[\"quality\"][:-1]))\n                deviation[\"is_original\"] = False\n                yield self.commit(deviation, video)\n\n            if \"flash\" in deviation:\n                deviation[\"is_original\"] = True\n                yield self.commit(deviation, deviation[\"flash\"])\n\n            if self.commit_journal:\n                if journal := self._extract_journal(deviation):\n                    if self.extra:\n                        deviation[\"_journal\"] = journal[\"html\"]\n                    deviation[\"is_original\"] = True\n                    yield self.commit_journal(deviation, journal)\n\n            if self.comments_avatars:\n                for comment in deviation[\"comments\"]:\n                    user = comment[\"user\"]\n                    name = user[\"username\"].lower()\n                    if user[\"usericon\"] == DEFAULT_AVATAR:\n                        self.log.debug(\n                            \"Skipping avatar of '%s' (default)\", name)\n                        continue\n                    self.cache_update(\n                        self._user_details, name, user, _mem=True)\n\n                    url = f\"{self.root}/{name}/avatar/\"\n                    comment[\"_extractor\"] = DeviantartAvatarExtractor\n                    yield Message.Queue, url, comment\n\n            if self.previews and \"preview\" in deviation:\n                preview = deviation[\"preview\"]\n                deviation[\"is_preview\"] = True\n                if self.previews_images:\n                    yield self.commit(deviation, preview)\n                else:\n                    mtype = mimetypes.guess_type(\n                        \"a.\" + deviation[\"extension\"], False)[0]\n                    if mtype and not mtype.startswith(\"image/\"):\n                        yield self.commit(deviation, preview)\n                del deviation[\"is_preview\"]\n\n            if not self.extra:\n                continue\n\n            # ref: https://www.deviantart.com\n            #      /developers/http/v1/20210526/object/editor_text\n            # the value of \"features\" is a JSON string with forward\n            # slashes escaped\n            text_content = \\\n                deviation[\"text_content\"][\"body\"][\"features\"].replace(\n                    \"\\\\/\", \"/\") if \"text_content\" in deviation else None\n            for txt in (text_content, deviation.get(\"description\"),\n                        deviation.get(\"_journal\")):\n                if txt is None:\n                    continue\n                for match in DeviantartStashExtractor.pattern.finditer(txt):\n                    url = text.ensure_http_scheme(match[0])\n                    deviation[\"_extractor\"] = DeviantartStashExtractor\n                    yield Message.Queue, url, deviation\n\n    def deviations(self):\n        \"\"\"Return an iterable containing all relevant Deviation-objects\"\"\"\n\n    def prepare(self, deviation):\n        \"\"\"Adjust the contents of a Deviation-object\"\"\"\n        if \"index\" not in deviation:\n            try:\n                if deviation[\"url\"].startswith((\n                    \"https://www.deviantart.com/stash/\", \"https://sta.sh\",\n                )):\n                    filename = deviation[\"content\"][\"src\"].split(\"/\")[5]\n                    deviation[\"index_base36\"] = filename.partition(\"-\")[0][1:]\n                    deviation[\"index\"] = id_from_base36(\n                        deviation[\"index_base36\"])\n                else:\n                    deviation[\"index\"] = text.parse_int(\n                        deviation[\"url\"].rpartition(\"-\")[2])\n            except KeyError:\n                deviation[\"index\"] = 0\n                deviation[\"index_base36\"] = \"0\"\n        if \"index_base36\" not in deviation:\n            deviation[\"index_base36\"] = base36_from_id(deviation[\"index\"])\n\n        if self.user:\n            deviation[\"username\"] = self.user\n            deviation[\"_username\"] = self.user.lower()\n        else:\n            deviation[\"username\"] = deviation[\"author\"][\"username\"]\n            deviation[\"_username\"] = deviation[\"username\"].lower()\n\n        deviation[\"published_time\"] = text.parse_int(\n            deviation[\"published_time\"])\n        deviation[\"date\"] = self.parse_timestamp(\n            deviation[\"published_time\"])\n\n        if self.comments:\n            deviation[\"comments\"] = (\n                self._extract_comments(deviation[\"deviationid\"], \"deviation\")\n                if deviation[\"stats\"][\"comments\"] else ()\n            )\n\n        # filename metadata\n        sub = text.re(r\"\\W\").sub\n        deviation[\"filename\"] = \"\".join((\n            sub(\"_\", deviation[\"title\"].lower()), \"_by_\",\n            sub(\"_\", deviation[\"author\"][\"username\"].lower()), \"-d\",\n            deviation[\"index_base36\"],\n        ))\n\n    def commit(self, deviation, target):\n        url = target[\"src\"]\n        name = target.get(\"filename\") or url\n        target = target.copy()\n        target[\"filename\"] = deviation[\"filename\"]\n        deviation[\"target\"] = target\n        deviation[\"extension\"] = target[\"extension\"] = text.ext_from_url(name)\n        if \"is_original\" not in deviation:\n            deviation[\"is_original\"] = (\"/v1/\" not in url)\n        return Message.Url, url, deviation\n\n    def _commit_journal_html(self, deviation, journal):\n        title = text.escape(deviation[\"title\"])\n        url = deviation[\"url\"]\n        thumbs = deviation.get(\"thumbs\") or deviation.get(\"files\")\n        html = journal[\"html\"]\n        tmpl = self.utils(\"journal\")\n        shadow = tmpl.SHADOW.format_map(thumbs[0]) if thumbs else \"\"\n\n        if not html:\n            self.log.warning(\"%s: Empty journal content\", deviation[\"index\"])\n\n        if \"css\" in journal:\n            css, cls = journal[\"css\"], \"withskin\"\n        elif html.startswith(\"<style\"):\n            css, _, html = html.partition(\"</style>\")\n            css = css.partition(\">\")[2]\n            cls = \"withskin\"\n        else:\n            css, cls = \"\", \"journal-green\"\n\n        if html.find('<div class=\"boxtop journaltop\">', 0, 250) != -1:\n            needle = '<div class=\"boxtop journaltop\">'\n            header = tmpl.HEADER_CUSTOM.format(\n                title=title, url=url, date=deviation[\"date\"],\n            )\n        else:\n            needle = '<div usr class=\"gr\">'\n            username = deviation[\"author\"][\"username\"]\n            urlname = deviation.get(\"username\") or username.lower()\n            header = tmpl.HEADER.format(\n                title=title,\n                url=url,\n                userurl=f\"{self.root}/{urlname}/\",\n                username=username,\n                date=deviation[\"date\"],\n            )\n\n        if needle in html:\n            html = html.replace(needle, header, 1)\n        else:\n            html = tmpl.HTML_EXTRA.format(header, html)\n\n        html = tmpl.HTML.format(\n            title=title, html=html, shadow=shadow, css=css, cls=cls)\n\n        deviation[\"extension\"] = \"htm\"\n        return Message.Url, html, deviation\n\n    def _commit_journal_text(self, deviation, journal):\n        html = journal[\"html\"]\n        if not html:\n            self.log.warning(\"%s: Empty journal content\", deviation[\"index\"])\n        elif html.startswith(\"<style\"):\n            html = html.partition(\"</style>\")[2]\n        head, _, tail = html.rpartition(\"<script\")\n        content = \"\\n\".join(\n            text.unescape(text.remove_html(txt))\n            for txt in (head or tail).split(\"<br />\")\n        )\n        txt = self.utils(\"journal\").TEXT.format(\n            title=deviation[\"title\"],\n            username=deviation[\"author\"][\"username\"],\n            date=deviation[\"date\"],\n            content=content,\n        )\n\n        deviation[\"extension\"] = \"txt\"\n        return Message.Url, txt, deviation\n\n    def _extract_journal(self, deviation):\n        if \"excerpt\" in deviation:\n            # # empty 'html'\n            #  return self.api.deviation_content(deviation[\"deviationid\"])\n\n            if \"_page\" in deviation:\n                page = deviation[\"_page\"]\n                del deviation[\"_page\"]\n            else:\n                page = self._limited_request(deviation[\"url\"]).text\n\n            # extract journal html from webpage\n            html = text.extr(\n                page,\n                \"<h2>Literature Text</h2></span><div>\",\n                \"</div></section></div></div>\")\n            if html:\n                return {\"html\": html}\n\n            self.log.debug(\"%s: Failed to extract journal HTML from webpage. \"\n                           \"Falling back to __INITIAL_STATE__ markup.\",\n                           deviation[\"index\"])\n\n            # parse __INITIAL_STATE__ as fallback\n            state = util.json_loads(text.extr(\n                page, 'window.__INITIAL_STATE__ = JSON.parse(\"', '\");')\n                .replace(\"\\\\\\\\\", \"\\\\\").replace(\"\\\\'\", \"'\").replace('\\\\\"', '\"'))\n            deviations = state[\"@@entities\"][\"deviation\"]\n            content = deviations.popitem()[1][\"textContent\"]\n\n            if html := self._textcontent_to_html(deviation, content):\n                return {\"html\": html}\n            return {\"html\": content[\"excerpt\"].replace(\"\\n\", \"<br />\")}\n\n        if \"body\" in deviation:\n            return {\"html\": deviation.pop(\"body\")}\n        return None\n\n    def _textcontent_to_html(self, deviation, content):\n        html = content[\"html\"]\n        markup = html.get(\"markup\")\n\n        if not markup or markup[0] != \"{\":\n            return markup\n\n        if html[\"type\"] == \"tiptap\":\n            try:\n                return self.utils(\"tiptap\").to_html(markup)\n            except Exception as exc:\n                self.log.traceback(exc)\n                self.log.error(\"%s: '%s: %s'\", deviation[\"index\"],\n                               exc.__class__.__name__, exc)\n\n        self.log.warning(\"%s: Unsupported '%s' markup.\",\n                         deviation[\"index\"], html[\"type\"])\n\n    def _extract_content(self, deviation):\n        content = deviation[\"content\"]\n\n        if self.original and deviation[\"is_downloadable\"]:\n            self._update_content(deviation, content)\n            return content\n\n        if self.jwt:\n            self._update_token(deviation, content)\n            return content\n\n        if content[\"src\"].startswith(\"https://images-wixmp-\"):\n            if self.intermediary and deviation[\"index\"] <= 790677560:\n                # https://github.com/r888888888/danbooru/issues/4069\n                intermediary, count = self.intermediary_subn(\n                    r\"/intermediary\\1\", content[\"src\"], 1)\n                if count:\n                    deviation[\"is_original\"] = False\n                    deviation[\"_fallback\"] = (content[\"src\"],)\n                    content[\"src\"] = intermediary\n            if self.quality:\n                content[\"src\"] = self.quality_sub(\n                    self.quality, content[\"src\"], 1)\n            content[\"src\"] = self.blur_sub(\"\", content[\"src\"], 1)\n\n        return content\n\n    def _find_folder(self, folders, name, uuid):\n        if uuid.isdecimal():\n            match = text.re(\n                \"(?i)\" + name.replace(\"-\", \"[^a-z0-9]+\") + \"$\").match\n            for folder in folders:\n                if match(folder[\"name\"]):\n                    return folder\n                elif folder.get(\"has_subfolders\"):\n                    for subfolder in folder[\"subfolders\"]:\n                        if match(subfolder[\"name\"]):\n                            return subfolder\n        else:\n            for folder in folders:\n                if folder[\"folderid\"] == uuid:\n                    return folder\n                elif folder.get(\"has_subfolders\"):\n                    for subfolder in folder[\"subfolders\"]:\n                        if subfolder[\"folderid\"] == uuid:\n                            return subfolder\n        raise self.exc.NotFoundError(\"folder\")\n\n    def _folder_urls(self, folders, category, extractor):\n        base = f\"{self.root}/{self.user}/{category}/\"\n        for folder in folders:\n            folder[\"_extractor\"] = extractor\n            url = f\"{base}{folder['folderid']}/{folder['name']}\"\n            yield url, folder\n\n    def _update_content_default(self, deviation, content):\n        if \"premium_folder_data\" in deviation or \\\n                \"tier_access\" in deviation or \\\n                deviation.get(\"is_mature\"):\n            public = False\n        else:\n            public = None\n\n        data = self.api.deviation_download(deviation[\"deviationid\"], public)\n        content.update(data)\n        deviation[\"is_original\"] = True\n\n    def _update_content_image(self, deviation, content):\n        data = self.api.deviation_download(deviation[\"deviationid\"])\n        url = data[\"src\"].partition(\"?\")[0]\n        mtype = mimetypes.guess_type(url, False)[0]\n        if mtype and mtype.startswith(\"image/\"):\n            content.update(data)\n            deviation[\"is_original\"] = True\n\n    def _update_token(self, deviation, content):\n        \"\"\"Replace JWT to be able to remove width/height limits\n\n        All credit goes to @Ironchest337\n        for discovering and implementing this method\n        \"\"\"\n        url, sep, _ = content[\"src\"].partition(\"/v1/\")\n        if not sep:\n            return\n\n        # 'images-wixmp' returns 401 errors, but just 'wixmp' still works\n        url = url.replace(\"//images-wixmp\", \"//wixmp\", 1)\n\n        #  header = b'{\"typ\":\"JWT\",\"alg\":\"none\"}'\n        payload = (\n            b'{\"sub\":\"urn:app:\",\"iss\":\"urn:app:\",\"obj\":[[{\"path\":\"/f/' +\n            url.partition(\"/f/\")[2].encode() +\n            b'\"}]],\"aud\":[\"urn:service:file.download\"]}'\n        )\n\n        deviation[\"_fallback\"] = (content[\"src\"],)\n        deviation[\"is_original\"] = True\n        pl = binascii.b2a_base64(payload).rstrip(b'=\\n').decode()\n        content[\"src\"] = (\n            # base64 of 'header' is precomputed as 'eyJ0eX...'\n            f\"{url}?token=eyJ0eXAiOiJKV1QiLCJhbGciOiJub25lIn0.{pl}.\")\n\n    def _extract_comments(self, target_id, target_type=\"deviation\"):\n        results = None\n        comment_ids = [None]\n\n        while comment_ids:\n            comments = self.api.comments(\n                target_id, target_type, comment_ids.pop())\n\n            if results:\n                results.extend(comments)\n            else:\n                results = comments\n\n            # parent comments, i.e. nodes with at least one child\n            parents = {c[\"parentid\"] for c in comments}\n            # comments with more than one reply\n            replies = {c[\"commentid\"] for c in comments if c[\"replies\"]}\n            # add comment UUIDs with replies that are not parent to any node\n            comment_ids.extend(replies - parents)\n\n        return results\n\n    def _limited_request(self, url, **kwargs):\n        \"\"\"Limits HTTP requests to one every 2 seconds\"\"\"\n        diff = time.time() - DeviantartExtractor._last_request\n        if diff < 2.0:\n            self.sleep(2.0 - diff, \"request\")\n        response = self.request(url, **kwargs)\n        DeviantartExtractor._last_request = time.time()\n        return response\n\n    def _fetch_premium(self, deviation):\n        try:\n            return self._premium_cache[deviation[\"deviationid\"]]\n        except KeyError:\n            pass\n\n        if not self.api.refresh_token_key:\n            self.log.warning(\n                \"Unable to access premium content (no refresh-token)\")\n            self._fetch_premium = lambda _: None\n            return None\n\n        dev = self.api.deviation(deviation[\"deviationid\"], False)\n        folder = deviation[\"premium_folder_data\"]\n        username = dev[\"author\"][\"username\"]\n\n        # premium_folder_data is no longer present when user has access (#5063)\n        has_access = (\"premium_folder_data\" not in dev) or folder[\"has_access\"]\n\n        if not has_access and folder[\"type\"] == \"watchers\" and \\\n                self.config(\"auto-watch\"):\n            if self.unwatch is not None:\n                self.unwatch.append(username)\n            if self.api.user_friends_watch(username):\n                has_access = True\n                self.log.info(\n                    \"Watching %s for premium folder access\", username)\n            else:\n                self.log.warning(\n                    \"Error when trying to watch %s. \"\n                    \"Try again with a new refresh-token\", username)\n\n        if has_access:\n            self.log.info(\"Fetching premium folder data\")\n        else:\n            self.log.warning(\"Unable to access premium content (type: %s)\",\n                             folder[\"type\"])\n\n        cache = self._premium_cache\n        for dev in self.api.gallery(\n                username, folder[\"gallery_id\"], public=False):\n            cache[dev[\"deviationid\"]] = dev if has_access else None\n\n        return cache.get(deviation[\"deviationid\"])\n\n    def _unwatch_premium(self, _=None):\n        for username in self.unwatch:\n            self.log.info(\"Unwatching %s\", username)\n            self.api.user_friends_unwatch(username)\n\n    def _eclipse_to_oauth(self, eclipse_api, deviations):\n        for obj in deviations:\n            deviation = obj[\"deviation\"] if \"deviation\" in obj else obj\n            deviation_uuid = eclipse_api.deviation_extended_fetch(\n                deviation[\"deviationId\"],\n                deviation[\"author\"][\"username\"],\n                \"journal\" if deviation[\"isJournal\"] else \"art\",\n            )[\"deviation\"][\"extended\"][\"deviationUuid\"]\n            yield self.api.deviation(deviation_uuid)\n\n    def _unescape_json(self, json):\n        return json.replace('\\\\\"', '\"') \\\n                   .replace(\"\\\\'\", \"'\") \\\n                   .replace(\"\\\\\\\\\", \"\\\\\")\n\n    def _user_details(self, name):\n        try:\n            return self.cache(self.api.user_profile, name)[\"user\"]\n        except Exception:\n            return None\n\n\nclass DeviantartUserExtractor(Dispatch, DeviantartExtractor):\n    \"\"\"Extractor for an artist's user profile\"\"\"\n    pattern = BASE_PATTERN + r\"/?$\"\n    example = \"https://www.deviantart.com/USER\"\n\n    def items(self):\n        base = f\"{self.root}/{self.user}/\"\n        return self._dispatch_extractors((\n            (DeviantartAvatarExtractor    , base + \"avatar\"),\n            (DeviantartBackgroundExtractor, base + \"banner\"),\n            (DeviantartGalleryExtractor   , base + \"gallery\"),\n            (DeviantartScrapsExtractor    , base + \"gallery/scraps\"),\n            (DeviantartJournalExtractor   , base + \"posts\"),\n            (DeviantartStatusExtractor    , base + \"posts/statuses\"),\n            (DeviantartFavoriteExtractor  , base + \"favourites\"),\n        ), (\"gallery\",))\n\n\n###############################################################################\n# OAuth #######################################################################\n\nclass DeviantartGalleryExtractor(DeviantartExtractor):\n    \"\"\"Extractor for all deviations from an artist's gallery\"\"\"\n    subcategory = \"gallery\"\n    archive_fmt = \"g_{_username}_{index}.{extension}\"\n    pattern = (BASE_PATTERN + r\"/gallery\"\n               r\"(?:/all|/recommended-for-you)?\"\n               r\"/?(\\?(?!q=|catpath=scraps).*)?$\")\n    example = \"https://www.deviantart.com/USER/gallery/\"\n\n    def deviations(self):\n        if self.flat and not self.group:\n            return self.api.gallery_all(self.user, self.offset)\n        folders = self.cache(self.api.gallery_folders, self.user)\n        return self._folder_urls(folders, \"gallery\", DeviantartFolderExtractor)\n\n\nclass DeviantartAvatarExtractor(DeviantartExtractor):\n    \"\"\"Extractor for an artist's avatar\"\"\"\n    subcategory = \"avatar\"\n    archive_fmt = \"a_{_username}_{index}\"\n    pattern = BASE_PATTERN + r\"/avatar\"\n    example = \"https://www.deviantart.com/USER/avatar/\"\n\n    def deviations(self):\n        name = self.user.lower()\n        user = self.cache(self._user_details, name)\n        if not user:\n            return ()\n\n        icon = user[\"usericon\"]\n        if icon == DEFAULT_AVATAR:\n            self.log.debug(\"Skipping avatar of '%s' (default)\", name)\n            return ()\n\n        _, sep, index = icon.rpartition(\"?\")\n        if not sep:\n            index = \"0\"\n\n        formats = self.config(\"formats\")\n        if not formats:\n            url = icon.replace(\"/avatars/\", \"/avatars-big/\", 1)\n            return (self._make_deviation(url, user, index, \"\"),)\n\n        if isinstance(formats, str):\n            formats = formats.replace(\" \", \"\").split(\",\")\n\n        results = []\n        for fmt in formats:\n            fmt, _, ext = fmt.rpartition(\".\")\n            if fmt:\n                fmt = \"-\" + fmt\n            url = (f\"https://a.deviantart.net/avatars{fmt}\"\n                   f\"/{name[0]}/{name[1]}/{name}.{ext}?{index}\")\n            results.append(self._make_deviation(url, user, index, fmt))\n        return results\n\n    def _make_deviation(self, url, user, index, fmt):\n        return {\n            \"author\"         : user,\n            \"da_category\"    : \"avatar\",\n            \"index\"          : text.parse_int(index),\n            \"is_deleted\"     : False,\n            \"is_downloadable\": False,\n            \"published_time\" : 0,\n            \"title\"          : \"avatar\" + fmt,\n            \"stats\"          : {\"comments\": 0},\n            \"content\"        : {\"src\": url},\n        }\n\n\nclass DeviantartBackgroundExtractor(DeviantartExtractor):\n    \"\"\"Extractor for an artist's banner\"\"\"\n    subcategory = \"background\"\n    archive_fmt = \"b_{index}\"\n    pattern = BASE_PATTERN + r\"/ba(?:nner|ckground)\"\n    example = \"https://www.deviantart.com/USER/banner/\"\n\n    def deviations(self):\n        try:\n            return (self.cache(self.api.user_profile, self.user.lower())\n                    [\"cover_deviation\"][\"cover_deviation\"],)\n        except Exception:\n            return ()\n\n\nclass DeviantartFolderExtractor(DeviantartExtractor):\n    \"\"\"Extractor for deviations inside an artist's gallery folder\"\"\"\n    subcategory = \"folder\"\n    directory_fmt = (\"{category}\", \"{username}\", \"{folder[title]}\")\n    archive_fmt = \"F_{folder[uuid]}_{index}.{extension}\"\n    pattern = BASE_PATTERN + r\"/gallery/([^/?#]+)/([^/?#]+)\"\n    example = \"https://www.deviantart.com/USER/gallery/12345/TITLE\"\n\n    def __init__(self, match):\n        DeviantartExtractor.__init__(self, match)\n        self.folder = None\n        self.folder_id = match[3]\n        self.folder_name = match[4]\n\n    def deviations(self):\n        folders = self.cache(self.api.gallery_folders, self.user)\n        folder = self._find_folder(folders, self.folder_name, self.folder_id)\n\n        # Leaving this here for backwards compatibility\n        self.folder = {\n            \"title\": folder[\"name\"],\n            \"uuid\" : folder[\"folderid\"],\n            \"index\": self.folder_id,\n            \"owner\": self.user,\n            \"parent_uuid\": folder[\"parent\"],\n        }\n\n        if folder.get(\"subfolder\"):\n            self.folder[\"parent_folder\"] = folder[\"parent_folder\"]\n            self.archive_fmt = \"F_{folder[parent_uuid]}_{index}.{extension}\"\n\n            if self.flat:\n                self.directory_fmt = (\"{category}\", \"{username}\",\n                                      \"{folder[parent_folder]}\")\n            else:\n                self.directory_fmt = (\"{category}\", \"{username}\",\n                                      \"{folder[parent_folder]}\",\n                                      \"{folder[title]}\")\n\n        if folder.get(\"has_subfolders\") and self.config(\"subfolders\", True):\n            for subfolder in folder[\"subfolders\"]:\n                subfolder[\"parent_folder\"] = folder[\"name\"]\n                subfolder[\"subfolder\"] = True\n            yield from self._folder_urls(\n                folder[\"subfolders\"], \"gallery\", DeviantartFolderExtractor)\n\n        yield from self.api.gallery(self.user, folder[\"folderid\"], self.offset)\n\n    def prepare(self, deviation):\n        DeviantartExtractor.prepare(self, deviation)\n        deviation[\"folder\"] = self.folder\n\n\nclass DeviantartStashExtractor(DeviantartExtractor):\n    \"\"\"Extractor for sta.sh-ed deviations\"\"\"\n    subcategory = \"stash\"\n    archive_fmt = \"{index}.{extension}\"\n    skip_files = None\n    pattern = (r\"(?:https?://)?(?:(?:www\\.)?deviantart\\.com/stash|sta\\.s(h))\"\n               r\"/([a-z0-9]+)\")\n    example = \"https://www.deviantart.com/stash/abcde\"\n\n    def __init__(self, match):\n        DeviantartExtractor.__init__(self, match)\n        self.user = \"\"\n\n    def deviations(self, stash_id=None, stash_data=None):\n        if stash_id is None:\n            legacy_url, stash_id = self.groups\n        else:\n            legacy_url = False\n\n        if legacy_url and stash_id[0] == \"2\":\n            url = \"https://sta.sh/\" + stash_id\n            response = self._limited_request(url)\n            stash_id = response.url.rpartition(\"/\")[2]\n            page = response.text\n        else:\n            url = \"https://www.deviantart.com/stash/\" + stash_id\n            page = self._limited_request(url).text\n\n        if stash_id[0] == \"0\":\n            if uuid := text.extr(page, '//deviation/', '\"'):\n                deviation = self.api.deviation(uuid)\n                deviation[\"_page\"] = page\n                deviation[\"index\"] = text.parse_int(text.extr(\n                    page, '\\\\\"deviationId\\\\\":', ','))\n\n                deviation[\"stash_id\"] = stash_id\n                if stash_data:\n                    folder = stash_data[\"folder\"]\n                    deviation[\"stash_name\"] = folder[\"name\"]\n                    deviation[\"stash_folder\"] = folder[\"folderId\"]\n                    deviation[\"stash_parent\"] = folder[\"parentId\"] or 0\n                    deviation[\"stash_description\"] = \\\n                        folder[\"richDescription\"][\"excerpt\"]\n                else:\n                    deviation[\"stash_name\"] = \"\"\n                    deviation[\"stash_description\"] = \"\"\n                    deviation[\"stash_folder\"] = 0\n                    deviation[\"stash_parent\"] = 0\n\n                yield deviation\n                return\n\n        if stash_data := text.extr(page, ',\\\\\"stash\\\\\":', ',\\\\\"@@'):\n            if stash_data.endswith(\":{}\"):\n                stash_data = stash_data[:stash_data.rfind(\"}\", None, -2)+1]\n            stash_data = util.json_loads(self._unescape_json(stash_data))\n\n        for sid in text.extract_iter(\n                page, 'href=\"https://www.deviantart.com/stash/', '\"'):\n            if sid == stash_id or sid.endswith(\"#comments\"):\n                continue\n            yield from self.deviations(sid, stash_data)\n\n\nclass DeviantartFavoriteExtractor(DeviantartExtractor):\n    \"\"\"Extractor for an artist's favorites\"\"\"\n    subcategory = \"favorite\"\n    directory_fmt = (\"{category}\", \"{username}\", \"Favourites\")\n    archive_fmt = \"f_{_username}_{index}.{extension}\"\n    pattern = BASE_PATTERN + r\"/favourites(?:/all|/?\\?catpath=)?/?$\"\n    example = \"https://www.deviantart.com/USER/favourites/\"\n\n    def deviations(self):\n        if self.flat:\n            return self.api.collections_all(self.user, self.offset)\n        folders = self.cache(self.api.collections_folders, self.user)\n        return self._folder_urls(\n            folders, \"favourites\", DeviantartCollectionExtractor)\n\n\nclass DeviantartCollectionExtractor(DeviantartExtractor):\n    \"\"\"Extractor for a single favorite collection\"\"\"\n    subcategory = \"collection\"\n    directory_fmt = (\"{category}\", \"{username}\", \"Favourites\",\n                     \"{collection[title]}\")\n    archive_fmt = \"C_{collection[uuid]}_{index}.{extension}\"\n    pattern = BASE_PATTERN + r\"/favourites/([^/?#]+)/([^/?#]+)\"\n    example = \"https://www.deviantart.com/USER/favourites/12345/TITLE\"\n\n    def __init__(self, match):\n        DeviantartExtractor.__init__(self, match)\n        self.collection = None\n        self.collection_id = match[3]\n        self.collection_name = match[4]\n\n    def deviations(self):\n        folders = self.cache(self.api.collections_folders, self.user)\n        folder = self._find_folder(\n            folders, self.collection_name, self.collection_id)\n        self.collection = {\n            \"title\": folder[\"name\"],\n            \"uuid\" : folder[\"folderid\"],\n            \"index\": self.collection_id,\n            \"owner\": self.user,\n        }\n        return self.api.collections(self.user, folder[\"folderid\"], self.offset)\n\n    def prepare(self, deviation):\n        DeviantartExtractor.prepare(self, deviation)\n        deviation[\"collection\"] = self.collection\n\n\nclass DeviantartJournalExtractor(DeviantartExtractor):\n    \"\"\"Extractor for an artist's journals\"\"\"\n    subcategory = \"journal\"\n    directory_fmt = (\"{category}\", \"{username}\", \"Journal\")\n    archive_fmt = \"j_{_username}_{index}.{extension}\"\n    pattern = BASE_PATTERN + r\"/(?:posts(?:/journals)?|journal)/?(?:\\?.*)?$\"\n    example = \"https://www.deviantart.com/USER/posts/journals/\"\n\n    def deviations(self):\n        return self.api.browse_user_journals(self.user, self.offset)\n\n\nclass DeviantartStatusExtractor(DeviantartExtractor):\n    \"\"\"Extractor for an artist's status updates\"\"\"\n    subcategory = \"status\"\n    directory_fmt = (\"{category}\", \"{username}\", \"Status\")\n    filename_fmt = \"{category}_{index}_{title}_{date}.{extension}\"\n    archive_fmt = \"S_{_username}_{index}.{extension}\"\n    pattern = BASE_PATTERN + r\"/posts/statuses\"\n    example = \"https://www.deviantart.com/USER/posts/statuses/\"\n\n    def deviations(self):\n        for status in self.api.user_statuses(self.user, self.offset):\n            yield from self.process_status(status)\n\n    def process_status(self, status):\n        for item in status.get(\"items\") or ():  # do not trust is_share\n            # shared deviations/statuses\n            if \"deviation\" in item:\n                yield item[\"deviation\"].copy()\n            if \"status\" in item:\n                yield from self.process_status(item[\"status\"].copy())\n        # assume is_deleted == true means necessary fields are missing\n        if status[\"is_deleted\"]:\n            self.log.warning(\n                \"Skipping status %s (deleted)\", status.get(\"statusid\"))\n            return\n        yield status\n\n    def prepare(self, deviation):\n        if \"deviationid\" in deviation:\n            return DeviantartExtractor.prepare(self, deviation)\n\n        try:\n            path = deviation[\"url\"].split(\"/\")\n            deviation[\"index\"] = text.parse_int(path[-1] or path[-2])\n        except KeyError:\n            deviation[\"index\"] = 0\n\n        if self.user:\n            deviation[\"username\"] = self.user\n            deviation[\"_username\"] = self.user.lower()\n        else:\n            deviation[\"username\"] = deviation[\"author\"][\"username\"]\n            deviation[\"_username\"] = deviation[\"username\"].lower()\n\n        deviation[\"date\"] = d = self.parse_datetime_iso(deviation[\"ts\"])\n        deviation[\"published_time\"] = int(dt.to_ts(d))\n\n        deviation[\"da_category\"] = \"Status\"\n        deviation[\"category_path\"] = \"status\"\n        deviation[\"is_downloadable\"] = False\n        deviation[\"title\"] = \"Status Update\"\n\n        comments_count = deviation.pop(\"comments_count\", 0)\n        deviation[\"stats\"] = {\"comments\": comments_count}\n        if self.comments:\n            deviation[\"comments\"] = (\n                self._extract_comments(deviation[\"statusid\"], \"status\")\n                if comments_count else ()\n            )\n\n\nclass DeviantartTagExtractor(DeviantartExtractor):\n    \"\"\"Extractor for deviations from tag searches\"\"\"\n    subcategory = \"tag\"\n    directory_fmt = (\"{category}\", \"Tags\", \"{search_tags}\")\n    archive_fmt = \"T_{search_tags}_{index}.{extension}\"\n    pattern = r\"(?:https?://)?www\\.deviantart\\.com/tag/([^/?#]+)\"\n    example = \"https://www.deviantart.com/tag/TAG\"\n\n    def __init__(self, match):\n        DeviantartExtractor.__init__(self, match)\n        self.tag = text.unquote(match[1])\n        self.user = \"\"\n\n    def deviations(self):\n        return self.api.browse_tags(self.tag, self.offset)\n\n    def prepare(self, deviation):\n        DeviantartExtractor.prepare(self, deviation)\n        deviation[\"search_tags\"] = self.tag\n\n\nclass DeviantartWatchExtractor(DeviantartExtractor):\n    \"\"\"Extractor for Deviations from watched users\"\"\"\n    subcategory = \"watch\"\n    pattern = (r\"(?:https?://)?(?:www\\.)?deviantart\\.com\"\n               r\"/(?:watch/deviations|notifications/watch)()()\")\n    example = \"https://www.deviantart.com/watch/deviations\"\n\n    def deviations(self):\n        return self.api.browse_deviantsyouwatch()\n\n\nclass DeviantartWatchPostsExtractor(DeviantartExtractor):\n    \"\"\"Extractor for Posts from watched users\"\"\"\n    subcategory = \"watch-posts\"\n    pattern = r\"(?:https?://)?(?:www\\.)?deviantart\\.com/watch/posts()()\"\n    example = \"https://www.deviantart.com/watch/posts\"\n\n    def deviations(self):\n        return self.api.browse_posts_deviantsyouwatch()\n\n\n###############################################################################\n# Eclipse #####################################################################\n\nclass DeviantartDeviationExtractor(DeviantartExtractor):\n    \"\"\"Extractor for single deviations\"\"\"\n    subcategory = \"deviation\"\n    archive_fmt = \"g_{_username}_{index}.{extension}\"\n    skip_files = None\n    pattern = (BASE_PATTERN + r\"/(art|journal)/(?:[^/?#]+-)?(\\d+)\"\n               r\"|(?:https?://)?(?:www\\.)?(?:fx)?deviantart\\.com/\"\n               r\"(?:view/|deviation/|view(?:-full)?\\.php/*\\?(?:[^#]+&)?id=)\"\n               r\"(\\d+)\"  # bare deviation ID without slug\n               r\"|(?:https?://)?fav\\.me/d([0-9a-z]+)\")  # base36\n    example = \"https://www.deviantart.com/UsER/art/TITLE-12345\"\n\n    def __init__(self, match):\n        DeviantartExtractor.__init__(self, match)\n        self.type = match[3]\n        self.deviation_id = \\\n            match[4] or match[5] or id_from_base36(match[6])\n\n    def deviations(self):\n        if self.user:\n            url = (f\"{self.root}/{self.user}\"\n                   f\"/{self.type or 'art'}/{self.deviation_id}\")\n        else:\n            url = f\"{self.root}/view/{self.deviation_id}/\"\n\n        page = self._limited_request(url, notfound=True).text\n        uuid = text.extr(page, '\"deviationUuid\\\\\":\\\\\"', '\\\\')\n        if not uuid:\n            raise self.exc.NotFoundError(\"deviation\")\n\n        deviation = self.api.deviation(uuid)\n        deviation[\"_page\"] = page\n        deviation[\"index_file\"] = 0\n        deviation[\"num\"] = deviation[\"count\"] = 1\n\n        additional_media = text.extr(page, ',\\\\\"additionalMedia\\\\\":', '}],\\\\\"')\n        if not additional_media:\n            yield deviation\n            return\n\n        self.filename_fmt = (\"{category}_{index}_{index_file}_{title}_\"\n                             \"{num:>02}.{extension}\")\n        self.archive_fmt = (\"g_{_username}_{index}{index_file:?_//}.\"\n                            \"{extension}\")\n\n        additional_media = util.json_loads(self._unescape_json(\n            additional_media) + \"}]\")\n        deviation[\"count\"] = 1 + len(additional_media)\n        yield deviation\n\n        for index, post in enumerate(additional_media):\n            uri = eclipse_media(post[\"media\"], \"fullview\")[0]\n            deviation[\"content\"][\"src\"] = uri\n            deviation[\"num\"] += 1\n            deviation[\"index_file\"] = post[\"fileId\"]\n            # Download only works on purchased materials - no way to check\n            deviation[\"is_downloadable\"] = False\n            yield deviation\n\n\nclass DeviantartScrapsExtractor(DeviantartExtractor):\n    \"\"\"Extractor for an artist's scraps\"\"\"\n    subcategory = \"scraps\"\n    directory_fmt = (\"{category}\", \"{username}\", \"Scraps\")\n    archive_fmt = \"s_{_username}_{index}.{extension}\"\n    pattern = BASE_PATTERN + r\"/gallery/(?:\\?catpath=)?scraps\\b\"\n    example = \"https://www.deviantart.com/USER/gallery/scraps\"\n\n    def deviations(self):\n        self.login()\n\n        eclipse_api = DeviantartEclipseAPI(self)\n        return self._eclipse_to_oauth(\n            eclipse_api, eclipse_api.gallery_scraps(self.user, self.offset))\n\n\nclass DeviantartSearchExtractor(DeviantartExtractor):\n    \"\"\"Extractor for deviantart search results\"\"\"\n    subcategory = \"search\"\n    directory_fmt = (\"{category}\", \"Search\", \"{search_tags}\")\n    archive_fmt = \"Q_{search_tags}_{index}.{extension}\"\n    skip_files = None\n    pattern = (r\"(?:https?://)?www\\.deviantart\\.com\"\n               r\"/search(?:/deviations)?/?\\?([^#]+)\")\n    example = \"https://www.deviantart.com/search?q=QUERY\"\n\n    def __init__(self, match):\n        DeviantartExtractor.__init__(self, match)\n        self.query = text.parse_query(self.user)\n        self.search = self.query.get(\"q\", \"\")\n        self.user = \"\"\n\n    def deviations(self):\n        logged_in = self.login()\n\n        eclipse_api = DeviantartEclipseAPI(self)\n        search = (eclipse_api.search_deviations\n                  if logged_in else self._search_html)\n        return self._eclipse_to_oauth(eclipse_api, search(self.query))\n\n    def prepare(self, deviation):\n        DeviantartExtractor.prepare(self, deviation)\n        deviation[\"search_tags\"] = self.search\n\n    def _search_html(self, params):\n        url = self.root + \"/search\"\n        find = text.re(r'''href=\"https://www.deviantart.com/([^/?#]+)'''\n                       r'''/(art|journal)/(?:[^\"]+-)?(\\d+)''').findall\n        while True:\n            response = self.request(url, params=params)\n\n            if response.history and \"/users/login\" in response.url:\n                raise self.exc.AbortExtraction(\"HTTP redirect to login page\")\n            page = response.text\n\n            for user, type, did in find(page)[:-3:3]:\n                yield {\n                    \"deviationId\": did,\n                    \"author\": {\"username\": user},\n                    \"isJournal\": type == \"journal\",\n                }\n\n            cursor = text.extr(page, r'\\\"cursor\\\":\\\"', '\\\\',)\n            if not cursor:\n                return\n            params[\"cursor\"] = cursor\n\n\nclass DeviantartGallerySearchExtractor(DeviantartExtractor):\n    \"\"\"Extractor for deviantart gallery searches\"\"\"\n    subcategory = \"gallery-search\"\n    archive_fmt = \"g_{_username}_{index}.{extension}\"\n    pattern = BASE_PATTERN + r\"/gallery/?\\?(q=[^#]+)\"\n    example = \"https://www.deviantart.com/USER/gallery?q=QUERY\"\n\n    def __init__(self, match):\n        DeviantartExtractor.__init__(self, match)\n        self.query = match[3]\n\n    def deviations(self):\n        self.login()\n\n        eclipse_api = DeviantartEclipseAPI(self)\n        query = text.parse_query(self.query)\n        self.search = query[\"q\"]\n\n        return self._eclipse_to_oauth(\n            eclipse_api, eclipse_api.galleries_search(\n                self.user,\n                self.search,\n                self.offset,\n                query.get(\"sort\", \"most-recent\"),\n            ))\n\n    def prepare(self, deviation):\n        DeviantartExtractor.prepare(self, deviation)\n        deviation[\"search_tags\"] = self.search\n\n\nclass DeviantartFollowingExtractor(DeviantartExtractor):\n    \"\"\"Extractor for user's watched users\"\"\"\n    subcategory = \"following\"\n    pattern = BASE_PATTERN + \"/(?:about#)?watching\"\n    example = \"https://www.deviantart.com/USER/about#watching\"\n\n    def items(self):\n        api = DeviantartOAuthAPI(self)\n\n        for user in api.user_friends(self.user):\n            url = f\"{self.root}/{user['user']['username']}\"\n            user[\"_extractor\"] = DeviantartUserExtractor\n            yield Message.Queue, url, user\n\n\n###############################################################################\n# API Interfaces ##############################################################\n\nclass DeviantartOAuthAPI():\n    \"\"\"Interface for the DeviantArt OAuth API\n\n    https://www.deviantart.com/developers/http/v1/20160316\n    \"\"\"\n    CLIENT_ID = \"5388\"\n    CLIENT_SECRET = \"76b08c69cfb27f26d6161f9ab6d061a1\"\n\n    def __init__(self, extractor):\n        self.extractor = extractor\n        self.log = extractor.log\n        self.exc = extractor.exc\n        self.headers = {\"dA-minor-version\": \"20210526\"}\n        self._warn_429 = True\n\n        self.delay = extractor.config(\"wait-min\", 0)\n        self.delay_min = max(2, self.delay)\n\n        self.mature = extractor.config(\"mature\", \"true\")\n        if not isinstance(self.mature, str):\n            self.mature = \"true\" if self.mature else \"false\"\n\n        self.strategy = extractor.config(\"pagination\")\n        self.folders = extractor.config(\"folders\", False)\n        self.public = extractor.config(\"public\", True)\n\n        if client_id := extractor.config(\"client-id\"):\n            self.client_id = str(client_id)\n            self.client_secret = extractor.config(\"client-secret\")\n        else:\n            self.client_id = self.CLIENT_ID\n            self.client_secret = self.CLIENT_SECRET\n\n        token = extractor.config(\"refresh-token\")\n        if token is None or token == \"cache\":\n            token = \"#\" + self.client_id\n            if not extractor.cache(_refresh_token_cache, token):\n                token = None\n        self.refresh_token_key = token\n\n        metadata = extractor.config(\"metadata\", False)\n        if not metadata:\n            metadata = True if extractor.extra else False\n        if metadata:\n            self.metadata = True\n\n            if isinstance(metadata, str):\n                if metadata == \"all\":\n                    metadata = (\"submission\", \"camera\", \"stats\",\n                                \"collection\", \"gallery\")\n                else:\n                    metadata = metadata.replace(\" \", \"\").split(\",\")\n            elif not isinstance(metadata, (list, tuple)):\n                metadata = ()\n\n            self._metadata_params = {\"mature_content\": self.mature}\n            self._metadata_public = None\n            if metadata:\n                # extended metadata\n                self.limit = 10\n                for param in metadata:\n                    self._metadata_params[\"ext_\" + param] = \"1\"\n                if \"ext_collection\" in self._metadata_params or \\\n                        \"ext_gallery\" in self._metadata_params:\n                    if token:\n                        self._metadata_public = False\n                    else:\n                        self.log.error(\"'collection' and 'gallery' metadata \"\n                                       \"require a refresh token\")\n            else:\n                # base metadata\n                self.limit = 50\n        else:\n            self.metadata = False\n            self.limit = None\n\n        self.log.debug(\n            \"Using %s API credentials (client-id %s)\",\n            \"default\" if self.client_id == self.CLIENT_ID else \"custom\",\n            self.client_id,\n        )\n\n    def browse_deviantsyouwatch(self, offset=0):\n        \"\"\"Yield deviations from users you watch\"\"\"\n        endpoint = \"/browse/deviantsyouwatch\"\n        params = {\"limit\": 50, \"offset\": offset,\n                  \"mature_content\": self.mature}\n        return self._pagination(endpoint, params, public=False)\n\n    def browse_posts_deviantsyouwatch(self, offset=0):\n        \"\"\"Yield posts from users you watch\"\"\"\n        endpoint = \"/browse/posts/deviantsyouwatch\"\n        params = {\"limit\": 50, \"offset\": offset,\n                  \"mature_content\": self.mature}\n        return self._pagination(endpoint, params, public=False, unpack=True)\n\n    def browse_tags(self, tag, offset=0):\n        \"\"\" Browse a tag \"\"\"\n        endpoint = \"/browse/tags\"\n        params = {\n            \"tag\"           : tag,\n            \"offset\"        : offset,\n            \"limit\"         : 50,\n            \"mature_content\": self.mature,\n        }\n        return self._pagination(endpoint, params)\n\n    def browse_user_journals(self, username, offset=0):\n        journals = filter(\n            lambda post: \"/journal/\" in post[\"url\"],\n            self.user_profile_posts(username))\n        if offset:\n            journals = util.advance(journals, offset)\n        return journals\n\n    def collections(self, username, folder_id, offset=0):\n        \"\"\"Yield all Deviation-objects contained in a collection folder\"\"\"\n        endpoint = \"/collections/\" + folder_id\n        params = {\"username\": username, \"offset\": offset, \"limit\": 24,\n                  \"mature_content\": self.mature}\n        return self._pagination(endpoint, params)\n\n    def collections_all(self, username, offset=0):\n        \"\"\"Yield all deviations in a user's collection\"\"\"\n        endpoint = \"/collections/all\"\n        params = {\"username\": username, \"offset\": offset, \"limit\": 24,\n                  \"mature_content\": self.mature}\n        return self._pagination(endpoint, params)\n\n    def collections_folders(self, username, offset=0):\n        \"\"\"Yield all collection folders of a specific user\"\"\"\n        endpoint = \"/collections/folders\"\n        params = {\"username\": username, \"offset\": offset, \"limit\": 50,\n                  \"mature_content\": self.mature}\n        return self._pagination_list(endpoint, params)\n\n    def comments(self, target_id, target_type=\"deviation\",\n                 comment_id=None, offset=0):\n        \"\"\"Fetch comments posted on a target\"\"\"\n        endpoint = f\"/comments/{target_type}/{target_id}\"\n        params = {\n            \"commentid\"     : comment_id,\n            \"maxdepth\"      : \"5\",\n            \"offset\"        : offset,\n            \"limit\"         : 50,\n            \"mature_content\": self.mature,\n        }\n        return self._pagination_list(endpoint, params=params, key=\"thread\")\n\n    def deviation(self, deviation_id, public=None):\n        \"\"\"Query and return info about a single Deviation\"\"\"\n        endpoint = \"/deviation/\" + deviation_id\n\n        deviation = self._call(endpoint, public=public)\n        if deviation.get(\"is_mature\") and public is None and \\\n                self.refresh_token_key:\n            deviation = self._call(endpoint, public=False)\n\n        if self.metadata:\n            self._metadata((deviation,))\n        if self.folders:\n            self._folders((deviation,))\n        return deviation\n\n    def deviation_content(self, deviation_id, public=None):\n        \"\"\"Get extended content of a single Deviation\"\"\"\n        endpoint = \"/deviation/content\"\n        params = {\"deviationid\": deviation_id}\n        content = self._call(endpoint, params=params, public=public)\n        if public and content[\"html\"].startswith(\n                '        <span class=\\\"username-with-symbol'):\n            if self.refresh_token_key:\n                content = self._call(endpoint, params=params, public=False)\n            else:\n                self.log.warning(\"Private Journal\")\n        return content\n\n    def deviation_download(self, deviation_id, public=None):\n        \"\"\"Get the original file download (if allowed)\"\"\"\n        endpoint = \"/deviation/download/\" + deviation_id\n        params = {\"mature_content\": self.mature}\n\n        try:\n            return self._call(\n                endpoint, params=params, public=public, log=False)\n        except Exception:\n            if not self.refresh_token_key:\n                raise\n            return self._call(endpoint, params=params, public=False)\n\n    def deviation_metadata(self, deviations):\n        \"\"\" Fetch deviation metadata for a set of deviations\"\"\"\n        endpoint = \"/deviation/metadata?\" + \"&\".join(\n            f\"deviationids[{num}]={deviation['deviationid']}\"\n            for num, deviation in enumerate(deviations)\n        )\n        return self._call(\n            endpoint,\n            params=self._metadata_params,\n            public=self._metadata_public,\n        )[\"metadata\"]\n\n    def gallery(self, username, folder_id, offset=0, extend=True, public=None):\n        \"\"\"Yield all Deviation-objects contained in a gallery folder\"\"\"\n        endpoint = \"/gallery/\" + folder_id\n        params = {\"username\": username, \"offset\": offset, \"limit\": 24,\n                  \"mature_content\": self.mature, \"mode\": \"newest\"}\n        return self._pagination(endpoint, params, extend, public)\n\n    def gallery_all(self, username, offset=0):\n        \"\"\"Yield all Deviation-objects of a specific user\"\"\"\n        endpoint = \"/gallery/all\"\n        params = {\"username\": username, \"offset\": offset, \"limit\": 24,\n                  \"mature_content\": self.mature}\n        return self._pagination(endpoint, params)\n\n    def gallery_folders(self, username, offset=0):\n        \"\"\"Yield all gallery folders of a specific user\"\"\"\n        endpoint = \"/gallery/folders\"\n        params = {\"username\": username, \"offset\": offset, \"limit\": 50,\n                  \"mature_content\": self.mature}\n        return self._pagination_list(endpoint, params)\n\n    def user_friends(self, username, offset=0):\n        \"\"\"Get the users list of friends\"\"\"\n        endpoint = \"/user/friends/\" + username\n        params = {\"limit\": 50, \"offset\": offset, \"mature_content\": self.mature}\n        return self._pagination(endpoint, params)\n\n    def user_friends_watch(self, username):\n        \"\"\"Watch a user\"\"\"\n        endpoint = \"/user/friends/watch/\" + username\n        data = {\n            \"watch[friend]\"       : \"0\",\n            \"watch[deviations]\"   : \"0\",\n            \"watch[journals]\"     : \"0\",\n            \"watch[forum_threads]\": \"0\",\n            \"watch[critiques]\"    : \"0\",\n            \"watch[scraps]\"       : \"0\",\n            \"watch[activity]\"     : \"0\",\n            \"watch[collections]\"  : \"0\",\n            \"mature_content\"      : self.mature,\n        }\n        return self._call(\n            endpoint, method=\"POST\", data=data, public=False, fatal=False,\n        ).get(\"success\")\n\n    def user_friends_unwatch(self, username):\n        \"\"\"Unwatch a user\"\"\"\n        endpoint = \"/user/friends/unwatch/\" + username\n        return self._call(\n            endpoint, method=\"POST\", public=False, fatal=False,\n        ).get(\"success\")\n\n    def user_profile(self, username):\n        \"\"\"Get user profile information\"\"\"\n        endpoint = \"/user/profile/\" + username\n        return self._call(endpoint, fatal=False)\n\n    def user_profile_posts(self, username):\n        endpoint = \"/user/profile/posts\"\n        params = {\"username\": username, \"limit\": 50,\n                  \"mature_content\": self.mature}\n        return self._pagination(endpoint, params)\n\n    def user_statuses(self, username, offset=0):\n        \"\"\"Yield status updates of a specific user\"\"\"\n        statuses = filter(\n            lambda post: \"/status-update/\" in post[\"url\"],\n            self.user_profile_posts(username))\n        if offset:\n            statuses = util.advance(statuses, offset)\n        return statuses\n\n    def authenticate(self, refresh_token_key):\n        \"\"\"Authenticate the application by requesting an access token\"\"\"\n        self.headers[\"Authorization\"] = self.extractor.cache(\n            self._authenticate_impl, refresh_token_key, _exp=3600, _mem=False)\n\n    def _authenticate_impl(self, refresh_token_key):\n        \"\"\"Actual authenticate implementation\"\"\"\n        url = \"https://www.deviantart.com/oauth2/token\"\n        if refresh_token_key:\n            self.log.info(\"Refreshing private access token\")\n            token = self.extractor.cache(\n                _refresh_token_cache, refresh_token_key, _mem=False)\n            data = {\"grant_type\": \"refresh_token\",\n                    \"refresh_token\": token}\n        else:\n            self.log.info(\"Requesting public access token\")\n            data = {\"grant_type\": \"client_credentials\"}\n\n        auth = util.HTTPBasicAuth(self.client_id, self.client_secret)\n        response = self.extractor.request(\n            url, method=\"POST\", data=data, auth=auth, fatal=False)\n        data = response.json()\n\n        if response.status_code != 200:\n            self.log.debug(\"Server response: %s\", data)\n            raise self.exc.AuthenticationError(\n                f\"\\\"{data.get('error_description')}\\\" ({data.get('error')})\")\n        if refresh_token_key:\n            self.extractor.cache_update(\n                _refresh_token_cache, refresh_token_key, data[\"refresh_token\"])\n        return \"Bearer \" + data[\"access_token\"]\n\n    def _call(self, endpoint, fatal=True, log=True, public=None, **kwargs):\n        \"\"\"Call an API endpoint\"\"\"\n        url = \"https://www.deviantart.com/api/v1/oauth2\" + endpoint\n        kwargs[\"fatal\"] = None\n\n        if public is None:\n            public = self.public\n\n        while True:\n            if self.delay:\n                self.extractor.sleep(self.delay, \"api\")\n\n            self.authenticate(None if public else self.refresh_token_key)\n            kwargs[\"headers\"] = self.headers\n            response = self.extractor.request(url, **kwargs)\n\n            try:\n                data = response.json()\n            except ValueError:\n                self.log.error(\"Unable to parse API response\")\n                data = {}\n\n            status = response.status_code\n            if 200 <= status < 400:\n                if self.delay > self.delay_min:\n                    self.delay -= 1\n                return data\n            if not fatal and status != 429:\n                return None\n\n            error = data.get(\"error_description\")\n            if error == \"User not found.\":\n                raise self.exc.NotFoundError(\"user or group\")\n            if error in {\"Deviation not downloadable.\",\n                         \"Only subscribers may have access to this download.\"}:\n                raise self.exc.AuthorizationError()\n\n            self.log.debug(response.text)\n            msg = f\"API responded with {status} {response.reason}\"\n            if status == 429:\n                if self.delay < 30:\n                    self.delay += 1\n                self.log.warning(\"%s. Using %ds delay.\", msg, self.delay)\n\n                if self._warn_429 and self.delay >= 3:\n                    self._warn_429 = False\n                    if self.client_id == self.CLIENT_ID:\n                        self.log.info(\n                            \"Register your own OAuth application and use its \"\n                            \"credentials to prevent this error: \"\n                            \"https://gdl-org.github.io/docs/configuration.html\"\n                            \"#extractor-deviantart-client-id-client-secret\")\n            else:\n                if log:\n                    self.log.error(msg)\n                return data\n\n    def _should_switch_tokens(self, results, params):\n        if len(results) < params[\"limit\"]:\n            return True\n\n        if not self.extractor.jwt:\n            for item in results:\n                if item.get(\"is_mature\"):\n                    return True\n\n        return False\n\n    def _pagination(self, endpoint, params,\n                    extend=True, public=None, unpack=False, key=\"results\"):\n        warn = True\n        if public is None:\n            public = self.public\n\n        if self.limit and params[\"limit\"] > self.limit:\n            params[\"limit\"] = (params[\"limit\"] // self.limit) * self.limit\n\n        while True:\n            data = self._call(endpoint, params=params, public=public)\n            try:\n                results = data[key]\n            except KeyError:\n                self.log.error(\"Unexpected API response: %s\", data)\n                return\n\n            if unpack:\n                results = [item[\"journal\"] for item in results\n                           if \"journal\" in item]\n            if extend:\n                if public and self._should_switch_tokens(results, params):\n                    if self.refresh_token_key:\n                        self.log.debug(\"Switching to private access token\")\n                        public = False\n                        continue\n                    elif data[\"has_more\"] and warn:\n                        warn = False\n                        self.log.warning(\n                            \"Private or mature deviations detected! \"\n                            \"Run 'gallery-dl oauth:deviantart' and follow the \"\n                            \"instructions to be able to access them.\")\n\n                # \"statusid\" cannot be used instead\n                if results and \"deviationid\" in results[0]:\n                    if self.metadata:\n                        self._metadata(results)\n                    if self.folders:\n                        self._folders(results)\n                else:  # attempt to fix \"deleted\" deviations\n                    for dev in self._shared_content(results):\n                        if not dev[\"is_deleted\"]:\n                            continue\n                        patch = self._call(\n                            \"/deviation/\" + dev[\"deviationid\"], fatal=False)\n                        if patch:\n                            dev.update(patch)\n\n            yield from results\n\n            if not data[\"has_more\"] and (\n                    self.strategy != \"manual\" or not results or not extend):\n                return\n\n            if \"next_cursor\" in data:\n                if not data[\"next_cursor\"]:\n                    return\n                params[\"offset\"] = None\n                params[\"cursor\"] = data[\"next_cursor\"]\n            elif data[\"next_offset\"] is not None:\n                params[\"offset\"] = data[\"next_offset\"]\n                params[\"cursor\"] = None\n            else:\n                if params.get(\"offset\") is None:\n                    return\n                params[\"offset\"] = int(params[\"offset\"]) + len(results)\n\n    def _pagination_list(self, endpoint, params, key=\"results\"):\n        return list(self._pagination(endpoint, params, False, key=key))\n\n    def _shared_content(self, results):\n        \"\"\"Return an iterable of shared deviations in 'results'\"\"\"\n        for result in results:\n            for item in result.get(\"items\") or ():\n                if \"deviation\" in item:\n                    yield item[\"deviation\"]\n\n    def _metadata(self, deviations):\n        \"\"\"Add extended metadata to each deviation object\"\"\"\n        if len(deviations) <= self.limit:\n            self._metadata_batch(deviations)\n        else:\n            n = self.limit\n            for index in range(0, len(deviations), n):\n                self._metadata_batch(deviations[index:index+n])\n\n    def _metadata_batch(self, deviations):\n        \"\"\"Fetch extended metadata for a single batch of deviations\"\"\"\n        for deviation, metadata in zip(\n                deviations, self.deviation_metadata(deviations)):\n            deviation.update(metadata)\n            deviation[\"tags\"] = [t[\"tag_name\"] for t in deviation[\"tags\"]]\n\n    def _folders(self, deviations):\n        \"\"\"Add a list of all containing folders to each deviation object\"\"\"\n        for deviation in deviations:\n            username = deviation[\"author\"][\"username\"]\n            deviation[\"folders\"] = self.extractor.cache(\n                self._folders_map, username)[deviation[\"deviationid\"]]\n\n    def _folders_map(self, username):\n        \"\"\"Generate a deviation_id -> folders mapping for 'username'\"\"\"\n        self.log.info(\"Collecting folder information for '%s'\", username)\n        folders = self.extractor.cache(self.gallery_folders, username)\n\n        # create 'folderid'-to-'folder' mapping\n        fmap = {\n            folder[\"folderid\"]: folder\n            for folder in folders\n        }\n\n        # add parent names to folders, but ignore \"Featured\" as parent\n        featured = folders[0][\"folderid\"]\n        done = False\n\n        while not done:\n            done = True\n            for folder in folders:\n                parent = folder[\"parent\"]\n                if not parent:\n                    pass\n                elif parent == featured:\n                    folder[\"parent\"] = None\n                else:\n                    parent = fmap[parent]\n                    if parent[\"parent\"]:\n                        done = False\n                    else:\n                        folder[\"name\"] = parent[\"name\"] + \"/\" + folder[\"name\"]\n                        folder[\"parent\"] = None\n\n        # map deviationids to folder names\n        dmap = collections.defaultdict(list)\n        for folder in folders:\n            for deviation in self.gallery(\n                    username, folder[\"folderid\"], 0, False):\n                dmap[deviation[\"deviationid\"]].append(folder[\"name\"])\n        return dmap\n\n\nclass DeviantartEclipseAPI():\n    \"\"\"Interface to the DeviantArt Eclipse API\"\"\"\n\n    def __init__(self, extractor):\n        self.extractor = extractor\n        self.log = extractor.log\n        self.request = self.extractor._limited_request\n        self.csrf_token = None\n\n    def deviation_extended_fetch(self, deviation_id, user, kind=None):\n        endpoint = \"/_puppy/dadeviation/init\"\n        params = {\n            \"deviationid\"     : deviation_id,\n            \"username\"        : user,\n            \"type\"            : kind,\n            \"include_session\" : \"false\",\n            \"expand\"          : \"deviation.related\",\n            \"da_minor_version\": \"20230710\",\n        }\n        return self._call(endpoint, params)\n\n    def gallery_scraps(self, user, offset=0):\n        endpoint = \"/_puppy/dashared/gallection/contents\"\n        params = {\n            \"username\"     : user,\n            \"type\"         : \"gallery\",\n            \"offset\"       : offset,\n            \"limit\"        : 24,\n            \"scraps_folder\": \"true\",\n        }\n        return self._pagination(endpoint, params)\n\n    def galleries_search(self, user, query, offset=0, order=\"most-recent\"):\n        endpoint = \"/_puppy/dashared/gallection/search\"\n        params = {\n            \"username\": user,\n            \"type\"    : \"gallery\",\n            \"order\"   : order,\n            \"q\"       : query,\n            \"offset\"  : offset,\n            \"limit\"   : 24,\n        }\n        return self._pagination(endpoint, params)\n\n    def search_deviations(self, params):\n        endpoint = \"/_puppy/dabrowse/search/deviations\"\n        return self._pagination(endpoint, params, key=\"deviations\")\n\n    def user_info(self, user, expand=False):\n        endpoint = \"/_puppy/dauserprofile/init/about\"\n        params = {\"username\": user}\n        return self._call(endpoint, params)\n\n    def user_watching(self, user, offset=0):\n        gruserid, moduleid = self._ids_watching(user)\n\n        endpoint = \"/_puppy/gruser/module/watching\"\n        params = {\n            \"gruserid\"     : gruserid,\n            \"gruser_typeid\": \"4\",\n            \"username\"     : user,\n            \"moduleid\"     : moduleid,\n            \"offset\"       : offset,\n            \"limit\"        : 24,\n        }\n        return self._pagination(endpoint, params)\n\n    def _call(self, endpoint, params):\n        url = \"https://www.deviantart.com\" + endpoint\n        params[\"csrf_token\"] = self.csrf_token or self._fetch_csrf_token()\n\n        response = self.request(url, params=params, fatal=None)\n\n        try:\n            return response.json()\n        except Exception:\n            return {\"error\": response.text}\n\n    def _pagination(self, endpoint, params, key=\"results\"):\n        limit = params.get(\"limit\", 24)\n        warn = True\n\n        while True:\n            data = self._call(endpoint, params)\n\n            results = data.get(key)\n            if results is None:\n                return\n            if len(results) < limit and warn and data.get(\"hasMore\"):\n                warn = False\n                self.log.warning(\n                    \"Private deviations detected! \"\n                    \"Provide login credentials or session cookies \"\n                    \"to be able to access them.\")\n            yield from results\n\n            if not data.get(\"hasMore\"):\n                return\n\n            if \"nextCursor\" in data:\n                params[\"offset\"] = None\n                params[\"cursor\"] = data[\"nextCursor\"]\n            elif \"nextOffset\" in data:\n                params[\"offset\"] = data[\"nextOffset\"]\n                params[\"cursor\"] = None\n            elif params.get(\"offset\") is None:\n                return\n            else:\n                params[\"offset\"] = int(params[\"offset\"]) + len(results)\n\n    def _ids_watching(self, user):\n        url = f\"{self.extractor.root}/{user}/about\"\n        page = self.request(url).text\n\n        gruser_id = text.extr(page, ' data-userid=\"', '\"')\n\n        pos = page.find('\\\\\"name\\\\\":\\\\\"watching\\\\\"')\n        if pos < 0:\n            raise self.extractor.exc.NotFoundError(\"'watching' module ID\")\n        module_id = text.rextr(page, '\\\\\"id\\\\\":', ',', pos).strip('\" ')\n\n        self._fetch_csrf_token(page)\n        return gruser_id, module_id\n\n    def _fetch_csrf_token(self, page=None):\n        if page is None:\n            page = self.request(self.extractor.root + \"/\").text\n        self.csrf_token = token = text.extr(\n            page, \"window.__CSRF_TOKEN__ = '\", \"'\")\n        return token\n\n\n_ALPHABET = \"0123456789abcdefghijklmnopqrstuvwxyz\"\n\n\ndef id_from_base36(base36):\n    return util.bdecode(base36, _ALPHABET)\n\n\ndef base36_from_id(deviation_id):\n    return util.bencode(int(deviation_id), _ALPHABET)\n\n\ndef eclipse_media(media, format=\"preview\"):\n    url = [media[\"baseUri\"]]\n\n    formats = {\n        fmt[\"t\"]: fmt\n        for fmt in media[\"types\"]\n    }\n\n    if tokens := media.get(\"token\") or ():\n        if len(tokens) <= 1:\n            fmt = formats[format]\n            if \"c\" in fmt:\n                url.append(fmt[\"c\"].replace(\n                    \"<prettyName>\", media[\"prettyName\"]))\n        url.append(\"?token=\")\n        url.append(tokens[-1])\n\n    return \"\".join(url), formats\n\n\ndef _refresh_token_cache(token):\n    if token and token[0] == \"#\":\n        return None\n    return token\n"
  },
  {
    "path": "gallery_dl/extractor/directlink.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2017-2025 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Direct link handling\"\"\"\n\nfrom .common import Extractor, Message\nfrom .. import text\n\n\nclass DirectlinkExtractor(Extractor):\n    \"\"\"Extractor for direct links to images and other media files\"\"\"\n    category = \"directlink\"\n    filename_fmt = \"{domain}/{path}/{filename}.{extension}\"\n    archive_fmt = filename_fmt\n    pattern = (r\"(?i)https?://(?P<domain>[^/?#]+)/(?P<path>[^?#]+\\.\"\n               r\"(?:jpe?g|jpe|png|gif|bmp|svg|web[mp]|avif|heic|psd\"\n               r\"|mp4|m4v|mov|mkv|og[gmv]|wav|mp3|opus|zip|rar|7z|pdf|swf))\"\n               r\"(?:\\?(?P<query>[^#]*))?(?:#(?P<fragment>.*))?$\")\n    example = \"https://en.wikipedia.org/static/images/project-logos/enwiki.png\"\n\n    def __init__(self, match):\n        self.data = data = match.groupdict()\n        self.subcategory = \".\".join(data[\"domain\"].rsplit(\".\", 2)[-2:])\n        Extractor.__init__(self, match)\n\n    def items(self):\n        data = self.data\n        for key, value in data.items():\n            if value:\n                data[key] = text.unquote(value)\n\n        data[\"path\"], _, name = data[\"path\"].rpartition(\"/\")\n        data[\"filename\"], _, ext = name.rpartition(\".\")\n        data[\"extension\"] = ext.lower()\n        data[\"_http_headers\"] = {\n            \"Referer\": self.url.encode(\"latin-1\", \"ignore\")}\n\n        yield Message.Directory, \"\", data\n        yield Message.Url, self.url, data\n"
  },
  {
    "path": "gallery_dl/extractor/discord.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://discord.com/\"\"\"\n\nfrom .common import Extractor, Message\nfrom .. import text, dt\n\nBASE_PATTERN = r\"(?:https?://)?discord\\.com\"\n\n\nclass DiscordExtractor(Extractor):\n    \"\"\"Base class for Discord extractors\"\"\"\n    category = \"discord\"\n    root = \"https://discord.com\"\n    directory_fmt = (\"{category}\", \"{server_id}_{server}\",\n                     \"{channel_id}_{channel}\")\n    filename_fmt = \"{message_id}_{num:>02}_{filename[:220]}.{extension}\"\n    archive_fmt = \"{message_id}_{num}\"\n\n    server_metadata = {}\n    server_channels_metadata = {}\n\n    def _init(self):\n        self.token = self.config(\"token\")\n        self.enabled_embeds = self.config(\"embeds\", [\"image\", \"gifv\", \"video\"])\n        self.enabled_threads = self.config(\"threads\", True)\n        self.api = DiscordAPI(self)\n        self.max_id = None\n\n    def extract_message_text(self, message):\n        text_content = [message[\"content\"]]\n\n        for embed in message[\"embeds\"]:\n            if embed[\"type\"] == \"rich\":\n                try:\n                    text_content.append(embed[\"author\"][\"name\"])\n                except Exception:\n                    pass\n                text_content.append(embed.get(\"title\", \"\"))\n                text_content.append(embed.get(\"description\", \"\"))\n\n                for field in embed.get(\"fields\", []):\n                    text_content.append(field.get(\"name\", \"\"))\n                    text_content.append(field.get(\"value\", \"\"))\n\n                try:\n                    text_content.append(embed[\"footer\"][\"text\"])\n                except Exception:\n                    pass\n\n        if message.get(\"poll\"):\n            text_content.append(message[\"poll\"][\"question\"][\"text\"])\n            for answer in message[\"poll\"][\"answers\"]:\n                text_content.append(answer[\"poll_media\"][\"text\"])\n\n        return \"\\n\".join(t for t in text_content if t)\n\n    def extract_message(self, message):\n        # https://discord.com/developers/docs/resources/message#message-object-message-types\n        if message[\"type\"] in {0, 19, 21}:\n            message_metadata = {}\n            message_metadata.update(self.server_metadata)\n            message_metadata.update(\n                self.server_channels_metadata[message[\"channel_id\"]])\n            message_metadata.update({\n                \"author\": message[\"author\"][\"username\"],\n                \"author_id\": message[\"author\"][\"id\"],\n                \"author_files\": [],\n                \"message\": self.extract_message_text(message),\n                \"message_id\": message[\"id\"],\n                \"date\": self.parse_datetime_iso(message[\"timestamp\"]),\n                \"files\": []\n            })\n\n            for icon_type, icon_path in (\n                (\"avatar\", \"avatars\"),\n                (\"banner\", \"banners\")\n            ):\n                if message[\"author\"].get(icon_type):\n                    message_metadata[\"author_files\"].append({\n                        \"url\": (f\"https://cdn.discordapp.com/{icon_path}/\"\n                                f\"{message_metadata['author_id']}/\"\n                                f\"{message['author'][icon_type]}.png\"\n                                f\"?size=4096\"),\n                        \"filename\": icon_type,\n                        \"extension\": \"png\",\n                    })\n\n            message_snapshots = [message]\n            message_snapshots.extend(\n                msg[\"message\"] for msg in message.get(\"message_snapshots\", [])\n                if msg[\"message\"][\"type\"] in {0, 19, 21}\n            )\n\n            for snapshot in message_snapshots:\n                for attachment in snapshot[\"attachments\"]:\n                    message_metadata[\"files\"].append({\n                        \"url\": attachment[\"url\"],\n                        \"type\": \"attachment\",\n                    })\n\n                for embed in snapshot[\"embeds\"]:\n                    if embed[\"type\"] in self.enabled_embeds:\n                        for field in (\"video\", \"image\", \"thumbnail\"):\n                            if field not in embed:\n                                continue\n                            url = embed[field].get(\"proxy_url\")\n                            if url is not None:\n                                message_metadata[\"files\"].append({\n                                    \"url\": url,\n                                    \"type\": \"embed\",\n                                })\n                                break\n\n                for num, file in enumerate(message_metadata[\"files\"], start=1):\n                    text.nameext_from_url(file[\"url\"], file)\n                    file[\"num\"] = num\n\n                yield Message.Directory, \"\", message_metadata\n\n                for file in message_metadata[\"files\"]:\n                    message_metadata_file = message_metadata.copy()\n                    message_metadata_file.update(file)\n                    yield Message.Url, file[\"url\"], message_metadata_file\n\n    def extract_search(self, server_id, params):\n        for messages in self.api.get_search_messages(server_id, params):\n            for message in messages:\n                if message[\"channel_id\"] not in self.server_channels_metadata:\n                    self.parse_channel(self.api.get_channel(\n                        message[\"channel_id\"]))\n                yield from self.extract_message(message)\n\n    def extract_channel_text(self, channel_id):\n        for message in self.api.get_channel_messages(channel_id):\n            yield from self.extract_message(message)\n\n    def extract_channel_threads(self, channel_id):\n        for thread in self.api.get_channel_threads(channel_id):\n            id = self.parse_channel(thread)[\"channel_id\"]\n            yield from self.extract_channel_text(id)\n\n    def extract_channel(self, channel_id, safe=False):\n        try:\n            if channel_id not in self.server_channels_metadata:\n                self.parse_channel(self.api.get_channel(channel_id))\n\n            channel_type = (\n                self.server_channels_metadata[channel_id][\"channel_type\"]\n            )\n\n            # https://discord.com/developers/docs/resources/channel#channel-object-channel-types\n            if channel_type in {0, 5}:\n                yield from self.extract_channel_text(channel_id)\n                if self.enabled_threads:\n                    yield from self.extract_channel_threads(channel_id)\n            elif channel_type in {1, 3, 10, 11, 12}:\n                yield from self.extract_channel_text(channel_id)\n            elif channel_type in {15, 16}:\n                yield from self.extract_channel_threads(channel_id)\n            elif channel_type == 4:\n                for channel in self.server_channels_metadata.copy().values():\n                    if channel[\"parent_id\"] == channel_id:\n                        yield from self.extract_channel(\n                            channel[\"channel_id\"], safe=True)\n            elif not safe:\n                raise self.exc.AbortExtraction(\n                    \"This channel type is not supported.\"\n                )\n        except self.exc.HttpError as exc:\n            if not (exc.status == 403 and safe):\n                raise\n\n    def parse_channel(self, channel):\n        parent_id = channel.get(\"parent_id\")\n        channel_metadata = {\n            \"channel\": channel.get(\"name\", \"\"),\n            \"channel_id\": channel.get(\"id\"),\n            \"channel_type\": channel.get(\"type\"),\n            \"channel_topic\": channel.get(\"topic\", \"\"),\n            \"parent_id\": parent_id,\n            \"is_thread\": \"thread_metadata\" in channel\n        }\n\n        if parent_id in self.server_channels_metadata:\n            parent_metadata = self.server_channels_metadata[parent_id]\n            channel_metadata.update({\n                \"parent\": parent_metadata[\"channel\"],\n                \"parent_type\": parent_metadata[\"channel_type\"]\n            })\n\n        if channel_metadata[\"channel_type\"] in {1, 3}:\n            channel_metadata.update({\n                \"channel\": \"DMs\",\n                \"recipients\": (\n                    [user[\"username\"] for user in channel[\"recipients\"]]\n                ),\n                \"recipients_id\": (\n                    [user[\"id\"] for user in channel[\"recipients\"]]\n                )\n            })\n\n        channel_id = channel_metadata[\"channel_id\"]\n\n        self.server_channels_metadata[channel_id] = channel_metadata\n        return channel_metadata\n\n    def parse_server(self, server):\n        self.server_metadata = {\n            \"server\"   : server[\"name\"],\n            \"server_id\": server[\"id\"],\n            \"owner_id\" : server[\"owner_id\"],\n            \"server_files\": self.collect_server_assets(server),\n        }\n\n        return self.server_metadata\n\n    def collect_server_assets(self, server, asset_type=None):\n        if asset_type and asset_type != \"general\":\n            return [\n                {\n                    **asset,\n                    \"url\": (f\"https://cdn.discordapp.com/{asset_type}/\"\n                            f\"{asset['id']}.png?size=4096\"),\n                    \"label\"    : asset_type,\n                    \"filename\" : f\"{asset['name']} ({asset['id']})\",\n                    \"extension\": \"png\",\n                }\n                for asset in assets\n            ] if (assets := server.get(asset_type)) else ()\n        else:\n            return [\n                {\n                    \"url\": (f\"https://cdn.discordapp.com/{asset_path}/\"\n                            f\"{server['id']}/{asset_id}.png?size=4096\"),\n                    \"id\"       : f\"{server['id']}/{asset_id}\",\n                    \"label\"    : \"\",\n                    \"name\"     : asset_type,\n                    \"filename\" : asset_type,\n                    \"extension\": \"png\",\n                }\n                for asset_type, asset_path in (\n                    (\"icon\"  , \"icons\"),\n                    (\"banner\", \"banners\"),\n                    (\"splash\", \"splashes\"),\n                    (\"discovery_splash\", \"discovery-splashes\")\n                )\n                if (asset_id := server.get(asset_type))\n            ]\n\n    def build_server_and_channels(self, server_id):\n        self.parse_server(self.api.get_server(server_id))\n\n        for channel in sorted(\n            self.api.get_server_channels(server_id),\n            key=lambda ch: ch[\"type\"] != 4\n        ):\n            self.parse_channel(channel)\n\n\nclass DiscordChannelExtractor(DiscordExtractor):\n    subcategory = \"channel\"\n    pattern = BASE_PATTERN + r\"/channels/(\\d+)/(?:\\d+/threads/)?(\\d+)/?$\"\n    example = \"https://discord.com/channels/1234567890/9876543210\"\n\n    def items(self):\n        server_id, channel_id = self.groups\n\n        self.build_server_and_channels(server_id)\n\n        return self.extract_channel(channel_id)\n\n\nclass DiscordMessageExtractor(DiscordExtractor):\n    subcategory = \"message\"\n    pattern = BASE_PATTERN + r\"/channels/(\\d+)/(\\d+)/(\\d+)/?$\"\n    example = \"https://discord.com/channels/1234567890/9876543210/2468013579\"\n\n    def items(self):\n        server_id, channel_id, message_id = self.groups\n\n        self.build_server_and_channels(server_id)\n\n        if channel_id not in self.server_channels_metadata:\n            self.parse_channel(self.api.get_channel(channel_id))\n\n        return self.extract_message(\n            self.api.get_message(channel_id, message_id))\n\n\nclass DiscordServerAssetsExtractor(DiscordExtractor):\n    subcategory = \"server-assets\"\n    filename_fmt = \"{name} ({id}).{extension}\"\n    directory_fmt = [\"{category}\", \"{server_id}_{server}\", \"Assets\", \"{label}\"]\n    archive_fmt = \"asset_{server_id}_{id}\"\n    pattern = (BASE_PATTERN +\n               r\"/channels/(\\d+)/(?:assets?|files)(?:/([\\w-]+))?/?$\")\n    example = \"https://discord.com/channels/1234567890/assets\"\n\n    def items(self):\n        server_id, asset_type = self.groups\n        server = self.api.get_server(server_id)\n        parsed = self.parse_server(server)\n\n        if asset_type is None:\n            asset_types = (\"\", \"emojis\", \"stickers\")\n        else:\n            asset_types = asset_type.split(\",\")\n\n        for asset_type in asset_types:\n            assets = self.collect_server_assets(server, asset_type)\n            parsed[\"count\"] = len(assets)\n            parsed[\"label\"] = asset_type\n            yield Message.Directory, \"\", parsed\n            for asset in assets:\n                asset.update(parsed)\n                yield Message.Url, asset[\"url\"], asset\n\n\nclass DiscordServerSearchExtractor(DiscordExtractor):\n    subcategory = \"server-search\"\n    pattern = BASE_PATTERN + r\"/channels/(\\d+)/search/?\\?([^#]+)\"\n    example = \"https://discord.com/channels/1234567890/search?QUERY\"\n\n    def items(self):\n        server_id, query = self.groups\n        server = self.api.get_server(server_id)\n        self.kwdict.update(self.parse_server(server))\n\n        params = {\n            **text.parse_query_list(query, {\n                \"from\", \"in\", \"has\", \"mentions\", \"author_id\", \"channel_id\"}),\n            \"sort_by\"   : \"timestamp\",\n            \"sort_order\": \"desc\",\n        }\n        if \"from\" in params:\n            params[\"author_id\"] = params.pop(\"from\")\n        if \"in\" in params:\n            params[\"channel_id\"] = params.pop(\"in\")\n        if self.max_id is not None:\n            params[\"max_id\"] = self.max_id\n\n        return self.extract_search(server_id, params)\n\n    def skip_date(self, date):\n        # https://docs.discord.com/developers/reference#snowflakes\n        self.max_id = ((int(dt.to_ts(date)) - 1_420_070_400) * 1000) << 22\n\n\nclass DiscordServerExtractor(DiscordExtractor):\n    subcategory = \"server\"\n    pattern = BASE_PATTERN + r\"/channels/(\\d+)/?$\"\n    example = \"https://discord.com/channels/1234567890\"\n\n    def items(self):\n        server_id = self.groups[0]\n\n        self.build_server_and_channels(server_id)\n\n        for channel in self.server_channels_metadata.copy().values():\n            if channel[\"channel_type\"] in {0, 5, 15, 16}:\n                yield from self.extract_channel(\n                    channel[\"channel_id\"], safe=True)\n\n\nclass DiscordDirectMessagesExtractor(DiscordExtractor):\n    subcategory = \"direct-messages\"\n    directory_fmt = (\"{category}\", \"Direct Messages\",\n                     \"{channel_id}_{recipients:J,}\")\n    pattern = BASE_PATTERN + r\"/channels/@me/(\\d+)/?$\"\n    example = \"https://discord.com/channels/@me/1234567890\"\n\n    def items(self):\n        return self.extract_channel(self.groups[0])\n\n\nclass DiscordDirectMessageExtractor(DiscordExtractor):\n    subcategory = \"direct-message\"\n    directory_fmt = (\"{category}\", \"Direct Messages\",\n                     \"{channel_id}_{recipients:J,}\")\n    pattern = BASE_PATTERN + r\"/channels/@me/(\\d+)/(\\d+)/?$\"\n    example = \"https://discord.com/channels/@me/1234567890/9876543210\"\n\n    def items(self):\n        channel_id, message_id = self.groups\n\n        self.parse_channel(self.api.get_channel(channel_id))\n\n        return self.extract_message(\n            self.api.get_message(channel_id, message_id))\n\n\nclass DiscordAPI():\n    \"\"\"Interface for the Discord API v10\n\n    https://discord.com/developers/docs/reference\n    \"\"\"\n\n    def __init__(self, extractor):\n        self.extractor = extractor\n        self.root = extractor.root + \"/api/v10\"\n        self.headers = {\"Authorization\": extractor.token}\n\n    def get_server(self, server_id):\n        \"\"\"Get server information\"\"\"\n        return self._call(\"/guilds/\" + server_id)\n\n    def get_server_channels(self, server_id):\n        \"\"\"Get server channels\"\"\"\n        return self._call(\"/guilds/\" + server_id + \"/channels\")\n\n    def get_channel(self, channel_id):\n        \"\"\"Get channel information\"\"\"\n        return self._call(\"/channels/\" + channel_id)\n\n    def get_channel_threads(self, channel_id):\n        \"\"\"Get channel threads\"\"\"\n        THREADS_BATCH = 25\n\n        def _method(offset):\n            return self._call(\"/channels/\" + channel_id + \"/threads/search\", {\n                \"sort_by\": \"last_message_time\",\n                \"sort_order\": \"desc\",\n                \"limit\": THREADS_BATCH,\n                \"offset\": + offset,\n            }).get(\"threads\", [])\n\n        return self._pagination(_method, THREADS_BATCH)\n\n    def get_channel_messages(self, channel_id):\n        \"\"\"Get channel messages\"\"\"\n        MESSAGES_BATCH = 100\n\n        before = None\n\n        def _method(_):\n            nonlocal before\n            messages = self._call(\"/channels/\" + channel_id + \"/messages\", {\n                \"limit\": MESSAGES_BATCH,\n                \"before\": before\n            })\n            if messages:\n                before = messages[-1][\"id\"]\n            return messages\n\n        return self._pagination(_method, MESSAGES_BATCH)\n\n    def get_search_messages(self, server_id, params):\n        \"\"\"Get search messages\"\"\"\n        MESSAGES_BATCH = 25\n\n        def _method(offset):\n            messages = self._call(url, params)[\"messages\"]\n\n            max_id = 0\n            for msgs in messages:\n                for msg in msgs:\n                    mid = int(msg[\"id\"])\n                    if max_id > mid or not max_id:\n                        max_id = mid\n            params[\"max_id\"] = max_id\n\n            return messages\n\n        url = f\"/guilds/{server_id}/messages/search\"\n        return self._pagination(_method, MESSAGES_BATCH)\n\n    def get_message(self, channel_id, message_id):\n        \"\"\"Get message information\"\"\"\n        return self._call(\"/channels/\" + channel_id + \"/messages\", {\n            \"limit\": 1,\n            \"around\": message_id\n        })[0]\n\n    def _call(self, endpoint, params=None):\n        url = self.root + endpoint\n        try:\n            response = self.extractor.request(\n                url, params=params, headers=self.headers)\n        except self.extractor.exc.HttpError as exc:\n            if exc.status == 401:\n                self._raise_invalid_token()\n            raise\n        return response.json()\n\n    def _pagination(self, method, batch):\n        offset = 0\n        while True:\n            data = method(offset)\n            yield from data\n            if len(data) < batch:\n                return\n            offset += len(data)\n\n    def _raise_invalid_token(self):\n        raise self.extractor.exc.AuthenticationError(\"\"\"\\\nInvalid or missing token.\nPlease provide a valid token following these instructions:\n\n1) Open Discord in your browser (https://discord.com/app);\n2) Open your browser's Developer Tools (F12) and switch to the Network panel;\n3) Reload the page and select any request going to https://discord.com/api/...;\n4) In the \"Headers\" tab, look for an entry beginning with \"Authorization: \";\n5) Right-click the entry and click \"Copy Value\";\n6) Paste the token in your configuration file under \"extractor.discord.token\",\nor run this command with the -o \"token=[your token]\" argument.\"\"\")\n"
  },
  {
    "path": "gallery_dl/extractor/dynastyscans.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2015-2025 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://dynasty-scans.com/\"\"\"\n\nfrom .common import ChapterExtractor, MangaExtractor, Extractor, Message\nfrom .. import text, util\n\nBASE_PATTERN = r\"(?:https?://)?(?:www\\.)?dynasty-scans\\.com\"\n\n\nclass DynastyscansBase():\n    \"\"\"Base class for dynastyscans extractors\"\"\"\n    category = \"dynastyscans\"\n    root = \"https://dynasty-scans.com\"\n\n    def _parse_image_page(self, image_id):\n        url = f\"{self.root}/images/{image_id}\"\n        extr = text.extract_from(self.request(url).text)\n\n        date = extr(\"class='create_at'>\", \"</span>\")\n        tags = extr(\"class='tags'>\", \"</span>\")\n        src = extr(\"class='btn-group'>\", \"</div>\")\n        url = extr(' src=\"', '\"')\n\n        src = text.extr(src, 'href=\"', '\"') if \"Source<\" in src else \"\"\n\n        return {\n            \"url\"     : self.root + url,\n            \"image_id\": text.parse_int(image_id),\n            \"tags\"    : text.split_html(tags),\n            \"date\"    : text.remove_html(date),\n            \"source\"  : text.unescape(src),\n        }\n\n\nclass DynastyscansChapterExtractor(DynastyscansBase, ChapterExtractor):\n    \"\"\"Extractor for manga-chapters from dynasty-scans.com\"\"\"\n    pattern = BASE_PATTERN + r\"(/chapters/[^/?#]+)\"\n    example = \"https://dynasty-scans.com/chapters/NAME\"\n\n    def metadata(self, page):\n        extr = text.extract_from(page)\n        match = text.re(\n            r\"(?:<a[^>]*>)?([^<]+)(?:</a>)?\"  # manga name\n            r\"(?: ch(\\d+)([^:<]*))?\"  # chapter info\n            r\"(?:: (.+))?\"  # title\n        ).match(extr(\"<h3 id='chapter-title'><b>\", \"</b>\"))\n        author = extr(\" by \", \"</a>\")\n        group = extr('\"icon-print\"></i> ', '</span>')\n\n        return {\n            \"manga\"   : text.unescape(match[1]),\n            \"chapter\" : text.parse_int(match[2]),\n            \"chapter_minor\": match[3] or \"\",\n            \"title\"   : text.unescape(match[4] or \"\"),\n            \"author\"  : text.remove_html(author),\n            \"group\"   : (text.remove_html(group) or\n                         text.extr(group, ' alt=\"', '\"')),\n            \"date\"    : self.parse_datetime(extr(\n                '\"icon-calendar\"></i> ', '<'), \"%b %d, %Y\"),\n            \"tags\"    : text.split_html(extr(\n                \"class='tags'>\", \"<div id='chapter-actions'\")),\n            \"lang\"    : \"en\",\n            \"language\": \"English\",\n        }\n\n    def images(self, page):\n        data = text.extr(page, \"var pages = \", \";\\n\")\n        return [\n            (self.root + img[\"image\"], None)\n            for img in util.json_loads(data)\n        ]\n\n\nclass DynastyscansMangaExtractor(DynastyscansBase, MangaExtractor):\n    chapterclass = DynastyscansChapterExtractor\n    reverse = False\n    pattern = BASE_PATTERN + r\"(/series/[^/?#]+)\"\n    example = \"https://dynasty-scans.com/series/NAME\"\n\n    def chapters(self, page):\n        return [\n            (self.root + path, {})\n            for path in text.extract_iter(page, '<dd>\\n<a href=\"', '\"')\n        ]\n\n\nclass DynastyscansSearchExtractor(DynastyscansBase, Extractor):\n    \"\"\"Extrator for image search results on dynasty-scans.com\"\"\"\n    subcategory = \"search\"\n    directory_fmt = (\"{category}\", \"Images\")\n    filename_fmt = \"{image_id}.{extension}\"\n    archive_fmt = \"i_{image_id}\"\n    pattern = BASE_PATTERN + r\"/images/?(?:\\?([^#]+))?$\"\n    example = \"https://dynasty-scans.com/images?QUERY\"\n\n    def __init__(self, match):\n        Extractor.__init__(self, match)\n        self.query = match[1] or \"\"\n\n    def items(self):\n        yield Message.Directory, \"\", {}\n        for image_id in self.images():\n            image = self._parse_image_page(image_id)\n            url = image[\"url\"]\n            yield Message.Url, url, text.nameext_from_url(url, image)\n\n    def images(self):\n        url = self.root + \"/images?\" + self.query.replace(\"[]\", \"%5B%5D\")\n        params = {\"page\": 1}\n\n        while True:\n            page = self.request(url, params=params).text\n            yield from text.extract_iter(page, '\"/images/', '\"')\n            if 'rel=\"next\"' not in page:\n                return\n            params[\"page\"] += 1\n\n\nclass DynastyscansImageExtractor(DynastyscansSearchExtractor):\n    \"\"\"Extractor for individual images on dynasty-scans.com\"\"\"\n    subcategory = \"image\"\n    pattern = BASE_PATTERN + r\"/images/(\\d+)\"\n    example = \"https://dynasty-scans.com/images/12345\"\n\n    def images(self):\n        return (self.query,)\n\n\nclass DynastyscansAnthologyExtractor(DynastyscansSearchExtractor):\n    \"\"\"Extractor for dynasty-scans anthologies\"\"\"\n    subcategory = \"anthology\"\n    pattern = BASE_PATTERN + r\"/anthologies/([^/?#]+)\"\n    example = \"https://dynasty-scans.com/anthologies/TITLE\"\n\n    def items(self):\n        url = f\"{self.root}/anthologies/{self.groups[0]}.atom\"\n        root = self.request_xml(url, xmlns=False)\n\n        data = {\n            \"_extractor\": DynastyscansChapterExtractor,\n            \"anthology\" : root[3].text[28:],\n        }\n\n        if self.config(\"metadata\", False):\n            page = self.request(url[:-5]).text\n            alert = text.extr(page, \"<div class='alert\", \"</div>\")\n\n            data[\"alert\"] = text.split_html(alert)[1:] if alert else ()\n            data[\"status\"] = text.extr(\n                page, \"<small>&mdash; \", \"</small>\")\n            data[\"description\"] = text.extr(\n                page, \"<div class='description'>\", \"</div>\")\n\n        for element in root:\n            if element.tag != \"entry\":\n                continue\n            content = element[6][0]\n            data[\"author\"] = content[0].text[8:]\n            data[\"scanlator\"] = content[1].text[11:]\n            data[\"tags\"] = content[2].text[6:].lower().split(\", \")\n            data[\"title\"] = element[5].text\n            data[\"date\"] = self.parse_datetime_iso(element[1].text)\n            data[\"date_updated\"] = self.parse_datetime_iso(element[2].text)\n            yield Message.Queue, element[4].text, data\n"
  },
  {
    "path": "gallery_dl/extractor/e621.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2014-2026 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://e621.net/ and other e621 instances\"\"\"\n\nfrom .common import Extractor, Message\nfrom . import danbooru\nfrom .. import text, util\n\n\nclass E621Extractor(danbooru.DanbooruExtractor):\n    \"\"\"Base class for e621 extractors\"\"\"\n    basecategory = \"E621\"\n    page_limit = 750\n    page_start = None\n    per_page = 320\n    useragent = util.USERAGENT_GALLERYDL + \" (by mikf)\"\n    request_interval_min = 1.0\n\n    def items(self):\n        if includes := self.config(\"metadata\") or ():\n            if isinstance(includes, str):\n                includes = includes.split(\",\")\n            elif not isinstance(includes, (list, tuple)):\n                includes = (\"notes\", \"pools\")\n\n        notes = (\"notes\" in includes)\n        pools = (\"pools\" in includes)\n\n        data = self.metadata()\n        for post in self.posts():\n            file = post[\"file\"]\n\n            if not file[\"url\"]:\n                md5 = file[\"md5\"]\n                file[\"url\"] = (f\"https://static1.{self.root[8:]}/data\"\n                               f\"/{md5[0:2]}/{md5[2:4]}/{md5}.{file['ext']}\")\n\n            if notes and post.get(\"has_notes\"):\n                post[\"notes\"] = self._get_notes(post[\"id\"])\n\n            if pools and post[\"pools\"]:\n                post[\"pools\"] = self.cache(\n                    self._get_pools, \",\".join(map(str, post[\"pools\"])))\n\n            post[\"filename\"] = file[\"md5\"]\n            post[\"extension\"] = file[\"ext\"]\n            post[\"date\"] = self.parse_datetime_iso(post[\"created_at\"])\n\n            tags = []\n            for category, tags_cat in post[\"tags\"].items():\n                post[\"tags_\" + category] = tags_cat\n                tags.extend(tags_cat)\n            tags.sort()\n            post[\"tags\"] = tags\n\n            post.update(data)\n            yield Message.Directory, \"\", post\n            yield Message.Url, file[\"url\"], post\n\n    def items_artists(self):\n        for artist in self.artists():\n            artist[\"_extractor\"] = E621TagExtractor\n            url = f\"{self.root}/posts?tags={text.quote(artist['name'])}\"\n            yield Message.Queue, url, artist\n\n    def _get_notes(self, id):\n        return self.request_json(\n            f\"{self.root}/notes.json?search[post_id]={id}\")\n\n    def _get_pools(self, ids):\n        pools = self.request_json(\n            f\"{self.root}/pools.json?search[id]={ids}\")\n        for pool in pools:\n            pool[\"name\"] = pool[\"name\"].replace(\"_\", \" \")\n        return pools\n\n\nBASE_PATTERN = E621Extractor.update({\n    \"e621\": {\n        \"root\": \"https://e621.net\",\n        \"pattern\": r\"e621\\.(?:net|cc)\",\n    },\n    \"e926\": {\n        \"root\": \"https://e926.net\",\n        \"pattern\": r\"e926\\.net\",\n    },\n    \"e6ai\": {\n        \"root\": \"https://e6ai.net\",\n        \"pattern\": r\"e6ai\\.net\",\n    },\n})\n\n\nclass E621TagExtractor(E621Extractor, danbooru.DanbooruTagExtractor):\n    \"\"\"Extractor for e621 posts from tag searches\"\"\"\n    pattern = BASE_PATTERN + r\"/posts?(?:\\?[^#]*?tags=|/index/\\d+/)([^&#]*)\"\n    example = \"https://e621.net/posts?tags=TAG\"\n\n\nclass E621PoolExtractor(E621Extractor, danbooru.DanbooruPoolExtractor):\n    \"\"\"Extractor for e621 pools\"\"\"\n    pattern = BASE_PATTERN + r\"/pool(?:s|/show)/(\\d+)\"\n    example = \"https://e621.net/pools/12345\"\n\n    def posts(self):\n        self.log.info(\"Collecting posts of pool %s\", self.pool_id)\n\n        id_to_post = {\n            post[\"id\"]: post\n            for post in self._pagination(\n                \"/posts.json\", {\"tags\": \"pool:\" + self.pool_id})\n        }\n\n        posts = []\n        for num, pid in enumerate(self.post_ids, 1):\n            if pid in id_to_post:\n                post = id_to_post[pid]\n                post[\"num\"] = num\n                posts.append(post)\n            else:\n                self.log.warning(\"Post %s is unavailable\", pid)\n        return posts\n\n\nclass E621PostExtractor(E621Extractor, danbooru.DanbooruPostExtractor):\n    \"\"\"Extractor for single e621 posts\"\"\"\n    pattern = BASE_PATTERN + r\"/p(?:ost(?:s|/show)/(\\d+)|/(\\w+))\"\n    example = \"https://e621.net/posts/12345\"\n\n    def posts(self):\n        pid = self.groups[-2] or int(self.groups[-1], 32)\n        url = f\"{self.root}/posts/{pid}.json\"\n        return (self.request_json(url)[\"post\"],)\n\n\nclass E621PopularExtractor(E621Extractor, danbooru.DanbooruPopularExtractor):\n    \"\"\"Extractor for popular images from e621\"\"\"\n    pattern = BASE_PATTERN + r\"/explore/posts/popular(?:\\?([^#]*))?\"\n    example = \"https://e621.net/explore/posts/popular\"\n\n    def posts(self):\n        return self._pagination(\"/popular.json\", self.params)\n\n\nclass E621ArtistExtractor(E621Extractor, danbooru.DanbooruArtistExtractor):\n    \"\"\"Extractor for e621 artists\"\"\"\n    subcategory = \"artist\"\n    pattern = BASE_PATTERN + r\"/artists/(\\d+)\"\n    example = \"https://e621.net/artists/12345\"\n\n    items = E621Extractor.items_artists\n\n\nclass E621ArtistSearchExtractor(E621Extractor,\n                                danbooru.DanbooruArtistSearchExtractor):\n    \"\"\"Extractor for e621 artist searches\"\"\"\n    subcategory = \"artist-search\"\n    pattern = BASE_PATTERN + r\"/artists/?\\?([^#]+)\"\n    example = \"https://e621.net/artists?QUERY\"\n\n    items = E621Extractor.items_artists\n\n\nclass E621FavoriteExtractor(E621Extractor):\n    \"\"\"Extractor for e621 favorites\"\"\"\n    subcategory = \"favorite\"\n    directory_fmt = (\"{category}\", \"Favorites\", \"{user_id}\")\n    archive_fmt = \"f_{user_id}_{id}\"\n    pattern = BASE_PATTERN + r\"/favorites(?:\\?([^#]*))?\"\n    example = \"https://e621.net/favorites\"\n\n    def metadata(self):\n        self.query = text.parse_query(self.groups[-1])\n        return {\"user_id\": self.query.get(\"user_id\", \"\")}\n\n    def posts(self):\n        return self._pagination(\"/favorites.json\", self.query)\n\n\nclass E621FrontendExtractor(Extractor):\n    \"\"\"Extractor for alternative e621 frontends\"\"\"\n    basecategory = \"E621\"\n    category = \"e621\"\n    subcategory = \"frontend\"\n    pattern = r\"(?:https?://)?e621\\.(?:cc/\\?tags|anthro\\.fr/\\?q)=([^&#]*)\"\n    example = \"https://e621.cc/?tags=TAG\"\n\n    def initialize(self):\n        pass\n\n    def items(self):\n        url = \"https://e621.net/posts?tags=\" + self.groups[0]\n        data = {\"_extractor\": E621TagExtractor}\n        yield Message.Queue, url, data\n"
  },
  {
    "path": "gallery_dl/extractor/eporner.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://www.eporner.com/\"\"\"\n\nfrom .common import GalleryExtractor\nfrom .. import text\n\n\nclass EpornerGalleryExtractor(GalleryExtractor):\n    \"\"\"Extractor for image galleries from eporner.com\"\"\"\n    category = \"eporner\"\n    root = \"https://www.eporner.com\"\n    pattern = (r\"(?:https?://)?(?:www\\.)?eporner\\.com\"\n               r\"/gallery/(\\w+)(?:/([\\w-]+))?\")\n    example = \"https://www.eporner.com/gallery/GID/SLUG/\"\n\n    def __init__(self, match):\n        url = f\"{self.root}/gallery/{match[1]}/{match[2]}/\"\n        GalleryExtractor.__init__(self, match, url)\n\n    def metadata(self, page):\n        if page.find(\"Age Verification<\", 0, 240) >= 0:\n            raise self.exc.AuthRequired(\"Age Verification\", \"gallery\")\n        title = text.extr(page, \"<title>\", \" - EPORNER</title>\")\n        if title.endswith(\" Photo Gallery\"):\n            title = title[:-14]\n\n        return {\n            \"gallery_id\": self.groups[0],\n            \"title\"     : text.unescape(title),\n            \"slug\"      : text.extr(\n                page, \"/gallery/\", '/\"').rpartition(\"/\")[2],\n            \"description\": text.unescape(text.extr(\n                page, 'name=\"description\" content=\"', '\"')),\n            \"tags\": text.extr(\n                page, 'EP.ads.keywords = \"', '\"').split(\",\"),\n        }\n\n    def images(self, page):\n        album = text.extr(\n            page, 'class=\"photosgrid gallerygrid\"', \"id='gallerySlideBox'\")\n\n        results = []\n        for url in text.extract_iter(album, ' src=\"', '\"'):\n            url, _, ext = url.rpartition(\".\")\n            # Preview images have a resolution suffix.\n            # E.g. \"11208293-image-3_296x1000.jpg\".\n            # The same name, but without the suffix, leads to the full image.\n            url = url[:url.rfind(\"_\")]\n            name = url[url.rfind(\"/\")+1:]\n            results.append((f\"{url}.{ext}\", {\"id\": name[:name.find(\"-\")]}))\n        return results\n"
  },
  {
    "path": "gallery_dl/extractor/erome.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2021-2026 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://www.erome.com/\"\"\"\n\nfrom .common import Extractor, Message\nfrom .. import text, util\nimport itertools\n\nBASE_PATTERN = r\"(?:https?://)?(?:www\\.)?erome\\.com\"\n\n\nclass EromeExtractor(Extractor):\n    category = \"erome\"\n    directory_fmt = (\"{category}\", \"{user}\")\n    filename_fmt = \"{album_id} {title} {num:>02}.{extension}\"\n    archive_fmt = \"{album_id}_{num}\"\n    root = \"https://www.erome.com\"\n    parent = True\n    _cookies = True\n\n    def items(self):\n        base = self.root + \"/a/\"\n        data = {\"_extractor\": EromeAlbumExtractor}\n        for album_id in self.albums():\n            yield Message.Queue, base + album_id, data\n\n    def albums(self):\n        return ()\n\n    def request(self, url, **kwargs):\n        if self._cookies:\n            self._cookies = False\n            self.cookies_update(self.cache(\n                _cookie_cache, _key=None, _mem=False))\n\n        for _ in range(5):\n            response = Extractor.request(self, url, **kwargs)\n            if response.cookies:\n                self.cache_update(_cookie_cache, None, response.cookies)\n            if response.content.find(\n                    b\"<title>Please wait a few moments</title>\", 0, 600) < 0:\n                return response\n            self.sleep(5.0, \"check\")\n\n    def _pagination(self, url, params):\n        find_albums = EromeAlbumExtractor.pattern.findall\n\n        for params[\"page\"] in itertools.count(\n                text.parse_int(params.get(\"page\"), 1)):\n            page = self.request(url, params=params).text\n\n            album_ids = find_albums(page)[::2]\n            yield from album_ids\n\n            if len(album_ids) < 36:\n                return\n\n\nclass EromeAlbumExtractor(EromeExtractor):\n    \"\"\"Extractor for albums on erome.com\"\"\"\n    subcategory = \"album\"\n    pattern = BASE_PATTERN + r\"/a/(\\w+)\"\n    example = \"https://www.erome.com/a/ID\"\n\n    def items(self):\n        album_id = self.groups[0]\n        url = f\"{self.root}/a/{album_id}\"\n\n        try:\n            page = self.request(url).text\n        except self.exc.HttpError as exc:\n            if exc.status == 410:\n                msg = text.extr(exc.response.text, \"<h1>\", \"<\")\n            else:\n                msg = \"Unable to fetch album page\"\n            raise self.exc.AbortExtraction(\n                f\"{album_id}: {msg} ({exc})\")\n\n        title, pos = text.extract(\n            page, 'property=\"og:title\" content=\"', '\"')\n        pos = page.index('<div class=\"user-profile', pos)\n        user, pos = text.extract(\n            page, 'href=\"https://www.erome.com/', '\"', pos)\n        tags, pos = text.extract(\n            page, '<p class=\"mt-10\"', '</p>', pos)\n\n        urls = []\n        date = None\n        groups = page.split('<div class=\"media-group\"')\n        for group in util.advance(groups, 1):\n            url = (text.extr(group, '<source src=\"', '\"') or\n                   text.extr(group, 'data-src=\"', '\"'))\n            if url:\n                urls.append(url)\n            if not date:\n                ts = text.extr(group, '?v=', '\"')\n                if len(ts) > 1:\n                    date = self.parse_timestamp(ts)\n\n        data = {\n            \"album_id\": album_id,\n            \"title\"   : text.unescape(title),\n            \"user\"    : text.unquote(user),\n            \"count\"   : len(urls),\n            \"date\"    : date,\n            \"tags\"    : ([t.replace(\"+\", \" \")\n                          for t in text.extract_iter(tags, \"?q=\", '\"')]\n                         if tags else ()),\n            \"_http_headers\": {\"Referer\": url},\n        }\n\n        yield Message.Directory, \"\", data\n        for data[\"num\"], url in enumerate(urls, 1):\n            yield Message.Url, url, text.nameext_from_url(url, data)\n\n\nclass EromeUserExtractor(EromeExtractor):\n    subcategory = \"user\"\n    pattern = BASE_PATTERN + r\"/(?!a/|search\\?)([^/?#]+)(?:/?\\?([^#]+))?\"\n    example = \"https://www.erome.com/USER\"\n\n    def albums(self):\n        user, qs = self.groups\n        url = f\"{self.root}/{user}\"\n\n        params = text.parse_query(qs)\n        if \"t\" not in params and not self.config(\"reposts\", False):\n            params[\"t\"] = \"posts\"\n\n        return self._pagination(url, params)\n\n\nclass EromeSearchExtractor(EromeExtractor):\n    subcategory = \"search\"\n    pattern = BASE_PATTERN + r\"/search/?\\?(q=[^#]+)\"\n    example = \"https://www.erome.com/search?q=QUERY\"\n\n    def albums(self):\n        url = self.root + \"/search\"\n        params = text.parse_query(self.groups[0])\n        return self._pagination(url, params)\n\n\ndef _cookie_cache():\n    return ()\n"
  },
  {
    "path": "gallery_dl/extractor/everia.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://everia.club\"\"\"\n\nfrom .common import Extractor, Message\nfrom .. import text\n\nBASE_PATTERN = r\"(?:https?://)?everia\\.club\"\n\n\nclass EveriaExtractor(Extractor):\n    category = \"everia\"\n    root = \"https://everia.club\"\n\n    def items(self):\n        data = {\"_extractor\": EveriaPostExtractor}\n        for url in self.posts():\n            yield Message.Queue, url, data\n\n    def posts(self):\n        return self._pagination(self.groups[0])\n\n    def _pagination(self, path, params=None, pnum=1):\n        find_posts = text.re(r'thumbnail\">\\s*<a href=\"([^\"]+)').findall\n\n        while True:\n            if pnum == 1:\n                url = f\"{self.root}{path}/\"\n            else:\n                url = f\"{self.root}{path}/page/{pnum}/\"\n            response = self.request(url, params=params, allow_redirects=False)\n\n            if response.status_code >= 300:\n                return\n\n            yield from find_posts(response.text)\n            pnum += 1\n\n\nclass EveriaPostExtractor(EveriaExtractor):\n    subcategory = \"post\"\n    directory_fmt = (\"{category}\", \"{title}\")\n    archive_fmt = \"{post_url}_{num}\"\n    pattern = BASE_PATTERN + r\"(/\\d{4}/\\d{2}/\\d{2}/[^/?#]+)\"\n    example = \"https://everia.club/0000/00/00/TITLE\"\n\n    def items(self):\n        url = self.root + self.groups[0] + \"/\"\n        page = self.request(url).text\n        content = text.extr(page, 'itemprop=\"text\">', \"<h3\")\n        urls = text.re(r'img.*?lazy-src=\"([^\"]+)').findall(content)\n\n        data = {\n            \"title\": text.unescape(\n                text.extr(page, 'itemprop=\"headline\">', \"</h\")),\n            \"tags\": list(text.extract_iter(page, 'rel=\"tag\">', \"</a>\")),\n            \"post_url\": text.unquote(url),\n            \"post_category\": text.extr(\n                page, \"post-in-category-\", \" \").capitalize(),\n            \"count\": len(urls),\n        }\n\n        yield Message.Directory, \"\", data\n        for data[\"num\"], url in enumerate(urls, 1):\n            url = text.unquote(url)\n            yield Message.Url, url, text.nameext_from_url(url, data)\n\n\nclass EveriaTagExtractor(EveriaExtractor):\n    subcategory = \"tag\"\n    pattern = BASE_PATTERN + r\"(/tag/[^/?#]+)\"\n    example = \"https://everia.club/tag/TAG\"\n\n\nclass EveriaCategoryExtractor(EveriaExtractor):\n    subcategory = \"category\"\n    pattern = BASE_PATTERN + r\"(/category/[^/?#]+)\"\n    example = \"https://everia.club/category/CATEGORY\"\n\n\nclass EveriaDateExtractor(EveriaExtractor):\n    subcategory = \"date\"\n    pattern = (BASE_PATTERN +\n               r\"(/\\d{4}(?:/\\d{2})?(?:/\\d{2})?)(?:/page/\\d+)?/?$\")\n    example = \"https://everia.club/0000/00/00\"\n\n\nclass EveriaSearchExtractor(EveriaExtractor):\n    subcategory = \"search\"\n    pattern = BASE_PATTERN + r\"/(?:page/\\d+/)?\\?s=([^&#]+)\"\n    example = \"https://everia.club/?s=SEARCH\"\n\n    def posts(self):\n        params = {\"s\": self.groups[0]}\n        return self._pagination(\"\", params)\n"
  },
  {
    "path": "gallery_dl/extractor/exhentai.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2014-2026 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://e-hentai.org/ and https://exhentai.org/\"\"\"\n\nfrom .common import Extractor, Message\nfrom .. import text, util\nimport collections\nimport itertools\nimport math\n\nBASE_PATTERN = r\"(?:https?://)?(e[x-]|g\\.e-)hentai\\.org\"\n\n\nclass ExhentaiExtractor(Extractor):\n    \"\"\"Base class for exhentai extractors\"\"\"\n    category = \"exhentai\"\n    directory_fmt = (\"{category}\", \"{gid} {title[:247]}\")\n    filename_fmt = \"{gid}_{num:>04}_{image_token}_{filename}.{extension}\"\n    archive_fmt = \"{gid}_{num}\"\n    cookies_domain = \".exhentai.org\"\n    cookies_names = (\"ipb_member_id\", \"ipb_pass_hash\")\n    root = \"https://exhentai.org\"\n    request_interval = (3.0, 6.0)\n    ciphers = \"DEFAULT:!DH\"\n\n    LIMIT = False\n\n    def __init__(self, match):\n        Extractor.__init__(self, match)\n        self.version = match[1]\n\n    def initialize(self):\n        domain = self.config(\"domain\", \"auto\")\n        if domain == \"auto\":\n            domain = (\"ex\" if self.version == \"ex\" else \"e-\") + \"hentai.org\"\n        self.root = \"https://\" + domain\n        self.api_url = self.root + \"/api.php\"\n        self.cookies_domain = \".\" + domain\n\n        Extractor.initialize(self)\n\n        if self.version != \"ex\":\n            self.cookies.set(\"nw\", \"1\", domain=self.cookies_domain)\n\n    def request(self, url, **kwargs):\n        response = Extractor.request(self, url, **kwargs)\n        if \"Cache-Control\" not in response.headers and not response.content:\n            self.log.info(\"blank page\")\n            raise self.exc.AuthorizationError()\n        return response\n\n    def login(self):\n        \"\"\"Login and set necessary cookies\"\"\"\n        if self.LIMIT:\n            raise self.exc.AbortExtraction(\"Image limit reached!\")\n\n        if self.cookies_check(self.cookies_names):\n            return\n\n        username, password = self._get_auth_info()\n        if username:\n            return self.cookies_update(self.cache(\n                self._login_impl, username, password,\n                _exp=90*86400, _mem=False))\n\n        if self.version == \"ex\":\n            self.log.info(\"No username or cookies given; using e-hentai.org\")\n            self.root = \"https://e-hentai.org\"\n            self.cookies_domain = \".e-hentai.org\"\n            self.cookies.set(\"nw\", \"1\", domain=self.cookies_domain)\n        self.original = False\n        self.limits = False\n\n    def _login_impl(self, username, password):\n        self.log.info(\"Logging in as %s\", username)\n\n        url = \"https://forums.e-hentai.org/index.php?act=Login&CODE=01\"\n        headers = {\n            \"Referer\": \"https://e-hentai.org/bounce_login.php?b=d&bt=1-1\",\n        }\n        data = {\n            \"CookieDate\": \"1\",\n            \"b\": \"d\",\n            \"bt\": \"1-1\",\n            \"UserName\": username,\n            \"PassWord\": password,\n            \"ipb_login_submit\": \"Login!\",\n        }\n\n        self.cookies.clear()\n\n        response = self.request(url, method=\"POST\", headers=headers, data=data)\n        content = response.content\n        if b\"You are now logged in as:\" not in content:\n            if b\"The captcha was not entered correctly\" in content:\n                raise self.exc.AuthenticationError(\n                    \"CAPTCHA required. Use cookies instead.\")\n            raise self.exc.AuthenticationError()\n\n        # collect more cookies\n        url = self.root + \"/favorites.php\"\n        response = self.request(url)\n        if response.history:\n            self.request(url)\n\n        return self.cookies\n\n\nclass ExhentaiGalleryExtractor(ExhentaiExtractor):\n    \"\"\"Extractor for image galleries from exhentai.org\"\"\"\n    subcategory = \"gallery\"\n    pattern = (BASE_PATTERN +\n               r\"(?:/(?:g|mpv)/(\\d+)/([0-9a-f]{10})(?:/#page(\\d+))?\"\n               r\"|/s/([0-9a-f]{10})/(\\d+)-(\\d+))\")\n    example = \"https://e-hentai.org/g/12345/67890abcde/\"\n\n    def __init__(self, match):\n        ExhentaiExtractor.__init__(self, match)\n        self.gallery_id = text.parse_int(match[2] or match[6])\n        self.gallery_token = match[3]\n        self.image_token = match[5]\n        self.image_num = text.parse_int(match[4] or match[7], 1)\n        self.key_start = None\n        self.key_show = None\n        self.key_next = None\n        self.count = 0\n        self.data = None\n        self.mpv = False\n\n    def _init(self):\n        source = self.config(\"source\")\n        if source == \"hitomi\":\n            self.items = self._items_hitomi\n        elif source == \"metadata\":\n            self.items = self._items_metadata\n\n        limits = self.config(\"limits\", False)\n        if limits and limits.__class__ is int:\n            self.limits = limits\n            self._limits_remaining = 0\n        else:\n            self.limits = False\n\n        self.fallback_retries = self.config(\"fallback-retries\", 2)\n        self.original = self.config(\"original\", True)\n\n    def finalize(self, status):\n        if not status or not self.data:\n            return\n\n        if self.mpv:\n            self.log.info(\"Use '%s/mpv/%s/%s/#page%s' as input URL \"\n                          \"to continue downloading from the current position\",\n                          self.root, self.gallery_id, self.gallery_token,\n                          self.data[\"num\"])\n        elif token := self.data.get(\"image_token\"):\n            self.log.info(\"Use '%s/s/%s/%s-%s' as input URL \"\n                          \"to continue downloading from the current position\",\n                          self.root, token, self.gallery_id, self.data[\"num\"])\n\n    def favorite(self, slot=\"0\"):\n        url = self.root + \"/gallerypopups.php\"\n        params = {\n            \"gid\": self.gallery_id,\n            \"t\"  : self.gallery_token,\n            \"act\": \"addfav\",\n        }\n        data = {\n            \"favcat\" : slot,\n            \"apply\"  : \"Apply Changes\",\n            \"update\" : \"1\",\n        }\n        self.request(url, method=\"POST\", params=params, data=data)\n\n    def items(self):\n        self.login()\n\n        if self.gallery_token:\n            gpage = self._gallery_page()\n            if not self.mpv:\n                self.image_token = text.extr(gpage, 'hentai.org/s/', '\"')\n                if not self.image_token:\n                    self.log.debug(\"Page content:\\n%s\", gpage)\n                    raise self.exc.AbortExtraction(\n                        \"Failed to extract initial image token\")\n                ipage = self._image_page()\n        else:\n            ipage = self._image_page()\n            part = text.extr(ipage, 'hentai.org/g/', '\"')\n            if not part:\n                self.log.debug(\"Page content:\\n%s\", ipage)\n                raise self.exc.AbortExtraction(\n                    \"Failed to extract gallery token\")\n            self.gallery_token = part.split(\"/\")[1]\n            gpage = self._gallery_page()\n\n        self.data = data = self.get_metadata(gpage)\n        self.count = text.parse_int(data[\"filecount\"])\n        yield Message.Directory, \"\", data\n\n        if self.mpv:\n            images = self.images_from_mpv()\n        else:\n            images = itertools.chain(\n                (self.image_from_page(ipage),), self.images_from_api())\n\n        for url, image in images:\n            data.update(image)\n            if self.limits:\n                self._limits_check(data)\n            if \"/fullimg\" in url:\n                data[\"_http_validate\"] = self._validate_response\n            else:\n                data[\"_http_validate\"] = None\n            data[\"_http_signature\"] = self._validate_signature\n            yield Message.Url, url, data\n\n        fav = self.config(\"fav\")\n        if fav is not None:\n            self.favorite(fav)\n        self.data = None\n\n    def _items_hitomi(self):\n        if self.config(\"metadata\", False):\n            data = self.metadata_from_api()\n            data[\"date\"] = self.parse_timestamp(data[\"posted\"])\n        else:\n            data = {}\n\n        from .hitomi import HitomiGalleryExtractor\n        url = f\"https://hitomi.la/galleries/{self.gallery_id}.html\"\n        data[\"_extractor\"] = HitomiGalleryExtractor\n        yield Message.Queue, url, data\n\n    def _items_metadata(self):\n        yield Message.Directory, \"\", self.metadata_from_api()\n\n    def get_metadata(self, page):\n        \"\"\"Extract gallery metadata\"\"\"\n        data = self.metadata_from_page(page)\n        if self.config(\"metadata\", False):\n            data.update(self.metadata_from_api())\n            data[\"date\"] = self.parse_timestamp(data[\"posted\"])\n        if self.config(\"tags\", True):\n            tags = collections.defaultdict(list)\n            for tag in data[\"tags\"]:\n                type, _, value = tag.partition(\":\")\n                tags[type].append(value)\n            for type, values in tags.items():\n                data[\"tags_\" + type] = values\n        return data\n\n    def metadata_from_page(self, page):\n        extr = text.extract_from(page)\n\n        if api_url := extr('var api_url = \"', '\"'):\n            self.api_url = api_url\n\n        data = {\n            \"gid\"          : self.gallery_id,\n            \"token\"        : self.gallery_token,\n            \"thumb\"        : extr(\"background:transparent url(\", \")\"),\n            \"title\"        : text.unescape(extr('<h1 id=\"gn\">', '</h1>')),\n            \"title_jpn\"    : text.unescape(extr('<h1 id=\"gj\">', '</h1>')),\n            \"_\"            : extr('<div id=\"gdc\"><div class=\"cs ct', '\"'),\n            \"eh_category\"  : extr('>', '<'),\n            \"uploader\"     : extr('<div id=\"gdn\">', '</div>'),\n            \"date\"         : self.parse_datetime_iso(extr(\n                '>Posted:</td><td class=\"gdt2\">', '</td>')),\n            \"parent\"       : extr(\n                '>Parent:</td><td class=\"gdt2\"><a href=\"', '\"'),\n            \"expunged\"     : \"Yes\" != extr(\n                '>Visible:</td><td class=\"gdt2\">', '<'),\n            \"language\"     : extr('>Language:</td><td class=\"gdt2\">', ' '),\n            \"filesize\"     : text.parse_bytes(extr(\n                '>File Size:</td><td class=\"gdt2\">', '<').rstrip(\"Bbi\")),\n            \"filecount\"    : extr('>Length:</td><td class=\"gdt2\">', ' '),\n            \"favorites\"    : extr('id=\"favcount\">', ' '),\n            \"rating\"       : extr(\">Average: \", \"<\"),\n            \"torrentcount\" : extr('>Torrent Download (', ')'),\n        }\n\n        uploader = data[\"uploader\"]\n        if uploader and uploader[0] == \"<\":\n            data[\"uploader\"] = text.unescape(text.extr(uploader, \">\", \"<\"))\n\n        f = data[\"favorites\"][0]\n        if f == \"N\":\n            data[\"favorites\"] = \"0\"\n        elif f == \"O\":\n            data[\"favorites\"] = \"1\"\n\n        data[\"lang\"] = util.language_to_code(data[\"language\"])\n        data[\"tags\"] = [\n            text.unquote(tag.replace(\"+\", \" \"))\n            for tag in text.extract_iter(page, 'hentai.org/tag/', '\"')\n        ]\n\n        return data\n\n    def metadata_from_api(self):\n        data = {\n            \"method\"   : \"gdata\",\n            \"gidlist\"  : ((self.gallery_id, self.gallery_token),),\n            \"namespace\": 1,\n        }\n\n        data = self.request_json(self.api_url, method=\"POST\", json=data)\n        if \"error\" in data:\n            raise self.exc.AbortExtraction(data[\"error\"])\n\n        return data[\"gmetadata\"][0]\n\n    def image_from_page(self, page):\n        \"\"\"Get image url and data from webpage\"\"\"\n        pos = page.index('<div id=\"i3\"><a onclick=\"return load_image(') + 26\n        extr = text.extract_from(page, pos)\n\n        self.key_next = extr(\"'\", \"'\")\n        iurl = extr('<img id=\"img\" src=\"', '\"')\n        nl = extr(\" nl(\", \")\").strip(\"\\\"'\")\n        orig = extr('hentai.org/fullimg', '\"')\n\n        try:\n            if self.original and orig:\n                url = self.root + \"/fullimg\" + text.unescape(orig)\n                data = self._parse_original_info(extr('ownload original', '<'))\n                data[\"_fallback\"] = self._fallback_original(nl, url)\n            else:\n                url = iurl\n                data = self._parse_image_info(url)\n                data[\"_fallback\"] = self._fallback_1280(nl, self.image_num)\n        except IndexError:\n            self.log.debug(\"Page content:\\n%s\", page)\n            raise self.exc.AbortExtraction(\n                f\"Unable to parse image info for '{url}'\")\n\n        data[\"num\"] = self.image_num\n        data[\"image_token\"] = self.key_start = extr('var startkey=\"', '\";')\n        data[\"_url_1280\"] = iurl\n        data[\"_nl\"] = nl\n        self.key_show = extr('var showkey=\"', '\";')\n\n        self._check_509(iurl)\n        return url, text.nameext_from_url(url, data)\n\n    def images_from_api(self):\n        \"\"\"Get image url and data from api calls\"\"\"\n        api_url = self.api_url\n        nextkey = self.key_next\n        request = {\n            \"method\" : \"showpage\",\n            \"gid\"    : self.gallery_id,\n            \"page\"   : 0,\n            \"imgkey\" : nextkey,\n            \"showkey\": self.key_show,\n        }\n\n        for request[\"page\"] in range(self.image_num + 1, self.count + 1):\n            page = self.request_json(api_url, method=\"POST\", json=request)\n\n            i3 = page[\"i3\"]\n            i6 = page[\"i6\"]\n\n            imgkey = nextkey\n            nextkey, pos = text.extract(i3, \"'\", \"'\")\n            imgurl , pos = text.extract(i3, 'id=\"img\" src=\"', '\"', pos)\n            nl     , pos = text.extract(i3, \" nl(\", \")\", pos)\n            nl = (nl or \"\").strip(\"\\\"'\")\n\n            try:\n                pos = i6.find(\"hentai.org/fullimg\")\n                if self.original and pos >= 0:\n                    origurl, pos = text.rextract(i6, '\"', '\"', pos)\n                    url = text.unescape(origurl)\n                    data = self._parse_original_info(text.extract(\n                        i6, \"ownload original\", \"<\", pos)[0])\n                    data[\"_fallback\"] = self._fallback_original(nl, url)\n                else:\n                    url = imgurl\n                    data = self._parse_image_info(url)\n                    data[\"_fallback\"] = self._fallback_1280(\n                        nl, request[\"page\"], imgkey)\n            except IndexError:\n                self.log.debug(\"Page content:\\n%s\", page)\n                raise self.exc.AbortExtraction(\n                    f\"Unable to parse image info for '{url}'\")\n\n            data[\"num\"] = request[\"page\"]\n            data[\"image_token\"] = imgkey\n            data[\"_url_1280\"] = imgurl\n            data[\"_nl\"] = nl\n\n            self._check_509(imgurl)\n            yield url, text.nameext_from_url(url, data)\n\n            request[\"imgkey\"] = nextkey\n\n    def images_from_mpv(self):\n        \"\"\"Get image url and data from MPV\"\"\"\n        url = f\"{self.root}/mpv/{self.gallery_id}/{self.gallery_token}/\"\n        page = self.request(url).text\n        images = util.json_loads(text.extr(page, \"var imagelist = \", \";\"))\n\n        api_url = self.api_url\n        pnum = self.image_num - 1\n        request = {\n            \"method\": \"imagedispatch\",\n            \"gid\"   : self.gallery_id,\n            \"page\"  : 0,\n            \"imgkey\": \"\",\n            \"mpvkey\": text.extr(page, 'var mpvkey = \"', '\"'),\n        }\n\n        if pnum:\n            images = util.advance(images, pnum)\n        for image in images:\n            pnum += 1\n            request[\"page\"] = pnum\n            request[\"imgkey\"] = imgkey = image[\"k\"]\n            info = self.request_json(api_url, method=\"POST\", json=request)\n\n            try:\n                imgurl = info[\"i\"]\n                if self.original and info.get(\"o\") and \" \" in info[\"o\"]:\n                    url = f\"{self.root}/{info['lf']}\"\n                    try:\n                        data = self._parse_mpv_info(info)\n                    except ValueError:\n                        self.log.warning(\n                            \"Failed to extract original file metadata\")\n                        data = self._parse_image_info(imgurl)\n                    data[\"_fallback\"] = self._fallback_mpv_original(info)\n                else:\n                    url = imgurl\n                    data = self._parse_image_info(url)\n                    data[\"_fallback\"] = self._fallback_mpv_1280(info, request)\n            except IndexError:\n                self.log.debug(\"Page content:\\n%s\", info)\n                raise self.exc.AbortExtraction(\n                    f\"Unable to parse image info for '{url}'\")\n\n            data[\"num\"] = pnum\n            data[\"_nl\"] = info[\"s\"]\n            data[\"_url_1280\"] = imgurl\n            data[\"image_token\"] = imgkey\n\n            self._check_509(imgurl)\n            if name := image.get(\"name\"):\n                text.nameext_from_name(name, data)\n            else:\n                text.nameext_from_url(url, data)\n\n            yield url, data\n\n    def _validate_response(self, response):\n        if response.history or not response.headers.get(\n                \"content-type\", \"\").startswith(\"text/html\"):\n            return True\n\n        page = response.text\n        self.log.warning(\"'%s'\", page)\n\n        if \" requires GP\" in page:\n            gp = self.config(\"gp\")\n            if gp == \"stop\":\n                raise self.exc.AbortExtraction(\"Not enough GP\")\n            elif gp == \"wait\":\n                self.input(\"Press ENTER to continue.\")\n                return response.url\n\n            self.log.info(\"Falling back to non-original downloads\")\n            self.original = False\n            return self.data[\"_url_1280\"]\n\n        if \" temporarily banned \" in page:\n            raise self.exc.AuthorizationError(\"Temporarily Banned\")\n\n        self._limits_exceeded()\n        return response.url\n\n    def _validate_signature(self, signature):\n        \"\"\"Return False if all file signature bytes are zero\"\"\"\n        if signature:\n            if byte := signature[0]:\n                # 60 == b\"<\"\n                if byte == 60 and b\"<!doctype html\".startswith(\n                        signature[:14].lower()):\n                    return \"HTML response\"\n                return True\n            for byte in signature:\n                if byte:\n                    return True\n        return False\n\n    def _request_home(self, **kwargs):\n        url = \"https://e-hentai.org/home.php\"\n        kwargs[\"cookies\"] = {\n            cookie.name: cookie.value\n            for cookie in self.cookies\n            if cookie.domain == self.cookies_domain and\n            cookie.name != \"igneous\"\n        }\n        page = self.request(url, **kwargs).text\n\n        # update image limits\n        current = text.extr(page, \"<strong>\", \"</strong>\").replace(\",\", \"\")\n        self.log.debug(\"Image Limits: %s/%s\", current, self.limits)\n        self._limits_remaining = self.limits - text.parse_int(current)\n\n        return page\n\n    def _check_509(self, url):\n        # full 509.gif URLs\n        # - https://exhentai.org/img/509.gif\n        # - https://ehgt.org/g/509.gif\n        if url.endswith((\"hentai.org/img/509.gif\",\n                         \"ehgt.org/g/509.gif\")):\n            self.log.debug(url)\n            self._limits_exceeded()\n\n    def _limits_exceeded(self):\n        msg = \"Image limit exceeded!\"\n        action = self.config(\"limits-action\")\n\n        if not action or action == \"stop\":\n            ExhentaiExtractor.LIMIT = True\n            raise self.exc.AbortExtraction(msg)\n\n        self.log.warning(msg)\n        if action == \"wait\":\n            self.input(\"Press ENTER to continue.\")\n            self._limits_update()\n        elif action == \"reset\":\n            self._limits_reset()\n        else:\n            self.log.error(\"Invalid 'limits-action' value '%s'\", action)\n\n    def _limits_check(self, data):\n        if not self._limits_remaining or data[\"num\"] % 25 == 0:\n            self._limits_update()\n        self._limits_remaining -= data[\"cost\"]\n        if self._limits_remaining <= 0:\n            self._limits_exceeded()\n\n    def _limits_reset(self):\n        self.log.info(\"Resetting image limits\")\n        self._request_home(\n            method=\"POST\",\n            headers={\"Content-Type\": \"application/x-www-form-urlencoded\"},\n            data=b\"reset_imagelimit=Reset+Quota\")\n\n    _limits_update = _request_home\n\n    def _gallery_page(self):\n        url = f\"{self.root}/g/{self.gallery_id}/{self.gallery_token}/\"\n        response = self.request(url, fatal=False)\n        page = response.text\n\n        if response.status_code == 404 and \"Gallery Not Available\" in page:\n            raise self.exc.AuthorizationError()\n        if page.startswith((\"Key missing\", \"Gallery not found\")):\n            raise self.exc.NotFoundError(\"gallery\")\n        if page.count(\"hentai.org/mpv/\") > 1:\n            if self.gallery_token is None:\n                raise self.exc.AbortExtraction(\n                    \"'/s/' URLs in MPV mode are not supported\")\n            self.mpv = True\n        return page\n\n    def _image_page(self):\n        url = (f\"{self.root}/s/{self.image_token}\"\n               f\"/{self.gallery_id}-{self.image_num}\")\n        page = self.request(url, fatal=False).text\n\n        if page.startswith((\"Invalid page\", \"Keep trying\")):\n            raise self.exc.NotFoundError(\"image page\")\n        return page\n\n    def _fallback_original(self, nl, fullimg):\n        url = f\"{fullimg}?nl={nl}\"\n        for _ in util.repeat(self.fallback_retries):\n            yield url\n\n    def _fallback_mpv_original(self, info):\n        url = f\"{self.root}/{info['lf']}?nl={info['s']}\"\n        for _ in util.repeat(self.fallback_retries):\n            yield url\n\n    def _fallback_1280(self, nl, num, token=None):\n        if not token:\n            token = self.key_start\n\n        for _ in util.repeat(self.fallback_retries):\n            url = f\"{self.root}/s/{token}/{self.gallery_id}-{num}?nl={nl}\"\n\n            page = self.request(url, fatal=False).text\n            if page.startswith((\"Invalid page\", \"Keep trying\")):\n                return\n            url, data = self.image_from_page(page)\n            yield url\n\n            nl = data[\"_nl\"]\n\n    def _fallback_mpv_1280(self, info, request):\n        for _ in util.repeat(self.fallback_retries):\n            request[\"nl\"] = info[\"s\"]\n            info = self.request_json(self.api_url, method=\"POST\", json=request)\n            yield info[\"i\"]\n\n    def _parse_image_info(self, url):\n        for part in url.split(\"/\")[4:]:\n            try:\n                _, size, width, height, _ = part.split(\"-\")\n                break\n            except ValueError:\n                pass\n        else:\n            size = width = height = 0\n\n        return {\n            \"cost\"  : 1,\n            \"size\"  : text.parse_int(size),\n            \"width\" : text.parse_int(width),\n            \"height\": text.parse_int(height),\n        }\n\n    def _parse_original_info(self, info):\n        parts = info.lstrip().split(\" \")\n        size = text.parse_bytes(parts[3] + parts[4][0])\n\n        return {\n            # 1 initial point + 1 per 0.1 MB\n            \"cost\"  : 1 + math.ceil(size / 100_000),\n            \"size\"  : size,\n            \"width\" : text.parse_int(parts[0]),\n            \"height\": text.parse_int(parts[2]),\n        }\n\n    def _parse_mpv_info(self, info):\n        _, _, w, _, h, s, u, _ = info[\"o\"].split()\n        size = text.parse_bytes(s + u[0])\n\n        return {\n            # 1 initial point + 1 per 0.1 MB\n            \"cost\"  : 1 + math.ceil(size / 100_000),\n            \"size\"  : size,\n            \"width\" : text.parse_int(w),\n            \"height\": text.parse_int(h),\n        }\n\n\nclass ExhentaiSearchExtractor(ExhentaiExtractor):\n    \"\"\"Extractor for exhentai search results\"\"\"\n    subcategory = \"search\"\n    pattern = BASE_PATTERN + r\"/(?:\\?([^#]*)|tag/([^/?#]+))\"\n    example = \"https://e-hentai.org/?f_search=QUERY\"\n\n    def __init__(self, match):\n        ExhentaiExtractor.__init__(self, match)\n\n        _, query, tag = self.groups\n        if tag:\n            if \"+\" in tag:\n                ns, _, tag = tag.rpartition(\":\")\n                tag = f\"{ns}:\\\"{tag.replace('+', ' ')}$\\\"\"\n            else:\n                tag += \"$\"\n            self.params = {\"f_search\": tag, \"page\": 0}\n        else:\n            self.params = text.parse_query(query)\n            if \"next\" not in self.params:\n                self.params[\"page\"] = text.parse_int(self.params.get(\"page\"))\n\n    def _init(self):\n        self.search_url = self.root\n\n    def items(self):\n        self.login()\n        data = {\"_extractor\": ExhentaiGalleryExtractor}\n        search_url = self.search_url\n        params = self.params\n\n        while True:\n            last = None\n            page = self.request(search_url, params=params).text\n\n            for match in ExhentaiGalleryExtractor.pattern.finditer(page):\n                url = match[0]\n                if url == last:\n                    continue\n                last = url\n                data[\"gallery_id\"] = text.parse_int(match[2])\n                data[\"gallery_token\"] = match[3]\n                yield Message.Queue, url + \"/\", data\n\n            next_url = text.extr(page, 'nexturl=\"', '\"', None)\n            if next_url is not None:\n                if not next_url:\n                    return\n                search_url = next_url\n                params = None\n\n            elif 'class=\"ptdd\">&gt;<' in page or \">No hits found</p>\" in page:\n                return\n            else:\n                params[\"page\"] += 1\n\n\nclass ExhentaiFavoriteExtractor(ExhentaiSearchExtractor):\n    \"\"\"Extractor for favorited exhentai galleries\"\"\"\n    subcategory = \"favorite\"\n    pattern = BASE_PATTERN + r\"/favorites\\.php(?:\\?([^#]*)())?\"\n    example = \"https://e-hentai.org/favorites.php\"\n\n    def _init(self):\n        self.search_url = self.root + \"/favorites.php\"\n"
  },
  {
    "path": "gallery_dl/extractor/facebook.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://www.facebook.com/\"\"\"\n\nfrom .common import Extractor, Message, Dispatch\nfrom .. import text, util\n\nBASE_PATTERN = r\"(?:https?://)?(?:[\\w-]+\\.)?facebook\\.com\"\nUSER_PATTERN = (BASE_PATTERN +\n                r\"/(?!media/|photo/|photo.php|watch/|permalink.php)\"\n                r\"(?:profile\\.php\\?id=|people/[^/?#]+/)?([^/?&#]+)\")\n\n\nclass FacebookExtractor(Extractor):\n    \"\"\"Base class for Facebook extractors\"\"\"\n    category = \"facebook\"\n    root = \"https://www.facebook.com\"\n    directory_fmt = (\"{category}\", \"{username}\", \"{title} ({set_id})\")\n    filename_fmt = \"{id}.{extension}\"\n    archive_fmt = \"{id}.{extension}\"\n\n    def _init(self):\n        headers = self.session.headers\n        headers[\"Accept\"] = (\n            \"text/html,application/xhtml+xml,application/xml;q=0.9,\"\n            \"image/avif,image/webp,image/png,image/svg+xml,*/*;q=0.8\"\n        )\n        headers[\"Sec-Fetch-Dest\"] = \"empty\"\n        headers[\"Sec-Fetch-Mode\"] = \"navigate\"\n        headers[\"Sec-Fetch-Site\"] = \"same-origin\"\n\n        self.fallback_retries = self.config(\"fallback-retries\", 2)\n        self.videos = self.config(\"videos\", True)\n        self.author_followups = self.config(\"author-followups\", False)\n\n    def decode_all(self, txt):\n        return text.unescape(\n            txt.encode().decode(\"unicode_escape\")\n            .encode(\"utf_16\", \"surrogatepass\").decode(\"utf_16\")\n        ).replace(\"\\\\/\", \"/\")\n\n    def parse_set_page(self, set_page):\n        directory = {\n            \"set_id\": text.extr(\n                set_page, '\"mediaSetToken\":\"', '\"'\n            ) or text.extr(\n                set_page, '\"mediasetToken\":\"', '\"'\n            ),\n            \"username\": self.decode_all(\n                text.extr(\n                    set_page, '\"user\":{\"__isProfile\":\"User\",\"name\":\"', '\",\"'\n                ) or text.extr(\n                    set_page, '\"actors\":[{\"__typename\":\"User\",\"name\":\"', '\",\"'\n                )\n            ),\n            \"user_id\": text.extr(\n                set_page, '\"owner\":{\"__typename\":\"User\",\"id\":\"', '\"'\n            ),\n            \"user_pfbid\": \"\",\n            \"title\": self.decode_all(text.extr(\n                set_page, '\"title\":{\"text\":\"', '\"'\n            )),\n            \"first_photo_id\": text.extr(\n                set_page,\n                '{\"__typename\":\"Photo\",\"__isMedia\":\"Photo\",\"',\n                '\",\"creation_story\"'\n            ).rsplit('\"id\":\"', 1)[-1] or\n            text.extr(\n                set_page, '{\"__typename\":\"Photo\",\"id\":\"', '\"'\n            )\n        }\n\n        if directory[\"user_id\"].startswith(\"pfbid\"):\n            directory[\"user_pfbid\"] = directory[\"user_id\"]\n            directory[\"user_id\"] = (\n                text.extr(\n                    set_page, '\"actors\":[{\"__typename\":\"User\",\"id\":\"', '\"') or\n                text.extr(\n                    set_page, '\"userID\":\"', '\"') or\n                directory[\"set_id\"].split(\".\")[1])\n\n        return directory\n\n    def parse_photo_page(self, photo_page):\n        photo = {\n            \"id\": text.extr(\n                photo_page, '\"__isNode\":\"Photo\",\"id\":\"', '\"'\n            ),\n            \"set_id\": text.extr(\n                photo_page,\n                '\"url\":\"https:\\\\/\\\\/www.facebook.com\\\\/photo\\\\/?fbid=',\n                '\"'\n            ).rsplit(\"&set=\", 1)[-1],\n            \"username\": self.decode_all(text.extr(\n                photo_page, '\"owner\":{\"__typename\":\"User\",\"name\":\"', '\"'\n            )),\n            \"user_id\": text.extr(\n                photo_page, '\"owner\":{\"__typename\":\"User\",\"id\":\"', '\"'\n            ),\n            \"user_pfbid\": \"\",\n            \"caption\": self.decode_all(text.extr(\n                photo_page,\n                '\"message\":{\"delight_ranges\"',\n                '\"},\"message_preferred_body\"'\n            ).rsplit('],\"text\":\"', 1)[-1]),\n            \"date\": self.parse_timestamp(\n                text.extr(photo_page, '\\\\\"publish_time\\\\\":', ',') or\n                text.extr(photo_page, '\"created_time\":', ',')\n            ),\n            \"url\": self.decode_all(text.extr(\n                photo_page, ',\"image\":{\"uri\":\"', '\",\"'\n            )),\n            \"next_photo_id\": text.extr(\n                photo_page,\n                '\"nextMediaAfterNodeId\":{\"__typename\":\"Photo\",\"id\":\"',\n                '\"'\n            ) or text.extr(\n                photo_page,\n                '\"nextMedia\":{\"edges\":[{\"node\":{\"__typename\":\"Photo\",\"id\":\"',\n                '\"'\n            )\n        }\n\n        if photo[\"user_id\"].startswith(\"pfbid\"):\n            photo[\"user_pfbid\"] = photo[\"user_id\"]\n            photo[\"user_id\"] = text.extr(\n                photo_page, r'\\\"content_owner_id_new\\\":\\\"', r'\\\"')\n\n        text.nameext_from_url(photo[\"url\"], photo)\n\n        photo[\"followups_ids\"] = []\n        for comment_raw in text.extract_iter(\n            photo_page, '{\"node\":{\"id\"', '\"cursor\":null}'\n        ):\n            if ('\"is_author_original_poster\":true' in comment_raw and\n                    '{\"__typename\":\"Photo\",\"id\":\"' in comment_raw):\n                photo[\"followups_ids\"].append(text.extr(\n                    comment_raw,\n                    '{\"__typename\":\"Photo\",\"id\":\"',\n                    '\"'\n                ))\n\n        return photo\n\n    def parse_post_page(self, post_page):\n        first_photo_url = text.extr(\n            text.extr(\n                post_page, '\"__isMedia\":\"Photo\"', '\"target_group\"'\n            ), '\"url\":\"', ','\n        )\n\n        post = {\n            \"set_id\": text.extr(post_page, '{\"mediaset_token\":\"', '\"') or\n            text.extr(first_photo_url, 'set=', '\"').rsplit(\"&\", 1)[0]\n        }\n\n        return post\n\n    def parse_video_page(self, video_page):\n        video = {\n            \"id\": text.extr(\n                video_page, '\\\\\"video_id\\\\\":\\\\\"', '\\\\\"'\n            ),\n            \"username\": self.decode_all(text.extr(\n                video_page, '\"actors\":[{\"__typename\":\"User\",\"name\":\"', '\",\"'\n            )),\n            \"user_id\": text.extr(\n                video_page, '\"owner\":{\"__typename\":\"User\",\"id\":\"', '\"'\n            ),\n            \"date\": self.parse_timestamp(text.extr(\n                video_page, '\\\\\"publish_time\\\\\":', ','\n            )),\n            \"type\": \"video\"\n        }\n\n        if not video[\"username\"]:\n            video[\"username\"] = self.decode_all(text.extr(\n                video_page,\n                '\"__typename\":\"User\",\"id\":\"' + video[\"user_id\"] + '\",\"name\":\"',\n                '\",\"'\n            ))\n\n        first_video_raw = text.extr(\n            video_page, '\"permalink_url\"', '\\\\/Period>\\\\u003C\\\\/MPD>'\n        )\n\n        audio = {\n            **video,\n            \"url\": self.decode_all(text.extr(\n                text.extr(\n                    first_video_raw,\n                    \"AudioChannelConfiguration\",\n                    \"BaseURL>\\\\u003C\"\n                ),\n                \"BaseURL>\", \"\\\\u003C\\\\/\"\n            )),\n            \"type\": \"audio\"\n        }\n\n        video[\"urls\"] = {}\n\n        for raw_url in text.extract_iter(\n            first_video_raw, 'FBQualityLabel=\\\\\"', '\\\\u003C\\\\/BaseURL>'\n        ):\n            resolution = raw_url.split('\\\\\"', 1)[0]\n            video[\"urls\"][resolution] = self.decode_all(\n                raw_url.split('BaseURL>', 1)[1]\n            )\n\n        if not video[\"urls\"]:\n            return video, audio\n\n        video[\"url\"] = max(\n            video[\"urls\"].items(),\n            key=lambda x: text.parse_int(x[0][:-1])\n        )[1]\n\n        text.nameext_from_url(video[\"url\"], video)\n        audio[\"filename\"] = video[\"filename\"]\n        audio[\"extension\"] = \"m4a\"\n\n        return video, audio\n\n    def photo_page_request_wrapper(self, url, **kwargs):\n        LEFT_OFF_TXT = \"\" if url.endswith(\"&set=\") else (\n            \"\\nYou can use this URL to continue from \"\n            \"where you left off (added \\\"&setextract\\\"): \"\n            \"\\n\" + url + \"&setextract\"\n        )\n\n        res = self.request(url, **kwargs)\n\n        if res.url.startswith(self.root + \"/login\"):\n            raise self.exc.AuthRequired(\n                message=(\"You must be logged in to continue viewing images.\" +\n                         LEFT_OFF_TXT))\n\n        if b'{\"__dr\":\"CometErrorRoot.react\"}' in res.content:\n            raise self.exc.AbortExtraction(\n                \"You've been temporarily blocked from viewing images.\\n\"\n                \"Please try using a different account, \"\n                \"using a VPN or waiting before you retry.\" + LEFT_OFF_TXT)\n\n        return res\n\n    def extract_set(self, set_data):\n        set_id = set_data[\"set_id\"]\n        all_photo_ids = [set_data[\"first_photo_id\"]]\n\n        retries = 0\n        i = 0\n\n        while i < len(all_photo_ids):\n            photo_id = all_photo_ids[i]\n            photo_url = f\"{self.root}/photo/?fbid={photo_id}&set={set_id}\"\n            photo_page = self.photo_page_request_wrapper(photo_url).text\n\n            photo = self.parse_photo_page(photo_page)\n            photo[\"num\"] = i + 1\n\n            if self.author_followups:\n                for followup_id in photo[\"followups_ids\"]:\n                    if followup_id not in all_photo_ids:\n                        self.log.debug(\n                            \"Found a followup in comments: %s\", followup_id\n                        )\n                        all_photo_ids.append(followup_id)\n\n            if not photo[\"url\"]:\n                if retries < self.fallback_retries and self._interval_429:\n                    seconds = self._interval_429()\n                    self.log.warning(\n                        \"Failed to find photo download URL for %s. \"\n                        \"Retrying in %s seconds.\", photo_url, seconds,\n                    )\n                    self.wait(seconds=seconds, reason=\"429 Too Many Requests\")\n                    retries += 1\n                    continue\n                else:\n                    self.log.error(\n                        \"Failed to find photo download URL for \" + photo_url +\n                        \". Skipping.\"\n                    )\n                    retries = 0\n            else:\n                retries = 0\n                photo.update(set_data)\n                yield Message.Directory, \"\", photo\n                yield Message.Url, photo[\"url\"], photo\n\n            if not photo[\"next_photo_id\"]:\n                self.log.debug(\n                    \"Can't find next image in the set. \"\n                    \"Extraction is over.\"\n                )\n            elif photo[\"next_photo_id\"] in all_photo_ids:\n                if photo[\"next_photo_id\"] != photo[\"id\"]:\n                    self.log.debug(\n                        \"Detected a loop in the set, it's likely finished. \"\n                        \"Extraction is over.\"\n                    )\n            elif int(photo[\"next_photo_id\"]) > int(photo[\"id\"]) + i*120:\n                self.log.info(\n                    \"Detected jump to the beginning of the set. (%s -> %s)\",\n                    photo[\"id\"], photo[\"next_photo_id\"])\n                if self.config(\"loop\", False):\n                    all_photo_ids.append(photo[\"next_photo_id\"])\n            else:\n                all_photo_ids.append(photo[\"next_photo_id\"])\n\n            i += 1\n\n    def _extract_profile(self, profile, set_id=False):\n        if set_id:\n            url = f\"{self.root}/{profile}/photos_by\"\n        else:\n            url = f\"{self.root}/{profile}\"\n        return self._extract_profile_page(url)\n\n    def _extract_profile_page(self, url):\n        for _ in range(self.fallback_retries + 1):\n            page = self.request(url).text\n\n            if page.find('>Page Not Found</title>', 0, 3000) > 0:\n                break\n            if ('\"props\":{\"title\":\"This content isn\\'t available right now\"' in\n                    page):\n                raise self.exc.AuthRequired(\n                    \"authenticated cookies\", \"profile\",\n                    \"This content isn't available right now\")\n\n            set_id = self._extract_profile_set_id(page)\n            user = self._extract_profile_user(page)\n            if set_id or user:\n                user[\"set_id\"] = set_id\n                return user\n\n            self.log.debug(\"Got empty profile photos page, retrying...\")\n        return {}\n\n    def _extract_profile_set_id(self, profile_photos_page):\n        set_ids_raw = text.extr(\n            profile_photos_page, '\"pageItems\"', '\"page_info\"'\n        )\n\n        set_id = text.extr(\n            set_ids_raw, 'set=', '\"'\n        ).rsplit(\"&\", 1)[0] or text.extr(\n            set_ids_raw, '\\\\/photos\\\\/', '\\\\/'\n        )\n\n        return set_id\n\n    def _extract_profile_user(self, page):\n        data = text.extr(page, '\",\"user\":{\"', '},\"viewer\":{')\n\n        user = None\n        try:\n            user = util.json_loads(f'{{\"{data}}}')\n            if user[\"id\"].startswith(\"pfbid\"):\n                user[\"user_pfbid\"] = user[\"id\"]\n                user[\"id\"] = text.extr(page, '\"userID\":\"', '\"')\n            user[\"username\"] = (text.extr(page, '\"userVanity\":\"', '\"') or\n                                text.extr(page, '\"vanity\":\"', '\"'))\n            user[\"profile_tabs\"] = [\n                edge[\"node\"]\n                for edge in (user[\"profile_tabs\"][\"profile_user\"]\n                             [\"timeline_nav_app_sections\"][\"edges\"])\n            ]\n\n            if bio := text.extr(page, '\"best_description\":{\"text\":\"', '\"'):\n                user[\"biography\"] = self.decode_all(bio)\n            elif (pos := page.find(\n                    '\"__module_operation_ProfileCometTileView_profileT')) >= 0:\n                user[\"biography\"] = self.decode_all(text.rextr(\n                    page, '\"text\":\"', '\"', pos))\n            else:\n                user[\"biography\"] = text.unescape(text.remove_html(text.extr(\n                    page, \"</span></span></h2>\", \"<ul>\")))\n        except Exception:\n            if user is None:\n                self.log.debug(\"Failed to extract user data: %s\", data)\n                user = {}\n        return user\n\n\nclass FacebookPhotoExtractor(FacebookExtractor):\n    \"\"\"Base class for Facebook Photo extractors\"\"\"\n    subcategory = \"photo\"\n    pattern = (BASE_PATTERN +\n               r\"/(?:[^/?#]+/photos/[^/?#]+/|photo(?:.php)?/?\\?\"\n               r\"(?:[^&#]+&)*fbid=)([^/?&#]+)[^/?#]*(?<!&setextract)$\")\n    example = \"https://www.facebook.com/photo/?fbid=PHOTO_ID\"\n\n    def items(self):\n        photo_id = self.groups[0]\n        photo_url = f\"{self.root}/photo/?fbid={photo_id}&set=\"\n        photo_page = self.photo_page_request_wrapper(photo_url).text\n\n        i = 1\n        photo = self.parse_photo_page(photo_page)\n        photo[\"num\"] = i\n\n        set_url = f\"{self.root}/media/set/?set={photo['set_id']}\"\n        set_page = self.request(set_url).text\n\n        directory = self.parse_set_page(set_page)\n\n        for key in (\"set_id\", \"title\", \"user_id\", \"user_pfbid\", \"username\"):\n            if not directory.get(key):\n                directory[key] = photo.get(key)\n            elif not photo.get(key):\n                photo[key] = directory.get(key)\n\n        yield Message.Directory, \"\", directory\n        yield Message.Url, photo[\"url\"], photo\n\n        if self.author_followups:\n            for comment_photo_id in photo[\"followups_ids\"]:\n                comment_photo = self.parse_photo_page(\n                    self.photo_page_request_wrapper(\n                        f\"{self.root}/photo/?fbid={comment_photo_id}&set=\"\n                    ).text\n                )\n                i += 1\n                comment_photo[\"num\"] = i\n                yield Message.Url, comment_photo[\"url\"], comment_photo\n\n\nclass FacebookSetExtractor(FacebookExtractor):\n    \"\"\"Base class for Facebook Set extractors\"\"\"\n    subcategory = \"set\"\n    pattern = (\n        BASE_PATTERN +\n        r\"/(?:(?:media/set|photo)/?\\?(?:[^&#]+&)*set=([^&#]+)\"\n        r\"[^/?#]*(?<!&setextract)$\"\n        r\"|([^/?#]+/posts/[^/?#]+)\"\n        r\"|photo/\\?(?:[^&#]+&)*fbid=([^/?&#]+)&set=([^/?&#]+)&setextract)\"\n    )\n    example = \"https://www.facebook.com/media/set/?set=SET_ID\"\n\n    def items(self):\n        set_id = self.groups[0] or self.groups[3]\n        if path := self.groups[1]:\n            post_url = self.root + \"/\" + path\n            post_page = self.request(post_url).text\n            set_id = self.parse_post_page(post_page)[\"set_id\"]\n\n        set_url = f\"{self.root}/media/set/?set={set_id}\"\n        set_page = self.request(set_url).text\n        set_data = self.parse_set_page(set_page)\n        if self.groups[2]:\n            set_data[\"first_photo_id\"] = self.groups[2]\n\n        return self.extract_set(set_data)\n\n\nclass FacebookVideoExtractor(FacebookExtractor):\n    \"\"\"Base class for Facebook Video extractors\"\"\"\n    subcategory = \"video\"\n    directory_fmt = (\"{category}\", \"{username}\", \"{subcategory}\")\n    pattern = BASE_PATTERN + r\"/(?:[^/?#]+/videos/|watch/?\\?v=)([^/?&#]+)\"\n    example = \"https://www.facebook.com/watch/?v=VIDEO_ID\"\n\n    def items(self):\n        video_id = self.groups[0]\n        video_url = self.root + \"/watch/?v=\" + video_id\n        video_page = self.request(video_url).text\n\n        video, audio = self.parse_video_page(video_page)\n\n        if \"url\" not in video:\n            return\n\n        yield Message.Directory, \"\", video\n\n        if self.videos == \"ytdl\":\n            yield Message.Url, \"ytdl:\" + video_url, video\n        elif self.videos:\n            yield Message.Url, video[\"url\"], video\n            if audio[\"url\"]:\n                yield Message.Url, audio[\"url\"], audio\n\n\nclass FacebookInfoExtractor(FacebookExtractor):\n    \"\"\"Extractor for Facebook Profile data\"\"\"\n    subcategory = \"info\"\n    directory_fmt = (\"{category}\", \"{username}\")\n    pattern = USER_PATTERN + r\"/info\"\n    example = \"https://www.facebook.com/USERNAME/info\"\n\n    def items(self):\n        user = self.cache(self._extract_profile, self.groups[0])\n        return iter(((Message.Directory, \"\", user),))\n\n\nclass FacebookAlbumsExtractor(FacebookExtractor):\n    \"\"\"Extractor for Facebook Profile albums\"\"\"\n    subcategory = \"albums\"\n    pattern = USER_PATTERN + r\"/photos_albums(?:/([^/?#]+))?\"\n    example = \"https://www.facebook.com/USERNAME/photos_albums\"\n\n    def items(self):\n        profile, name = self.groups\n        url = f\"{self.root}/{profile}/photos_albums\"\n        page = self.request(url).text\n\n        pos = page.find(\n            '\"TimelineAppCollectionAlbumsRenderer\",\"collection\":{\"id\":\"')\n        if pos < 0:\n            return\n        if name is not None:\n            name = name.lower()\n\n        items = text.extract(page, '},\"pageItems\":', '}}},', pos)[0]\n        edges = util.json_loads(items + \"}}\")[\"edges\"]\n\n        # TODO: use /graphql API endpoint\n        for edge in edges:\n            node = edge[\"node\"]\n            album = node[\"node\"]\n            album[\"title\"] = title = node[\"title\"][\"text\"]\n            if name is not None and name != title.lower():\n                continue\n            album[\"_extractor\"] = FacebookSetExtractor\n            album[\"thumbnail\"] = (img := node[\"image\"]) and img[\"uri\"]\n            yield Message.Queue, album[\"url\"], album\n\n\nclass FacebookPhotosExtractor(FacebookExtractor):\n    \"\"\"Extractor for Facebook Profile Photos\"\"\"\n    subcategory = \"photos\"\n    pattern = USER_PATTERN + r\"/photos(?:_by)?\"\n    example = \"https://www.facebook.com/USERNAME/photos\"\n\n    def items(self):\n        set_id = self.cache(\n            self._extract_profile, self.groups[0], True)[\"set_id\"]\n        if not set_id:\n            return iter(())\n\n        set_url = f\"{self.root}/media/set/?set={set_id}\"\n        set_page = self.request(set_url).text\n        set_data = self.parse_set_page(set_page)\n        return self.extract_set(set_data)\n\n\nclass FacebookAvatarExtractor(FacebookExtractor):\n    \"\"\"Extractor for Facebook Profile Avatars\"\"\"\n    subcategory = \"avatar\"\n    pattern = USER_PATTERN + r\"/avatar\"\n    example = \"https://www.facebook.com/USERNAME/avatar\"\n\n    def items(self):\n        user = self.cache(self._extract_profile, self.groups[0])\n        avatar_page_url = user[\"profilePhoto\"][\"url\"]\n        avatar_page = self.photo_page_request_wrapper(avatar_page_url).text\n\n        avatar = self.parse_photo_page(avatar_page)\n        avatar[\"count\"] = avatar[\"num\"] = 1\n        avatar[\"type\"] = \"avatar\"\n\n        set_url = f\"{self.root}/media/set/?set={avatar['set_id']}\"\n        set_page = self.request(set_url).text\n        directory = self.parse_set_page(set_page)\n\n        yield Message.Directory, \"\", directory\n        yield Message.Url, avatar[\"url\"], avatar\n\n\nclass FacebookUserExtractor(Dispatch, FacebookExtractor):\n    \"\"\"Extractor for Facebook Profiles\"\"\"\n    pattern = USER_PATTERN + r\"/?(?:$|\\?|#)\"\n    example = \"https://www.facebook.com/USERNAME\"\n\n    def items(self):\n        base = f\"{self.root}/{self.groups[0]}/\"\n        return self._dispatch_extractors((\n            (FacebookInfoExtractor  , base + \"info\"),\n            (FacebookAvatarExtractor, base + \"avatar\"),\n            (FacebookPhotosExtractor, base + \"photos\"),\n            (FacebookAlbumsExtractor, base + \"photos_albums\"),\n        ), (\"photos\",))\n"
  },
  {
    "path": "gallery_dl/extractor/fanbox.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://www.fanbox.cc/\"\"\"\n\nfrom .common import Extractor, Message\nfrom .. import text, util\n\nBASE_PATTERN = r\"(?:https?://)?(?:www\\.)?fanbox\\.cc\"\nUSER_PATTERN = (\n    r\"(?:https?://)?(?:\"\n    r\"(?!www\\.)([\\w-]+)\\.fanbox\\.cc|\"\n    r\"(?:www\\.)?fanbox\\.cc/@([\\w-]+))\"\n)\n\n\nclass FanboxExtractor(Extractor):\n    \"\"\"Base class for fanbox extractors\"\"\"\n    category = \"fanbox\"\n    root = \"https://www.fanbox.cc\"\n    directory_fmt = (\"{category}\", \"{creatorId}\")\n    filename_fmt = \"{id}_{num}.{extension}\"\n    archive_fmt = \"{id}_{num}\"\n    browser = \"firefox\"\n    _warning = True\n\n    def _init(self):\n        self.headers = {\n            \"Accept\" : \"application/json, text/plain, */*\",\n            \"Origin\" : \"https://www.fanbox.cc\",\n            \"Referer\": \"https://www.fanbox.cc/\",\n            \"Cookie\" : None,\n            \"Sec-Fetch-Dest\": \"empty\",\n            \"Sec-Fetch-Mode\": \"cors\",\n            \"Sec-Fetch-Site\": \"same-site\",\n        }\n        self.embeds = self.config(\"embeds\", True)\n\n        if includes := self.config(\"metadata\"):\n            if isinstance(includes, str):\n                includes = includes.split(\",\")\n            elif not isinstance(includes, (list, tuple)):\n                includes = (\"user\", \"plan\")\n            self._meta_user = (\"user\" in includes)\n            self._meta_plan = (\"plan\" in includes)\n            self._meta_comments = (\"comments\" in includes)\n        else:\n            self._meta_user = self._meta_plan = self._meta_comments = False\n\n        if self.config(\"comments\"):\n            self._meta_comments = True\n\n        if self._warning:\n            if not self.cookies_check((\"FANBOXSESSID\",)):\n                self.log.warning(\"no 'FANBOXSESSID' cookie set\")\n            FanboxExtractor._warning = False\n\n    def items(self):\n        fee_max = self.config(\"fee-max\")\n\n        for item in self.posts():\n            if fee_max is not None and fee_max < item[\"feeRequired\"]:\n                self.log.warning(\"Skipping post %s (feeRequired of %s > %s)\",\n                                 item[\"id\"], item[\"feeRequired\"], fee_max)\n            else:\n                try:\n                    url = (\"https://api.fanbox.cc/post.info?postId=\" +\n                           item[\"id\"])\n                    item = self.request_json(url, headers=self.headers)[\"body\"]\n                except Exception as exc:\n                    self.log.warning(\"Skipping post %s (%s: %s)\",\n                                     item[\"id\"], exc.__class__.__name__, exc)\n\n            content_body, post = self._extract_post(item)\n            yield Message.Directory, \"\", post\n            yield from self._get_urls_from_post(content_body, post)\n\n    def posts(self):\n        \"\"\"Return all relevant post objects\"\"\"\n\n    def _pagination(self, url):\n        while url:\n            url = text.ensure_http_scheme(url)\n            body = self.request_json(url, headers=self.headers)[\"body\"]\n\n            yield from body[\"items\"]\n\n            url = body[\"nextUrl\"]\n\n    def _extract_post(self, post):\n        \"\"\"Fetch and process post data\"\"\"\n        post[\"archives\"] = ()\n\n        if content_body := post.pop(\"body\", None):\n            if \"html\" in content_body:\n                post[\"html\"] = content_body[\"html\"]\n            if post[\"type\"] == \"article\":\n                post[\"articleBody\"] = content_body.copy()\n            if \"blocks\" in content_body:\n                content = []  # text content\n                images = []   # image IDs in 'body' order\n                files = []    # file IDs in 'body' order\n\n                for block in content_body[\"blocks\"]:\n                    if \"text\" in block:\n                        content.append(block[\"text\"])\n                    if \"links\" in block:\n                        for link in block[\"links\"]:\n                            content.append(link[\"url\"])\n                    if \"imageId\" in block:\n                        images.append(block[\"imageId\"])\n                    if \"fileId\" in block:\n                        files.append(block[\"fileId\"])\n\n                post[\"content\"] = \"\\n\".join(content)\n\n                self._sort_map(content_body, \"imageMap\", images)\n                if file_map := self._sort_map(content_body, \"fileMap\", files):\n                    exts = util.EXTS_ARCHIVE\n                    post[\"archives\"] = [\n                        file\n                        for file in file_map.values()\n                        if file.get(\"extension\", \"\").lower() in exts\n                    ]\n\n        try:\n            post[\"date\"] = self.parse_datetime_iso(post[\"publishedDatetime\"])\n        except Exception:\n            post[\"date\"] = None\n        post[\"text\"] = content_body.get(\"text\") if content_body else None\n        post[\"isCoverImage\"] = False\n\n        cid = post.get(\"creatorId\")\n        if self._meta_user and cid is not None:\n            post[\"user\"] = self.cache(self._get_user_data, cid)\n        if self._meta_plan and cid is not None:\n            plans = self.cache(self._get_plan_data, cid)\n            fee = post.get(\"feeRequired\") or 0\n            try:\n                post[\"plan\"] = plans[fee]\n            except KeyError:\n                if fees := [f for f in plans if f >= fee]:\n                    plan = plans[min(fees)]\n                else:\n                    plan = plans[0].copy()\n                    plan[\"fee\"] = fee\n                post[\"plan\"] = plans[fee] = plan\n        if self._meta_comments:\n            if post.get(\"commentCount\"):\n                post[\"comments\"] = self._get_comment_data(post[\"id\"])\n            else:\n                post[\"comments\"] = ()\n\n        return content_body, post\n\n    def _sort_map(self, body, key, ids):\n        orig = body.get(key)\n        if not orig:\n            return {} if orig is None else orig\n\n        body[key] = new = {\n            id: orig[id]\n            for id in ids\n            if id in orig\n        }\n\n        return new\n\n    def _get_user_data(self, creator_id):\n        url = \"https://api.fanbox.cc/creator.get\"\n        params = {\"creatorId\": creator_id}\n        data = self.request_json(url, params=params, headers=self.headers)\n\n        user = data[\"body\"]\n        user.update(user.pop(\"user\"))\n\n        return user\n\n    def _get_plan_data(self, creator_id):\n        url = \"https://api.fanbox.cc/plan.listCreator\"\n        params = {\"creatorId\": creator_id}\n        data = self.request_json(url, params=params, headers=self.headers)\n\n        plans = {0: {\n            \"id\"             : \"\",\n            \"title\"          : \"\",\n            \"fee\"            : 0,\n            \"description\"    : \"\",\n            \"coverImageUrl\"  : \"\",\n            \"creatorId\"      : creator_id,\n            \"hasAdultContent\": None,\n            \"paymentMethod\"  : None,\n        }}\n        for plan in data[\"body\"]:\n            del plan[\"user\"]\n            plans[plan[\"fee\"]] = plan\n\n        return plans\n\n    def _get_comment_data(self, post_id):\n        url = (\"https://api.fanbox.cc/post.getComments\"\n               \"?limit=10&postId=\" + post_id)\n\n        comments = []\n        try:\n            while url:\n                comlist = self.request_json(\n                    text.ensure_http_scheme(url), headers=self.headers,\n                )[\"body\"][\"commentList\"]\n                comments.extend(comlist[\"items\"])\n                url = comlist[\"nextUrl\"]\n        except Exception as exc:\n            self.log.debug(\"comments: %s: %s\", exc.__class__.__name__, exc)\n        return comments\n\n    def _get_urls_from_post(self, content_body, post):\n        num = 0\n        if cover_image := post.get(\"coverImageUrl\"):\n            cover_image = text.re(\"/c/[0-9a-z_]+\").sub(\"\", cover_image)\n            final_post = post.copy()\n            final_post[\"isCoverImage\"] = True\n            final_post[\"fileUrl\"] = cover_image\n            text.nameext_from_url(cover_image, final_post)\n            final_post[\"num\"] = num\n            num += 1\n            yield Message.Url, cover_image, final_post\n\n        if not content_body:\n            return\n\n        if \"html\" in content_body:\n            html_urls = []\n\n            for href in text.extract_iter(content_body[\"html\"], 'href=\"', '\"'):\n                if \"fanbox.pixiv.net/images/entry\" in href:\n                    html_urls.append(href)\n                elif \"downloads.fanbox.cc\" in href:\n                    html_urls.append(href)\n            for src in text.extract_iter(content_body[\"html\"],\n                                         'data-src-original=\"', '\"'):\n                html_urls.append(src)\n\n            for url in html_urls:\n                final_post = post.copy()\n                text.nameext_from_url(url, final_post)\n                final_post[\"fileUrl\"] = url\n                final_post[\"num\"] = num\n                num += 1\n                yield Message.Url, url, final_post\n\n        for group in (\"images\", \"imageMap\"):\n            if group in content_body:\n                for item in content_body[group]:\n                    if group == \"imageMap\":\n                        # imageMap is a dict with image objects as values\n                        item = content_body[group][item]\n\n                    final_post = post.copy()\n                    final_post[\"fileUrl\"] = item[\"originalUrl\"]\n                    text.nameext_from_url(item[\"originalUrl\"], final_post)\n                    if \"extension\" in item:\n                        final_post[\"extension\"] = item[\"extension\"]\n                    final_post[\"fileId\"] = item.get(\"id\")\n                    final_post[\"width\"] = item.get(\"width\")\n                    final_post[\"height\"] = item.get(\"height\")\n                    final_post[\"num\"] = num\n                    num += 1\n                    yield Message.Url, item[\"originalUrl\"], final_post\n\n        for group in (\"files\", \"fileMap\"):\n            if group in content_body:\n                for item in content_body[group]:\n                    if group == \"fileMap\":\n                        # fileMap is a dict with file objects as values\n                        item = content_body[group][item]\n\n                    final_post = post.copy()\n                    final_post[\"fileUrl\"] = item[\"url\"]\n                    text.nameext_from_url(item[\"url\"], final_post)\n                    if \"extension\" in item:\n                        final_post[\"extension\"] = item[\"extension\"]\n                    if \"name\" in item:\n                        final_post[\"filename\"] = item[\"name\"]\n                    final_post[\"fileId\"] = item.get(\"id\")\n                    final_post[\"num\"] = num\n                    num += 1\n                    yield Message.Url, item[\"url\"], final_post\n\n        if self.embeds:\n            embeds_found = []\n            if \"video\" in content_body:\n                embeds_found.append(content_body[\"video\"])\n            embeds_found.extend(content_body.get(\"embedMap\", {}).values())\n\n            for embed in embeds_found:\n                # embed_result is (message type, url, metadata dict)\n                embed_result = self._process_embed(post, embed)\n                if not embed_result:\n                    continue\n                embed_result[2][\"num\"] = num\n                num += 1\n                yield embed_result\n\n    def _process_embed(self, post, embed):\n        final_post = post.copy()\n        provider = embed[\"serviceProvider\"]\n        content_id = embed.get(\"videoId\") or embed.get(\"contentId\")\n        prefix = \"ytdl:\" if self.embeds == \"ytdl\" else \"\"\n        url = None\n        is_video = False\n\n        if provider == \"soundcloud\":\n            url = prefix+\"https://soundcloud.com/\"+content_id\n            is_video = True\n        elif provider == \"youtube\":\n            url = prefix+\"https://youtube.com/watch?v=\"+content_id\n            is_video = True\n        elif provider == \"vimeo\":\n            url = prefix+\"https://vimeo.com/\"+content_id\n            is_video = True\n        elif provider == \"fanbox\":\n            # this is an old URL format that redirects\n            # to a proper Fanbox URL\n            url = \"https://www.pixiv.net/fanbox/\"+content_id\n            # resolve redirect\n            try:\n                url = self.request_location(url)\n            except Exception as exc:\n                url = None\n                self.log.warning(\"Unable to extract fanbox embed %s (%s: %s)\",\n                                 content_id, exc.__class__.__name__, exc)\n            else:\n                final_post[\"_extractor\"] = FanboxPostExtractor\n        elif provider == \"twitter\":\n            url = \"https://twitter.com/_/status/\"+content_id\n        elif provider == \"google_forms\":\n            url = (f\"https://docs.google.com/forms/d/e/\"\n                   f\"{content_id}/viewform?usp=sf_link\")\n        else:\n            self.log.warning(\"service not recognized: %s\", provider)\n\n        if url:\n            final_post[\"embed\"] = embed\n            final_post[\"embedUrl\"] = url\n            text.nameext_from_url(url, final_post)\n            msg_type = Message.Queue\n            if is_video and self.embeds == \"ytdl\":\n                msg_type = Message.Url\n            return msg_type, url, final_post\n\n\nclass FanboxCreatorExtractor(FanboxExtractor):\n    \"\"\"Extractor for a pixivFANBOX creator's works\"\"\"\n    subcategory = \"creator\"\n    pattern = USER_PATTERN + r\"(?:/posts)?/?$\"\n    example = \"https://USER.fanbox.cc/\"\n\n    def posts(self):\n        url = \"https://api.fanbox.cc/post.paginateCreator?creatorId=\"\n        creator_id = self.groups[0] or self.groups[1]\n        return self._pagination_creator(url + creator_id)\n\n    def _pagination_creator(self, url):\n        urls = self.request_json(url, headers=self.headers)[\"body\"]\n        if offset := self.config(\"offset\"):\n            quotient, remainder = divmod(offset, 10)\n            if quotient:\n                urls = urls[quotient:]\n        else:\n            remainder = None\n\n        for url in urls:\n            url = text.ensure_http_scheme(url)\n            posts = self.request_json(url, headers=self.headers)[\"body\"]\n            if remainder:\n                posts = posts[remainder:]\n                remainder = None\n            yield from posts\n\n\nclass FanboxTagExtractor(FanboxExtractor):\n    \"\"\"Extractor for a pixivFANBOX creator's tagged works\"\"\"\n    subcategory = \"tag\"\n    pattern = USER_PATTERN + r\"/tags/([^/?#]+)\"\n    example = \"https://USER.fanbox.cc/tags/TAG\"\n\n    def posts(self):\n        cid, cid2, tag = self.groups\n        self.kwdict[\"search_tags\"] = text.unquote(tag)\n        url = (f\"https://api.fanbox.cc/post.listTagged\"\n               f\"?creatorId={cid or cid2}&tag={tag}\")\n        return self._pagination(url)\n\n\nclass FanboxPostExtractor(FanboxExtractor):\n    \"\"\"Extractor for media from a single pixivFANBOX post\"\"\"\n    subcategory = \"post\"\n    pattern = USER_PATTERN + r\"/posts/(\\d+)\"\n    example = \"https://USER.fanbox.cc/posts/12345\"\n\n    def posts(self):\n        return ({\"id\": self.groups[2], \"feeRequired\": 0},)\n\n\nclass FanboxHomeExtractor(FanboxExtractor):\n    \"\"\"Extractor for your pixivFANBOX home feed\"\"\"\n    subcategory = \"home\"\n    pattern = BASE_PATTERN + r\"/?$\"\n    example = \"https://fanbox.cc/\"\n\n    def posts(self):\n        url = \"https://api.fanbox.cc/post.listHome?limit=10\"\n        return self._pagination(url)\n\n\nclass FanboxSupportingExtractor(FanboxExtractor):\n    \"\"\"Extractor for your supported pixivFANBOX users feed\"\"\"\n    subcategory = \"supporting\"\n    pattern = BASE_PATTERN + r\"/home/supporting\"\n    example = \"https://fanbox.cc/home/supporting\"\n\n    def posts(self):\n        url = \"https://api.fanbox.cc/post.listSupporting?limit=10\"\n        return self._pagination(url)\n\n\nclass FanboxRedirectExtractor(Extractor):\n    \"\"\"Extractor for pixiv redirects to fanbox.cc\"\"\"\n    category = \"fanbox\"\n    subcategory = \"redirect\"\n    cookies_domain = None\n    pattern = r\"(?:https?://)?(?:www\\.)?pixiv\\.net/fanbox/creator/(\\d+)\"\n    example = \"https://www.pixiv.net/fanbox/creator/12345\"\n\n    def items(self):\n        url = \"https://www.pixiv.net/fanbox/creator/\" + self.groups[0]\n        location = self.request_location(url, notfound=\"user\")\n        yield Message.Queue, location, {\"_extractor\": FanboxCreatorExtractor}\n"
  },
  {
    "path": "gallery_dl/extractor/fansly.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2025-2026 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://fansly.com/\"\"\"\n\nfrom .common import Extractor, Message\nfrom .. import text, util\nimport time\n\nBASE_PATTERN = r\"(?:https?://)?(?:www\\.)?fansly\\.com\"\n\n\nclass FanslyExtractor(Extractor):\n    \"\"\"Base class for fansly extractors\"\"\"\n    category = \"fansly\"\n    root = \"https://fansly.com\"\n    directory_fmt = (\"{category}\", \"{account[username]} ({account[id]})\")\n    filename_fmt = \"{id}_{num}_{file[id]}.{extension}\"\n    archive_fmt = \"{file[id]}\"\n\n    def _init(self):\n        self.api = FanslyAPI(self)\n        self.previews = self.config(\"previews\", True)\n\n        if fmts := self.config(\"formats\"):\n            self.formats = set(fmts)\n        else:\n            self.formats = None\n\n    def items(self):\n        for post in self.posts():\n            files = self._extract_files(post)\n            post[\"count\"] = len(files)\n            post[\"date\"] = self.parse_timestamp(post[\"createdAt\"])\n\n            yield Message.Directory, \"\", post\n            for post[\"num\"], file in enumerate(files, 1):\n                post.update(file)\n                url = file[\"url\"]\n                yield Message.Url, url, text.nameext_from_url(url, post)\n\n    def posts(self):\n        creator, wall_id = self.groups\n        account = self.api.account(creator)\n        walls = account[\"walls\"]\n\n        if wall_id:\n            for wall in walls:\n                if wall[\"id\"] == wall_id:\n                    break\n            else:\n                raise self.exc.NotFoundError(\"wall\")\n            walls = (wall,)\n\n        for wall in walls:\n            self.kwdict[\"wall\"] = wall\n            yield from self.posts_wall(account, wall)\n\n    def _extract_files(self, post):\n        if \"attachments\" not in post:\n            return ()\n\n        if \"_extra\" in post:\n            extra = post.pop(\"_extra\", ())\n            media = {\n                media[\"id\"]: media\n                for media in self.api.account_media(extra)\n            }\n            post[\"attachments\"].extend(\n                media[mid]\n                for mid in extra\n                if mid in media\n            )\n\n        files = []\n        for attachment in post.pop(\"attachments\"):\n            try:\n                self._extract_attachment(files, post, attachment)\n            except Exception as exc:\n                self.log.traceback(exc)\n                self.log.error(\n                    \"%s/%s, Failed to extract media (%s: %s)\",\n                    post[\"id\"], attachment.get(\"id\"),\n                    exc.__class__.__name__, exc)\n        return files\n\n    def _extract_attachment(self, files, post, attachment, preview=False):\n        media = attachment[\"preview\" if preview else \"media\"]\n\n        variants = media.pop(\"variants\") or []\n        if media.get(\"locations\"):\n            variants.append(media)\n\n        fmts = self.formats\n        formats = [\n            (variant[\"width\"], (type-500 if type > 256 else type), variant)\n            for variant in variants\n            if variant.get(\"locations\") and\n            (type := variant[\"type\"]) and\n            (fmts is None or type in fmts)\n        ]\n\n        try:\n            variant = max(formats)[-1]\n        except Exception:\n            if self.previews and \"preview\" in attachment and not preview:\n                self.log.info(\"%s/%s: Downloading Preview\",\n                              post[\"id\"], attachment[\"id\"])\n                return self._extract_attachment(files, post, attachment, True)\n            return self.log.warning(\"%s/%s: No format available\",\n                                    post[\"id\"], attachment[\"id\"])\n\n        mime = variant[\"mimetype\"]\n        location = variant.pop(\"locations\")[0]\n        if \"metadata\" in variant:\n            try:\n                variant.update(util.json_loads(variant.pop(\"metadata\")))\n            except Exception:\n                pass\n\n        file = {\n            **variant,\n            \"preview\": preview,\n            \"format\": variant[\"type\"],\n            \"date\": self.parse_timestamp(media[\"createdAt\"]),\n            \"date_updated\": self.parse_timestamp(media[\"updatedAt\"]),\n        }\n\n        if \"metadata\" in location:\n            # manifest\n            meta = location[\"metadata\"]\n            file[\"type\"] = \"video\"\n\n            try:\n                fallback = (media[\"locations\"][0][\"location\"],)\n            except Exception:\n                fallback = ()\n\n            files.append({\n                \"file\": file,\n                \"url\": \"ytdl:\" + location[\"location\"],\n                \"_fallback\": fallback,\n                \"_ytdl_manifest\":\n                    \"dash\" if mime == \"application/dash+xml\" else \"hls\",\n                \"_ytdl_manifest_cookies\": (\n                    (\"CloudFront-Key-Pair-Id\", meta[\"Key-Pair-Id\"]),\n                    (\"CloudFront-Signature\"  , meta[\"Signature\"]),\n                    (\"CloudFront-Policy\"     , meta[\"Policy\"]),\n                ),\n            })\n        else:\n            file[\"type\"] = \"image\" if mime.startswith(\"image/\") else \"video\"\n            files.append({\n                \"file\": file,\n                \"url\" : location[\"location\"],\n            })\n\n\nclass FanslyPostExtractor(FanslyExtractor):\n    subcategory = \"post\"\n    pattern = BASE_PATTERN + r\"/post/(\\d+)\"\n    example = \"https://fansly.com/post/1234567890\"\n\n    def posts(self):\n        return self.api.post(self.groups[0])\n\n\nclass FanslyHomeExtractor(FanslyExtractor):\n    subcategory = \"home\"\n    pattern = BASE_PATTERN + r\"/home(?:/(?:subscribed()|list/(\\d+)))?\"\n    example = \"https://fansly.com/home\"\n\n    def posts(self):\n        subscribed, list_id = self.groups\n        if subscribed is not None:\n            mode = \"1\"\n        elif list_id is not None:\n            mode = None\n        else:\n            mode = \"0\"\n        return self.api.timeline_home(mode, list_id)\n\n\nclass FanslyListExtractor(FanslyExtractor):\n    subcategory = \"list\"\n    pattern = BASE_PATTERN + r\"/lists/(\\d+)\"\n    example = \"https://fansly.com/lists/1234567890\"\n\n    def items(self):\n        base = self.root + \"/\"\n        for account in self.api.lists_itemsnew(self.groups[0]):\n            account[\"_extractor\"] = FanslyCreatorPostsExtractor\n            url = f\"{base}{account['username']}/posts\"\n            yield Message.Queue, url, account\n\n\nclass FanslyListsExtractor(FanslyExtractor):\n    subcategory = \"lists\"\n    pattern = BASE_PATTERN + r\"/lists\"\n    example = \"https://fansly.com/lists\"\n\n    def items(self):\n        base = self.root + \"/lists/\"\n        for list in self.api.lists_account():\n            list[\"_extractor\"] = FanslyListExtractor\n            url = f\"{base}{list['id']}#{list['label']}\"\n            yield Message.Queue, url, list\n\n\nclass FanslyCreatorPostsExtractor(FanslyExtractor):\n    subcategory = \"creator-posts\"\n    pattern = BASE_PATTERN + r\"/([^/?#]+)/posts(?:/wall/(\\d+))?\"\n    example = \"https://fansly.com/CREATOR/posts\"\n\n    def posts_wall(self, account, wall):\n        return self.api.timeline_new(account[\"id\"], wall[\"id\"])\n\n\nclass FanslyCreatorMediaExtractor(FanslyExtractor):\n    subcategory = \"creator-media\"\n    pattern = BASE_PATTERN + r\"/([^/?#]+)/media(?:/wall/(\\d+))?\"\n    example = \"https://fansly.com/CREATOR/media\"\n\n    def posts_wall(self, account, wall):\n        return self.api.mediaoffers_location(account[\"id\"], wall[\"id\"])\n\n\nclass FanslyAPI():\n    ROOT = \"https://apiv3.fansly.com\"\n\n    def __init__(self, extractor):\n        self.extractor = extractor\n        self.headers = {\n            \"fansly-client-ts\": None,\n            \"Origin\"          : extractor.root,\n        }\n\n        if token := extractor.config(\"token\"):\n            self.headers[\"authorization\"] = token\n            self.extractor.log.debug(\n                \"Using authorization 'token' %.5s...\", token)\n        else:\n            self.extractor.log.warning(\"No 'token' provided\")\n\n    def account(self, creator):\n        if creator.startswith(\"id:\"):\n            return self.account_by_id(creator[3:])\n        return self.account_by_username(creator)\n\n    def account_by_username(self, username):\n        endpoint = \"/v1/account\"\n        params = {\"usernames\": username}\n        return self._call(endpoint, params)[0]\n\n    def account_by_id(self, account_id):\n        endpoint = \"/v1/account\"\n        params = {\"ids\": account_id}\n        return self._call(endpoint, params)[0]\n\n    def accounts_by_id(self, account_ids):\n        endpoint = \"/v1/account\"\n        params = {\"ids\": \",\".join(map(str, account_ids))}\n        return self._call(endpoint, params)\n\n    def account_media(self, media_ids):\n        endpoint = \"/v1/account/media\"\n        params = {\"ids\": \",\".join(map(str, media_ids))}\n        return self._call(endpoint, params)\n\n    def lists_account(self):\n        endpoint = \"/v1/lists/account\"\n        params = {\"itemId\": \"\"}\n        return self._call(endpoint, params)\n\n    def lists_itemsnew(self, list_id, sort=\"3\"):\n        endpoint = \"/v1/lists/itemsnew\"\n        params = {\n            \"listId\"  : list_id,\n            \"limit\"   : 50,\n            \"after\"   : None,\n            \"sortMode\": sort,\n        }\n        return self._pagination_list(endpoint, params)\n\n    def mediaoffers_location(self, account_id, wall_id):\n        endpoint = \"/v1/mediaoffers/location\"\n        params = {\n            \"locationId\": wall_id,\n            \"locationType\": \"1002\",\n            \"accountId\": account_id,\n            \"mediaType\": \"\",\n            \"before\": \"\",\n            \"after\" : \"0\",\n            \"limit\" : \"30\",\n            \"offset\": \"0\",\n        }\n        return self._pagination_media(endpoint, params)\n\n    def post(self, post_id):\n        endpoint = \"/v1/post\"\n        params = {\"ids\": post_id}\n        return self._update_posts(self._call(endpoint, params))\n\n    def timeline_home(self, mode=\"0\", list_id=None):\n        endpoint = \"/v1/timeline/home\"\n        params = {\"before\": \"0\", \"after\": \"0\"}\n        if list_id is None:\n            params[\"mode\"] = mode\n        else:\n            params[\"listId\"] = list_id\n        return self._pagination(endpoint, params)\n\n    def timeline_new(self, account_id, wall_id):\n        endpoint = \"/v1/timelinenew/\" + str(account_id)\n        params = {\n            \"before\"       : \"0\",\n            \"after\"        : \"0\",\n            \"wallId\"       : wall_id,\n            \"contentSearch\": \"\",\n        }\n        return self._pagination(endpoint, params)\n\n    def _update_posts(self, response):\n        accounts = {\n            account[\"id\"]: account\n            for account in response[\"accounts\"]\n        }\n        media = {\n            media[\"id\"]: media\n            for media in response[\"accountMedia\"]\n        }\n        bundles = {\n            bundle[\"id\"]: bundle\n            for bundle in response[\"accountMediaBundles\"]\n        }\n\n        posts = response[\"posts\"]\n        for post in posts:\n            try:\n                post[\"account\"] = accounts[post.pop(\"accountId\")]\n            except KeyError:\n                pass\n\n            extra = None\n            attachments = []\n            for attachment in post[\"attachments\"]:\n                try:\n                    cid = attachment[\"contentId\"]\n                except KeyError:\n                    attachments.append(attachment)\n                    continue\n\n                if cid in media:\n                    attachments.append(media[cid])\n                elif cid in bundles:\n                    bundle = bundles[cid][\"bundleContent\"]\n                    bundle.sort(key=lambda c: c[\"pos\"])\n                    for c in bundle:\n                        mid = c[\"accountMediaId\"]\n                        if mid in media:\n                            attachments.append(media[mid])\n                        else:\n                            if extra is None:\n                                post[\"_extra\"] = extra = []\n                            extra.append(mid)\n                else:\n                    self.extractor.log.warning(\n                        \"%s: Unhandled 'contentId' %s\",\n                        post[\"id\"], cid)\n            post[\"attachments\"] = attachments\n\n        return posts\n\n    def _update_media(self, items, response):\n        posts = {\n            post[\"id\"]: post\n            for post in response[\"posts\"]\n        }\n\n        response[\"posts\"] = [\n            posts[item[\"correlationId\"]]\n            for item in items\n        ]\n\n        return self._update_posts(response)\n\n    def _update_items(self, items):\n        ids = [item[\"id\"] for item in items]\n        accounts = {\n            account[\"id\"]: account\n            for account in self.accounts_by_id(ids)\n        }\n        return [accounts[id] for id in ids]\n\n    def _call(self, endpoint, params):\n        url = f\"{self.ROOT}/api{endpoint}\"\n        params[\"ngsw-bypass\"] = \"true\"\n        headers = self.headers.copy()\n        headers[\"fansly-client-ts\"] = str(int(time.time() * 1000))\n\n        data = self.extractor.request_json(\n            url, params=params, headers=headers)\n        return data[\"response\"]\n\n    def _pagination(self, endpoint, params):\n        while True:\n            response = self._call(endpoint, params)\n\n            if not response.get(\"posts\"):\n                return\n            posts = self._update_posts(response)\n            yield from posts\n            params[\"before\"] = min(p[\"id\"] for p in posts)\n\n    def _pagination_list(self, endpoint, params):\n        while True:\n            response = self._call(endpoint, params)\n\n            if not response:\n                return\n            yield from self._update_items(response)\n            params[\"after\"] = response[-1][\"sortId\"]\n\n    def _pagination_media(self, endpoint, params):\n        while True:\n            response = self._call(endpoint, params)\n\n            if not (data := response.get(\"data\")):\n                return\n            yield from self._update_media(data, response[\"aggregationData\"])\n            params[\"before\"] = data[-1][\"id\"]\n"
  },
  {
    "path": "gallery_dl/extractor/fantia.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://fantia.jp/\"\"\"\n\nfrom .common import Extractor, Message\nfrom .. import text, util\n\n\nclass FantiaExtractor(Extractor):\n    \"\"\"Base class for Fantia extractors\"\"\"\n    category = \"fantia\"\n    root = \"https://fantia.jp\"\n    directory_fmt = (\"{category}\", \"{fanclub_id}\")\n    filename_fmt = \"{post_id}_{file_id}.{extension}\"\n    archive_fmt = \"{post_id}_{file_id}\"\n    _warning = True\n\n    def _init(self):\n        self.headers = {\n            \"Accept\" : \"application/json, text/plain, */*\",\n            \"X-Requested-With\": \"XMLHttpRequest\",\n        }\n        self._empty_plan = {\n            \"id\"   : 0,\n            \"price\": 0,\n            \"limit\": 0,\n            \"name\" : \"\",\n            \"description\": \"\",\n            \"thumb\": self.root + \"/images/fallback/plan/thumb_default.png\",\n        }\n        if self._warning:\n            if not self.cookies_check((\"_session_id\",)):\n                self.log.warning(\"no '_session_id' cookie set\")\n            FantiaExtractor._warning = False\n\n    def items(self):\n        for post_id in self.posts():\n            post = self._get_post_data(post_id)\n            post[\"num\"] = 0\n\n            contents = self._get_post_contents(post)\n            post[\"content_count\"] = len(contents)\n            post[\"content_num\"] = 0\n\n            for content in contents:\n                files = self._process_content(post, content)\n                yield Message.Directory, \"\", post\n\n                if content[\"visible_status\"] != \"visible\":\n                    self.log.warning(\n                        \"Unable to download '%s' files from \"\n                        \"%s#post-content-id-%s\", content[\"visible_status\"],\n                        post[\"post_url\"], content[\"id\"])\n\n                for file in files:\n                    post.update(file)\n                    post[\"num\"] += 1\n                    text.nameext_from_url(\n                        post[\"content_filename\"] or file[\"file_url\"], post)\n                    yield Message.Url, file[\"file_url\"], post\n\n            post[\"content_num\"] += 1\n\n    def posts(self):\n        \"\"\"Return post IDs\"\"\"\n\n    def _pagination(self, url, params, needle=\"\"):\n        params[\"page\"] = 1\n\n        while True:\n            page = self.request(url, params=params).text\n            self._csrf_token(page)\n\n            target_id = None\n            for target_id in text.extract_iter(page, needle, '\"'):\n                yield target_id\n\n            if not target_id:\n                return\n            params[\"page\"] += 1\n\n    def _csrf_token(self, page=None):\n        if not page:\n            page = self.request(self.root + \"/\").text\n        self.headers[\"X-CSRF-Token\"] = text.extr(\n            page, 'name=\"csrf-token\" content=\"', '\"')\n\n    def _get_post_data(self, post_id):\n        \"\"\"Fetch and process post data\"\"\"\n        url = self.root+\"/api/v1/posts/\"+post_id\n        resp = self.request_json(url, headers=self.headers)[\"post\"]\n        return {\n            \"post_id\": resp[\"id\"],\n            \"post_url\": self.root + \"/posts/\" + str(resp[\"id\"]),\n            \"post_title\": resp[\"title\"],\n            \"comment\": resp[\"comment\"],\n            \"rating\": resp[\"rating\"],\n            \"posted_at\": resp[\"posted_at\"],\n            \"date\": self.parse_datetime(\n                resp[\"posted_at\"], \"%a, %d %b %Y %H:%M:%S %z\"),\n            \"fanclub_id\": resp[\"fanclub\"][\"id\"],\n            \"fanclub_user_id\": resp[\"fanclub\"][\"user\"][\"id\"],\n            \"fanclub_user_name\": resp[\"fanclub\"][\"user\"][\"name\"],\n            \"fanclub_name\": resp[\"fanclub\"][\"name\"],\n            \"fanclub_url\": self.root+\"/fanclubs/\"+str(resp[\"fanclub\"][\"id\"]),\n            \"tags\": [t[\"name\"] for t in resp[\"tags\"]],\n            \"_data\": resp,\n        }\n\n    def _get_post_contents(self, post):\n        contents = post[\"_data\"][\"post_contents\"]\n\n        try:\n            url = post[\"_data\"][\"thumb\"][\"original\"]\n        except Exception:\n            pass\n        else:\n            contents.insert(0, {\n                \"id\": \"thumb\",\n                \"title\": \"thumb\",\n                \"category\": \"thumb\",\n                \"download_uri\": url,\n                \"visible_status\": \"visible\",\n                \"plan\": None,\n            })\n\n        return contents\n\n    def _process_content(self, post, content):\n        post[\"content_category\"] = content[\"category\"]\n        post[\"content_title\"] = content[\"title\"]\n        post[\"content_filename\"] = content.get(\"filename\") or \"\"\n        post[\"content_id\"] = content[\"id\"]\n        post[\"content_comment\"] = content.get(\"comment\") or \"\"\n        post[\"content_num\"] += 1\n        post[\"plan\"] = content[\"plan\"] or self._empty_plan\n\n        files = []\n\n        if \"post_content_photos\" in content:\n            for photo in content[\"post_content_photos\"]:\n                files.append({\"file_id\" : photo[\"id\"],\n                              \"file_url\": photo[\"url\"][\"original\"]})\n\n        if \"download_uri\" in content:\n            url = content[\"download_uri\"]\n            if url[0] == \"/\":\n                url = self.root + url\n            files.append({\"file_id\" : content[\"id\"],\n                          \"file_url\": url})\n\n        if content[\"category\"] == \"blog\" and \"comment\" in content:\n            comment_json = util.json_loads(content[\"comment\"])\n\n            blog_text = \"\"\n            for op in comment_json.get(\"ops\") or ():\n                insert = op.get(\"insert\")\n                if isinstance(insert, str):\n                    blog_text += insert\n                elif isinstance(insert, dict) and \"fantiaImage\" in insert:\n                    img = insert[\"fantiaImage\"]\n                    files.append({\"file_id\" : img[\"id\"],\n                                  \"file_url\": self.root + img[\"original_url\"]})\n            post[\"blogpost_text\"] = blog_text\n        else:\n            post[\"blogpost_text\"] = \"\"\n\n        return files\n\n\nclass FantiaCreatorExtractor(FantiaExtractor):\n    \"\"\"Extractor for a Fantia creator's works\"\"\"\n    subcategory = \"creator\"\n    pattern = r\"(?:https?://)?(?:www\\.)?fantia\\.jp/fanclubs/(\\d+)\"\n    example = \"https://fantia.jp/fanclubs/12345\"\n\n    def posts(self):\n        url = f\"{self.root}/fanclubs/{self.groups[0]}/posts\"\n        return self._pagination(url, {}, 'class=\"link-block\" href=\"/posts/')\n\n\nclass FantiaPostExtractor(FantiaExtractor):\n    \"\"\"Extractor for media from a single Fantia post\"\"\"\n    subcategory = \"post\"\n    pattern = r\"(?:https?://)?(?:www\\.)?fantia\\.jp/posts/(\\d+)\"\n    example = \"https://fantia.jp/posts/12345\"\n\n    def posts(self):\n        self._csrf_token()\n        return (self.groups[0],)\n\n\nclass FantiaSupportingExtractor(FantiaExtractor):\n    \"\"\"Extractor for free and paid supporting fanclubs for the current user\"\"\"\n    subcategory = \"supporting\"\n    pattern = (r\"(?:https?://)?(?:www\\.)?fantia\\.jp/mypage/users/plans\"\n               r\"(?:\\?type=((?:not_)?free))?\")\n    example = \"https://fantia.jp/mypage/users/plans\"\n\n    def items(self):\n        url = self.root + \"/mypage/users/plans\"\n        base = self.root + \"/fanclubs/\"\n        data = {\"_extractor\": FantiaCreatorExtractor}\n\n        type = self.groups[0]\n        for plan_type in (\"not_free\", \"free\") if type is None else (type,):\n            params = {\"type\": plan_type}\n            for fanclub_id in self._pagination(\n                    url, params, 'class=\"user-avatar\" href=\"/fanclubs/'):\n                yield Message.Queue, base + fanclub_id, data\n"
  },
  {
    "path": "gallery_dl/extractor/fapachi.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://fapachi.com/\"\"\"\n\nfrom .common import Extractor, Message\nfrom .. import text\n\n\nclass FapachiPostExtractor(Extractor):\n    \"\"\"Extractor for individual posts on fapachi.com\"\"\"\n    category = \"fapachi\"\n    subcategory = \"post\"\n    root = \"https://fapachi.com\"\n    directory_fmt = (\"{category}\", \"{user}\")\n    filename_fmt = \"{user}_{id}.{extension}\"\n    archive_fmt = \"{user}_{id}\"\n    pattern = (r\"(?:https?://)?(?:www\\.)?fapachi\\.com\"\n               r\"/(?!search/)([^/?#]+)/media/(\\d+)\")\n    example = \"https://fapachi.com/MODEL/media/12345\"\n\n    def __init__(self, match):\n        Extractor.__init__(self, match)\n        self.user, self.id = match.groups()\n\n    def items(self):\n        data = {\n            \"user\": self.user,\n            \"id\"  : self.id,\n        }\n        page = self.request(f\"{self.root}/{self.user}/media/{self.id}\").text\n        url = self.root + text.extract(\n            page, 'data-src=\"', '\"', page.index('class=\"media-img'))[0]\n        yield Message.Directory, \"\", data\n        yield Message.Url, url, text.nameext_from_url(url, data)\n\n\nclass FapachiUserExtractor(Extractor):\n    \"\"\"Extractor for all posts from a fapachi user\"\"\"\n    category = \"fapachi\"\n    subcategory = \"user\"\n    root = \"https://fapachi.com\"\n    pattern = (r\"(?:https?://)?(?:www\\.)?fapachi\\.com\"\n               r\"/(?!search(?:/|$))([^/?#]+)(?:/page/(\\d+))?$\")\n    example = \"https://fapachi.com/MODEL\"\n\n    def __init__(self, match):\n        Extractor.__init__(self, match)\n        self.user = match[1]\n        self.num = text.parse_int(match[2], 1)\n\n    def items(self):\n        data = {\"_extractor\": FapachiPostExtractor}\n        while True:\n            url = f\"{self.root}/{self.user}/page/{self.num}\"\n            page = self.request(url).text\n            for post in text.extract_iter(page, 'model-media-prew\">', \">\"):\n                if path := text.extr(post, '<a href=\"', '\"'):\n                    yield Message.Queue, self.root + path, data\n\n            if '\">Next page</a>' not in page:\n                return\n            self.num += 1\n"
  },
  {
    "path": "gallery_dl/extractor/fapello.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://fapello.com/\"\"\"\n\nfrom .common import Extractor, Message\nfrom .. import text\n\n\nBASE_PATTERN = r\"(?:https?://)?(?:www\\.)?fapello\\.(?:com|su)\"\n\n\nclass FapelloPostExtractor(Extractor):\n    \"\"\"Extractor for individual posts on fapello.com\"\"\"\n    category = \"fapello\"\n    subcategory = \"post\"\n    directory_fmt = (\"{category}\", \"{model}\")\n    filename_fmt = \"{model}_{id}.{extension}\"\n    archive_fmt = \"{type}_{model}_{id}\"\n    pattern = BASE_PATTERN + r\"/(?!search/|popular_videos/)([^/?#]+)/(\\d+)\"\n    example = \"https://fapello.com/MODEL/12345/\"\n\n    def __init__(self, match):\n        Extractor.__init__(self, match)\n        self.root = text.root_from_url(match[0])\n        self.model, self.id = match.groups()\n\n    def items(self):\n        url = f\"{self.root}/{self.model}/{self.id}/\"\n        page = text.extr(\n            self.request(url, allow_redirects=False).text,\n            'class=\"uk-align-center\"', \"</div>\", None)\n        if page is None:\n            raise self.exc.NotFoundError(\"post\")\n\n        data = {\n            \"model\": self.model,\n            \"id\"   : text.parse_int(self.id),\n            \"type\" : \"video\" if 'type=\"video' in page else \"photo\",\n            \"thumbnail\": text.extr(page, 'poster=\"', '\"'),\n        }\n        url = text.extr(page, 'src=\"', '\"').replace(\n            \".md\", \"\").replace(\".th\", \"\")\n        yield Message.Directory, \"\", data\n        yield Message.Url, url, text.nameext_from_url(url, data)\n\n\nclass FapelloModelExtractor(Extractor):\n    \"\"\"Extractor for all posts from a fapello model\"\"\"\n    category = \"fapello\"\n    subcategory = \"model\"\n    pattern = (BASE_PATTERN + r\"/(?!top-(?:likes|followers)|popular_videos\"\n               r\"|videos|trending|search/?$)\"\n               r\"([^/?#]+)/?$\")\n    example = \"https://fapello.com/model/\"\n\n    def __init__(self, match):\n        Extractor.__init__(self, match)\n        self.root = text.root_from_url(match[0])\n        self.model = match[1]\n\n    def items(self):\n        num = 1\n        data = {\"_extractor\": FapelloPostExtractor}\n        while True:\n            url = f\"{self.root}/ajax/model/{self.model}/page-{num}/\"\n            page = self.request(url).text\n            if not page:\n                return\n\n            url = None\n            for url in text.extract_iter(page, '<a href=\"', '\"'):\n                if url == \"javascript:void(0);\":\n                    continue\n                yield Message.Queue, url, data\n            if url is None:\n                return\n            num += 1\n\n\nclass FapelloPathExtractor(Extractor):\n    \"\"\"Extractor for models and posts from fapello.com paths\"\"\"\n    category = \"fapello\"\n    subcategory = \"path\"\n    pattern = (BASE_PATTERN +\n               r\"/(?!search/?$)(top-(?:likes|followers)|videos|trending\"\n               r\"|popular_videos/[^/?#]+)/?$\")\n    example = \"https://fapello.com/trending/\"\n\n    def __init__(self, match):\n        Extractor.__init__(self, match)\n        self.root = text.root_from_url(match[0])\n        self.path = match[1]\n\n    def items(self):\n        num = 1\n        if self.path in {\"top-likes\", \"top-followers\"}:\n            data = {\"_extractor\": FapelloModelExtractor}\n        else:\n            data = {\"_extractor\": FapelloPostExtractor}\n\n        if \"fapello.su\" in self.root:\n            self.path = self.path.replace(\"-\", \"/\")\n            if self.path == \"trending\":\n                data = {\"_extractor\": FapelloModelExtractor}\n\n        while True:\n            url = f\"{self.root}/ajax/{self.path}/page-{num}/\"\n            page = self.request(url).text\n            if not page:\n                return\n\n            for item in text.extract_iter(\n                    page, 'uk-transition-toggle\">', \"</a>\"):\n                yield Message.Queue, text.extr(item, '<a href=\"', '\"'), data\n            num += 1\n"
  },
  {
    "path": "gallery_dl/extractor/fikfap.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2025 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://fikfap.com/\"\"\"\n\nfrom .common import Extractor, Message\nfrom .. import text\n\nBASE_PATTERN = r\"(?:https?://)?(?:www\\.)?fikfap\\.com\"\n\n\nclass FikfapExtractor(Extractor):\n    \"\"\"Base class for fikfap extractors\"\"\"\n    category = \"fikfap\"\n    root = \"https://fikfap.com\"\n    root_api = \"https://api.fikfap.com\"\n    directory_fmt = (\"{category}\", \"{author[username]}\")\n    filename_fmt = \"{postId} {label[:240]}.{extension}\"\n    archive_fmt = \"{postId}\"\n\n    def items(self):\n        headers = {\n            \"Referer\"       : self.root + \"/\",\n            \"Origin\"        : self.root,\n            \"Sec-Fetch-Dest\": \"empty\",\n            \"Sec-Fetch-Mode\": \"cors\",\n            \"Sec-Fetch-Site\": \"cross-site\",\n        }\n\n        for post in self.posts():\n            if url := post.get(\"videoFileOriginalUrl\"):\n                post[\"extension\"] = text.ext_from_url(url)\n            elif url := post.get(\"videoStreamUrl\"):\n                url = \"ytdl:\" + url\n                post[\"extension\"] = \"mp4\"\n                post[\"_ytdl_manifest\"] = \"hls\"\n                post[\"_ytdl_manifest_headers\"] = headers\n            else:\n                self.log.warning(\"%s: No video available\", post[\"postId\"])\n                continue\n\n            post[\"date\"] = self.parse_datetime_iso(post[\"createdAt\"])\n            post[\"date_updated\"] = self.parse_datetime_iso(post[\"updatedAt\"])\n            post[\"tags\"] = [t[\"label\"] for t in post[\"hashtags\"]]\n            post[\"filename\"] = post[\"label\"]\n\n            yield Message.Directory, \"\", post\n            yield Message.Url, url, post\n\n    def request_api(self, url, params):\n        return self.request_json(url, params=params, headers={\n            \"Referer\"       : self.root + \"/\",\n            \"Authorization-Anonymous\": \"2527cc30-c3c5-41be-b8bb-104b6ea7a206\",\n            \"IsLoggedIn\"    : \"false\",\n            \"IsPWA\"         : \"false\",\n            \"Origin\"        : self.root,\n            \"Sec-Fetch-Dest\": \"empty\",\n            \"Sec-Fetch-Mode\": \"cors\",\n            \"Sec-Fetch-Site\": \"same-site\",\n        })\n\n\nclass FikfapPostExtractor(FikfapExtractor):\n    subcategory = \"post\"\n    pattern = BASE_PATTERN + r\"/(?:user/[^/?#]+/)?post/(\\d+)\"\n    example = \"https://fikfap.com/user/USER/post/12345\"\n\n    def posts(self):\n        pid = self.groups[0]\n\n        url = f\"{self.root_api}/posts/{pid}\"\n        post = self.request_api(url, None)\n\n        if post[\"postId\"] == int(pid):\n            return (post,)\n        raise self.exc.NotFoundError(\"post\")\n\n\nclass FikfapUserExtractor(FikfapExtractor):\n    subcategory = \"user\"\n    pattern = BASE_PATTERN + r\"/user/([^/?#]+)\"\n    example = \"https://fikfap.com/user/USER\"\n\n    def posts(self):\n        user = self.groups[0]\n\n        url = f\"{self.root_api}/profile/username/{user}/posts\"\n        params = {\"amount\": \"21\"}\n\n        while True:\n            data = self.request_api(url, params)\n\n            yield from data\n\n            if len(data) < 21:\n                return\n            params[\"afterId\"] = data[-1][\"postId\"]\n\n\nclass FikfapHashtagExtractor(FikfapExtractor):\n    subcategory = \"hashtag\"\n    directory_fmt = (\"{category}\", \"{hashtag}\")\n    pattern = BASE_PATTERN + r\"/hash/([^/?#]+)\"\n    example = \"https://fikfap.com/hash/HASH\"\n\n    def posts(self):\n        self.kwdict[\"hashtag\"] = hashtag = self.groups[0]\n\n        url = f\"{self.root_api}/hashtags/label/{hashtag}/posts\"\n        params = {\"amount\": \"21\"}\n\n        while True:\n            data = self.request_api(url, params)\n\n            yield from data\n\n            if len(data) < 21:\n                return\n            params[\"afterId\"] = data[-1][\"postId\"]\n"
  },
  {
    "path": "gallery_dl/extractor/filester.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2026 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://filester.me/\"\"\"\n\nfrom .common import Extractor, Message\nfrom .. import text\nimport random\n\nBASE_PATTERN = r\"(?:https?://)?(?:www\\.)?filester\\.me\"\n\n\nclass FilesterExtractor(Extractor):\n    \"\"\"Base class for filester extractors\"\"\"\n    category = \"filester\"\n    archive_fmt = \"{id}\"\n    root = \"https://filester.me\"\n\n    def _download_url(self, slug):\n        url = self.root + \"/api/public/download\"\n        data = self.request_json(url, method=\"POST\", json={\"file_slug\": slug})\n        return (f\"https://cache{random.choice((1, 6))}.filester.me\"\n                f\"{data['download_url']}?download=true\")\n\n\nclass FilesterFileExtractor(FilesterExtractor):\n    subcategory = \"file\"\n    filename_fmt = \"{id} {filename}.{extension}\"\n    pattern = BASE_PATTERN + r\"/d/([^/?#]+)\"\n    example = \"https://filester.me/d/ID\"\n\n    def items(self):\n        file_slug = self.groups[0]\n\n        url = f\"{self.root}/d/{file_slug}\"\n        page = self.request(url).text\n        extr = text.extract_from(page)\n\n        name = text.unquote(text.unescape(extr(\n            'property=\"og:title\" content=\"', '\"')))\n        file = text.nameext_from_name(name, {\n            \"id\"  : file_slug,\n            \"uuid\": extr('>UUID</span', '</span').rpartition(\">\")[2],\n            \"mime\": extr('>Type</span', '</span').rpartition(\">\")[2],\n            \"date\": self.parse_datetime_iso(extr(\n                '>Uploaded</span', '</span').rpartition(\">\")[2]),\n            \"size\": extr('>Size</span', '</span').rpartition(\">\")[2],\n            \"hash\": extr('>SHA-256</span', '</span').rpartition(\">\")[2],\n        })\n\n        yield Message.Directory, \"\", file\n        yield Message.Url, self._download_url(file_slug), file\n\n\nclass FilesterFolderExtractor(FilesterExtractor):\n    subcategory = \"folder\"\n    directory_fmt = (\"{category}\", \"{folder_name} ({folder_id})\")\n    filename_fmt = \"{num:>03} {id} {filename}.{extension}\"\n    pattern = BASE_PATTERN + r\"/f/([^/?#]+)\"\n    example = \"https://filester.me/f/ID\"\n\n    def items(self):\n        folder_slug = self.groups[0]\n        num = None\n\n        url = f\"{self.root}/f/{folder_slug}\"\n        params = {\"page\": 1}\n        while True:\n            page = self.request(url, params=params).text\n\n            if num is None:\n                extr = text.extract_from(page)\n                kw = self.kwdict\n                kw[\"folder_id\"] = folder_slug\n                kw[\"folder_name\"] = text.unescape(extr(\n                    'property=\"og:title\" content=\"', '\"'))\n                kw[\"folder_uuid\"] = extr('/t/', '\"')\n                kw[\"folder_date\"] = self.parse_datetime(extr(\n                    \"<span>Created \", \"<\"), \"%b %d, %Y\")\n                kw[\"count\"] = num = text.parse_int(extr(\"<span>\", \" \"))\n                kw[\"folder_size\"] = text.parse_bytes(extr(\"<span>\", \"B<\"))\n                del extr\n                yield Message.Directory, \"\", {}\n\n            for html in text.extract_iter(\n                    page, 'class=\"file-item\"', \"</button>\"):\n                extr = text.extract_from(html)\n                name = text.unescape(extr('data-name=\"', '\"'))\n                file = text.nameext_from_name(name, {\n                    \"size\": extr('data-size=\"', '\"'),\n                    \"date\": self.parse_datetime_iso(extr('data-date=\"', '\"')),\n                    \"id\"  : extr(\"href='/d/\", \"'\"),\n                    \"uuid\": extr('src=\"/t/', '\"'),\n                    \"num\" : num,\n                })\n                num -= 1\n                yield Message.Url, self._download_url(file[\"id\"]), file\n\n            if \">→</a>\" not in page:\n                break\n            params[\"page\"] += 1\n"
  },
  {
    "path": "gallery_dl/extractor/fitnakedgirls.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://fitnakedgirls.com/\"\"\"\n\nfrom .common import GalleryExtractor, Extractor, Message\nfrom .. import text\n\nBASE_PATTERN = r\"(?:https?://)?(?:www\\.)?fitnakedgirls\\.com\"\n\n\nclass FitnakedgirlsExtractor(Extractor):\n    \"\"\"Base class for fitnakedgirls extractors\"\"\"\n    category = \"fitnakedgirls\"\n    root = \"https://fitnakedgirls.com\"\n\n    def items(self):\n        data = {\"_extractor\": FitnakedgirlsGalleryExtractor}\n        for url in self.galleries():\n            yield Message.Queue, url, data\n\n    def _pagination(self, base):\n        url = base\n        pnum = 1\n\n        while True:\n            page = self.request(url).text\n\n            for post in text.extract_iter(\n                    page, 'class=\"entry-body', \"</a>\"):\n                yield text.extr(post, 'href=\"', '\"')\n\n            pnum += 1\n            url = f\"{base}page/{pnum}/\"\n            if f'href=\"{url}\"' not in page:\n                return\n\n    def _extract_title(self, extr, sep=\" - \"):\n        title = text.unescape(extr(\"<title>\", \"<\"))\n        if sep in title:\n            title = title.rpartition(sep)[0]\n        return title.strip()\n\n\nclass FitnakedgirlsGalleryExtractor(GalleryExtractor, FitnakedgirlsExtractor):\n    \"\"\"Extractor for fitnakedgirls galleries\"\"\"\n    directory_fmt = (\"{category}\", \"{title}\")\n    filename_fmt = \"{filename}.{extension}\"\n    archive_fmt = \"{gallery_id}_{filename}\"\n    pattern = BASE_PATTERN + r\"/photos/gallery/([\\w-]+)/?$\"\n    example = \"https://fitnakedgirls.com/photos/gallery/MODEL-nude/\"\n\n    def __init__(self, match):\n        url = f\"{self.root}/photos/gallery/{match[1]}/\"\n        GalleryExtractor.__init__(self, match, url)\n\n    def metadata(self, page):\n        extr = text.extract_from(page)\n        title = self._extract_title(extr)\n\n        # Strip common patterns to get cleaner model name\n        for pattern in (\" Nudes\", \" Nude\", \" nudes\", \" nude\"):\n            if pattern in title:\n                title = title.partition(pattern)[0]\n                break\n\n        return {\n            \"gallery_id\"  : text.parse_int(extr('data-post-id=\"', '\"')),\n            \"gallery_slug\": self.groups[0],\n            \"model\": title,\n            \"title\": title,\n            \"date\" : self.parse_datetime_iso(extr(\n                'article:published_time\" content=\"', '\"')),\n        }\n\n    def images(self, page):\n        results = []\n\n        content = text.extr(\n            page, 'itemprop=\"articleBody\"', '<!-- .entry-content -->') or page\n\n        # Extract videos from wp-block-video figures\n        for figure in text.extract_iter(\n                content, '<figure class=\"wp-block-video\">', '</figure>'):\n            if src := text.extr(figure, 'src=\"', '\"'):\n                if \"/wp-content/uploads/\" in src:\n                    results.append((src, None))\n\n        # Extract images from wp-block-image figures (newer template)\n        for figure in text.extract_iter(\n                content, '<figure class=\"wp-block-image', '</figure>'):\n            if src := text.extr(figure, 'data-src=\"', '\"'):\n                if \"/wp-content/uploads/\" in src:\n                    results.append((src, None))\n\n        # Fallback: Extract images with size-large class (older template)\n        if not results:\n            for img in text.extract_iter(content, \"<img \", \">\"):\n                if \"size-large\" in img:\n                    if src := text.extr(img, 'data-src=\"', '\"'):\n                        if \"/wp-content/uploads/\" in src:\n                            results.append((src, None))\n\n        return results\n\n\nclass FitnakedgirlsCategoryExtractor(FitnakedgirlsExtractor):\n    \"\"\"Extractor for fitnakedgirls category pages\"\"\"\n    subcategory = \"category\"\n    pattern = BASE_PATTERN + r\"/photos/gallery/category/([\\w-]+)\"\n    example = \"https://fitnakedgirls.com/photos/gallery/category/CATEGORY/\"\n\n    def galleries(self):\n        base = f\"{self.root}/photos/gallery/category/{self.groups[0]}/\"\n        return self._pagination(base)\n\n\nclass FitnakedgirlsTagExtractor(FitnakedgirlsExtractor):\n    \"\"\"Extractor for fitnakedgirls tag pages\"\"\"\n    subcategory = \"tag\"\n    pattern = BASE_PATTERN + r\"/photos/gallery/tag/([\\w-]+)\"\n    example = \"https://fitnakedgirls.com/photos/gallery/tag/TAG/\"\n\n    def galleries(self):\n        base = f\"{self.root}/photos/gallery/tag/{self.groups[0]}/\"\n        return self._pagination(base)\n\n\nclass FitnakedgirlsVideoExtractor(FitnakedgirlsExtractor):\n    \"\"\"Extractor for fitnakedgirls video posts\"\"\"\n    subcategory = \"video\"\n    directory_fmt = (\"{category}\", \"{title}\")\n    filename_fmt = \"{filename}.{extension}\"\n    archive_fmt = \"{video_id}_{filename}\"\n    pattern = BASE_PATTERN + r\"/videos/(\\d+)/(\\d+)/([\\w-]+)\"\n    example = \"https://fitnakedgirls.com/videos/2025/08/VIDEO-TITLE/\"\n\n    def items(self):\n        year, month, slug = self.groups\n        url = f\"{self.root}/videos/{year}/{month}/{slug}/\"\n        page = self.request(url).text\n\n        extr = text.extract_from(page)\n        data = {\n            \"slug\"    : slug,\n            \"title\"   : self._extract_title(extr, \" | \"),\n            \"video_id\": text.parse_int(extr('data-post-id=\"', '\"')),\n            \"date\"    : self.parse_datetime_iso(\n                extr('article:published_time\" content=\"', '\"')),\n        }\n\n        yield Message.Directory, \"\", data\n\n        content = text.extr(\n            page, 'itemprop=\"articleBody\"', '<!-- .entry-content -->') or page\n        for video in text.extract_iter(content, \"<video \", \"</video>\"):\n            if src := text.extr(video, 'src=\"', '\"'):\n                if \"/wp-content/uploads/\" in src:\n                    yield Message.Url, src, text.nameext_from_url(src, data)\n\n\nclass FitnakedgirlsBlogExtractor(FitnakedgirlsExtractor):\n    \"\"\"Extractor for fitnakedgirls blog posts\"\"\"\n    subcategory = \"blog\"\n    directory_fmt = (\"{category}\", \"{title}\")\n    filename_fmt = \"{filename}.{extension}\"\n    archive_fmt = \"{post_id}_{filename}\"\n    pattern = BASE_PATTERN + r\"/fitblog/([\\w-]+)\"\n    example = \"https://fitnakedgirls.com/fitblog/MODEL-NAME/\"\n\n    def items(self):\n        slug = self.groups[0]\n        url = f\"{self.root}/fitblog/{slug}/\"\n        page = self.request(url).text\n\n        extr = text.extract_from(page)\n        data = {\n            \"slug\"   : slug,\n            \"title\"  : self._extract_title(extr),\n            \"post_id\": text.parse_int(extr('data-post-id=\"', '\"')),\n            \"date\"   : self.parse_datetime_iso(\n                extr('article:published_time\" content=\"', '\"')),\n        }\n\n        yield Message.Directory, \"\", data\n\n        # Extract images from wp-block-image figures\n        content = text.extr(\n            page, 'itemprop=\"articleBody\"', '<!-- .entry-content -->') or page\n        for figure in text.extract_iter(\n                content, '<figure class=\"wp-block-image', '</figure>'):\n            # Try srcset first for highest resolution\n            if srcset := text.extr(figure, 'srcset=\"', '\"'):\n                # Get the last (largest) image from srcset\n                urls = srcset.split(\", \")\n                if urls:\n                    src = urls[-1].partition(\" \")[0]\n                    if \"/wp-content/uploads/\" in src:\n                        yield Message.Url, src, text.nameext_from_url(\n                            src, data)\n                        continue\n            # Fallback to src\n            if src := text.extr(figure, 'src=\"', '\"'):\n                if \"/wp-content/uploads/\" in src:\n                    yield Message.Url, src, text.nameext_from_url(src, data)\n"
  },
  {
    "path": "gallery_dl/extractor/flickr.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2017-2026 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://www.flickr.com/\"\"\"\n\nfrom .common import Extractor, Message\nfrom .. import text, oauth, util\n\nBASE_PATTERN = r\"(?:https?://)?(?:www\\.|secure\\.|m\\.)?flickr\\.com\"\n\n\nclass FlickrExtractor(Extractor):\n    \"\"\"Base class for flickr extractors\"\"\"\n    category = \"flickr\"\n    root = \"https://www.flickr.com\"\n    filename_fmt = \"{category}_{id}.{extension}\"\n    directory_fmt = (\"{category}\", \"{user[username]}\")\n    archive_fmt = \"{id}\"\n    request_interval = (1.0, 2.0)\n    request_interval_min = 0.5\n\n    def _init(self):\n        self.user = None\n        self.item_id = self.groups[0]\n\n    def items(self):\n        self.api = FlickrAPI(self)\n\n        data = self.metadata()\n        extract = self.api._extract_format\n        for photo in self.photos():\n            try:\n                photo = extract(photo)\n            except Exception as exc:\n                self.log.warning(\n                    \"Skipping photo %s (%s: %s)\",\n                    photo[\"id\"], exc.__class__.__name__, exc)\n                self.log.traceback(exc)\n            else:\n                photo.update(data)\n                url = self._file_url(photo)\n                yield Message.Directory, \"\", photo\n                yield Message.Url, url, text.nameext_from_url(url, photo)\n\n    def metadata(self):\n        \"\"\"Return general metadata\"\"\"\n        self.user = self.api.urls_lookupUser(self.item_id)\n        if self.config(\"profile\", False):\n            self.user.update(self.api.people_getInfo(self.user[\"nsid\"]))\n        return {\"user\": self.user}\n\n    def photos(self):\n        \"\"\"Return an iterable with all relevant photo objects\"\"\"\n\n    def _file_url(self, photo):\n        url = photo[\"url\"]\n\n        if \"/video/\" in url:\n            return url\n\n        path, _, ext = url.rpartition(\".\")\n        return path + \"_d.\" + ext\n\n\nclass FlickrImageExtractor(FlickrExtractor):\n    \"\"\"Extractor for individual images from flickr.com\"\"\"\n    subcategory = \"image\"\n    pattern = (r\"(?:https?://)?(?:\"\n               r\"(?:(?:www\\.|secure\\.|m\\.)?flickr\\.com/photos/[^/?#]+/\"\n               r\"|[\\w-]+\\.static\\.?flickr\\.com/(?:\\d+/)+)(\\d+)\"\n               r\"|flic\\.kr/p/([A-Za-z1-9]+))\")\n    example = \"https://www.flickr.com/photos/USER/12345\"\n\n    def items(self):\n        self.api = FlickrAPI(self)\n\n        item_id, enc_id = self.groups\n        if enc_id is not None:\n            alphabet = (\"123456789abcdefghijkmnopqrstu\"\n                        \"vwxyzABCDEFGHJKLMNPQRSTUVWXYZ\")\n            item_id = util.bdecode(enc_id, alphabet)\n\n        photo = self.api.photos_getInfo(item_id)\n\n        self.api._extract_metadata(photo, False)\n        if photo[\"media\"] == \"video\" and self.api.videos:\n            self.api._extract_video(photo)\n        else:\n            self.api._extract_photo(photo)\n\n        if self.config(\"profile\", False):\n            photo[\"user\"] = self.api.people_getInfo(photo[\"owner\"][\"nsid\"])\n        else:\n            photo[\"user\"] = photo[\"owner\"]\n\n        photo[\"title\"] = photo[\"title\"][\"_content\"]\n        photo[\"comments\"] = text.parse_int(photo[\"comments\"][\"_content\"])\n        photo[\"description\"] = photo[\"description\"][\"_content\"]\n        photo[\"tags\"] = [t[\"raw\"] for t in photo[\"tags\"][\"tag\"]]\n        photo[\"date\"] = self.parse_timestamp(photo[\"dateuploaded\"])\n        photo[\"views\"] = text.parse_int(photo[\"views\"])\n        photo[\"id\"] = text.parse_int(photo[\"id\"])\n\n        if \"location\" in photo:\n            location = photo[\"location\"]\n            for key, value in location.items():\n                if isinstance(value, dict):\n                    location[key] = value[\"_content\"]\n\n        url = self._file_url(photo)\n        yield Message.Directory, \"\", photo\n        yield Message.Url, url, text.nameext_from_url(url, photo)\n\n\nclass FlickrAlbumExtractor(FlickrExtractor):\n    \"\"\"Extractor for photo albums from flickr.com\"\"\"\n    subcategory = \"album\"\n    directory_fmt = (\"{category}\", \"{user[username]}\",\n                     \"Albums\", \"{album[id]} {album[title]}\")\n    archive_fmt = \"a_{album[id]}_{id}\"\n    pattern = BASE_PATTERN + r\"/photos/([^/?#]+)/(?:album|set)s(?:/(\\d+))?\"\n    example = \"https://www.flickr.com/photos/USER/albums/12345\"\n\n    def items(self):\n        self.album_id = self.groups[1]\n        if self.album_id:\n            return FlickrExtractor.items(self)\n        return self._album_items()\n\n    def _album_items(self):\n        self.api = FlickrAPI(self)\n\n        data = FlickrExtractor.metadata(self)\n        data[\"_extractor\"] = FlickrAlbumExtractor\n\n        for album in self.api.photosets_getList(self.user[\"nsid\"]):\n            self.api._clean_info(album).update(data)\n            url = (f\"https://www.flickr.com/photos/{self.user['path_alias']}\"\n                   f\"/albums/{album['id']}\")\n            yield Message.Queue, url, album\n\n    def metadata(self):\n        data = FlickrExtractor.metadata(self)\n        try:\n            data[\"album\"] = self.api.photosets_getInfo(\n                self.album_id, self.user[\"nsid\"])\n        except Exception:\n            data[\"album\"] = {}\n            self.log.warning(\"%s: Unable to retrieve album metadata\",\n                             self.album_id)\n        return data\n\n    def photos(self):\n        return self.api.photosets_getPhotos(self.album_id)\n\n\nclass FlickrGalleryExtractor(FlickrExtractor):\n    \"\"\"Extractor for photo galleries from flickr.com\"\"\"\n    subcategory = \"gallery\"\n    directory_fmt = (\"{category}\", \"{user[username]}\",\n                     \"Galleries\", \"{gallery[gallery_id]} {gallery[title]}\")\n    archive_fmt = \"g_{gallery[id]}_{id}\"\n    pattern = BASE_PATTERN + r\"/photos/([^/?#]+)/galleries/(\\d+)\"\n    example = \"https://www.flickr.com/photos/USER/galleries/12345/\"\n\n    def metadata(self):\n        data = FlickrExtractor.metadata(self)\n        self.gallery_id = self.groups[1]\n        data[\"gallery\"] = self.api.galleries_getInfo(self.gallery_id)\n        return data\n\n    def photos(self):\n        return self.api.galleries_getPhotos(self.gallery_id)\n\n\nclass FlickrGroupExtractor(FlickrExtractor):\n    \"\"\"Extractor for group pools from flickr.com\"\"\"\n    subcategory = \"group\"\n    directory_fmt = (\"{category}\", \"Groups\", \"{group[groupname]}\")\n    archive_fmt = \"G_{group[nsid]}_{id}\"\n    pattern = BASE_PATTERN + r\"/groups/([^/?#]+)\"\n    example = \"https://www.flickr.com/groups/NAME/\"\n\n    def metadata(self):\n        self.group = self.api.urls_lookupGroup(self.item_id)\n        return {\"group\": self.group}\n\n    def photos(self):\n        return self.api.groups_pools_getPhotos(self.group[\"nsid\"])\n\n\nclass FlickrUserExtractor(FlickrExtractor):\n    \"\"\"Extractor for the photostream of a flickr user\"\"\"\n    subcategory = \"user\"\n    archive_fmt = \"u_{user[nsid]}_{id}\"\n    pattern = BASE_PATTERN + r\"/photos/([^/?#]+)/?$\"\n    example = \"https://www.flickr.com/photos/USER/\"\n\n    def photos(self):\n        return self.api.people_getPhotos(self.user[\"nsid\"])\n\n\nclass FlickrFavoriteExtractor(FlickrExtractor):\n    \"\"\"Extractor for favorite photos of a flickr user\"\"\"\n    subcategory = \"favorite\"\n    directory_fmt = (\"{category}\", \"{user[username]}\", \"Favorites\")\n    archive_fmt = \"f_{user[nsid]}_{id}\"\n    pattern = BASE_PATTERN + r\"/photos/([^/?#]+)/favorites\"\n    example = \"https://www.flickr.com/photos/USER/favorites\"\n\n    def photos(self):\n        return self.api.favorites_getList(self.user[\"nsid\"])\n\n\nclass FlickrSearchExtractor(FlickrExtractor):\n    \"\"\"Extractor for flickr photos based on search results\"\"\"\n    subcategory = \"search\"\n    directory_fmt = (\"{category}\", \"Search\", \"{search[text]}\")\n    archive_fmt = \"s_{search}_{id}\"\n    pattern = BASE_PATTERN + r\"/search/?\\?([^#]+)\"\n    example = \"https://flickr.com/search/?text=QUERY\"\n\n    def metadata(self):\n        self.search = text.parse_query(self.groups[0])\n        if \"text\" not in self.search:\n            self.search[\"text\"] = \"\"\n        return {\"search\": self.search}\n\n    def photos(self):\n        return self.api.photos_search(self.search)\n\n\nclass FlickrAPI(oauth.OAuth1API):\n    \"\"\"Minimal interface for the flickr API\n\n    https://www.flickr.com/services/api/\n    \"\"\"\n\n    API_URL = \"https://api.flickr.com/services/rest/\"\n    #  API_KEY = \"\"\n    API_SECRET = \"\"\n    FORMATS = [\n        (\"o\" , \"Original\"    , None),\n        (\"6k\", \"X-Large 6K\"  , 6144),\n        (\"5k\", \"X-Large 5K\"  , 5120),\n        (\"4k\", \"X-Large 4K\"  , 4096),\n        (\"3k\", \"X-Large 3K\"  , 3072),\n        (\"k\" , \"Large 2048\"  , 2048),\n        (\"h\" , \"Large 1600\"  , 1600),\n        (\"l\" , \"Large\"       , 1024),\n        (\"c\" , \"Medium 800\"  , 800),\n        (\"z\" , \"Medium 640\"  , 640),\n        (\"m\" , \"Medium\"      , 500),\n        (\"n\" , \"Small 320\"   , 320),\n        (\"s\" , \"Small\"       , 240),\n        (\"q\" , \"Large Square\", 150),\n        (\"t\" , \"Thumbnail\"   , 100),\n        (\"s\" , \"Square\"      , 75),\n    ]\n    VIDEO_FORMATS = {\n        \"orig\"       : 9,\n        \"1080p\"      : 8,\n        \"720p\"       : 7,\n        \"360p\"       : 6,\n        \"288p\"       : 5,\n        \"700\"        : 4,\n        \"300\"        : 3,\n        \"100\"        : 2,\n        \"appletv\"    : 1,\n        \"iphone_wifi\": 0,\n    }\n    LICENSES = {\n        \"0\": \"All Rights Reserved\",\n        \"1\": \"Attribution-NonCommercial-ShareAlike License\",\n        \"2\": \"Attribution-NonCommercial License\",\n        \"3\": \"Attribution-NonCommercial-NoDerivs License\",\n        \"4\": \"Attribution License\",\n        \"5\": \"Attribution-ShareAlike License\",\n        \"6\": \"Attribution-NoDerivs License\",\n        \"7\": \"No known copyright restrictions\",\n        \"8\": \"United States Government Work\",\n        \"9\": \"Public Domain Dedication (CC0)\",\n        \"10\": \"Public Domain Mark\",\n    }\n\n    @property\n    def API_KEY(self):\n        return self.extractor.cache(self._extract_apikey, _key=None, _exp=3600)\n\n    def __init__(self, extractor):\n        oauth.OAuth1API.__init__(self, extractor)\n\n        self.videos = extractor.config(\"videos\", True)\n        self.meta_exif = extractor.config(\"exif\", False)\n        self.meta_info = extractor.config(\"info\", False)\n        self.meta_contexts = extractor.config(\"contexts\", False)\n\n        self.maxsize = extractor.config(\"size-max\")\n        if isinstance(self.maxsize, str):\n            for fmt, fmtname, fmtwidth in self.FORMATS:\n                if self.maxsize == fmt or self.maxsize == fmtname:\n                    self.maxsize = fmtwidth\n                    break\n            else:\n                self.maxsize = None\n                extractor.log.warning(\n                    \"Could not match '%s' to any format\", self.maxsize)\n        if self.maxsize:\n            self.formats = [fmt for fmt in self.FORMATS\n                            if not fmt[2] or fmt[2] <= self.maxsize]\n        else:\n            self.formats = self.FORMATS\n        self.formats = self.formats[:8]\n\n    def favorites_getList(self, user_id):\n        \"\"\"Returns a list of the user's favorite photos.\"\"\"\n        params = {\"user_id\": user_id}\n        return self._pagination(\"favorites.getList\", params)\n\n    def galleries_getInfo(self, gallery_id):\n        \"\"\"Gets information about a gallery.\"\"\"\n        params = {\"gallery_id\": gallery_id}\n        gallery = self._call(\"galleries.getInfo\", params)[\"gallery\"]\n        return self._clean_info(gallery)\n\n    def galleries_getPhotos(self, gallery_id):\n        \"\"\"Return the list of photos for a gallery.\"\"\"\n        params = {\"gallery_id\": gallery_id}\n        return self._pagination(\"galleries.getPhotos\", params)\n\n    def groups_pools_getPhotos(self, group_id):\n        \"\"\"Returns a list of pool photos for a given group.\"\"\"\n        params = {\"group_id\": group_id}\n        return self._pagination(\"groups.pools.getPhotos\", params)\n\n    def people_getInfo(self, user_id):\n        \"\"\"Get information about a user.\"\"\"\n        params = {\"user_id\": user_id}\n        user = self._call(\"people.getInfo\", params)\n\n        try:\n            user = user[\"person\"]\n            for key in (\"description\", \"username\", \"realname\", \"location\",\n                        \"profileurl\", \"photosurl\", \"mobileurl\"):\n                if isinstance(user.get(key), dict):\n                    user[key] = user[key][\"_content\"]\n            photos = user[\"photos\"]\n            for key in (\"count\", \"firstdate\", \"firstdatetaken\"):\n                if isinstance(photos.get(key), dict):\n                    photos[key] = photos[key][\"_content\"]\n        except Exception:\n            pass\n\n        return user\n\n    def people_getPhotos(self, user_id):\n        \"\"\"Return photos from the given user's photostream.\"\"\"\n        params = {\"user_id\": user_id}\n        return self._pagination(\"people.getPhotos\", params)\n\n    def photos_getAllContexts(self, photo_id):\n        \"\"\"Returns all visible sets and pools the photo belongs to.\"\"\"\n        params = {\"photo_id\": photo_id}\n        data = self._call(\"photos.getAllContexts\", params)\n        del data[\"stat\"]\n        return data\n\n    def photos_getExif(self, photo_id):\n        \"\"\"Retrieves a list of EXIF/TIFF/GPS tags for a given photo.\"\"\"\n        params = {\"photo_id\": photo_id}\n        return self._call(\"photos.getExif\", params)[\"photo\"]\n\n    def photos_getInfo(self, photo_id):\n        \"\"\"Get information about a photo.\"\"\"\n        params = {\"photo_id\": photo_id}\n        return self._call(\"photos.getInfo\", params)[\"photo\"]\n\n    def photos_getSizes(self, photo_id):\n        \"\"\"Returns the available sizes for a photo.\"\"\"\n        params = {\"photo_id\": photo_id}\n        sizes = self._call(\"photos.getSizes\", params)[\"sizes\"][\"size\"]\n        if self.maxsize:\n            for index, size in enumerate(sizes):\n                if index > 0 and (int(size[\"width\"]) > self.maxsize or\n                                  int(size[\"height\"]) > self.maxsize):\n                    del sizes[index:]\n                    break\n        return sizes\n\n    def photos_search(self, params):\n        \"\"\"Return a list of photos matching some criteria.\"\"\"\n        return self._pagination(\"photos.search\", params.copy())\n\n    def photosets_getInfo(self, photoset_id, user_id):\n        \"\"\"Gets information about a photoset.\"\"\"\n        params = {\"photoset_id\": photoset_id, \"user_id\": user_id}\n        photoset = self._call(\"photosets.getInfo\", params)[\"photoset\"]\n        return self._clean_info(photoset)\n\n    def photosets_getList(self, user_id):\n        \"\"\"Returns the photosets belonging to the specified user.\"\"\"\n        params = {\"user_id\": user_id}\n        return self._pagination_sets(\"photosets.getList\", params)\n\n    def photosets_getPhotos(self, photoset_id):\n        \"\"\"Get the list of photos in a set.\"\"\"\n        params = {\"photoset_id\": photoset_id}\n        return self._pagination(\"photosets.getPhotos\", params, \"photoset\")\n\n    def urls_lookupGroup(self, groupname):\n        \"\"\"Returns a group NSID, given the url to a group's page.\"\"\"\n        params = {\"url\": \"https://www.flickr.com/groups/\" + groupname}\n        group = self._call(\"urls.lookupGroup\", params)[\"group\"]\n        return {\"nsid\": group[\"id\"],\n                \"path_alias\": groupname,\n                \"groupname\": group[\"groupname\"][\"_content\"]}\n\n    def urls_lookupUser(self, username):\n        \"\"\"Returns a user NSID, given the url to a user's photos or profile.\"\"\"\n        params = {\"url\": \"https://www.flickr.com/photos/\" + username}\n        user = self._call(\"urls.lookupUser\", params)[\"user\"]\n        return {\n            \"nsid\"      : user[\"id\"],\n            \"username\"  : user[\"username\"][\"_content\"],\n            \"path_alias\": username,\n        }\n\n    def video_getStreamInfo(self, video_id, secret=None):\n        \"\"\"Returns all available video streams\"\"\"\n        params = {\"photo_id\": video_id}\n        if not secret:\n            secret = self._call(\"photos.getInfo\", params)[\"photo\"][\"secret\"]\n        params[\"secret\"] = secret\n        stream = self._call(\"video.getStreamInfo\", params)[\"streams\"][\"stream\"]\n        return max(stream, key=lambda s: self.VIDEO_FORMATS.get(s[\"type\"], 0))\n\n    def _call(self, method, params):\n        params[\"method\"] = \"flickr.\" + method\n        params[\"format\"] = \"json\"\n        params[\"nojsoncallback\"] = \"1\"\n        if self.api_key:\n            params[\"api_key\"] = self.api_key\n        response = self.request(self.API_URL, params=params)\n        try:\n            data = response.json()\n        except ValueError:\n            data = {\"code\": -1, \"message\": response.content}\n        if \"code\" in data:\n            msg = data.get(\"message\", \"\")\n            self.log.debug(\"Server response: %s\", data)\n            if data[\"code\"] == 1:\n                raise self.exc.NotFoundError(self.extractor.subcategory)\n            elif data[\"code\"] == 2:\n                raise self.exc.AuthorizationError(msg)\n            elif data[\"code\"] == 98:\n                raise self.exc.AuthenticationError(msg)\n            elif data[\"code\"] == 99:\n                raise self.exc.AuthorizationError(msg)\n            raise self.exc.AbortExtraction(\"API request failed: \" + msg)\n        return data\n\n    def _pagination(self, method, params, key=\"photos\"):\n        extras = (\"description,date_upload,tags,views,media,\"\n                  \"path_alias,owner_name,\")\n        if includes := self.extractor.config(\"metadata\"):\n            if isinstance(includes, (list, tuple)):\n                includes = \",\".join(includes)\n            elif not isinstance(includes, str):\n                includes = (\"license,date_taken,original_format,last_update,\"\n                            \"geo,machine_tags,o_dims\")\n            extras = extras + includes + \",\"\n        extras += \",\".join(\"url_\" + fmt[0] for fmt in self.formats)\n\n        params[\"extras\"] = extras\n        params[\"page\"] = 1\n\n        while True:\n            data = self._call(method, params)[key]\n            yield from data[\"photo\"]\n            if params[\"page\"] >= data[\"pages\"]:\n                return\n            params[\"page\"] += 1\n\n    def _pagination_sets(self, method, params):\n        params[\"page\"] = 1\n\n        while True:\n            data = self._call(method, params)[\"photosets\"]\n            yield from data[\"photoset\"]\n            if params[\"page\"] >= data[\"pages\"]:\n                return\n            params[\"page\"] += 1\n\n    def _extract_apikey(self):\n        extr = self.extractor\n        extr.log.info(\"Retrieving public API key\")\n        page = extr.request(extr.root + \"/prints\").text\n        return text.extr(page, '.flickr.api.site_key = \"', '\"')\n\n    def _extract_format(self, photo):\n        photo[\"description\"] = photo[\"description\"][\"_content\"].strip()\n        photo[\"views\"] = text.parse_int(photo[\"views\"])\n        photo[\"date\"] = self.extractor.parse_timestamp(photo[\"dateupload\"])\n        photo[\"tags\"] = photo[\"tags\"].split()\n\n        self._extract_metadata(photo)\n        photo[\"id\"] = text.parse_int(photo[\"id\"])\n\n        if \"owner\" not in photo:\n            photo[\"owner\"] = self.extractor.user\n        elif not self.meta_info:\n            photo[\"owner\"] = {\n                \"nsid\"      : photo[\"owner\"],\n                \"username\"  : photo[\"ownername\"],\n                \"path_alias\": photo[\"pathalias\"],\n            }\n\n        del photo[\"pathalias\"]\n        del photo[\"ownername\"]\n\n        if photo[\"media\"] == \"video\" and self.videos:\n            return self._extract_video(photo)\n\n        for fmt, fmtname, fmtwidth in self.formats:\n            key = \"url_\" + fmt\n            if key in photo:\n                photo[\"width\"] = text.parse_int(photo[\"width_\" + fmt])\n                photo[\"height\"] = text.parse_int(photo[\"height_\" + fmt])\n                if self.maxsize and (photo[\"width\"] > self.maxsize or\n                                     photo[\"height\"] > self.maxsize):\n                    continue\n                photo[\"url\"] = photo[key]\n                photo[\"label\"] = fmtname\n\n                # remove excess data\n                keys = [\n                    key for key in photo\n                    if key.startswith((\"url_\", \"width_\", \"height_\"))\n                ]\n                for key in keys:\n                    del photo[key]\n                break\n        else:\n            self._extract_photo(photo)\n\n        return photo\n\n    def _extract_photo(self, photo):\n        size = self.photos_getSizes(photo[\"id\"])[-1]\n        photo[\"url\"] = size[\"source\"]\n        photo[\"label\"] = size[\"label\"]\n        photo[\"width\"] = text.parse_int(size[\"width\"])\n        photo[\"height\"] = text.parse_int(size[\"height\"])\n        return photo\n\n    def _extract_video(self, photo):\n        stream = self.video_getStreamInfo(photo[\"id\"], photo.get(\"secret\"))\n        photo[\"url\"] = stream[\"_content\"]\n        photo[\"label\"] = stream[\"type\"]\n        photo[\"width\"] = photo[\"height\"] = 0\n        return photo\n\n    def _extract_metadata(self, photo, info=True):\n        if info and self.meta_info:\n            try:\n                photo.update(self.photos_getInfo(photo[\"id\"]))\n                photo[\"title\"] = photo[\"title\"][\"_content\"]\n                photo[\"comments\"] = text.parse_int(\n                    photo[\"comments\"][\"_content\"])\n                photo[\"description\"] = photo[\"description\"][\"_content\"]\n                photo[\"tags\"] = [t[\"raw\"] for t in photo[\"tags\"][\"tag\"]]\n                photo[\"views\"] = text.parse_int(photo[\"views\"])\n                photo[\"id\"] = text.parse_int(photo[\"id\"])\n            except Exception as exc:\n                self.log.warning(\n                    \"Unable to retrieve 'info' data for %s (%s: %s)\",\n                    photo[\"id\"], exc.__class__.__name__, exc)\n\n        if self.meta_exif:\n            try:\n                photo.update(self.photos_getExif(photo[\"id\"]))\n            except Exception as exc:\n                self.log.warning(\n                    \"Unable to retrieve 'exif' data for %s (%s: %s)\",\n                    photo[\"id\"], exc.__class__.__name__, exc)\n\n        if self.meta_contexts:\n            try:\n                photo.update(self.photos_getAllContexts(photo[\"id\"]))\n            except Exception as exc:\n                self.log.warning(\n                    \"Unable to retrieve 'contexts' data for %s (%s: %s)\",\n                    photo[\"id\"], exc.__class__.__name__, exc)\n\n        if \"license\" in photo:\n            photo[\"license_name\"] = self.LICENSES.get(photo[\"license\"])\n\n    def _clean_info(self, info):\n        info[\"title\"] = info[\"title\"][\"_content\"]\n        info[\"description\"] = info[\"description\"][\"_content\"]\n        return info\n"
  },
  {
    "path": "gallery_dl/extractor/foolfuuka.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2019-2025 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for FoolFuuka 4chan archives\"\"\"\n\nfrom .common import BaseExtractor, Message\nfrom .. import text\nimport itertools\n\n\nclass FoolfuukaExtractor(BaseExtractor):\n    \"\"\"Base extractor for FoolFuuka based boards/archives\"\"\"\n    basecategory = \"foolfuuka\"\n    filename_fmt = \"{timestamp_ms} {filename_media}.{extension}\"\n    archive_fmt = \"{board[shortname]}_{num}_{timestamp}\"\n    external = \"default\"\n\n    def __init__(self, match):\n        BaseExtractor.__init__(self, match)\n        if self.category == \"b4k\":\n            self.remote = self._remote_direct\n        elif self.category == \"archivedmoe\":\n            self.referer = False\n            self.fixup_redirect = True\n        else:\n            self.fixup_redirect = False\n\n    def items(self):\n        yield Message.Directory, \"\", self.metadata()\n        for post in self.posts():\n            if not (media := post.get(\"media\")):\n                continue\n            board = post[\"board\"][\"shortname\"]\n            url = media[\"media_link\"]\n\n            if not url and \"remote_media_link\" in media:\n                url = self.remote(board, media)\n            if url and url[0] == \"/\":\n                url = self.root + url\n\n            post[\"filename\"], _, post[\"extension\"] = \\\n                media[\"media\"].rpartition(\".\")\n            post[\"filename_media\"] = media[\"media_filename\"].rpartition(\".\")[0]\n            post[\"timestamp_ms\"] = text.parse_int(\n                media[\"media_orig\"].rpartition(\".\")[0])\n            yield Message.Url, url, post\n\n    def metadata(self):\n        \"\"\"Return general metadata\"\"\"\n\n    def posts(self):\n        \"\"\"Return an iterable with all relevant posts\"\"\"\n\n    def remote(self, board, media):\n        \"\"\"Resolve a remote media link\"\"\"\n        if board in {\"wsg\", \"gif\"}:\n            return f\"https://i.4cdn.org/{board}/{media['media_orig']}\"\n        page = self.request(media[\"remote_media_link\"]).text\n        url = text.extr(page, 'http-equiv=\"Refresh\" content=\"0; url=', '\"')\n\n        if url.startswith(\"https://thebarchive.com/\"):\n            # '.webm' -> '.web' (#5116)\n            if url.endswith(\".webm\"):\n                url = url[:-1]\n\n        elif self.fixup_redirect:\n            # update redirect domain or filename (#7652)\n            path, _, filename = url.rpartition(\"/\")\n\n            # these boards link directly to i.4cdn.org\n            # -> redirect to warosu or 4plebs instead\n            board_domains = {\n                \"3\"  : \"warosu.org\",\n                \"biz\": \"warosu.org\",\n                \"ck\" : \"warosu.org\",\n                \"diy\": \"warosu.org\",\n                \"fa\" : \"warosu.org\",\n                \"ic\" : \"warosu.org\",\n                \"jp\" : \"warosu.org\",\n                \"lit\": \"warosu.org\",\n                \"sci\": \"warosu.org\",\n                \"tg\" : \"archive.4plebs.org\",\n            }\n            if board in board_domains:\n                domain = board_domains[board]\n                url = f\"https://{domain}/{board}/full_image/{filename}\"\n\n            # if it's one of these archives, slice the name\n            elif any(archive in path for archive in (\n                     \"b4k.\", \"desuarchive.\", \"palanq.\")):\n                name, _, ext = filename.rpartition(\".\")\n                if len(name) > 13:\n                    url = f\"{path}/{name[:13]}.{ext}\"\n\n        return url\n\n    def _remote_direct(self, board, media):\n        return media[\"remote_media_link\"]\n\n\nBASE_PATTERN = FoolfuukaExtractor.update({\n    \"4plebs\": {\n        \"root\": \"https://archive.4plebs.org\",\n        \"pattern\": r\"(?:archive\\.)?4plebs\\.org\",\n    },\n    \"archivedmoe\": {\n        \"root\": \"https://archived.moe\",\n        \"pattern\": r\"archived\\.moe\",\n    },\n    \"archiveofsins\": {\n        \"root\": \"https://archiveofsins.com\",\n        \"pattern\": r\"(?:www\\.)?archiveofsins\\.com\",\n    },\n    \"b4k\": {\n        \"root\": \"https://arch.b4k.dev\",\n        \"pattern\": r\"arch\\.b4k\\.(?:dev|co)\",\n    },\n    \"desuarchive\": {\n        \"root\": \"https://desuarchive.org\",\n        \"pattern\": r\"desuarchive\\.org\",\n    },\n    \"fireden\": {\n        \"root\": \"https://boards.fireden.net\",\n        \"pattern\": r\"boards\\.fireden\\.net\",\n    },\n    \"palanq\": {\n        \"root\": \"https://archive.palanq.win\",\n        \"pattern\": r\"archive\\.palanq\\.win\",\n    },\n    \"rbt\": {\n        \"root\": \"https://rbt.asia\",\n        \"pattern\": r\"(?:rbt\\.asia|(?:archive\\.)?rebeccablacktech\\.com)\",\n    },\n    \"thebarchive\": {\n        \"root\": \"https://thebarchive.com\",\n        \"pattern\": r\"thebarchive\\.com\",\n    },\n})\n\n\nclass FoolfuukaThreadExtractor(FoolfuukaExtractor):\n    \"\"\"Base extractor for threads on FoolFuuka based boards/archives\"\"\"\n    subcategory = \"thread\"\n    directory_fmt = (\"{category}\", \"{board[shortname]}\",\n                     \"{thread_num} {title|comment[:50]}\")\n    pattern = BASE_PATTERN + r\"/([^/?#]+)/thread/(\\d+)\"\n    example = \"https://archived.moe/a/thread/12345/\"\n\n    def __init__(self, match):\n        FoolfuukaExtractor.__init__(self, match)\n        self.board = self.groups[-2]\n        self.thread = self.groups[-1]\n        self.data = None\n\n    def metadata(self):\n        url = self.root + \"/_/api/chan/thread/\"\n        params = {\"board\": self.board, \"num\": self.thread}\n        self.data = self.request_json(url, params=params)[self.thread]\n        return self.data[\"op\"]\n\n    def posts(self):\n        op = (self.data[\"op\"],)\n        if posts := self.data.get(\"posts\"):\n            posts = list(posts.values())\n            posts.sort(key=lambda p: p[\"timestamp\"])\n            return itertools.chain(op, posts)\n        return op\n\n\nclass FoolfuukaBoardExtractor(FoolfuukaExtractor):\n    \"\"\"Base extractor for FoolFuuka based boards/archives\"\"\"\n    subcategory = \"board\"\n    pattern = BASE_PATTERN + r\"/([^/?#]+)(?:/(?:page/)?(\\d*))?$\"\n    example = \"https://archived.moe/a/\"\n\n    def __init__(self, match):\n        FoolfuukaExtractor.__init__(self, match)\n        self.board = self.groups[-2]\n        self.page = self.groups[-1]\n\n    def items(self):\n        index_base = f\"{self.root}/_/api/chan/index/?board={self.board}&page=\"\n        thread_base = f\"{self.root}/{self.board}/thread/\"\n\n        page = self.page\n        for pnum in itertools.count(text.parse_int(page, 1)):\n            with self.request(index_base + str(pnum)) as response:\n                try:\n                    threads = response.json()\n                except ValueError:\n                    threads = None\n\n            if not threads:\n                return\n\n            for num, thread in threads.items():\n                thread[\"url\"] = thread_base + format(num)\n                thread[\"_extractor\"] = FoolfuukaThreadExtractor\n                yield Message.Queue, thread[\"url\"], thread\n\n            if page:\n                return\n\n\nclass FoolfuukaSearchExtractor(FoolfuukaExtractor):\n    \"\"\"Base extractor for search results on FoolFuuka based boards/archives\"\"\"\n    subcategory = \"search\"\n    directory_fmt = (\"{category}\", \"search\", \"{search}\")\n    pattern = BASE_PATTERN + r\"/([^/?#]+)/search((?:/[^/?#]+/[^/?#]+)+)\"\n    example = \"https://archived.moe/_/search/text/QUERY/\"\n    request_interval = (0.5, 1.5)\n\n    def __init__(self, match):\n        FoolfuukaExtractor.__init__(self, match)\n        self.params = params = {}\n\n        key = None\n        for arg in self.groups[-1].split(\"/\"):\n            if key:\n                params[key] = text.unescape(arg)\n                key = None\n            else:\n                key = arg\n\n        board = self.groups[-2]\n        if board != \"_\":\n            params[\"boards\"] = board\n\n    def metadata(self):\n        return {\"search\": self.params.get(\"text\", \"\")}\n\n    def posts(self):\n        url = self.root + \"/_/api/chan/search/\"\n        params = self.params.copy()\n        params[\"page\"] = text.parse_int(params.get(\"page\"), 1)\n        if \"filter\" not in params:\n            params[\"filter\"] = \"text\"\n\n        while True:\n            try:\n                data = self.request_json(url, params=params)\n            except ValueError:\n                return\n\n            if isinstance(data, dict):\n                if data.get(\"error\"):\n                    return\n                posts = data[\"0\"][\"posts\"]\n            elif isinstance(data, list):\n                posts = data[0][\"posts\"]\n            else:\n                return\n\n            yield from posts\n            if len(posts) <= 3:\n                return\n            params[\"page\"] += 1\n\n\nclass FoolfuukaGalleryExtractor(FoolfuukaExtractor):\n    \"\"\"Base extractor for FoolFuuka galleries\"\"\"\n    subcategory = \"gallery\"\n    directory_fmt = (\"{category}\", \"{board}\", \"gallery\")\n    pattern = BASE_PATTERN + r\"/([^/?#]+)/gallery(?:/(\\d+))?\"\n    example = \"https://archived.moe/a/gallery\"\n\n    def metadata(self):\n        self.board = board = self.groups[-2]\n        return {\"board\": board}\n\n    def posts(self):\n        pnum = self.groups[-1]\n        pages = itertools.count(1) if pnum is None else (pnum,)\n        base = f\"{self.root}/_/api/chan/gallery/?board={self.board}&page=\"\n\n        for pnum in pages:\n            posts = self.request_json(base + str(pnum))\n            if not posts:\n                return\n            yield from posts\n"
  },
  {
    "path": "gallery_dl/extractor/foolslide.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2016-2025 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for FoOlSlide based sites\"\"\"\n\nfrom .common import BaseExtractor, Message\nfrom .. import text, util\n\n\nclass FoolslideExtractor(BaseExtractor):\n    \"\"\"Base class for FoOlSlide extractors\"\"\"\n    basecategory = \"foolslide\"\n\n    def __init__(self, match):\n        BaseExtractor.__init__(self, match)\n        self.page_url = self.root + self.groups[-1]\n\n    def request(self, url):\n        return BaseExtractor.request(\n            self, url, encoding=\"utf-8\", method=\"POST\", data={\"adult\": \"true\"})\n\n    def parse_chapter_url(self, url, data):\n        info = url.partition(\"/read/\")[2].rstrip(\"/\").split(\"/\")\n        lang = info[1].partition(\"-\")[0]\n        data[\"lang\"] = lang\n        data[\"language\"] = util.code_to_language(lang)\n        data[\"volume\"] = text.parse_int(info[2])\n        data[\"chapter\"] = text.parse_int(info[3])\n        data[\"chapter_minor\"] = \".\" + info[4] if len(info) >= 5 else \"\"\n        data[\"title\"] = data[\"chapter_string\"].partition(\":\")[2].strip()\n        return data\n\n\nBASE_PATTERN = FoolslideExtractor.update({\n})\n\n\nclass FoolslideChapterExtractor(FoolslideExtractor):\n    \"\"\"Base class for chapter extractors for FoOlSlide based sites\"\"\"\n    subcategory = \"chapter\"\n    directory_fmt = (\"{category}\", \"{manga}\", \"{chapter_string}\")\n    filename_fmt = (\n        \"{manga}_c{chapter:>03}{chapter_minor:?//}_{page:>03}.{extension}\")\n    archive_fmt = \"{id}\"\n    pattern = BASE_PATTERN + r\"(/read/[^/?#]+/[a-z-]+/\\d+/\\d+(?:/\\d+)?)\"\n    example = \"https://read.powermanga.org/read/MANGA/en/0/123/\"\n\n    def items(self):\n        page = self.request(self.page_url).text\n        data = self.metadata(page)\n        imgs = self.images(page)\n\n        data[\"count\"] = len(imgs)\n        data[\"chapter_id\"] = text.parse_int(imgs[0][\"chapter_id\"])\n\n        yield Message.Directory, \"\", data\n        enum = util.enumerate_reversed if self.config(\n            \"page-reverse\") else enumerate\n        for data[\"page\"], image in enum(imgs, 1):\n            try:\n                url = image[\"url\"]\n                del image[\"url\"]\n                del image[\"chapter_id\"]\n                del image[\"thumb_url\"]\n            except KeyError:\n                pass\n            for key in (\"height\", \"id\", \"size\", \"width\"):\n                image[key] = text.parse_int(image[key])\n            data.update(image)\n            text.nameext_from_url(data[\"filename\"], data)\n            yield Message.Url, url, data\n\n    def metadata(self, page):\n        extr = text.extract_from(page)\n        extr('<h1 class=\"tbtitle dnone\">', '')\n        return self.parse_chapter_url(self.page_url, {\n            \"manga\"         : text.unescape(extr('title=\"', '\"')).strip(),\n            \"chapter_string\": text.unescape(extr('title=\"', '\"')),\n        })\n\n    def images(self, page):\n        return util.json_loads(text.extr(page, \"var pages = \", \";\"))\n\n\nclass FoolslideMangaExtractor(FoolslideExtractor):\n    \"\"\"Base class for manga extractors for FoOlSlide based sites\"\"\"\n    subcategory = \"manga\"\n    categorytransfer = True\n    pattern = BASE_PATTERN + r\"(/series/[^/?#]+)\"\n    example = \"https://read.powermanga.org/series/MANGA/\"\n\n    def items(self):\n        page = self.request(self.page_url).text\n\n        chapters = self.chapters(page)\n        if not self.config(\"chapter-reverse\", False):\n            chapters.reverse()\n\n        for chapter, data in chapters:\n            data[\"_extractor\"] = FoolslideChapterExtractor\n            yield Message.Queue, chapter, data\n\n    def chapters(self, page):\n        extr = text.extract_from(page)\n        manga = text.unescape(extr('<h1 class=\"title\">', '</h1>')).strip()\n        author = extr('<b>Author</b>: ', '<br')\n        artist = extr('<b>Artist</b>: ', '<br')\n\n        results = []\n        while True:\n            url = extr('<div class=\"title\"><a href=\"', '\"')\n            if not url:\n                return results\n            results.append((url, self.parse_chapter_url(url, {\n                \"manga\": manga, \"author\": author, \"artist\": artist,\n                \"chapter_string\": extr('title=\"', '\"'),\n                \"group\"         : extr('title=\"', '\"'),\n            })))\n"
  },
  {
    "path": "gallery_dl/extractor/foriio.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2026 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://foriio.com/\"\"\"\n\nfrom .common import Extractor, Message\nfrom .. import text\n\nBASE_PATTERN = r\"(?:https?://)?(?:www\\.)?fori(?:io\\.com|\\.io)\"\n\n\nclass ForiioExtractor(Extractor):\n    \"\"\"Base class for foriio extractors\"\"\"\n    category = \"foriio\"\n    root = \"https://foriio.com\"\n    root_api = \"https://api.foriio.com/api\"\n\n\nclass ForiioWorkExtractor(ForiioExtractor):\n    subcategory = \"work\"\n    directory_fmt = (\"{category}\", \"{author[screen_name]} ({author[id]})\",\n                     \"{date:%Y-%m-%d} {title[b:230]} ({work_id})\")\n    filename_fmt = \"{num:>02} {file_id}.{extension}\"\n    archive_fmt = \"{work_id}/{file_id}/{num}\"\n    pattern = BASE_PATTERN + r\"/works/(\\d+)\"\n    example = \"https://www.foriio.com/works/12345\"\n\n    def items(self):\n        url = f\"{self.root_api}/v1/works/{self.groups[0]}\"\n        work = self.request_json(url, notfound=True)[\"work\"]\n\n        work.pop(\"related_works\", None)\n        work.pop(\"single_work_og\", None)\n        work.pop(\"og_image_url_for_instagram\", None)\n        work[\"work_id\"] = work.pop(\"id\")\n        work[\"date\"] = self.parse_datetime_iso(work[\"published_at\"])\n\n        if \"images\" in work:\n            files = work.pop(\"images\")\n            work[\"count\"] = len(files)\n            yield Message.Directory, \"\", work\n            for work[\"num\"], file in enumerate(files, 1):\n                url = file[\"urls\"][\"list\"].partition(\"?\")[0]\n\n                work[\"width\"] = file[\"width\"]\n                work[\"height\"] = file[\"height\"]\n                work[\"file_id\"] = file[\"id\"]\n\n                url = _orig(name := url[url.rfind(\"/\")+1:])\n                yield Message.Url, url, text.nameext_from_url(name, work)\n            return\n\n        previews = self.config(\"previews\", False)\n        if \"videos\" in work:\n            files = work.pop(\"videos\")\n            work[\"count\"] = len(files)\n            yield Message.Directory, \"\", work\n\n            video = self.config(\"videos\", True)\n            for work[\"num\"], file in enumerate(files, 1):\n                work[\"file_id\"] = file.get(\"video_id\") or file[\"title\"]\n                work.update(file)\n                if video:\n                    work[\"extension\"] = \"mp4\"\n                    yield Message.Url, \"ytdl:\" + file[\"url\"], work\n                if previews:\n                    url = file[\"picture_url\"]\n                    url = _orig(name := url[url.rfind(\"/\")+1:])\n                    yield Message.Url, url, text.nameext_from_url(name, work)\n            return\n\n        if \"sounds\" in work:\n            files = work.pop(\"sounds\")\n            work[\"count\"] = len(files)\n            yield Message.Directory, \"\", work\n\n            audio = self.config(\"audio\", True)\n            for work[\"num\"], file in enumerate(files, 1):\n                work[\"file_id\"] = file[\"title\"]\n                work.update(file)\n                if audio:\n                    work[\"extension\"] = \"mp3\"\n                    yield Message.Url, \"ytdl:\" + file[\"url\"], work\n                if previews:\n                    url = file[\"picture_url\"]\n                    url = _orig(name := url[url.rfind(\"/\")+1:])\n                    yield Message.Url, url, text.nameext_from_url(name, work)\n            return\n\n        if \"web_articles\" in work:\n            files = work.pop(\"web_articles\")\n            work[\"count\"] = len(files)\n            yield Message.Directory, \"\", work\n\n            external = self.config(\"external\", True)\n            for work[\"num\"], file in enumerate(files, 1):\n                work[\"file_id\"] = file[\"title\"]\n                work.update(file)\n                if external:\n                    yield Message.Queue, file[\"url\"], work\n                if previews:\n                    url = file[\"image\"]\n                    url = _orig(name := url[url.rfind(\"/\")+1:])\n                    yield Message.Url, url, text.nameext_from_url(name, work)\n            return\n\n        if \"copy_writing\" in work:\n            file = work[\"copy_writing\"]\n            work[\"count\"] = work[\"num\"] = 1\n            work[\"file_id\"] = file[\"id\"]\n            url = file[\"image\"]\n            url = _orig(name := url[url.rfind(\"/\")+1:])\n            yield Message.Directory, \"\", work\n            yield Message.Url, url, text.nameext_from_url(name, work)\n\n        else:\n            return self.log.error(\"%s: Unsupported type %r\",\n                                  work[\"id\"], work[\"type\"])\n\n\nclass ForiioUserExtractor(ForiioExtractor):\n    subcategory = \"user\"\n    pattern = BASE_PATTERN + r\"/(?!works/)([^/?#]+)\"\n    example = \"https://foriio.com/USER\"\n\n    def items(self):\n        if posts := self.config(\"posts\"):\n            if isinstance(posts, str):\n                posts = posts.split(\",\")\n            posts = set(posts)\n\n        url = f\"{self.root_api}/v1/users/{self.groups[0]}/works\"\n        params = {\n            \"page\"    : 1,\n            \"per_page\": \"20\",\n        }\n        headers = {\n            \"Referer\": self.root + \"/\",\n            \"Origin\" : self.root,\n        }\n\n        base = self.root + \"/works/\"\n        while True:\n            data = self.request_json(url, params=params, headers=headers)\n\n            for work in data[\"works\"]:\n                if posts and work.get(\"type\") not in posts:\n                    self.log.debug(\"%s: Skipping work of type %r\",\n                                   work[\"id\"], work[\"type\"])\n                    continue\n                work[\"_extractor\"] = ForiioWorkExtractor\n                yield Message.Queue, base + str(work[\"id\"]), work\n\n            try:\n                meta = data[\"meta\"]\n                if meta[\"current_page\"] >= meta[\"total_pages\"]:\n                    break\n            except Exception as exc:\n                self.log.traceback(exc)\n                break\n\n            params[\"page\"] += 1\n\n\ndef _orig(name):\n    return \"https://foriio.imgix.net/store/\" + name\n"
  },
  {
    "path": "gallery_dl/extractor/furaffinity.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2020-2025 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://www.furaffinity.net/\"\"\"\n\nfrom .common import Extractor, Message, Dispatch\nfrom .. import text, util\n\nBASE_PATTERN = r\"(?:https?://)?(?:www\\.|sfw\\.)?(?:f[ux]|f?xfu)raffinity\\.net\"\n\n\nclass FuraffinityExtractor(Extractor):\n    \"\"\"Base class for furaffinity extractors\"\"\"\n    category = \"furaffinity\"\n    directory_fmt = (\"{category}\", \"{user!l}\")\n    filename_fmt = \"{id}{title:? //}.{extension}\"\n    archive_fmt = \"{id}\"\n    cookies_domain = \".furaffinity.net\"\n    cookies_names = (\"a\", \"b\")\n    root = \"https://www.furaffinity.net\"\n    request_interval = 1.0\n    _warning = True\n\n    def __init__(self, match):\n        Extractor.__init__(self, match)\n        self.user = match[1]\n        self.offset = 0\n\n    def _init(self):\n        self.external = self.config(\"external\", False)\n\n        if self.config(\"descriptions\") == \"html\":\n            self._process_description = str.strip\n\n        layout = self.config(\"layout\")\n        if layout and layout != \"auto\":\n            self._new_layout = False if layout == \"old\" else True\n        else:\n            self._new_layout = None\n\n        if self._warning:\n            if not self.cookies_check(self.cookies_names):\n                self.log.warning(\"no 'a' and 'b' session cookies set\")\n            FuraffinityExtractor._warning = False\n\n    def items(self):\n        metadata = self.metadata()\n        for post_id in util.advance(self.posts(), self.offset):\n            if post := self._parse_post(post_id):\n                if metadata:\n                    post.update(metadata)\n                yield Message.Directory, \"\", post\n                yield Message.Url, post[\"url\"], post\n\n                if self.external:\n                    for url in text.extract_iter(\n                            post[\"_description\"], 'href=\"http', '\"'):\n                        yield Message.Queue, \"http\" + url, post\n\n    def metadata(self):\n        return None\n\n    def skip_files(self, num):\n        self.offset += num\n        return num\n\n    def _parse_post(self, post_id):\n        url = f\"{self.root}/view/{post_id}/\"\n        extr = text.extract_from(self.request(url).text)\n\n        if self._new_layout is None:\n            self._new_layout = (\"http-equiv=\" not in extr(\"<meta \", \">\"))\n\n        path = extr('href=\"//d', '\"')\n        if not path:\n            msg = text.remove_html(\n                extr('System Message', '</section>') or\n                extr('System Message', '</table>')\n            ).partition(\" . Continue \")[0]\n            return self.log.warning(\n                \"Unable to download post %s (\\\"%s\\\")\", post_id, msg)\n\n        pi = text.parse_int\n        rh = text.remove_html\n\n        data = text.nameext_from_url(path, {\n            \"id\" : pi(post_id),\n            \"url\": \"https://d\" + path,\n        })\n\n        if self._new_layout:\n            data[\"tags\"] = text.split_html(extr(\n                \"<h3>Keywords</h3>\", \"</section>\"))\n            data[\"scraps\"] = (extr(' submissions\">', \"<\") == \"Scraps\")\n            data[\"title\"] = text.unescape(extr(\"<h2><p>\", \"</p></h2>\"))\n            data[\"artist_url\"] = extr('title=\"', '\"').strip()\n            data[\"artist\"] = extr(\">\", \"<\")\n            data[\"_description\"] = extr(\n                'class=\"submission-description user-submitted-links\">',\n                '                                    </div>')\n            data[\"views\"] = pi(rh(extr('class=\"views\">', '</span>')))\n            data[\"favorites\"] = pi(rh(extr('class=\"favorites\">', '</span>')))\n            data[\"comments\"] = pi(rh(extr('class=\"comments\">', '</span>')))\n            data[\"rating\"] = rh(extr('class=\"rating\">', '</span>'))\n            data[\"fa_category\"] = rh(extr('>Category</strong>', '</span>'))\n            data[\"theme\"] = rh(extr('>', '<'))\n            data[\"species\"] = rh(extr('>Species</strong>', '</div>'))\n            data[\"gender\"] = rh(extr('>Gender</strong>', '</div>'))\n            data[\"width\"] = pi(extr(\"<span>\", \"x\"))\n            data[\"height\"] = pi(extr(\"\", \"p\"))\n            data[\"folders\"] = folders = []\n            for folder in extr(\n                    \"<h3>Listed in Folders</h3>\", \"</section>\").split(\"</a>\"):\n                if folder := rh(folder):\n                    folders.append(folder)\n        else:\n            # old site layout\n            data[\"scraps\"] = (\n                \"/scraps/\" in extr('class=\"minigallery-title', \"</a>\"))\n            data[\"title\"] = text.unescape(extr(\"<h2>\", \"</h2>\"))\n            data[\"artist_url\"] = extr('title=\"', '\"').strip()\n            data[\"artist\"] = extr(\">\", \"<\")\n            data[\"fa_category\"] = extr(\"<b>Category:</b>\", \"<\").strip()\n            data[\"theme\"] = extr(\"<b>Theme:</b>\", \"<\").strip()\n            data[\"species\"] = extr(\"<b>Species:</b>\", \"<\").strip()\n            data[\"gender\"] = extr(\"<b>Gender:</b>\", \"<\").strip()\n            data[\"favorites\"] = pi(extr(\"<b>Favorites:</b>\", \"<\"))\n            data[\"comments\"] = pi(extr(\"<b>Comments:</b>\", \"<\"))\n            data[\"views\"] = pi(extr(\"<b>Views:</b>\", \"<\"))\n            data[\"width\"] = pi(extr(\"<b>Resolution:</b>\", \"x\"))\n            data[\"height\"] = pi(extr(\"\", \"<\"))\n            data[\"tags\"] = text.split_html(extr(\n                'id=\"keywords\">', '</div>'))[::2]\n            data[\"rating\"] = extr('<img alt=\"', ' ')\n            data[\"_description\"] = extr(\n                '<td valign=\"top\" align=\"left\" width=\"70%\" class=\"alt1\" '\n                'style=\"padding:8px\">', '                               </td>')\n            data[\"folders\"] = ()  # folders not present in old layout\n\n        data[\"user\"] = self.user or data[\"artist_url\"]\n        data[\"date\"] = self.parse_timestamp(data[\"filename\"].partition(\".\")[0])\n        data[\"description\"] = self._process_description(data[\"_description\"])\n        data[\"thumbnail\"] = (f\"https://t.furaffinity.net/{post_id}@600-\"\n                             f\"{path.rsplit('/', 2)[1]}.jpg\")\n        return data\n\n    def _process_description(self, description):\n        return text.unescape(text.remove_html(description, \"\", \"\"))\n\n    def _pagination(self, path, folder=None):\n        num = 1\n        folder = \"\" if folder is None else f\"/folder/{folder}/a\"\n\n        while True:\n            url = f\"{self.root}/{path}/{self.user}{folder}/{num}/\"\n            page = self.request(url).text\n            post_id = None\n\n            for post_id in text.extract_iter(page, 'id=\"sid-', '\"'):\n                yield post_id\n\n            if not post_id:\n                return\n            num += 1\n\n    def _pagination_favorites(self, start=None):\n        path = f\"/favorites/{self.user}/\"\n        if start is not None:\n            path += start\n\n        while path:\n            page = self.request(self.root + path).text\n            extr = text.extract_from(page)\n            while True:\n                post_id = extr('id=\"sid-', '\"')\n                if not post_id:\n                    break\n                self._favorite_id = text.parse_int(extr('data-fav-id=\"', '\"'))\n                yield post_id\n\n            pos = page.find('type=\"submit\">Next</button>')\n            if pos >= 0:\n                path = text.rextr(page, '<form action=\"', '\"', pos)\n                continue\n            path = text.extr(page, 'right\" href=\"', '\"')\n\n    def _pagination_search(self, query):\n        url = self.root + \"/search/\"\n        data = {\n            \"page\"           : 1,\n            \"order-by\"       : \"relevancy\",\n            \"order-direction\": \"desc\",\n            \"range\"          : \"all\",\n            \"range_from\"     : \"\",\n            \"range_to\"       : \"\",\n            \"rating-general\" : \"1\",\n            \"rating-mature\"  : \"1\",\n            \"rating-adult\"   : \"1\",\n            \"type-art\"       : \"1\",\n            \"type-music\"     : \"1\",\n            \"type-flash\"     : \"1\",\n            \"type-story\"     : \"1\",\n            \"type-photo\"     : \"1\",\n            \"type-poetry\"    : \"1\",\n            \"mode\"           : \"extended\",\n        }\n\n        data.update(query)\n        if \"page\" in query:\n            data[\"page\"] = text.parse_int(query[\"page\"])\n\n        while True:\n            page = self.request(url, method=\"POST\", data=data).text\n            post_id = None\n\n            for post_id in text.extract_iter(page, 'id=\"sid-', '\"'):\n                yield post_id\n\n            if not post_id:\n                return\n\n            if \"next_page\" in data:\n                data[\"page\"] += 1\n            else:\n                data[\"next_page\"] = \"Next\"\n\n\nclass FuraffinityGalleryExtractor(FuraffinityExtractor):\n    \"\"\"Extractor for a furaffinity user's gallery\"\"\"\n    subcategory = \"gallery\"\n    pattern = BASE_PATTERN + r\"/gallery/([^/?#]+)(?:$|/(?!folder/))\"\n    example = \"https://www.furaffinity.net/gallery/USER/\"\n\n    def posts(self):\n        return self._pagination(\"gallery\")\n\n\nclass FuraffinityFolderExtractor(FuraffinityExtractor):\n    \"\"\"Extractor for a FurAffinity folder\"\"\"\n    subcategory = \"folder\"\n    directory_fmt = (\"{category}\", \"{user!l}\",\n                     \"Folders\", \"{folder_id}{folder_name:? //}\")\n    pattern = BASE_PATTERN + r\"/gallery/([^/?#]+)/folder/(\\d+)(?:/([^/?#]+))?\"\n    example = \"https://www.furaffinity.net/gallery/USER/folder/12345/FOLDER\"\n\n    def metadata(self):\n        return {\n            \"folder_id\"  : self.groups[1],\n            \"folder_name\": self.groups[2] or \"\",\n        }\n\n    def posts(self):\n        return self._pagination(\"gallery\", self.groups[1])\n\n\nclass FuraffinityScrapsExtractor(FuraffinityExtractor):\n    \"\"\"Extractor for a furaffinity user's scraps\"\"\"\n    subcategory = \"scraps\"\n    directory_fmt = (\"{category}\", \"{user!l}\", \"Scraps\")\n    pattern = BASE_PATTERN + r\"/scraps/([^/?#]+)\"\n    example = \"https://www.furaffinity.net/scraps/USER/\"\n\n    def posts(self):\n        return self._pagination(\"scraps\")\n\n\nclass FuraffinityFavoriteExtractor(FuraffinityExtractor):\n    \"\"\"Extractor for a furaffinity user's favorites\"\"\"\n    subcategory = \"favorite\"\n    directory_fmt = (\"{category}\", \"{user!l}\", \"Favorites\")\n    pattern = BASE_PATTERN + r\"/favorites/([^/?#]+)(/\\d+/(?:next|prev))?\"\n    example = \"https://www.furaffinity.net/favorites/USER/\"\n\n    def posts(self):\n        return self._pagination_favorites(self.groups[1])\n\n    def _parse_post(self, post_id):\n        if post := FuraffinityExtractor._parse_post(self, post_id):\n            post[\"favorite_id\"] = self._favorite_id\n        return post\n\n\nclass FuraffinitySearchExtractor(FuraffinityExtractor):\n    \"\"\"Extractor for furaffinity search results\"\"\"\n    subcategory = \"search\"\n    directory_fmt = (\"{category}\", \"Search\", \"{search}\")\n    pattern = BASE_PATTERN + r\"/search(?:/([^/?#]+))?/?[?&]([^#]+)\"\n    example = \"https://www.furaffinity.net/search/?q=QUERY\"\n\n    def __init__(self, match):\n        FuraffinityExtractor.__init__(self, match)\n        self.query = text.parse_query(match[2])\n        if self.user and \"q\" not in self.query:\n            self.query[\"q\"] = text.unquote(self.user)\n\n    def metadata(self):\n        return {\"search\": self.query.get(\"q\")}\n\n    def posts(self):\n        return self._pagination_search(self.query)\n\n\nclass FuraffinityPostExtractor(FuraffinityExtractor):\n    \"\"\"Extractor for individual posts on furaffinity\"\"\"\n    subcategory = \"post\"\n    pattern = BASE_PATTERN + r\"/(?:view|full)/(\\d+)\"\n    example = \"https://www.furaffinity.net/view/12345/\"\n\n    def posts(self):\n        post_id = self.user\n        self.user = None\n        return (post_id,)\n\n\nclass FuraffinityUserExtractor(Dispatch, FuraffinityExtractor):\n    \"\"\"Extractor for furaffinity user profiles\"\"\"\n    pattern = BASE_PATTERN + r\"/user/([^/?#]+)\"\n    example = \"https://www.furaffinity.net/user/USER/\"\n\n    def items(self):\n        base = self.root\n        user = self.user + \"/\"\n        return self._dispatch_extractors((\n            (FuraffinityGalleryExtractor , f\"{base}/gallery/{user}\"),\n            (FuraffinityScrapsExtractor  , f\"{base}/scraps/{user}\"),\n            (FuraffinityFavoriteExtractor, f\"{base}/favorites/{user}\"),\n        ), (\"gallery\",))\n\n\nclass FuraffinityFollowingExtractor(FuraffinityExtractor):\n    \"\"\"Extractor for a furaffinity user's watched users\"\"\"\n    subcategory = \"following\"\n    pattern = BASE_PATTERN + \"/watchlist/by/([^/?#]+)\"\n    example = \"https://www.furaffinity.net/watchlist/by/USER/\"\n\n    def items(self):\n        url = f\"{self.root}/watchlist/by/{self.user}/\"\n        data = {\"_extractor\": FuraffinityUserExtractor}\n\n        while True:\n            page = self.request(url).text\n\n            for path in text.extract_iter(page, '<a href=\"', '\"'):\n                yield Message.Queue, self.root + path, data\n\n            path = text.rextr(page, 'action=\"', '\"')\n            if url.endswith(path):\n                return\n            url = self.root + path\n\n\nclass FuraffinitySubmissionsExtractor(FuraffinityExtractor):\n    \"\"\"Extractor for new furaffinity submissions\"\"\"\n    subcategory = \"submissions\"\n    pattern = BASE_PATTERN + r\"(/msg/submissions(?:/[^/?#]+)?)\"\n    example = \"https://www.furaffinity.net/msg/submissions\"\n\n    def posts(self):\n        self.user = None\n        url = self.root + self.groups[0]\n        return self._pagination_submissions(url)\n\n    def _pagination_submissions(self, url):\n        while True:\n            page = self.request(url).text\n\n            for post_id in text.extract_iter(page, 'id=\"sid-', '\"'):\n                yield post_id\n\n            if (pos := page.find(\">Next 48</a>\")) < 0 and \\\n                    (pos := page.find(\">&gt;&gt;&gt; Next 48 &gt;&gt;\")) < 0:\n                return\n\n            path = text.rextr(page, 'href=\"', '\"', pos)\n            url = self.root + text.unescape(path)\n"
  },
  {
    "path": "gallery_dl/extractor/furry34.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2025 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://furry34.com/\"\"\"\n\nfrom .booru import BooruExtractor\nfrom .. import text\nimport collections\n\nBASE_PATTERN = r\"(?:https?://)?(?:www\\.)?furry34\\.com\"\n\n\nclass Furry34Extractor(BooruExtractor):\n    category = \"furry34\"\n    root = \"https://furry34.com\"\n    root_cdn = \"https://furry34com.b-cdn.net\"\n    filename_fmt = \"{category}_{id}.{extension}\"\n    per_page = 30\n\n    TAG_TYPES = {\n        None: \"general\",\n        1   : \"general\",\n        2   : \"copyright\",\n        4   : \"character\",\n        8   : \"artist\",\n    }\n    FORMATS = (\n        (\"100\", \"mov.mp4\"),\n        (\"101\", \"mov720.mp4\"),\n        (\"102\", \"mov480.mp4\"),\n        (\"10\" , \"pic.jpg\"),\n    )\n\n    def _file_url(self, post):\n        files = post[\"files\"]\n        for fmt, extension in self.FORMATS:\n            if fmt in files:\n                break\n        else:\n            fmt = next(iter(files))\n\n        post_id = post[\"id\"]\n        root = self.root_cdn if files[fmt][0] else self.root\n        post[\"file_url\"] = url = \\\n            f\"{root}/posts/{post_id // 1000}/{post_id}/{post_id}.{extension}\"\n        post[\"format_id\"] = fmt\n        post[\"format\"] = extension.partition(\".\")[0]\n\n        return url\n\n    def _prepare(self, post):\n        post.pop(\"files\", None)\n        post[\"date\"] = self.parse_datetime_iso(post[\"created\"])\n        post[\"filename\"], _, post[\"format\"] = post[\"filename\"].rpartition(\".\")\n        if \"tags\" in post:\n            post[\"tags\"] = [t[\"value\"] for t in post[\"tags\"]]\n\n    def _tags(self, post, _):\n        if \"tags\" not in post:\n            post.update(self._fetch_post(post[\"id\"]))\n\n        tags = collections.defaultdict(list)\n        for tag in post[\"tags\"]:\n            tags[tag[\"type\"] or 1].append(tag[\"value\"])\n        types = self.TAG_TYPES\n        for type, values in tags.items():\n            post[\"tags_\" + types[type]] = values\n\n    def _fetch_post(self, post_id):\n        url = f\"{self.root}/api/v2/post/{post_id}\"\n        return self.request_json(url)\n\n    def _pagination(self, endpoint, params=None):\n        url = f\"{self.root}/api{endpoint}\"\n\n        if params is None:\n            params = {}\n        params[\"sortBy\"] = 0\n        params[\"take\"] = self.per_page\n        threshold = self.per_page\n\n        while True:\n            data = self.request_json(url, method=\"POST\", json=params)\n\n            yield from data[\"items\"]\n\n            if len(data[\"items\"]) < threshold:\n                return\n            params[\"cursor\"] = data.get(\"cursor\")\n\n\nclass Furry34PostExtractor(Furry34Extractor):\n    subcategory = \"post\"\n    archive_fmt = \"{id}\"\n    pattern = BASE_PATTERN + r\"/post/(\\d+)\"\n    example = \"https://furry34.com/post/12345\"\n\n    def posts(self):\n        return (self._fetch_post(self.groups[0]),)\n\n\nclass Furry34PlaylistExtractor(Furry34Extractor):\n    subcategory = \"playlist\"\n    directory_fmt = (\"{category}\", \"{playlist_id}\")\n    archive_fmt = \"p_{playlist_id}_{id}\"\n    pattern = BASE_PATTERN + r\"/playlists/view/(\\d+)\"\n    example = \"https://furry34.com/playlists/view/12345\"\n\n    def metadata(self):\n        return {\"playlist_id\": self.groups[0]}\n\n    def posts(self):\n        endpoint = \"/v2/post/search/playlist/\" + self.groups[0]\n        return self._pagination(endpoint)\n\n\nclass Furry34TagExtractor(Furry34Extractor):\n    subcategory = \"tag\"\n    directory_fmt = (\"{category}\", \"{search_tags}\")\n    archive_fmt = \"t_{search_tags}_{id}\"\n    pattern = BASE_PATTERN + r\"/(?:([^/?#]+))?(?:/?\\?([^#]+))?(?:$|#)\"\n    example = \"https://furry34.com/TAG\"\n\n    def _init(self):\n        tag, query = self.groups\n        params = text.parse_query(query)\n\n        self.tags = tags = []\n        if tag:\n            tags.extend(text.unquote(text.unquote(tag)).split(\"|\"))\n        if \"tags\" in params:\n            tags.extend(params[\"tags\"].split(\"|\"))\n\n        type = params.get(\"type\")\n        if type == \"video\":\n            self.type = 1\n        elif type == \"image\":\n            self.type = 0\n        else:\n            self.type = None\n\n    def metadata(self):\n        return {\"search_tags\": \" \".join(self.tags)}\n\n    def posts(self):\n        endpoint = \"/v2/post/search/root\"\n        params = {\"includeTags\": [t.replace(\"_\", \" \") for t in self.tags]}\n        if self.type is not None:\n            params[\"type\"] = self.type\n        return self._pagination(endpoint, params)\n"
  },
  {
    "path": "gallery_dl/extractor/fuskator.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2019-2025 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://fuskator.com/\"\"\"\n\nfrom .common import GalleryExtractor, Extractor, Message\nfrom .. import text\nimport time\n\n\nclass FuskatorGalleryExtractor(GalleryExtractor):\n    \"\"\"Extractor for image galleries on fuskator.com\"\"\"\n    category = \"fuskator\"\n    root = \"https://fuskator.com\"\n    pattern = r\"(?:https?://)?fuskator\\.com/(?:thumbs|expanded)/([^/?#]+)\"\n    example = \"https://fuskator.com/thumbs/ID/\"\n\n    def __init__(self, match):\n        self.gallery_hash = match[1]\n        url = f\"{self.root}/thumbs/{self.gallery_hash}/index.html\"\n        GalleryExtractor.__init__(self, match, url)\n\n    def metadata(self, page):\n        headers = {\n            \"Referer\"         : self.page_url,\n            \"X-Requested-With\": \"XMLHttpRequest\",\n        }\n        auth = self.request(\n            self.root + \"/ajax/auth.aspx\", method=\"POST\", headers=headers,\n        ).text\n\n        params = {\n            \"X-Auth\": auth,\n            \"hash\"  : self.gallery_hash,\n            \"_\"     : int(time.time()),\n        }\n        self.data = data = self.request_json(\n            self.root + \"/ajax/gal.aspx\", params=params, headers=headers)\n\n        title = text.extr(page, \"<title>\", \"</title>\").strip()\n        title, _, gallery_id = title.rpartition(\"#\")\n\n        return {\n            \"gallery_id\"  : text.parse_int(gallery_id),\n            \"gallery_hash\": self.gallery_hash,\n            \"title\"       : text.unescape(title[:-15]),\n            \"views\"       : data.get(\"hits\"),\n            \"score\"       : data.get(\"rating\"),\n            \"tags\"        : (data.get(\"tags\") or \"\").split(\",\"),\n        }\n\n    def images(self, page):\n        return [\n            (\"https:\" + image[\"imageUrl\"], image)\n            for image in self.data[\"images\"]\n        ]\n\n\nclass FuskatorSearchExtractor(Extractor):\n    \"\"\"Extractor for search results on fuskator.com\"\"\"\n    category = \"fuskator\"\n    subcategory = \"search\"\n    root = \"https://fuskator.com\"\n    pattern = r\"(?:https?://)?fuskator\\.com(/(?:search|page)/.+)\"\n    example = \"https://fuskator.com/search/TAG/\"\n\n    def __init__(self, match):\n        Extractor.__init__(self, match)\n        self.path = match[1]\n\n    def items(self):\n        url = self.root + self.path\n        data = {\"_extractor\": FuskatorGalleryExtractor}\n\n        while True:\n            page = self.request(url).text\n            for path in text.extract_iter(\n                    page, 'class=\"pic_pad\"><a href=\"', '\"'):\n                yield Message.Queue, self.root + path, data\n\n            pages = text.extr(page, 'class=\"pages\"><span>', '>&gt;&gt;<')\n            if not pages:\n                return\n            url = self.root + text.rextr(pages, 'href=\"', '\"')\n"
  },
  {
    "path": "gallery_dl/extractor/gelbooru.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2014-2026 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://gelbooru.com/\"\"\"\n\nfrom .common import Extractor, Message\nfrom . import gelbooru_v02\nfrom .. import text\nimport binascii\n\nBASE_PATTERN = r\"(?:https?://)?(?:www\\.)?gelbooru\\.com/(?:index\\.php)?\\?\"\n\n\nclass GelbooruBase():\n    \"\"\"Base class for gelbooru extractors\"\"\"\n    category = \"gelbooru\"\n    basecategory = \"booru\"\n    root = \"https://gelbooru.com\"\n    offset = 0\n\n    def _api_request(self, params, key=\"post\", log=False):\n        if \"s\" not in params:\n            params[\"s\"] = \"post\"\n\n        params[\"api_key\"] = self.api_key\n        params[\"user_id\"] = self.user_id\n\n        url = self.root + \"/index.php?page=dapi&q=index&json=1\"\n        try:\n            data = self.request_json(url, params=params)\n        except self.exc.HttpError as exc:\n            if exc.status == 401:\n                raise self.exc.AuthRequired(\n                    \"'api-key' & 'user-id'\", \"the API\")\n            raise\n\n        if not key:\n            return data\n\n        try:\n            posts = data[key]\n        except KeyError:\n            if log:\n                self.log.error(\"Incomplete API response (missing '%s')\", key)\n                self.log.debug(\"%s\", data)\n            return []\n\n        if not isinstance(posts, list):\n            return (posts,)\n        return posts\n\n    def _pagination(self, params):\n        params[\"pid\"] = self.page_start\n        params[\"limit\"] = self.per_page\n        limit = self.per_page // 2\n        pid = False\n\n        if \"tags\" in params:\n            tags = params[\"tags\"].split()\n            op = \"<\"\n            id = False\n\n            for tag in tags:\n                if tag.startswith(\"sort:\"):\n                    if tag == \"sort:id:asc\":\n                        op = \">\"\n                    elif tag == \"sort:id\" or tag.startswith(\"sort:id:\"):\n                        op = \"<\"\n                    else:\n                        pid = True\n                elif tag.startswith(\"id:\"):\n                    id = True\n\n            if not pid:\n                if id:\n                    tag = \"id:\" + op\n                    tags = [t for t in tags if not t.startswith(tag)]\n                tags = f\"{' '.join(tags)} id:{op}\"\n\n        while True:\n            posts = self._api_request(params)\n\n            yield from posts\n\n            if len(posts) < limit:\n                return\n\n            if pid:\n                params[\"pid\"] += 1\n            else:\n                if \"pid\" in params:\n                    del params[\"pid\"]\n                params[\"tags\"] = tags + str(posts[-1][\"id\"])\n\n    def _pagination_html(self, params):\n        url = self.root + \"/index.php\"\n        params[\"pid\"] = self.offset\n\n        data = {}\n        while True:\n            num_ids = 0\n            page = self.request(url, params=params).text\n\n            for data[\"id\"] in text.extract_iter(page, '\" id=\"p', '\"'):\n                num_ids += 1\n                yield from self._api_request(data)\n\n            if num_ids < self.per_page:\n                return\n            params[\"pid\"] += self.per_page\n\n    def _file_url(self, post):\n        url = post[\"file_url\"]\n        if url.endswith((\".webm\", \".mp4\")):\n            post[\"_fallback\"] = (url,)\n            md5 = post[\"md5\"]\n            root = text.root_from_url(post[\"preview_url\"])\n            path = f\"/images/{md5[0:2]}/{md5[2:4]}/{md5}.webm\"\n            url = root + path\n        return url\n\n    def _notes(self, post, page):\n        notes_data = text.extr(page, '<section id=\"notes\"', '</section>')\n        if not notes_data:\n            return\n\n        post[\"notes\"] = notes = []\n        extr = text.extract\n        for note in text.extract_iter(notes_data, '<article', '</article>'):\n            notes.append({\n                \"width\" : int(extr(note, 'data-width=\"', '\"')[0]),\n                \"height\": int(extr(note, 'data-height=\"', '\"')[0]),\n                \"x\"     : int(extr(note, 'data-x=\"', '\"')[0]),\n                \"y\"     : int(extr(note, 'data-y=\"', '\"')[0]),\n                \"body\"  : extr(note, 'data-body=\"', '\"')[0],\n            })\n\n    def _skip_offset(self, num):\n        self.offset += num\n        return num\n\n\nclass GelbooruTagExtractor(GelbooruBase,\n                           gelbooru_v02.GelbooruV02TagExtractor):\n    \"\"\"Extractor for images from gelbooru.com based on search-tags\"\"\"\n    pattern = BASE_PATTERN + r\"page=post&s=list&tags=([^&#]*)\"\n    example = \"https://gelbooru.com/index.php?page=post&s=list&tags=TAG\"\n\n\nclass GelbooruPoolExtractor(GelbooruBase,\n                            gelbooru_v02.GelbooruV02PoolExtractor):\n    \"\"\"Extractor for gelbooru pools\"\"\"\n    per_page = 45\n    skip_files = GelbooruBase._skip_offset\n    pattern = BASE_PATTERN + r\"page=pool&s=show&id=(\\d+)\"\n    example = \"https://gelbooru.com/index.php?page=pool&s=show&id=12345\"\n\n    def metadata(self):\n        url = self.root + \"/index.php\"\n        self._params = {\n            \"page\": \"pool\",\n            \"s\"   : \"show\",\n            \"id\"  : self.pool_id,\n        }\n        page = self.request(url, params=self._params).text\n\n        name, pos = text.extract(page, \"<h3>Now Viewing: \", \"</h3>\")\n        if not name:\n            raise self.exc.NotFoundError(\"pool\")\n\n        return {\n            \"pool\": text.parse_int(self.pool_id),\n            \"pool_name\": text.unescape(name),\n        }\n\n    def posts(self):\n        return self._pagination_html(self._params)\n\n\nclass GelbooruFavoriteExtractor(GelbooruBase,\n                                gelbooru_v02.GelbooruV02FavoriteExtractor):\n    \"\"\"Extractor for gelbooru favorites\"\"\"\n    per_page = 100\n    skip_files = GelbooruBase._skip_offset\n    pattern = BASE_PATTERN + r\"page=favorites&s=view&id=(\\d+)\"\n    example = \"https://gelbooru.com/index.php?page=favorites&s=view&id=12345\"\n\n    def posts(self):\n        # get number of favorites\n        params = {\n            \"s\"    : \"favorite\",\n            \"id\"   : self.favorite_id,\n            \"limit\": \"2\",\n        }\n        data = self._api_request(params, None, True)\n\n        count = data[\"@attributes\"][\"count\"]\n        self.log.debug(\"API reports %s favorite entries\", count)\n\n        favs = data[\"favorite\"]\n        try:\n            order = 1 if favs[0][\"id\"] < favs[1][\"id\"] else -1\n        except LookupError as exc:\n            self.log.debug(\n                \"Error when determining API favorite order (%s: %s)\",\n                exc.__class__.__name__, exc)\n            order = -1\n        else:\n            self.log.debug(\"API yields favorites in %sscending order\",\n                           \"a\" if order > 0 else \"de\")\n\n        order_favs = self.config(\"order-posts\")\n        if order_favs and order_favs[0] in {\"r\", \"a\"}:\n            self.log.debug(\"Returning them in reverse\")\n            order = -order\n\n        if order < 0:\n            return self._pagination(params, count)\n        return self._pagination_reverse(params, count)\n\n    def _pagination(self, params, count):\n        if self.offset:\n            pnum, skip = divmod(self.offset, self.per_page)\n        else:\n            pnum = skip = 0\n\n        params[\"pid\"] = pnum\n        params[\"limit\"] = self.per_page\n\n        while True:\n            favs = self._api_request(params, \"favorite\")\n\n            if not favs:\n                return\n\n            if skip:\n                favs = favs[skip:]\n                skip = 0\n\n            for fav in favs:\n                for post in self._api_request({\"id\": fav[\"favorite\"]}):\n                    post[\"date_favorited\"] = self.parse_timestamp(fav[\"added\"])\n                    yield post\n\n            params[\"pid\"] += 1\n\n    def _pagination_reverse(self, params, count):\n        pnum, last = divmod(count-1, self.per_page)\n        if self.offset > last:\n            # page number change\n            self.offset -= last\n            diff, self.offset = divmod(self.offset-1, self.per_page)\n            pnum -= diff + 1\n        skip = self.offset\n\n        params[\"pid\"] = pnum\n        params[\"limit\"] = self.per_page\n\n        while True:\n            favs = self._api_request(params, \"favorite\")\n            favs.reverse()\n\n            if skip:\n                favs = favs[skip:]\n                skip = 0\n\n            for fav in favs:\n                for post in self._api_request({\"id\": fav[\"favorite\"]}):\n                    post[\"date_favorited\"] = self.parse_timestamp(fav[\"added\"])\n                    yield post\n\n            params[\"pid\"] -= 1\n            if params[\"pid\"] < 0:\n                return\n\n\nclass GelbooruPostExtractor(GelbooruBase,\n                            gelbooru_v02.GelbooruV02PostExtractor):\n    \"\"\"Extractor for single images from gelbooru.com\"\"\"\n    pattern = (BASE_PATTERN +\n               r\"(?=(?:[^#]+&)?page=post(?:&|#|$))\"\n               r\"(?=(?:[^#]+&)?s=view(?:&|#|$))\"\n               r\"(?:[^#]+&)?id=(\\d+)\")\n    example = \"https://gelbooru.com/index.php?page=post&s=view&id=12345\"\n\n\nclass GelbooruRedirectExtractor(GelbooruBase, Extractor):\n    subcategory = \"redirect\"\n    pattern = (r\"(?:https?://)?(?:www\\.)?gelbooru\\.com\"\n               r\"/redirect\\.php\\?s=([^&#]+)\")\n    example = \"https://gelbooru.com/redirect.php?s=BASE64\"\n\n    def __init__(self, match):\n        Extractor.__init__(self, match)\n        self.url_base64 = match[1]\n\n    def items(self):\n        url = text.ensure_http_scheme(binascii.a2b_base64(\n            self.url_base64).decode())\n        data = {\"_extractor\": GelbooruPostExtractor}\n        yield Message.Queue, url, data\n"
  },
  {
    "path": "gallery_dl/extractor/gelbooru_v01.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2021-2025 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for Gelbooru Beta 0.1.11 sites\"\"\"\n\nfrom . import booru\nfrom .. import text\n\n\nclass GelbooruV01Extractor(booru.BooruExtractor):\n    basecategory = \"gelbooru_v01\"\n    per_page = 20\n\n    def _parse_post(self, post_id):\n        url = f\"{self.root}/index.php?page=post&s=view&id={post_id}\"\n        extr = text.extract_from(self.request(url).text)\n\n        post = {\n            \"id\"        : post_id,\n            \"created_at\": extr('Posted: ', ' <'),\n            \"uploader\"  : extr('By: ', ' <'),\n            \"width\"     : extr('Size: ', 'x'),\n            \"height\"    : extr('', ' <'),\n            \"source\"    : extr('Source: ', ' <'),\n            \"rating\"    : (extr('Rating: ', '<') or \"?\")[0].lower(),\n            \"score\"     : extr('Score: ', ' <'),\n            \"file_url\"  : extr('<img alt=\"img\" src=\"', '\"'),\n            \"tags\"      : text.unescape(extr(\n                'id=\"tags\" name=\"tags\" cols=\"40\" rows=\"5\">', '<')),\n        }\n\n        post[\"md5\"] = post[\"file_url\"].rpartition(\"/\")[2].partition(\".\")[0]\n        post[\"date\"] = self.parse_datetime_iso(post[\"created_at\"])\n\n        return post\n\n    def skip_files(self, num):\n        self.page_start += num\n        return num\n\n    def _pagination(self, url, begin, end):\n        pid = self.page_start\n\n        while True:\n            page = self.request(url + str(pid)).text\n\n            cnt = 0\n            for post_id in text.extract_iter(page, begin, end):\n                yield self._parse_post(post_id)\n                cnt += 1\n\n            if cnt < self.per_page:\n                return\n            pid += self.per_page\n\n\nBASE_PATTERN = GelbooruV01Extractor.update({\n    \"thecollection\": {\n        \"root\": \"https://the-collection.booru.org\",\n        \"pattern\": r\"the-collection\\.booru\\.org\",\n    },\n    \"illusioncardsbooru\": {\n        \"root\": \"https://illusioncards.booru.org\",\n        \"pattern\": r\"illusioncards\\.booru\\.org\",\n    },\n    \"allgirlbooru\": {\n        \"root\": \"https://allgirl.booru.org\",\n        \"pattern\": r\"allgirl\\.booru\\.org\",\n    },\n    \"drawfriends\": {\n        \"root\": \"https://drawfriends.booru.org\",\n        \"pattern\": r\"drawfriends\\.booru\\.org\",\n    },\n    \"vidyart2\": {\n        \"root\": \"https://vidyart2.booru.org\",\n        \"pattern\": r\"vidyart2\\.booru\\.org\",\n    },\n})\n\n\nclass GelbooruV01TagExtractor(GelbooruV01Extractor):\n    subcategory = \"tag\"\n    directory_fmt = (\"{category}\", \"{search_tags}\")\n    archive_fmt = \"t_{search_tags}_{id}\"\n    pattern = BASE_PATTERN + r\"/index\\.php\\?page=post&s=list&tags=([^&#]+)\"\n    example = \"https://allgirl.booru.org/index.php?page=post&s=list&tags=TAG\"\n\n    def metadata(self):\n        self.tags = tags = self.groups[-1]\n        return {\"search_tags\": text.unquote(tags.replace(\"+\", \" \"))}\n\n    def posts(self):\n        url = f\"{self.root}/index.php?page=post&s=list&tags={self.tags}&pid=\"\n        return self._pagination(url, 'class=\"thumb\"><a id=\"p', '\"')\n\n\nclass GelbooruV01FavoriteExtractor(GelbooruV01Extractor):\n    subcategory = \"favorite\"\n    directory_fmt = (\"{category}\", \"favorites\", \"{favorite_id}\")\n    archive_fmt = \"f_{favorite_id}_{id}\"\n    per_page = 50\n    pattern = BASE_PATTERN + r\"/index\\.php\\?page=favorites&s=view&id=(\\d+)\"\n    example = \"https://allgirl.booru.org/index.php?page=favorites&s=view&id=1\"\n\n    def metadata(self):\n        self.favorite_id = fav_id = self.groups[-1]\n        return {\"favorite_id\": text.parse_int(fav_id)}\n\n    def posts(self):\n        url = (f\"{self.root}/index.php\"\n               f\"?page=favorites&s=view&id={self.favorite_id}&pid=\")\n        return self._pagination(url, \"posts[\", \"]\")\n\n\nclass GelbooruV01PostExtractor(GelbooruV01Extractor):\n    subcategory = \"post\"\n    archive_fmt = \"{id}\"\n    pattern = BASE_PATTERN + r\"/index\\.php\\?page=post&s=view&id=(\\d+)\"\n    example = \"https://allgirl.booru.org/index.php?page=post&s=view&id=12345\"\n\n    def posts(self):\n        return (self._parse_post(self.groups[-1]),)\n"
  },
  {
    "path": "gallery_dl/extractor/gelbooru_v02.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2021-2025 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for Gelbooru Beta 0.2 sites\"\"\"\n\nfrom . import booru\nfrom .. import text, util\nimport collections\n\n\nclass GelbooruV02Extractor(booru.BooruExtractor):\n    basecategory = \"gelbooru_v02\"\n\n    def __init__(self, match):\n        booru.BooruExtractor.__init__(self, match)\n        self.request_interval = self.config_instance(\"request-interval\", 0.0)\n        self.root_api = self.config_instance(\"root-api\") or self.root\n\n    def _init(self):\n        self.api_key = self.config(\"api-key\")\n        self.user_id = self.config(\"user-id\")\n\n        if self.category == \"rule34\":\n            self._file_url = self._file_url_rule34\n\n    def _api_request(self, params):\n        params[\"api_key\"] = self.api_key\n        params[\"user_id\"] = self.user_id\n\n        url = self.root_api + \"/index.php?page=dapi&s=post&q=index\"\n        root = self.request_xml(url, params=params)\n\n        if root.tag == \"error\":\n            msg = root.text\n            if msg.lower().startswith(\"missing authentication\"):\n                raise self.exc.AuthRequired(\n                    \"'api-key' & 'user-id'\", \"the API\", msg)\n            raise self.exc.AbortExtraction(f\"'{msg}'\")\n\n        return root\n\n    def _pagination(self, params):\n        params[\"pid\"] = self.page_start\n        params[\"limit\"] = self.per_page\n\n        post = total = None\n        count = 0\n\n        while True:\n            try:\n                root = self._api_request(params)\n            except SyntaxError:  # ElementTree.ParseError\n                if \"tags\" not in params or post is None:\n                    raise\n                taglist = [tag for tag in params[\"tags\"].split()\n                           if not tag.startswith(\"id:<\")]\n                taglist.append(\"id:<\" + str(post.attrib[\"id\"]))\n                params[\"tags\"] = \" \".join(taglist)\n                params[\"pid\"] = 0\n                continue\n\n            if total is None:\n                try:\n                    self.kwdict[\"total\"] = total = int(root.attrib[\"count\"])\n                    if \"search_tags\" in self.kwdict:\n                        self.kwdict[\"search_count\"] = total\n                    self.log.debug(\"%s posts in total\", total)\n                except Exception as exc:\n                    total = 0\n                    self.log.debug(\n                        \"Failed to get total number of posts (%s: %s)\",\n                        exc.__class__.__name__, exc)\n\n            post = None\n            for post in root:\n                yield post.attrib\n\n            num = len(root)\n            count += num\n            if num < self.per_page:\n                if not total or count >= total:\n                    return\n                if not num:\n                    self.log.debug(\"Empty response - Retrying\")\n                    continue\n\n            params[\"pid\"] += 1\n\n    def _pagination_html(self, params):\n        url = self.root + \"/index.php\"\n        params[\"pid\"] = self.page_start * self.per_page\n\n        data = {}\n        find_ids = text.re(r\"\\sid=\\\"p(\\d+)\").findall\n\n        while True:\n            page = self.request(url, params=params).text\n            pids = find_ids(page)\n\n            for data[\"id\"] in pids:\n                for post in self._api_request(data):\n                    yield post.attrib\n\n            if len(pids) < self.per_page:\n                return\n            params[\"pid\"] += self.per_page\n\n    def _file_url_rule34(self, post):\n        url = post[\"file_url\"]\n\n        if text.ext_from_url(url) not in util.EXTS_VIDEO:\n            path = url.partition(\".\")[2]\n            post[\"_fallback\"] = (url,)\n            post[\"file_url\"] = url = \"https://wimg.\" + path\n\n        return url\n\n    def _prepare(self, post):\n        post[\"tags\"] = post[\"tags\"].strip()\n        post[\"date\"] = self.parse_datetime(\n            post[\"created_at\"], \"%a %b %d %H:%M:%S %z %Y\")\n\n    def _html(self, post):\n        url = f\"{self.root}/index.php?page=post&s=view&id={post['id']}\"\n        return self.request(url).text\n\n    def _tags(self, post, page):\n        tag_container = (text.extr(page, '<ul id=\"tag-', '</ul>') or\n                         text.extr(page, '<ul class=\"tag-', '</ul>'))\n        if not tag_container:\n            return\n\n        tags = collections.defaultdict(list)\n        pattern = text.re(r\"(?s)tag-type-([^\\\"' ]+).*?[?;]tags=([^\\\"'&]+)\")\n        for tag_type, tag_name in pattern.findall(tag_container):\n            tags[tag_type].append(text.unescape(text.unquote(tag_name)))\n        for key, value in tags.items():\n            post[\"tags_\" + key] = \" \".join(value)\n\n    def _notes(self, post, page):\n        note_container = text.extr(page, 'id=\"note-container\"', \"<img \")\n        if not note_container:\n            return\n\n        post[\"notes\"] = notes = []\n        for note in note_container.split('class=\"note-box\"')[1:]:\n            extr = text.extract_from(note)\n            notes.append({\n                \"width\" : int(extr(\"width:\", \"p\")),\n                \"height\": int(extr(\"height:\", \"p\")),\n                \"y\"     : int(extr(\"top:\", \"p\")),\n                \"x\"     : int(extr(\"left:\", \"p\")),\n                \"id\"    : int(extr('id=\"note-body-', '\"')),\n                \"body\"  : text.unescape(text.remove_html(extr(\">\", \"</div>\"))),\n            })\n\n\nBASE_PATTERN = GelbooruV02Extractor.update({\n    \"rule34\": {\n        \"root\": \"https://rule34.xxx\",\n        \"root-api\": \"https://api.rule34.xxx\",\n        \"request-interval\": 1.0,\n        \"pattern\": r\"(?:www\\.)?rule34\\.xxx\",\n    },\n    \"safebooru\": {\n        \"root\": \"https://safebooru.org\",\n        \"pattern\": r\"safebooru\\.org\",\n    },\n    \"tbib\": {\n        \"root\": \"https://tbib.org\",\n        \"pattern\": r\"tbib\\.org\",\n    },\n    \"hypnohub\": {\n        \"root\": \"https://hypnohub.net\",\n        \"pattern\": r\"hypnohub\\.net\",\n    },\n    \"xbooru\": {\n        \"root\": \"https://xbooru.com\",\n        \"pattern\": r\"xbooru\\.com\",\n    },\n})\n\n\nclass GelbooruV02TagExtractor(GelbooruV02Extractor):\n    subcategory = \"tag\"\n    directory_fmt = (\"{category}\", \"{search_tags}\")\n    archive_fmt = \"t_{search_tags}_{id}\"\n    pattern = BASE_PATTERN + r\"/index\\.php\\?page=post&s=list&tags=([^&#]*)\"\n    example = \"https://safebooru.org/index.php?page=post&s=list&tags=TAG\"\n\n    def posts(self):\n        self.kwdict[\"search_tags\"] = tags = text.unquote(\n            self.groups[-1].replace(\"+\", \" \"))\n\n        if tags == \"all\":\n            tags = \"\"\n        return self._pagination({\"tags\": tags})\n\n\nclass GelbooruV02PoolExtractor(GelbooruV02Extractor):\n    subcategory = \"pool\"\n    directory_fmt = (\"{category}\", \"pool\", \"{pool}\")\n    archive_fmt = \"p_{pool}_{id}\"\n    pattern = BASE_PATTERN + r\"/index\\.php\\?page=pool&s=show&id=(\\d+)\"\n    example = \"https://safebooru.org/index.php?page=pool&s=show&id=12345\"\n\n    def __init__(self, match):\n        GelbooruV02Extractor.__init__(self, match)\n        self.pool_id = self.groups[-1]\n\n        if self.category == \"rule34\":\n            self.posts = self._posts_pages\n            self.per_page = 45\n        else:\n            self.post_ids = ()\n\n    def skip_files(self, num):\n        self.page_start += num\n        return num\n\n    def metadata(self):\n        url = f\"{self.root}/index.php?page=pool&s=show&id={self.pool_id}\"\n        page = self.request(url).text\n\n        name, pos = text.extract(page, \"<h4>Pool: \", \"</h4>\")\n        if not name:\n            raise self.exc.NotFoundError(\"pool\")\n        self.post_ids = text.extract_iter(\n            page, 'class=\"thumb\" id=\"p', '\"', pos)\n\n        return {\n            \"pool\": text.parse_int(self.pool_id),\n            \"pool_name\": text.unescape(name),\n        }\n\n    def posts(self):\n        params = {}\n        for params[\"id\"] in util.advance(self.post_ids, self.page_start):\n            for post in self._api_request(params):\n                yield post.attrib\n\n    def _posts_pages(self):\n        return self._pagination_html({\n            \"page\": \"pool\",\n            \"s\"   : \"show\",\n            \"id\"  : self.pool_id,\n        })\n\n\nclass GelbooruV02FavoriteExtractor(GelbooruV02Extractor):\n    subcategory = \"favorite\"\n    directory_fmt = (\"{category}\", \"favorites\", \"{favorite_id}\")\n    archive_fmt = \"f_{favorite_id}_{id}\"\n    per_page = 50\n    pattern = BASE_PATTERN + r\"/index\\.php\\?page=favorites&s=view&id=(\\d+)\"\n    example = \"https://safebooru.org/index.php?page=favorites&s=view&id=12345\"\n\n    def metadata(self):\n        self.favorite_id = fav_id = self.groups[-1]\n        return {\"favorite_id\": text.parse_int(fav_id)}\n\n    def posts(self):\n        return self._pagination_html({\n            \"page\": \"favorites\",\n            \"s\"   : \"view\",\n            \"id\"  : self.favorite_id,\n        })\n\n\nclass GelbooruV02PostExtractor(GelbooruV02Extractor):\n    subcategory = \"post\"\n    archive_fmt = \"{id}\"\n    pattern = BASE_PATTERN + r\"/index\\.php\\?page=post&s=view&id=(\\d+)\"\n    example = \"https://safebooru.org/index.php?page=post&s=view&id=12345\"\n\n    def posts(self):\n        return self._pagination({\"id\": self.groups[-1]})\n"
  },
  {
    "path": "gallery_dl/extractor/generic.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Generic information extractor\"\"\"\n\nfrom .common import Extractor, Message\nfrom .. import config, text\nimport os.path\n\n\nclass GenericExtractor(Extractor):\n    \"\"\"Extractor for images in a generic web page.\"\"\"\n    category = \"generic\"\n    directory_fmt = (\"{category}\", \"{subcategory}\", \"{path}\")\n    archive_fmt = \"{imageurl}\"\n\n    # By default, the generic extractor is disabled\n    # and the \"g(eneric):\" prefix in url is required.\n    # If the extractor is enabled, make the prefix optional\n    pattern = r\"(?i)(?P<generic>g(?:eneric)?:)\"\n    if config.get((\"extractor\", \"generic\"), \"enabled\"):\n        pattern += r\"?\"\n\n    # The generic extractor pattern should match (almost) any valid url\n    # Based on: https://tools.ietf.org/html/rfc3986#appendix-B\n    pattern += (\n        r\"(?P<scheme>https?://)?\"          # optional http(s) scheme\n        r\"(?P<domain>[-\\w\\.]+)\"            # required domain\n        r\"(?P<path>/[^?#]*)?\"              # optional path\n        r\"(?:\\?(?P<query>[^#]*))?\"         # optional query\n        r\"(?:\\#(?P<fragment>.*))?\"         # optional fragment\n    )\n    example = \"generic:https://www.nongnu.org/lzip/\"\n\n    def __init__(self, match):\n        self.subcategory = match['domain']\n        Extractor.__init__(self, match)\n\n        # Strip the \"g(eneric):\" prefix\n        # and inform about \"forced\" or \"fallback\" mode\n        if match['generic']:\n            self.url = match[0].partition(\":\")[2]\n        else:\n            self.log.info(\"Falling back on generic information extractor.\")\n            self.url = match[0]\n\n        # Make sure we have a scheme, or use https\n        if match['scheme']:\n            self.scheme = match['scheme']\n        else:\n            self.scheme = 'https://'\n            self.url = text.ensure_http_scheme(self.url, self.scheme)\n\n        self.path = match['path']\n\n        # Used to resolve relative image urls\n        self.root = self.scheme + match['domain']\n\n    def items(self):\n        \"\"\"Get page, extract metadata & images, yield them in suitable messages\n\n        Adapted from common.GalleryExtractor.items()\n\n        \"\"\"\n        page = self.request(self.url).text\n        data = self.metadata(page)\n        imgs = self.images(page)\n\n        try:\n            data[\"count\"] = len(imgs)\n        except TypeError:\n            pass\n        images = enumerate(imgs, 1)\n\n        yield Message.Directory, \"\", data\n\n        for data[\"num\"], (url, imgdata) in images:\n            if imgdata:\n                data.update(imgdata)\n                if \"extension\" not in imgdata:\n                    text.nameext_from_url(url, data)\n            else:\n                text.nameext_from_url(url, data)\n            yield Message.Url, url, data\n\n    def metadata(self, page):\n        \"\"\"Extract generic webpage metadata, return them in a dict.\"\"\"\n        data = {\n            \"title\"         : text.extr(\n                page, \"<title>\", \"</title>\"),\n            \"description\"   : text.extr(\n                page, '<meta name=\"description\" content=\"', '\"'),\n            \"keywords\"      : text.extr(\n                page, '<meta name=\"keywords\" content=\"', '\"'),\n            \"language\"      : text.extr(\n                page, '<meta name=\"language\" content=\"', '\"'),\n            \"name\"          : text.extr(\n                page, '<meta itemprop=\"name\" content=\"', '\"'),\n            \"copyright\"     : text.extr(\n                page, '<meta name=\"copyright\" content=\"', '\"'),\n            \"og_site\"       : text.extr(\n                page, '<meta property=\"og:site\" content=\"', '\"'),\n            \"og_site_name\"  : text.extr(\n                page, '<meta property=\"og:site_name\" content=\"', '\"'),\n            \"og_title\"      : text.extr(\n                page, '<meta property=\"og:title\" content=\"', '\"'),\n            \"og_description\": text.extr(\n                page, '<meta property=\"og:description\" content=\"', '\"'),\n\n        }\n\n        data = {k: text.unescape(v) for k, v in data.items() if v}\n        data[\"path\"] = self.path.replace(\"/\", \"\")\n        data[\"pageurl\"] = self.url\n\n        return data\n\n    def images(self, page):\n        \"\"\"Extract image urls, return a list of (image url, metadata) tuples.\n\n        The extractor aims at finding as many _likely_ image urls as possible,\n        using two strategies (see below); since these often overlap, any\n        duplicate urls will be removed at the end of the process.\n\n        Note: since we are using re.findall() (see below), it's essential that\n        the following patterns contain 0 or at most 1 capturing group, so that\n        re.findall() return a list of urls (instead of a list of tuples of\n        matching groups). All other groups used in the pattern should be\n        non-capturing (?:...).\n\n        1: Look in src/srcset attributes of img/video/source elements\n\n        See:\n        https://www.w3schools.com/tags/att_src.asp\n        https://www.w3schools.com/tags/att_source_srcset.asp\n\n        We allow both absolute and relative urls here.\n\n        Note that srcset attributes often contain multiple space separated\n        image urls; this pattern matches only the first url; remaining urls\n        will be matched by the \"imageurl_pattern_ext\" pattern below.\n        \"\"\"\n\n        imageurl_pattern_src = (\n            r\"(?i)\"\n            r\"<(?:img|video|source)\\s[^>]*\"    # <img>, <video> or <source>\n            r\"src(?:set)?=[\\\"']?\"              # src or srcset attributes\n            r\"(?P<URL>[^\\\"'\\s>]+)\"             # url\n        )\n\n        \"\"\"\n        2: Look anywhere for urls containing common image/video extensions\n\n        The list of allowed extensions is borrowed from the directlink.py\n        extractor; other could be added, see\n        https://en.wikipedia.org/wiki/List_of_file_formats\n\n        Compared to the \"pattern\" class variable, here we must exclude also\n        other special characters (space, \", ', <, >), since we are looking for\n        urls in html tags.\n        \"\"\"\n\n        imageurl_pattern_ext = (\n            r\"(?i)\"\n            r\"(?:[^?&#\\\"'>\\s]+)\"           # anything until dot+extension\n                                           # dot + image/video extensions\n            r\"\\.(?:jpe?g|jpe|png|gif|web[mp]|mp4|mkv|og[gmv]|opus)\"\n            r\"(?:[^\\\"'<>\\s]*)?\"            # optional query and fragment\n        )\n\n        imageurls_src = text.re(imageurl_pattern_src).findall(page)\n        imageurls_ext = text.re(imageurl_pattern_ext).findall(page)\n        imageurls = imageurls_src + imageurls_ext\n\n        # Resolve relative urls\n        #\n        # Image urls catched so far may be relative, so we must resolve them\n        # by prepending a suitable base url.\n        #\n        # If the page contains a <base> element, use it as base url\n        basematch = text.re(\n            r\"(?i)(?:<base\\s.*?href=[\\\"']?)(?P<url>[^\\\"' >]+)\").search(page)\n        if basematch:\n            self.baseurl = basematch['url'].rstrip('/')\n        # Otherwise, extract the base url from self.url\n        else:\n            if self.url.endswith(\"/\"):\n                self.baseurl = self.url.rstrip('/')\n            else:\n                self.baseurl = os.path.dirname(self.url)\n\n        # Build the list of absolute image urls\n        absimageurls = []\n        for u in imageurls:\n            # Absolute urls are taken as-is\n            if u.startswith('http'):\n                absimageurls.append(u)\n            # // relative urls are prefixed with current scheme\n            elif u.startswith('//'):\n                absimageurls.append(self.scheme + u.lstrip('/'))\n            # / relative urls are prefixed with current scheme+domain\n            elif u.startswith('/'):\n                absimageurls.append(self.root + u)\n            # other relative urls are prefixed with baseurl\n            else:\n                absimageurls.append(self.baseurl + '/' + u)\n\n        # Remove duplicates\n        absimageurls = dict.fromkeys(absimageurls)\n\n        # Create the image metadata dict and add imageurl to it\n        # (image filename and extension are added by items())\n        images = [(u, {'imageurl': u}) for u in absimageurls]\n\n        return images\n"
  },
  {
    "path": "gallery_dl/extractor/girlsreleased.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://girlsreleased.com/\"\"\"\n\nfrom .common import Extractor, Message\nfrom .. import text\nimport itertools\n\nBASE_PATTERN = r\"(?:https?://)?(?:www\\.)?girlsreleased\\.com\"\n\n\nclass GirlsreleasedExtractor(Extractor):\n    \"\"\"Base class for girlsreleased extractors\"\"\"\n    category = \"girlsreleased\"\n    root = \"https://girlsreleased.com\"\n    request_interval = 0.5\n    request_interval_min = 0.2\n\n    def items(self):\n        data = {\"_extractor\": GirlsreleasedSetExtractor}\n        base = self.root + \"/set/\"\n        for set in self._pagination():\n            yield Message.Queue, base + set[0], data\n\n    def _pagination(self):\n        base = f\"{self.root}/api/0.2/sets/{self._path}/{self.groups[0]}/page/\"\n        for pnum in itertools.count():\n            sets = self.request_json(base + str(pnum))[\"sets\"]\n            if not sets:\n                return\n\n            yield from sets[1:] if pnum else sets\n            if len(sets) < 80:\n                return\n\n\nclass GirlsreleasedSetExtractor(GirlsreleasedExtractor):\n    \"\"\"Extractor for girlsreleased galleries\"\"\"\n    subcategory = \"set\"\n    pattern = BASE_PATTERN + r\"/set/(\\d+)\"\n    example = \"https://girlsreleased.com/set/12345\"\n\n    def items(self):\n        url = f\"{self.root}/api/0.2/set/{self.groups[0]}\"\n        json = self.request_json(url)[\"set\"]\n        data = {\n            \"title\": json[\"name\"] or json[\"id\"],\n            \"id\": json[\"id\"],\n            \"site\": json[\"site\"],\n            \"model\": [model for _, model in json[\"models\"]],\n            \"date\": self.parse_timestamp(json[\"date\"]),\n            \"count\": len(json[\"images\"]),\n            \"url\": \"https://girlsreleased.com/set/\" + json[\"id\"],\n        }\n        yield Message.Directory, \"\", data\n        for data[\"num\"], image in enumerate(json[\"images\"], 1):\n            text.nameext_from_url(image[5], data)\n            yield Message.Queue, image[3], data\n\n\nclass GirlsreleasedModelExtractor(GirlsreleasedExtractor):\n    \"\"\"Extractor for girlsreleased models\"\"\"\n    subcategory = _path = \"model\"\n    pattern = BASE_PATTERN + r\"/model/(\\d+(?:/.+)?)\"\n    example = \"https://girlsreleased.com/model/12345/MODEL\"\n\n\nclass GirlsreleasedSiteExtractor(GirlsreleasedExtractor):\n    \"\"\"Extractor for girlsreleased sites\"\"\"\n    subcategory = _path = \"site\"\n    pattern = BASE_PATTERN + r\"/site/([^/?#]+(?:/model/\\d+/?.*)?)\"\n    example = \"https://girlsreleased.com/site/SITE\"\n"
  },
  {
    "path": "gallery_dl/extractor/girlswithmuscle.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom .common import Extractor, Message\nfrom .. import text\n\nBASE_PATTERN = r\"(?:https?://)?(?:www\\.)?girlswithmuscle\\.com\"\n\n\nclass GirlswithmuscleExtractor(Extractor):\n    \"\"\"Base class for girlswithmuscle extractors\"\"\"\n    category = \"girlswithmuscle\"\n    root = \"https://www.girlswithmuscle.com\"\n    directory_fmt = (\"{category}\", \"{model}\")\n    filename_fmt = \"{model}_{id}.{extension}\"\n    archive_fmt = \"{type}_{model}_{id}\"\n\n    def login(self):\n        username, password = self._get_auth_info()\n        if username:\n            return self.cookies_update(self.cache(\n                self._login_impl, username, password,\n                _exp=14*86400, _mem=False))\n\n    def _login_impl(self, username, password):\n        self.log.info(\"Logging in as %s\", username)\n\n        url = self.root + \"/login/\"\n        page = self.request(url).text\n        csrf_token = text.extr(page, 'name=\"csrfmiddlewaretoken\" value=\"', '\"')\n\n        headers = {\n            \"Origin\" : self.root,\n            \"Referer\": url,\n        }\n        data = {\n            \"csrfmiddlewaretoken\": csrf_token,\n            \"username\": username,\n            \"password\": password,\n            \"next\": \"/\",\n        }\n        response = self.request(\n            url, method=\"POST\", headers=headers, data=data)\n\n        if not response.history:\n            raise self.exc.AuthenticationError()\n\n        page = response.text\n        if \">Wrong username or password\" in page:\n            raise self.exc.AuthenticationError()\n        if \">Log in<\" in page:\n            raise self.exc.AuthenticationError(\"Account data is missing\")\n\n        return {c.name: c.value for c in response.history[0].cookies}\n\n\nclass GirlswithmusclePostExtractor(GirlswithmuscleExtractor):\n    \"\"\"Extractor for individual posts on girlswithmuscle.com\"\"\"\n    subcategory = \"post\"\n    pattern = BASE_PATTERN + r\"/(\\d+)\"\n    example = \"https://www.girlswithmuscle.com/12345/\"\n\n    def items(self):\n        self.login()\n\n        url = f\"{self.root}/{self.groups[0]}/\"\n        page = self.request(url).text\n        if not page:\n            raise self.exc.NotFoundError(\"post\")\n\n        metadata = self.metadata(page)\n\n        if url := text.extr(page, 'class=\"main-image\" src=\"', '\"'):\n            metadata[\"type\"] = \"picture\"\n        else:\n            url = text.extr(page, '<source src=\"', '\"')\n            metadata[\"type\"] = \"video\"\n\n        text.nameext_from_url(url, metadata)\n        yield Message.Directory, \"\", metadata\n        yield Message.Url, url, metadata\n\n    def metadata(self, page):\n        source = text.remove_html(text.extr(\n            page, '<div id=\"info-source\" style=\"display: none\">', \"</div>\"))\n        image_info = text.extr(\n            page, '<div class=\"image-info\">', \"</div>\")\n        uploader = text.remove_html(text.extr(\n            image_info, '<span class=\"username-html\">', \"</a>\"))\n\n        tags = text.extr(page, 'id=\"tags-text\">', \"</div>\")\n        score = text.parse_int(text.remove_html(text.extr(\n            page, \"Score: <b>\", \"</span\")))\n        model = self._extract_model(page)\n\n        return {\n            \"id\": self.groups[0],\n            \"model\": model,\n            \"model_list\": self._parse_model_list(model),\n            \"tags\": text.split_html(tags)[1::2],\n            \"date\": self.parse_datetime_iso(text.extr(\n                page, 'class=\"hover-time\"  title=\"', '\"')[:19]),\n            \"is_favorite\": self._parse_is_favorite(page),\n            \"source_filename\": source,\n            \"uploader\": uploader,\n            \"score\": score,\n            \"comments\": self._extract_comments(page),\n        }\n\n    def _extract_model(self, page):\n        model = text.extr(page, \"<title>\", \"</title>\")\n        return \"unknown\" if model.startswith(\"Picture #\") else model\n\n    def _parse_model_list(self, model):\n        if model == \"unknown\":\n            return []\n        else:\n            return [name.strip() for name in model.split(\",\")]\n\n    def _parse_is_favorite(self, page):\n        fav_button = text.extr(\n            page, 'id=\"favorite-button\">', \"</span>\")\n        unfav_button = text.extr(\n            page, 'class=\"actionbutton unfavorite-button\">', \"</span>\")\n\n        is_favorite = None\n        if unfav_button == \"Unfavorite\":\n            is_favorite = True\n        if fav_button == \"Favorite\":\n            is_favorite = False\n\n        return is_favorite\n\n    def _extract_comments(self, page):\n        comments = text.extract_iter(\n            page, '<div class=\"comment-body-inner\">', \"</div>\")\n        return [comment.strip() for comment in comments]\n\n\nclass GirlswithmuscleSearchExtractor(GirlswithmuscleExtractor):\n    \"\"\"Extractor for search results on girlswithmuscle.com\"\"\"\n    subcategory = \"search\"\n    pattern = BASE_PATTERN + r\"/images/(.*)\"\n    example = \"https://www.girlswithmuscle.com/images/?name=MODEL\"\n\n    def pages(self):\n        query = self.groups[0]\n        url = f\"{self.root}/images/{query}\"\n        response = self.request(url)\n        if response.history:\n            msg = f'Request was redirected to \"{response.url}\", try logging in'\n            raise self.exc.AuthorizationError(msg)\n        page = response.text\n\n        match = text.re(r\"Page (\\d+) of (\\d+)\").search(page)\n        current, total = match.groups()\n        current, total = text.parse_int(current), text.parse_int(total)\n\n        yield page\n        for i in range(current + 1, total + 1):\n            url = f\"{self.root}/images/{i}/{query}\"\n            yield self.request(url).text\n\n    def items(self):\n        self.login()\n        for page in self.pages():\n            data = {\n                \"_extractor\"  : GirlswithmusclePostExtractor,\n                \"gallery_name\": text.unescape(text.extr(page, \"<title>\", \"<\")),\n            }\n            for imgid in text.extract_iter(page, 'id=\"imgid-', '\"'):\n                url = f\"{self.root}/{imgid}/\"\n                yield Message.Queue, url, data\n"
  },
  {
    "path": "gallery_dl/extractor/gofile.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://gofile.io/\"\"\"\n\nfrom .common import Extractor, Message\nimport hashlib\nimport time\n\n\nclass GofileFolderExtractor(Extractor):\n    category = \"gofile\"\n    subcategory = \"folder\"\n    root = \"https://gofile.io\"\n    directory_fmt = (\"{category}\", \"{name} ({code})\")\n    archive_fmt = \"{id}\"\n    pattern = r\"(?:https?://)?(?:www\\.)?gofile\\.io/d/([^/?#]+)\"\n    example = \"https://gofile.io/d/ID\"\n\n    def items(self):\n        recursive = self.config(\"recursive\", True)\n        password = self.config(\"password\")\n\n        token = self.config(\"api-token\")\n        if not token:\n            token = self.cache(self._create_account, _key=None)\n        self.cookies.set(\"accountToken\", token, domain=\".gofile.io\")\n        self.api_token = token\n\n        folder = self._get_content(self.groups[0], password)\n        yield Message.Directory, \"\", folder\n\n        try:\n            contents = folder.pop(\"children\")\n        except KeyError:\n            raise self.exc.AuthorizationError(\"Password required\")\n\n        num = 0\n        for content in contents.values():\n            content[\"folder\"] = folder\n\n            if content[\"type\"] == \"file\":\n                num += 1\n                content[\"num\"] = num\n                content[\"filename\"], _, content[\"extension\"] = \\\n                    content[\"name\"].rpartition(\".\")\n                yield Message.Url, content[\"link\"], content\n\n            elif content[\"type\"] == \"folder\":\n                if recursive:\n                    url = \"https://gofile.io/d/\" + content[\"id\"]\n                    content[\"_extractor\"] = GofileFolderExtractor\n                    yield Message.Queue, url, content\n                else:\n                    self.log.debug(\"Skipping subfolder '%s'\", content[\"id\"])\n            else:\n                self.log.debug(\"'%s' is of unknown type (%s)\",\n                               content.get(\"name\"), content[\"type\"])\n\n    def request_api(self, endpoint, params=None, headers=None, method=\"GET\"):\n        if headers is None:\n            headers = {}\n        headers[\"Referer\"] = self.root + \"/\"\n        headers[\"Origin\"] = self.root\n\n        response = self.request_json(\n            \"https://api.gofile.io\" + endpoint,\n            method=method, params=params, headers=headers)\n\n        if response[\"status\"] != \"ok\":\n            if response[\"status\"] == \"error-notFound\":\n                raise self.exc.NotFoundError(\"content\")\n            if response[\"status\"] == \"error-passwordRequired\":\n                raise self.exc.AuthorizationError(\"Password required\")\n            raise self.exc.AbortExtraction(\n                f\"{endpoint} failed (Status: {response['status']})\")\n\n        return response[\"data\"]\n\n    def _create_account(self):\n        self.log.debug(\"Creating temporary account\")\n        return self.request_api(\"/accounts\", method=\"POST\")[\"token\"]\n\n    def _generate_website_token(self, lang=\"en-US\"):\n        # https://gofile.io/dist/js/wt.obf.js\n        data = (f\"{self.session.headers['User-Agent']}::\"\n                f\"{lang}::\"\n                f\"{self.api_token}::\"\n                f\"{int(time.time() / 14400)}::\"\n                f\"f4s58gs6\")\n        return hashlib.sha256(data.encode()).hexdigest()\n\n    def _get_content(self, content_id, password=None):\n        params = {\n            \"contentFilter\": \"\"\t,\n            \"page\"         : \"1\",\n            \"pageSize\"     : \"1000\",\n            \"sortField\"    : \"name\",\n            \"sortDirection\": \"1\",\n            \"password\"     : None if password is None else\n                             hashlib.sha256(password.encode()).hexdigest(),\n        }\n        headers = {\n            \"Authorization\"  : \"Bearer \" + self.api_token,\n            \"X-Website-Token\": self._generate_website_token(\"en-US\"),\n            \"X-BL\"           : \"en-US\",\n        }\n        return self.request_api(\"/contents/\" + content_id, params, headers)\n"
  },
  {
    "path": "gallery_dl/extractor/hatenablog.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://hatenablog.com\"\"\"\n\nfrom .common import Extractor, Message\nfrom .. import text\n\n\nBASE_PATTERN = (\n    r\"(?:hatenablog:https?://([^/?#]+)|(?:https?://)?\"\n    r\"([\\w-]+\\.(?:hatenablog\\.(?:com|jp)\"\n    r\"|hatenadiary\\.com|hateblo\\.jp)))\"\n)\nQUERY_RE = r\"(?:\\?([^#]*))?(?:#.*)?$\"\n\n\nclass HatenablogExtractor(Extractor):\n    \"\"\"Base class for HatenaBlog extractors\"\"\"\n    category = \"hatenablog\"\n    directory_fmt = (\"{category}\", \"{domain}\")\n    filename_fmt = \"{category}_{domain}_{entry}_{num:>02}.{extension}\"\n    archive_fmt = \"{filename}\"\n\n    def __init__(self, match):\n        Extractor.__init__(self, match)\n        self.domain = match[1] or match[2]\n\n    def _init(self):\n        self._find_img = text.re(r'<img +([^>]+)').finditer\n\n    def _handle_article(self, article: str):\n        extr = text.extract_from(article)\n        date = self.parse_datetime_iso(extr('<time datetime=\"', '\"'))\n        entry_link = text.unescape(extr('<a href=\"', '\"'))\n        entry = entry_link.partition(\"/entry/\")[2]\n        title = text.unescape(extr('>', '<'))\n        content = extr(\n            '<div class=\"entry-content hatenablog-entry\">', '</div>')\n\n        images = []\n        for match in self._find_img(content):\n            attributes = match[1]\n            if 'class=\"hatena-fotolife\"' not in attributes:\n                continue\n            image = text.unescape(text.extr(attributes, 'src=\"', '\"'))\n            images.append(image)\n\n        data = {\n            \"domain\": self.domain,\n            \"date\": date,\n            \"entry\": entry,\n            \"title\": title,\n            \"count\": len(images),\n        }\n        yield Message.Directory, \"\", data\n        for data[\"num\"], url in enumerate(images, 1):\n            yield Message.Url, url, text.nameext_from_url(url, data)\n\n\nclass HatenablogEntriesExtractor(HatenablogExtractor):\n    \"\"\"Base class for a list of entries\"\"\"\n    allowed_parameters = ()\n\n    def __init__(self, match):\n        HatenablogExtractor.__init__(self, match)\n        self.path = match[3]\n        self.query = {key: value for key, value in text.parse_query(\n            match[4]).items() if self._acceptable_query(key)}\n\n    def _init(self):\n        HatenablogExtractor._init(self)\n        self._find_pager_url = text.re(\n            r' class=\"pager-next\">\\s*<a href=\"([^\"]+)').search\n\n    def items(self):\n        url = \"https://\" + self.domain + self.path\n        query = self.query\n\n        while url:\n            page = self.request(url, params=query).text\n\n            extr = text.extract_from(page)\n            attributes = extr('<body ', '>')\n            if \"page-archive\" in attributes:\n                yield from self._handle_partial_articles(extr)\n            else:\n                yield from self._handle_full_articles(extr)\n\n            match = self._find_pager_url(page)\n            url = text.unescape(match[1]) if match else None\n            query = None\n\n    def _handle_partial_articles(self, extr):\n        while True:\n            section = extr('<section class=\"archive-entry', '</section>')\n            if not section:\n                break\n\n            url = \"hatenablog:\" + text.unescape(text.extr(\n                section, '<a class=\"entry-title-link\" href=\"', '\"'))\n            data = {\"_extractor\": HatenablogEntryExtractor}\n            yield Message.Queue, url, data\n\n    def _handle_full_articles(self, extr):\n        while True:\n            attributes = extr('<article ', '>')\n            if not attributes:\n                break\n            if \"no-entry\" in attributes:\n                continue\n\n            article = extr('', '</article>')\n            yield from self._handle_article(article)\n\n    def _acceptable_query(self, key):\n        return key == \"page\" or key in self.allowed_parameters\n\n\nclass HatenablogEntryExtractor(HatenablogExtractor):\n    \"\"\"Extractor for a single entry URL\"\"\"\n    subcategory = \"entry\"\n    pattern = BASE_PATTERN + r\"/entry/([^?#]+)\" + QUERY_RE\n    example = \"https://BLOG.hatenablog.com/entry/PATH\"\n\n    def __init__(self, match):\n        HatenablogExtractor.__init__(self, match)\n        self.path = match[3]\n\n    def items(self):\n        url = \"https://\" + self.domain + \"/entry/\" + self.path\n        page = self.request(url).text\n\n        extr = text.extract_from(page)\n        while True:\n            attributes = extr('<article ', '>')\n            if \"no-entry\" in attributes:\n                continue\n            article = extr('', '</article>')\n            return self._handle_article(article)\n\n\nclass HatenablogHomeExtractor(HatenablogEntriesExtractor):\n    \"\"\"Extractor for a blog's home page\"\"\"\n    subcategory = \"home\"\n    pattern = BASE_PATTERN + r\"(/?)\" + QUERY_RE\n    example = \"https://BLOG.hatenablog.com\"\n\n\nclass HatenablogArchiveExtractor(HatenablogEntriesExtractor):\n    \"\"\"Extractor for a blog's archive page\"\"\"\n    subcategory = \"archive\"\n    pattern = (BASE_PATTERN + r\"(/archive(?:/\\d+(?:/\\d+(?:/\\d+)?)?\"\n               r\"|/category/[^?#]+)?)\" + QUERY_RE)\n    example = \"https://BLOG.hatenablog.com/archive/2024\"\n\n\nclass HatenablogSearchExtractor(HatenablogEntriesExtractor):\n    \"\"\"Extractor for a blog's search results\"\"\"\n    subcategory = \"search\"\n    pattern = BASE_PATTERN + r\"(/search)\" + QUERY_RE\n    example = \"https://BLOG.hatenablog.com/search?q=QUERY\"\n    allowed_parameters = (\"q\",)\n"
  },
  {
    "path": "gallery_dl/extractor/hdoujin.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2025 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://hdoujin.org/\"\"\"\n\nfrom . import schalenetwork\n\nBASE_PATTERN = r\"(?:https?://)?(?:www\\.)?(hdoujin\\.(?:org|net))\"\n\n\nclass HdoujinBase():\n    \"\"\"Base class for hdoujin extractors\"\"\"\n    category = \"hdoujin\"\n    root = \"https://hdoujin.org\"\n    root_api = \"https://api.hdoujin.org\"\n    root_auth = \"https://auth.hdoujin.org\"\n\n\nclass HdoujinGalleryExtractor(\n        HdoujinBase, schalenetwork.SchalenetworkGalleryExtractor):\n    pattern = BASE_PATTERN + r\"/(?:g|reader)/(\\d+)/(\\w+)\"\n    example = \"https://hdoujin.org/g/12345/67890abcdef/\"\n\n\nclass HdoujinSearchExtractor(\n        HdoujinBase, schalenetwork.SchalenetworkSearchExtractor):\n    pattern = BASE_PATTERN + r\"/(?:tag/([^/?#]+)|browse)?(?:/?\\?([^#]*))?$\"\n    example = \"https://hdoujin.org/browse?s=QUERY\"\n\n\nclass HdoujinFavoriteExtractor(\n        HdoujinBase, schalenetwork.SchalenetworkFavoriteExtractor):\n    pattern = BASE_PATTERN + r\"/favorites(?:\\?([^#]*))?\"\n    example = \"https://hdoujin.org/favorites\"\n\n\nHdoujinBase.extr_class = HdoujinGalleryExtractor\n"
  },
  {
    "path": "gallery_dl/extractor/hentai2read.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2016-2025 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://hentai2read.com/\"\"\"\n\nfrom .common import ChapterExtractor, MangaExtractor\nfrom .. import text, util\n\n\nclass Hentai2readBase():\n    \"\"\"Base class for hentai2read extractors\"\"\"\n    category = \"hentai2read\"\n    root = \"https://hentai2read.com\"\n\n\nclass Hentai2readChapterExtractor(Hentai2readBase, ChapterExtractor):\n    \"\"\"Extractor for a single manga chapter from hentai2read.com\"\"\"\n    archive_fmt = \"{chapter_id}_{page}\"\n    pattern = r\"(?:https?://)?(?:www\\.)?hentai2read\\.com(/[^/?#]+/([^/?#]+))\"\n    example = \"https://hentai2read.com/TITLE/1/\"\n\n    def metadata(self, page):\n        title, pos = text.extract(page, \"<title>\", \"</title>\")\n        manga_id, pos = text.extract(page, 'data-mid=\"', '\"', pos)\n        chapter_id, pos = text.extract(page, 'data-cid=\"', '\"', pos)\n        chapter, sep, minor = self.groups[1].partition(\".\")\n\n        match = text.re(\n            r\"Reading (.+) \\(([^)]+)\\) Hentai(?: by (.*))? - \"\n            r\"([^:]+): (.+) . Page 1 \").match(title)\n        if match:\n            manga, type, author, _, title = match.groups()\n        else:\n            self.log.warning(\"Failed to extract 'manga', 'type', 'author', \"\n                             \"and 'title' metadata\")\n            manga = type = author = title = \"\"\n\n        return {\n            \"manga\": manga,\n            \"manga_id\": text.parse_int(manga_id),\n            \"chapter\": text.parse_int(chapter),\n            \"chapter_minor\": sep + minor,\n            \"chapter_id\": text.parse_int(chapter_id),\n            \"type\": type,\n            \"author\": author,\n            \"title\": title,\n            \"lang\": \"en\",\n            \"language\": \"English\",\n        }\n\n    def images(self, page):\n        images = text.extract(page, \"'images' : \", \",\\n\")[0]\n        return [\n            (\"https://hentaicdn.com/hentai\" + part, None)\n            for part in util.json_loads(images)\n        ]\n\n\nclass Hentai2readMangaExtractor(Hentai2readBase, MangaExtractor):\n    \"\"\"Extractor for hmanga from hentai2read.com\"\"\"\n    chapterclass = Hentai2readChapterExtractor\n    pattern = r\"(?:https?://)?(?:www\\.)?hentai2read\\.com(/[^/?#]+)/?$\"\n    example = \"https://hentai2read.com/TITLE/\"\n\n    def chapters(self, page):\n        results = []\n\n        pos = page.find('itemscope itemtype=\"http://schema.org/Book') + 1\n        manga, pos = text.extract(\n            page, '<span itemprop=\"name\">', '</span>', pos)\n        mtype, pos = text.extract(\n            page, '<small class=\"text-danger\">[', ']</small>', pos)\n        manga_id = text.parse_int(text.extract(\n            page, 'data-mid=\"', '\"', pos)[0])\n\n        while True:\n            chapter_id, pos = text.extract(page, ' data-cid=\"', '\"', pos)\n            if not chapter_id:\n                return results\n            _  , pos = text.extract(page, ' href=\"', '\"', pos)\n            url, pos = text.extract(page, ' href=\"', '\"', pos)\n\n            chapter, pos = text.extract(page, '>', '<', pos)\n            chapter, _, title = text.unescape(chapter).strip().partition(\" - \")\n            chapter, sep, minor = chapter.partition(\".\")\n\n            results.append((url, {\n                \"manga\": manga,\n                \"manga_id\": manga_id,\n                \"chapter\": text.parse_int(chapter),\n                \"chapter_minor\": sep + minor,\n                \"chapter_id\": text.parse_int(chapter_id),\n                \"type\": mtype,\n                \"title\": title,\n                \"lang\": \"en\",\n                \"language\": \"English\",\n            }))\n"
  },
  {
    "path": "gallery_dl/extractor/hentaicosplays.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://hentai-cosplay-xxx.com/\n(also works for hentai-img-xxx.com and porn-image.com)\"\"\"\n\nfrom .common import BaseExtractor, GalleryExtractor\nfrom .. import text\n\n\nclass HentaicosplaysExtractor(BaseExtractor):\n    basecategory = \"hentaicosplays\"\n\n\nBASE_PATTERN = HentaicosplaysExtractor.update({\n    \"hentaicosplay\": {\n        \"root\": \"https://hentai-cosplay-xxx.com\",\n        \"pattern\": r\"(?:\\w\\w\\.)?hentai-cosplays?(?:-xxx)?\\.com\",\n    },\n    \"hentaiimg\": {\n        \"root\": \"https://hentai-img-xxx.com\",\n        \"pattern\": r\"(?:\\w\\w\\.)?hentai-img(?:-xxx)?\\.com\",\n    },\n    \"pornimage\": {\n        \"root\": \"https://porn-image.com\",\n        \"pattern\": r\"(?:\\w\\w\\.)?porn-images?(?:-xxx)?\\.com\",\n    },\n})\n\n\nclass HentaicosplaysGalleryExtractor(\n        HentaicosplaysExtractor, GalleryExtractor):\n    \"\"\"Extractor for image galleries from\n    hentai-cosplay-xxx.com, hentai-img-xxx.com, and porn-image.com\"\"\"\n    directory_fmt = (\"{site}\", \"{title}\")\n    filename_fmt = \"{filename}.{extension}\"\n    archive_fmt = \"{title}_{filename}\"\n    pattern = BASE_PATTERN + r\"/(?:image|story)/([\\w-]+)\"\n    example = \"https://hentai-cosplay-xxx.com/image/TITLE/\"\n\n    def __init__(self, match):\n        BaseExtractor.__init__(self, match)\n        self.slug = self.groups[-1]\n        self.page_url = f\"{self.root}/story/{self.slug}/\"\n\n    def _init(self):\n        self.session.headers[\"Referer\"] = self.page_url\n\n    def metadata(self, page):\n        title = text.extr(page, \"<title>\", \"</title>\")\n        return {\n            \"title\": text.unescape(title.rpartition(\" Story Viewer - \")[0]),\n            \"slug\" : self.slug,\n            \"site\" : self.root.partition(\"://\")[2].rpartition(\".\")[0],\n        }\n\n    def images(self, page):\n        return [\n            (url.replace(\"http:\", \"https:\", 1), None)\n            for url in text.extract_iter(\n                page, '<amp-img class=\"auto-style\" src=\"', '\"')\n        ]\n"
  },
  {
    "path": "gallery_dl/extractor/hentaifoundry.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2015-2026 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://www.hentai-foundry.com/\"\"\"\n\nfrom .common import Extractor, Message, Dispatch\nfrom .. import text, util\n\nBASE_PATTERN = r\"(https?://)?(?:www\\.)?hentai-foundry\\.com\"\n\n\nclass HentaifoundryExtractor(Extractor):\n    \"\"\"Base class for hentaifoundry extractors\"\"\"\n    category = \"hentaifoundry\"\n    directory_fmt = (\"{category}\", \"{user}\")\n    filename_fmt = \"{category}_{index}_{title}.{extension}\"\n    archive_fmt = \"{index}\"\n    cookies_domain = \"www.hentai-foundry.com\"\n    root = \"https://www.hentai-foundry.com\"\n    per_page = 25\n\n    def __init__(self, match):\n        self.root = (match[1] or \"https://\") + \"www.hentai-foundry.com\"\n        self.user = match[2]\n        Extractor.__init__(self, match)\n        self.page_url = \"\"\n        self.start_post = 0\n        self.start_page = 1\n\n    def _init(self):\n        if self.config(\"descriptions\") == \"html\":\n            self._process_description = self._process_description_html\n\n    def items(self):\n        self._init_site_filters()\n        data = self.metadata()\n\n        for post_url in util.advance(self.posts(), self.start_post):\n            image = self._parse_post(post_url)\n            image.update(data)\n            yield Message.Directory, \"\", image\n            yield Message.Url, image[\"src\"], image\n\n    def skip_files(self, num):\n        pages, posts = divmod(num, self.per_page)\n        self.start_page += pages\n        self.start_post += posts\n        return num\n\n    def metadata(self):\n        return {\"user\": self.user}\n\n    def posts(self):\n        return self._pagination(self.page_url)\n\n    def _pagination(self, url, begin='thumbTitle\"><a href=\"', end='\"'):\n        num = self.start_page\n\n        while True:\n            page = self.request(f\"{url}/page/{num}\").text\n            yield from text.extract_iter(page, begin, end)\n\n            if 'class=\"pager\"' not in page or 'class=\"last hidden\"' in page:\n                return\n            num += 1\n\n    def _parse_post(self, path):\n        \"\"\"Collect url and metadata from an image post\"\"\"\n        url = text.urljoin(self.root, path)\n        page = self.request(url).text\n        extr = text.extract_from(page, page.index('id=\"picBox\"'))\n\n        data = {\n            \"index\"      : text.parse_int(path.rsplit(\"/\", 2)[1]),\n            \"title\"      : text.unescape(extr('class=\"imageTitle\">', '<')),\n            \"artist\"     : text.unescape(extr('/profile\">', '<')),\n            \"_body\"      : extr(\n                '<div class=\"boxbody\"', '<div class=\"boxfooter\"'),\n            \"description\": self._process_description(extr(\n                \"<div class='picDescript'>\", '</section>')\n                .replace(\"\\r\\n\", \"\\n\")),\n            \"ratings\"    : [text.unescape(r) for r in text.extract_iter(extr(\n                \"class='ratings_box'\", \"</div>\"), \"title='\", \"'\")],\n            \"categories\" : self._extract_categories(extr),\n            \"date\"       : self.parse_datetime_iso(extr(\"datetime='\", \"'\")),\n            \"views\"      : text.parse_int(extr(\">Views</span>\", \"<\")),\n            \"score\"      : text.parse_int(extr(\">Vote Score</span>\", \"<\")),\n            \"media\"      : text.unescape(extr(\">Media</span>\", \"<\").strip()),\n            \"tags\"       : text.split_html(extr(\n                \">Tags </span>\", \"</div>\")),\n        }\n\n        body = data[\"_body\"]\n        if \"<object \" in body:\n            data[\"src\"] = text.urljoin(self.root, text.unescape(text.extr(\n                body, 'name=\"movie\" value=\"', '\"')))\n            data[\"width\"] = text.parse_int(text.extr(\n                body, \"name='width' value='\", \"'\"))\n            data[\"height\"] = text.parse_int(text.extr(\n                body, \"name='height' value='\", \"'\"))\n        else:\n            data[\"src\"] = text.urljoin(self.root, text.unescape(text.extr(\n                body, 'src=\"', '\"')))\n            data[\"width\"] = text.parse_int(text.extr(body, 'width=\"', '\"'))\n            data[\"height\"] = text.parse_int(text.extr(body, 'height=\"', '\"'))\n\n        return text.nameext_from_url(data[\"src\"], data)\n\n    def _process_description(self, description):\n        return text.unescape(text.remove_html(description, \"\", \"\"))\n\n    def _process_description_html(self, description):\n        pos1 = description.rfind('</div')  # picDescript\n        pos2 = description.rfind('</div', None, pos1)  # boxBody\n        return str.strip(description[0:pos2])\n\n    def _parse_story(self, html):\n        \"\"\"Collect url and metadata for a story\"\"\"\n        extr = text.extract_from(html)\n        data = {\n            \"user\"    : self.user,\n            \"title\"   : text.unescape(extr(\n                \"<div class='titlebar'>\", \"</a>\").rpartition(\">\")[2]),\n            \"author\"  : text.unescape(extr('alt=\"', '\"')),\n            \"date\"    : self.parse_datetime(extr(\n                \">Updated<\", \"</span>\").rpartition(\">\")[2], \"%B %d, %Y\"),\n            \"status\"  : extr(\"class='indent'>\", \"<\"),\n        }\n\n        for c in (\"Chapters\", \"Words\", \"Comments\", \"Views\", \"Rating\"):\n            data[c.lower()] = text.parse_int(extr(\n                f\">{c}:</span>\", \"<\").replace(\",\", \"\"))\n\n        data[\"description\"] = text.unescape(extr(\n            \"class='storyDescript'>\", '<div class=\"storyRead\">')).replace(\n            \"\\r\\n\", \"\\n\")\n        path = extr('class=\"pdfLink\" href=\"', '\"')\n        data[\"src\"] = self.root + path\n        data[\"index\"] = text.parse_int(path.rsplit(\"/\", 2)[1])\n        data[\"categories\"] = self._extract_categories(extr)\n        data[\"ratings\"] = [text.unescape(r) for r in text.extract_iter(extr(\n            \"class='ratings_box'\", \"</div>\"), \"title='\", \"'\")]\n\n        return text.nameext_from_url(data[\"src\"], data)\n\n    def _extract_categories(self, extr):\n        return [text.unescape(text.extr(c, \">\", \"<\"))\n                for c in extr('class=\"categoryBreadcrumbs\">', \"</span>\")\n                .split(\"&raquo;\")]\n\n    def _request_check(self, url, **kwargs):\n        self.request = self._request_original\n\n        # check for Enter button / front page\n        # and update PHPSESSID and content filters if necessary\n        response = self.request(url, **kwargs)\n        content = response.content\n        if len(content) < 5000 and \\\n                b'<div id=\"entryButtonContainer\"' in content:\n            self._init_site_filters(False)\n            response = self.request(url, **kwargs)\n        return response\n\n    def _init_site_filters(self, check_cookies=True):\n        \"\"\"Set site-internal filters to show all images\"\"\"\n        if check_cookies and self.cookies.get(\n                \"PHPSESSID\", domain=self.cookies_domain):\n            self._request_original = self.request\n            self.request = self._request_check\n            return\n\n        url = self.root + \"/?enterAgree=1\"\n        self.request(url, method=\"HEAD\")\n\n        csrf_token = self.cookies.get(\n            \"YII_CSRF_TOKEN\", domain=self.cookies_domain)\n        if not csrf_token:\n            self.log.warning(\"Unable to update site content filters\")\n            return\n\n        url = self.root + \"/site/filters\"\n        data = {\n            \"rating_nudity\"   : \"3\",\n            \"rating_violence\" : \"3\",\n            \"rating_profanity\": \"3\",\n            \"rating_racism\"   : \"3\",\n            \"rating_sex\"      : \"3\",\n            \"rating_spoilers\" : \"3\",\n            \"rating_yaoi\"     : \"1\",\n            \"rating_yuri\"     : \"1\",\n            \"rating_teen\"     : \"1\",\n            \"rating_guro\"     : \"1\",\n            \"rating_furry\"    : \"1\",\n            \"rating_beast\"    : \"1\",\n            \"rating_male\"     : \"1\",\n            \"rating_female\"   : \"1\",\n            \"rating_futa\"     : \"1\",\n            \"rating_other\"    : \"1\",\n            \"rating_scat\"     : \"1\",\n            \"rating_incest\"   : \"1\",\n            \"rating_rape\"     : \"1\",\n            \"filter_order\"    : \"date_new\",\n            \"filter_type\"     : \"0\",\n            \"YII_CSRF_TOKEN\"  : text.unquote(text.extr(\n                csrf_token, \"%22\", \"%22\")),\n        }\n        self.request(url, method=\"POST\", data=data)\n\n\nclass HentaifoundryUserExtractor(Dispatch, HentaifoundryExtractor):\n    \"\"\"Extractor for a hentaifoundry user profile\"\"\"\n    pattern = BASE_PATTERN + r\"/user/([^/?#]+)/profile\"\n    example = \"https://www.hentai-foundry.com/user/USER/profile\"\n\n    def items(self):\n        root = self.root\n        user = \"/user/\" + self.user\n        return self._dispatch_extractors((\n            (HentaifoundryPicturesExtractor, f\"{root}/pictures{user}\"),\n            (HentaifoundryScrapsExtractor  , f\"{root}/pictures{user}/scraps\"),\n            (HentaifoundryStoriesExtractor , f\"{root}/stories{user}\"),\n            (HentaifoundryFavoriteExtractor, f\"{root}{user}/faves/pictures\"),\n        ), (\"pictures\",))\n\n\nclass HentaifoundryPicturesExtractor(HentaifoundryExtractor):\n    \"\"\"Extractor for all pictures of a hentaifoundry user\"\"\"\n    subcategory = \"pictures\"\n    pattern = BASE_PATTERN + r\"/pictures/user/([^/?#]+)(?:/page/(\\d+))?/?$\"\n    example = \"https://www.hentai-foundry.com/pictures/user/USER\"\n\n    def __init__(self, match):\n        HentaifoundryExtractor.__init__(self, match)\n        self.page_url = f\"{self.root}/pictures/user/{self.user}\"\n\n\nclass HentaifoundryScrapsExtractor(HentaifoundryExtractor):\n    \"\"\"Extractor for scraps of a hentaifoundry user\"\"\"\n    subcategory = \"scraps\"\n    directory_fmt = (\"{category}\", \"{user}\", \"Scraps\")\n    pattern = BASE_PATTERN + r\"/pictures/user/([^/?#]+)/scraps\"\n    example = \"https://www.hentai-foundry.com/pictures/user/USER/scraps\"\n\n    def __init__(self, match):\n        HentaifoundryExtractor.__init__(self, match)\n        self.page_url = f\"{self.root}/pictures/user/{self.user}/scraps\"\n\n\nclass HentaifoundryFavoriteExtractor(HentaifoundryExtractor):\n    \"\"\"Extractor for favorite images of a hentaifoundry user\"\"\"\n    subcategory = \"favorite\"\n    directory_fmt = (\"{category}\", \"{user}\", \"Favorites\")\n    archive_fmt = \"f_{user}_{index}\"\n    pattern = BASE_PATTERN + r\"/user/([^/?#]+)/faves/pictures\"\n    example = \"https://www.hentai-foundry.com/user/USER/faves/pictures\"\n\n    def __init__(self, match):\n        HentaifoundryExtractor.__init__(self, match)\n        self.page_url = f\"{self.root}/user/{self.user}/faves/pictures\"\n\n\nclass HentaifoundryTagExtractor(HentaifoundryExtractor):\n    \"\"\"Extractor for tag searches on hentaifoundry.com\"\"\"\n    subcategory = \"tag\"\n    directory_fmt = (\"{category}\", \"{search_tags}\")\n    archive_fmt = \"t_{search_tags}_{index}\"\n    pattern = BASE_PATTERN + r\"/pictures/tagged/([^/?#]+)\"\n    example = \"https://www.hentai-foundry.com/pictures/tagged/TAG\"\n\n    def __init__(self, match):\n        HentaifoundryExtractor.__init__(self, match)\n        self.page_url = f\"{self.root}/pictures/tagged/{self.user}\"\n\n    def metadata(self):\n        return {\"search_tags\": self.user}\n\n\nclass HentaifoundryRecentExtractor(HentaifoundryExtractor):\n    \"\"\"Extractor for 'Recent Pictures' on hentaifoundry.com\"\"\"\n    subcategory = \"recent\"\n    directory_fmt = (\"{category}\", \"Recent Pictures\", \"{date}\")\n    archive_fmt = \"r_{index}\"\n    pattern = BASE_PATTERN + r\"/pictures/recent/(\\d\\d\\d\\d-\\d\\d-\\d\\d)\"\n    example = \"https://www.hentai-foundry.com/pictures/recent/1970-01-01\"\n\n    def __init__(self, match):\n        HentaifoundryExtractor.__init__(self, match)\n        self.page_url = f\"{self.root}/pictures/recent/{self.user}\"\n\n    def metadata(self):\n        return {\"date\": self.user}\n\n\nclass HentaifoundryPopularExtractor(HentaifoundryExtractor):\n    \"\"\"Extractor for popular images on hentaifoundry.com\"\"\"\n    subcategory = \"popular\"\n    directory_fmt = (\"{category}\", \"Popular Pictures\")\n    archive_fmt = \"p_{index}\"\n    pattern = BASE_PATTERN + r\"/pictures/popular()\"\n    example = \"https://www.hentai-foundry.com/pictures/popular\"\n\n    def __init__(self, match):\n        HentaifoundryExtractor.__init__(self, match)\n        self.page_url = self.root + \"/pictures/popular\"\n\n\nclass HentaifoundryImageExtractor(HentaifoundryExtractor):\n    \"\"\"Extractor for a single image from hentaifoundry.com\"\"\"\n    subcategory = \"image\"\n    skip_files = None\n    pattern = (r\"(https?://)?(?:www\\.|pictures\\.)?hentai-foundry\\.com\"\n               r\"/(?:pictures/user|[^/?#])/([^/?#]+)/(\\d+)\")\n    example = \"https://www.hentai-foundry.com/pictures/user/USER/12345/TITLE\"\n\n    def __init__(self, match):\n        HentaifoundryExtractor.__init__(self, match)\n        self.index = match[3]\n\n    def items(self):\n        post_url = (f\"{self.root}/pictures/user/{self.user}\"\n                    f\"/{self.index}/?enterAgree=1\")\n        image = self._parse_post(post_url)\n        image[\"user\"] = self.user\n        yield Message.Directory, \"\", image\n        yield Message.Url, image[\"src\"], image\n\n\nclass HentaifoundryStoriesExtractor(HentaifoundryExtractor):\n    \"\"\"Extractor for stories of a hentaifoundry user\"\"\"\n    subcategory = \"stories\"\n    archive_fmt = \"s_{index}\"\n    pattern = BASE_PATTERN + r\"/stories/user/([^/?#]+)(?:/page/(\\d+))?/?$\"\n    example = \"https://www.hentai-foundry.com/stories/user/USER\"\n\n    def items(self):\n        self._init_site_filters()\n        for story_html in util.advance(self.stories(), self.start_post):\n            story = self._parse_story(story_html)\n            yield Message.Directory, \"\", story\n            yield Message.Url, story[\"src\"], story\n\n    def stories(self):\n        url = f\"{self.root}/stories/user/{self.user}\"\n        return self._pagination(url, '<div class=\"storyRow\">', '</tr></table>')\n\n\nclass HentaifoundryStoryExtractor(HentaifoundryExtractor):\n    \"\"\"Extractor for a hentaifoundry story\"\"\"\n    subcategory = \"story\"\n    archive_fmt = \"s_{index}\"\n    skip_files = None\n    pattern = BASE_PATTERN + r\"/stories/user/([^/?#]+)/(\\d+)\"\n    example = \"https://www.hentai-foundry.com/stories/user/USER/12345/TITLE\"\n\n    def __init__(self, match):\n        HentaifoundryExtractor.__init__(self, match)\n        self.index = match[3]\n\n    def items(self):\n        story_url = (f\"{self.root}/stories/user/{self.user}\"\n                     f\"/{self.index}/x?enterAgree=1\")\n        story = self._parse_story(self.request(story_url).text)\n        yield Message.Directory, \"\", story\n        yield Message.Url, story[\"src\"], story\n"
  },
  {
    "path": "gallery_dl/extractor/hentaihand.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2020-2025 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://hentaihand.com/\"\"\"\n\nfrom .common import GalleryExtractor, Extractor, Message\nfrom .. import text, util\n\n\nclass HentaihandGalleryExtractor(GalleryExtractor):\n    \"\"\"Extractor for image galleries on hentaihand.com\"\"\"\n    category = \"hentaihand\"\n    root = \"https://hentaihand.com\"\n    pattern = r\"(?:https?://)?(?:www\\.)?hentaihand\\.com/\\w+/comic/([\\w-]+)\"\n    example = \"https://hentaihand.com/en/comic/TITLE\"\n\n    def __init__(self, match):\n        self.slug = match[1]\n        url = f\"{self.root}/api/comics/{self.slug}\"\n        GalleryExtractor.__init__(self, match, url)\n\n    def metadata(self, page):\n        info = util.json_loads(page)\n        data = {\n            \"gallery_id\" : text.parse_int(info[\"id\"]),\n            \"title\"      : info[\"title\"],\n            \"title_alt\"  : info[\"alternative_title\"],\n            \"slug\"       : self.slug,\n            \"type\"       : info[\"category\"][\"name\"],\n            \"language\"   : info[\"language\"][\"name\"],\n            \"lang\"       : util.language_to_code(info[\"language\"][\"name\"]),\n            \"tags\"       : [t[\"slug\"] for t in info[\"tags\"]],\n            \"date\"       : self.parse_datetime_iso(info[\"uploaded_at\"]),\n        }\n        for key in (\"artists\", \"authors\", \"groups\", \"characters\",\n                    \"relationships\", \"parodies\"):\n            data[key] = [v[\"name\"] for v in info[key]]\n        return data\n\n    def images(self, _):\n        info = self.request_json(self.page_url + \"/images\")\n        return [(img[\"source_url\"], img) for img in info[\"images\"]]\n\n\nclass HentaihandTagExtractor(Extractor):\n    \"\"\"Extractor for tag searches on hentaihand.com\"\"\"\n    category = \"hentaihand\"\n    subcategory = \"tag\"\n    root = \"https://hentaihand.com\"\n    pattern = (r\"(?i)(?:https?://)?(?:www\\.)?hentaihand\\.com\"\n               r\"/\\w+/(parody|character|tag|artist|group|language\"\n               r\"|category|relationship)/([^/?#]+)\")\n    example = \"https://hentaihand.com/en/tag/TAG\"\n\n    def __init__(self, match):\n        Extractor.__init__(self, match)\n        self.type, self.key = match.groups()\n\n    def items(self):\n        if self.type[-1] == \"y\":\n            tpl = self.type[:-1] + \"ies\"\n        else:\n            tpl = self.type + \"s\"\n\n        url = f\"{self.root}/api/{tpl}/{self.key}\"\n        tid = self.request_json(url, notfound=self.type)[\"id\"]\n\n        url = self.root + \"/api/comics\"\n        params = {\n            \"per_page\": \"18\",\n            tpl       : tid,\n            \"page\"    : 1,\n            \"q\"       : \"\",\n            \"sort\"    : \"uploaded_at\",\n            \"order\"   : \"desc\",\n            \"duration\": \"day\",\n        }\n        while True:\n            info = self.request_json(url, params=params)\n\n            for gallery in info[\"data\"]:\n                gurl = f\"{self.root}/en/comic/{gallery['slug']}\"\n                gallery[\"_extractor\"] = HentaihandGalleryExtractor\n                yield Message.Queue, gurl, gallery\n\n            if params[\"page\"] >= info[\"last_page\"]:\n                return\n            params[\"page\"] += 1\n"
  },
  {
    "path": "gallery_dl/extractor/hentaihere.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2016-2025 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://hentaihere.com/\"\"\"\n\nfrom .common import ChapterExtractor, MangaExtractor\nfrom .. import text, util\n\n\nclass HentaihereBase():\n    \"\"\"Base class for hentaihere extractors\"\"\"\n    category = \"hentaihere\"\n    root = \"https://hentaihere.com\"\n\n\nclass HentaihereChapterExtractor(HentaihereBase, ChapterExtractor):\n    \"\"\"Extractor for a single manga chapter from hentaihere.com\"\"\"\n    archive_fmt = \"{chapter_id}_{page}\"\n    pattern = r\"(?:https?://)?(?:www\\.)?hentaihere\\.com/m/S(\\d+)/([^/?#]+)\"\n    example = \"https://hentaihere.com/m/S12345/1/1/\"\n\n    def __init__(self, match):\n        self.manga_id, self.chapter = match.groups()\n        url = f\"{self.root}/m/S{self.manga_id}/{self.chapter}/1\"\n        ChapterExtractor.__init__(self, match, url)\n\n    def metadata(self, page):\n        title = text.extr(page, \"<title>\", \"</title>\")\n        chapter_id = text.extr(page, 'report/C', '\"')\n        chapter, sep, minor = self.chapter.partition(\".\")\n        match = text.re(\n            r\"Page 1 \\| (.+) \\(([^)]+)\\) - Chapter \\d+: (.+) by \"\n            r\"(.+) at \").match(title)\n        return {\n            \"manga\": match[1],\n            \"manga_id\": text.parse_int(self.manga_id),\n            \"chapter\": text.parse_int(chapter),\n            \"chapter_minor\": sep + minor,\n            \"chapter_id\": text.parse_int(chapter_id),\n            \"type\": match[2],\n            \"title\": match[3],\n            \"author\": match[4],\n            \"lang\": \"en\",\n            \"language\": \"English\",\n        }\n\n    def images(self, page):\n        images = text.extr(page, \"var rff_imageList = \", \";\")\n        return [\n            (\"https://hentaicdn.com/hentai\" + part, None)\n            for part in util.json_loads(images)\n        ]\n\n\nclass HentaihereMangaExtractor(HentaihereBase, MangaExtractor):\n    \"\"\"Extractor for hmanga from hentaihere.com\"\"\"\n    chapterclass = HentaihereChapterExtractor\n    pattern = r\"(?:https?://)?(?:www\\.)?hentaihere\\.com(/m/S\\d+)/?$\"\n    example = \"https://hentaihere.com/m/S12345\"\n\n    def chapters(self, page):\n        results = []\n\n        pos = page.find('itemscope itemtype=\"http://schema.org/Book') + 1\n        manga, pos = text.extract(\n            page, '<span itemprop=\"name\">', '</span>', pos)\n        mtype, pos = text.extract(\n            page, '<span class=\"mngType text-danger\">[', ']</span>', pos)\n        manga_id = text.parse_int(\n            self.page_url.rstrip(\"/\").rpartition(\"/\")[2][1:])\n\n        while True:\n            marker, pos = text.extract(\n                page, '<li class=\"sub-chp clearfix\">', '', pos)\n            if marker is None:\n                return results\n            url, pos = text.extract(page, '<a href=\"', '\"', pos)\n\n            chapter, pos = text.extract(page, 'title=\"Tagged: -\">\\n', '<', pos)\n            chapter_id, pos = text.extract(page, '/C', '\"', pos)\n            chapter, _, title = text.unescape(chapter).strip().partition(\" - \")\n            chapter, sep, minor = chapter.partition(\".\")\n\n            results.append((url, {\n                \"manga_id\": manga_id,\n                \"manga\": manga,\n                \"chapter\": text.parse_int(chapter),\n                \"chapter_minor\": sep + minor,\n                \"chapter_id\": text.parse_int(chapter_id),\n                \"type\": mtype,\n                \"title\": title,\n                \"lang\": \"en\",\n                \"language\": \"English\",\n            }))\n"
  },
  {
    "path": "gallery_dl/extractor/hentainexus.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2019-2025 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://hentainexus.com/\"\"\"\n\nfrom .common import GalleryExtractor, Extractor, Message\nfrom .. import text, util\nimport binascii\n\n\nclass HentainexusGalleryExtractor(GalleryExtractor):\n    \"\"\"Extractor for hentainexus galleries\"\"\"\n    category = \"hentainexus\"\n    root = \"https://hentainexus.com\"\n    pattern = (r\"(?i)(?:https?://)?(?:www\\.)?hentainexus\\.com\"\n               r\"/(?:view|read)/(\\d+)\")\n    example = \"https://hentainexus.com/view/12345\"\n\n    def __init__(self, match):\n        self.gallery_id = match[1]\n        url = f\"{self.root}/view/{self.gallery_id}\"\n        GalleryExtractor.__init__(self, match, url)\n\n    def metadata(self, page):\n        rmve = text.remove_html\n        extr = text.extract_from(page)\n        data = {\n            \"gallery_id\": text.parse_int(self.gallery_id),\n            \"cover\"     : extr('\"og:image\" content=\"', '\"'),\n            \"title\"     : extr('<h1 class=\"title\">', '</h1>'),\n        }\n\n        for key in (\"Artist\", \"Book\", \"Circle\", \"Event\", \"Language\",\n                    \"Magazine\", \"Parody\", \"Publisher\", \"Description\"):\n            value = rmve(extr('viewcolumn\">' + key + '</td>', '</td>'))\n            value, sep, rest = value.rpartition(\" (\")\n            data[key.lower()] = value if sep else rest\n\n        data[\"tags\"] = tags = []\n        for k in text.extract_iter(page, '<a href=\"/?q=tag:', '\"'):\n            tags.append(text.unquote(k).strip('\"').replace(\"+\", \" \"))\n\n        if not data[\"language\"]:\n            data[\"language\"] = \"English\"\n        data[\"lang\"] = util.language_to_code(data[\"language\"])\n\n        if \"doujin\" in data[\"tags\"]:\n            data[\"type\"] = \"Doujinshi\"\n        elif \"illustration\" in data[\"tags\"]:\n            data[\"type\"] = \"Illustration\"\n        else:\n            data[\"type\"] = \"Manga\"\n        data[\"title_conventional\"] = self._join_title(data)\n        return data\n\n    def images(self, _):\n        url = f\"{self.root}/read/{self.gallery_id}\"\n        page = self.request(url).text\n        imgs = util.json_loads(self._decode(text.extr(\n            page, 'initReader(\"', '\"')))\n\n        headers = None\n        if not self.config(\"original\", True):\n            headers = {\"Accept\": \"image/webp,*/*\"}\n            for img in imgs:\n                img[\"_http_headers\"] = headers\n\n        results = []\n        for img in imgs:\n            try:\n                results.append((img[\"image\"], img))\n            except KeyError:\n                pass\n        return results\n\n    def _decode(self, data):\n        # https://hentainexus.com/static/js/reader.min.js?r=22\n        hostname = \"hentainexus.com\"\n        primes = (2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47, 53)\n        blob = list(binascii.a2b_base64(data))\n        for i in range(0, len(hostname)):\n            blob[i] = blob[i] ^ ord(hostname[i])\n\n        key = blob[0:64]\n\n        C = 0\n        for k in key:\n            C = C ^ k\n            for _ in range(8):\n                if C & 1:\n                    C = C >> 1 ^ 0xc\n                else:\n                    C = C >> 1\n        k = primes[C & 0x7]\n\n        x = 0\n        L = len(key)\n        S = list(range(256))\n        for i in range(256):\n            x = (x + S[i] + key[i % L]) & 255\n            S[i], S[x] = S[x], S[i]\n\n        result = []\n        a = c = m = x = 0\n        for n in range(64, len(blob)):\n            a = (a + k) & 255\n            x = (c + S[(x + S[a]) & 255]) & 255\n            c = (c + a + S[a]) & 255\n\n            S[a], S[x] = S[x], S[a]\n            m = S[(x + S[(a + S[(m + c) & 255]) & 255]) & 255]\n            result.append(chr(blob[n] ^ m))\n        return \"\".join(result)\n\n    def _join_title(self, data):\n        event = data['event']\n        artist = data['artist']\n        circle = data['circle']\n        title = data['title']\n        parody = data['parody']\n        book = data['book']\n        magazine = data['magazine']\n\n        # a few galleries have a large number of artists or parodies,\n        # which get replaced with \"Various\" in the title string\n        if artist.count(',') >= 3:\n            artist = 'Various'\n        if parody.count(',') >= 3:\n            parody = 'Various'\n\n        jt = ''\n        if event:\n            jt += f'({event}) '\n        if circle:\n            jt += f'[{circle} ({artist})] '\n        else:\n            jt += f'[{artist}] '\n        jt += title\n        if parody.lower() != 'original work':\n            jt += f' ({parody})'\n        if book:\n            jt += f' ({book})'\n        if magazine:\n            jt += f' ({magazine})'\n        return jt\n\n\nclass HentainexusSearchExtractor(Extractor):\n    \"\"\"Extractor for hentainexus search results\"\"\"\n    category = \"hentainexus\"\n    subcategory = \"search\"\n    root = \"https://hentainexus.com\"\n    pattern = (r\"(?i)(?:https?://)?(?:www\\.)?hentainexus\\.com\"\n               r\"(?:/page/\\d+)?/?(?:\\?(q=[^/?#]+))?$\")\n    example = \"https://hentainexus.com/?q=QUERY\"\n\n    def items(self):\n        params = text.parse_query(self.groups[0])\n        data = {\"_extractor\": HentainexusGalleryExtractor}\n        path = \"/\"\n\n        while path:\n            page = self.request(self.root + path, params=params).text\n            extr = text.extract_from(page)\n\n            while True:\n                gallery_id = extr('<a href=\"/view/', '\"')\n                if not gallery_id:\n                    break\n                yield Message.Queue, self.root + \"/view/\" + gallery_id, data\n\n            path = extr('class=\"pagination-next\" href=\"', '\"')\n"
  },
  {
    "path": "gallery_dl/extractor/hiperdex.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2020-2026 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://hiperdex.com/\"\"\"\n\nfrom .common import ChapterExtractor, MangaExtractor\nfrom .. import text\n\nBASE_PATTERN = (r\"((?:https?://)?(?:www\\.)?\"\n                r\"(?:1st)?hiper(?:dex|toon)\\d?\\.(?:com|net|info|top))\")\n\n\nclass HiperdexBase():\n    \"\"\"Base class for hiperdex extractors\"\"\"\n    category = \"hiperdex\"\n    root = \"https://hiperdex.com\"\n\n    def manga_data(self, manga, page=None):\n        if not page:\n            url = f\"{self.root}/manga/{manga}/\"\n            page = self.request(url).text\n        extr = text.extract_from(page)\n\n        return {\n            \"url\"    : text.unescape(extr(\n                'property=\"og:url\" content=\"', '\"')),\n            \"manga\"  : text.unescape(extr(\n                ' property=\"name\" title=\"', '\"')),\n            \"score\"  : text.parse_float(extr(\n                'id=\"averagerate\">', '<')),\n            \"author\" : text.remove_html(extr(\n                'class=\"author-content\">', '</div>')),\n            \"artist\" : text.remove_html(extr(\n                'class=\"artist-content\">', '</div>')),\n            \"genre\"  : text.split_html(extr(\n                'class=\"genres-content\">', '</div>'))[::2],\n            \"type\"   : extr(\n                'class=\"summary-content\">', '<').strip(),\n            \"release\": text.parse_int(text.remove_html(extr(\n                'class=\"summary-content\">', '</div>'))),\n            \"status\" : extr(\n                'class=\"summary-content\">', '<').strip(),\n            \"description\": text.remove_html(text.unescape(extr(\n                '<div class=\"description-summary\">', \"</div>\"))),\n            \"language\": \"English\",\n            \"lang\"    : \"en\",\n        }\n\n    def chapter_data(self, chapter):\n        if chapter.startswith(\"chapter-\"):\n            chapter = chapter[8:]\n        chapter, _, minor = chapter.partition(\"-\")\n        return {\n            **self.cache(self.manga_data, self.manga.lower()),\n            \"chapter\"      : text.parse_int(chapter),\n            \"chapter_minor\": \".\" + minor if minor and minor != \"end\" else \"\",\n        }\n\n\nclass HiperdexChapterExtractor(HiperdexBase, ChapterExtractor):\n    \"\"\"Extractor for hiperdex manga chapters\"\"\"\n    pattern = BASE_PATTERN + r\"(/mangas?/([^/?#]+)/([^/?#]+))\"\n    example = \"https://hiperdex.com/manga/MANGA/CHAPTER/\"\n\n    def __init__(self, match):\n        root, path, self.manga, self.chapter = match.groups()\n        self.root = text.ensure_http_scheme(root)\n        ChapterExtractor.__init__(self, match, self.root + path + \"/\")\n\n    def metadata(self, _):\n        return self.chapter_data(self.chapter)\n\n    def images(self, page):\n        pattern = text.re(r'id=\"image-\\d+\"\\s+(?:data-)?src=\"([^\"]+)')\n        return [\n            (url.strip(), None)\n            for url in pattern.findall(page)\n        ]\n\n\nclass HiperdexMangaExtractor(HiperdexBase, MangaExtractor):\n    \"\"\"Extractor for hiperdex manga\"\"\"\n    chapterclass = HiperdexChapterExtractor\n    pattern = BASE_PATTERN + r\"(/mangas?/([^/?#]+))/?$\"\n    example = \"https://hiperdex.com/manga/MANGA/\"\n\n    def __init__(self, match):\n        root, path, self.manga = match.groups()\n        self.root = text.ensure_http_scheme(root)\n        MangaExtractor.__init__(self, match, self.root + path + \"/\")\n\n    def chapters(self, page):\n        data = self.cache(self.manga_data, self.manga, page)\n        self.page_url = url = data[\"url\"]\n\n        url = self.page_url + \"ajax/chapters/\"\n        headers = {\n            \"Accept\": \"*/*\",\n            \"X-Requested-With\": \"XMLHttpRequest\",\n            \"Origin\": self.root,\n            \"Referer\": \"https://\" + text.quote(self.page_url[8:]),\n        }\n        html = self.request(url, method=\"POST\", headers=headers).text\n\n        results = []\n        for item in text.extract_iter(\n                html, '<li class=\"wp-manga-chapter', '</li>'):\n            url = text.extr(item, 'href=\"', '\"')\n            chapter = url.rstrip(\"/\").rpartition(\"/\")[2]\n            results.append((url, self.chapter_data(chapter)))\n        return results\n\n\nclass HiperdexArtistExtractor(HiperdexBase, MangaExtractor):\n    \"\"\"Extractor for an artists's manga on hiperdex\"\"\"\n    subcategory = \"artist\"\n    categorytransfer = False\n    chapterclass = HiperdexMangaExtractor\n    reverse = False\n    pattern = BASE_PATTERN + r\"(/manga-a(?:rtist|uthor)/(?:[^/?#]+))\"\n    example = \"https://hiperdex.com/manga-artist/NAME/\"\n\n    def __init__(self, match):\n        self.root = text.ensure_http_scheme(match[1])\n        MangaExtractor.__init__(self, match, self.root + match[2] + \"/\")\n\n    def chapters(self, page):\n        results = []\n        for info in text.extract_iter(page, 'id=\"manga-item-', '<img'):\n            url = text.extr(info, 'href=\"', '\"')\n            results.append((url, {}))\n        return results\n"
  },
  {
    "path": "gallery_dl/extractor/hitomi.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2015-2026 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://hitomi.la/\"\"\"\n\nfrom .common import GalleryExtractor, Extractor, Message\nfrom .nozomi import decode_nozomi\nfrom .. import text, util\nimport string\n\n\nclass HitomiExtractor(Extractor):\n    \"\"\"Base class for hitomi extractors\"\"\"\n    category = \"hitomi\"\n    root = \"https://hitomi.la\"\n    domain = \"gold-usergeneratedcontent.net\"\n\n    def load_nozomi(self, query, language=\"all\", headers=None):\n        ns, _, tag = query.strip().partition(\":\")\n\n        if ns == \"female\" or ns == \"male\":\n            ns = \"tag/\"\n            tag = query\n        elif ns == \"language\":\n            ns = \"\"\n            language = tag\n            tag = \"index\"\n        else:\n            ns += \"/\"\n\n        url = (f\"https://ltn.{self.domain}/n/{ns}\"\n               f\"/{tag.replace('_', ' ')}-{language}.nozomi\")\n        if headers is None:\n            headers = {}\n        headers[\"Origin\"] = self.root\n        headers[\"Referer\"] = self.root + \"/\"\n        return decode_nozomi(self.request(url, headers=headers).content)\n\n\nclass HitomiGalleryExtractor(HitomiExtractor, GalleryExtractor):\n    \"\"\"Extractor for hitomi.la galleries\"\"\"\n    pattern = (r\"(?:https?://)?hitomi\\.la\"\n               r\"/(?:manga|doujinshi|cg|gamecg|imageset|galleries|reader)\"\n               r\"/(?:[^/?#]+-)?(\\d+)\")\n    example = \"https://hitomi.la/manga/TITLE-867789.html\"\n\n    def __init__(self, match):\n        GalleryExtractor.__init__(self, match, False)\n        self.gid = gid = self.groups[0]\n        self.page_url = f\"https://ltn.{self.domain}/galleries/{gid}.js\"\n\n    def _init(self):\n        self.session.headers[\"Referer\"] = f\"{self.root}/reader/{self.gid}.html\"\n\n    def metadata(self, page):\n        self.info = info = util.json_loads(page.partition(\"=\")[2])\n        iget = info.get\n\n        if language := iget(\"language\"):\n            language = language.capitalize()\n\n        if date := iget(\"date\"):\n            date += \":00\"\n\n        tags = []\n        for tinfo in iget(\"tags\") or ():\n            tag = string.capwords(tinfo[\"tag\"])\n            if tinfo.get(\"female\"):\n                tag += \" ♀\"\n            elif tinfo.get(\"male\"):\n                tag += \" ♂\"\n            tags.append(tag)\n\n        return {\n            \"gallery_id\": text.parse_int(info[\"id\"]),\n            \"title\"     : info[\"title\"],\n            \"title_jpn\" : info.get(\"japanese_title\") or \"\",\n            \"type\"      : info[\"type\"].capitalize(),\n            \"language\"  : language,\n            \"lang\"      : util.language_to_code(language),\n            \"date\"      : self.parse_datetime_iso(date),\n            \"tags\"      : tags,\n            \"artist\"    : [o[\"artist\"] for o in iget(\"artists\") or ()],\n            \"group\"     : [o[\"group\"] for o in iget(\"groups\") or ()],\n            \"parody\"    : [o[\"parody\"] for o in iget(\"parodys\") or ()],\n            \"characters\": [o[\"character\"] for o in iget(\"characters\") or ()]\n        }\n\n    def images(self, _):\n        # https://ltn.gold-usergeneratedcontent.net/gg.js\n        gg_m, gg_b, gg_default = self.cache(\n            _parse_gg, self, _key=None, _exp=1800)\n\n        fmt = ext = self.config(\"format\") or \"webp\"\n        check = (fmt != \"webp\")\n\n        results = []\n        for image in self.info[\"files\"]:\n            if check:\n                ext = fmt if image.get(\"has\" + fmt) else \"webp\"\n            ihash = image[\"hash\"]\n            idata = text.nameext_from_url(image[\"name\"])\n            idata[\"extension_original\"] = idata[\"extension\"]\n            idata[\"extension\"] = ext\n\n            # https://ltn.gold-usergeneratedcontent.net/common.js\n            inum = int(ihash[-1] + ihash[-3:-1], 16)\n            url = (f\"https://{ext[0]}{gg_m.get(inum, gg_default) + 1}.\"\n                   f\"{self.domain}/{gg_b}/{inum}/{ihash}.{ext}\")\n            results.append((url, idata))\n        return results\n\n\nclass HitomiTagExtractor(HitomiExtractor):\n    \"\"\"Extractor for galleries from tag searches on hitomi.la\"\"\"\n    subcategory = \"tag\"\n    pattern = (r\"(?:https?://)?hitomi\\.la\"\n               r\"/(tag|artist|group|series|type|character)\"\n               r\"/([^/?#]+)\\.html\")\n    example = \"https://hitomi.la/tag/TAG-LANG.html\"\n\n    def __init__(self, match):\n        Extractor.__init__(self, match)\n        self.type, self.tag = match.groups()\n\n        tag, _, num = self.tag.rpartition(\"-\")\n        if num.isdecimal():\n            self.tag = tag\n\n    def items(self):\n        data = {\n            \"_extractor\": HitomiGalleryExtractor,\n            \"search_tags\": text.unquote(self.tag.rpartition(\"-\")[0]),\n        }\n        nozomi_url = f\"https://ltn.{self.domain}/{self.type}/{self.tag}.nozomi\"\n        headers = {\n            \"Origin\": self.root,\n            \"Cache-Control\": \"max-age=0\",\n        }\n\n        offset = 0\n        total = None\n        while True:\n            headers[\"Referer\"] = (f\"{self.root}/{self.type}/{self.tag}.html\"\n                                  f\"?page={offset // 100 + 1}\")\n            headers[\"Range\"] = f\"bytes={offset}-{offset + 99}\"\n            response = self.request(nozomi_url, headers=headers)\n\n            for gallery_id in decode_nozomi(response.content):\n                gallery_url = f\"{self.root}/galleries/{gallery_id}.html\"\n                yield Message.Queue, gallery_url, data\n\n            offset += 100\n            if total is None:\n                total = text.parse_int(\n                    response.headers[\"content-range\"].rpartition(\"/\")[2])\n            if offset >= total:\n                return\n\n\nclass HitomiIndexExtractor(HitomiTagExtractor):\n    \"\"\"Extractor for galleries from index searches on hitomi.la\"\"\"\n    subcategory = \"index\"\n    pattern = r\"(?:https?://)?hitomi\\.la/(\\w+)-(\\w+)\\.html\"\n    example = \"https://hitomi.la/index-LANG.html\"\n\n    def __init__(self, match):\n        Extractor.__init__(self, match)\n        self.tag, self.language = match.groups()\n\n    def items(self):\n        data = {\"_extractor\": HitomiGalleryExtractor}\n        nozomi_url = (f\"https://ltn.{self.domain}\"\n                      f\"/{self.tag}-{self.language}.nozomi\")\n        headers = {\n            \"Origin\": self.root,\n            \"Cache-Control\": \"max-age=0\",\n        }\n\n        offset = 0\n        total = None\n        while True:\n            headers[\"Referer\"] = (f\"{self.root}/{self.tag}-{self.language}\"\n                                  f\".html?page={offset // 100 + 1}\")\n            headers[\"Range\"] = f\"bytes={offset}-{offset + 99}\"\n            response = self.request(nozomi_url, headers=headers)\n\n            for gallery_id in decode_nozomi(response.content):\n                gallery_url = f\"{self.root}/galleries/{gallery_id}.html\"\n                yield Message.Queue, gallery_url, data\n\n            offset += 100\n            if total is None:\n                total = text.parse_int(\n                    response.headers[\"content-range\"].rpartition(\"/\")[2])\n            if offset >= total:\n                return\n\n\nclass HitomiSearchExtractor(HitomiExtractor):\n    \"\"\"Extractor for galleries from multiple tag searches on hitomi.la\"\"\"\n    subcategory = \"search\"\n    pattern = r\"(?:https?://)?hitomi\\.la/search\\.html\\?([^#]+)\"\n    example = \"https://hitomi.la/search.html?QUERY\"\n\n    def items(self):\n        tags = text.unquote(self.groups[0])\n\n        data = {\n            \"_extractor\": HitomiGalleryExtractor,\n            \"search_tags\": tags,\n        }\n\n        for gallery_id in self.gallery_ids(tags):\n            gallery_url = f\"{self.root}/galleries/{gallery_id}.html\"\n            yield Message.Queue, gallery_url, data\n\n    def gallery_ids(self, tags):\n        result = None\n        positive = []\n        negative = []\n\n        for tag in tags.split():\n            if tag[0] == \"-\":\n                negative.append(tag[1:])\n            else:\n                positive.append(tag)\n\n        for tag in positive:\n            ids = self.load_nozomi(tag)\n            if result is None:\n                result = set(ids)\n            else:\n                result.intersection_update(ids)\n\n        if result is None:\n            #  result = set(self.load_nozomi(\"index\"))\n            result = set(self.load_nozomi(\"language:all\"))\n        for tag in negative:\n            result.difference_update(self.load_nozomi(tag))\n\n        return sorted(result, reverse=True) if result else ()\n\n\ndef _parse_gg(extr):\n    page = extr.request(\"https://ltn.gold-usergeneratedcontent.net/gg.js\").text\n\n    m = {}\n\n    keys = []\n    for match in util.re_compile(\n            r\"case\\s+(\\d+):(?:\\s*o\\s*=\\s*(\\d+))?\").finditer(page):\n        key, value = match.groups()\n        keys.append(int(key))\n\n        if value:\n            value = int(value)\n            for key in keys:\n                m[key] = value\n            keys.clear()\n\n    for match in util.re_compile(\n            r\"if\\s+\\(g\\s*===?\\s*(\\d+)\\)[\\s{]*o\\s*=\\s*(\\d+)\").finditer(page):\n        m[int(match[1])] = int(match[2])\n\n    d = util.re_compile(r\"(?:var\\s|default:)\\s*o\\s*=\\s*(\\d+)\").search(page)\n    b = util.re_compile(r\"b:\\s*[\\\"'](.+)[\\\"']\").search(page)\n\n    return m, b[1].strip(\"/\"), int(d[1]) if d else 0\n"
  },
  {
    "path": "gallery_dl/extractor/hotleak.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://hotleak.vip/\"\"\"\n\nfrom .common import Extractor, Message\nfrom .. import text\nimport binascii\n\nBASE_PATTERN = r\"(?:https?://)?(?:www\\.)?hotleak\\.vip\"\n\n\nclass HotleakExtractor(Extractor):\n    \"\"\"Base class for hotleak extractors\"\"\"\n    category = \"hotleak\"\n    directory_fmt = (\"{category}\", \"{creator}\",)\n    filename_fmt = \"{creator}_{id}.{extension}\"\n    archive_fmt = \"{type}_{creator}_{id}\"\n    root = \"https://hotleak.vip\"\n\n    def items(self):\n        for post in self.posts():\n            if not post[\"url\"].startswith(\"ytdl:\"):\n                post[\"url\"] = (\n                    post[\"url\"]\n                    .replace(\"/storage/storage/\", \"/storage/\")\n                    .replace(\"_thumb.\", \".\")\n                )\n            post[\"_http_expected_status\"] = (404,)\n            yield Message.Directory, \"\", post\n            yield Message.Url, post[\"url\"], post\n\n    def posts(self):\n        \"\"\"Return an iterable containing relevant posts\"\"\"\n        return ()\n\n    def _pagination(self, url, params):\n        params = text.parse_query(params)\n        params[\"page\"] = text.parse_int(params.get(\"page\"), 1)\n\n        while True:\n            page = self.request(url, params=params).text\n            if \"</article>\" not in page:\n                return\n\n            for item in text.extract_iter(\n                    page, '<article class=\"movie-item', '</article>'):\n                yield text.extr(item, '<a href=\"', '\"')\n\n            params[\"page\"] += 1\n\n\ndef decode_video_url(url):\n    # cut first and last 16 characters, reverse, base64 decode\n    return binascii.a2b_base64(url[-17:15:-1]).decode()\n\n\nclass HotleakPostExtractor(HotleakExtractor):\n    \"\"\"Extractor for individual posts on hotleak\"\"\"\n    subcategory = \"post\"\n    pattern = (BASE_PATTERN + r\"/(?!(?:hot|creators|videos|photos)(?:$|/))\"\n               r\"([^/]+)/(photo|video)/(\\d+)\")\n    example = \"https://hotleak.vip/MODEL/photo/12345\"\n\n    def __init__(self, match):\n        HotleakExtractor.__init__(self, match)\n        self.creator, self.type, self.id = match.groups()\n\n    def posts(self):\n        url = f\"{self.root}/{self.creator}/{self.type}/{self.id}\"\n        page = self.request(url).text\n        page = text.extr(\n            page, '<div class=\"movie-image thumb\">', '</article>')\n        data = {\n            \"id\"     : text.parse_int(self.id),\n            \"creator\": self.creator,\n            \"type\"   : self.type,\n        }\n\n        if self.type == \"photo\":\n            data[\"url\"] = text.extr(page, 'data-src=\"', '\"')\n            text.nameext_from_url(data[\"url\"], data)\n\n        elif self.type == \"video\":\n            data[\"url\"] = \"ytdl:\" + decode_video_url(text.extr(\n                text.unescape(page), '\"src\":\"', '\"'))\n            text.nameext_from_url(data[\"url\"], data)\n            data[\"extension\"] = \"mp4\"\n\n        return (data,)\n\n\nclass HotleakCreatorExtractor(HotleakExtractor):\n    \"\"\"Extractor for all posts from a hotleak creator\"\"\"\n    subcategory = \"creator\"\n    pattern = (BASE_PATTERN + r\"/(?!(?:hot|creators|videos|photos)(?:$|/))\"\n               r\"([^/?#]+)/?$\")\n    example = \"https://hotleak.vip/MODEL\"\n\n    def __init__(self, match):\n        HotleakExtractor.__init__(self, match)\n        self.creator = match[1]\n\n    def posts(self):\n        url = f\"{self.root}/{self.creator}\"\n        return self._pagination(url)\n\n    def _pagination(self, url):\n        headers = {\"X-Requested-With\": \"XMLHttpRequest\"}\n        params = {\"page\": 1}\n\n        while True:\n            try:\n                response = self.request(\n                    url, headers=headers, params=params, notfound=True)\n            except self.exc.HttpError as exc:\n                if exc.response.status_code == 429:\n                    self.wait(\n                        until=exc.response.headers.get(\"X-RateLimit-Reset\"))\n                    continue\n                raise\n\n            posts = response.json()\n            if not posts:\n                return\n\n            data = {\"creator\": self.creator}\n            for post in posts:\n                data[\"id\"] = text.parse_int(post[\"id\"])\n\n                if post[\"type\"] == 0:\n                    data[\"type\"] = \"photo\"\n                    data[\"url\"] = self.root + \"/storage/\" + post[\"image\"]\n                    text.nameext_from_url(data[\"url\"], data)\n\n                elif post[\"type\"] == 1:\n                    data[\"type\"] = \"video\"\n                    data[\"url\"] = \"ytdl:\" + decode_video_url(\n                        post[\"stream_url_play\"])\n                    text.nameext_from_url(data[\"url\"], data)\n                    data[\"extension\"] = \"mp4\"\n\n                yield data\n            params[\"page\"] += 1\n\n\nclass HotleakCategoryExtractor(HotleakExtractor):\n    \"\"\"Extractor for hotleak categories\"\"\"\n    subcategory = \"category\"\n    pattern = BASE_PATTERN + r\"/(hot|creators|videos|photos)(?:/?\\?([^#]+))?\"\n    example = \"https://hotleak.vip/photos\"\n\n    def __init__(self, match):\n        HotleakExtractor.__init__(self, match)\n        self._category, self.params = match.groups()\n\n    def items(self):\n        url = f\"{self.root}/{self._category}\"\n\n        if self._category in {\"hot\", \"creators\"}:\n            data = {\"_extractor\": HotleakCreatorExtractor}\n        elif self._category in {\"videos\", \"photos\"}:\n            data = {\"_extractor\": HotleakPostExtractor}\n\n        for item in self._pagination(url, self.params):\n            yield Message.Queue, item, data\n\n\nclass HotleakSearchExtractor(HotleakExtractor):\n    \"\"\"Extractor for hotleak search results\"\"\"\n    subcategory = \"search\"\n    pattern = BASE_PATTERN + r\"/search(?:/?\\?([^#]+))\"\n    example = \"https://hotleak.vip/search?search=QUERY\"\n\n    def __init__(self, match):\n        HotleakExtractor.__init__(self, match)\n        self.params = match[1]\n\n    def items(self):\n        data = {\"_extractor\": HotleakCreatorExtractor}\n        for creator in self._pagination(self.root + \"/search\", self.params):\n            yield Message.Queue, creator, data\n"
  },
  {
    "path": "gallery_dl/extractor/idolcomplex.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2018-2025 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://www.idolcomplex.com/\"\"\"\n\nfrom . import sankaku\n\nBASE_PATTERN = (r\"(?:https?://)?(?:www\\.)?\"\n                r\"idol(?:\\.sankaku)?complex\\.com\"\n                r\"(?:/[a-z]{2}(?:[-_][A-Z]{2})?)?\")\n\n\nclass IdolcomplexBase():\n    \"\"\"Base class for idolcomplex extractors\"\"\"\n    category = \"idolcomplex\"\n    root = \"https://www.idolcomplex.com\"\n    cookies_domain = \".idolcomplex.com\"\n\n    def _init(self):\n        self.api = sankaku.SankakuAPI(self)\n        self.api.ROOT = \"https://i.sankakuapi.com\"\n        self.api.headers[\"Origin\"] = self.root\n\n\nclass IdolcomplexTagExtractor(IdolcomplexBase, sankaku.SankakuTagExtractor):\n    \"\"\"Extractor for idolcomplex tag searches\"\"\"\n    pattern = BASE_PATTERN + r\"(?:/posts)?/?\\?([^#]*)\"\n    example = \"https://www.idolcomplex.com/en/posts?tags=TAGS\"\n\n\nclass IdolcomplexPoolExtractor(IdolcomplexBase, sankaku.SankakuPoolExtractor):\n    \"\"\"Extractor for idolcomplex pools\"\"\"\n    pattern = BASE_PATTERN + r\"/pools?/(?:show/)?(\\w+)\"\n    example = \"https://www.idolcomplex.com/en/pools/0123456789abcdef\"\n\n\nclass IdolcomplexPostExtractor(IdolcomplexBase, sankaku.SankakuPostExtractor):\n    \"\"\"Extractor for individual idolcomplex posts\"\"\"\n    pattern = BASE_PATTERN + r\"/posts?(?:/show)?/(\\w+)\"\n    example = \"https://www.idolcomplex.com/en/posts/0123456789abcdef\"\n"
  },
  {
    "path": "gallery_dl/extractor/imagebam.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2014-2026 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://www.imagebam.com/\"\"\"\n\nfrom .common import Extractor, Message\nfrom .. import text\n\n\nclass ImagebamExtractor(Extractor):\n    \"\"\"Base class for imagebam extractors\"\"\"\n    category = \"imagebam\"\n    root = \"https://www.imagebam.com\"\n\n    def __init__(self, match):\n        Extractor.__init__(self, match)\n        self.path = match[1]\n\n    def _init(self):\n        self.cookies.set(\"nsfw_inter\", \"1\", domain=\"www.imagebam.com\")\n        self.cookies.set(\"sfw_inter\", \"1\", domain=\"www.imagebam.com\")\n\n    def _parse_image_page(self, path):\n        page = self.request(self.root + path).text\n        url, pos = text.extract(page, '<img src=\"https://images', '\"')\n        if not url:\n            raise self.exc.NotFoundError(\"image\")\n        filename = text.unescape(text.extract(page, 'alt=\"', '\"', pos)[0])\n\n        return text.nameext_from_name(filename, {\n            \"url\"      : \"https://images\" + url,\n            \"image_key\": path.rpartition(\"/\")[2],\n        })\n\n\nclass ImagebamGalleryExtractor(ImagebamExtractor):\n    \"\"\"Extractor for imagebam galleries\"\"\"\n    subcategory = \"gallery\"\n    directory_fmt = (\"{category}\", \"{title} {gallery_key}\")\n    filename_fmt = \"{num:>03} {filename}.{extension}\"\n    archive_fmt = \"{gallery_key}_{image_key}\"\n    pattern = (r\"(?:https?://)?(?:www\\.)?imagebam\\.com\"\n               r\"(/(?:gallery/|view/G)[a-zA-Z0-9]+)\")\n    example = \"https://www.imagebam.com/view/GID\"\n\n    def items(self):\n        page = self.request(self.root + self.path, notfound=True).text\n\n        images = self.images(page)\n        images.reverse()\n\n        data = self.metadata(page)\n        data[\"count\"] = len(images)\n        data[\"gallery_key\"] = self.path.rpartition(\"/\")[2]\n\n        yield Message.Directory, \"\", data\n        for data[\"num\"], path in enumerate(images, 1):\n            image = self._parse_image_page(path)\n            image.update(data)\n            yield Message.Url, image[\"url\"], image\n\n    def metadata(self, page):\n        return {\"title\": text.unescape(text.extr(\n            page, 'id=\"gallery-name\">', '<').strip())}\n\n    def images(self, page):\n        findall = text.re(r'<a href=\"https://www\\.imagebam\\.com'\n                          r'(/(?:image/|view/M)[a-zA-Z0-9]+)').findall\n        paths = []\n        while True:\n            paths += findall(page)\n            pos = page.find('rel=\"next\" aria-label=\"Next')\n            if pos > 0:\n                if url := text.rextr(page, 'href=\"', '\"', pos):\n                    page = self.request(url).text\n                    continue\n            return paths\n\n\nclass ImagebamImageExtractor(ImagebamExtractor):\n    \"\"\"Extractor for single imagebam images\"\"\"\n    subcategory = \"image\"\n    archive_fmt = \"{image_key}\"\n    pattern = (r\"(?:https?://)?(?:\\w+\\.)?imagebam\\.com\"\n               r\"(/(?:image/|view/M|(?:[0-9a-f]{2}/){3})[a-zA-Z0-9]+)\")\n    example = \"https://www.imagebam.com/view/MID\"\n\n    def items(self):\n        path = self.path\n        if path[3] == \"/\":\n            path = (\"/view/\" if path[10] == \"M\" else \"/image/\") + path[10:]\n\n        image = self._parse_image_page(path)\n        yield Message.Directory, \"\", image\n        yield Message.Url, image[\"url\"], image\n"
  },
  {
    "path": "gallery_dl/extractor/imagechest.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2020 Leonid \"Bepis\" Pavel\n# Copyright 2023-2026 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://imgchest.com/\"\"\"\n\nfrom .common import GalleryExtractor, Extractor, Message\nfrom .. import text, util\n\nBASE_PATTERN = r\"(?:https?://)?(?:www\\.)?imgchest\\.com\"\n\n\nclass ImagechestGalleryExtractor(GalleryExtractor):\n    \"\"\"Extractor for image galleries from imgchest.com\"\"\"\n    category = \"imagechest\"\n    root = \"https://imgchest.com\"\n    pattern = BASE_PATTERN + r\"/p/([A-Za-z0-9]{11})\"\n    example = \"https://imgchest.com/p/abcdefghijk\"\n\n    def __init__(self, match):\n        self.gallery_id = match[1]\n        url = self.root + \"/p/\" + self.gallery_id\n        GalleryExtractor.__init__(self, match, url)\n\n    def _init(self):\n        if access_token := self.config(\"access-token\"):\n            self.api = ImagechestAPI(self, access_token)\n            self.page_url = None\n            self.metadata = self._metadata_api\n\n    def metadata(self, page):\n        try:\n            data = util.json_loads(text.unescape(text.extr(\n                page, 'data-page=\"', '\"')))\n            post = data[\"props\"][\"post\"]\n        except Exception:\n            if \"<title>Not Found</title>\" in page:\n                raise self.exc.NotFoundError(\"gallery\")\n            self.files = ()\n            return {}\n\n        self.files = post.pop(\"files\", ())\n        post[\"gallery_id\"] = self.gallery_id\n        post[\"tags\"] = [tag[\"name\"] for tag in post[\"tags\"]]\n\n        return post\n\n    def _metadata_api(self, page):\n        post = self.api.post(self.gallery_id)\n\n        post[\"date\"] = self.parse_datetime_iso(post[\"created\"])\n        for img in post[\"images\"]:\n            img[\"date\"] = self.parse_datetime_iso(img[\"created\"])\n\n        post[\"gallery_id\"] = self.gallery_id\n        post.pop(\"image_count\", None)\n        self.files = post.pop(\"images\")\n\n        return post\n\n    def images(self, page):\n        try:\n            return [\n                (file[\"link\"], file)\n                for file in self.files\n            ]\n        except Exception:\n            return ()\n\n\nclass ImagechestUserExtractor(Extractor):\n    \"\"\"Extractor for imgchest.com user profiles\"\"\"\n    category = \"imagechest\"\n    subcategory = \"user\"\n    root = \"https://imgchest.com\"\n    pattern = BASE_PATTERN + r\"/u/([^/?#]+)\"\n    example = \"https://imgchest.com/u/USER\"\n\n    def items(self):\n        url = self.root + \"/api/posts\"\n        params = {\n            \"page\"    : 1,\n            \"sort\"    : \"new\",\n            \"tag\"     : \"\",\n            \"q\"       : \"\",\n            \"username\": text.unquote(self.groups[0]),\n            \"nsfw\"    : \"true\",\n        }\n\n        while True:\n            try:\n                data = self.request_json(url, params=params)[\"data\"]\n            except (TypeError, KeyError):\n                return\n\n            if not data:\n                return\n\n            for gallery in data:\n                gallery[\"_extractor\"] = ImagechestGalleryExtractor\n                yield Message.Queue, gallery[\"link\"], gallery\n\n            params[\"page\"] += 1\n\n\nclass ImagechestAPI():\n    \"\"\"Interface for the Image Chest API\n\n    https://imgchest.com/docs/api/1.0/general/overview\n    \"\"\"\n    root = \"https://api.imgchest.com\"\n\n    def __init__(self, extractor, access_token):\n        self.extractor = extractor\n        self.headers = {\"Authorization\": \"Bearer \" + access_token}\n\n    def file(self, file_id):\n        endpoint = \"/v1/file/\" + file_id\n        return self._call(endpoint)\n\n    def post(self, post_id):\n        endpoint = \"/v1/post/\" + post_id\n        return self._call(endpoint)\n\n    def user(self, username):\n        endpoint = \"/v1/user/\" + username\n        return self._call(endpoint)\n\n    def _call(self, endpoint):\n        url = self.root + endpoint\n\n        while True:\n            response = self.extractor.request(\n                url, headers=self.headers, fatal=None, allow_redirects=False)\n\n            if response.status_code < 300:\n                return response.json()[\"data\"]\n\n            elif response.status_code < 400:\n                raise self.extractor.exc.AuthenticationError(\n                    \"Invalid API access token\")\n\n            elif response.status_code == 429:\n                self.extractor.wait(seconds=600)\n\n            else:\n                self.extractor.log.debug(response.text)\n                raise self.extractor.exc.AbortExtraction(\"API request failed\")\n"
  },
  {
    "path": "gallery_dl/extractor/imagefap.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2016-2026 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://www.imagefap.com/\"\"\"\n\nfrom .common import Extractor, Message\nfrom .. import text\n\nBASE_PATTERN = r\"(?:https?://)?(?:www\\.|beta\\.)?imagefap\\.com\"\n\n\nclass ImagefapExtractor(Extractor):\n    \"\"\"Base class for imagefap extractors\"\"\"\n    category = \"imagefap\"\n    root = \"https://www.imagefap.com\"\n    directory_fmt = (\"{category}\", \"{gallery_id} {title}\")\n    filename_fmt = (\"{category}_{gallery_id}_{num:?/_/>04}\"\n                    \"{filename}.{extension}\")\n    archive_fmt = \"{gallery_id}_{image_id}\"\n    request_interval = (2.0, 4.0)\n\n    def request(self, url, **kwargs):\n        response = Extractor.request(self, url, **kwargs)\n\n        if response.history and response.url.endswith(\"/human-verification\"):\n            self.log.warning(\"HTTP redirect to '%s'\", response.url)\n            if msg := text.extr(response.text, '<div class=\"mt-4', '<'):\n                msg = \" \".join(msg.partition(\">\")[2].split())\n                raise self.exc.AbortExtraction(f\"'{msg}'\")\n\n        return response\n\n\nclass ImagefapGalleryExtractor(ImagefapExtractor):\n    \"\"\"Extractor for image galleries from imagefap.com\"\"\"\n    subcategory = \"gallery\"\n    pattern = BASE_PATTERN + r\"/(?:gallery\\.php\\?gid=|gallery/|pictures/)(\\d+)\"\n    example = \"https://www.imagefap.com/gallery/12345\"\n\n    def items(self):\n        self.gid = self.groups[0]\n        url = f\"{self.root}/gallery/{self.gid}\"\n        page = self.request(url).text\n        data = self.get_job_metadata(page)\n        yield Message.Directory, \"\", data\n        for url, image in self.get_images():\n            data.update(image)\n            yield Message.Url, url, data\n\n    def get_job_metadata(self, page):\n        \"\"\"Collect metadata for extractor-job\"\"\"\n        extr = text.extract_from(page)\n\n        data = {\n            \"gallery_id\": text.parse_int(self.gid),\n            \"uploader\": extr(\"porn picture gallery by \", \" to see hottest\"),\n            \"title\": text.unescape(extr(\"<title>\", \"<\")),\n            \"description\": text.unescape(extr(\n                'id=\"gdesc_text\"', '<').partition(\">\")[2]),\n            \"categories\": text.split_html(extr(\n                'id=\"cnt_cats\"', '</div>'))[1::2],\n            \"tags\": text.split_html(extr(\n                'id=\"cnt_tags\"', '</div>'))[1::2],\n            \"count\": text.parse_int(extr(' 1 of ', ' pics\"')),\n        }\n\n        self.image_id = extr('id=\"img_ed_', '\"')\n        self._count = data[\"count\"]\n\n        return data\n\n    def get_images(self):\n        \"\"\"Collect image-urls and -metadata\"\"\"\n        url = f\"{self.root}/photo/{self.image_id}/\"\n        params = {\"gid\": self.gid, \"idx\": 0, \"partial\": \"true\"}\n        headers = {\n            \"Content-Type\": \"application/x-www-form-urlencoded\",\n            \"X-Requested-With\": \"XMLHttpRequest\",\n            \"Referer\": f\"{url}?pgid=&gid={self.image_id}&page=0\"\n        }\n\n        num = 0\n        total = self._count\n        while True:\n            page = self.request(url, params=params, headers=headers).text\n\n            cnt = 0\n            for image_url in text.extract_iter(page, '<a href=\"', '\"'):\n                num += 1\n                cnt += 1\n                data = text.nameext_from_url(image_url)\n                data[\"num\"] = num\n                data[\"image_id\"] = text.parse_int(data[\"filename\"])\n                yield image_url, data\n\n            if not cnt or cnt < 24 and num >= total:\n                return\n            params[\"idx\"] += cnt\n\n\nclass ImagefapImageExtractor(ImagefapExtractor):\n    \"\"\"Extractor for single images from imagefap.com\"\"\"\n    subcategory = \"image\"\n    pattern = BASE_PATTERN + r\"/photo/(\\d+)\"\n    example = \"https://www.imagefap.com/photo/12345\"\n\n    def items(self):\n        url, data = self.get_image()\n        yield Message.Directory, \"\", data\n        yield Message.Url, url, data\n\n    def get_image(self):\n        url = f\"{self.root}/photo/{self.groups[0]}/\"\n        page = self.request(url).text\n\n        url, pos = text.extract(\n            page, 'original=\"', '\"')\n        image_id, pos = text.extract(\n            page, 'id=\"imageid_input\" value=\"', '\"', pos)\n        gallery_id, pos = text.extract(\n            page, 'id=\"galleryid_input\" value=\"', '\"', pos)\n        info = self._extract_jsonld(page)\n\n        return url, text.nameext_from_url(url, {\n            \"title\": text.unescape(info[\"name\"]),\n            \"uploader\": info[\"author\"],\n            \"date\": info[\"datePublished\"],\n            \"width\": text.parse_int(info[\"width\"]),\n            \"height\": text.parse_int(info[\"height\"]),\n            \"gallery_id\": text.parse_int(gallery_id),\n            \"image_id\": text.parse_int(image_id),\n        })\n\n\nclass ImagefapFolderExtractor(ImagefapExtractor):\n    \"\"\"Extractor for imagefap user folders\"\"\"\n    subcategory = \"folder\"\n    pattern = (BASE_PATTERN + r\"/(?:organizer/|\"\n               r\"(?:usergallery\\.php\\?user(id)?=([^&#]+)&\"\n               r\"|profile/([^/?#]+)/galleries\\?)folderid=(?!0\\b))(\\d+|-1)\")\n    example = \"https://www.imagefap.com/organizer/12345\"\n\n    def items(self):\n        for gallery_id, name, folder in self.galleries():\n            url = f\"{self.root}/gallery/{gallery_id}\"\n            data = {\n                \"gallery_id\": gallery_id,\n                \"title\"     : text.unescape(name),\n                \"folder\"    : text.unescape(folder),\n                \"_extractor\": ImagefapGalleryExtractor,\n            }\n            yield Message.Queue, url, data\n\n    def galleries(self):\n        \"\"\"Yield gallery IDs and titles of a folder\"\"\"\n        _id, user, profile, folder_id = self.groups\n\n        if folder_id == \"-1\":\n            folder_name = \"Uncategorized\"\n            if _id:\n                url = (f\"{self.root}/usergallery.php\"\n                       f\"?userid={user}&folderid=-1\")\n            else:\n                url = (f\"{self.root}/profile/\"\n                       f\"{user or profile}/galleries?folderid=-1\")\n        else:\n            folder_name = None\n            url = f\"{self.root}/organizer/{folder_id}/\"\n\n        params = {\"page\": 0}\n        extr = text.extract_from(self.request(url, params=params).text)\n        if folder_name is None:\n            folder_name = extr(\"class'blk_galleries'><b>\", \"</b>\")\n\n        while True:\n            cnt = 0\n\n            while True:\n                gid = extr(' id=\"gid-', '\"')\n                if not gid:\n                    break\n                yield gid, extr(\"<b>\", \"<\"), folder_name\n                cnt += 1\n\n            if cnt < 20:\n                break\n            params[\"page\"] += 1\n            extr = text.extract_from(self.request(url, params=params).text)\n\n\nclass ImagefapUserExtractor(ImagefapExtractor):\n    \"\"\"Extractor for an imagefap user profile\"\"\"\n    subcategory = \"user\"\n    pattern = (BASE_PATTERN +\n               r\"/(?:profile(?:\\.php\\?user=|/)([^/?#]+)\"\n               r\"(?:/galleries(?:\\?folderid=0)?)?\"\n               r\"|usergallery\\.php\\?userid=(\\d+))(?:$|#)\")\n    example = \"https://www.imagefap.com/profile/USER\"\n\n    def items(self):\n        data = {\"_extractor\": ImagefapFolderExtractor}\n\n        for folder_id in self.folders():\n            if folder_id == \"-1\":\n                url = (f\"{self.root}/profile/{self.user}/galleries\"\n                       f\"?folderid=-1\")\n            else:\n                url = f\"{self.root}/organizer/{folder_id}/\"\n            yield Message.Queue, url, data\n\n    def folders(self):\n        \"\"\"Return a list of folder IDs of a user\"\"\"\n        user, user_id = self.groups\n        if user:\n            url = f\"{self.root}/profile/{user}/galleries\"\n        else:\n            url = f\"{self.root}/usergallery.php?userid={user_id}\"\n        params = {\"page\": 0}\n        pnum = 0\n\n        self.user = None\n        while True:\n            response = self.request(url, params=params)\n\n            if self.user is None:\n                url = response.url.partition(\"?\")[0]\n                self.user = url.rsplit(\"/\", 2)[1]\n\n            page = response.text\n            folders = text.extr(\n                page, ' id=\"tgl_all\" value=\"', '\"').rstrip(\"|\").split(\"|\")\n            if folders[-1] == \"-1\":\n                last = folders.pop()\n                if not pnum:\n                    folders.insert(0, last)\n            elif not folders[0]:\n                break\n            yield from folders\n\n            params[\"page\"] = pnum = pnum + 1\n            if f'href=\"?page={pnum}\">{pnum+1}</a>' not in page:\n                break\n"
  },
  {
    "path": "gallery_dl/extractor/imagehosts.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2016-2026 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Collection of extractors for various imagehosts\"\"\"\n\nfrom .common import Extractor, Message\nfrom .. import text\n\n\nclass ImagehostImageExtractor(Extractor):\n    \"\"\"Base class for single-image extractors for various imagehosts\"\"\"\n    basecategory = \"imagehost\"\n    subcategory = \"image\"\n    archive_fmt = \"{token}\"\n    parent = True\n    _params = None\n    _cookies = None\n    _encoding = None\n    _validate = None\n\n    def __init__(self, match):\n        Extractor.__init__(self, match)\n        self.page_url = (self.root or \"https://\") + match[1]\n        self.token = match[2]\n\n        if self._params == \"simple\":\n            self._params = {\n                \"imgContinue\": \"Continue+to+image+...+\",\n            }\n        elif self._params == \"complex\":\n            self._params = {\n                \"op\": \"view\",\n                \"id\": self.token,\n                \"pre\": \"1\",\n                \"adb\": \"1\",\n                \"next\": \"Continue+to+image+...+\",\n            }\n\n    def items(self):\n        _cookies = self._cookies\n        if _cookies is not None and callable(_cookies):\n            _cookies = self.cache(_cookies, _key=None, _exp=3*3600)\n\n        page = self.request(\n            self.page_url,\n            method=(\"POST\" if self._params else \"GET\"),\n            data=self._params,\n            cookies=_cookies,\n            encoding=self._encoding,\n        ).text\n\n        url, filename = self.get_info(page)\n        if not url:\n            return\n\n        if filename:\n            data = text.nameext_from_name(filename)\n            if not data[\"extension\"]:\n                data[\"extension\"] = text.ext_from_url(url)\n        else:\n            data = text.nameext_from_url(url)\n        data[\"token\"] = self.token\n        data[\"post_url\"] = self.page_url\n        data.update(self.metadata(page))\n\n        if url.startswith(\"http:\"):\n            url = \"https:\" + url[5:]\n        if self._validate is not None:\n            data[\"_http_validate\"] = self._validate\n\n        yield Message.Directory, \"\", data\n        yield Message.Url, url, data\n\n    def get_info(self, page):\n        \"\"\"Find image-url and string to get filename from\"\"\"\n\n    def metadata(self, page):\n        \"\"\"Return additional metadata\"\"\"\n        return ()\n\n    def not_found(self, resource=None):\n        raise self.exc.NotFoundError(resource or self.__class__.subcategory)\n\n\nclass ImxtoImageExtractor(ImagehostImageExtractor):\n    \"\"\"Extractor for single images from imx.to\"\"\"\n    category = \"imxto\"\n    pattern = (r\"(?:https?://)?(?:www\\.)?((?:imx\\.to|img\\.yt)\"\n               r\"/(?:i/|img-)(\\w+)(\\.html)?)\")\n    example = \"https://imx.to/i/ID\"\n    _params = \"simple\"\n    _encoding = \"utf-8\"\n\n    def __init__(self, match):\n        ImagehostImageExtractor.__init__(self, match)\n        if \"/img-\" in self.page_url:\n            self.page_url = self.page_url.replace(\"img.yt\", \"imx.to\")\n\n    def get_info(self, page):\n        url, pos = text.extract(\n            page, '<div style=\"text-align:center;\"><a href=\"', '\"')\n        if not url:\n            self.not_found()\n        filename, pos = text.extract(page, ' title=\"', '\"', pos)\n        return url, filename or None\n\n    def metadata(self, page):\n        extr = text.extract_from(page, page.index(\"[ FILESIZE <\"))\n        size = extr(\">\", \"</span>\").replace(\" \", \"\")[:-1]\n        width, _, height = extr(\">\", \" px</span>\").partition(\"x\")\n        return {\n            \"size\"  : text.parse_bytes(size),\n            \"width\" : text.parse_int(width),\n            \"height\": text.parse_int(height),\n            \"hash\"  : extr(\">\", \"</span>\"),\n        }\n\n\nclass ImxtoGalleryExtractor(ImagehostImageExtractor):\n    \"\"\"Extractor for image galleries from imx.to\"\"\"\n    category = \"imxto\"\n    subcategory = \"gallery\"\n    pattern = r\"(?:https?://)?(?:www\\.)?(imx\\.to/g/([^/?#]+))\"\n    example = \"https://imx.to/g/ID\"\n\n    def items(self):\n        page = self.request(self.page_url).text\n        title, pos = text.extract(page, '<div class=\"title', '<')\n        data = {\n            \"_extractor\": ImxtoImageExtractor,\n            \"title\": text.unescape(title.partition(\">\")[2]).strip(),\n        }\n\n        params = {\"page\": 1}\n        while True:\n            for url in text.extract_iter(page, \"<a href=\", \" \", pos):\n                if \"/i/\" in url:\n                    yield Message.Queue, url.strip(\"\\\"'\"), data\n\n            if 'class=\"pagination' not in page or \\\n                    'class=\"disabled\">Last' in page:\n                return\n\n            params[\"page\"] += 1\n            page = self.request(self.page_url, params=params).text\n\n\nclass AcidimgImageExtractor(ImagehostImageExtractor):\n    \"\"\"Extractor for single images from acidimg.cc\"\"\"\n    category = \"acidimg\"\n    pattern = r\"(?:https?://)?((?:www\\.)?acidimg\\.cc/img-([a-z0-9]+)\\.html)\"\n    example = \"https://acidimg.cc/img-abc123.html\"\n    _params = \"simple\"\n    _encoding = \"utf-8\"\n\n    def get_info(self, page):\n        url, pos = text.extract(page, \"<img class='centred' src='\", \"'\")\n        if not url:\n            url, pos = text.extract(page, '<img class=\"centred\" src=\"', '\"')\n            if not url:\n                self.not_found()\n\n        filename, pos = text.extract(page, \"alt='\", \"'\", pos)\n        if not filename:\n            filename, pos = text.extract(page, 'alt=\"', '\"', pos)\n\n        return url, filename or None\n\n\nclass ImagevenueImageExtractor(ImagehostImageExtractor):\n    \"\"\"Extractor for single images from imagevenue.com\"\"\"\n    category = \"imagevenue\"\n    pattern = (r\"(?:https?://)?((?:www|img\\d+)\\.imagevenue\\.com\"\n               r\"/([A-Z0-9]{8,10}|view/.*|img\\.php\\?.*))\")\n    example = \"https://www.imagevenue.com/ME123456789\"\n\n    def _cookies(self):\n        return self.request(self.page_url).cookies\n\n    def get_info(self, page):\n        try:\n            pos = page.index('class=\"card-body')\n        except ValueError:\n            self.not_found()\n\n        url, pos = text.extract(page, '<img src=\"', '\"', pos)\n        if url.endswith(\"/loader.svg\"):\n            url, pos = text.extract(page, '<img src=\"', '\"', pos)\n        filename, pos = text.extract(page, 'alt=\"', '\"', pos)\n        return url, text.unescape(filename)\n\n    def _validate(self, response):\n        hget = response.headers.get\n        return not (\n            hget(\"content-length\") == \"14396\" and\n            hget(\"content-type\") == \"image/jpeg\" and\n            hget(\"last-modified\") == \"Mon, 04 May 2020 07:19:52 GMT\"\n        )\n\n\nclass ImagetwistImageExtractor(ImagehostImageExtractor):\n    \"\"\"Extractor for single images from imagetwist.com\"\"\"\n    category = \"imagetwist\"\n    pattern = (r\"(?:https?://)?((?:www\\.|phun\\.)?image(?:twist|haha)\\.com\"\n               r\"/([a-z0-9]{12}))\")\n    example = \"https://imagetwist.com/123456abcdef/NAME.EXT\"\n\n    def _cookies(self):\n        return self.request(self.page_url).cookies\n\n    def get_info(self, page):\n        url     , pos = text.extract(page, '<img src=\"', '\"')\n        if url and url.startswith(\"/imgs/\"):\n            self.not_found()\n        filename, pos = text.extract(page, ' alt=\"', '\"', pos)\n        return url, filename\n\n\nclass ImagetwistGalleryExtractor(ImagehostImageExtractor):\n    \"\"\"Extractor for galleries from imagetwist.com\"\"\"\n    category = \"imagetwist\"\n    subcategory = \"gallery\"\n    pattern = (r\"(?:https?://)?((?:www\\.|phun\\.)?image(?:twist|haha)\\.com/(\"\n               r\"p/[^/?#]+/(\\d+)|\"\n               r\"\\?[^#]*\\bfld_id=\\d+[^#]*&page=\\d+))\")\n    example = \"https://imagetwist.com/p/USER/12345/TITLE\"\n\n    def items(self):\n        url = self.page_url\n        root = url[:url.find(\"/\", 8)]\n        page = self.request(url).text\n\n        extr = text.extract_from(page)\n        data = {\n            \"_extractor\"   : ImagetwistImageExtractor,\n            \"gallery_title\": text.unescape(extr('page_main_title\">', \"<\")),\n            \"gallery_id\"   : self.groups[2] or extr(\"&amp;fld_id=\", \"&\"),\n        }\n        del extr\n\n        while True:\n            gallery = text.extr(page, 'class=\"gallerys', \"</div\")\n            for path in text.extract_iter(gallery, ' href=\"', '\"'):\n                yield Message.Queue, root + path, data\n\n            pos = page.find(\"&#187;</a>\")\n            if pos < 0:\n                break\n            qs = text.unescape(text.rextr(page, \"href='\", \"'\", pos))\n\n            page = self.request(f\"{root}/{qs}\").text\n\n\nclass ImgadultImageExtractor(ImagehostImageExtractor):\n    \"\"\"Extractor for single images from imgadult.com\"\"\"\n    category = \"imgadult\"\n    _cookies = {\"img_i_d\": \"1\"}\n    pattern = r\"(?:https?://)?((?:www\\.)?imgadult\\.com/img-([0-9a-f]+)\\.html)\"\n    example = \"https://imgadult.com/img-0123456789abc.html\"\n\n    def get_info(self, page):\n        url , pos = text.extract(page, \"' src='\", \"'\")\n        name, pos = text.extract(page, \"alt='\", \"'\", pos)\n\n        if name:\n            name, _, rhs = name.rpartition(\" image hosted at ImgAdult.com\")\n            if not name:\n                name = rhs\n            name = text.unescape(name)\n\n        return url, name\n\n\nclass ImgspiceImageExtractor(ImagehostImageExtractor):\n    \"\"\"Extractor for single images from imgspice.com\"\"\"\n    category = \"imgspice\"\n    pattern = r\"(?:https?://)?((?:www\\.)?imgspice\\.com/([^/?#]+))\"\n    example = \"https://imgspice.com/ID/NAME.EXT.html\"\n\n    def get_info(self, page):\n        pos = page.find('id=\"imgpreview\"')\n        if pos < 0:\n            self.not_found()\n        url , pos = text.extract(page, 'src=\"', '\"', pos)\n        name, pos = text.extract(page, 'alt=\"', '\"', pos)\n        return url, text.unescape(name)\n\n\nclass PixhostImageExtractor(ImagehostImageExtractor):\n    \"\"\"Extractor for single images from pixhost.to\"\"\"\n    category = \"pixhost\"\n    root = \"https://pixhost.to\"\n    pattern = (r\"(?:https?://)?(?:www\\.)?pixhost\\.(?:to|org)\"\n               r\"(/show/\\d+/(\\d+)_[^/?#]+)\")\n    example = \"https://pixhost.to/show/123/12345_NAME.EXT\"\n    _cookies = {\"pixhostads\": \"1\", \"pixhosttest\": \"1\"}\n\n    def get_info(self, page):\n        self.kwdict[\"directory\"] = self.page_url.rsplit(\"/\")[-2]\n        url , pos = text.extract(page, \"class=\\\"image-img\\\" src=\\\"\", \"\\\"\")\n        name, pos = text.extract(page, \"alt=\\\"\", \"\\\"\", pos)\n        return url, text.unescape(name) if name else None\n\n\nclass PixhostGalleryExtractor(ImagehostImageExtractor):\n    \"\"\"Extractor for image galleries from pixhost.to\"\"\"\n    category = \"pixhost\"\n    subcategory = \"gallery\"\n    root = \"https://pixhost.to\"\n    pattern = (r\"(?:https?://)?(?:www\\.)?pixhost\\.(?:to|org)\"\n               r\"(/gallery/([^/?#]+))\")\n    example = \"https://pixhost.to/gallery/ID\"\n\n    def items(self):\n        page = text.extr(self.request(\n            self.page_url).text, 'class=\"images\"', \"</div>\")\n        data = {\"_extractor\": PixhostImageExtractor}\n        for url in text.extract_iter(page, '<a href=\"', '\"'):\n            yield Message.Queue, url, data\n\n\nclass PostimgImageExtractor(ImagehostImageExtractor):\n    \"\"\"Extractor for single images from postimages.org\"\"\"\n    category = \"postimg\"\n    root = \"https://postimg.cc\"\n    pattern = (r\"(?:https?://)?(?:www\\.)?(?:postim(?:ages|g)|pixxxels)\"\n               r\"\\.(?:cc|org)(/(?!gallery/)(?:image/)?([^/?#]+)/?)\")\n    example = \"https://postimg.cc/ID\"\n\n    def get_info(self, page):\n        pos = page.index(' id=\"download\"')\n        url     , pos = text.rextract(page, ' href=\"', '\"', pos)\n        filename, pos = text.extract(page, ' alt=\"', '\"', pos)\n        return url, text.unescape(filename) if filename else None\n\n\nclass PostimgGalleryExtractor(ImagehostImageExtractor):\n    \"\"\"Extractor for images galleries from postimages.org\"\"\"\n    category = \"postimg\"\n    subcategory = \"gallery\"\n    root = \"https://postimg.cc\"\n    pattern = (r\"(?:https?://)?(?:www\\.)?(?:postim(?:ages|g)|pixxxels)\"\n               r\"\\.(?:cc|org)(/gallery/([^/?#]+))\")\n    example = \"https://postimg.cc/gallery/ID\"\n\n    def items(self):\n        page = self.request(self.page_url).text\n        title = text.unescape(text.extr(\n            page, 'property=\"og:title\" content=\"', ' — Postimages\"'))\n\n        url = self.root + \"/json\"\n        params = {\n            \"action\": \"list\",\n            \"page\"  : 1,\n            \"album\" : self.groups[1],\n        }\n\n        base = self.root + \"/\"\n        while True:\n            data = self.request_json(url, params=params)\n\n            for token, t, name, ext, w, h, _, _, _, _, _ in data[\"images\"]:\n                yield Message.Queue, base + t, {\n                    \"_extractor\"   : PostimgImageExtractor,\n                    \"gallery_title\": title,\n                    \"token\"    : token,\n                    \"filename\" : name,\n                    \"extension\": ext,\n                    \"width\"    : w,\n                    \"height\"   : h,\n                    \"thumbnail\": t,\n                }\n\n            if not data.get(\"has_page_next\"):\n                break\n            params[\"page\"] += 1\n\n\nclass TurboimagehostImageExtractor(ImagehostImageExtractor):\n    \"\"\"Extractor for single images from www.turboimagehost.com\"\"\"\n    category = \"turboimagehost\"\n    pattern = (r\"(?:https?://)?((?:www\\.)?turboimagehost\\.com\"\n               r\"/p/(\\d+)/[^/?#]+\\.html)\")\n    example = \"https://www.turboimagehost.com/p/12345/NAME.EXT.html\"\n\n    def get_info(self, page):\n        url = text.extract(page, 'src=\"', '\"', page.index(\"<img \"))[0]\n        return url, None\n\n\nclass TurboimagehostGalleryExtractor(ImagehostImageExtractor):\n    \"\"\"Extractor for image galleries from turboimagehost.com\"\"\"\n    category = \"turboimagehost\"\n    subcategory = \"gallery\"\n    pattern = (r\"(?:https?://)?((?:www\\.)?turboimagehost\\.com\"\n               r\"/album/(\\d+)/([^/?#]*))\")\n    example = \"https://www.turboimagehost.com/album/12345/GALLERY_NAME\"\n\n    def items(self):\n        data = {\"_extractor\": TurboimagehostImageExtractor}\n        params = {\"p\": 1}\n\n        while True:\n            page = self.request(self.page_url, params=params).text\n\n            if params[\"p\"] == 1 and \\\n                    \"Requested gallery don`t exist on our website.\" in page:\n                self.not_found()\n\n            thumb_url = None\n            for thumb_url in text.extract_iter(page, '\"><a href=\"', '\"'):\n                yield Message.Queue, thumb_url, data\n            if thumb_url is None:\n                return\n\n            params[\"p\"] += 1\n\n\nclass ViprImageExtractor(ImagehostImageExtractor):\n    \"\"\"Extractor for single images from vipr.im\"\"\"\n    category = \"vipr\"\n    pattern = r\"(?:https?://)?(vipr\\.im/(\\w+))\"\n    example = \"https://vipr.im/abc123.html\"\n\n    def get_info(self, page):\n        url, pos = text.extract(page, '<img src=\"', '\"')\n        if not url or url[0] != \"h\":\n            self.not_found()\n        alt, pos = text.extract(page, ' alt=\"', '\"', pos)\n        return url, alt and text.unescape(alt)\n\n\nclass ImgclickImageExtractor(ImagehostImageExtractor):\n    \"\"\"Extractor for single images from imgclick.net\"\"\"\n    category = \"imgclick\"\n    pattern = r\"(?:https?://)?((?:www\\.)?imgclick\\.net/([^/?#]+))\"\n    example = \"http://imgclick.net/abc123/NAME.EXT.html\"\n    _params = \"complex\"\n\n    def get_info(self, page):\n        url     , pos = text.extract(page, '<br><img src=\"', '\"')\n        filename, pos = text.extract(page, 'alt=\"', '\"', pos)\n        return url, filename\n\n\nclass FappicImageExtractor(ImagehostImageExtractor):\n    \"\"\"Extractor for single images from fappic.com\"\"\"\n    category = \"fappic\"\n    pattern = (r\"(?:https?://)?(?:www\\.|img\\d+\\.)?fappic\\.com\"\n               r\"/(?:i/\\d+/())?(\\w{10,})(?:/|\\.)\\w+\")\n    example = \"https://fappic.com/abcde12345/NAME.EXT\"\n\n    def __init__(self, match):\n        Extractor.__init__(self, match)\n\n        thumb, token = self.groups\n        if thumb is not None and token.endswith(\"_t\"):\n            self.token = token = token[:-2]\n        else:\n            self.token = token\n        self.page_url = f\"https://fappic.com/{token}/pic.jpg\"\n\n    def get_info(self, page):\n        url     , pos = text.extract(page, '<a href=\"#\"><img src=\"', '\"')\n        filename, pos = text.extract(page, 'alt=\"', '\"', pos)\n        return url, text.re(r\"^Porn[ -]Pic(?:s|ture)[ -]\").sub(\"\", filename)\n\n\nclass PicstateImageExtractor(ImagehostImageExtractor):\n    \"\"\"Extractor for single images from picstate.com\"\"\"\n    category = \"picstate\"\n    pattern = r\"(?:https?://)?((?:www\\.)?picstate\\.com/view/full/([^/?#]+))\"\n    example = \"https://picstate.com/view/full/123\"\n\n    def get_info(self, page):\n        pos = page.index(' id=\"image_container\"')\n        url     , pos = text.extract(page, '<img src=\"', '\"', pos)\n        filename, pos = text.extract(page, 'alt=\"', '\"', pos)\n        return url, filename\n\n\nclass ImgdriveImageExtractor(ImagehostImageExtractor):\n    \"\"\"Extractor for single images from imgdrive.net\"\"\"\n    category = \"imgdrive\"\n    pattern = (r\"(?:https?://)?(?:www\\.)?(img(drive|taxi|wallet)\\.(?:com|net)\"\n               r\"/img-(\\w+)\\.html)\")\n    example = \"https://imgdrive.net/img-0123456789abc.html\"\n\n    def __init__(self, match):\n        path, category, self.token = match.groups()\n        self.page_url = \"https://\" + path\n        self.category = \"img\" + category\n        Extractor.__init__(self, match)\n\n    def get_info(self, page):\n        title, pos = text.extract(\n            page, 'property=\"og:title\" content=\"', '\"')\n        image, pos = text.extract(\n            page, 'property=\"og:image\" content=\"', '\"', pos)\n        return image.replace(\"/small/\", \"/big/\"), title.rsplit(\" | \", 2)[0]\n\n\nclass SilverpicImageExtractor(ImagehostImageExtractor):\n    \"\"\"Extractor for single images from silverpic.com\"\"\"\n    category = \"silverpic\"\n    root = \"https://silverpic.net\"\n    _params = \"complex\"\n    pattern = (r\"(?:https?://)?(?:www\\.)?silverpic\\.(?:net|com)\"\n               r\"(/([a-z0-9]{10,})/[\\S]+\\.html)\")\n    example = \"https://silverpic.net/a1b2c3d4f5g6/NAME.EXT.html\"\n\n    def get_info(self, page):\n        url, pos = text.extract(page, '<img src=\"/img/', '\"')\n        alt, pos = text.extract(page, 'alt=\"', '\"', pos)\n        return f\"{self.root}/img/{url}\", alt\n\n    def metadata(self, page):\n        pos = page.find('<img src=\"/img/')\n        width = text.extract(page, 'width=\"', '\"', pos)[0]\n        height = text.extract(page, 'height=\"', '\"', pos)[0]\n\n        return {\n            \"width\" : text.parse_int(width),\n            \"height\": text.parse_int(height),\n        }\n\n\nclass ImgpvImageExtractor(ImagehostImageExtractor):\n    \"\"\"Extractor for imgpv.com images\"\"\"\n    category = \"imgpv\"\n    root = \"https://imgpv.com\"\n    pattern = (r\"(?:https?://)?(?:www\\.)?imgpv\\.com\"\n               r\"(/([a-z0-9]{10,})/[\\S]+\\.html)\")\n    example = \"https://www.imgpv.com/a1b2c3d4f5g6/NAME.EXT.html\"\n\n    def get_info(self, page):\n        url, pos = text.extract(page, 'id=\"img-preview\" src=\"', '\"')\n        alt, pos = text.extract(page, 'alt=\"', '\"', pos)\n        return url, text.unescape(alt)\n\n    def metadata(self, page):\n        pos = page.find('class=\"upinfo\">')\n        date, pos = text.extract(page, '<b>', 'by', pos)\n        user, pos = text.extract(page, '>', '<', pos)\n\n        date = date.split()\n        return {\n            \"date\": self.parse_datetime_iso(f\"{date[0][:10]} {date[1]}\"),\n            \"user\": text.unescape(user),\n        }\n"
  },
  {
    "path": "gallery_dl/extractor/imagepond.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2026 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://www.imagepond.net/\"\"\"\n\nfrom .common import Extractor, Message\nfrom .. import text\n\nBASE_PATTERN = r\"(?:https?://)?(?:www\\.)?imagepond\\.net\"\n\n\nclass ImagepondExtractor(Extractor):\n    \"\"\"Base class for imagepond extractors\"\"\"\n    basecategory = \"chevereto\"\n    category = \"imagepond\"\n    root = \"https://www.imagepond.net\"\n    directory_fmt = (\"{category}\", \"{user}\", \"{album}\")\n    archive_fmt = \"{id}\"\n    parent = True\n\n    def _pagination(self, url, callback=None, pattern=None):\n        page = self.request(url).text\n\n        if callback is not None:\n            callback(page)\n\n        if pattern is not None:\n            find = text.re(pattern).findall\n\n        while True:\n            if pattern is None:\n                yield from text.extract_iter(\n                    page, \"'javascript:void(0)' : '\", \"'\")\n            else:\n                yield from find(page)\n\n            pos = page.find(' rel=\"next\"')\n            if pos < 0:\n                break\n            anchor = page[page.rfind(\"<\", None, pos):page.find(\">\", pos)]\n            url = text.extr(anchor, 'href=\"', '\"')\n            if not url:\n                break\n            page = self.request(text.unescape(url)).text\n\n\nclass ImagepondFileExtractor(ImagepondExtractor):\n    subcategory = \"file\"\n    pattern = BASE_PATTERN + r\"(/(?:i(?:mg|mage)?|video)/[^/?#]+)\"\n    example = \"https://www.imagepond.net/i/ID\"\n\n    def items(self):\n        url = self.root + self.groups[0]\n        response = self.request(url)\n        extr = text.extract_from(response.text)\n\n        title = text.unescape(extr('property=\"og:title\" content=\"', '\"'))\n        type = extr('property=\"og:type\" content=\"', '\"')\n\n        if type == \"image\":\n            file = {\n                \"type\": \"image\",\n                \"url\": extr('property=\"og:image\" content=\"', '\"'),\n                \"width\": text.parse_int(extr(\n                    'property=\"og:image:width\" content=\"', '\"')),\n                \"height\": text.parse_int(extr(\n                    'property=\"og:image:height\" content=\"', '\"')),\n            }\n        else:\n            file = {\n                \"type\": \"video\",\n                \"url\": extr('property=\"og:video\" content=\"', '\"'),\n                \"mime\": extr('property=\"og:video:type\" content=\"', '\"'),\n                \"width\": text.parse_int(extr(\n                    'property=\"og:video:width\" content=\"', '\"')),\n                \"height\": text.parse_int(extr(\n                    'property=\"og:video:height\" content=\"', '\"')),\n                \"thumbnail\": extr('property=\"og:image\" content=\"', '\"'),\n            }\n            extr('<span class=\"uppercase', \"</span>\")\n            m, _, s = extr('<span>', \"</span>\").partition(\":\")\n            file[\"duration\"] = text.parse_int(m) * 60 + text.parse_int(s)\n\n        file[\"id\"] = response.url.rpartition(\"/\")[2]\n        file[\"title\"] = title\n        file[\"date\"] = self.parse_datetime(extr(\n            '<span class=\"hidden sm:inline\">', '<'), \"%b %d, %Y\")\n        file[\"user\"] = extr('/user/', '\"')\n\n        if aid := extr(\"/a/\", '\"'):\n            file[\"album\"] = text.unescape(extr(\n                \"<span \", \"<\").partition(\">\")[2])\n            file[\"album_id\"] = aid\n        else:\n            file[\"album\"] = file[\"album_id\"] = \"\"\n\n        text.nameext_from_url(file[\"url\"], file)\n        if \"mime\" not in file:\n            file[\"mime\"] = f\"{file['type']}/{file['extension']}\"\n        yield Message.Directory, \"\", file\n        yield Message.Url, file[\"url\"], file\n\n\nclass ImagepondAlbumExtractor(ImagepondExtractor):\n    subcategory = \"album\"\n    pattern = BASE_PATTERN + r\"/a(?:lbum)?/(.+)\"\n    example = \"https://www.imagepond.net/a/ID\"\n\n    def items(self):\n        url = f\"{self.root}/a/{self.groups[0]}\"\n\n        data = {\"_extractor\": ImagepondFileExtractor}\n        for self.kwdict[\"num\"], item_url in enumerate(self._pagination(\n                url, callback=self._extract_metadata_album), 1):\n            yield Message.Queue, item_url, data\n\n    def _extract_metadata_album(self, page):\n        kwdict = self.kwdict\n        title, pos = text.extract(page, '<h1', '<')\n        kwdict[\"album\"] = \"\" if title is None else text.unescape(\n            title[title.find(\">\")+1:])\n        kwdict[\"count\"] = text.parse_int(text.extract(\n            page, \"<span>\", \" \", pos)[0])\n\n\nclass ImagepondUserExtractor(ImagepondExtractor):\n    subcategory = \"user\"\n    pattern = BASE_PATTERN + r\"/(?:user/)?(.+)\"\n    example = \"https://www.imagepond.net/user/USER\"\n\n    def items(self):\n        url = f\"{self.root}/user/{self.groups[0]}\"\n\n        base = self.root + \"/i/\"\n        data_file = {\"_extractor\": ImagepondFileExtractor}\n        for path in self._pagination(url, pattern=r'/i/([^/?#\"]+)\"'):\n            yield Message.Queue, base + path, data_file\n"
  },
  {
    "path": "gallery_dl/extractor/imgbb.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2019-2026 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://imgbb.com/\"\"\"\n\nfrom .common import Extractor, Message\nfrom .. import text, util\n\n\nclass ImgbbExtractor(Extractor):\n    \"\"\"Base class for imgbb extractors\"\"\"\n    category = \"imgbb\"\n    directory_fmt = (\"{category}\", \"{user[name]:?//}{user[id]:? (/)/}\",\n                     \"{album[title]} ({album[id]})\")\n    filename_fmt = \"{title} ({id}).{extension}\"\n    archive_fmt = \"{user[id]} {id}\"\n    cookies_domain = \".imgbb.com\"\n    cookies_names = (\"PHPSESSID\", \"LID\")\n    root = \"https://ibb.co\"\n\n    def items(self):\n        self.login()\n\n        for image in self.posts():\n            url = image[\"url\"]\n            text.nameext_from_url(url, image)\n            yield Message.Directory, \"\", image\n            yield Message.Url, url, image\n\n    def login(self):\n        if self.cookies_check(self.cookies_names):\n            return\n\n        username, password = self._get_auth_info()\n        if username:\n            return self.cookies_update(self.cache(\n                self._login_impl, username, password,\n                _exp=365*86400, _mem=False))\n\n    def _login_impl(self, username, password):\n        self.log.info(\"Logging in as %s\", username)\n\n        url = \"https://imgbb.com/login\"\n        page = self.request(url).text\n        token = text.extr(page, 'name=\"auth_token\" value=\"', '\"')\n\n        headers = {\n            \"Referer\": url,\n        }\n        data = {\n            \"auth_token\"   : token,\n            \"login-subject\": username,\n            \"password\"     : password,\n        }\n        response = self.request(url, method=\"POST\", headers=headers, data=data)\n\n        if not response.history:\n            raise self.exc.AuthenticationError()\n        return self.cookies\n\n    def _pagination(self, page, url, params):\n        seek, pos = text.extract(page, 'data-seek=\"', '\"')\n        tokn, pos = text.extract(page, 'PF.obj.config.auth_token=\"', '\"', pos)\n        resc, pos = text.extract(page, \"CHV.obj.resource=\", \"};\", pos)\n        self.kwdict[\"user\"] = util.json_loads(resc + \"}\").get(\"user\")\n\n        data = None\n        while True:\n            for obj in text.extract_iter(page, \"data-object='\", \"'\"):\n                post = util.json_loads(text.unquote(obj))\n                image = post[\"image\"]\n                image[\"filename\"], image[\"name\"] = \\\n                    image[\"name\"], image[\"filename\"]\n                image[\"id\"] = post[\"id_encoded\"]\n                image[\"title\"] = post[\"title\"]\n                image[\"width\"] = text.parse_int(post[\"width\"])\n                image[\"height\"] = text.parse_int(post[\"height\"])\n                image[\"size\"] = text.parse_int(image[\"size\"])\n                yield image\n\n            if data:\n                if not data[\"seekEnd\"] or params[\"seek\"] == data[\"seekEnd\"]:\n                    return\n                params[\"seek\"] = data[\"seekEnd\"]\n                params[\"page\"] += 1\n            elif not seek or 'class=\"pagination-next\"' not in page:\n                return\n            else:\n                params[\"action\"] = \"list\"\n                params[\"page\"] = 2\n                params[\"seek\"] = seek\n                params[\"auth_token\"] = tokn\n\n                headers = {\n                    \"Accept\": \"application/json, text/javascript, */*; q=0.01\",\n                    \"X-Requested-With\": \"XMLHttpRequest\",\n                    \"Origin\": self.root,\n                    \"Sec-Fetch-Dest\": \"empty\",\n                    \"Sec-Fetch-Mode\": \"cors\",\n                    \"Sec-Fetch-Site\": \"same-origin\",\n                }\n\n            data = self.request_json(\n                url, method=\"POST\", headers=headers, data=params)\n            page = data[\"html\"]\n\n\nclass ImgbbAlbumExtractor(ImgbbExtractor):\n    \"\"\"Extractor for imgbb albums\"\"\"\n    subcategory = \"album\"\n    pattern = r\"(?:https?://)?ibb\\.co/album/([^/?#]+)/?(?:\\?([^#]+))?\"\n    example = \"https://ibb.co/album/ID\"\n\n    def posts(self):\n        album_id, qs = self.groups\n        url = f\"{self.root}/album/{album_id}\"\n        params = text.parse_query(qs)\n        page = self.request(url, params=params).text\n        extr = text.extract_from(page)\n\n        self.kwdict[\"album\"] = album = {\n            \"url\": extr(\n                'property=\"og:url\" content=\"', '\"'),\n            \"title\": text.unescape(extr(\n                'property=\"og:title\" content=\"', '\"')),\n            \"description\": text.unescape(extr(\n                'property=\"og:description\" content=\"', '\"')),\n            \"id\": extr(\n                'data-text=\"album-name\" href=\"https://ibb.co/album/', '\"'),\n            \"count\": text.parse_int(extr(\n                'data-text=\"image-count\">', \"<\")),\n        }\n\n        url = self.root + \"/json\"\n        params[\"pathname\"] = \"/album/\" + album[\"id\"]\n        return self._pagination(page, url, params)\n\n\nclass ImgbbImageExtractor(ImgbbExtractor):\n    subcategory = \"image\"\n    pattern = r\"(?:https?://)?ibb\\.co/([^/?#]+)\"\n    example = \"https://ibb.co/ID\"\n\n    def posts(self):\n        url = f\"{self.root}/{self.groups[0]}\"\n        page = self.request(url).text\n        extr = text.extract_from(page)\n\n        image = {\n            \"id\"    : extr('property=\"og:url\" content=\"https://ibb.co/', '\"'),\n            \"title\" : text.unescape(extr(\n                '\"og:title\" content=\"', ' hosted at ImgBB\"')),\n            \"url\"   : extr('\"og:image\" content=\"', '\"'),\n            \"width\" : text.parse_int(extr('\"og:image:width\" content=\"', '\"')),\n            \"height\": text.parse_int(extr('\"og:image:height\" content=\"', '\"')),\n            \"album\" : extr(\"Added to <a\", \"</a>\"),\n            \"date\"  : self.parse_datetime_iso(extr('<span title=\"', '\"')),\n            \"user\"  : util.json_loads(extr(\n                \"CHV.obj.resource=\", \"};\") + \"}\").get(\"user\"),\n        }\n\n        if album := image[\"album\"]:\n            image[\"album\"] = {\n                \"id\"   : text.extr(album, \"/album/\", '\"'),\n                \"title\": text.unescape(album.rpartition(\">\")[2]),\n            }\n        else:\n            image[\"album\"] = None\n\n        return (image,)\n\n\nclass ImgbbUserExtractor(ImgbbExtractor):\n    \"\"\"Extractor for imgbb user profiles\"\"\"\n    subcategory = \"user\"\n    directory_fmt = (\"{category}\", \"{user[name]} ({user[id]})\")\n    pattern = r\"(?:https?://)?([\\w-]+)\\.imgbb\\.com/?(?:\\?([^#]+))?\"\n    example = \"https://USER.imgbb.com\"\n\n    def posts(self):\n        user, qs = self.groups\n        url = f\"https://{user}.imgbb.com/\"\n        params = text.parse_query(qs)\n        response = self.request(url, params=params, allow_redirects=False)\n\n        if response.status_code < 300:\n            params[\"pathname\"] = \"/\"\n            return self._pagination(response.text, url + \"json\", params)\n\n        if response.status_code == 301:\n            raise self.exc.NotFoundError(\"user\")\n        redirect = \"HTTP redirect to \" + response.headers.get(\"Location\", \"\")\n        if response.status_code == 302:\n            raise self.exc.AuthRequired(\n                (\"username & password\", \"authenticated cookies\"),\n                \"profile\", redirect)\n        raise self.exc.AbortExtraction(redirect)\n"
  },
  {
    "path": "gallery_dl/extractor/imgbox.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2014-2025 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://imgbox.com/\"\"\"\n\nfrom .common import Extractor, Message, AsynchronousMixin\nfrom .. import text\n\n\nclass ImgboxExtractor(Extractor):\n    \"\"\"Base class for imgbox extractors\"\"\"\n    category = \"imgbox\"\n    root = \"https://imgbox.com\"\n\n    def items(self):\n        data = self.get_job_metadata()\n        yield Message.Directory, \"\", data\n\n        for image_key in self.get_image_keys():\n            imgpage = self.request(self.root + \"/\" + image_key).text\n            imgdata = self.get_image_metadata(imgpage)\n            if imgdata[\"filename\"]:\n                imgdata.update(data)\n                imgdata[\"image_key\"] = image_key\n                text.nameext_from_url(imgdata[\"filename\"], imgdata)\n                yield Message.Url, self.get_image_url(imgpage), imgdata\n\n    def get_job_metadata(self):\n        \"\"\"Collect metadata for extractor-job\"\"\"\n        return {}\n\n    def get_image_keys(self):\n        \"\"\"Return an iterable containing all image-keys\"\"\"\n        return []\n\n    def get_image_metadata(self, page):\n        \"\"\"Collect metadata for a downloadable file\"\"\"\n        return text.extract_all(page, (\n            (\"num\"      , '</a> &nbsp; ', ' of '),\n            (None       , 'class=\"image-container\"', ''),\n            (\"filename\" , ' title=\"', '\"'),\n        ))[0]\n\n    def get_image_url(self, page):\n        \"\"\"Extract download-url\"\"\"\n        return text.extr(page, 'property=\"og:image\" content=\"', '\"')\n\n\nclass ImgboxGalleryExtractor(AsynchronousMixin, ImgboxExtractor):\n    \"\"\"Extractor for image galleries from imgbox.com\"\"\"\n    subcategory = \"gallery\"\n    directory_fmt = (\"{category}\", \"{title} - {gallery_key}\")\n    filename_fmt = \"{num:>03}-{filename}.{extension}\"\n    archive_fmt = \"{gallery_key}_{image_key}\"\n    pattern = r\"(?:https?://)?(?:www\\.)?imgbox\\.com/g/([A-Za-z0-9]{10})\"\n    example = \"https://imgbox.com/g/12345abcde\"\n\n    def __init__(self, match):\n        ImgboxExtractor.__init__(self, match)\n        self.gallery_key = match[1]\n        self.image_keys = []\n\n    def get_job_metadata(self):\n        page = self.request(self.root + \"/g/\" + self.gallery_key).text\n        if \"The specified gallery could not be found.\" in page:\n            raise self.exc.NotFoundError(\"gallery\")\n        self.image_keys = text.re(\n            r'<a href=\"/([^\"]+)\"><img alt=\"').findall(page)\n\n        title = text.extr(page, \"<h1>\", \"</h1>\")\n        title, _, count = title.rpartition(\" - \")\n        return {\n            \"gallery_key\": self.gallery_key,\n            \"title\": text.unescape(title),\n            \"count\": count[:-7],\n        }\n\n    def get_image_keys(self):\n        return self.image_keys\n\n\nclass ImgboxImageExtractor(ImgboxExtractor):\n    \"\"\"Extractor for single images from imgbox.com\"\"\"\n    subcategory = \"image\"\n    archive_fmt = \"{image_key}\"\n    pattern = (r\"(?:https?://)?(?:\"\n               r\"(?:www\\.|i\\.)?imgbox\\.com|\"\n               r\"images\\d+\\.imgbox\\.com/[0-9a-f]{2}/[0-9a-f]{2}\"\n               r\")/([A-Za-z0-9]{8})\")\n    example = \"https://imgbox.com/1234abcd\"\n\n    def __init__(self, match):\n        ImgboxExtractor.__init__(self, match)\n        self.image_key = match[1]\n\n    def get_image_keys(self):\n        return (self.image_key,)\n\n    def get_image_metadata(self, page):\n        data = ImgboxExtractor.get_image_metadata(self, page)\n        if not data[\"filename\"]:\n            raise self.exc.NotFoundError(\"image\")\n        return data\n"
  },
  {
    "path": "gallery_dl/extractor/imgpile.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2025 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://imgpile.com/\"\"\"\n\nfrom .common import Extractor, Message\nfrom .. import text\n\nBASE_PATTERN = r\"(?:https?://)?(?:www\\.)?imgpile\\.com\"\n\n\nclass ImgpileExtractor(Extractor):\n    \"\"\"Base class for imgpile extractors\"\"\"\n    category = \"imgpile\"\n    root = \"https://imgpile.com\"\n    directory_fmt = (\"{category}\", \"{post[author]}\",\n                     \"{post[title]} ({post[id_slug]})\")\n    archive_fmt = \"{post[id_slug]}_{id}\"\n\n\nclass ImgpilePostExtractor(ImgpileExtractor):\n    subcategory = \"post\"\n    pattern = BASE_PATTERN + r\"/p/(\\w+)\"\n    example = \"https://imgpile.com/p/AbCdEfG\"\n\n    def items(self):\n        post_id = self.groups[0]\n        url = f\"{self.root}/p/{post_id}\"\n        page = self.request(url).text\n        extr = text.extract_from(page)\n\n        post = {\n            \"id_slug\": post_id,\n            \"title\"  : text.unescape(extr(\"<title>\", \" - imgpile<\")),\n            \"id\"     : text.parse_int(extr('data-post-id=\"', '\"')),\n            \"author\" : extr('/u/', '\"'),\n            \"score\"  : text.parse_int(text.remove_html(extr(\n                'class=\"post-score\">', \"</\"))),\n            \"views\"  : text.parse_int(extr(\n                'class=\"meta-value\">', \"<\").replace(\",\", \"\")),\n            \"tags\"   : text.split_html(extr(\n                \" <!-- Tags -->\", '<!-- \"')),\n        }\n\n        files = self._extract_files(extr)\n        data = {\"post\": post}\n        data[\"count\"] = post[\"count\"] = len(files)\n\n        yield Message.Directory, \"\", data\n        for data[\"num\"], file in enumerate(files, 1):\n            data.update(file)\n            url = file[\"url\"]\n            yield Message.Url, url, text.nameext_from_url(url, data)\n\n    def _extract_files(self, extr):\n        files = []\n\n        while True:\n            media = extr('lass=\"post-media', '</div>')\n            if not media:\n                break\n            files.append({\n                \"id_slug\": text.extr(media, 'data-id=\"', '\"'),\n                \"id\" : text.parse_int(text.extr(\n                    media, 'data-media-id=\"', '\"')),\n                \"url\": \"http\" + text.extr(media, '<a href=\"http', '\"'),\n            })\n        return files\n\n\nclass ImgpileUserExtractor(ImgpileExtractor):\n    subcategory = \"user\"\n    pattern = BASE_PATTERN + r\"/u/([^/?#]+)\"\n    example = \"https://imgpile.com/u/USER\"\n\n    def items(self):\n        url = self.root + \"/api/v1/posts\"\n        params = {\n            \"limit\"     : \"100\",\n            \"sort\"      : \"latest\",\n            \"period\"    : \"all\",\n            \"visibility\": \"public\",\n            \"username\"  : self.groups[0],\n        }\n        headers = {\n            \"Accept\"        : \"application/json\",\n            #  \"Referer\"       : \"https://imgpile.com/u/USER\",\n            \"Content-Type\"  : \"application/json\",\n            #  \"X-CSRF-TOKEN\": \"\",\n            \"Sec-Fetch-Dest\": \"empty\",\n            \"Sec-Fetch-Mode\": \"cors\",\n            \"Sec-Fetch-Site\": \"same-origin\",\n        }\n\n        base = self.root + \"/p/\"\n        while True:\n            data = self.request_json(url, params=params, headers=headers)\n\n            if params is not None:\n                params = None\n                self.kwdict[\"total\"] = data[\"meta\"][\"total\"]\n\n            for item in data[\"data\"]:\n                item[\"_extractor\"] = ImgpilePostExtractor\n                url = base + item[\"slug\"]\n                yield Message.Queue, url, item\n\n            url = data[\"links\"].get(\"next\")\n            if not url:\n                return\n"
  },
  {
    "path": "gallery_dl/extractor/imgth.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2015-2025 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://imgth.com/\"\"\"\n\nfrom .common import GalleryExtractor\nfrom .. import text\n\n\nclass ImgthGalleryExtractor(GalleryExtractor):\n    \"\"\"Extractor for image galleries from imgth.com\"\"\"\n    category = \"imgth\"\n    root = \"https://imgth.com\"\n    pattern = r\"(?:https?://)?(?:www\\.)?imgth\\.com/gallery/(\\d+)\"\n    example = \"https://imgth.com/gallery/123/TITLE\"\n\n    def __init__(self, match):\n        self.gallery_id = gid = match[1]\n        url = f\"{self.root}/gallery/{gid}/g/\"\n        GalleryExtractor.__init__(self, match, url)\n\n    def metadata(self, page):\n        extr = text.extract_from(page)\n        return {\n            \"gallery_id\": text.parse_int(self.gallery_id),\n            \"title\": text.unescape(extr(\"<h1>\", \"</h1>\")),\n            \"count\": text.parse_int(extr(\n                \"total of images in this gallery: \", \" \")),\n            \"date\" : self.parse_datetime(\n                extr(\"created on \", \" by <\")\n                .replace(\"th, \", \" \", 1).replace(\"nd, \", \" \", 1)\n                .replace(\"st, \", \" \", 1), \"%B %d %Y at %H:%M\"),\n            \"user\" : text.unescape(extr(\">\", \"<\")),\n        }\n\n    def images(self, page):\n        pnum = 0\n\n        while True:\n            thumbs = text.extr(page, '<ul class=\"thumbnails\">', '</ul>')\n            for url in text.extract_iter(thumbs, '<img src=\"', '\"'):\n                path = url.partition(\"/thumbs/\")[2]\n                yield (f\"{self.root}/images/{path}\", None)\n\n            if '<li class=\"next\">' not in page:\n                return\n\n            pnum += 1\n            url = f\"{self.root}/gallery/{self.gallery_id}/g/page/{pnum}\"\n            page = self.request(url).text\n"
  },
  {
    "path": "gallery_dl/extractor/imgur.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2015-2025 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://imgur.com/\"\"\"\n\nfrom .common import Extractor, Message\nfrom .. import text\n\nBASE_PATTERN = r\"(?:https?://)?(?:www\\.|[im]\\.)?imgur\\.(?:com|io)\"\n\n\nclass ImgurExtractor(Extractor):\n    \"\"\"Base class for imgur extractors\"\"\"\n    category = \"imgur\"\n    root = \"https://imgur.com\"\n\n    def __init__(self, match):\n        Extractor.__init__(self, match)\n        self.key = match[1]\n\n    def _init(self):\n        self.api = ImgurAPI(self)\n        self.mp4 = self.config(\"mp4\", True)\n\n    def _prepare(self, image):\n        image.update(image[\"metadata\"])\n        del image[\"metadata\"]\n\n        if image[\"ext\"] == \"jpeg\":\n            image[\"ext\"] = \"jpg\"\n        elif image[\"is_animated\"] and self.mp4 and image[\"ext\"] == \"gif\":\n            image[\"ext\"] = \"mp4\"\n\n        image[\"url\"] = url = \\\n            f\"https://i.imgur.com/{image['id']}.{image['ext']}\"\n        image[\"date\"] = self.parse_datetime_iso(image[\"created_at\"])\n        image[\"_http_validate\"] = self._validate\n        text.nameext_from_url(url, image)\n\n        return url\n\n    def _validate(self, response):\n        return (not response.history or\n                not response.url.endswith(\"/removed.png\"))\n\n    def _items_queue(self, items):\n        album_ex = ImgurAlbumExtractor\n        image_ex = ImgurImageExtractor\n\n        for item in items:\n            if item[\"is_album\"]:\n                url = \"https://imgur.com/a/\" + item[\"id\"]\n                item[\"_extractor\"] = album_ex\n            else:\n                url = \"https://imgur.com/\" + item[\"id\"]\n                item[\"_extractor\"] = image_ex\n            yield Message.Queue, url, item\n\n\nclass ImgurImageExtractor(ImgurExtractor):\n    \"\"\"Extractor for individual images on imgur.com\"\"\"\n    subcategory = \"image\"\n    filename_fmt = \"{category}_{id}{title:?_//}.{extension}\"\n    archive_fmt = \"{id}\"\n    pattern = (BASE_PATTERN + r\"/(?!gallery|search)\"\n               r\"(?:r/\\w+/)?(?:[^/?#]+-)?(\\w{7}|\\w{5})[sbtmlh]?\")\n    example = \"https://imgur.com/abcdefg\"\n\n    def items(self):\n        image = self.api.image(self.key)\n\n        try:\n            del image[\"ad_url\"]\n            del image[\"ad_type\"]\n        except KeyError:\n            pass\n\n        image.update(image[\"media\"][0])\n        del image[\"media\"]\n        url = self._prepare(image)\n        yield Message.Directory, \"\", image\n        yield Message.Url, url, image\n\n\nclass ImgurAlbumExtractor(ImgurExtractor):\n    \"\"\"Extractor for imgur albums\"\"\"\n    subcategory = \"album\"\n    directory_fmt = (\"{category}\", \"{album[id]}{album[title]:? - //}\")\n    filename_fmt = \"{category}_{album[id]}_{num:>03}_{id}.{extension}\"\n    archive_fmt = \"{album[id]}_{id}\"\n    pattern = BASE_PATTERN + r\"/a/(?:[^/?#]+-)?(\\w{7}|\\w{5})\"\n    example = \"https://imgur.com/a/abcde\"\n\n    def items(self):\n        album = self.api.album(self.key)\n\n        try:\n            images = album[\"media\"]\n        except KeyError:\n            return\n\n        del album[\"media\"]\n        count = len(images)\n        album[\"date\"] = self.parse_datetime_iso(album[\"created_at\"])\n\n        try:\n            del album[\"ad_url\"]\n            del album[\"ad_type\"]\n        except KeyError:\n            pass\n\n        for num, image in enumerate(images, 1):\n            url = self._prepare(image)\n            image[\"num\"] = num\n            image[\"count\"] = count\n            image[\"album\"] = album\n            yield Message.Directory, \"\", image\n            yield Message.Url, url, image\n\n\nclass ImgurGalleryExtractor(ImgurExtractor):\n    \"\"\"Extractor for imgur galleries\"\"\"\n    subcategory = \"gallery\"\n    pattern = BASE_PATTERN + r\"/(?:gallery|t/\\w+)/(?:[^/?#]+-)?(\\w{7}|\\w{5})\"\n    example = \"https://imgur.com/gallery/abcde\"\n\n    def items(self):\n        if self.api.gallery(self.key)[\"is_album\"]:\n            url = f\"{self.root}/a/{self.key}\"\n            extr = ImgurAlbumExtractor\n        else:\n            url = f\"{self.root}/{self.key}\"\n            extr = ImgurImageExtractor\n        yield Message.Queue, url, {\"_extractor\": extr}\n\n\nclass ImgurUserExtractor(ImgurExtractor):\n    \"\"\"Extractor for all images posted by a user\"\"\"\n    subcategory = \"user\"\n    pattern = (BASE_PATTERN + r\"/user/(?!me(?:/|$|\\?|#))\"\n               r\"([^/?#]+)(?:/posts|/submitted)?/?$\")\n    example = \"https://imgur.com/user/USER\"\n\n    def items(self):\n        return self._items_queue(self.api.account_submissions(self.key))\n\n\nclass ImgurFavoriteExtractor(ImgurExtractor):\n    \"\"\"Extractor for a user's favorites\"\"\"\n    subcategory = \"favorite\"\n    pattern = BASE_PATTERN + r\"/user/([^/?#]+)/favorites/?$\"\n    example = \"https://imgur.com/user/USER/favorites\"\n\n    def items(self):\n        return self._items_queue(self.api.account_favorites(self.key))\n\n\nclass ImgurFavoriteFolderExtractor(ImgurExtractor):\n    \"\"\"Extractor for a user's favorites folder\"\"\"\n    subcategory = \"favorite-folder\"\n    pattern = BASE_PATTERN + r\"/user/([^/?#]+)/favorites/folder/(\\d+)\"\n    example = \"https://imgur.com/user/USER/favorites/folder/12345/TITLE\"\n\n    def __init__(self, match):\n        ImgurExtractor.__init__(self, match)\n        self.folder_id = match[2]\n\n    def items(self):\n        return self._items_queue(self.api.account_favorites_folder(\n            self.key, self.folder_id))\n\n\nclass ImgurMeExtractor(ImgurExtractor):\n    \"\"\"Extractor for your personal uploads\"\"\"\n    subcategory = \"me\"\n    pattern = BASE_PATTERN + r\"/user/me(?:/posts)?(/hidden)?\"\n    example = \"https://imgur.com/user/me\"\n\n    def items(self):\n        if not self.cookies_check((\"accesstoken\",)):\n            self.log.error(\"'accesstoken' cookie required\")\n\n        if self.groups[0]:\n            posts = self.api.accounts_me_hiddenalbums()\n        else:\n            posts = self.api.accounts_me_allposts()\n        return self._items_queue(posts)\n\n\nclass ImgurSubredditExtractor(ImgurExtractor):\n    \"\"\"Extractor for a subreddits's imgur links\"\"\"\n    subcategory = \"subreddit\"\n    pattern = BASE_PATTERN + r\"/r/([^/?#]+)/?$\"\n    example = \"https://imgur.com/r/SUBREDDIT\"\n\n    def items(self):\n        return self._items_queue(self.api.gallery_subreddit(self.key))\n\n\nclass ImgurTagExtractor(ImgurExtractor):\n    \"\"\"Extractor for imgur tag searches\"\"\"\n    subcategory = \"tag\"\n    pattern = BASE_PATTERN + r\"/t/([^/?#]+)$\"\n    example = \"https://imgur.com/t/TAG\"\n\n    def items(self):\n        return self._items_queue(self.api.gallery_tag(self.key))\n\n\nclass ImgurSearchExtractor(ImgurExtractor):\n    \"\"\"Extractor for imgur search results\"\"\"\n    subcategory = \"search\"\n    pattern = BASE_PATTERN + r\"/search(?:/[^?#]+)?/?\\?q=([^&#]+)\"\n    example = \"https://imgur.com/search?q=UERY\"\n\n    def items(self):\n        key = text.unquote(self.key.replace(\"+\", \" \"))\n        return self._items_queue(self.api.gallery_search(key))\n\n\nclass ImgurAPI():\n    \"\"\"Interface for the Imgur API\n\n    Ref: https://apidocs.imgur.com/\n    \"\"\"\n    def __init__(self, extractor):\n        self.extractor = extractor\n        self.client_id = extractor.config(\"client-id\") or \"546c25a59c58ad7\"\n        self.headers = {\"Authorization\": \"Client-ID \" + self.client_id}\n\n    def account_submissions(self, account):\n        endpoint = f\"/3/account/{account}/submissions\"\n        return self._pagination(endpoint)\n\n    def account_favorites(self, account):\n        endpoint = f\"/3/account/{account}/gallery_favorites\"\n        return self._pagination(endpoint)\n\n    def account_favorites_folder(self, account, folder_id):\n        endpoint = f\"/3/account/{account}/folders/{folder_id}/favorites\"\n        return self._pagination_v2(endpoint)\n\n    def accounts_me_allposts(self):\n        endpoint = \"/post/v1/accounts/me/all_posts\"\n        params = {\n            \"include\": \"media,tags,account\",\n            \"page\"   : 1,\n            \"sort\"   : \"-created_at\",\n        }\n        return self._pagination_v2(endpoint, params)\n\n    def accounts_me_hiddenalbums(self):\n        endpoint = \"/post/v1/accounts/me/hidden_albums\"\n        params = {\n            \"include\": \"media,tags,account\",\n            \"page\"   : 1,\n            \"sort\"   : \"-created_at\",\n        }\n        return self._pagination_v2(endpoint, params)\n\n    def gallery_search(self, query):\n        endpoint = \"/3/gallery/search\"\n        params = {\"q\": query}\n        return self._pagination(endpoint, params)\n\n    def gallery_subreddit(self, subreddit):\n        endpoint = \"/3/gallery/r/\" + subreddit\n        return self._pagination(endpoint)\n\n    def gallery_tag(self, tag):\n        endpoint = \"/3/gallery/t/\" + tag\n        return self._pagination(endpoint, key=\"items\")\n\n    def image(self, image_hash):\n        endpoint = \"/post/v1/media/\" + image_hash\n        params = {\"include\": \"media,tags,account\"}\n        return self._call(endpoint, params)\n\n    def album(self, album_hash):\n        endpoint = \"/post/v1/albums/\" + album_hash\n        params = {\"include\": \"media,tags,account\"}\n        return self._call(endpoint, params)\n\n    def gallery(self, gallery_hash):\n        endpoint = \"/post/v1/posts/\" + gallery_hash\n        return self._call(endpoint)\n\n    def _call(self, endpoint, params=None, headers=None):\n        while True:\n            try:\n                return self.extractor.request_json(\n                    \"https://api.imgur.com\" + endpoint,\n                    params=params, headers=(headers or self.headers))\n            except self.extractor.exc.HttpError as exc:\n                if exc.status not in (403, 429) or \\\n                        b\"capacity\" not in exc.response.content:\n                    raise\n            self.extractor.wait(seconds=600)\n\n    def _pagination(self, endpoint, params=None, key=None):\n        num = 0\n\n        while True:\n            data = self._call(f\"{endpoint}/{num}\", params)[\"data\"]\n            if key:\n                data = data[key]\n            if not data:\n                return\n            yield from data\n            num += 1\n\n    def _pagination_v2(self, endpoint, params=None, key=None):\n        if params is None:\n            params = {}\n        params[\"client_id\"] = self.client_id\n        if \"page\" not in params:\n            params[\"page\"] = 0\n        if \"sort\" not in params:\n            params[\"sort\"] = \"newest\"\n        headers = {\"Origin\": \"https://imgur.com\"}\n\n        while True:\n            data = self._call(endpoint, params, headers)\n            if \"data\" in data:\n                data = data[\"data\"]\n            if not data:\n                return\n            yield from data\n\n            params[\"page\"] += 1\n"
  },
  {
    "path": "gallery_dl/extractor/imhentai.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2025-2026 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://imhentai.xxx/ and mirror sites\"\"\"\n\nfrom .common import GalleryExtractor, BaseExtractor, Message\nfrom .. import text, util\n\n\nclass ImhentaiExtractor(BaseExtractor):\n    basecategory = \"IMHentai\"\n\n    def _pagination(self, url):\n        prev = None\n        base = self.root + \"/gallery/\"\n        data = {\"_extractor\": ImhentaiGalleryExtractor}\n\n        while True:\n            page = self.request(url).text\n\n            pos = page.find('class=\"ranking_list\"')\n            if pos >= 0:\n                page = page[:pos]\n\n            extr = text.extract_from(page)\n\n            while True:\n                gallery_id = extr('href=\"/gallery/', '\"')\n                if gallery_id == prev:\n                    continue\n                if not gallery_id:\n                    break\n                yield Message.Queue, base + gallery_id, data\n                prev = gallery_id\n\n            href = text.rextr(page, \"class='page-link' href='\", \"'\")\n            if not href or href == \"#\":\n                return\n            if href[0] == \"/\":\n                if href[1] == \"/\":\n                    href = \"https:\" + href\n                else:\n                    href = self.root + href\n            url = href\n\n\nBASE_PATTERN = ImhentaiExtractor.update({\n    \"imhentai\": {\n        \"root\": \"https://imhentai.xxx\",\n        \"pattern\": r\"(?:www\\.)?imhentai\\.xxx\",\n    },\n    \"hentaiera\": {\n        \"root\": \"https://hentaiera.com\",\n        \"pattern\": r\"(?:www\\.)?hentaiera\\.com\",\n    },\n    \"hentairox\": {\n        \"root\": \"https://hentairox.com\",\n        \"pattern\": r\"(?:www\\.)?hentairox\\.com\",\n    },\n    \"hentaifox\": {\n        \"root\": \"https://hentaifox.com\",\n        \"pattern\": r\"(?:www\\.)?hentaifox\\.com\",\n    },\n    \"hentaienvy\": {\n        \"root\": \"https://hentaienvy.com\",\n        \"pattern\": r\"(?:www\\.)?hentaienvy\\.com\",\n    },\n    \"hentaizap\": {\n        \"root\": \"https://hentaizap.com\",\n        \"pattern\": r\"(?:www\\.)?hentaizap\\.com\",\n    },\n})\n\n\nclass ImhentaiGalleryExtractor(ImhentaiExtractor, GalleryExtractor):\n    \"\"\"Extractor for imhentai galleries\"\"\"\n    pattern = BASE_PATTERN + r\"/(?:gallery|view)/(\\d+)\"\n    example = \"https://imhentai.xxx/gallery/12345/\"\n\n    def __init__(self, match):\n        ImhentaiExtractor.__init__(self, match)\n        self.gallery_id = self.groups[-1]\n        self.page_url = f\"{self.root}/gallery/{self.gallery_id}/\"\n\n    def metadata(self, page):\n        extr = text.extract_from(page)\n        title = extr(\"<h1>\", \"<\")\n        title_alt = extr('class=\"subtitle\">', \"<\")\n        end = \"</li>\" if extr('<ul class=\"galleries_info', \">\") else \"</ul>\"\n\n        data = {\n            \"gallery_id\": text.parse_int(self.gallery_id),\n            \"title\"     : text.unescape(title),\n            \"title_alt\" : text.unescape(title_alt),\n            \"parody\"    : self._split(extr(\">Parodies\", end)),\n            \"character\" : self._split(extr(\">Characters\", end)),\n            \"tags\"      : self._split(extr(\">Tags\", end)),\n            \"artist\"    : self._split(extr(\">Artists\", end)),\n            \"group\"     : self._split(extr(\">Groups\", end)),\n            \"language\"  : self._split(extr(\">Languages\", end)),\n            \"type\"      : extr(\"href='/category/\", \"/\"),\n        }\n\n        if data[\"language\"]:\n            data[\"lang\"] = util.language_to_code(data[\"language\"][0])\n\n        return data\n\n    def _split(self, html):\n        results = []\n        for tag in text.extract_iter(html, \">\", \"</a>\"):\n            badge = (\"badge'>\" in tag or \"class='badge\" in tag)\n            tag = text.remove_html(tag)\n            if badge:\n                tag = tag.rpartition(\" \")[0]\n            results.append(tag)\n        results.sort()\n        return results\n\n    def images(self, page):\n        base = text.extr(page, 'data-src=\"', '\"').rpartition(\"/\")[0] + \"/\"\n        exts = {\"j\": \"jpg\", \"p\": \"png\", \"g\": \"gif\", \"w\": \"webp\", \"a\": \"avif\"}\n\n        try:\n            data = util.json_loads(text.extr(page, \"$.parseJSON('\", \"'\"))\n        except Exception:\n            data = None\n\n        if data is None:\n            self.log.warning(\"%s: Missing image data\", self.gallery_id)\n\n            def _fallback_exts(i):\n                for ext in util.advance(exts.values(), 1):\n                    yield f\"{base}{i}.{ext}\"\n            cnt = text.parse_int(text.extr(\n                page, 'id=\"load_pages\" value=\"', '\"'))\n            return [(f\"{base}{i}.jpg\", {\"_fallback\": _fallback_exts(i)})\n                    for i in range(1, cnt+1)]\n\n        results = []\n        for i in map(str, range(1, len(data)+1)):\n            ext, width, height = data[i].split(\",\")\n            url = f\"{base}{i}.{exts[ext]}\"\n            results.append((url, {\n                \"width\" : text.parse_int(width),\n                \"height\": text.parse_int(height),\n            }))\n        return results\n\n\nclass ImhentaiTagExtractor(ImhentaiExtractor):\n    \"\"\"Extractor for imhentai tag searches\"\"\"\n    subcategory = \"tag\"\n    pattern = (BASE_PATTERN + r\"(/(?:\"\n               r\"artist|category|character|group|language|parody|tag\"\n               r\")/([^/?#]+))\")\n    example = \"https://imhentai.xxx/tag/TAG/\"\n\n    def items(self):\n        url = self.root + self.groups[-2] + \"/\"\n        return self._pagination(url)\n\n\nclass ImhentaiSearchExtractor(ImhentaiExtractor):\n    \"\"\"Extractor for imhentai search results\"\"\"\n    subcategory = \"search\"\n    pattern = BASE_PATTERN + r\"(/(?:advanced-)?search/?\\?[^#]+|/[^/?#]+/?)\"\n    example = \"https://imhentai.xxx/search/?key=QUERY\"\n\n    def items(self):\n        return self._pagination(self.root + self.groups[-1])\n"
  },
  {
    "path": "gallery_dl/extractor/inkbunny.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2020-2026 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://inkbunny.net/\"\"\"\n\nfrom .common import Extractor, Message\nfrom .. import text\n\n\nBASE_PATTERN = r\"(?:https?://)?(?:www\\.)?inkbunny\\.net\"\n\n\nclass InkbunnyExtractor(Extractor):\n    \"\"\"Base class for inkbunny extractors\"\"\"\n    category = \"inkbunny\"\n    directory_fmt = (\"{category}\", \"{username!l}\")\n    filename_fmt = \"{submission_id} {file_id} {title}.{extension}\"\n    archive_fmt = \"{file_id}\"\n    root = \"https://inkbunny.net\"\n\n    def _init(self):\n        self.api = InkbunnyAPI(self)\n\n    def items(self):\n        self.api.authenticate()\n        metadata = self.metadata()\n        to_bool = (\"deleted\", \"favorite\", \"friends_only\", \"guest_block\",\n                   \"hidden\", \"public\", \"scraps\")\n\n        for post in self.posts():\n            post.update(metadata)\n            post[\"date\"] = self.parse_datetime_iso(\n                post[\"create_datetime\"][:19])\n            post[\"tags\"] = [kw[\"keyword_name\"] for kw in post[\"keywords\"]]\n            post[\"ratings\"] = [r[\"name\"] for r in post[\"ratings\"]]\n            files = post[\"files\"]\n\n            for key in to_bool:\n                if key in post:\n                    post[key] = (post[key] == \"t\")\n\n            del post[\"keywords\"]\n            del post[\"files\"]\n\n            yield Message.Directory, \"\", post\n            for post[\"num\"], file in enumerate(files, 1):\n                post.update(file)\n                post[\"deleted\"] = (file[\"deleted\"] == \"t\")\n                post[\"date\"] = self.parse_datetime_iso(\n                    file[\"create_datetime\"][:19])\n                text.nameext_from_url(file[\"file_name\"], post)\n\n                url = file[\"file_url_full\"]\n                if \"/private_files/\" in url:\n                    url += \"?sid=\" + self.api.session_id\n                yield Message.Url, url, post\n\n    def posts(self):\n        return ()\n\n    def metadata(self):\n        return ()\n\n\nclass InkbunnyUserExtractor(InkbunnyExtractor):\n    \"\"\"Extractor for inkbunny user profiles\"\"\"\n    subcategory = \"user\"\n    pattern = BASE_PATTERN + r\"/(?!s/)(gallery/|scraps/)?(\\w+)(?:$|[/?#])\"\n    example = \"https://inkbunny.net/USER\"\n\n    def __init__(self, match):\n        kind, self.user = match.groups()\n        if not kind:\n            self.scraps = None\n        elif kind[0] == \"g\":\n            self.subcategory = \"gallery\"\n            self.scraps = \"no\"\n        else:\n            self.subcategory = \"scraps\"\n            self.scraps = \"only\"\n        InkbunnyExtractor.__init__(self, match)\n\n    def posts(self):\n        orderby = self.config(\"orderby\")\n        params = {\n            \"username\": self.user,\n            \"scraps\"  : self.scraps,\n            \"orderby\" : orderby,\n        }\n        if orderby and orderby.startswith(\"unread_\"):\n            params[\"unread_submissions\"] = \"yes\"\n        return self.api.search(params)\n\n\nclass InkbunnyPoolExtractor(InkbunnyExtractor):\n    \"\"\"Extractor for inkbunny pools\"\"\"\n    subcategory = \"pool\"\n    pattern = (BASE_PATTERN + r\"/(?:\"\n               r\"poolview_process\\.php\\?pool_id=(\\d+)|\"\n               r\"submissionsviewall\\.php\"\n               r\"\\?((?:[^#]+&)?mode=pool(?:&[^#]+)?))\")\n    example = \"https://inkbunny.net/poolview_process.php?pool_id=12345\"\n\n    def __init__(self, match):\n        InkbunnyExtractor.__init__(self, match)\n        if pid := match[1]:\n            self.pool_id = pid\n            self.orderby = \"pool_order\"\n        else:\n            params = text.parse_query(match[2])\n            self.pool_id = params.get(\"pool_id\")\n            self.orderby = params.get(\"orderby\", \"pool_order\")\n\n    def metadata(self):\n        return {\"pool_id\": self.pool_id}\n\n    def posts(self):\n        params = {\n            \"pool_id\": self.pool_id,\n            \"orderby\": self.orderby,\n        }\n        return self.api.search(params)\n\n\nclass InkbunnyFavoriteExtractor(InkbunnyExtractor):\n    \"\"\"Extractor for inkbunny user favorites\"\"\"\n    subcategory = \"favorite\"\n    directory_fmt = (\"{category}\", \"{favs_username!l}\", \"Favorites\")\n    pattern = (BASE_PATTERN + r\"/(?:\"\n               r\"userfavorites_process\\.php\\?favs_user_id=(\\d+)|\"\n               r\"submissionsviewall\\.php\"\n               r\"\\?((?:[^#]+&)?mode=userfavs(?:&[^#]+)?))\")\n    example = (\"https://inkbunny.net/userfavorites_process.php\"\n               \"?favs_user_id=12345\")\n\n    def __init__(self, match):\n        InkbunnyExtractor.__init__(self, match)\n        if uid := match[1]:\n            self.user_id = uid\n            self.orderby = self.config(\"orderby\", \"fav_datetime\")\n        else:\n            params = text.parse_query(match[2])\n            self.user_id = params.get(\"user_id\")\n            self.orderby = params.get(\"orderby\", \"fav_datetime\")\n\n    def metadata(self):\n        # Lookup fav user ID as username\n        url = (f\"{self.root}/userfavorites_process.php\"\n               f\"?favs_user_id={self.user_id}\")\n        page = self.request(url).text\n        user_link = text.extr(page, '<a rel=\"author\"', '</a>')\n        favs_username = text.extr(user_link, 'href=\"/', '\"')\n\n        return {\n            \"favs_user_id\": self.user_id,\n            \"favs_username\": favs_username,\n        }\n\n    def posts(self):\n        params = {\n            \"favs_user_id\": self.user_id,\n            \"orderby\"     : self.orderby,\n        }\n        if self.orderby and self.orderby.startswith(\"unread_\"):\n            params[\"unread_submissions\"] = \"yes\"\n        return self.api.search(params)\n\n\nclass InkbunnyUnreadExtractor(InkbunnyExtractor):\n    \"\"\"Extractor for unread inkbunny submissions\"\"\"\n    subcategory = \"unread\"\n    pattern = (BASE_PATTERN + r\"/submissionsviewall\\.php\"\n               r\"\\?((?:[^#]+&)?mode=unreadsubs(?:&[^#]+)?)\")\n    example = (\"https://inkbunny.net/submissionsviewall.php\"\n               \"?text=&mode=unreadsubs&type=\")\n\n    def __init__(self, match):\n        InkbunnyExtractor.__init__(self, match)\n        self.params = text.parse_query(match[1])\n\n    def posts(self):\n        params = self.params.copy()\n        params.pop(\"rid\", None)\n        params.pop(\"mode\", None)\n        params[\"unread_submissions\"] = \"yes\"\n        return self.api.search(params)\n\n\nclass InkbunnySearchExtractor(InkbunnyExtractor):\n    \"\"\"Extractor for inkbunny search results\"\"\"\n    subcategory = \"search\"\n    pattern = (BASE_PATTERN + r\"/submissionsviewall\\.php\"\n               r\"\\?((?:[^#]+&)?mode=search(?:&[^#]+)?)\")\n    example = (\"https://inkbunny.net/submissionsviewall.php\"\n               \"?text=TAG&mode=search&type=\")\n\n    def __init__(self, match):\n        InkbunnyExtractor.__init__(self, match)\n        self.params = text.parse_query(match[1])\n\n    def metadata(self):\n        return {\"search\": self.params}\n\n    def posts(self):\n        params = self.params.copy()\n        pop = params.pop\n\n        pop(\"rid\", None)\n        params[\"string_join_type\"] = pop(\"stringtype\", None)\n        params[\"dayslimit\"] = pop(\"days\", None)\n        params[\"username\"] = pop(\"artist\", None)\n\n        if favsby := pop(\"favsby\", None):\n            # get user_id from user profile\n            url = f\"{self.root}/{favsby}\"\n            page = self.request(url).text\n            user_id = text.extr(page, \"?user_id=\", \"'\")\n            params[\"favs_user_id\"] = user_id.partition(\"&\")[0]\n\n        return self.api.search(params)\n\n\nclass InkbunnyFollowingExtractor(InkbunnyExtractor):\n    \"\"\"Extractor for inkbunny user watches\"\"\"\n    subcategory = \"following\"\n    pattern = (BASE_PATTERN + r\"/(?:\"\n               r\"watchlist_process\\.php\\?mode=watching&user_id=(\\d+)|\"\n               r\"usersviewall\\.php\"\n               r\"\\?((?:[^#]+&)?mode=watching(?:&[^#]+)?))\")\n    example = (\"https://inkbunny.net/watchlist_process.php\"\n               \"?mode=watching&user_id=12345\")\n\n    def __init__(self, match):\n        InkbunnyExtractor.__init__(self, match)\n        self.user_id = match[1] or \\\n            text.parse_query(match[2]).get(\"user_id\")\n\n    def items(self):\n        url = self.root + \"/watchlist_process.php\"\n        params = {\"mode\": \"watching\", \"user_id\": self.user_id}\n\n        with self.request(url, params=params) as response:\n            url, _, params = response.url.partition(\"?\")\n            page = response.text\n\n        params = text.parse_query(params)\n        params[\"page\"] = text.parse_int(params.get(\"page\"), 1)\n        data = {\"_extractor\": InkbunnyUserExtractor}\n\n        while True:\n            for user in text.extract_iter(\n                    page, '<a class=\"widget_userNameSmall\" href=\"', '\"',\n                    page.index('id=\"changethumboriginal_form\"')):\n                yield Message.Queue, self.root + user, data\n\n            if \"<a title='next page' \" not in page:\n                return\n            params[\"page\"] += 1\n            page = self.request(url, params=params).text\n\n\nclass InkbunnyPostExtractor(InkbunnyExtractor):\n    \"\"\"Extractor for individual Inkbunny posts\"\"\"\n    subcategory = \"post\"\n    pattern = BASE_PATTERN + r\"/s/(\\d+)\"\n    example = \"https://inkbunny.net/s/12345\"\n\n    def __init__(self, match):\n        InkbunnyExtractor.__init__(self, match)\n        self.submission_id = match[1]\n\n    def posts(self):\n        submissions = self.api.detail(({\"submission_id\": self.submission_id},))\n        if submissions[0] is None:\n            raise self.exc.NotFoundError(\"submission\")\n        return submissions\n\n\nclass InkbunnyAPI():\n    \"\"\"Interface for the Inkunny API\n\n    Ref: https://wiki.inkbunny.net/wiki/API\n    \"\"\"\n\n    def __init__(self, extractor):\n        self.extractor = extractor\n        self.session_id = None\n\n    def detail(self, submissions):\n        \"\"\"Get full details about submissions with the given IDs\"\"\"\n        ids = {\n            sub[\"submission_id\"]: idx\n            for idx, sub in enumerate(submissions)\n        }\n        params = {\n            \"submission_ids\": \",\".join(ids),\n            \"show_description\": \"yes\",\n            \"show_pools\": \"yes\",\n        }\n\n        submissions = [None] * len(ids)\n        for sub in self._call(\"submissions\", params)[\"submissions\"]:\n            submissions[ids[sub[\"submission_id\"]]] = sub\n        return submissions\n\n    def search(self, params):\n        \"\"\"Perform a search\"\"\"\n        return self._pagination_search(params)\n\n    def set_allowed_ratings(self, nudity=True, sexual=True,\n                            violence=True, strong_violence=True):\n        \"\"\"Change allowed submission ratings\"\"\"\n        params = {\n            \"tag[2]\": \"yes\" if nudity else \"no\",\n            \"tag[3]\": \"yes\" if violence else \"no\",\n            \"tag[4]\": \"yes\" if sexual else \"no\",\n            \"tag[5]\": \"yes\" if strong_violence else \"no\",\n        }\n        self._call(\"userrating\", params)\n\n    def authenticate(self, invalidate=False):\n        extr = self.extractor\n        username, password = extr._get_auth_info()\n        if not username:\n            username, password = \"guest\", \"\"\n        if invalidate:\n            extr.cache_update(self._authenticate_impl, username, None)\n\n        self.session_id = extr.cache(\n            self._authenticate_impl, username, password,\n            _exp=365*86400, _mem=False)\n\n        if username == \"guest\":\n            self.set_allowed_ratings()\n\n    def _authenticate_impl(self, username, password):\n        self.extractor.log.info(\"Logging in as %s\", username)\n\n        url = \"https://inkbunny.net/api_login.php\"\n        data = {\"username\": username, \"password\": password}\n        data = self.extractor.request_json(url, method=\"POST\", data=data)\n\n        if \"sid\" not in data:\n            raise Extractor.exc.AuthenticationError(data.get(\"error_message\"))\n        return data[\"sid\"]\n\n    def _call(self, endpoint, params):\n        url = \"https://inkbunny.net/api_\" + endpoint + \".php\"\n\n        while True:\n            params[\"sid\"] = self.session_id\n            data = self.extractor.request_json(url, params=params)\n\n            if \"error_code\" not in data:\n                return data\n\n            if str(data[\"error_code\"]) == \"2\":\n                self.authenticate(invalidate=True)\n                continue\n\n            raise self.extractor.exc.AbortExtraction(data.get(\"error_message\"))\n\n    def _pagination_search(self, params):\n        params[\"page\"] = 1\n        params[\"get_rid\"] = \"yes\"\n        params[\"submission_ids_only\"] = \"yes\"\n\n        while True:\n            data = self._call(\"search\", params)\n            if not data[\"submissions\"]:\n                return\n\n            yield from self.detail(data[\"submissions\"])\n\n            if data[\"page\"] >= data[\"pages_count\"]:\n                return\n            if \"get_rid\" in params:\n                del params[\"get_rid\"]\n                params[\"rid\"] = data[\"rid\"]\n            params[\"page\"] += 1\n"
  },
  {
    "path": "gallery_dl/extractor/instagram.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2018-2020 Leonardo Taccari\n# Copyright 2018-2026 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://www.instagram.com/\"\"\"\n\nfrom .common import Extractor, Message, Dispatch\nfrom .. import text, util\nimport itertools\nimport binascii\n\nBASE_PATTERN = r\"(?:https?://)?(?:www\\.)?instagram\\.com\"\nUSER_PATTERN = BASE_PATTERN + r\"/(?!(?:p|tv|reel|explore|stories)/)([^/?#]+)\"\n\n\nclass InstagramExtractor(Extractor):\n    \"\"\"Base class for instagram extractors\"\"\"\n    category = \"instagram\"\n    directory_fmt = (\"{category}\", \"{username}\")\n    filename_fmt = \"{sidecar_media_id:?/_/}{media_id}.{extension}\"\n    archive_fmt = \"{media_id}\"\n    root = \"https://www.instagram.com\"\n    cookies_domain = \".instagram.com\"\n    cookies_names = (\"sessionid\",)\n    useragent = util.USERAGENT_CHROME\n    request_interval = (6.0, 12.0)\n\n    def __init__(self, match):\n        Extractor.__init__(self, match)\n        self.item = match[1]\n\n    def _init(self):\n        self.www_claim = \"0\"\n        self.csrf_token = util.generate_token()\n        self._find_tags = text.re(r\"#\\w+\").findall\n        self._logged_in = True\n        self._cursor = None\n        self._user = None\n\n        self.cookies.set(\n            \"csrftoken\", self.csrf_token, domain=self.cookies_domain)\n\n        if self.config(\"api\") == \"graphql\":\n            self.api = InstagramGraphqlAPI(self)\n        else:\n            self.api = InstagramRestAPI(self)\n\n        self._static_video = \\\n            True if self.config(\"static-videos\", True) else False\n        self._warn_video = \\\n            True if self.config(\"warn-videos\", True) else False\n        self._warn_image = (\n            9 if not (wi := self.config(\"warn-images\", True)) else\n            1 if wi in {\"all\", \"both\"} else\n            0)\n\n    def items(self):\n        self.login()\n\n        data = self.metadata()\n        if videos := self.config(\"videos\", True):\n            self.videos_dash = videos_dash = (videos != \"merged\")\n            videos_headers = {\"User-Agent\": \"Mozilla/5.0\"}\n        else:\n            self.videos_dash = False\n        previews = self.config(\"previews\", False)\n        max_posts = self.config(\"max-posts\")\n\n        order = self.config(\"order-files\")\n        reverse = order[0] in {\"r\", \"d\"} if order else False\n\n        posts = self.posts()\n        if max_posts:\n            posts = itertools.islice(posts, max_posts)\n\n        for post in posts:\n\n            if \"__typename\" in post:\n                post = self._parse_post_graphql(post)\n            else:\n                post = self._parse_post_rest(post)\n            if self._user:\n                post[\"user\"] = self._user\n            post.update(data)\n            files = post.pop(\"_files\")\n\n            post[\"count\"] = len(files)\n            yield Message.Directory, \"\", post\n\n            if reverse:\n                files.reverse()\n\n            for file in files:\n                file = {**post, **file}\n\n                if url := file.get(\"video_url\"):\n                    if videos:\n                        file[\"_http_headers\"] = videos_headers\n                        text.nameext_from_url(url, file)\n                        if videos_dash and \"_ytdl_manifest_data\" in file:\n                            file[\"_fallback\"] = (url,)\n                            file[\"_ytdl_manifest\"] = \"dash\"\n                            url = f\"ytdl:{post['post_url']}{file['num']}.mp4\"\n                        yield Message.Url, url, file\n                    if previews:\n                        file[\"media_id\"] += \"p\"\n                    else:\n                        continue\n\n                url = file[\"display_url\"]\n                text.nameext_from_url(url, file)\n                if file[\"extension\"] == \"webp\" and \"stp=dst-jpg\" in url:\n                    file[\"extension\"] = \"jpg\"\n                yield Message.Url, url, file\n\n    def metadata(self):\n        return ()\n\n    def posts(self):\n        return ()\n\n    def finalize(self, status):\n        if status and self._cursor:\n            self.log.info(\"Use '-o cursor=%s' to continue downloading \"\n                          \"from the current position\", self._cursor)\n\n    def request(self, url, **kwargs):\n        response = Extractor.request(self, url, **kwargs)\n\n        if response.history:\n\n            url = response.url\n            if \"/accounts/login/\" in url:\n                page = \"login\"\n            elif \"/challenge/\" in url:\n                page = \"challenge\"\n            elif 24 < len(url) < 28 and url[-1] == \"/\":\n                page = \"home\"\n            else:\n                page = None\n\n            if page is not None:\n                raise self.exc.AbortExtraction(\n                    f\"HTTP redirect to {page} page ({url.partition('?')[0]})\")\n\n        www_claim = response.headers.get(\"x-ig-set-www-claim\")\n        if www_claim is not None:\n            self.www_claim = www_claim\n\n        if csrf_token := response.cookies.get(\"csrftoken\"):\n            self.csrf_token = csrf_token\n\n        return response\n\n    def login(self):\n        if self.cookies_check(self.cookies_names):\n            return\n\n        username, password = self._get_auth_info()\n        if username:\n            return self.cookies_update(self.cache(\n                self._login_impl, username, password,\n                _exp=90*86400, _mem=False))\n\n        self._logged_in = False\n\n    def _login_impl(self, username, password):\n        self.log.error(\"Login with username & password is no longer \"\n                       \"supported. Use browser cookies instead.\")\n        return {}\n\n    def _parse_post_rest(self, post):\n        if \"items\" in post:  # story or highlight\n            items = post[\"items\"]\n            reel_id = str(post[\"id\"]).rpartition(\":\")[2]\n            if expires := post.get(\"expiring_at\"):\n                post_url = f\"{self.root}/stories/{post['user']['username']}/\"\n            else:\n                post_url = f\"{self.root}/stories/highlights/{reel_id}/\"\n            data = {\n                \"user\"   : post.get(\"user\"),\n                \"expires\": self.parse_timestamp(expires),\n                \"post_id\": reel_id,\n                \"post_shortcode\": shortcode_from_id(reel_id),\n                \"post_url\": post_url,\n                \"type\": \"story\" if expires else \"highlight\",\n            }\n            if \"title\" in post:\n                data[\"highlight_title\"] = post[\"title\"]\n            if expires and not post.get(\"seen\"):\n                post[\"seen\"] = expires - 86400\n\n        else:  # regular image/video post\n            data = {\n                \"post_id\" : post[\"pk\"],\n                \"post_shortcode\": post[\"code\"],\n                \"likes\": post.get(\"like_count\", 0),\n                \"liked\": post.get(\"has_liked\", False),\n                \"pinned\": self._extract_pinned(post),\n            }\n\n            caption = post[\"caption\"]\n            data[\"description\"] = caption[\"text\"] if caption else \"\"\n\n            if tags := self._find_tags(data[\"description\"]):\n                data[\"tags\"] = sorted(set(tags))\n\n            if location := post.get(\"location\"):\n                slug = location[\"short_name\"].replace(\" \", \"-\").lower()\n                data[\"location_id\"] = location[\"pk\"]\n                data[\"location_slug\"] = slug\n                data[\"location_url\"] = \\\n                    f\"{self.root}/explore/locations/{location['pk']}/{slug}/\"\n\n            if coauthors := post.get(\"coauthor_producers\"):\n                data[\"coauthors\"] = [\n                    {\"id\"       : user[\"pk\"],\n                     \"username\" : user[\"username\"],\n                     \"full_name\": user[\"full_name\"]}\n                    for user in coauthors\n                ]\n\n            if items := post.get(\"carousel_media\"):\n                data[\"sidecar_media_id\"] = data[\"post_id\"]\n                data[\"sidecar_shortcode\"] = data[\"post_shortcode\"]\n            else:\n                items = (post,)\n\n        owner = post[\"user\"]\n        data[\"owner_id\"] = owner[\"pk\"]\n        data[\"username\"] = owner.get(\"username\")\n        data[\"fullname\"] = owner.get(\"full_name\")\n        data[\"post_date\"] = data[\"date\"] = self.parse_timestamp(\n            post.get(\"taken_at\") or post.get(\"created_at\") or post.get(\"seen\"))\n        data[\"_files\"] = files = []\n        for num, item in enumerate(items, 1):\n\n            try:\n                image = item[\"image_versions2\"][\"candidates\"][0]\n            except Exception:\n                self.log.warning(\"Missing media in post %s\",\n                                 data[\"post_shortcode\"])\n                continue\n\n            if not self._static_video and \\\n                    (type_orig := item.get(\"original_media_type\")) and \\\n                    type_orig == 1 and type_orig != item.get(\"media_type\"):\n                if item.pop(\"video_versions\", None):\n                    item[\"original_width\"] = image[\"width\"]\n                    item[\"original_height\"] = image[\"height\"]\n\n            width_orig = item.get(\"original_width\", 0)\n            height_orig = item.get(\"original_height\", 0)\n\n            if video_versions := item.get(\"video_versions\"):\n                video = max(\n                    video_versions,\n                    key=lambda x: (x[\"width\"], x[\"height\"], x[\"type\"]),\n                )\n\n                media = video\n                if (manifest := item.get(\"video_dash_manifest\")) and \\\n                        self.videos_dash:\n                    width = width_orig\n                    height = height_orig\n                else:\n                    width = video[\"width\"]\n                    height = video[\"height\"]\n\n                if self._warn_video:\n                    self._warn_video = False\n                    pattern = text.re(\n                        r\"Chrome/\\d{3,}\\.\\d+\\.\\d+\\.\\d+(?!\\d* Mobile)\")\n                    if not pattern.search(self.session.headers[\"User-Agent\"]):\n                        self.log.warning(\"Potentially lowered video quality \"\n                                         \"due to non-Chrome User-Agent\")\n            else:\n                video = manifest = None\n                media = image\n                width = image[\"width\"]\n                height = image[\"height\"]\n\n                if self._warn_image < ((width * 1.1 < width_orig) +\n                                       (height * 1.1 < height_orig)):\n                    self.log.warning(\n                        \"%s: Available image resolutions lower than the \"\n                        \"original (%sx%s < %sx%s). \"\n                        \"Consider refreshing your cookies.\",\n                        data[\"post_shortcode\"],\n                        width, height, width_orig, height_orig)\n\n            media = {\n                \"num\"        : num,\n                \"date\"       : self.parse_timestamp(item.get(\"taken_at\") or\n                                                    media.get(\"taken_at\") or\n                                                    post.get(\"taken_at\")),\n                \"media_id\"   : item[\"pk\"],\n                \"shortcode\"  : (item.get(\"code\") or\n                                shortcode_from_id(item[\"pk\"])),\n                \"display_url\": image[\"url\"],\n                \"video_url\"  : video[\"url\"] if video else None,\n                \"width\"          : width,\n                \"width_original\" : width_orig,\n                \"height\"         : height,\n                \"height_original\": height_orig,\n            }\n\n            if manifest is not None:\n                media[\"_ytdl_manifest_data\"] = manifest\n            if \"owner\" in item:\n                media[\"owner\"] = item[\"owner\"]\n            if \"reshared_story_media_author\" in item:\n                media[\"author\"] = item[\"reshared_story_media_author\"]\n            if \"expiring_at\" in item:\n                media[\"expires\"] = self.parse_timestamp(item[\"expiring_at\"])\n            if \"subscription_media_visibility\" in item:\n                media[\"subscription\"] = item[\"subscription_media_visibility\"]\n\n            self._extract_tagged_users(item, media)\n            files.append(media)\n\n        if \"subscription_media_visibility\" in post:\n            data[\"subscription\"] = post[\"subscription_media_visibility\"]\n        if \"type\" not in data:\n            if len(files) == 1 and files[0][\"video_url\"]:\n                data[\"type\"] = \"reel\"\n                data[\"post_url\"] = f\"{self.root}/reel/{post['code']}/\"\n            else:\n                data[\"type\"] = \"post\"\n                data[\"post_url\"] = f\"{self.root}/p/{post['code']}/\"\n\n        return data\n\n    def _parse_post_graphql(self, post):\n        typename = post[\"__typename\"]\n\n        if self._logged_in:\n            if post.get(\"is_video\") and \"video_url\" not in post:\n                post = self.api.media(post[\"id\"])[0]\n            elif typename == \"GraphSidecar\" and \\\n                    \"edge_sidecar_to_children\" not in post:\n                post = self.api.media(post[\"id\"])[0]\n\n        if pinned := post.get(\"pinned_for_users\", ()):\n            for index, user in enumerate(pinned):\n                pinned[index] = int(user[\"id\"])\n\n        owner = post[\"owner\"]\n        data = {\n            \"typename\"   : typename,\n            \"likes\"      : post[\"edge_media_preview_like\"][\"count\"],\n            \"liked\"      : post.get(\"viewer_has_liked\", False),\n            \"pinned\"     : pinned,\n            \"owner_id\"   : owner[\"id\"],\n            \"username\"   : owner.get(\"username\"),\n            \"fullname\"   : owner.get(\"full_name\"),\n            \"post_id\"    : post[\"id\"],\n            \"post_shortcode\": post[\"shortcode\"],\n            \"post_url\"   : f\"{self.root}/p/{post['shortcode']}/\",\n            \"post_date\"  : self.parse_timestamp(post[\"taken_at_timestamp\"]),\n            \"description\": text.parse_unicode_escapes(\"\\n\".join(\n                edge[\"node\"][\"text\"]\n                for edge in post[\"edge_media_to_caption\"][\"edges\"]\n            )),\n        }\n        data[\"date\"] = data[\"post_date\"]\n\n        if tags := self._find_tags(data[\"description\"]):\n            data[\"tags\"] = sorted(set(tags))\n\n        if location := post.get(\"location\"):\n            data[\"location_id\"] = location[\"id\"]\n            data[\"location_slug\"] = location[\"slug\"]\n            data[\"location_url\"] = (f\"{self.root}/explore/locations/\"\n                                    f\"{location['id']}/{location['slug']}/\")\n\n        if coauthors := post.get(\"coauthor_producers\"):\n            data[\"coauthors\"] = [\n                {\"id\"      : user[\"id\"],\n                 \"username\": user[\"username\"]}\n                for user in coauthors\n            ]\n\n        data[\"_files\"] = files = []\n        if \"edge_sidecar_to_children\" in post:\n            for num, edge in enumerate(\n                    post[\"edge_sidecar_to_children\"][\"edges\"], 1):\n                node = edge[\"node\"]\n                dimensions = node[\"dimensions\"]\n                media = {\n                    \"num\": num,\n                    \"media_id\"   : node[\"id\"],\n                    \"date\"       : data[\"date\"],\n                    \"shortcode\"  : (node.get(\"shortcode\") or\n                                    shortcode_from_id(node[\"id\"])),\n                    \"display_url\": node[\"display_url\"],\n                    \"video_url\"  : node.get(\"video_url\"),\n                    \"width\"      : dimensions[\"width\"],\n                    \"height\"     : dimensions[\"height\"],\n                    \"sidecar_media_id\" : post[\"id\"],\n                    \"sidecar_shortcode\": post[\"shortcode\"],\n                }\n                self._extract_tagged_users(node, media)\n                files.append(media)\n        else:\n            dimensions = post[\"dimensions\"]\n            media = {\n                \"media_id\"   : post[\"id\"],\n                \"date\"       : data[\"date\"],\n                \"shortcode\"  : post[\"shortcode\"],\n                \"display_url\": post[\"display_url\"],\n                \"video_url\"  : post.get(\"video_url\"),\n                \"width\"      : dimensions[\"width\"],\n                \"height\"     : dimensions[\"height\"],\n            }\n            self._extract_tagged_users(post, media)\n            files.append(media)\n\n        return data\n\n    def _extract_tagged_users(self, src, dest):\n        dest[\"tagged_users\"] = tagged_users = []\n\n        if edges := src.get(\"edge_media_to_tagged_user\"):\n            for edge in edges[\"edges\"]:\n                user = edge[\"node\"][\"user\"]\n                tagged_users.append({\"id\"       : user[\"id\"],\n                                     \"username\" : user[\"username\"],\n                                     \"full_name\": user[\"full_name\"]})\n\n        if usertags := src.get(\"usertags\"):\n            for tag in usertags[\"in\"]:\n                user = tag[\"user\"]\n                tagged_users.append({\"id\"       : user[\"pk\"],\n                                     \"username\" : user[\"username\"],\n                                     \"full_name\": user[\"full_name\"]})\n\n        if mentions := src.get(\"reel_mentions\"):\n            for mention in mentions:\n                user = mention[\"user\"]\n                tagged_users.append({\"id\"       : user.get(\"pk\"),\n                                     \"username\" : user[\"username\"],\n                                     \"full_name\": user[\"full_name\"]})\n\n        if stickers := src.get(\"story_bloks_stickers\"):\n            for sticker in stickers:\n                sticker = sticker[\"bloks_sticker\"]\n                if sticker[\"bloks_sticker_type\"] == \"mention\":\n                    user = sticker[\"sticker_data\"][\"ig_mention\"]\n                    tagged_users.append({\"id\"       : user[\"account_id\"],\n                                         \"username\" : user[\"username\"],\n                                         \"full_name\": user[\"full_name\"]})\n\n    def _extract_pinned(self, post):\n        return (post.get(\"timeline_pinned_user_ids\") or\n                post.get(\"clips_tab_pinned_user_ids\") or ())\n\n    def _init_cursor(self):\n        cursor = self.config(\"cursor\", True)\n        if cursor is True:\n            return None\n        elif not cursor:\n            self._update_cursor = util.identity\n        return cursor\n\n    def _update_cursor(self, cursor):\n        if cursor:\n            self.log.debug(\"Cursor: %s\", cursor)\n        self._cursor = cursor\n        return cursor\n\n    def _assign_user(self, user):\n        self._user = user\n\n        for key, old in (\n                (\"count_media\"     , \"edge_owner_to_timeline_media\"),\n                (\"count_video\"     , \"edge_felix_video_timeline\"),\n                (\"count_saved\"     , \"edge_saved_media\"),\n                (\"count_mutual\"    , \"edge_mutual_followed_by\"),\n                (\"count_follow\"    , \"edge_follow\"),\n                (\"count_followed\"  , \"edge_followed_by\"),\n                (\"count_collection\", \"edge_media_collections\")):\n            try:\n                user[key] = user.pop(old)[\"count\"]\n            except Exception:\n                user[key] = 0\n\n\nclass InstagramPostExtractor(InstagramExtractor):\n    \"\"\"Extractor for an Instagram post\"\"\"\n    subcategory = \"post\"\n    pattern = (r\"(?:https?://)?(?:www\\.)?instagram\\.com\"\n               r\"/(?:share()(?:/(?:p|tv|reels?()))?\"\n               r\"|(?:[^/?#]+/)?(?:p|tv|reels?()))\"\n               r\"/([^/?#]+)\")\n    example = \"https://www.instagram.com/p/abcdefg/\"\n\n    def __init__(self, match):\n        if match[2] is not None or match[3] is not None:\n            self.subcategory = \"reel\"\n        InstagramExtractor.__init__(self, match)\n\n    def posts(self):\n        share, _, _, shortcode = self.groups\n        if share is not None:\n            url = text.ensure_http_scheme(self.url)\n            headers = {\n                \"Sec-Fetch-Dest\": \"empty\",\n                \"Sec-Fetch-Mode\": \"navigate\",\n                \"Sec-Fetch-Site\": \"same-origin\",\n            }\n            location = self.request_location(url, headers=headers)\n            shortcode = location.split(\"/\")[-2]\n        return self.api.media(shortcode)\n\n\nclass InstagramUserExtractor(Dispatch, InstagramExtractor):\n    \"\"\"Extractor for an Instagram user profile\"\"\"\n    pattern = USER_PATTERN + r\"/?(?:$|[?#])\"\n    example = \"https://www.instagram.com/USER/\"\n\n    def items(self):\n        base = f\"{self.root}/{self.item}/\"\n        stories = f\"{self.root}/stories/{self.item}/\"\n        return self._dispatch_extractors((\n            (InstagramInfoExtractor      , base + \"info/\"),\n            (InstagramAvatarExtractor    , base + \"avatar/\"),\n            (InstagramStoriesExtractor   , stories),\n            (InstagramHighlightsExtractor, base + \"highlights/\"),\n            (InstagramPostsExtractor     , base + \"posts/\"),\n            (InstagramReelsExtractor     , base + \"reels/\"),\n            (InstagramTaggedExtractor    , base + \"tagged/\"),\n        ), (\"posts\",))\n\n\nclass InstagramPostsExtractor(InstagramExtractor):\n    \"\"\"Extractor for an Instagram user's posts\"\"\"\n    subcategory = \"posts\"\n    pattern = USER_PATTERN + r\"/posts\"\n    example = \"https://www.instagram.com/USER/posts/\"\n\n    def posts(self):\n        uid = self.api.user_id(self.item)\n        return self.api.user_feed(uid)\n\n    def _extract_pinned(self, post):\n        try:\n            return post[\"timeline_pinned_user_ids\"]\n        except KeyError:\n            return ()\n\n\nclass InstagramReelsExtractor(InstagramExtractor):\n    \"\"\"Extractor for an Instagram user's reels\"\"\"\n    subcategory = \"reels\"\n    pattern = USER_PATTERN + r\"/reels\"\n    example = \"https://www.instagram.com/USER/reels/\"\n\n    def posts(self):\n        uid = self.api.user_id(self.item)\n        return self.api.user_clips(uid)\n\n    def _extract_pinned(self, post):\n        try:\n            return post[\"clips_tab_pinned_user_ids\"]\n        except KeyError:\n            return ()\n\n\nclass InstagramTaggedExtractor(InstagramExtractor):\n    \"\"\"Extractor for an Instagram user's tagged posts\"\"\"\n    subcategory = \"tagged\"\n    pattern = USER_PATTERN + r\"/tagged\"\n    example = \"https://www.instagram.com/USER/tagged/\"\n\n    def metadata(self):\n        if self.item.startswith(\"id:\"):\n            self.user_id = self.item[3:]\n            if not self.config(\"metadata\"):\n                return {\"tagged_owner_id\": self.user_id}\n            user = self.api.user_by_id(self.user_id)\n        else:\n            self.user_id = self.api.user_id(self.item)\n            user = self.api.user_by_screen_name(self.item)\n\n        return {\n            \"tagged_owner_id\" : user[\"id\"],\n            \"tagged_username\" : user[\"username\"],\n            \"tagged_full_name\": user[\"full_name\"],\n        }\n\n    def posts(self):\n        return self.api.user_tagged(self.user_id)\n\n\nclass InstagramGuideExtractor(InstagramExtractor):\n    \"\"\"Extractor for an Instagram guide\"\"\"\n    subcategory = \"guide\"\n    pattern = USER_PATTERN + r\"/guide/[^/?#]+/(\\d+)\"\n    example = \"https://www.instagram.com/USER/guide/NAME/12345\"\n\n    def __init__(self, match):\n        InstagramExtractor.__init__(self, match)\n        self.guide_id = match[2]\n\n    def metadata(self):\n        return {\"guide\": self.api.guide(self.guide_id)}\n\n    def posts(self):\n        return self.api.guide_media(self.guide_id)\n\n\nclass InstagramSavedExtractor(InstagramExtractor):\n    \"\"\"Extractor for an Instagram user's saved media\"\"\"\n    subcategory = \"saved\"\n    pattern = USER_PATTERN + r\"/saved(?:/all-posts)?/?$\"\n    example = \"https://www.instagram.com/USER/saved/\"\n\n    def posts(self):\n        return self.api.user_saved()\n\n\nclass InstagramCollectionExtractor(InstagramExtractor):\n    \"\"\"Extractor for Instagram collection\"\"\"\n    subcategory = \"collection\"\n    pattern = USER_PATTERN + r\"/saved/([^/?#]+)/([^/?#]+)\"\n    example = \"https://www.instagram.com/USER/saved/COLLECTION/12345\"\n\n    def __init__(self, match):\n        InstagramExtractor.__init__(self, match)\n        self.user, self.collection_name, self.collection_id = match.groups()\n\n    def metadata(self):\n        return {\n            \"collection_id\"  : self.collection_id,\n            \"collection_name\": text.unescape(self.collection_name),\n        }\n\n    def posts(self):\n        return self.api.user_collection(self.collection_id)\n\n\nclass InstagramStoriesTrayExtractor(InstagramExtractor):\n    \"\"\"Extractor for your Instagram account's stories tray\"\"\"\n    subcategory = \"stories-tray\"\n    pattern = BASE_PATTERN + r\"/stories/me/?$()\"\n    example = \"https://www.instagram.com/stories/me/\"\n\n    def items(self):\n        base = self.root + \"/stories/id:\"\n        for story in self.api.reels_tray():\n            story[\"date\"] = self.parse_timestamp(story[\"latest_reel_media\"])\n            story[\"_extractor\"] = InstagramStoriesExtractor\n            yield Message.Queue, f\"{base}{story['id']}/\", story\n\n\nclass InstagramStoriesExtractor(InstagramExtractor):\n    \"\"\"Extractor for Instagram stories\"\"\"\n    subcategory = \"stories\"\n    pattern = (r\"(?:https?://)?(?:www\\.)?instagram\\.com\"\n               r\"/s(?:tories/(?:highlights/(\\d+)|([^/?#]+)(?:/(\\d+))?)\"\n               r\"|/(aGlnaGxpZ2h0[^?#]+)(?:\\?story_media_id=(\\d+))?)\")\n    example = \"https://www.instagram.com/stories/USER/\"\n\n    def __init__(self, match):\n        h1, self.user, m1, h2, m2 = match.groups()\n\n        if self.user:\n            self.highlight_id = None\n        else:\n            self.subcategory = InstagramHighlightsExtractor.subcategory\n            self.highlight_id = (\"highlight:\" + h1 if h1 else\n                                 binascii.a2b_base64(h2).decode())\n\n        self.media_id = m1 or m2\n        InstagramExtractor.__init__(self, match)\n\n    def posts(self):\n        reel_id = self.highlight_id or self.api.user_id(self.user)\n        reels = self.api.reels_media(reel_id)\n\n        if not reels:\n            return ()\n\n        if self.media_id:\n            reel = reels[0]\n            for item in reel[\"items\"]:\n                if item[\"pk\"] == self.media_id:\n                    reel[\"items\"] = (item,)\n                    break\n            else:\n                raise self.exc.NotFoundError(\"story\")\n\n        elif self.config(\"split\"):\n            reel = reels[0]\n            reels = []\n            for item in reel[\"items\"]:\n                item.pop(\"user\", None)\n                copy = reel.copy()\n                copy.update(item)\n                copy[\"items\"] = (item,)\n                reels.append(copy)\n\n        return reels\n\n\nclass InstagramHighlightsExtractor(InstagramExtractor):\n    \"\"\"Extractor for an Instagram user's story highlights\"\"\"\n    subcategory = \"highlights\"\n    pattern = USER_PATTERN + r\"/highlights\"\n    example = \"https://www.instagram.com/USER/highlights/\"\n\n    def posts(self):\n        uid = self.api.user_id(self.item)\n        return self.api.highlights_media(uid)\n\n\nclass InstagramFollowersExtractor(InstagramExtractor):\n    \"\"\"Extractor for an Instagram user's followers\"\"\"\n    subcategory = \"followers\"\n    pattern = USER_PATTERN + r\"/followers\"\n    example = \"https://www.instagram.com/USER/followers/\"\n\n    def items(self):\n        uid = self.api.user_id(self.item)\n        for user in self.api.user_followers(uid):\n            user[\"_extractor\"] = InstagramUserExtractor\n            url = f\"{self.root}/{user['username']}\"\n            yield Message.Queue, url, user\n\n\nclass InstagramFollowingExtractor(InstagramExtractor):\n    \"\"\"Extractor for an Instagram user's followed users\"\"\"\n    subcategory = \"following\"\n    pattern = USER_PATTERN + r\"/following\"\n    example = \"https://www.instagram.com/USER/following/\"\n\n    def items(self):\n        uid = self.api.user_id(self.item)\n        for user in self.api.user_following(uid):\n            user[\"_extractor\"] = InstagramUserExtractor\n            url = f\"{self.root}/{user['username']}\"\n            yield Message.Queue, url, user\n\n\nclass InstagramTagExtractor(InstagramExtractor):\n    \"\"\"Extractor for Instagram tags\"\"\"\n    subcategory = \"tag\"\n    directory_fmt = (\"{category}\", \"{subcategory}\", \"{tag}\")\n    pattern = BASE_PATTERN + r\"/explore/tags/([^/?#]+)\"\n    example = \"https://www.instagram.com/explore/tags/TAG/\"\n\n    def metadata(self):\n        return {\"tag\": text.unquote(self.item)}\n\n    def posts(self):\n        return self.api.tags_media(self.item)\n\n\nclass InstagramInfoExtractor(InstagramExtractor):\n    \"\"\"Extractor for an Instagram user's profile data\"\"\"\n    subcategory = \"info\"\n    pattern = USER_PATTERN + r\"/info\"\n    example = \"https://www.instagram.com/USER/info/\"\n\n    def items(self):\n        screen_name = self.item\n        if screen_name.startswith(\"id:\"):\n            user = self.api.user_by_id(screen_name[3:])\n        else:\n            user = self.api.user_by_screen_name(screen_name)\n\n        return iter(((Message.Directory, \"\", user),))\n\n\nclass InstagramAvatarExtractor(InstagramExtractor):\n    \"\"\"Extractor for an Instagram user's avatar\"\"\"\n    subcategory = \"avatar\"\n    pattern = USER_PATTERN + r\"/avatar\"\n    example = \"https://www.instagram.com/USER/avatar/\"\n\n    def posts(self):\n        if self._logged_in:\n            user_id = self.api.user_id(self.item, check_private=False)\n            user = self.api.user_by_id(user_id)\n            avatar = (user.get(\"hd_profile_pic_url_info\") or\n                      user[\"hd_profile_pic_versions\"][-1])\n        else:\n            user = self.item\n            if user.startswith(\"id:\"):\n                user = self.api.user_by_id(user[3:])\n            else:\n                user = self.api.user_by_screen_name(user)\n                user[\"pk\"] = user[\"id\"]\n            url = user.get(\"profile_pic_url_hd\") or user[\"profile_pic_url\"]\n            avatar = {\"url\": url, \"width\": 0, \"height\": 0}\n\n        if pk := user.get(\"profile_pic_id\"):\n            pk = pk.partition(\"_\")[0]\n            code = shortcode_from_id(pk)\n        else:\n            pk = code = \"avatar:\" + str(user[\"pk\"])\n\n        return ({\n            \"pk\"        : pk,\n            \"code\"      : code,\n            \"user\"      : user,\n            \"caption\"   : None,\n            \"like_count\": 0,\n            \"image_versions2\": {\"candidates\": (avatar,)},\n        },)\n\n\nclass InstagramRestAPI():\n\n    def __init__(self, extractor):\n        self.extractor = extractor\n        self.exc = extractor.exc\n\n        _cache = self.extractor.config(\"user-cache\", True)\n        self._user_cache = True if not _cache or _cache == \"memory\" else False\n\n        if strategy := self.extractor.config(\"user-strategy\"):\n            if isinstance(strategy, str):\n                strategy = strategy.split(\",\")\n            self._strategy_uid = strategy\n        else:\n            self._strategy_uid = (\"search\", \"web\")\n\n    def guide(self, guide_id):\n        endpoint = \"/v1/guides/web_info/\"\n        params = {\"guide_id\": guide_id}\n        return self._call(endpoint, params=params)\n\n    def guide_media(self, guide_id):\n        endpoint = f\"/v1/guides/guide/{guide_id}/\"\n        return self._pagination_guides(endpoint)\n\n    def highlights_media(self, user_id, chunk_size=5):\n        reel_ids = [hl[\"id\"] for hl in self.highlights_tray(user_id)]\n\n        if order := self.extractor.config(\"order-posts\"):\n            if order in {\"desc\", \"reverse\"}:\n                reel_ids.reverse()\n            elif order in {\"id\", \"id_asc\"}:\n                reel_ids.sort(key=lambda r: int(r[10:]))\n            elif order == \"id_desc\":\n                reel_ids.sort(key=lambda r: int(r[10:]), reverse=True)\n            elif order != \"asc\":\n                self.extractor.log.warning(\"Unknown posts order '%s'\", order)\n\n        for offset in range(0, len(reel_ids), chunk_size):\n            yield from self.reels_media(\n                reel_ids[offset : offset+chunk_size])\n\n    def highlights_tray(self, user_id):\n        endpoint = f\"/v1/highlights/{user_id}/highlights_tray/\"\n        return self._call(endpoint)[\"tray\"]\n\n    def media(self, shortcode):\n        if len(shortcode) > 28:\n            shortcode = shortcode[:-28]\n        endpoint = f\"/v1/media/{id_from_shortcode(shortcode)}/info/\"\n        return self._pagination(endpoint)\n\n    def reels_media(self, reel_ids):\n        endpoint = \"/v1/feed/reels_media/\"\n        params = {\"reel_ids\": reel_ids}\n        try:\n            return self._call(endpoint, params=params)[\"reels_media\"]\n        except KeyError:\n            raise self.exc.AuthRequired(\"authenticated cookies\")\n\n    def reels_tray(self):\n        endpoint = \"/v1/feed/reels_tray/\"\n        return self._call(endpoint)[\"tray\"]\n\n    def tags_media(self, tag):\n        for section in self.tags_sections(tag):\n            for media in section[\"layout_content\"][\"medias\"]:\n                yield media[\"media\"]\n\n    def tags_sections(self, tag):\n        endpoint = f\"/v1/tags/{tag}/sections/\"\n        data = {\n            \"include_persistent\": \"0\",\n            \"max_id\" : None,\n            \"page\"   : None,\n            \"surface\": \"grid\",\n            \"tab\"    : \"recent\",\n        }\n        return self._pagination_sections(endpoint, data)\n\n    def user_by_id(self, user_id):\n        return self.extractor.cache(\n            self._user_by_id_impl, user_id, _mem=self._user_cache)\n\n    def _user_by_id_impl(self, user_id):\n        endpoint = f\"/v1/users/{user_id}/info/\"\n        try:\n            return self._call(endpoint, notfound=\"user\")[\"user\"]\n        except Exception:\n            raise self.exc.NotFoundError(\"user\")\n\n    def user_by_name(self, username):\n        return self.extractor.cache(\n            self._user_by_name_impl, username, _mem=self._user_cache)\n\n    def _user_by_name_impl(self, username):\n        endpoint = \"/v1/users/web_profile_info/\"\n        params = {\"username\": username}\n        try:\n            return self._call(\n                endpoint, params=params, notfound=\"user\")[\"data\"][\"user\"]\n        except Exception:\n            raise self.exc.NotFoundError(\"user\")\n\n    def user_by_search(self, username):\n        return self.extractor.cache(\n            self._user_by_search_impl, username, _mem=self._user_cache)\n\n    def _user_by_search_impl(self, username):\n        url = \"https://www.instagram.com/web/search/topsearch/\"\n        params = {\"query\": username}\n\n        name = username.lower()\n        try:\n            for result in self._call(url, params=params)[\"users\"]:\n                user = result[\"user\"]\n                if user[\"username\"].lower() == name:\n                    return user\n        except Exception:\n            pass\n        raise self.exc.NotFoundError(\"user\")\n\n    def user_by_web(self, username):\n        return self.extractor.cache(\n            self._user_by_web_impl, username, _mem=self._user_cache)\n\n    def _user_by_web_impl(self, username):\n        url = \"https://www.instagram.com/\" + username\n\n        try:\n            headers = {\n                \"Accept\": \"text/html,application/xhtml+xml,\"\n                          \"application/xml;q=0.9,*/*;q=0.8\",\n                \"Accept-Language\": \"en-US,en;q=0.5\",\n                \"Accept-Encoding\": \"gzip, deflate, br, zstd\",\n                \"Alt-Used\": \"www.instagram.com\",\n                \"Connection\": \"keep-alive\",\n                \"Sec-Fetch-Dest\": \"document\",\n                \"Sec-Fetch-Mode\": \"navigate\",\n                \"Sec-Fetch-Site\": \"none\",\n                \"Priority\": \"u=0, i\",\n            }\n            page = self.extractor.request(url, headers=headers).text\n            if uid := text.extr(page, '\"profile_id\":\"', '\"'):\n                return {\"id\": uid}\n        except Exception:\n            pass\n        raise self.exc.NotFoundError(\"user\")\n\n    def user_by_screen_name(self, screen_name):\n        for strategy in self._strategy_uid:\n            try:\n                if strategy in {\"search\", \"topsearch\"}:\n                    return self.user_by_search(screen_name)\n                elif strategy in {\"info\", \"web_profile_info\", \"api\"}:\n                    return self.user_by_name(screen_name)\n                elif strategy in {\"web\", \"webpage\"}:\n                    return self.user_by_web(screen_name)\n                else:\n                    self.extractor.log.warning(\"Invalid strategy %r\", strategy)\n            except Exception:\n                self.extractor.log.debug(\"Failed to get user via %r\", strategy)\n        raise self.exc.NotFoundError(\"user\")\n\n    def user_id(self, screen_name, check_private=True):\n        if screen_name.startswith(\"id:\"):\n            if self.extractor.config(\"metadata\"):\n                self.extractor._user = self.user_by_id(screen_name[3:])\n            return screen_name[3:]\n\n        user = self.user_by_screen_name(screen_name)\n        if check_private and user.get(\"is_private\") and \\\n                not user.get(\"followed_by_viewer\", True):\n            name = user[\"username\"]\n            s = \"\" if name.endswith(\"s\") else \"s\"\n            self.extractor.log.warning(\"%s'%s posts are private\", name, s)\n\n        self.extractor._assign_user(user)\n        return user[\"id\"]\n\n    def user_clips(self, user_id):\n        endpoint = \"/v1/clips/user/\"\n        data = {\n            \"target_user_id\": user_id,\n            \"page_size\": \"50\",\n            \"max_id\": None,\n            \"include_feed_video\": \"true\",\n        }\n        return self._pagination_post(endpoint, data)\n\n    def user_collection(self, collection_id):\n        endpoint = f\"/v1/feed/collection/{collection_id}/posts/\"\n        params = {\"count\": 50}\n        return self._pagination(endpoint, params, media=True)\n\n    def user_feed(self, user_id):\n        endpoint = f\"/v1/feed/user/{user_id}/\"\n        params = {\"count\": 30}\n        return self._pagination(endpoint, params)\n\n    def user_followers(self, user_id):\n        endpoint = f\"/v1/friendships/{user_id}/followers/\"\n        params = {\"count\": 12}\n        return self._pagination_following(endpoint, params)\n\n    def user_following(self, user_id):\n        endpoint = f\"/v1/friendships/{user_id}/following/\"\n        params = {\"count\": 12}\n        return self._pagination_following(endpoint, params)\n\n    def user_saved(self):\n        endpoint = \"/v1/feed/saved/posts/\"\n        params = {\"count\": 50}\n        return self._pagination(endpoint, params, media=True)\n\n    def user_tagged(self, user_id):\n        endpoint = f\"/v1/usertags/{user_id}/feed/\"\n        params = {\"count\": 20}\n        return self._pagination(endpoint, params)\n\n    def _call(self, endpoint, **kwargs):\n        extr = self.extractor\n\n        if endpoint[0] == \"/\":\n            url = \"https://www.instagram.com/api\" + endpoint\n        else:\n            url = endpoint\n        kwargs[\"headers\"] = {\n            \"Accept\"          : \"*/*\",\n            \"X-CSRFToken\"     : extr.csrf_token,\n            \"X-IG-App-ID\"     : \"936619743392459\",\n            \"X-ASBD-ID\"       : \"129477\",\n            \"X-IG-WWW-Claim\"  : extr.www_claim,\n            \"X-Requested-With\": \"XMLHttpRequest\",\n            \"Connection\"      : \"keep-alive\",\n            \"Referer\"         : extr.root + \"/\",\n            \"Sec-Fetch-Dest\"  : \"empty\",\n            \"Sec-Fetch-Mode\"  : \"cors\",\n            \"Sec-Fetch-Site\"  : \"same-origin\",\n        }\n        return extr.request_json(url, **kwargs)\n\n    def _pagination(self, endpoint, params=None, media=False):\n        if params is None:\n            params = {}\n        extr = self.extractor\n        params[\"max_id\"] = extr._init_cursor()\n\n        while True:\n            data = self._call(endpoint, params=params)\n\n            if media:\n                for item in data[\"items\"]:\n                    yield item[\"media\"]\n            else:\n                yield from data[\"items\"]\n\n            if not data.get(\"more_available\"):\n                return extr._update_cursor(None)\n            params[\"max_id\"] = extr._update_cursor(data[\"next_max_id\"])\n\n    def _pagination_post(self, endpoint, params):\n        extr = self.extractor\n        params[\"max_id\"] = extr._init_cursor()\n\n        while True:\n            data = self._call(endpoint, method=\"POST\", data=params)\n\n            for item in data[\"items\"]:\n                yield item[\"media\"]\n\n            info = data[\"paging_info\"]\n            if not info.get(\"more_available\"):\n                return extr._update_cursor(None)\n            params[\"max_id\"] = extr._update_cursor(info[\"max_id\"])\n\n    def _pagination_sections(self, endpoint, params):\n        extr = self.extractor\n        params[\"max_id\"] = extr._init_cursor()\n\n        while True:\n            info = self._call(endpoint, method=\"POST\", data=params)\n\n            yield from info[\"sections\"]\n\n            if not info.get(\"more_available\"):\n                return extr._update_cursor(None)\n            params[\"page\"] = info[\"next_page\"]\n            params[\"max_id\"] = extr._update_cursor(info[\"next_max_id\"])\n\n    def _pagination_guides(self, endpoint):\n        extr = self.extractor\n        params = {\"max_id\": extr._init_cursor()}\n\n        while True:\n            data = self._call(endpoint, params=params)\n\n            for item in data[\"items\"]:\n                yield from item[\"media_items\"]\n\n            next_max_id = data.get(\"next_max_id\")\n            if not next_max_id:\n                return extr._update_cursor(None)\n            params[\"max_id\"] = extr._update_cursor(next_max_id)\n\n    def _pagination_following(self, endpoint, params):\n        extr = self.extractor\n        params[\"max_id\"] = text.parse_int(extr._init_cursor())\n\n        while True:\n            data = self._call(endpoint, params=params)\n\n            yield from data[\"users\"]\n\n            next_max_id = data.get(\"next_max_id\")\n            if not next_max_id:\n                return extr._update_cursor(None)\n            params[\"max_id\"] = extr._update_cursor(next_max_id)\n\n\nclass InstagramGraphqlAPI():\n\n    def __init__(self, extractor):\n        self.extractor = extractor\n        self.user_collection = self.user_saved = self.reels_media = \\\n            self.highlights_media = self.guide = self.guide_media = \\\n            self._unsupported\n        self._json_dumps = util.json_dumps\n\n        api = InstagramRestAPI(extractor)\n        self.user_by_screen_name = api.user_by_screen_name\n        self.user_by_id = api.user_by_id\n        self.user_id = api.user_id\n\n    def _unsupported(self, _=None):\n        raise self.extractor.exc.AbortExtraction(\n            \"Unsupported with GraphQL API\")\n\n    def highlights_tray(self, user_id):\n        query_hash = \"d4d88dc1500312af6f937f7b804c68c3\"\n        variables = {\n            \"user_id\": user_id,\n            \"include_chaining\": False,\n            \"include_reel\": False,\n            \"include_suggested_users\": False,\n            \"include_logged_out_extras\": True,\n            \"include_highlight_reels\": True,\n            \"include_live_status\": False,\n        }\n        edges = (self._call(query_hash, variables)[\"user\"]\n                 [\"edge_highlight_reels\"][\"edges\"])\n        return [edge[\"node\"] for edge in edges]\n\n    def media(self, shortcode):\n        query_hash = \"9f8827793ef34641b2fb195d4d41151c\"\n        variables = {\n            \"shortcode\": shortcode,\n            \"child_comment_count\": 3,\n            \"fetch_comment_count\": 40,\n            \"parent_comment_count\": 24,\n            \"has_threaded_comments\": True,\n        }\n        media = self._call(query_hash, variables).get(\"shortcode_media\")\n        return (media,) if media else ()\n\n    def tags_media(self, tag):\n        query_hash = \"9b498c08113f1e09617a1703c22b2f32\"\n        variables = {\"tag_name\": text.unescape(tag), \"first\": 24}\n        return self._pagination(query_hash, variables,\n                                \"hashtag\", \"edge_hashtag_to_media\")\n\n    def user_clips(self, user_id):\n        query_hash = \"bc78b344a68ed16dd5d7f264681c4c76\"\n        variables = {\"id\": user_id, \"first\": 24}\n        return self._pagination(query_hash, variables)\n\n    def user_feed(self, user_id):\n        query_hash = \"69cba40317214236af40e7efa697781d\"\n        variables = {\"id\": user_id, \"first\": 24}\n        return self._pagination(query_hash, variables)\n\n    def user_tagged(self, user_id):\n        query_hash = \"be13233562af2d229b008d2976b998b5\"\n        variables = {\"id\": user_id, \"first\": 24}\n        return self._pagination(query_hash, variables)\n\n    def _call(self, query_hash, variables):\n        extr = self.extractor\n\n        url = \"https://www.instagram.com/graphql/query/\"\n        params = {\n            \"query_hash\": query_hash,\n            \"variables\" : self._json_dumps(variables),\n        }\n        headers = {\n            \"Accept\"          : \"*/*\",\n            \"X-CSRFToken\"     : extr.csrf_token,\n            \"X-Instagram-AJAX\": \"1006267176\",\n            \"X-IG-App-ID\"     : \"936619743392459\",\n            \"X-ASBD-ID\"       : \"198387\",\n            \"X-IG-WWW-Claim\"  : extr.www_claim,\n            \"X-Requested-With\": \"XMLHttpRequest\",\n            \"Referer\"         : extr.root + \"/\",\n        }\n        return extr.request_json(url, params=params, headers=headers)[\"data\"]\n\n    def _pagination(self, query_hash, variables,\n                    key_data=\"user\", key_edge=None):\n        extr = self.extractor\n        variables[\"after\"] = extr._init_cursor()\n\n        while True:\n            data = self._call(query_hash, variables)[key_data]\n            data = data[key_edge] if key_edge else next(iter(data.values()))\n\n            for edge in data[\"edges\"]:\n                yield edge[\"node\"]\n\n            info = data[\"page_info\"]\n            if not info[\"has_next_page\"]:\n                return extr._update_cursor(None)\n            elif not data[\"edges\"]:\n                user = self.extractor.item\n                s = \"\" if user.endswith(\"s\") else \"s\"\n                raise self.exc.AbortExtraction(\n                    f\"{user}'{s} posts are private\")\n\n            variables[\"after\"] = extr._update_cursor(info[\"end_cursor\"])\n\n\ndef id_from_shortcode(shortcode):\n    return util.bdecode(shortcode, _ALPHABET)\n\n\ndef shortcode_from_id(post_id):\n    return util.bencode(int(post_id), _ALPHABET)\n\n\n_ALPHABET = (\"ABCDEFGHIJKLMNOPQRSTUVWXYZ\"\n             \"abcdefghijklmnopqrstuvwxyz\"\n             \"0123456789-_\")\n"
  },
  {
    "path": "gallery_dl/extractor/issuu.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2019-2025 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://issuu.com/\"\"\"\n\nfrom .common import GalleryExtractor, Extractor, Message\nfrom .. import text, util\n\n\nclass IssuuBase():\n    \"\"\"Base class for issuu extractors\"\"\"\n    category = \"issuu\"\n    root = \"https://issuu.com\"\n\n\nclass IssuuPublicationExtractor(IssuuBase, GalleryExtractor):\n    \"\"\"Extractor for a single publication\"\"\"\n    subcategory = \"publication\"\n    directory_fmt = (\"{category}\", \"{document[username]}\",\n                     \"{document[date]:%Y-%m-%d} {document[title]}\")\n    filename_fmt = \"{num:>03}.{extension}\"\n    archive_fmt = \"{document[publicationId]}_{num}\"\n    pattern = r\"(?:https?://)?issuu\\.com(/[^/?#]+/docs/[^/?#]+)\"\n    example = \"https://issuu.com/issuu/docs/TITLE/\"\n\n    def metadata(self, page):\n\n        data = text.extr(\n            page, '{\\\\\"documentTextVersion\\\\\":', ']\\\\n\"])</script>')\n        data = util.json_loads(text.unescape(\n            '{\"\":' + data.replace('\\\\\"', '\"')))\n\n        doc = data[\"initialDocumentData\"][\"document\"]\n        doc[\"date\"] = self.parse_datetime_iso(\n            doc[\"originalPublishDateInISOString\"])\n\n        self.count = text.parse_int(doc[\"pageCount\"])\n        self.base = (f\"https://image.isu.pub/{doc['revisionId']}-\"\n                     f\"{doc['publicationId']}/jpg/page_\")\n\n        return {\"document\": doc}\n\n    def images(self, page):\n        return [(f\"{self.base}{i}.jpg\", None)\n                for i in range(1, self.count + 1)]\n\n\nclass IssuuUserExtractor(IssuuBase, Extractor):\n    \"\"\"Extractor for all publications of a user/publisher\"\"\"\n    subcategory = \"user\"\n    pattern = r\"(?:https?://)?issuu\\.com/([^/?#]+)(?:/(\\d*))?$\"\n    example = \"https://issuu.com/USER\"\n\n    def items(self):\n        user, pnum = self.groups\n        base = self.root + \"/\" + user\n        pnum = text.parse_int(pnum, 1)\n\n        while True:\n            url = base + \"/\" + str(pnum) if pnum > 1 else base\n            try:\n                html = self.request(url).text\n                data = text.extr(html, '\\\\\"docs\\\\\":', '}]\\\\n\"]')\n                docs = util.json_loads(data.replace('\\\\\"', '\"'))\n            except Exception as exc:\n                self.log.traceback(exc)\n                return\n\n            for publication in docs:\n                url = self.root + \"/\" + publication[\"uri\"]\n                publication[\"_extractor\"] = IssuuPublicationExtractor\n                yield Message.Queue, url, publication\n\n            if len(docs) < 48:\n                return\n            pnum += 1\n"
  },
  {
    "path": "gallery_dl/extractor/itaku.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2022-2026 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://itaku.ee/\"\"\"\n\nfrom .common import Extractor, Message, Dispatch\nfrom .. import text, util\n\nBASE_PATTERN = r\"(?:https?://)?itaku\\.ee\"\nUSER_PATTERN = BASE_PATTERN + r\"/profile/([^/?#]+)\"\n\n\nclass ItakuExtractor(Extractor):\n    \"\"\"Base class for itaku extractors\"\"\"\n    category = \"itaku\"\n    root = \"https://itaku.ee\"\n    directory_fmt = (\"{category}\", \"{owner_username}\")\n    filename_fmt = (\"{id}{title:? //}.{extension}\")\n    archive_fmt = \"{id}\"\n    request_interval = (0.5, 1.5)\n\n    def _init(self):\n        self.api = ItakuAPI(self)\n        self.videos = self.config(\"videos\", True)\n\n    def items(self):\n        if images := self.images():\n            for image in images:\n                image[\"date\"] = self.parse_datetime_iso(image[\"date_added\"])\n                for category, tags in image.pop(\"categorized_tags\").items():\n                    image[\"tags_\" + category.lower()] = [\n                        t[\"name\"] for t in tags]\n                image[\"tags\"] = [t[\"name\"] for t in image[\"tags\"]]\n\n                sections = []\n                for s in image[\"sections\"]:\n                    if group := s[\"group\"]:\n                        sections.append(f\"{group['title']}/{s['title']}\")\n                    else:\n                        sections.append(s[\"title\"])\n                image[\"sections\"] = sections\n\n                if self.videos and image[\"video\"]:\n                    url = image[\"video\"][\"video\"]\n                else:\n                    url = image[\"image\"]\n\n                yield Message.Directory, \"\", image\n                yield Message.Url, url, text.nameext_from_url(url, image)\n            return\n\n        if posts := self.posts():\n            for post in posts:\n                images = post.pop(\"gallery_images\") or ()\n                post[\"count\"] = len(images)\n                post[\"date\"] = self.parse_datetime_iso(post[\"date_added\"])\n                post[\"tags\"] = [t[\"name\"] for t in post[\"tags\"]]\n\n                yield Message.Directory, \"\", post\n                for post[\"num\"], image in enumerate(images, 1):\n                    post[\"file\"] = image\n                    image[\"date\"] = self.parse_datetime_iso(\n                        image[\"date_added\"])\n\n                    url = image[\"image\"]\n                    yield Message.Url, url, text.nameext_from_url(url, post)\n            return\n\n        if users := self.users():\n            base = self.root + \"/profile/\"\n            for user in users:\n                url = base + user[\"owner_username\"]\n                user[\"_extractor\"] = ItakuUserExtractor\n                yield Message.Queue, url, user\n            return\n\n    images = posts = users = util.noop\n\n\nclass ItakuGalleryExtractor(ItakuExtractor):\n    \"\"\"Extractor for an itaku user's gallery\"\"\"\n    subcategory = \"gallery\"\n    pattern = USER_PATTERN + r\"/gallery(?:/(\\d+))?\"\n    example = \"https://itaku.ee/profile/USER/gallery\"\n\n    def images(self):\n        user, section = self.groups\n        return self.api.galleries_images({\n            \"owner\"   : self.api.user_id(user),\n            \"sections\": section,\n        })\n\n\nclass ItakuPostsExtractor(ItakuExtractor):\n    \"\"\"Extractor for an itaku user's posts\"\"\"\n    subcategory = \"posts\"\n    directory_fmt = (\"{category}\", \"{owner_username}\", \"Posts\",\n                     \"{id}{title:? //}\")\n    filename_fmt = \"{file[id]}{file[title]:? //}.{extension}\"\n    archive_fmt = \"{id}_{file[id]}\"\n    pattern = USER_PATTERN + r\"/posts(?:/(\\d+))?\"\n    example = \"https://itaku.ee/profile/USER/posts\"\n\n    def posts(self):\n        user, folder = self.groups\n        return self.api.posts({\n            \"owner\"  : self.api.user_id(user),\n            \"folders\": folder,\n        })\n\n\nclass ItakuStarsExtractor(ItakuExtractor):\n    \"\"\"Extractor for an itaku user's starred images\"\"\"\n    subcategory = \"stars\"\n    pattern = USER_PATTERN + r\"/stars(?:/(\\d+))?\"\n    example = \"https://itaku.ee/profile/USER/stars\"\n\n    def images(self):\n        user, section = self.groups\n        return self.api.galleries_images({\n            \"stars_of\": self.api.user_id(user),\n            \"sections\": section,\n            \"ordering\": \"-like_date\",\n        }, \"/user_starred_imgs\")\n\n\nclass ItakuFollowingExtractor(ItakuExtractor):\n    subcategory = \"following\"\n    pattern = USER_PATTERN + r\"/following\"\n    example = \"https://itaku.ee/profile/USER/following\"\n\n    def users(self):\n        return self.api.user_profiles({\n            \"followed_by\": self.api.user_id(self.groups[0]),\n        })\n\n\nclass ItakuFollowersExtractor(ItakuExtractor):\n    subcategory = \"followers\"\n    pattern = USER_PATTERN + r\"/followers\"\n    example = \"https://itaku.ee/profile/USER/followers\"\n\n    def users(self):\n        return self.api.user_profiles({\n            \"followers_of\": self.api.user_id(self.groups[0]),\n        })\n\n\nclass ItakuBookmarksExtractor(ItakuExtractor):\n    \"\"\"Extractor for an itaku bookmarks folder\"\"\"\n    subcategory = \"bookmarks\"\n    pattern = USER_PATTERN + r\"/bookmarks/(image|user)/(\\d+)\"\n    example = \"https://itaku.ee/profile/USER/bookmarks/image/12345\"\n\n    def _init(self):\n        if self.groups[1] == \"user\":\n            self.images = util.noop\n        ItakuExtractor._init(self)\n\n    def images(self):\n        return self.api.galleries_images({\n            \"bookmark_folder\": self.groups[2],\n        })\n\n    def users(self):\n        return self.api.user_profiles({\n            \"bookmark_folder\": self.groups[2],\n        })\n\n\nclass ItakuUserExtractor(Dispatch, ItakuExtractor):\n    \"\"\"Extractor for itaku user profiles\"\"\"\n    pattern = USER_PATTERN + r\"/?(?:$|\\?|#)\"\n    example = \"https://itaku.ee/profile/USER\"\n\n    def items(self):\n        base = f\"{self.root}/profile/{self.groups[0]}/\"\n        return self._dispatch_extractors((\n            (ItakuGalleryExtractor  , base + \"gallery\"),\n            (ItakuPostsExtractor    , base + \"posts\"),\n            (ItakuFollowersExtractor, base + \"followers\"),\n            (ItakuFollowingExtractor, base + \"following\"),\n            (ItakuStarsExtractor    , base + \"stars\"),\n        ), (\"gallery\",))\n\n\nclass ItakuImageExtractor(ItakuExtractor):\n    subcategory = \"image\"\n    pattern = BASE_PATTERN + r\"/images/(\\d+)\"\n    example = \"https://itaku.ee/images/12345\"\n\n    def images(self):\n        return (self.api.image(self.groups[0]),)\n\n\nclass ItakuPostExtractor(ItakuExtractor):\n    subcategory = \"post\"\n    directory_fmt = (\"{category}\", \"{owner_username}\", \"Posts\",\n                     \"{id}{title:? //}\")\n    filename_fmt = \"{file[id]}{file[title]:? //}.{extension}\"\n    archive_fmt = \"{id}_{file[id]}\"\n    pattern = BASE_PATTERN + r\"/posts/(\\d+)\"\n    example = \"https://itaku.ee/posts/12345\"\n\n    def posts(self):\n        return (self.api.post(self.groups[0]),)\n\n\nclass ItakuSearchExtractor(ItakuExtractor):\n    subcategory = \"search\"\n    pattern = BASE_PATTERN + r\"/home/images/?\\?([^#]+)\"\n    example = \"https://itaku.ee/home/images?tags=SEARCH\"\n\n    def images(self):\n        required_tags = []\n        negative_tags = []\n        optional_tags = []\n\n        params = text.parse_query_list(\n            self.groups[0], {\"tags\", \"maturity_rating\"})\n        if tags := params.pop(\"tags\", None):\n            for tag in tags:\n                if not tag:\n                    pass\n                elif tag[0] == \"-\":\n                    negative_tags.append(tag[1:])\n                elif tag[0] == \"~\":\n                    optional_tags.append(tag[1:])\n                else:\n                    required_tags.append(tag)\n\n        return self.api.galleries_images({\n            \"required_tags\": required_tags,\n            \"negative_tags\": negative_tags,\n            \"optional_tags\": optional_tags,\n        })\n\n\nclass ItakuAPI():\n\n    def __init__(self, extractor):\n        self.extractor = extractor\n        self.root = extractor.root + \"/api\"\n        self.headers = {\n            \"Accept\": \"application/json, text/plain, */*\",\n        }\n\n    def galleries_images(self, params, path=\"\"):\n        endpoint = f\"/galleries/images{path}/\"\n        params = {\n            \"cursor\"    : None,\n            \"date_range\": \"\",\n            \"maturity_rating\": (\"SFW\", \"Questionable\", \"NSFW\"),\n            \"ordering\"  : self._order(),\n            \"page\"      : \"1\",\n            \"page_size\" : \"30\",\n            \"visibility\": (\"PUBLIC\", \"PROFILE_ONLY\"),\n            **params,\n        }\n        return self._pagination(endpoint, params, self.image)\n\n    def posts(self, params):\n        endpoint = \"/posts/\"\n        params = {\n            \"cursor\"    : None,\n            \"date_range\": \"\",\n            \"maturity_rating\": (\"SFW\", \"Questionable\", \"NSFW\"),\n            \"ordering\"  : self._order(),\n            \"page\"      : \"1\",\n            \"page_size\" : \"30\",\n            **params,\n        }\n        return self._pagination(endpoint, params)\n\n    def user_profiles(self, params):\n        endpoint = \"/user_profiles/\"\n        params = {\n            \"cursor\"   : None,\n            \"ordering\" : self._order(),\n            \"page\"     : \"1\",\n            \"page_size\": \"50\",\n            \"sfw_only\" : \"false\",\n            **params,\n        }\n        return self._pagination(endpoint, params)\n\n    def image(self, image_id):\n        endpoint = f\"/galleries/images/{image_id}/\"\n        return self._call(endpoint)\n\n    def post(self, post_id):\n        endpoint = f\"/posts/{post_id}/\"\n        return self._call(endpoint)\n\n    def user(self, username):\n        return self._call(f\"/user_profiles/{username}/\")\n\n    def user_id(self, username):\n        if username.startswith(\"id:\"):\n            return int(username[3:])\n        return self.extractor.cache(self.user, username)[\"owner\"]\n\n    def _call(self, endpoint, params=None):\n        if not endpoint.startswith(\"http\"):\n            endpoint = self.root + endpoint\n        return self.extractor.request_json(\n            endpoint, params=params, headers=self.headers)\n\n    def _pagination(self, endpoint, params, extend=None):\n        data = self._call(endpoint, params)\n\n        while True:\n            if extend is None:\n                yield from data[\"results\"]\n            else:\n                for result in data[\"results\"]:\n                    yield extend(result[\"id\"])\n\n            url_next = data[\"links\"].get(\"next\")\n            if not url_next:\n                return\n\n            data = self._call(url_next)\n\n    def _order(self):\n        if order := self.extractor.config(\"order\"):\n            if order in {\"a\", \"asc\", \"r\", \"reverse\"}:\n                return \"date_added\"\n            if order not in {\"d\", \"desc\"}:\n                return order\n        return \"-date_added\"\n"
  },
  {
    "path": "gallery_dl/extractor/itchio.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2023 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://itch.io/\"\"\"\n\nfrom .common import Extractor, Message\nfrom .. import text\n\n\nclass ItchioGameExtractor(Extractor):\n    \"\"\"Extractor for itch.io games\"\"\"\n    category = \"itchio\"\n    subcategory = \"game\"\n    root = \"https://itch.io\"\n    directory_fmt = (\"{category}\", \"{user[name]}\")\n    filename_fmt = \"{game[title]} ({id}).{extension}\"\n    archive_fmt = \"{id}\"\n    pattern = r\"(?:https?://)?(\\w+)\\.itch\\.io/([\\w-]+)\"\n    example = \"https://USER.itch.io/GAME\"\n\n    def __init__(self, match):\n        self.user, self.slug = match.groups()\n        Extractor.__init__(self, match)\n\n    def items(self):\n        game_url = f\"https://{self.user}.itch.io/{self.slug}\"\n        page = self.request(game_url).text\n\n        params = {\n            \"source\": \"view_game\",\n            \"as_props\": \"1\",\n            \"after_download_lightbox\": \"true\",\n        }\n        headers = {\n            \"Referer\": game_url,\n            \"X-Requested-With\": \"XMLHttpRequest\",\n            \"Origin\": f\"https://{self.user}.itch.io\",\n        }\n        data = {\n            \"csrf_token\": text.unquote(self.cookies[\"itchio_token\"]),\n        }\n\n        for upload_id in text.extract_iter(page, 'data-upload_id=\"', '\"'):\n            file_url = f\"{game_url}/file/{upload_id}\"\n            info = self.request_json(file_url, method=\"POST\", params=params,\n                                     headers=headers, data=data)\n\n            game = info[\"lightbox\"][\"game\"]\n            user = info[\"lightbox\"][\"user\"]\n            game[\"url\"] = game_url\n            user.pop(\"follow_button\", None)\n            game = {\"game\": game, \"user\": user, \"id\": upload_id}\n\n            url = info[\"url\"]\n            yield Message.Directory, \"\", game\n            yield Message.Url, url, text.nameext_from_url(url, game)\n"
  },
  {
    "path": "gallery_dl/extractor/iwara.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://www.iwara.tv/\"\"\"\n\nfrom .common import Extractor, Message, Dispatch\nfrom .. import text, util\nimport hashlib\n\nBASE_PATTERN = r\"(?:https?://)?(?:www\\.)?iwara\\.(tv|ai)\"\nUSER_PATTERN = BASE_PATTERN + r\"/profile/([^/?#]+)\"\n\n\nclass IwaraExtractor(Extractor):\n    \"\"\"Base class for iwara.tv extractors\"\"\"\n    category = \"iwara\"\n    root = \"https://www.iwara.tv\"\n    directory_fmt = (\"{category}\", \"{user[name]}\")\n    filename_fmt = \"{date} {id} {title[:200]} {filename}.{extension}\"\n    archive_fmt = \"{type} {user[name]} {id} {file_id}\"\n\n    def _init(self):\n        self.root = \"https://www.iwara.\" + self.groups[0]\n        self.api = IwaraAPI(self)\n\n        if fmts := self.config(\"format\"):\n            if isinstance(fmts, str):\n                fmts = fmts.replace(\" \", \"\").lower().split(\",\")\n            elif not isinstance(fmts, (list, tuple)):\n                fmts = (str(fmts),)\n            self.formats = fmts\n            self.extract_video_source = self.extract_video_source_custom\n\n    def items_image(self, images, user=None):\n        for image in images:\n            try:\n                if \"image\" in image:\n                    # could extract 'date_favorited' here\n                    image = image[\"image\"]\n                if not (files := image.get(\"files\")):\n                    image = self.api.image(image[\"id\"])\n                    files = image[\"files\"]\n\n                group_info = self.extract_media_info(image, \"file\", False)\n                group_info[\"user\"] = (self.extract_user_info(image)\n                                      if user is None else user)\n            except Exception as exc:\n                self.status |= 1\n                self.log.error(\"Failed to process image %s (%s: %s)\",\n                               image[\"id\"], exc.__class__.__name__, exc)\n                continue\n\n            group_info[\"type\"] = \"image\"\n            group_info[\"count\"] = len(files)\n            yield Message.Directory, \"\", group_info\n            for num, file in enumerate(files, 1):\n                file_info = self.extract_media_info(file, None)\n                file_id = file_info[\"file_id\"]\n                url = (f\"https://i.iwara.tv/image/original/\"\n                       f\"{file_id}/{file_id}.{file_info['extension']}\")\n                yield Message.Url, url, {**file_info, **group_info, \"num\": num}\n\n    def items_video(self, videos, user=None):\n        for video in videos:\n            try:\n                if \"video\" in video:\n                    video = video[\"video\"]\n                if \"fileUrl\" not in video:\n                    video = self.api.video(video[\"id\"])\n                file_url = video[\"fileUrl\"]\n\n                source = self.extract_video_source(self.api.source(file_url))\n                download_url = source[\"src\"].get(\"download\")\n\n                info = self.extract_media_info(video, \"file\")\n                info[\"format\"] = source.get(\"name\")\n                info[\"count\"] = info[\"num\"] = 1\n                info[\"user\"] = (self.extract_user_info(video)\n                                if user is None else user)\n            except Exception as exc:\n                self.status |= 1\n                self.log.traceback(exc)\n                self.log.error(\"Failed to process video %s (%s: %s)\",\n                               video[\"id\"], exc.__class__.__name__, exc)\n                continue\n\n            yield Message.Directory, \"\", info\n            yield Message.Url, \"https:\" + download_url, info\n\n    def items_user(self, users, key=None):\n        base = self.root + \"/profile/\"\n        for user in users:\n            if key is not None:\n                user = user[key]\n            if (username := user[\"username\"]) is None:\n                continue\n            user[\"type\"] = \"user\"\n            user[\"_extractor\"] = IwaraUserExtractor\n            yield Message.Queue, base + username, user\n\n    def items_by_type(self, type, results):\n        if type == \"image\":\n            return self.items_image(results)\n        if type == \"video\":\n            return self.items_video(results)\n        if type == \"user\":\n            return self.items_user(results)\n\n        raise self.exc.AbortExtraction(f\"Unsupported result type '{type}'\")\n\n    def extract_media_info(self, item, key, include_file_info=True):\n        info = {\n            \"id\"      : item[\"id\"],\n            \"slug\"    : item.get(\"slug\"),\n            \"rating\"  : item.get(\"rating\"),\n            \"likes\"   : item.get(\"numLikes\"),\n            \"views\"   : item.get(\"numViews\"),\n            \"comments\": item.get(\"numComments\"),\n            \"tags\"    : [t[\"id\"] for t in item.get(\"tags\") or ()],\n            \"title\"   : t.strip() if (t := item.get(\"title\")) else \"\",\n            \"description\": t.strip() if (t := item.get(\"body\")) else \"\",\n        }\n\n        if include_file_info:\n            file_info = item if key is None else item.get(key) or {}\n            filename, _, extension = file_info.get(\"name\", \"\").rpartition(\".\")\n\n            info[\"file_id\"] = file_info.get(\"id\")\n            info[\"filename\"] = filename\n            info[\"extension\"] = extension\n            info[\"date\"] = self.parse_datetime_iso(\n                file_info.get(\"createdAt\"))\n            info[\"date_updated\"] = self.parse_datetime_iso(\n                file_info.get(\"updatedAt\"))\n            info[\"mime\"] = file_info.get(\"mime\")\n            info[\"size\"] = file_info.get(\"size\")\n            info[\"width\"] = file_info.get(\"width\")\n            info[\"height\"] = file_info.get(\"height\")\n            info[\"duration\"] = file_info.get(\"duration\")\n            info[\"type\"] = file_info.get(\"type\")\n\n        return info\n\n    def extract_user_info(self, profile):\n        user = profile.get(\"user\") or {}\n        return {\n            \"id\"     : user.get(\"id\"),\n            \"name\"   : user.get(\"username\"),\n            \"nick\"   : user.get(\"name\").strip(),\n            \"status\" : user.get(\"status\"),\n            \"role\"   : user.get(\"role\"),\n            \"premium\": user.get(\"premium\"),\n            \"date\"   : self.parse_datetime_iso(user.get(\"createdAt\")),\n            \"description\": profile.get(\"body\"),\n        }\n\n    def extract_video_source(self, sources):\n        sources.sort(key=self._sort_formats, reverse=True)\n        return sources[0]\n\n    def extract_video_source_custom(self, sources):\n        fmts = {\n            name.lower(): source\n            for source in sources\n            if source.get(\"src\") and (name := source.get(\"name\"))\n        }\n\n        for fmt in self.formats:\n            if fmt in fmts:\n                return fmts[fmt]\n        self.log.warning(\"Requested format(s) not available\")\n\n    def _user_params(self):\n        _, user, qs = self.groups\n        params = text.parse_query(qs)\n        profile = self.cache(self.api.profile, user)\n        params[\"user\"] = profile[\"user\"][\"id\"]\n        return self.extract_user_info(profile), params\n\n    def _sort_formats(self, fmt):\n        return (0 if not fmt.get(\"src\") else\n                99999 if (name := fmt.get(\"name\")) == \"Source\" else\n                text.parse_int(name))\n\n\nclass IwaraUserExtractor(Dispatch, IwaraExtractor):\n    \"\"\"Extractor for iwara.tv profile pages\"\"\"\n    pattern = USER_PATTERN + r\"/?$\"\n    example = \"https://www.iwara.tv/profile/USERNAME\"\n\n    def items(self):\n        tld, user = self.groups\n        base = f\"{self.root[:-2]}{tld}/profile/{user}/\"\n        return self._dispatch_extractors((\n            (IwaraUserImagesExtractor   , base + \"images\"),\n            (IwaraUserVideosExtractor   , base + \"videos\"),\n            (IwaraUserPlaylistsExtractor, base + \"playlists\"),\n        ), (\"user-images\", \"user-videos\"))\n\n\nclass IwaraUserImagesExtractor(IwaraExtractor):\n    subcategory = \"user-images\"\n    pattern = USER_PATTERN + r\"/images(?:\\?([^#]+))?\"\n    example = \"https://www.iwara.tv/profile/USERNAME/images\"\n\n    def items(self):\n        user, params = self._user_params()\n        return self.items_image(self.api.images(params), user)\n\n\nclass IwaraUserVideosExtractor(IwaraExtractor):\n    subcategory = \"user-videos\"\n    pattern = USER_PATTERN + r\"/videos(?:\\?([^#]+))?\"\n    example = \"https://www.iwara.tv/profile/USERNAME/videos\"\n\n    def items(self):\n        user, params = self._user_params()\n        return self.items_video(self.api.videos(params), user)\n\n\nclass IwaraUserPlaylistsExtractor(IwaraExtractor):\n    subcategory = \"user-playlists\"\n    pattern = USER_PATTERN + r\"/playlists(?:\\?([^#]+))?\"\n    example = \"https://www.iwara.tv/profile/USERNAME/playlists\"\n\n    def items(self):\n        base = self.root + \"/playlist/\"\n\n        for playlist in self.api.playlists(self._user_params()[1]):\n            playlist[\"type\"] = \"playlist\"\n            playlist[\"_extractor\"] = IwaraPlaylistExtractor\n            url = base + playlist[\"id\"]\n            yield Message.Queue, url, playlist\n\n\nclass IwaraFollowingExtractor(IwaraExtractor):\n    subcategory = \"following\"\n    pattern = USER_PATTERN + r\"/following\"\n    example = \"https://www.iwara.tv/profile/USERNAME/following\"\n\n    def items(self):\n        uid = self.cache(self.api.profile, self.groups[1])[\"user\"][\"id\"]\n        return self.items_user(self.api.user_following(uid), \"user\")\n\n\nclass IwaraFollowersExtractor(IwaraExtractor):\n    subcategory = \"followers\"\n    pattern = USER_PATTERN + r\"/followers\"\n    example = \"https://www.iwara.tv/profile/USERNAME/followers\"\n\n    def items(self):\n        uid = self.cache(self.api.profile, self.groups[1])[\"user\"][\"id\"]\n        return self.items_user(self.api.user_followers(uid), \"follower\")\n\n\nclass IwaraImageExtractor(IwaraExtractor):\n    \"\"\"Extractor for individual iwara.tv image pages\"\"\"\n    subcategory = \"image\"\n    pattern = BASE_PATTERN + r\"/image/([^/?#]+)\"\n    example = \"https://www.iwara.tv/image/ID\"\n\n    def items(self):\n        return self.items_image((self.api.image(self.groups[1]),))\n\n\nclass IwaraVideoExtractor(IwaraExtractor):\n    \"\"\"Extractor for individual iwara.tv videos\"\"\"\n    subcategory = \"video\"\n    pattern = BASE_PATTERN + r\"/video/([^/?#]+)\"\n    example = \"https://www.iwara.tv/video/ID\"\n\n    def items(self):\n        return self.items_video((self.api.video(self.groups[1]),))\n\n\nclass IwaraPlaylistExtractor(IwaraExtractor):\n    \"\"\"Extractor for individual iwara.tv playlist pages\"\"\"\n    subcategory = \"playlist\"\n    pattern = BASE_PATTERN + r\"/playlist/([^/?#]+)\"\n    example = \"https://www.iwara.tv/playlist/ID\"\n\n    def items(self):\n        return self.items_video(self.api.playlist(self.groups[1]))\n\n\nclass IwaraFavoriteExtractor(IwaraExtractor):\n    subcategory = \"favorite\"\n    pattern = BASE_PATTERN + r\"/favorites(?:/(image|video)s)?\"\n    example = \"https://www.iwara.tv/favorites/videos\"\n\n    def items(self):\n        type = self.groups[1] or \"vidoo\"\n        return self.items_by_type(type, self.api.favorites(type))\n\n\nclass IwaraSearchExtractor(IwaraExtractor):\n    \"\"\"Extractor for iwara.tv search pages\"\"\"\n    subcategory = \"search\"\n    pattern = BASE_PATTERN + r\"/search\\?([^#]+)\"\n    example = \"https://www.iwara.tv/search?query=QUERY&type=TYPE\"\n\n    def items(self):\n        params = text.parse_query(self.groups[1])\n        type = params.get(\"type\") or \"videos\"\n        if type[-1] != \"s\":\n            type += \"s\"\n        self.kwdict[\"search_tags\"] = query = params.get(\"query\")\n        return self.items_by_type(type[:-1], self.api.search(type, query))\n\n\nclass IwaraTagExtractor(IwaraExtractor):\n    \"\"\"Extractor for iwara.tv tag search\"\"\"\n    subcategory = \"tag\"\n    pattern = BASE_PATTERN + r\"/(image|video)s(?:\\?([^#]+))?\"\n    example = \"https://www.iwara.tv/videos?tags=TAGS\"\n\n    def items(self):\n        _, type, qs = self.groups\n        params = text.parse_query(qs)\n        self.kwdict[\"search_tags\"] = params.get(\"tags\")\n        return self.items_by_type(type, self.api.media(type, params))\n\n\nclass IwaraAPI():\n    \"\"\"Interface for the Iwara API\"\"\"\n    root = \"https://apiq.iwara.tv\"\n\n    def __init__(self, extractor):\n        self.extractor = extractor\n        self.exc = extractor.exc\n        self.headers = {\n            \"Referer\"     : extractor.root + \"/\",\n            \"Content-Type\": \"application/json\",\n            \"Origin\"      : extractor.root,\n            \"X-Site\"      : extractor.root[8:],\n        }\n\n        self.username, self.password = extractor._get_auth_info()\n        if not self.username:\n            self.authenticate = util.noop\n\n    def image(self, image_id):\n        endpoint = \"/image/\" + image_id\n        return self._call(endpoint)\n\n    def video(self, video_id):\n        endpoint = \"/video/\" + video_id\n        return self._call(endpoint)\n\n    def playlist(self, playlist_id):\n        endpoint = \"/playlist/\" + playlist_id\n        return self._pagination(endpoint)\n\n    def detail(self, media):\n        endpoint = f\"/{media['type']}/{media['id']}\"\n        return self._call(endpoint)\n\n    def images(self, params):\n        endpoint = \"/images\"\n        params.setdefault(\"rating\", \"all\")\n        return self._pagination(endpoint, params)\n\n    def videos(self, params):\n        endpoint = \"/videos\"\n        params.setdefault(\"rating\", \"all\")\n        return self._pagination(endpoint, params)\n\n    def playlists(self, params):\n        endpoint = \"/playlists\"\n        return self._pagination(endpoint, params)\n\n    def media(self, type, params):\n        endpoint = f\"/{type}s\"\n        params.setdefault(\"rating\", \"all\")\n        return self._pagination(endpoint, params)\n\n    def favorites(self, type):\n        if not self.username:\n            raise self.exc.AuthRequired(\n                \"username & password\", \"your favorites\")\n        endpoint = f\"/favorites/{type}s\"\n        return self._pagination(endpoint)\n\n    def search(self, type, query):\n        endpoint = \"/search\"\n        params = {\"type\": type, \"query\": query}\n        return self._pagination(endpoint, params)\n\n    def profile(self, username):\n        endpoint = \"/profile/\" + username\n        return self._call(endpoint)\n\n    def user_following(self, user_id):\n        endpoint = f\"/user/{user_id}/following\"\n        return self._pagination(endpoint)\n\n    def user_followers(self, user_id):\n        endpoint = f\"/user/{user_id}/followers\"\n        return self._pagination(endpoint)\n\n    def source(self, file_url):\n        base, _, query = file_url.partition(\"?\")\n        if not (expires := text.extr(query, \"expires=\", \"&\")):\n            return ()\n        file_id = base.rpartition(\"/\")[2]\n        sha_postfix = \"mSvL05GfEmeEmsEYfGCnVpEjYgTJraJN\"\n        sha_key = f\"{file_id}_{expires}_{sha_postfix}\"\n        hash = hashlib.sha1(sha_key.encode()).hexdigest()\n        headers = {\"X-Version\": hash, **self.headers}\n        return self.extractor.request_json(file_url, headers=headers)\n\n    def authenticate(self):\n        self.headers[\"Authorization\"] = self.extractor.cache(\n            self._authenticate_impl, self.username, _exp=3600, _mem=False)\n\n    def _authenticate_impl(self, username):\n        refresh_token = self.extractor.cache(\n            _refresh_token_cache, username, _exp=28*86400, _mem=False)\n        if refresh_token is None:\n            self.extractor.log.info(\"Logging in as %s\", username)\n\n            url = self.root + \"/user/login\"\n            json = {\n                \"email\"   : username,\n                \"password\": self.password\n            }\n            data = self.extractor.request_json(\n                url, method=\"POST\", headers=self.headers, json=json,\n                fatal=False)\n\n            if not (refresh_token := data.get(\"token\")):\n                self.extractor.log.debug(data)\n                raise self.exc.AuthenticationError(data.get(\"message\"))\n            self.extractor.cache_update(\n                _refresh_token_cache, username, refresh_token, _exp=28*86400)\n\n        self.extractor.log.info(\"Refreshing access token for %s\", username)\n\n        url = self.root + \"/user/token\"\n        headers = {\"Authorization\": \"Bearer \" + refresh_token, **self.headers}\n        data = self.extractor.request_json(\n            url, method=\"POST\", headers=headers, fatal=False)\n\n        if not (access_token := data.get(\"accessToken\")):\n            self.extractor.log.debug(data)\n            raise self.exc.AuthenticationError(data.get(\"message\"))\n        return \"Bearer \" + access_token\n\n    def _call(self, endpoint, params=None, headers=None):\n        if headers is None:\n            headers = self.headers\n\n        url = self.root + endpoint\n        self.authenticate()\n        return self.extractor.request_json(url, params=params, headers=headers)\n\n    def _pagination(self, endpoint, params=None):\n        if params is None:\n            params = {}\n        params[\"page\"] = 0\n        params[\"limit\"] = 50\n\n        while True:\n            data = self._call(endpoint, params)\n\n            if not (results := data.get(\"results\")):\n                break\n            yield from results\n\n            if len(results) < params[\"limit\"]:\n                break\n            params[\"page\"] += 1\n\n\ndef _refresh_token_cache(username):\n    return None\n"
  },
  {
    "path": "gallery_dl/extractor/joyreactor.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2026 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://joyreactor.com/\"\"\"\n\nfrom .common import Extractor, Message\nfrom .. import text\nimport binascii\n\nBASE_PATTERN = r\"(?:https?://)?joyreactor\\.c(om|c)\"\n\n\nclass JoyreactorExtractor(Extractor):\n    \"\"\"Base class for joyreactor extractors\"\"\"\n    basecategory = \"reactor\"\n    category = \"joyreactor\"\n    root = \"https://joyreactor.com\"\n    filename_fmt = (\"{post_id}_{num:>02}_{file_id}\"\n                    \"{title:?_//[b:200]}.{extension}\")\n    archive_fmt = \"{post_id}_{num}\"\n    request_interval = (3.0, 6.0)\n\n    def __init__(self, match):\n        self.root = \"https://joyreactor.c\" + match[1]\n        Extractor.__init__(self, match)\n\n    def _init(self):\n        self.embeds = self.config(\"embeds\", False)\n        self.metadata = self.config(\"metadata\", False)\n        self.videos = self.config(\"videos\", True)\n        self.videos_formats = self.config(\"format\")\n\n        if isinstance(self.videos_formats, str):\n            self.videos_formats = self.videos_formats.split(\",\")\n\n    def items(self):\n        headers = {\"Referer\": self.root + \"/\"}\n        if not self.metadata:\n            dfmt = (\"%d.%m.%y, %H:%M\" if self.groups[0] == \"c\" else\n                    \"%d/%m/%y, %H:%M\")\n\n        for html, data in self.posts():\n            files = self._extract_files(html)\n\n            extr = text.extract_from(html)\n            post = {\n                \"count\"  : len(files),\n                \"user\"   : text.unescape(extr(' alt=\"', '\"')),\n                \"user_id\": extr(\"/avatar/user/\", '\"'),\n                \"tags\"   : text.split_html(extr(\n                    'class=\"post-tags', '</div>'))[1:],\n                \"content\": extr(\n                    'class=\"post-content\">', '<div class=\"post-footer\">'),\n                \"date\"   : extr(\"</span></button>\", \"</div>\"),\n                \"post_id\": extr('\"/post/', '\"'),\n                \"_http_headers\": headers,\n            }\n\n            if data is None:\n                post[\"date\"] = self.parse_datetime(\n                    post[\"date\"].rpartition(\">\")[2], dfmt)\n            else:\n                post[\"date\"] = self.parse_datetime_iso(data.get(\"createdAt\"))\n                post[\"nsfw\"] = data.get(\"nsfw\")\n                post[\"unsafe\"] = data.get(\"unsafe\")\n                post[\"banned\"] = data.get(\"banned\")\n                post[\"comments\"] = data.get(\"commentsCount\")\n                post[\"rating\"] = data.get(\"rating\")\n                if seo := data.get(\"seoAttributes\"):\n                    post[\"title\"] = seo.get(\"title\")\n\n            yield Message.Directory, \"\", post\n            for post[\"num\"], file in enumerate(files, 1):\n                url = file[\"file_url\"]\n                text.nameext_from_url(url, post)\n                post.update(file)\n                post[\"file_id\"] = post[\"filename\"].rpartition(\"-\")[2]\n                yield Message.Url, url, post\n\n    def _extract_files(self, html):\n        files = []\n        last = \"\"\n\n        for url in text.extract_iter(html, 'src=\"', '\"'):\n            if \"/avatar/\" in url or last == url:\n                continue\n            last = url\n\n            if \".joyreactor.c\" not in url:\n                if not self.embeds:\n                    continue\n                files.append({\"file_url\": \"ytdl:\"+url, \"type\": \"embed\"})\n            elif \"/webm/\" in url:\n                if not self.videos:\n                    continue\n                if self.videos_formats is None:\n                    files.append({\n                        \"file_url\": url,\n                        \"type\"    : \"video\",\n                        \"format\"  : \"webm\",\n                    })\n                else:\n                    for fmt in self.videos_formats:\n                        lhs, _, rhs = url.rpartition(\"/webm/\")\n                        files.append({\n                            \"file_url\": f\"{lhs}/{fmt}/\"\n                                        f\"{rhs[:rhs.find('.')]}.{fmt}\",\n                            \"type\"    : \"video\",\n                            \"format\"  : fmt,\n                        })\n            else:\n                lhs, _, rhs = url.rpartition(\"/\")\n                url = f\"{lhs}/full/{rhs}\"\n                files.append({\"file_url\": url, \"type\": \"image\"})\n\n        return files\n\n    def _request_graphql(self, opname, variables):\n        url = \"https://api.joyreactor.com/graphql\"\n        headers = {\n            \"Referer\": self.root + \"/\",\n            \"Origin\" : self.root,\n        }\n        data = {\n            \"variables\": variables,\n            \"query\"    : self.utils(\"graphql\", opname),\n        }\n        return self.request_json(\n            url, method=\"POST\", headers=headers, json=data,\n            interval=False)[\"data\"]\n\n    def _pagination(self, url, opname, variables):\n        data = path = None\n\n        while True:\n            html = self.request(url).text\n            html_posts = html.split('class=\"content\"')\n            del html_posts[0]\n\n            if path is None and ('class=\"expand-wrapper relative\">'\n                                 '<div class=\"absolute' in html_posts[0]):\n                del html_posts[0]\n\n            pgn = text.extr(html, 'ant-pagination-next', \"</li>\")\n            path = text.extr(pgn, 'href=\"', '\"')\n\n            if self.metadata:\n                variables[\"page\"] = (text.parse_int(path[path.rfind(\"/\")+1:])+1\n                                     if path else None)\n                data = self._request_graphql(opname, variables)\n                data_posts = data.popitem()[1][\"postPager\"][\"posts\"]\n                yield from zip(html_posts, data_posts)\n            else:\n                for post in html_posts:\n                    yield post, None\n\n            if not path or not path.endswith(\"/1/rev\"):\n                break\n            url = self.root + path\n\n\nclass JoyreactorPostExtractor(JoyreactorExtractor):\n    \"\"\"Extractor for single joyreactor posts\"\"\"\n    subcategory = \"post\"\n    pattern = BASE_PATTERN + r\"/post/(\\d+)\"\n    example = \"http://joyreactor.com/post/12345\"\n\n    def posts(self):\n        pid = self.groups[1]\n        url = f\"{self.root}/post/{pid}\"\n        page = self.request(url).text\n        html = text.extr(page, 'class=\"content\"', 'class=\"comment\"')\n\n        if self.metadata:\n            data = self._request_graphql(\"IdPostPageQuery\", {\n                \"id\": binascii.b2a_base64(b\"Post:\" + bytes(str(pid), \"ascii\"),\n                                          newline=False).decode(),\n                \"isAuthorised\": False,\n            })[\"node\"]\n        else:\n            data = None\n\n        return ((html, data),)\n\n\nclass JoyreactorTagExtractor(JoyreactorExtractor):\n    \"\"\"Extractor for joyreactor tag searches\"\"\"\n    subcategory = \"tag\"\n    directory_fmt = (\"{category}\", \"{search_tags}\")\n    archive_fmt = \"{search_tags}_{post_id}_{num}\"\n    pattern = BASE_PATTERN + r\"(/tag/([^/?#]+)(?:/[^/?#]+)?)\"\n    example = \"http://joyreactor.com/tag/TAG\"\n\n    def posts(self):\n        _, path, tag = self.groups\n        self.kwdict[\"search_tags\"] = tag = text.unquote(tag)\n\n        variables = {\n            \"name\"        : tag,\n            \"page\"        : None,\n            \"lineType\"    : \"GOOD\",\n            \"isAuthorised\": False,\n            \"isHomepage\"  : False,\n        }\n        return self._pagination(\n            self.root + path, \"TagPageQuery\", variables)\n\n\nclass JoyreactorUserExtractor(JoyreactorExtractor):\n    \"\"\"Extractor for posts of a joyreactor user\"\"\"\n    subcategory = \"user\"\n    directory_fmt = (\"{category}\", \"user\", \"{user}\")\n    pattern = BASE_PATTERN + r\"(/user/([^/?#]+)(?:/[^/?#]+)?)\"\n    example = \"http://joyreactor.com/user/USER\"\n\n    def posts(self):\n        _, path, user = self.groups\n\n        variables = {\n            \"username\"    : text.unquote(user),\n            \"page\"        : None,\n            \"lineType\"    : \"GOOD\",\n            \"isAuthorised\": False,\n            \"isHomepage\"  : False,\n        }\n        return self._pagination(\n            self.root + path, \"UserProfilePageQuery\", variables)\n\n\nclass JoyreactorSearchExtractor(JoyreactorExtractor):\n    \"\"\"Extractor for joyreactor search results\"\"\"\n    subcategory = \"search\"\n    directory_fmt = (\"{category}\", \"{search_tags}\")\n    archive_fmt = \"{search_tags}_{post_id}_{num}\"\n    pattern = BASE_PATTERN + r\"(/search/([^/?#]+)(?:/[^/?#]+)?)\"\n    example = \"http://joyreactor.com/search/TAG\"\n\n    def posts(self):\n        _, path, query = self.groups\n        self.kwdict[\"search_tags\"] = query = text.unquote(query)\n\n        variables = {\n            \"query\"       : query,\n            \"page\"        : None,\n            \"tagNames\"    : (),\n            \"username\"    : None,\n            \"isAuthorised\": False,\n            \"showUnsafe\"  : None,\n            \"showNsfw\"    : True,\n            \"showOnlyNsfw\": False,\n            \"minRating\"   : None,\n            \"maxRating\"   : None,\n            \"sortByDate\"  : False,\n            \"sortByRating\": False,\n        }\n\n        return self._pagination(\n            self.root + path, \"SearchPageQuery\", variables)\n"
  },
  {
    "path": "gallery_dl/extractor/jschan.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for jschan Imageboards\"\"\"\n\nfrom .common import BaseExtractor, Message\nfrom .. import text\nimport itertools\n\n\nclass JschanExtractor(BaseExtractor):\n    basecategory = \"jschan\"\n\n\nBASE_PATTERN = JschanExtractor.update({\n    \"94chan\": {\n        \"root\": \"https://94chan.org\",\n        \"pattern\": r\"94chan\\.org\"\n    }\n})\n\n\nclass JschanThreadExtractor(JschanExtractor):\n    \"\"\"Extractor for jschan threads\"\"\"\n    subcategory = \"thread\"\n    directory_fmt = (\"{category}\", \"{board}\",\n                     \"{threadId} {subject|nomarkup[:50]}\")\n    filename_fmt = \"{postId}{num:?-//} {filename}.{extension}\"\n    archive_fmt = \"{board}_{postId}_{num}\"\n    pattern = BASE_PATTERN + r\"/([^/?#]+)/thread/(\\d+)\\.html\"\n    example = \"https://94chan.org/a/thread/12345.html\"\n\n    def items(self):\n        url = f\"{self.root}/{self.groups[-2]}/thread/{self.groups[-1]}.json\"\n        thread = self.request_json(url)\n        thread[\"threadId\"] = thread[\"postId\"]\n        posts = thread.pop(\"replies\", ())\n\n        yield Message.Directory, \"\", thread\n        for post in itertools.chain((thread,), posts):\n            if files := post.pop(\"files\", ()):\n                thread.update(post)\n                thread[\"count\"] = len(files)\n                for num, file in enumerate(files):\n                    url = f\"{self.root}/file/{file['filename']}\"\n                    file.update(thread)\n                    file[\"num\"] = num\n                    file[\"siteFilename\"] = file[\"filename\"]\n                    text.nameext_from_url(file[\"originalFilename\"], file)\n                    yield Message.Url, url, file\n\n\nclass JschanBoardExtractor(JschanExtractor):\n    \"\"\"Extractor for jschan boards\"\"\"\n    subcategory = \"board\"\n    pattern = (BASE_PATTERN + r\"/([^/?#]+)\"\n               r\"(?:/index\\.html|/catalog\\.html|/\\d+\\.html|/?$)\")\n    example = \"https://94chan.org/a/\"\n\n    def items(self):\n        board = self.groups[-1]\n        url = f\"{self.root}/{board}/catalog.json\"\n        for thread in self.request_json(url):\n            url = f\"{self.root}/{board}/thread/{thread['postId']}.html\"\n            thread[\"_extractor\"] = JschanThreadExtractor\n            yield Message.Queue, url, thread\n"
  },
  {
    "path": "gallery_dl/extractor/kabeuchi.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2020-2025 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://kabe-uchiroom.com/\"\"\"\n\nfrom .common import Extractor, Message\nfrom .. import text\n\n\nclass KabeuchiUserExtractor(Extractor):\n    \"\"\"Extractor for all posts of a user on kabe-uchiroom.com\"\"\"\n    category = \"kabeuchi\"\n    subcategory = \"user\"\n    directory_fmt = (\"{category}\", \"{twitter_user_id} {twitter_id}\")\n    filename_fmt = \"{id}_{num:>02}{title:?_//}.{extension}\"\n    archive_fmt = \"{id}_{num}\"\n    root = \"https://kabe-uchiroom.com\"\n    pattern = r\"(?:https?://)?kabe-uchiroom\\.com/mypage/?\\?id=(\\d+)\"\n    example = \"https://kabe-uchiroom.com/mypage/?id=12345\"\n\n    def items(self):\n        uid = self.groups[0]\n        base = f\"{self.root}/accounts/upfile/{uid[-1]}/{uid}/\"\n        keys = (\"image1\", \"image2\", \"image3\", \"image4\", \"image5\", \"image6\")\n\n        for post in self.posts(uid):\n            if post.get(\"is_ad\") or not post[\"image1\"]:\n                continue\n\n            post[\"date\"] = self.parse_datetime_iso(post[\"created_at\"])\n            yield Message.Directory, \"\", post\n\n            for key in keys:\n                name = post[key]\n                if not name:\n                    break\n                url = base + name\n                post[\"num\"] = ord(key[-1]) - 48\n                yield Message.Url, url, text.nameext_from_url(name, post)\n\n    def posts(self, uid):\n        url = f\"{self.root}/mypage/?id={uid}\"\n        response = self.request(url)\n        if response.history and response.url == self.root + \"/\":\n            raise self.exc.NotFoundError(\"user\")\n        target_id = text.extr(response.text, 'user_friend_id = \"', '\"')\n        return self._pagination(target_id)\n\n    def _pagination(self, target_id):\n        url = self.root + \"/get_posts.php\"\n        data = {\n            \"user_id\"    : \"0\",\n            \"target_id\"  : target_id,\n            \"type\"       : \"uploads\",\n            \"sort_type\"  : \"0\",\n            \"category_id\": \"all\",\n            \"latest_post\": \"\",\n            \"page_num\"   : 0,\n        }\n\n        while True:\n            info = self.request_json(url, method=\"POST\", data=data)\n            datas = info[\"datas\"]\n\n            if not datas or not isinstance(datas, list):\n                return\n            yield from datas\n\n            last_id = datas[-1][\"id\"]\n            if last_id == info[\"last_data\"]:\n                return\n            data[\"latest_post\"] = last_id\n            data[\"page_num\"] += 1\n"
  },
  {
    "path": "gallery_dl/extractor/kaliscan.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://kaliscan.me/\"\"\"\n\nfrom .common import ChapterExtractor, MangaExtractor\nfrom .. import text\n\nBASE_PATTERN = r\"(?:https?://)?kaliscan\\.me\"\n\n\nclass KaliscanBase():\n    \"\"\"Base class for kaliscan extractors\"\"\"\n    category = \"kaliscan\"\n    root = \"https://kaliscan.me\"\n\n    def manga_data(self, manga_slug, page=None):\n        if page is None:\n            url = f\"{self.root}/manga/{manga_slug}\"\n            page = self.request(url).text\n        extr = text.extract_from(page)\n\n        manga_id = text.parse_int(extr(\"bookId =\", \";\"))\n        title = text.unescape(extr(\"<h1>\", \"<\"))\n        if alt_titles := extr(\"<h2>\", \"<\"):\n            alt_titles = [t.strip() for t in alt_titles.split(\",\")]\n        else:\n            alt_titles = ()\n\n        author = text.remove_html(extr(\n            \"Authors :</strong>\", \"</p>\"))\n        status = text.remove_html(extr(\n            \"Status :</strong>\", \"</p>\"))\n        genres = [g.strip(\" ,\") for g in text.split_html(extr(\n            \"Genres :</strong>\", \"</p>\"))]\n\n        if descr := extr('class=\"content\"', '<div class=\"readmore\"'):\n            descr = text.remove_html(descr[descr.find(\">\")+1:]).strip()\n        else:\n            descr = \"\"\n\n        return {\n            \"manga\"       : title,\n            \"manga_id\"    : manga_id,\n            \"manga_slug\"  : manga_slug,\n            \"manga_titles\": alt_titles,\n            \"author\"      : author,\n            \"status\"      : status,\n            \"genres\"      : genres,\n            \"description\" : descr,\n            \"lang\"        : \"en\",\n            \"language\"    : \"English\",\n        }\n\n\nclass KaliscanChapterExtractor(KaliscanBase, ChapterExtractor):\n    \"\"\"Extractor for kaliscan manga chapters\"\"\"\n    pattern = BASE_PATTERN + r\"(/manga/([\\w-]+)/chapter-([\\d.]+))\"\n    example = \"https://kaliscan.me/manga/ID-MANGA/chapter-1\"\n\n    def metadata(self, page):\n        extr = text.extract_from(page)\n\n        manga_id = text.parse_int(extr(\"bookId =\", \";\"))\n        extr(\"bookSlug =\", \";\")\n        chapter_id = text.parse_int(extr(\"chapterId =\", \";\"))\n        extr(\"chapterSlug =\", \";\")\n        chapter_number = extr(\"chapterNumber =\", \";\").strip(' \"\\'')\n\n        chapter, sep, minor = chapter_number.partition(\".\")\n\n        data = {\n            **self.cache(self.manga_data, self.groups[1]),\n            \"chapter\"      : text.parse_int(chapter),\n            \"chapter_minor\": sep + minor,\n            \"chapter_id\"   : chapter_id,\n        }\n        if manga_id and not data[\"manga_id\"]:\n            data[\"manga_id\"] = manga_id\n        return data\n\n    def images(self, page):\n        images_str = text.extr(page, 'var chapImages = \"', '\"')\n        if not images_str:\n            return ()\n        return [\n            (url, None)\n            for url in (u.strip() for u in images_str.split(\",\"))\n            if url\n        ]\n\n\nclass KaliscanMangaExtractor(KaliscanBase, MangaExtractor):\n    \"\"\"Extractor for kaliscan manga\"\"\"\n    chapterclass = KaliscanChapterExtractor\n    pattern = BASE_PATTERN + r\"(/manga/([\\w-]+))/?$\"\n    example = \"https://kaliscan.me/manga/ID-MANGA\"\n\n    def chapters(self, page):\n        data = self.cache(self.manga_data, self.groups[1], page)\n\n        chapter_list = text.extr(page, 'id=\"chapter-list\">', '</ul>')\n        if not chapter_list:\n            return ()\n\n        results = []\n        for li in text.extract_iter(chapter_list, \"<li\", \"</li>\"):\n            url = text.extr(li, 'href=\"', '\"')\n            if not url:\n                continue\n            if url[0] == \"/\":\n                url = self.root + url\n\n            chapter, sep, minor = url.rpartition(\n                \"/chapter-\")[2].partition(\".\")\n\n            results.append((url, {\n                \"chapter\"      : text.parse_int(chapter),\n                \"chapter_minor\": sep + minor,\n                **data,\n            }))\n        return results\n"
  },
  {
    "path": "gallery_dl/extractor/keenspot.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2019-2025 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for http://www.keenspot.com/\"\"\"\n\nfrom .common import Extractor, Message\nfrom .. import text\n\n\nclass KeenspotComicExtractor(Extractor):\n    \"\"\"Extractor for webcomics from keenspot.com\"\"\"\n    category = \"keenspot\"\n    subcategory = \"comic\"\n    directory_fmt = (\"{category}\", \"{comic}\")\n    filename_fmt = \"{filename}.{extension}\"\n    archive_fmt = \"{comic}_{filename}\"\n    pattern = r\"(?:https?://)?(?!www\\.|forums\\.)([\\w-]+)\\.keenspot\\.com(/.+)?\"\n    example = \"http://COMIC.keenspot.com/\"\n\n    def __init__(self, match):\n        Extractor.__init__(self, match)\n        self.comic = match[1].lower()\n        self.path = match[2]\n        self.root = \"http://\" + self.comic + \".keenspot.com\"\n\n        self._needle = \"\"\n        self._image = 'class=\"ksc\"'\n        self._next = self._next_needle\n\n    def items(self):\n        data = {\"comic\": self.comic}\n        yield Message.Directory, \"\", data\n\n        with self.request(self.root + \"/\") as response:\n            if response.history:\n                url = response.request.url\n                self.root = url[:url.index(\"/\", 8)]\n            page = response.text\n            del response\n\n        url = self._first(page)\n        if self.path:\n            url = self.root + self.path\n\n        prev = None\n        ilen = len(self._image)\n        while url and url != prev:\n            prev = url\n            page = self.request(text.urljoin(self.root, url)).text\n\n            pos = 0\n            while True:\n                pos = page.find(self._image, pos)\n                if pos < 0:\n                    break\n                img, pos = text.extract(page, 'src=\"', '\"', pos + ilen)\n                if img.endswith(\".js\"):\n                    continue\n                if img[0] == \"/\":\n                    img = self.root + img\n                elif \"youtube.com/\" in img:\n                    img = \"ytdl:\" + img\n                yield Message.Url, img, text.nameext_from_url(img, data)\n\n            url = self._next(page)\n\n    def _first(self, page):\n        if self.comic == \"brawlinthefamily\":\n            self._next = self._next_brawl\n            self._image = '<div id=\"comic\">'\n            return \"http://brawlinthefamily.keenspot.com/comic/theshowdown/\"\n\n        if url := text.extr(page, '<link rel=\"first\" href=\"', '\"'):\n            if self.comic == \"porcelain\":\n                self._needle = 'id=\"porArchivetop_\"'\n            else:\n                self._next = self._next_link\n            return url\n\n        pos = page.find('id=\"first_day1\"')\n        if pos >= 0:\n            self._next = self._next_id\n            return text.rextr(page, 'href=\"', '\"', pos)\n\n        pos = page.find('>FIRST PAGE<')\n        if pos >= 0:\n            if self.comic == \"lastblood\":\n                self._next = self._next_lastblood\n                self._image = '<div id=\"comic\">'\n            else:\n                self._next = self._next_id\n            return text.rextr(page, 'href=\"', '\"', pos)\n\n        pos = page.find('<div id=\"kscomicpart\"')\n        if pos >= 0:\n            self._needle = '<a href=\"/archive.html'\n            return text.extract(page, 'href=\"', '\"', pos)[0]\n\n        pos = page.find('>First Comic<')  # twokinds\n        if pos >= 0:\n            self._image = '</header>'\n            self._needle = 'class=\"navarchive\"'\n            return text.rextr(page, 'href=\"', '\"', pos)\n\n        pos = page.find('id=\"flip_FirstDay\"')  # flipside\n        if pos >= 0:\n            self._image = 'class=\"flip_Pages ksc\"'\n            self._needle = 'id=\"flip_ArcButton\"'\n            return text.rextr(page, 'href=\"', '\"', pos)\n\n        self.log.error(\"Unrecognized page layout\")\n        return None\n\n    def _next_needle(self, page):\n        pos = page.index(self._needle) + len(self._needle)\n        return text.extract(page, 'href=\"', '\"', pos)[0]\n\n    def _next_link(self, page):\n        return text.extr(page, '<link rel=\"next\" href=\"', '\"')\n\n    def _next_id(self, page):\n        pos = page.find('id=\"next_')\n        return text.rextr(page, 'href=\"', '\"', pos) if pos >= 0 else None\n\n    def _next_lastblood(self, page):\n        pos = page.index(\"link rel='next'\")\n        return text.extract(page, \"href='\", \"'\", pos)[0]\n\n    def _next_brawl(self, page):\n        pos = page.index(\"comic-nav-next\")\n        url = text.rextr(page, 'href=\"', '\"', pos)\n        return None if \"?random\" in url else url\n"
  },
  {
    "path": "gallery_dl/extractor/kemono.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2021-2026 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://kemono.cr/\"\"\"\n\nfrom .common import Extractor, Message\nfrom .. import text, util\nimport itertools\nimport json\n\nBASE_PATTERN = (r\"(?:https?://)?(?:www\\.|beta\\.)?\"\n                r\"(kemono|coomer)\\.(cr|s[tu]|party)\")\nUSER_PATTERN = BASE_PATTERN + r\"/([^/?#]+)/user/([^/?#]+)\"\nHASH_PATTERN = r\"/[0-9a-f]{2}/[0-9a-f]{2}/([0-9a-f]{64})\"\n\n\nclass KemonoExtractor(Extractor):\n    \"\"\"Base class for kemono extractors\"\"\"\n    category = \"kemono\"\n    root = \"https://kemono.cr\"\n    directory_fmt = (\"{category}\", \"{service}\", \"{user}\")\n    filename_fmt = \"{id}_{title[:180]}_{num:>02}_{filename[:180]}.{extension}\"\n    archive_fmt = \"{service}_{user}_{id}_{num}\"\n    cookies_domain = \".kemono.cr\"\n\n    def __init__(self, match):\n        if match[1] == \"coomer\":\n            self.category = \"coomer\"\n            self.root = \"https://coomer.st\"\n            self.cookies_domain = \".coomer.st\"\n        Extractor.__init__(self, match)\n\n    def _init(self):\n        self.api = KemonoAPI(self)\n        self.revisions = self.config(\"revisions\")\n        if self.revisions:\n            self.revisions_unique = (self.revisions == \"unique\")\n        order = self.config(\"order-revisions\")\n        self.revisions_reverse = order[0] in {\"r\", \"a\"} if order else False\n\n        self._find_inline = text.re(\n            r'src=\"(?:https?://(?:kemono\\.cr|coomer\\.st))?(/inline/[^\"]+'\n            r'|/[0-9a-f]{2}/[0-9a-f]{2}/[0-9a-f]{64}\\.[^\"]+)').findall\n        self._json_dumps = json.JSONEncoder(\n            ensure_ascii=False, check_circular=False,\n            sort_keys=True, separators=(\",\", \":\")).encode\n\n    def items(self):\n        find_hash = text.re(HASH_PATTERN).match\n        generators = self._build_file_generators(self.config(\"files\"))\n        announcements = True if self.config(\"announcements\") else None\n        archives = True if self.config(\"archives\") else False\n        archives_type = dict if self.config(\"archives-format\") in {\n            \"dict\", \"object\"} else list\n        comments = True if self.config(\"comments\") else False\n        dms = True if self.config(\"dms\") else None\n        max_posts = self.config(\"max-posts\")\n        creator_info = {} if self.config(\"metadata\", True) else None\n        exts_archive = util.EXTS_ARCHIVE\n\n        if duplicates := self.config(\"duplicates\"):\n            if isinstance(duplicates, str):\n                duplicates = set(duplicates.split(\",\"))\n            elif isinstance(duplicates, (list, tuple)):\n                duplicates = set(duplicates)\n            else:\n                duplicates = {\"file\", \"attachment\", \"inline\"}\n        else:\n            duplicates = ()\n\n        # prevent files from being sent with gzip compression\n        headers = {\"Accept-Encoding\": \"identity\"}\n\n        posts = self.posts()\n        if max_posts:\n            posts = itertools.islice(posts, max_posts)\n        if self.revisions:\n            posts = self._revisions(posts)\n\n        for post in posts:\n            if \"post\" in post:\n                post = post[\"post\"]\n            headers[\"Referer\"] = (f\"{self.root}/{post['service']}/user/\"\n                                  f\"{post['user']}/post/{post['id']}\")\n            post[\"_http_headers\"] = headers\n            post[\"date\"] = self._parse_datetime(\n                post.get(\"published\") or post.get(\"added\") or \"\")\n            service = post[\"service\"]\n            creator_id = post[\"user\"]\n\n            if creator_info is not None:\n                key = f\"{service}_{creator_id}\"\n                if key not in creator_info:\n                    try:\n                        creator = creator_info[key] = self.api.creator_profile(\n                            service, creator_id)\n                    except self.exc.HttpError:\n                        self.log.warning(\"%s/%s/%s: 'Creator not found'\",\n                                         service, creator_id, post[\"id\"])\n                        creator = creator_info[key] = util.NONE\n                else:\n                    creator = creator_info[key]\n\n                post[\"user_profile\"] = creator\n                post[\"username\"] = creator[\"name\"]\n\n            if comments:\n                post[\"comments\"] = cmts = self.api.creator_post_comments(\n                    service, creator_id, post[\"id\"])\n                if not isinstance(cmts, list):\n                    self.log.debug(\"%s/%s: %s\", creator_id, post[\"id\"], cmts)\n                    post[\"comments\"] = ()\n            if dms is not None:\n                if dms is True:\n                    dms = self.api.creator_dms(\n                        post[\"service\"], post[\"user\"])\n                post[\"dms\"] = dms\n            if announcements is not None:\n                if announcements is True:\n                    announcements = self.api.creator_announcements(\n                        post[\"service\"], post[\"user\"])\n                post[\"announcements\"] = announcements\n\n            files = []\n            hashes = set()\n            post_archives = post[\"archives\"] = archives_type()\n\n            for file in itertools.chain.from_iterable(\n                    g(post) for g in generators):\n                url = file[\"path\"]\n\n                if \"\\\\\" in url:\n                    file[\"path\"] = url = url.replace(\"\\\\\", \"/\")\n\n                if match := find_hash(url):\n                    file[\"hash\"] = hash = match[1]\n                    if file[\"type\"] not in duplicates and hash in hashes:\n                        self.log.debug(\"Skipping %s %s (duplicate)\",\n                                       file[\"type\"], url)\n                        continue\n                    hashes.add(hash)\n                else:\n                    file[\"hash\"] = hash = \"\"\n\n                if url[0] == \"/\":\n                    url = f\"{self.root}/data{url}\"\n                elif url.startswith(self.root):\n                    url = f\"{self.root}/data{url[20:]}\"\n                file[\"url\"] = url\n\n                if name := file.get(\"name\"):\n                    text.nameext_from_name(name, file)\n                    ext = text.ext_from_url(url)\n\n                    if not file[\"extension\"]:\n                        file[\"extension\"] = ext\n                    elif ext == \"txt\" and file[\"extension\"] != \"txt\":\n                        file[\"_http_validate\"] = _validate\n                else:\n                    text.nameext_from_url(url, file)\n                    ext = file[\"extension\"]\n\n                if ext in exts_archive or \\\n                        ext == \"bin\" and file[\"extension\"] in exts_archive:\n                    file[\"type\"] = \"archive\"\n                    if archives:\n                        try:\n                            archive = self.api.file(hash)\n                            archive.update(file)\n                        except Exception as exc:\n                            self.log.warning(\n                                \"%s: Failed to retrieve archive metadata of \"\n                                \"'%s' (%s: %s)\", post[\"id\"], file.get(\"name\"),\n                                exc.__class__.__name__, exc)\n                            archive = file.copy()\n                    else:\n                        archive = file.copy()\n                    if archives_type is dict:\n                        post_archives[hash] = archive\n                    else:\n                        post_archives.append(archive)\n\n                files.append(file)\n\n            post[\"count\"] = len(files)\n            yield Message.Directory, \"\", post\n            for post[\"num\"], file in enumerate(files, 1):\n                if \"id\" in file:\n                    del file[\"id\"]\n                post.update(file)\n                yield Message.Url, file[\"url\"], post\n\n    def login(self):\n        username, password = self._get_auth_info()\n        if username:\n            self.cookies_update(self.cache(\n                self._login_impl, (username, self.cookies_domain), password),\n                _exp=3650*86400, _mem=False)\n\n    def _login_impl(self, username, password):\n        username = username[0]\n        self.log.info(\"Logging in as %s\", username)\n\n        url = self.root + \"/api/v1/authentication/login\"\n        data = {\"username\": username, \"password\": password}\n\n        response = self.request(url, method=\"POST\", json=data, fatal=False)\n        if response.status_code >= 400:\n            try:\n                msg = f'\"{response.json()[\"error\"]}\"'\n            except Exception:\n                msg = '\"Username or password is incorrect\"'\n            raise self.exc.AuthenticationError(msg)\n\n        return {c.name: c.value for c in response.cookies}\n\n    def _file(self, post):\n        file = post[\"file\"]\n        if not file or \"path\" not in file:\n            return ()\n        file[\"type\"] = \"file\"\n        return (file,)\n\n    def _attachments(self, post):\n        for attachment in post[\"attachments\"]:\n            attachment[\"type\"] = \"attachment\"\n        return post[\"attachments\"]\n\n    def _inline(self, post):\n        for path in self._find_inline(post.get(\"content\") or \"\"):\n            yield {\"path\": path, \"name\": path, \"type\": \"inline\"}\n\n    def _build_file_generators(self, filetypes):\n        if filetypes is None:\n            if self.category == \"coomer\":\n                return (self._file, self._attachments, self._inline)\n            return (self._attachments, self._file, self._inline)\n        genmap = {\n            \"file\"       : self._file,\n            \"attachments\": self._attachments,\n            \"inline\"     : self._inline,\n        }\n        if isinstance(filetypes, str):\n            filetypes = filetypes.split(\",\")\n        return [genmap[ft] for ft in filetypes]\n\n    def _parse_datetime(self, date_string):\n        if len(date_string) > 19:\n            date_string = date_string[:19]\n        return self.parse_datetime_iso(date_string)\n\n    def _revisions(self, posts):\n        return itertools.chain.from_iterable(\n            self._revisions_post(post) for post in posts)\n\n    def _revisions_get(self, post):\n        if (props := post.get(\"props\")) and \"revisions\" in props:\n            return [\n                rev[1]\n                for rev in props[\"revisions\"]\n                if \"revision_id\" in rev[1]\n            ]\n        return self.api.creator_post_revisions(\n            post[\"service\"], post[\"user\"], post[\"id\"])\n\n    def _revisions_post(self, post):\n        revs = self._revisions_get(post)\n\n        if \"post\" in post:\n            post = post[\"post\"]\n        post[\"revision_id\"] = 0\n\n        if not revs:\n            post[\"revision_hash\"] = self._revision_hash(post)\n            post[\"revision_index\"] = 1\n            post[\"revision_count\"] = 1\n            return (post,)\n\n        revs.insert(0, post)\n        for rev in revs:\n            rev[\"revision_hash\"] = self._revision_hash(rev)\n\n        if self.revisions_unique:\n            uniq = []\n            last = None\n            for rev in revs:\n                if last != rev[\"revision_hash\"]:\n                    last = rev[\"revision_hash\"]\n                    uniq.append(rev)\n            revs = uniq\n\n        cnt = idx = len(revs)\n        for rev in revs:\n            rev[\"revision_index\"] = idx\n            rev[\"revision_count\"] = cnt\n            idx -= 1\n\n        if self.revisions_reverse:\n            revs.reverse()\n\n        return revs\n\n    def _revisions_all(self, post):\n        revs = self._revisions_get(post)\n\n        cnt = idx = len(revs)\n        for rev in revs:\n            rev[\"revision_hash\"] = self._revision_hash(rev)\n            rev[\"revision_index\"] = idx\n            rev[\"revision_count\"] = cnt\n            idx -= 1\n\n        if self.revisions_reverse:\n            revs.reverse()\n\n        return revs\n\n    def _revision_hash(self, revision):\n        if isinstance(revision[\"file\"], str):\n            revision[\"file\"] = util.json_loads(revision[\"file\"])\n            revision[\"attachments\"] = [\n                util.json_loads(a) for a in revision[\"attachments\"]]\n\n        rev = revision.copy()\n        rev.pop(\"revision_id\", None)\n        rev.pop(\"added\", None)\n        rev.pop(\"next\", None)\n        rev.pop(\"prev\", None)\n        rev[\"file\"] = rev[\"file\"].copy()\n        rev[\"file\"].pop(\"name\", None)\n        rev[\"attachments\"] = [a.copy() for a in rev[\"attachments\"]]\n        for a in rev[\"attachments\"]:\n            a.pop(\"name\", None)\n        return util.sha1(self._json_dumps(rev))\n\n    def _discord_server_info(self, server_id):\n        server = self.api.discord_server(server_id)\n        return server, {\n            channel[\"id\"]: channel\n            for channel in server.pop(\"channels\")\n        }\n\n\ndef _validate(response):\n    return (response.headers[\"content-length\"] != \"9\" or\n            response.content != b\"not found\")\n\n\nclass KemonoUserExtractor(KemonoExtractor):\n    \"\"\"Extractor for all posts from a kemono.cr user listing\"\"\"\n    subcategory = \"user\"\n    pattern = USER_PATTERN + r\"/?(?:\\?([^#]+))?(?:$|\\?|#)\"\n    example = \"https://kemono.cr/SERVICE/user/12345\"\n\n    def __init__(self, match):\n        self.subcategory = match[3]\n        KemonoExtractor.__init__(self, match)\n\n    def posts(self):\n        _, _, service, creator_id, query = self.groups\n        params = text.parse_query(query)\n\n        if self.config(\"endpoint\") in {\"posts+\", \"legacy+\"}:\n            endpoint = self.api.creator_posts_expand\n        else:\n            endpoint = self.api.creator_posts\n\n        return endpoint(service, creator_id,\n                        params.get(\"o\"), params.get(\"q\"), params.get(\"tag\"))\n\n\nclass KemonoPostsExtractor(KemonoExtractor):\n    \"\"\"Extractor for kemono.cr post listings\"\"\"\n    subcategory = \"posts\"\n    pattern = BASE_PATTERN + r\"/posts()()(?:/?\\?([^#]+))?\"\n    example = \"https://kemono.cr/posts\"\n\n    def posts(self):\n        params = text.parse_query(self.groups[4])\n        return self.api.posts(\n            params.get(\"o\"), params.get(\"q\"), params.get(\"tag\"))\n\n\nclass KemonoPostExtractor(KemonoExtractor):\n    \"\"\"Extractor for a single kemono.cr post\"\"\"\n    subcategory = \"post\"\n    pattern = USER_PATTERN + r\"/post/([^/?#]+)(/revisions?(?:/(\\d*))?)?\"\n    example = \"https://kemono.cr/SERVICE/user/12345/post/12345\"\n\n    def __init__(self, match):\n        self.subcategory = match[3]\n        KemonoExtractor.__init__(self, match)\n\n    def posts(self):\n        _, _, service, creator_id, post_id, revision, revision_id = self.groups\n        post = self.api.creator_post(service, creator_id, post_id)\n        if not revision:\n            return (post,)\n\n        self.revisions = False\n\n        revs = self._revisions_all(post)\n        if not revision_id:\n            return revs\n\n        for rev in revs:\n            if str(rev[\"revision_id\"]) == revision_id:\n                return (rev,)\n\n        raise self.exc.NotFoundError(\"revision\")\n\n\nclass KemonoDiscordExtractor(KemonoExtractor):\n    \"\"\"Extractor for kemono.cr discord servers\"\"\"\n    subcategory = \"discord\"\n    directory_fmt = (\"{category}\", \"discord\",\n                     \"{server_id} {server}\", \"{channel_id} {channel}\")\n    filename_fmt = \"{id}_{num:>02}_{filename}.{extension}\"\n    archive_fmt = \"discord_{server_id}_{id}_{num}\"\n    pattern = BASE_PATTERN + r\"/discord/server/(\\d+)[/#](?:channel/)?(\\d+)\"\n    example = \"https://kemono.cr/discord/server/12345/12345\"\n\n    def items(self):\n        _, _, server_id, channel_id = self.groups\n\n        try:\n            server, channels = self.cache(self._discord_server_info, server_id)\n            channel = channels[channel_id]\n        except Exception:\n            raise self.exc.NotFoundError(\"channel\")\n\n        metadata = {\n            \"server\"       : server[\"name\"],\n            \"server_id\"    : server[\"id\"],\n            \"channel\"      : channel[\"name\"],\n            \"channel_id\"   : channel[\"id\"],\n            \"channel_nsfw\" : channel[\"is_nsfw\"],\n            \"channel_type\" : channel[\"type\"],\n            \"channel_topic\": channel[\"topic\"],\n            \"parent_id\"    : channel[\"parent_channel_id\"],\n        }\n\n        find_inline = text.re(\n            r\"https?://(?:cdn\\.discordapp.com|media\\.discordapp\\.net)\"\n            r\"(/[A-Za-z0-9-._~:/?#\\[\\]@!$&'()*+,;%=]+)\").findall\n        find_hash = text.re(HASH_PATTERN).match\n        archives = True if self.config(\"archives\") else False\n        archives_type = dict if self.config(\"archives-format\") in {\n            \"dict\", \"object\"} else list\n        exts_archive = util.EXTS_ARCHIVE\n\n        if (order := self.config(\"order-posts\")) and order[0] in {\"r\", \"d\"}:\n            posts = self.api.discord_channel(channel_id, channel[\"post_count\"])\n        else:\n            posts = self.api.discord_channel(channel_id)\n\n        if max_posts := self.config(\"max-posts\"):\n            posts = itertools.islice(posts, max_posts)\n\n        for post in posts:\n            files = []\n            for attachment in post[\"attachments\"]:\n                match = find_hash(attachment[\"path\"])\n                attachment[\"hash\"] = match[1] if match else \"\"\n                attachment[\"type\"] = \"attachment\"\n                files.append(attachment)\n            for path in find_inline(post[\"content\"] or \"\"):\n                files.append({\"path\": \"https://cdn.discordapp.com\" + path,\n                              \"name\": path, \"type\": \"inline\", \"hash\": \"\"})\n\n            post.update(metadata)\n            post[\"date\"] = self._parse_datetime(post[\"published\"])\n            post[\"count\"] = len(files)\n            post[\"archives\"] = post_archives = ()\n\n            yield Message.Directory, \"\", post\n\n            for post[\"num\"], file in enumerate(files, 1):\n                post[\"hash\"] = hash = file[\"hash\"]\n                post[\"type\"] = file[\"type\"]\n                url = file[\"path\"]\n\n                if name := file.get(\"name\"):\n                    text.nameext_from_name(name, post)\n                    ext = text.ext_from_url(url)\n                    if not post[\"extension\"]:\n                        post[\"extension\"] = ext\n                else:\n                    text.nameext_from_url(url, post)\n                    ext = post[\"extension\"]\n\n                if ext in exts_archive:\n                    if not post_archives:\n                        post[\"archives\"] = post_archives = archives_type()\n                    post[\"type\"] = \"archive\"\n                    if archives:\n                        try:\n                            archive = self.api.file(hash)\n                            archive.update(file)\n                        except Exception as exc:\n                            self.log.warning(\n                                \"%s: Failed to retrieve archive metadata of \"\n                                \"'%s' (%s: %s)\", post[\"id\"], file.get(\"name\"),\n                                exc.__class__.__name__, exc)\n                            archive = file.copy()\n                    else:\n                        archive = file.copy()\n                    if archives_type is dict:\n                        post_archives[hash] = archive\n                    else:\n                        post_archives.append(archive)\n\n                if url[0] == \"/\":\n                    url = f\"{self.root}/data{url}\"\n                elif url.startswith(self.root):\n                    url = f\"{self.root}/data{url[20:]}\"\n                yield Message.Url, url, post\n\n\nclass KemonoDiscordServerExtractor(KemonoExtractor):\n    subcategory = \"discord-server\"\n    pattern = BASE_PATTERN + r\"/discord/server/(\\d+)\"\n    example = \"https://kemono.cr/discord/server/12345\"\n\n    def items(self):\n        server_id = self.groups[2]\n        server, channels = self.cache(self._discord_server_info, server_id)\n        for channel in channels.values():\n            url = (f\"{self.root}/discord/server/{server_id}/\"\n                   f\"{channel['id']}#{channel['name']}\")\n            yield Message.Queue, url, {\n                \"server\"    : server,\n                \"channel\"   : channel,\n                \"_extractor\": KemonoDiscordExtractor,\n            }\n\n\nclass KemonoFavoriteExtractor(KemonoExtractor):\n    \"\"\"Extractor for kemono.cr favorites\"\"\"\n    subcategory = \"favorite\"\n    pattern = BASE_PATTERN + r\"/(?:account/)?favorites()()(?:/?\\?([^#]+))?\"\n    example = \"https://kemono.cr/account/favorites/artists\"\n\n    def items(self):\n        self.login()\n\n        params = text.parse_query(self.groups[4])\n        type = params.get(\"type\") or self.config(\"favorites\") or \"artist\"\n\n        sort = params.get(\"sort\")\n        order = params.get(\"order\") or \"desc\"\n\n        if type == \"artist\":\n            users = self.api.account_favorites(\"artist\")\n\n            if not sort:\n                sort = \"updated\"\n            users.sort(key=lambda x: x[sort] or util.NONE,\n                       reverse=(order == \"desc\"))\n\n            for user in users:\n                service = user[\"service\"]\n                if service == \"discord\":\n                    user[\"_extractor\"] = KemonoDiscordServerExtractor\n                    url = f\"{self.root}/discord/server/{user['id']}\"\n                else:\n                    user[\"_extractor\"] = KemonoUserExtractor\n                    url = f\"{self.root}/{service}/user/{user['id']}\"\n                yield Message.Queue, url, user\n\n        elif type == \"post\":\n            posts = self.api.account_favorites(\"post\")\n\n            if not sort:\n                sort = \"faved_seq\"\n            posts.sort(key=lambda x: x[sort] or util.NONE,\n                       reverse=(order == \"desc\"))\n\n            for post in posts:\n                post[\"_extractor\"] = KemonoPostExtractor\n                url = (f\"{self.root}/{post['service']}/user/\"\n                       f\"{post['user']}/post/{post['id']}\")\n                yield Message.Queue, url, post\n\n\nclass KemonoArtistsExtractor(KemonoExtractor):\n    \"\"\"Extractor for kemono artists\"\"\"\n    subcategory = \"artists\"\n    pattern = BASE_PATTERN + r\"/artists(?:\\?([^#]+))?\"\n    example = \"https://kemono.cr/artists\"\n\n    def items(self):\n        params = text.parse_query(self.groups[2])\n        users = self.api.creators()\n\n        if params.get(\"service\"):\n            service = params[\"service\"].lower()\n            users = [user for user in users\n                     if user[\"service\"] == service]\n\n        if params.get(\"q\"):\n            q = params[\"q\"].lower()\n            users = [user for user in users\n                     if q in user[\"name\"].lower()]\n\n        sort = params.get(\"sort_by\") or \"favorited\"\n        order = params.get(\"order\") or \"desc\"\n        users.sort(key=lambda user: user[sort] or util.NONE,\n                   reverse=(order != \"asc\"))\n\n        for user in users:\n            service = user[\"service\"]\n            if service == \"discord\":\n                user[\"_extractor\"] = KemonoDiscordServerExtractor\n                url = f\"{self.root}/discord/server/{user['id']}\"\n            else:\n                user[\"_extractor\"] = KemonoUserExtractor\n                url = f\"{self.root}/{service}/user/{user['id']}\"\n            yield Message.Queue, url, user\n\n\nclass KemonoAPI():\n    \"\"\"Interface for the Kemono API v1.3.0\n\n    https://kemono.cr/documentation/api\n    \"\"\"\n\n    def __init__(self, extractor):\n        self.extractor = extractor\n        self.root = extractor.root + \"/api\"\n        self.headers = {\"Accept\": \"text/css\"}\n\n    def posts(self, offset=0, query=None, tags=None):\n        endpoint = \"/v1/posts\"\n        params = {\"q\": query, \"o\": offset, \"tag\": tags}\n        return self._pagination(endpoint, params, 50, \"posts\")\n\n    def file(self, file_hash):\n        endpoint = \"/v1/file/\" + file_hash\n        return self._call(endpoint)\n\n    def creators(self):\n        endpoint = \"/v1/creators\"\n        return self._call(endpoint)\n\n    def creator_posts(self, service, creator_id,\n                      offset=0, query=None, tags=None):\n        endpoint = f\"/v1/{service}/user/{creator_id}/posts\"\n        params = {\"o\": offset, \"tag\": tags, \"q\": query}\n        return self._pagination(endpoint, params, 50)\n\n    def creator_posts_expand(self, service, creator_id,\n                             offset=0, query=None, tags=None):\n        for post in self.creator_posts(\n                service, creator_id, offset, query, tags):\n            yield self.creator_post(\n                service, creator_id, post[\"id\"])[\"post\"]\n\n    def creator_announcements(self, service, creator_id):\n        endpoint = f\"/v1/{service}/user/{creator_id}/announcements\"\n        return self._call(endpoint)\n\n    def creator_dms(self, service, creator_id):\n        endpoint = f\"/v1/{service}/user/{creator_id}/dms\"\n        return self._call(endpoint)\n\n    def creator_fancards(self, service, creator_id):\n        endpoint = f\"/v1/{service}/user/{creator_id}/fancards\"\n        return self._call(endpoint)\n\n    def creator_post(self, service, creator_id, post_id):\n        endpoint = f\"/v1/{service}/user/{creator_id}/post/{post_id}\"\n        return self._call(endpoint)\n\n    def creator_post_comments(self, service, creator_id, post_id):\n        endpoint = f\"/v1/{service}/user/{creator_id}/post/{post_id}/comments\"\n        return self._call(endpoint, fatal=False)\n\n    def creator_post_revisions(self, service, creator_id, post_id):\n        endpoint = f\"/v1/{service}/user/{creator_id}/post/{post_id}/revisions\"\n        return self._call(endpoint, fatal=False)\n\n    def creator_profile(self, service, creator_id):\n        endpoint = f\"/v1/{service}/user/{creator_id}/profile\"\n        return self._call(endpoint)\n\n    def creator_links(self, service, creator_id):\n        endpoint = f\"/v1/{service}/user/{creator_id}/links\"\n        return self._call(endpoint)\n\n    def creator_tags(self, service, creator_id):\n        endpoint = f\"/v1/{service}/user/{creator_id}/tags\"\n        return self._call(endpoint)\n\n    def discord_channel(self, channel_id, post_count=None):\n        endpoint = \"/v1/discord/channel/\" + channel_id\n        if post_count is None:\n            return self._pagination(endpoint, {}, 150)\n        else:\n            return self._pagination_reverse(endpoint, {}, 150, post_count)\n\n    def discord_channel_lookup(self, server_id):\n        endpoint = \"/v1/discord/channel/lookup/\" + server_id\n        return self._call(endpoint)\n\n    def discord_server(self, server_id):\n        endpoint = \"/v1/discord/server/\" + server_id\n        return self._call(endpoint)\n\n    def account_favorites(self, type):\n        endpoint = \"/v1/account/favorites\"\n        params = {\"type\": type}\n        return self._call(endpoint, params)\n\n    def _call(self, endpoint, params=None, headers=None, fatal=True):\n        if headers is None:\n            headers = self.headers\n        else:\n            headers = {**self.headers, **headers}\n\n        return self.extractor.request_json(\n            self.root + endpoint, params=params, headers=headers,\n            encoding=\"utf-8\", fatal=fatal)\n\n    def _pagination(self, endpoint, params, batch=50, key=None):\n        offset = text.parse_int(params.get(\"o\"))\n        params[\"o\"] = offset - offset % batch\n\n        while True:\n            data = self._call(endpoint, params)\n\n            if key is not None:\n                data = data.get(key)\n            if not data:\n                return\n            yield from data\n\n            if len(data) < batch:\n                return\n            params[\"o\"] += batch\n\n    def _pagination_reverse(self, endpoint, params, batch, count):\n        params[\"o\"] = count // batch * batch\n\n        while True:\n            data = self._call(endpoint, params)\n\n            if not data:\n                return\n            data.reverse()\n            yield from data\n\n            if not params[\"o\"]:\n                return\n            params[\"o\"] -= batch\n"
  },
  {
    "path": "gallery_dl/extractor/khinsider.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2016-2025 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://downloads.khinsider.com/\"\"\"\n\nfrom .common import Extractor, Message, AsynchronousMixin\nfrom .. import text\n\n\nclass KhinsiderSoundtrackExtractor(AsynchronousMixin, Extractor):\n    \"\"\"Extractor for soundtracks from khinsider.com\"\"\"\n    category = \"khinsider\"\n    subcategory = \"soundtrack\"\n    root = \"https://downloads.khinsider.com\"\n    directory_fmt = (\"{category}\", \"{album[name]}\")\n    archive_fmt = \"{filename}.{extension}\"\n    pattern = (r\"(?:https?://)?downloads\\.khinsider\\.com\"\n               r\"/game-soundtracks/album/([^/?#]+)\")\n    example = (\"https://downloads.khinsider.com\"\n               \"/game-soundtracks/album/TITLE\")\n\n    def __init__(self, match):\n        Extractor.__init__(self, match)\n        self.album = match[1]\n\n    def items(self):\n        url = self.root + \"/game-soundtracks/album/\" + self.album\n        page = self.request(url, encoding=\"utf-8\").text\n        if \"Download all songs at once:\" not in page:\n            raise self.exc.NotFoundError(\"soundtrack\")\n\n        data = self.metadata(page)\n        yield Message.Directory, \"\", data\n\n        if self.config(\"covers\", False):\n            for num, url in enumerate(self._extract_covers(page), 1):\n                cover = text.nameext_from_url(\n                    url, {\"url\": url, \"num\": num, \"type\": \"cover\"})\n                cover.update(data)\n                yield Message.Url, url, cover\n\n        for track in self._extract_tracks(page):\n            track.update(data)\n            track[\"type\"] = \"track\"\n            yield Message.Url, track[\"url\"], track\n\n    def metadata(self, page):\n        extr = text.extract_from(page)\n        return {\"album\": {\n            \"name\" : text.unescape(extr(\"<h2>\", \"<\")),\n            \"platform\": text.split_html(extr(\"Platforms: \", \"<br>\"))[::2],\n            \"year\": extr(\"Year: <b>\", \"<\"),\n            \"catalog\": extr(\"Catalog Number: <b>\", \"<\"),\n            \"developer\": text.remove_html(extr(\" Developed by: \", \"</\")),\n            \"publisher\": text.remove_html(extr(\" Published by: \", \"</\")),\n            \"count\": text.parse_int(extr(\"Number of Files: <b>\", \"<\")),\n            \"size\" : text.parse_bytes(extr(\"Total Filesize: <b>\", \"<\")[:-1]),\n            \"date\" : extr(\"Date Added: <b>\", \"<\"),\n            \"type\" : text.remove_html(extr(\"Album type: <b>\", \"</b>\")),\n            \"uploader\": text.remove_html(extr(\"Uploaded by: \", \"</\")),\n            \"description\": extr(\"<h2>Description</h2>\", \"<h2>\").strip(),\n        }}\n\n    def _extract_tracks(self, page):\n        fmt = self.config(\"format\", (\"mp3\",))\n        if fmt and isinstance(fmt, str):\n            if fmt == \"all\":\n                fmt = None\n            else:\n                fmt = fmt.lower().split(\",\")\n\n        page = text.extr(page, '<table id=\"songlist\">', '</table>')\n        for num, url in enumerate(text.extract_iter(\n                page, '<td class=\"clickable-row\"><a href=\"', '\"'), 1):\n            url = text.urljoin(self.root, url)\n            page = self.request(url, encoding=\"utf-8\").text\n            track = first = None\n\n            for url in text.extract_iter(page, '<p><a href=\"', '\"'):\n                track = text.nameext_from_url(url, {\"num\": num, \"url\": url})\n                if first is None:\n                    first = track\n                if not fmt or track[\"extension\"] in fmt:\n                    first = False\n                    yield track\n            if first:\n                yield first\n\n    def _extract_covers(self, page):\n        return [\n            text.unescape(text.extr(cover, ' href=\"', '\"'))\n            for cover in text.extract_iter(page, ' class=\"albumImage', '</')\n        ]\n"
  },
  {
    "path": "gallery_dl/extractor/komikcast.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2018-2025 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://komikcast.li/\"\"\"\n\nfrom .common import ChapterExtractor, MangaExtractor\nfrom .. import text\n\nBASE_PATTERN = (r\"(?:https?://)?(?:www\\.)?\"\n                r\"komikcast\\d*\\.(?:l(?:i|a|ol)|com|cz|site|mo?e)\")\n\n\nclass KomikcastBase():\n    \"\"\"Base class for komikcast extractors\"\"\"\n    category = \"komikcast\"\n    root = \"https://komikcast.li\"\n\n    def parse_chapter_string(self, chapter_string, data=None):\n        \"\"\"Parse 'chapter_string' value and add its info to 'data'\"\"\"\n        if data is None:\n            data = {}\n\n        pattern = text.re(r\"(?:(.*) Chapter )?0*(\\d+)([^ ]*)(?: (?:- )?(.+))?\")\n        match = pattern.match(text.unescape(chapter_string))\n        manga, chapter, data[\"chapter_minor\"], title = match.groups()\n\n        if manga:\n            data[\"manga\"] = manga.partition(\" Chapter \")[0]\n        if title and not title.lower().startswith(\"bahasa indonesia\"):\n            data[\"title\"] = title.strip()\n        else:\n            data[\"title\"] = \"\"\n        data[\"chapter\"] = text.parse_int(chapter)\n        data[\"lang\"] = \"id\"\n        data[\"language\"] = \"Indonesian\"\n\n        return data\n\n\nclass KomikcastChapterExtractor(KomikcastBase, ChapterExtractor):\n    \"\"\"Extractor for komikcast manga chapters\"\"\"\n    pattern = BASE_PATTERN + r\"(/chapter/[^/?#]+/)\"\n    example = \"https://komikcast.li/chapter/TITLE/\"\n\n    def metadata(self, page):\n        info = text.extr(page, \"<title>\", \" - Komikcast<\")\n        return self.parse_chapter_string(info)\n\n    def images(self, page):\n        readerarea = text.extr(\n            page, '<div class=\"main-reading-area', '</div')\n        pattern = text.re(r\"<img[^>]* src=[\\\"']([^\\\"']+)\")\n        return [\n            (text.unescape(url), None)\n            for url in pattern.findall(readerarea)\n        ]\n\n\nclass KomikcastMangaExtractor(KomikcastBase, MangaExtractor):\n    \"\"\"Extractor for komikcast manga\"\"\"\n    chapterclass = KomikcastChapterExtractor\n    pattern = BASE_PATTERN + r\"(/(?:komik/)?[^/?#]+/?)$\"\n    example = \"https://komikcast.li/komik/TITLE\"\n\n    def chapters(self, page):\n        results = []\n        data = self.metadata(page)\n\n        for item in text.extract_iter(\n                page, '<a class=\"chapter-link-item\" href=\"', '</a'):\n            url, _, chapter = item.rpartition('\">Chapter')\n            chapter, sep, minor = chapter.strip().partition(\".\")\n            data[\"chapter\"] = text.parse_int(chapter)\n            data[\"chapter_minor\"] = sep + minor\n            results.append((url, data.copy()))\n        return results\n\n    def metadata(self, page):\n        \"\"\"Return a dict with general metadata\"\"\"\n        manga , pos = text.extract(page, \"<title>\" , \" - Komikcast<\")\n        genres, pos = text.extract(\n            page, 'class=\"komik_info-content-genre\">', \"</span>\", pos)\n        author, pos = text.extract(page, \">Author:\", \"</span>\", pos)\n        mtype , pos = text.extract(page, \">Type:\"  , \"</span>\", pos)\n\n        return {\n            \"manga\": text.unescape(manga),\n            \"genres\": text.split_html(genres),\n            \"author\": text.remove_html(author),\n            \"type\": text.remove_html(mtype),\n        }\n"
  },
  {
    "path": "gallery_dl/extractor/koofr.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2025-2026 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://koofr.net/\"\"\"\n\nfrom .common import Extractor, Message\nfrom .. import text\n\n\nclass KoofrSharedExtractor(Extractor):\n    \"\"\"Base class for koofr extractors\"\"\"\n    category = \"koofr\"\n    subcategory = \"shared\"\n    root = \"https://app.koofr.net\"\n    directory_fmt = (\"{category}\", \"{post[title]} ({post[id]})\", \"{path:I}\")\n    filename_fmt = \"{num:>03} {filename} ({hash|id:[:8]}).{extension}\"\n    archive_fmt = \"{post[id]}_{path:J/}_{hash|id}\"\n    pattern = (r\"(?:https?://)?(?:\"\n               r\"(?:app\\.)?koofr\\.(?:net|eu)/links/([\\w-]+)|\"\n               r\"k00\\.fr/(\\w+))\")\n    example = \"https://app.koofr.net/links/UUID\"\n\n    def items(self):\n        uuid, code = self.groups\n        if code is not None:\n            uuid = self.request_location(\n                \"https://k00.fr/\" + code, method=\"GET\").rpartition(\"/\")[2]\n\n        url = f\"{self.root}/api/v2/public/links/{uuid}\"\n        referer = f\"{self.root}/links/{uuid}\"\n        password = self.config(\"password\")\n        params = {\"password\": password or \"\"}\n        headers = {\n            \"Referer\"        : referer,\n            \"X-Client\"       : \"newfrontend\",\n            \"X-Koofr-Version\": \"2.1\",\n            \"Sec-Fetch-Dest\" : \"empty\",\n            \"Sec-Fetch-Mode\" : \"cors\",\n            \"Sec-Fetch-Site\" : \"same-origin\",\n        }\n        data = self.request_json(url, params=params, headers=headers)\n\n        file = data[\"file\"]\n        file[\"path\"] = []\n        if file[\"type\"] == \"dir\" and self.config(\"recursive\", True):\n            items = self._extract_files(file, url + \"/bundle\", params, headers)\n            recursive = True\n        else:\n            items = ((file, (file,)),)\n            recursive = False\n\n        post = {\n            \"id\"   : data[\"id\"],\n            \"title\": data[\"name\"],\n            \"date\" : self.parse_timestamp(file[\"modified\"] / 1000),\n        }\n\n        base = (f\"{data.get('publicUrlBase') or self.root}\"\n                f\"/content/links/{uuid}/files/get/\")\n        headers = {\"Referer\": referer}\n        password = \"&password=\" + text.escape(password) if password else \"\"\n\n        for dir, files in items:\n            dir[\"post\"] = post\n            dir[\"count\"] = count = len(files)\n            yield Message.Directory, \"\", dir\n\n            num = 0\n            for file in files:\n                num += 1\n                file[\"num\"] = num\n                file[\"count\"] = count\n                file[\"post\"] = post\n                file[\"date\"] = self.parse_timestamp(file[\"modified\"] / 1000)\n                file[\"_http_headers\"] = headers\n\n                name = file[\"name\"]\n                text.nameext_from_name(name, file)\n\n                if recursive:\n                    if path := file[\"path\"]:\n                        path = f\"{'/'.join(path)}/{name}\"\n                    else:\n                        path = name\n                else:\n                    path = \"\"\n                    password += \"&force\"\n\n                url = (f\"{base}{text.escape(name)}\"\n                       f\"?path=/{text.escape(path)}{password}\")\n                yield Message.Url, url, file\n\n    def _extract_files(self, dir, url, params, headers):\n        path = dir[\"path\"]\n        params[\"path\"] = \"/\" + \"/\".join(path)\n\n        items = self.request_json(\n            url, params=params, headers=headers)[\"files\"]\n\n        dirs = []\n        files = []\n        for item in items:\n            if item[\"type\"] == \"dir\":\n                item[\"path\"] = path.copy()\n                item[\"path\"].append(item[\"name\"])\n                dirs.append(item)\n            else:\n                item[\"path\"] = path\n                files.append(item)\n\n        yield dir, files\n        for sub in dirs:\n            yield from self._extract_files(sub, url, params.copy(), headers)\n"
  },
  {
    "path": "gallery_dl/extractor/leakgallery.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://leakgallery.com\"\"\"\n\nfrom .common import Extractor, Message\nfrom .. import text\n\nBASE_PATTERN = r\"(?:https?://)?(?:www\\.)?leakgallery\\.com\"\n\n\nclass LeakgalleryExtractor(Extractor):\n    category = \"leakgallery\"\n    directory_fmt = (\"{category}\", \"{creator}\")\n    filename_fmt = \"{id}_{filename}.{extension}\"\n    archive_fmt = \"{creator}_{id}\"\n\n    def _yield_media_items(self, medias, creator=None):\n        seen = set()\n        for media in medias:\n            path = media[\"file_path\"]\n            if path in seen:\n                continue\n            seen.add(path)\n\n            if creator is None:\n                try:\n                    media[\"creator\"] = \\\n                        media[\"profile\"][\"username\"] or \"unknown\"\n                except Exception:\n                    media[\"creator\"] = \"unknown\"\n            else:\n                media[\"creator\"] = creator\n\n            media[\"url\"] = url = \"https://cdn.leakgallery.com/\" + path\n            text.nameext_from_url(url, media)\n            yield Message.Directory, \"\", media\n            yield Message.Url, url, media\n\n    def _pagination(self, type, base, params=None, creator=None, pnum=1):\n        while True:\n            try:\n                data = self.request_json(base + str(pnum), params=params)\n\n                if not data:\n                    return\n                if \"medias\" in data:\n                    data = data[\"medias\"]\n                    if not data or not isinstance(data, list):\n                        return\n\n                yield from self._yield_media_items(data, creator)\n                pnum += 1\n            except Exception as exc:\n                self.log.error(\"Failed to retrieve %s page %s: %s\",\n                               type, pnum, exc)\n                return\n\n\nclass LeakgalleryUserExtractor(LeakgalleryExtractor):\n    \"\"\"Extractor for profile posts on leakgallery.com\"\"\"\n    subcategory = \"user\"\n    pattern = (\n        BASE_PATTERN +\n        r\"/(?!trending-medias|most-liked|random/medias)([^/?#]+)\"\n        r\"(?:/(Photos|Videos|All))?\"\n        r\"(?:/(MostRecent|MostViewed|MostLiked))?/?$\"\n    )\n    example = \"https://leakgallery.com/creator\"\n\n    def items(self):\n        creator, mtype, msort = self.groups\n        base = f\"https://api.leakgallery.com/profile/{creator}/\"\n        params = {\"type\": mtype or \"All\", \"sort\": msort or \"MostRecent\"}\n        return self._pagination(creator, base, params, creator)\n\n\nclass LeakgalleryTrendingExtractor(LeakgalleryExtractor):\n    \"\"\"Extractor for trending posts on leakgallery.com\"\"\"\n    subcategory = \"trending\"\n    pattern = BASE_PATTERN + r\"/trending-medias(?:/([\\w-]+))?\"\n    example = \"https://leakgallery.com/trending-medias/Week\"\n\n    def items(self):\n        period = self.groups[0] or \"Last-Hour\"\n        base = f\"https://api.leakgallery.com/popular/media/{period}/\"\n        return self._pagination(\"trending\", base)\n\n\nclass LeakgalleryMostlikedExtractor(LeakgalleryExtractor):\n    \"\"\"Extractor for most liked posts on leakgallery.com\"\"\"\n    subcategory = \"mostliked\"\n    pattern = BASE_PATTERN + r\"/most-liked\"\n    example = \"https://leakgallery.com/most-liked\"\n\n    def items(self):\n        base = \"https://api.leakgallery.com/most-liked/\"\n        return self._pagination(\"most-liked\", base)\n\n\nclass LeakgalleryPostExtractor(LeakgalleryExtractor):\n    \"\"\"Extractor for individual posts on leakgallery.com\"\"\"\n    subcategory = \"post\"\n    pattern = BASE_PATTERN + r\"/([^/?#]+)/(\\d+)\"\n    example = \"https://leakgallery.com/CREATOR/12345\"\n\n    def items(self):\n        creator, post_id = self.groups\n        url = f\"https://leakgallery.com/{creator}/{post_id}\"\n\n        try:\n            page = self.request(url).text\n            video_urls = text.re(\n                r\"https://cdn\\.leakgallery\\.com/content[^/?#]*/\"\n                r\"(?:compressed_)?watermark_[^\\\"]+\\.\"\n                r\"(?:mp4|mov|m4a|webm)\"\n            ).findall(page)\n            image_urls = text.re(\n                r\"https://cdn\\.leakgallery\\.com/content[^/?#]*/\"\n                r\"watermark_[^\\\"]+\\.(?:jpe?g|png)\"\n            ).findall(page)\n\n            seen = set()\n            for url in video_urls + image_urls:\n                if url in seen:\n                    continue\n                seen.add(url)\n                data = {\n                    \"id\": post_id,\n                    \"creator\": creator,\n                    \"url\": url,\n                }\n                text.nameext_from_url(url, data)\n                yield Message.Directory, \"\", data\n                yield Message.Url, url, data\n        except Exception as exc:\n            self.log.error(\"Failed to extract post page %s/%s: %s\",\n                           creator, post_id, exc)\n"
  },
  {
    "path": "gallery_dl/extractor/lensdump.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://lensdump.com/\"\"\"\n\nfrom .common import GalleryExtractor, Extractor, Message\nfrom .. import text, util\n\nBASE_PATTERN = r\"(?:https?://)?lensdump\\.com\"\n\n\nclass LensdumpBase():\n    \"\"\"Base class for lensdump extractors\"\"\"\n    category = \"lensdump\"\n    root = \"https://lensdump.com\"\n\n    def _pagination(self, page, begin, end):\n        while True:\n            yield from text.extract_iter(page, begin, end)\n\n            next = text.extr(page, ' data-pagination=\"next\"', '>')\n            if not next:\n                return\n\n            url = text.urljoin(self.root, text.extr(next, 'href=\"', '\"'))\n            page = self.request(url).text\n\n\nclass LensdumpAlbumExtractor(LensdumpBase, GalleryExtractor):\n    subcategory = \"album\"\n    pattern = BASE_PATTERN + r\"/a/(\\w+)(?:/?\\?([^#]+))?\"\n    example = \"https://lensdump.com/a/ID\"\n\n    def __init__(self, match):\n        self.gallery_id, query = match.groups()\n        if query:\n            url = f\"{self.root}/a/{self.gallery_id}/?{query}\"\n        else:\n            url = f\"{self.root}/a/{self.gallery_id}\"\n        GalleryExtractor.__init__(self, match, url)\n\n    def metadata(self, page):\n        return {\n            \"gallery_id\": self.gallery_id,\n            \"title\": text.unescape(text.extr(\n                page, 'property=\"og:title\" content=\"', '\"').strip())\n        }\n\n    def images(self, page):\n        for image in self._pagination(page, ' class=\"list-item ', '>'):\n\n            data = util.json_loads(text.unquote(\n                text.extr(image, \"data-object='\", \"'\") or\n                text.extr(image, 'data-object=\"', '\"')))\n            image_id = data.get(\"name\")\n            image_url = data.get(\"url\")\n            image_title = data.get(\"title\")\n            if image_title is not None:\n                image_title = text.unescape(image_title)\n\n            yield (image_url, {\n                \"id\"       : image_id,\n                \"url\"      : image_url,\n                \"title\"    : image_title,\n                \"name\"     : data.get(\"filename\"),\n                \"filename\" : image_id,\n                \"extension\": data.get(\"extension\"),\n                \"width\"    : text.parse_int(data.get(\"width\")),\n                \"height\"   : text.parse_int(data.get(\"height\")),\n            })\n\n\nclass LensdumpAlbumsExtractor(LensdumpBase, Extractor):\n    \"\"\"Extractor for album list from lensdump.com\"\"\"\n    subcategory = \"albums\"\n    pattern = BASE_PATTERN + r\"/(?![ai]/)([^/?#]+)(?:/?\\?([^#]+))?\"\n    example = \"https://lensdump.com/USER\"\n\n    def items(self):\n        user, query = self.groups\n        url = f\"{self.root}/{user}/\"\n        if query:\n            params = text.parse_query(query)\n        else:\n            params = {\"sort\": \"date_asc\", \"page\": \"1\"}\n        page = self.request(url, params=params).text\n\n        data = {\"_extractor\": LensdumpAlbumExtractor}\n        for album_path in self._pagination(page, 'data-url-short=\"', '\"'):\n            album_url = text.urljoin(self.root, album_path)\n            yield Message.Queue, album_url, data\n\n\nclass LensdumpImageExtractor(LensdumpBase, Extractor):\n    \"\"\"Extractor for individual images on lensdump.com\"\"\"\n    subcategory = \"image\"\n    filename_fmt = \"{category}_{id}{title:?_//}.{extension}\"\n    directory_fmt = (\"{category}\",)\n    archive_fmt = \"{id}\"\n    pattern = (r\"(?:https?://)?(?:(?:i\\d?\\.)?lensdump\\.com|\\w\\.l3n\\.co)\"\n               r\"/(?:i/)?(\\w+)\")\n    example = \"https://lensdump.com/i/ID\"\n\n    def items(self):\n        key = self.groups[0]\n        url = f\"{self.root}/i/{key}\"\n        extr = text.extract_from(self.request(url).text)\n\n        data = {\n            \"id\"    : key,\n            \"title\" : text.unescape(extr(\n                'property=\"og:title\" content=\"', '\"')),\n            \"url\"   : extr(\n                'property=\"og:image\" content=\"', '\"'),\n            \"width\" : text.parse_int(extr(\n                'property=\"image:width\" content=\"', '\"')),\n            \"height\": text.parse_int(extr(\n                'property=\"image:height\" content=\"', '\"')),\n            \"date\"  : self.parse_datetime_iso(extr('<span title=\"', '\"')),\n        }\n\n        text.nameext_from_url(data[\"url\"], data)\n        yield Message.Directory, \"\", data\n        yield Message.Url, data[\"url\"], data\n"
  },
  {
    "path": "gallery_dl/extractor/lexica.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2023-2025 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://lexica.art/\"\"\"\n\nfrom .common import Extractor, Message\nfrom .. import text\n\n\nclass LexicaSearchExtractor(Extractor):\n    \"\"\"Extractor for lexica.art search results\"\"\"\n    category = \"lexica\"\n    subcategory = \"search\"\n    root = \"https://lexica.art\"\n    directory_fmt = (\"{category}\", \"{search_tags}\")\n    archive_fmt = \"{id}\"\n    pattern = r\"(?:https?://)?lexica\\.art/?\\?q=([^&#]+)\"\n    example = \"https://lexica.art/?q=QUERY\"\n\n    def __init__(self, match):\n        Extractor.__init__(self, match)\n        self.query = match[1]\n        self.text = text.unquote(self.query).replace(\"+\", \" \")\n\n    def items(self):\n        base = (\"https://lexica-serve-encoded-images2.sharif.workers.dev\"\n                \"/full_jpg/\")\n        tags = self.text\n\n        for image in self.posts():\n            image[\"filename\"] = image[\"id\"]\n            image[\"extension\"] = \"jpg\"\n            image[\"search_tags\"] = tags\n            yield Message.Directory, \"\", image\n            yield Message.Url, base + image[\"id\"], image\n\n    def posts(self):\n        url = self.root + \"/api/infinite-prompts\"\n        headers = {\n            \"Accept\" : \"application/json, text/plain, */*\",\n            \"Referer\": f\"{self.root}/?q={self.query}\",\n        }\n        json = {\n            \"text\"      : self.text,\n            \"searchMode\": \"images\",\n            \"source\"    : \"search\",\n            \"cursor\"    : 0,\n            \"model\"     : \"lexica-aperture-v2\",\n        }\n\n        while True:\n            data = self.request_json(\n                url, method=\"POST\", headers=headers, json=json)\n\n            prompts = {\n                prompt[\"id\"]: prompt\n                for prompt in data[\"prompts\"]\n            }\n\n            for image in data[\"images\"]:\n                image[\"prompt\"] = prompts[image[\"promptid\"]]\n                del image[\"promptid\"]\n                yield image\n\n            cursor = data.get(\"nextCursor\")\n            if not cursor:\n                return\n\n            json[\"cursor\"] = cursor\n"
  },
  {
    "path": "gallery_dl/extractor/lightroom.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://lightroom.adobe.com/\"\"\"\n\nfrom .common import Extractor, Message\nfrom .. import text, util\n\n\nclass LightroomGalleryExtractor(Extractor):\n    \"\"\"Extractor for an image gallery on lightroom.adobe.com\"\"\"\n    category = \"lightroom\"\n    subcategory = \"gallery\"\n    directory_fmt = (\"{category}\", \"{user}\", \"{title}\")\n    filename_fmt = \"{num:>04}_{id}.{extension}\"\n    archive_fmt = \"{id}\"\n    pattern = r\"(?:https?://)?lightroom\\.adobe\\.com/shares/([0-9a-f]+)\"\n    example = \"https://lightroom.adobe.com/shares/0123456789abcdef\"\n\n    def __init__(self, match):\n        Extractor.__init__(self, match)\n        self.href = match[1]\n\n    def items(self):\n        # Get config\n        url = \"https://lightroom.adobe.com/shares/\" + self.href\n        response = self.request(url)\n        album = util.json_loads(\n            text.extr(response.text, \"albumAttributes: \", \"\\n\")\n        )\n\n        images = self.images(album)\n        for img in images:\n            url = img[\"url\"]\n            yield Message.Directory, \"\", img\n            yield Message.Url, url, text.nameext_from_url(url, img)\n\n    def metadata(self, album):\n        payload = album[\"payload\"]\n        story = payload.get(\"story\") or {}\n        return {\n            \"gallery_id\": self.href,\n            \"user\": story.get(\"author\", \"\"),\n            \"title\": story.get(\"title\", payload[\"name\"]),\n        }\n\n    def images(self, album):\n        album_md = self.metadata(album)\n        base_url = album[\"base\"]\n        next_url = album[\"links\"][\"/rels/space_album_images_videos\"][\"href\"]\n        num = 1\n\n        while next_url:\n            url = base_url + next_url\n            page = self.request(url).text\n            # skip 1st line as it's a JS loop\n            data = util.json_loads(page[page.index(\"\\n\") + 1:])\n\n            base_url = data[\"base\"]\n            for res in data[\"resources\"]:\n                img_url, img_size = None, 0\n                for key, value in res[\"asset\"][\"links\"].items():\n                    if not key.startswith(\"/rels/rendition_type/\"):\n                        continue\n                    size = text.parse_int(key.split(\"/\")[-1])\n                    if size > img_size:\n                        img_size = size\n                        img_url = value[\"href\"]\n\n                if img_url:\n                    img = {\n                        \"id\": res[\"asset\"][\"id\"],\n                        \"num\": num,\n                        \"url\": base_url + img_url,\n                    }\n                    img.update(album_md)\n                    yield img\n                    num += 1\n            try:\n                next_url = data[\"links\"][\"next\"][\"href\"]\n            except KeyError:\n                next_url = None\n"
  },
  {
    "path": "gallery_dl/extractor/listal.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://listal.com\"\"\"\n\nfrom .common import Extractor, Message\nfrom .. import text\n\nBASE_PATTERN = r\"(?:https?://)?(?:www\\.)?listal\\.com\"\n\n\nclass ListalExtractor(Extractor):\n    \"\"\"Base class for Listal extractor\"\"\"\n    category = \"listal\"\n    root = \"https://www.listal.com\"\n    directory_fmt = (\"{category}\", \"{title}\")\n    filename_fmt = \"{id}_{filename}.{extension}\"\n    archive_fmt = \"{id}/{filename}\"\n\n    def items(self):\n        for image_id in self.image_ids():\n            img = self._extract_image(image_id)\n            url = img[\"url\"]\n            text.nameext_from_url(url, img)\n            yield Message.Directory, \"\", img\n            yield Message.Url, url, img\n\n    def _pagination(self, base_url, pnum=None):\n        if pnum is None:\n            url = base_url\n            pnum = 1\n        else:\n            url = f\"{base_url}/{pnum}\"\n\n        while True:\n            page = self.request(url).text\n\n            yield page\n\n            if pnum is None or \"<span class='nextprev'>Next\" in page:\n                return\n            pnum += 1\n            url = f\"{base_url}/{pnum}\"\n\n    def _extract_image(self, image_id):\n        url = f\"{self.root}/viewimage/{image_id}h\"\n        page = self.request(url).text\n        extr = text.extract_from(page)\n\n        return {\n            \"id\"        : image_id,\n            \"url\"       : extr(\"<div><center><img src='\", \"'\"),\n            \"title\"     : text.unescape(extr('title=\"', '\"')),\n            \"width\"     : text.parse_int(extr(\"width='\", \"'\")),\n            \"height\"    : text.parse_int(extr(\"height='\", \"'\")),\n            \"author_url\": extr(\"Added by <a href='\", \"'\"),\n            \"author\"    : text.unescape(extr(\">\", \"<\")),\n            \"date\"      : self.parse_datetime(extr(\n                \" ago on \", \"<\"), \"%d %B %Y %H:%M\"),\n        }\n\n\nclass ListalImageExtractor(ListalExtractor):\n    \"\"\"Extractor for listal pictures\"\"\"\n    subcategory = \"image\"\n    pattern = BASE_PATTERN + r\"/viewimage/(\\d+)\"\n    example = \"https://www.listal.com/viewimage/12345678\"\n\n    def image_ids(self):\n        return (self.groups[0],)\n\n\nclass ListalPeopleExtractor(ListalExtractor):\n    \"\"\"Extractor for listal people pictures\"\"\"\n    subcategory = \"people\"\n    pattern = BASE_PATTERN + r\"/([^/?#]+)/pictures\"\n    example = \"https://www.listal.com/NAME/pictures\"\n\n    def image_ids(self):\n        url = f\"{self.root}/{self.groups[0]}/pictures\"\n        for page in self._pagination(url):\n            yield from text.extract_iter(page, \"listal.com/viewimage/\", \"'\")\n"
  },
  {
    "path": "gallery_dl/extractor/livedoor.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2019-2025 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for http://blog.livedoor.jp/\"\"\"\n\nfrom .common import Extractor, Message\nfrom .. import text\n\n\nclass LivedoorExtractor(Extractor):\n    \"\"\"Base class for livedoor extractors\"\"\"\n    category = \"livedoor\"\n    root = \"http://blog.livedoor.jp\"\n    filename_fmt = \"{post[id]}_{post[title]}_{num:>02}.{extension}\"\n    directory_fmt = (\"{category}\", \"{post[user]}\")\n    archive_fmt = \"{post[id]}_{hash}\"\n\n    def __init__(self, match):\n        Extractor.__init__(self, match)\n        self.user = match[1]\n\n    def items(self):\n        for post in self.posts():\n            if images := self._images(post):\n                yield Message.Directory, \"\", {\"post\": post}\n                for image in images:\n                    yield Message.Url, image[\"url\"], image\n\n    def posts(self):\n        \"\"\"Return an iterable with post objects\"\"\"\n\n    def _load(self, data, body):\n        extr = text.extract_from(data)\n        tags = text.extr(body, 'class=\"article-tags\">', '</dl>')\n        about = extr('rdf:about=\"', '\"')\n\n        return {\n            \"id\"         : text.parse_int(\n                about.rpartition(\"/\")[2].partition(\".\")[0]),\n            \"title\"      : text.unescape(extr('dc:title=\"', '\"')),\n            \"categories\" : extr('dc:subject=\"', '\"').partition(\",\")[::2],\n            \"description\": extr('dc:description=\"', '\"'),\n            \"date\"       : self.parse_datetime_iso(extr('dc:date=\"', '\"')),\n            \"tags\"       : text.split_html(tags)[1:] if tags else [],\n            \"user\"       : self.user,\n            \"body\"       : body,\n        }\n\n    def _images(self, post):\n        imgs = []\n        body = post.pop(\"body\")\n\n        for num, img in enumerate(text.extract_iter(body, \"<img \", \">\"), 1):\n            src = text.extr(img, 'src=\"', '\"')\n            alt = text.extr(img, 'alt=\"', '\"')\n\n            if not src:\n                continue\n            if \"://livedoor.blogimg.jp/\" in src:\n                url = src.replace(\"http:\", \"https:\", 1).replace(\"-s.\", \".\")\n            else:\n                url = text.urljoin(self.root, src)\n            name, _, ext = url.rpartition(\"/\")[2].rpartition(\".\")\n\n            imgs.append({\n                \"url\"      : url,\n                \"num\"      : num,\n                \"hash\"     : name,\n                \"filename\" : alt or name,\n                \"extension\": ext,\n                \"post\"     : post,\n            })\n\n        return imgs\n\n\nclass LivedoorBlogExtractor(LivedoorExtractor):\n    \"\"\"Extractor for a user's blog on blog.livedoor.jp\"\"\"\n    subcategory = \"blog\"\n    pattern = r\"(?:https?://)?blog\\.livedoor\\.jp/(\\w+)/?(?:$|[?#])\"\n    example = \"http://blog.livedoor.jp/USER/\"\n\n    def posts(self):\n        url = f\"{self.root}/{self.user}\"\n        while url:\n            extr = text.extract_from(self.request(url).text)\n            while True:\n                data = extr('<rdf:RDF', '</rdf:RDF>')\n                if not data:\n                    break\n                body = extr('class=\"article-body-inner\">',\n                            'class=\"article-footer\">')\n                yield self._load(data, body)\n            url = extr('<a rel=\"next\" href=\"', '\"')\n\n\nclass LivedoorPostExtractor(LivedoorExtractor):\n    \"\"\"Extractor for images from a blog post on blog.livedoor.jp\"\"\"\n    subcategory = \"post\"\n    pattern = r\"(?:https?://)?blog\\.livedoor\\.jp/(\\w+)/archives/(\\d+)\"\n    example = \"http://blog.livedoor.jp/USER/archives/12345.html\"\n\n    def __init__(self, match):\n        LivedoorExtractor.__init__(self, match)\n        self.post_id = match[2]\n\n    def posts(self):\n        url = f\"{self.root}/{self.user}/archives/{self.post_id}.html\"\n        extr = text.extract_from(self.request(url).text)\n        data = extr('<rdf:RDF', '</rdf:RDF>')\n        body = extr('class=\"article-body-inner\">', 'class=\"article-footer\">')\n        return (self._load(data, body),)\n"
  },
  {
    "path": "gallery_dl/extractor/lofter.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://www.lofter.com/\"\"\"\n\nfrom .common import Extractor, Message\nfrom .. import text, util\n\n\nclass LofterExtractor(Extractor):\n    \"\"\"Base class for lofter extractors\"\"\"\n    category = \"lofter\"\n    root = \"https://www.lofter.com\"\n    directory_fmt = (\"{category}\", \"{blog_name}\")\n    filename_fmt = \"{id}_{num}.{extension}\"\n    archive_fmt = \"{id}_{num}\"\n\n    def _init(self):\n        self.api = LofterAPI(self)\n\n    def items(self):\n        for post in self.posts():\n            if post is None:\n                continue\n            if \"post\" in post:\n                post = post[\"post\"]\n\n            post[\"blog_name\"] = post[\"blogInfo\"][\"blogName\"]\n            post[\"date\"] = self.parse_timestamp(post[\"publishTime\"] / 1000)\n            post_type = post[\"type\"]\n\n            # Article\n            if post_type == 1:\n                content = post[\"content\"]\n                image_urls = text.extract_iter(content, '<img src=\"', '\"')\n                image_urls = [text.unescape(x) for x in image_urls]\n                image_urls = [x.partition(\"?\")[0] for x in image_urls]\n\n            # Photo\n            elif post_type == 2:\n                photo_links = util.json_loads(post[\"photoLinks\"])\n                image_urls = [x[\"orign\"] for x in photo_links]\n                image_urls = [x.partition(\"?\")[0] for x in image_urls]\n\n            # Video\n            elif post_type == 4:\n                embed = util.json_loads(post[\"embed\"])\n                image_urls = [embed[\"originUrl\"]]\n\n            # Answer\n            elif post_type == 5:\n                images = util.json_loads(post[\"images\"])\n                image_urls = [x[\"orign\"] for x in images]\n                image_urls = [x.partition(\"?\")[0] for x in image_urls]\n\n            else:\n                image_urls = ()\n                self.log.warning(\n                    \"%s: Unsupported post type '%s'.\",\n                    post[\"id\"], post_type)\n\n            post[\"count\"] = len(image_urls)\n            yield Message.Directory, \"\", post\n            for post[\"num\"], url in enumerate(image_urls, 1):\n                yield Message.Url, url, text.nameext_from_url(url, post)\n\n    def posts(self):\n        return ()\n\n\nclass LofterPostExtractor(LofterExtractor):\n    \"\"\"Extractor for a lofter post\"\"\"\n    subcategory = \"post\"\n    pattern = r\"(?:https?://)?[\\w-]+\\.lofter\\.com/post/([0-9a-f]+)_([0-9a-f]+)\"\n    example = \"https://BLOG.lofter.com/post/12345678_90abcdef\"\n\n    def posts(self):\n        blog_id, post_id = self.groups\n        post = self.api.post(int(blog_id, 16), int(post_id, 16))\n        return (post,)\n\n\nclass LofterBlogPostsExtractor(LofterExtractor):\n    \"\"\"Extractor for a lofter blog's posts\"\"\"\n    subcategory = \"blog-posts\"\n    pattern = (r\"(?:https?://)?(?:\"\n               # https://www.lofter.com/front/blog/home-page/<blog_name>\n               r\"www\\.lofter\\.com/front/blog/home-page/([\\w-]+)|\"\n               # https://<blog_name>.lofter.com/\n               r\"([\\w-]+)\\.lofter\\.com\"\n               r\")/?(?:$|\\?|#)\")\n    example = \"https://BLOG.lofter.com/\"\n\n    def posts(self):\n        blog_name = self.groups[0] or self.groups[1]\n        return self.api.blog_posts(blog_name)\n\n\nclass LofterAPI():\n\n    def __init__(self, extractor):\n        self.extractor = extractor\n\n    def blog_posts(self, blog_name):\n        endpoint = \"/v2.0/blogHomePage.api\"\n        params = {\n            \"method\": \"getPostLists\",\n            \"offset\": 0,\n            \"limit\": 200,\n            \"blogdomain\": blog_name + \".lofter.com\",\n        }\n        return self._pagination(endpoint, params)\n\n    def post(self, blog_id, post_id):\n        endpoint = \"/oldapi/post/detail.api\"\n        params = {\n            \"targetblogid\": blog_id,\n            \"postid\": post_id,\n        }\n        return self._call(endpoint, params)[\"posts\"][0]\n\n    def _call(self, endpoint, data):\n        url = \"https://api.lofter.com\" + endpoint\n        params = {\n            'product': 'lofter-android-7.9.10'\n        }\n        response = self.extractor.request(\n            url, method=\"POST\", params=params, data=data)\n        info = response.json()\n\n        if info[\"meta\"][\"status\"] == 4200:\n            raise self.extractor.exc.NotFoundError(\"blog\")\n\n        if info[\"meta\"][\"status\"] != 200:\n            self.extractor.log.debug(\"Server response: %s\", info)\n            raise self.extractor.exc.AbortExtraction(\"API request failed\")\n\n        return info[\"response\"]\n\n    def _pagination(self, endpoint, params):\n        while True:\n            data = self._call(endpoint, params)\n            posts = data[\"posts\"]\n\n            yield from posts\n\n            if data[\"offset\"] < 0:\n                break\n\n            if params[\"offset\"] + len(posts) < data[\"offset\"]:\n                break\n            params[\"offset\"] = data[\"offset\"]\n"
  },
  {
    "path": "gallery_dl/extractor/lolisafe.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2021-2025 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for lolisafe/chibisafe instances\"\"\"\n\nfrom .common import BaseExtractor, Message\nfrom .. import text\n\n\nclass LolisafeExtractor(BaseExtractor):\n    \"\"\"Base class for lolisafe extractors\"\"\"\n    basecategory = \"lolisafe\"\n    directory_fmt = (\"{category}\", \"{album_name} ({album_id})\")\n    archive_fmt = \"{album_id}_{id}\"\n\n\nBASE_PATTERN = LolisafeExtractor.update({\n})\n\n\nclass LolisafeAlbumExtractor(LolisafeExtractor):\n    subcategory = \"album\"\n    pattern = BASE_PATTERN + \"/a/([^/?#]+)\"\n    example = \"https://xbunkr.com/a/ID\"\n\n    def __init__(self, match):\n        LolisafeExtractor.__init__(self, match)\n        self.album_id = self.groups[-1]\n\n    def _init(self):\n        domain = self.config(\"domain\")\n        if domain == \"auto\":\n            self.root = text.root_from_url(self.url)\n        elif domain:\n            self.root = text.ensure_http_scheme(domain)\n\n    def items(self):\n        files, data = self.fetch_album(self.album_id)\n\n        yield Message.Directory, \"\", data\n        for data[\"num\"], file in enumerate(files, 1):\n            url = file[\"file\"]\n            file.update(data)\n\n            if \"extension\" not in file:\n                text.nameext_from_url(url, file)\n\n            if \"name\" in file:\n                name = file[\"name\"]\n                file[\"name\"] = name.rpartition(\".\")[0] or name\n                _, sep, fid = file[\"filename\"].rpartition(\"-\")\n                if not sep or len(fid) == 12:\n                    if \"id\" not in file:\n                        file[\"id\"] = \"\"\n                    file[\"filename\"] = file[\"name\"]\n                else:\n                    file[\"id\"] = fid\n                    file[\"filename\"] = f\"{file['name']}-{fid}\"\n            elif \"id\" in file:\n                file[\"name\"] = file[\"filename\"]\n                if file[\"filename\"] != file[\"id\"]:\n                    file[\"filename\"] = f\"{file['name']}-{file['id']}\"\n            else:\n                file[\"name\"], sep, file[\"id\"] = \\\n                    file[\"filename\"].rpartition(\"-\")\n\n            yield Message.Url, url, file\n\n    def fetch_album(self, album_id):\n        url = f\"{self.root}/api/album/get/{album_id}\"\n        data = self.request_json(url)\n\n        return data[\"files\"], {\n            \"album_id\"  : self.album_id,\n            \"album_name\": text.unescape(data[\"title\"]),\n            \"count\"     : data[\"count\"],\n        }\n"
  },
  {
    "path": "gallery_dl/extractor/luscious.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2016-2026 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://members.luscious.net/\"\"\"\n\nfrom .common import Extractor, Message\nfrom .. import text\n\n\nclass LusciousExtractor(Extractor):\n    \"\"\"Base class for luscious extractors\"\"\"\n    category = \"luscious\"\n    cookies_domain = \".luscious.net\"\n    root = \"https://members.luscious.net\"\n\n    def _request_graphql(self, opname, variables):\n        data = {\n            \"id\"           : 1,\n            \"operationName\": opname,\n            \"query\"        : self.utils(\"graphql\", opname),\n            \"variables\"    : variables,\n        }\n        response = self.request(\n            f\"{self.root}/graphql/nobatch/?operationName={opname}\",\n            method=\"POST\", json=data, fatal=False,\n        )\n\n        if response.status_code >= 400:\n            self.log.debug(\"Server response: %s\", response.text)\n            raise self.exc.AbortExtraction(\n                f\"GraphQL query failed \"\n                f\"('{response.status_code} {response.reason}')\")\n\n        return response.json()[\"data\"]\n\n\nclass LusciousAlbumExtractor(LusciousExtractor):\n    \"\"\"Extractor for image albums from luscious.net\"\"\"\n    subcategory = \"album\"\n    filename_fmt = \"{category}_{album[id]}_{num:>03}.{extension}\"\n    directory_fmt = (\"{category}\", \"{album[id]} {album[title]}\")\n    archive_fmt = \"{album[id]}_{id}\"\n    pattern = (r\"(?:https?://)?(?:www\\.|members\\.)?luscious\\.net\"\n               r\"/(?:albums|pictures/c/[^/?#]+/album)/[^/?#]+_(\\d+)\")\n    example = \"https://luscious.net/albums/TITLE_12345/\"\n\n    def _init(self):\n        self.album_id = self.groups[0]\n        self.gif = self.config(\"gif\", False)\n\n    def items(self):\n        album = self.metadata()\n        yield Message.Directory, \"\", {\"album\": album}\n        for num, image in enumerate(self.images(), 1):\n            image[\"num\"] = num\n            image[\"album\"] = album\n\n            try:\n                image[\"thumbnail\"] = image.pop(\"thumbnails\")[0][\"url\"]\n            except LookupError:\n                image[\"thumbnail\"] = \"\"\n\n            image[\"tags\"] = [item[\"text\"] for item in image[\"tags\"]]\n            image[\"date\"] = self.parse_timestamp(image[\"created\"])\n            image[\"id\"] = text.parse_int(image[\"id\"])\n\n            url = ((image[\"url_to_original\"] or image[\"url_to_video\"]\n                    if self.gif else\n                    image[\"url_to_video\"] or image[\"url_to_original\"]) or\n                   image[\"thumbnail\"])\n\n            yield Message.Url, url, text.nameext_from_url(url, image)\n\n    def metadata(self):\n        variables = {\n            \"id\": self.album_id,\n        }\n\n        album = self._request_graphql(\"AlbumGet\", variables)[\"album\"][\"get\"]\n        if \"errors\" in album:\n            raise self.exc.NotFoundError(\"album\")\n\n        album[\"audiences\"] = [item[\"title\"] for item in album[\"audiences\"]]\n        album[\"genres\"] = [item[\"title\"] for item in album[\"genres\"]]\n        album[\"tags\"] = [item[\"text\"] for item in album[\"tags\"]]\n\n        album[\"cover\"] = album[\"cover\"][\"url\"]\n        album[\"content\"] = album[\"content\"][\"title\"]\n        album[\"language\"] = album[\"language\"][\"title\"].partition(\" \")[0]\n        album[\"created_by\"] = album[\"created_by\"][\"display_name\"]\n\n        album[\"id\"] = text.parse_int(album[\"id\"])\n        album[\"date\"] = self.parse_timestamp(album[\"created\"])\n\n        return album\n\n    def images(self):\n        variables = {\n            \"input\": {\n                \"filters\": [{\n                    \"name\" : \"album_id\",\n                    \"value\": self.album_id,\n                }],\n                \"display\": \"position\",\n                \"page\"   : 1,\n            },\n        }\n\n        while True:\n            data = self._request_graphql(\"AlbumListOwnPictures\", variables)\n            yield from data[\"picture\"][\"list\"][\"items\"]\n\n            if not data[\"picture\"][\"list\"][\"info\"][\"has_next_page\"]:\n                return\n            variables[\"input\"][\"page\"] += 1\n\n\nclass LusciousSearchExtractor(LusciousExtractor):\n    \"\"\"Extractor for album searches on luscious.net\"\"\"\n    subcategory = \"search\"\n    pattern = (r\"(?:https?://)?(?:www\\.|members\\.)?luscious\\.net\"\n               r\"/albums/list/?(?:\\?([^#]+))?\")\n    example = \"https://luscious.net/albums/list/?tagged=TAG\"\n\n    def items(self):\n        query = text.parse_query(self.groups[0])\n        display = query.pop(\"display\", \"date_newest\")\n        page = query.pop(\"page\", None)\n\n        variables = {\n            \"input\": {\n                \"display\": display,\n                \"filters\": [{\"name\": n, \"value\": v} for n, v in query.items()],\n                \"page\": text.parse_int(page, 1),\n            },\n        }\n\n        while True:\n            data = self._request_graphql(\"AlbumListWithPeek\", variables)\n\n            for album in data[\"album\"][\"list\"][\"items\"]:\n                album[\"url\"] = self.root + album[\"url\"]\n                album[\"_extractor\"] = LusciousAlbumExtractor\n                yield Message.Queue, album[\"url\"], album\n\n            if not data[\"album\"][\"list\"][\"info\"][\"has_next_page\"]:\n                return\n            variables[\"input\"][\"page\"] += 1\n"
  },
  {
    "path": "gallery_dl/extractor/lynxchan.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for LynxChan Imageboards\"\"\"\n\nfrom .common import BaseExtractor, Message\nfrom .. import text\nimport itertools\n\n\nclass LynxchanExtractor(BaseExtractor):\n    \"\"\"Base class for LynxChan extractors\"\"\"\n    basecategory = \"lynxchan\"\n\n\nBASE_PATTERN = LynxchanExtractor.update({\n    \"bbw-chan\": {\n        \"root\": \"https://bbw-chan.link\",\n        \"pattern\": r\"bbw-chan\\.(?:link|nl)\",\n    },\n    \"kohlchan\": {\n        \"root\": \"https://kohlchan.net\",\n        \"pattern\": r\"kohlchan\\.net\",\n    },\n    \"endchan\": {\n        \"root\": None,\n        \"pattern\": r\"endchan\\.(?:org|net|gg)\",\n    },\n})\n\n\nclass LynxchanThreadExtractor(LynxchanExtractor):\n    \"\"\"Extractor for LynxChan threads\"\"\"\n    subcategory = \"thread\"\n    directory_fmt = (\"{category}\", \"{boardUri}\",\n                     \"{threadId} {subject|message[:50]}\")\n    filename_fmt = \"{postId}{num:?-//} {filename}.{extension}\"\n    archive_fmt = \"{boardUri}_{postId}_{num}\"\n    pattern = BASE_PATTERN + r\"/([^/?#]+)/res/(\\d+)\"\n    example = \"https://endchan.org/a/res/12345.html\"\n\n    def items(self):\n        url = f\"{self.root}/{self.groups[-2]}/res/{self.groups[-1]}.json\"\n        thread = self.request_json(url)\n        thread[\"postId\"] = thread[\"threadId\"]\n        posts = thread.pop(\"posts\", ())\n\n        yield Message.Directory, \"\", thread\n        for post in itertools.chain((thread,), posts):\n            if files := post.pop(\"files\", ()):\n                thread.update(post)\n                for num, file in enumerate(files):\n                    file.update(thread)\n                    file[\"num\"] = num\n                    url = self.root + file[\"path\"]\n                    text.nameext_from_url(file[\"originalName\"], file)\n                    yield Message.Url, url, file\n\n\nclass LynxchanBoardExtractor(LynxchanExtractor):\n    \"\"\"Extractor for LynxChan boards\"\"\"\n    subcategory = \"board\"\n    pattern = BASE_PATTERN + r\"/([^/?#]+)(?:/index|/catalog|/\\d+|/?$)\"\n    example = \"https://endchan.org/a/\"\n\n    def items(self):\n        board = self.groups[-1]\n        url = f\"{self.root}/{board}/catalog.json\"\n        for thread in self.request_json(url):\n            url = f\"{self.root}/{board}/res/{thread['threadId']}.html\"\n            thread[\"_extractor\"] = LynxchanThreadExtractor\n            yield Message.Queue, url, thread\n"
  },
  {
    "path": "gallery_dl/extractor/madokami.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2025 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://manga.madokami.al/\"\"\"\n\nfrom .common import Extractor, Message\nfrom .. import text, util\n\nBASE_PATTERN = r\"(?:https?://)?manga\\.madokami\\.al\"\n\n\nclass MadokamiExtractor(Extractor):\n    \"\"\"Base class for madokami extractors\"\"\"\n    category = \"madokami\"\n    root = \"https://manga.madokami.al\"\n\n\nclass MadokamiMangaExtractor(MadokamiExtractor):\n    \"\"\"Extractor for madokami manga\"\"\"\n    subcategory = \"manga\"\n    directory_fmt = (\"{category}\", \"{manga}\")\n    archive_fmt = \"{chapter_id}\"\n    pattern = BASE_PATTERN + r\"/Manga/(\\w/\\w{2}/\\w{4}/.+)\"\n    example = \"https://manga.madokami.al/Manga/A/AB/ABCD/ABCDE_TITLE\"\n\n    def items(self):\n        username, password = self._get_auth_info()\n        if not username:\n            raise self.exc.AuthRequired(\"username & password\")\n        self.session.auth = util.HTTPBasicAuth(username, password)\n\n        url = f\"{self.root}/Manga/{self.groups[0]}\"\n        page = self.request(url).text\n        extr = text.extract_from(page)\n\n        chapters = []\n        while True:\n            if not (cid := extr('<tr data-record=\"', '\"')):\n                break\n            chapters.append({\n                \"chapter_id\": text.parse_int(cid),\n                \"path\": text.unescape(extr('href=\"', '\"')),\n                \"chapter_string\": text.unescape(extr(\">\", \"<\")),\n                \"size\": text.parse_bytes(extr(\"<td>\", \"</td>\")),\n                \"date\": self.parse_datetime_iso(extr(\"<td>\", \"</td>\").strip()),\n            })\n\n        if self.config(\"chapter-reverse\"):\n            chapters.reverse()\n\n        self.kwdict.update({\n            \"manga\" : text.unescape(extr('itemprop=\"name\">', \"<\")),\n            \"year\"  : text.parse_int(extr(\n                'itemprop=\"datePublished\" content=\"', \"-\")),\n            \"author\": text.split_html(extr('<p class=\"staff', \"</p>\"))[1::2],\n            \"genre\" : text.split_html(extr(\"<h3>Genres</h3>\", \"</div>\")),\n            \"tags\"  : text.split_html(extr(\"<h3>Tags</h3>\", \"</div>\")),\n            \"complete\": extr('span class=\"scanstatus\">', \"<\").lower() == \"yes\",\n        })\n\n        search_chstr = text.re(\n            r\"(?i)((?:v(?:ol)?\\.?\\s*(\\d+))\"\n            r\"(?:\\s+ch?\\.?\\s*(\\d+)(?:-(\\d+))?)?)\").search\n        search_chstr_min = text.re(\n            r\"(?i)(ch?\\.?\\s*(\\d+)(?:-(\\d+))?)\").search\n\n        for ch in chapters:\n\n            chstr = ch[\"chapter_string\"]\n            if match := search_chstr(chstr):\n                ch[\"chapter_string\"], volume, chapter, end = match.groups()\n                ch[\"volume\"] = text.parse_int(volume)\n                ch[\"chapter\"] = text.parse_int(chapter)\n                ch[\"chapter_end\"] = text.parse_int(end)\n            elif match := search_chstr_min(chstr):\n                ch[\"chapter_string\"], chapter, end = match.groups()\n                ch[\"volume\"] = 0\n                ch[\"chapter\"] = text.parse_int(chapter)\n                ch[\"chapter_end\"] = text.parse_int(end)\n            else:\n                ch[\"volume\"] = ch[\"chapter\"] = ch[\"chapter_end\"] = 0\n\n            url = self.root + ch[\"path\"]\n            text.nameext_from_url(url, ch)\n\n            yield Message.Directory, \"\", ch\n            yield Message.Url, url, ch\n"
  },
  {
    "path": "gallery_dl/extractor/mangadex.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2018-2026 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://mangadex.org/\"\"\"\n\nfrom .common import Extractor, Message\nfrom .. import text, util\nfrom collections import defaultdict\n\nBASE_PATTERN = r\"(?:https?://)?(?:www\\.)?mangadex\\.(?:org|cc)\"\n\n\nclass MangadexExtractor(Extractor):\n    \"\"\"Base class for mangadex extractors\"\"\"\n    category = \"mangadex\"\n    directory_fmt = (\n        \"{category}\", \"{manga}\",\n        \"{volume:?v/ />02}c{chapter:>03}{chapter_minor}{title:?: //}\")\n    filename_fmt = (\n        \"{manga}_c{chapter:>03}{chapter_minor}_{page:>03}.{extension}\")\n    archive_fmt = \"{chapter_id}_{page}\"\n    root = \"https://mangadex.org\"\n    useragent = util.USERAGENT_GALLERYDL\n    _cache = {}\n\n    def _init(self):\n        self.uuid = self.groups[0]\n        self.api = MangadexAPI(self)\n\n    def _items_chapters(self):\n        for chapter in self.chapters():\n            uuid = chapter[\"id\"]\n            data = self._transform(chapter)\n            data[\"_extractor\"] = MangadexChapterExtractor\n            self._cache[uuid] = data\n            yield Message.Queue, f\"{self.root}/chapter/{uuid}\", data\n\n    def _items_manga(self):\n        data = {\"_extractor\": MangadexMangaExtractor}\n        for manga in self.manga():\n            url = f\"{self.root}/title/{manga['id']}\"\n            yield Message.Queue, url, data\n\n    items = _items_chapters\n\n    def _transform(self, chapter):\n        relationships = defaultdict(list)\n        for item in chapter[\"relationships\"]:\n            relationships[item[\"type\"]].append(item)\n\n        cattributes = chapter[\"attributes\"]\n        if lang := cattributes.get(\"translatedLanguage\"):\n            lang = lang.partition(\"-\")[0]\n\n        if cattributes[\"chapter\"]:\n            chnum, sep, minor = cattributes[\"chapter\"].partition(\".\")\n        else:\n            chnum, sep, minor = 0, \"\", \"\"\n\n        return {\n            **self.cache(self._manga_info, relationships[\"manga\"][0][\"id\"]),\n            \"title\"   : cattributes[\"title\"],\n            \"volume\"  : text.parse_int(cattributes[\"volume\"]),\n            \"chapter\" : text.parse_int(chnum),\n            \"chapter_minor\": sep + minor,\n            \"chapter_id\": chapter[\"id\"],\n            \"date\"    : self.parse_datetime_iso(cattributes[\"publishAt\"]),\n            \"group\"   : [group[\"attributes\"][\"name\"]\n                         for group in relationships[\"scanlation_group\"]],\n            \"lang\"    : lang,\n            \"count\"   : cattributes[\"pages\"],\n            \"_external_url\": cattributes.get(\"externalUrl\"),\n        }\n\n    def _manga_info(self, uuid):\n        manga = self.cache(self.api.manga, uuid)\n\n        rel = defaultdict(list)\n        for item in manga[\"relationships\"]:\n            rel[item[\"type\"]].append(item)\n        mattr = manga[\"attributes\"]\n\n        return {\n            \"manga\" : (mattr[\"title\"].get(\"en\") or\n                       next(iter(mattr[\"title\"].values()), \"\")),\n            \"manga_id\": manga[\"id\"],\n            \"manga_titles\": [t.popitem()[1]\n                             for t in mattr.get(\"altTitles\") or ()],\n            \"manga_date\"  : self.parse_datetime_iso(mattr.get(\"createdAt\")),\n            \"description\" : (mattr[\"description\"].get(\"en\") or\n                             next(iter(mattr[\"description\"].values()), \"\")),\n            \"demographic\": mattr.get(\"publicationDemographic\"),\n            \"origin\": mattr.get(\"originalLanguage\"),\n            \"status\": mattr.get(\"status\"),\n            \"year\"  : mattr.get(\"year\"),\n            \"rating\": mattr.get(\"contentRating\"),\n            \"links\" : mattr.get(\"links\"),\n            \"tags\"  : [t[\"attributes\"][\"name\"][\"en\"] for t in mattr[\"tags\"]],\n            \"artist\": [a[\"attributes\"][\"name\"] for a in rel[\"artist\"]],\n            \"author\": [a[\"attributes\"][\"name\"] for a in rel[\"author\"]],\n        }\n\n\nclass MangadexCoversExtractor(MangadexExtractor):\n    \"\"\"Extractor for mangadex manga covers\"\"\"\n    subcategory = \"covers\"\n    directory_fmt = (\"{category}\", \"{manga}\", \"Covers\")\n    filename_fmt = \"{volume:>02}_{lang}.{extension}\"\n    archive_fmt = \"c_{cover_id}\"\n    pattern = (BASE_PATTERN + r\"/(?:title|manga)/(?!follows|feed$)([0-9a-f-]+)\"\n               r\"(?:/[^/?#]+)?\\?tab=art\")\n    example = (\"https://mangadex.org/title\"\n               \"/01234567-89ab-cdef-0123-456789abcdef?tab=art\")\n\n    def items(self):\n        base = f\"{self.root}/covers/{self.uuid}/\"\n        for cover in self.api.covers_manga(self.uuid):\n            data = self._transform_cover(cover)\n            name = data[\"cover\"]\n            text.nameext_from_url(name, data)\n            data[\"cover_id\"] = data[\"filename\"]\n            yield Message.Directory, \"\", data\n            yield Message.Url, base + name, data\n\n    def _transform_cover(self, cover):\n        relationships = defaultdict(list)\n        for item in cover[\"relationships\"]:\n            relationships[item[\"type\"]].append(item)\n        cattributes = cover[\"attributes\"]\n\n        return {\n            **self.cache(self._manga_info, relationships[\"manga\"][0][\"id\"]),\n            \"cover\"   : cattributes[\"fileName\"],\n            \"lang\"    : cattributes.get(\"locale\"),\n            \"volume\"  : text.parse_int(cattributes[\"volume\"]),\n            \"date\"    : self.parse_datetime_iso(cattributes[\"createdAt\"]),\n            \"date_updated\": self.parse_datetime_iso(cattributes[\"updatedAt\"]),\n        }\n\n\nclass MangadexChapterExtractor(MangadexExtractor):\n    \"\"\"Extractor for manga-chapters from mangadex.org\"\"\"\n    subcategory = \"chapter\"\n    pattern = BASE_PATTERN + r\"/chapter/([0-9a-f-]+)\"\n    example = (\"https://mangadex.org/chapter\"\n               \"/01234567-89ab-cdef-0123-456789abcdef\")\n\n    def items(self):\n        try:\n            data = self._cache.pop(self.uuid)\n        except KeyError:\n            chapter = self.api.chapter(self.uuid)\n            data = self._transform(chapter)\n\n        if data.get(\"_external_url\") and not data[\"count\"]:\n            raise self.exc.AbortExtraction(\n                f\"Chapter {data['chapter']}{data['chapter_minor']} is not \"\n                f\"available on MangaDex and can instead be read on the \"\n                f\"official publisher's website at {data['_external_url']}.\")\n\n        yield Message.Directory, \"\", data\n\n        if self.config(\"data-saver\", False):\n            path = \"data-saver\"\n            key = \"dataSaver\"\n        else:\n            path = key = \"data\"\n\n        server = self.api.athome_server(self.uuid)\n        chapter = server[\"chapter\"]\n        base = f\"{server['baseUrl']}/{path}/{chapter['hash']}/\"\n\n        enum = util.enumerate_reversed if self.config(\n            \"page-reverse\") else enumerate\n        for data[\"page\"], path in enum(chapter[key], 1):\n            text.nameext_from_url(path, data)\n            yield Message.Url, base + path, data\n\n\nclass MangadexMangaExtractor(MangadexExtractor):\n    \"\"\"Extractor for manga from mangadex.org\"\"\"\n    subcategory = \"manga\"\n    pattern = BASE_PATTERN + r\"/(?:title|manga)/(?!follows|feed$)([0-9a-f-]+)\"\n    example = (\"https://mangadex.org/title\"\n               \"/01234567-89ab-cdef-0123-456789abcdef\")\n\n    def items(self):\n        items = self._items_chapters()\n        if self.config(\"covers\", False):\n            import itertools\n            url = f\"{self.root}/title/{self.uuid}?tab=art\"\n            data = {\"_extractor\": MangadexCoversExtractor}\n            items = itertools.chain(((Message.Queue, url, data),), items)\n        return items\n\n    def chapters(self):\n        return self.api.manga_feed(self.uuid)\n\n\nclass MangadexFeedExtractor(MangadexExtractor):\n    \"\"\"Extractor for chapters from your Updates Feed\"\"\"\n    subcategory = \"feed\"\n    pattern = BASE_PATTERN + r\"/titles?/feed$()\"\n    example = \"https://mangadex.org/title/feed\"\n\n    def chapters(self):\n        return self.api.user_follows_manga_feed()\n\n\nclass MangadexFollowingExtractor(MangadexExtractor):\n    \"\"\"Extractor for followed manga from your Library\"\"\"\n    subcategory = \"following\"\n    pattern = BASE_PATTERN + r\"/titles?/follows(?:\\?([^#]+))?$\"\n    example = \"https://mangadex.org/title/follows\"\n\n    items = MangadexExtractor._items_manga\n\n    def manga(self):\n        return self.api.user_follows_manga()\n\n\nclass MangadexListExtractor(MangadexExtractor):\n    \"\"\"Extractor for mangadex MDLists\"\"\"\n    subcategory = \"list\"\n    pattern = (BASE_PATTERN +\n               r\"/list/([0-9a-f-]+)(?:/[^/?#]*)?(?:\\?tab=(\\w+))?\")\n    example = (\"https://mangadex.org/list\"\n               \"/01234567-89ab-cdef-0123-456789abcdef/NAME\")\n\n    def __init__(self, match):\n        if match[2] == \"feed\":\n            self.subcategory = \"list-feed\"\n        else:\n            self.items = self._items_manga\n        MangadexExtractor.__init__(self, match)\n\n    def chapters(self):\n        return self.api.list_feed(self.uuid)\n\n    def manga(self):\n        return [\n            item\n            for item in self.api.list(self.uuid)[\"relationships\"]\n            if item[\"type\"] == \"manga\"\n        ]\n\n\nclass MangadexAuthorExtractor(MangadexExtractor):\n    \"\"\"Extractor for mangadex authors\"\"\"\n    subcategory = \"author\"\n    pattern = BASE_PATTERN + r\"/author/([0-9a-f-]+)\"\n    example = (\"https://mangadex.org/author\"\n               \"/01234567-89ab-cdef-0123-456789abcdef/NAME\")\n\n    def items(self):\n        for manga in self.api.manga_author(self.uuid):\n            manga[\"_extractor\"] = MangadexMangaExtractor\n            url = f\"{self.root}/title/{manga['id']}\"\n            yield Message.Queue, url, manga\n\n\nclass MangadexAPI():\n    \"\"\"Interface for the MangaDex API v5\n\n    https://api.mangadex.org/docs/\n    \"\"\"\n\n    def __init__(self, extr):\n        self.extractor = extr\n        self.headers = None\n        self.headers_auth = {}\n\n        self.username, self.password = extr._get_auth_info()\n        if self.username:\n            self.client_id = cid = extr.config(\"client-id\")\n            self.client_secret = extr.config(\"client-secret\")\n            if cid:\n                self._authenticate_impl = self._authenticate_impl_client\n            else:\n                self._authenticate_impl = self._authenticate_impl_legacy\n        else:\n            self.authenticate = util.noop\n\n        server = extr.config(\"api-server\")\n        self.root = (\"https://api.mangadex.org\" if server is None\n                     else text.ensure_http_scheme(server).rstrip(\"/\"))\n\n    def athome_server(self, uuid):\n        return self._call(\"/at-home/server/\" + uuid)\n\n    def author(self, uuid, manga=False):\n        params = {\"includes[]\": (\"manga\",)} if manga else None\n        return self._call(\"/author/\" + uuid, params)[\"data\"]\n\n    def chapter(self, uuid):\n        params = {\"includes[]\": (\"scanlation_group\",)}\n        return self._call(\"/chapter/\" + uuid, params)[\"data\"]\n\n    def covers_manga(self, uuid):\n        params = {\"manga[]\": uuid}\n        return self._pagination_covers(\"/cover\", params)\n\n    def list(self, uuid):\n        return self._call(\"/list/\" + uuid, None, True)[\"data\"]\n\n    def list_feed(self, uuid):\n        return self._pagination_chapters(f\"/list/{uuid}/feed\", None, True)\n\n    def manga(self, uuid):\n        params = {\"includes[]\": (\"artist\", \"author\")}\n        return self._call(\"/manga/\" + uuid, params)[\"data\"]\n\n    def manga_author(self, uuid_author):\n        params = {\"authorOrArtist\": uuid_author}\n        return self._pagination_manga(\"/manga\", params)\n\n    def manga_feed(self, uuid):\n        order = \"desc\" if self.extractor.config(\"chapter-reverse\") else \"asc\"\n        params = {\n            \"order[volume]\" : order,\n            \"order[chapter]\": order,\n        }\n        return self._pagination_chapters(f\"/manga/{uuid}/feed\", params)\n\n    def user_follows_manga(self):\n        params = {\"contentRating\": None}\n        return self._pagination_manga(\n            \"/user/follows/manga\", params, True)\n\n    def user_follows_manga_feed(self):\n        params = {\"order[publishAt]\": \"desc\"}\n        return self._pagination_chapters(\n            \"/user/follows/manga/feed\", params, True)\n\n    def authenticate(self):\n        self.headers_auth[\"Authorization\"] = self.cache(\n            self._authenticate_impl, self.username, self.password,\n            _exp=900, _mem=False)\n\n    def _authenticate_impl_client(self, username, password):\n        if refresh_token := self.extractor.cache(\n                _refresh_token_cache, (username, \"personal\"), _mem=False):\n            self.extractor.log.info(\"Refreshing access token\")\n            data = {\n                \"grant_type\"   : \"refresh_token\",\n                \"refresh_token\": refresh_token,\n                \"client_id\"    : self.client_id,\n                \"client_secret\": self.client_secret,\n            }\n        else:\n            self.extractor.log.info(\"Logging in as %s\", username)\n            data = {\n                \"grant_type\"   : \"password\",\n                \"username\"     : self.username,\n                \"password\"     : self.password,\n                \"client_id\"    : self.client_id,\n                \"client_secret\": self.client_secret,\n            }\n\n        self.extractor.log.debug(\"Using client-id '%s…'\", self.client_id[:24])\n        url = (\"https://auth.mangadex.org/realms/mangadex\"\n               \"/protocol/openid-connect/token\")\n        data = self.extractor.request_json(\n            url, method=\"POST\", data=data, fatal=None)\n\n        try:\n            access_token = data[\"access_token\"]\n        except Exception:\n            raise self.extractor.exc.AuthenticationError(\n                data.get(\"error_description\"))\n\n        if refresh_token != data.get(\"refresh_token\"):\n            self.extractor.cache_update(\n                _refresh_token_cache,\n                (username, \"personal\"), data[\"refresh_token\"], _exp=90*86400)\n\n        return \"Bearer \" + access_token\n\n    def _authenticate_impl_legacy(self, username, password):\n        if refresh_token := self.extractor.cache(\n                _refresh_token_cache, username, _mem=False):\n            self.extractor.log.info(\"Refreshing access token\")\n            url = self.root + \"/auth/refresh\"\n            json = {\"token\": refresh_token}\n        else:\n            self.extractor.log.info(\"Logging in as %s\", username)\n            url = self.root + \"/auth/login\"\n            json = {\"username\": username, \"password\": password}\n\n        self.extractor.log.debug(\"Using legacy login method\")\n        data = self.extractor.request_json(\n            url, method=\"POST\", json=json, fatal=None)\n        if data.get(\"result\") != \"ok\":\n            raise self.extractor.exc.AuthenticationError()\n\n        if refresh_token != data[\"token\"][\"refresh\"]:\n            self.extractor.cache_update(\n                _refresh_token_cache,\n                username, data[\"token\"][\"refresh\"], _exp=90*86400)\n\n        return \"Bearer \" + data[\"token\"][\"session\"]\n\n    def _call(self, endpoint, params=None, auth=False):\n        url = self.root + endpoint\n        headers = self.headers_auth if auth else self.headers\n\n        while True:\n            if auth:\n                self.authenticate()\n            response = self.extractor.request(\n                url, params=params, headers=headers, fatal=None)\n\n            if response.status_code < 400:\n                return response.json()\n            if response.status_code == 429:\n                until = response.headers.get(\"X-RateLimit-Retry-After\")\n                self.extractor.wait(until=until)\n                continue\n\n            msg = \", \".join(f'{error[\"title\"]}: \"{error[\"detail\"]}\"'\n                            for error in response.json()[\"errors\"])\n            raise self.extractor.exc.AbortExtraction(\n                f\"{response.status_code} {response.reason} ({msg})\")\n\n    def _pagination_chapters(self, endpoint, params=None, auth=False):\n        if params is None:\n            params = {}\n\n        lang = self.extractor.config(\"lang\")\n        if isinstance(lang, str) and \",\" in lang:\n            lang = lang.split(\",\")\n        params[\"translatedLanguage[]\"] = lang\n        params[\"includes[]\"] = (\"scanlation_group\",)\n\n        return self._pagination(endpoint, params, auth)\n\n    def _pagination_manga(self, endpoint, params=None, auth=False):\n        if params is None:\n            params = {}\n\n        return self._pagination(endpoint, params, auth)\n\n    def _pagination_covers(self, endpoint, params=None, auth=False):\n        if params is None:\n            params = {}\n\n        lang = self.extractor.config(\"lang\")\n        if isinstance(lang, str) and \",\" in lang:\n            lang = lang.split(\",\")\n        params[\"locales[]\"] = lang\n        params[\"contentRating\"] = None\n        params[\"order[volume]\"] = \\\n            \"desc\" if self.extractor.config(\"chapter-reverse\") else \"asc\"\n\n        return self._pagination(endpoint, params, auth)\n\n    def _pagination(self, endpoint, params, auth=False):\n        config = self.extractor.config\n\n        if \"contentRating\" not in params:\n            ratings = config(\"ratings\")\n            if ratings is None:\n                ratings = (\"safe\", \"suggestive\", \"erotica\", \"pornographic\")\n            elif isinstance(ratings, str):\n                ratings = ratings.split(\",\")\n            params[\"contentRating[]\"] = ratings\n        params[\"offset\"] = 0\n\n        if api_params := config(\"api-parameters\"):\n            for key in api_params:\n                if key in params:\n                    del params[key]\n            params.update(api_params)\n\n        while True:\n            data = self._call(endpoint, params, auth)\n            yield from data[\"data\"]\n\n            params[\"offset\"] = data[\"offset\"] + data[\"limit\"]\n            if params[\"offset\"] >= data[\"total\"]:\n                return\n\n\ndef _refresh_token_cache(username):\n    return None\n"
  },
  {
    "path": "gallery_dl/extractor/mangafire.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2025-2026 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://mangafire.to/\"\"\"\n\nfrom .common import ChapterExtractor, MangaExtractor\nfrom .. import text\n\nBASE_PATTERN = r\"(?:https?://)?(?:www\\.)?mangafire\\.to\"\n\n\nclass MangafireBase():\n    \"\"\"Base class for mangafire extractors\"\"\"\n    category = \"mangafire\"\n    root = \"https://mangafire.to\"\n\n    def _manga_info(self, manga_path, page=None):\n        if page is None:\n            url = f\"{self.root}/manga/{manga_path}\"\n            page = self.request(url).text\n        slug, _, mid = manga_path.rpartition(\".\")\n\n        extr = text.extract_from(page)\n        manga = {\n            \"cover\": text.extr(extr(\n                'class=\"poster\">', '</div>'), 'src=\"', '\"'),\n            \"status\": extr(\"<p>\", \"<\").replace(\"_\", \" \").title(),\n            \"manga\"     : text.unescape(extr(\n                'itemprop=\"name\">', \"<\")),\n            \"manga_id\": mid,\n            \"manga_slug\": slug,\n            \"manga_titles\": text.unescape(extr(\n                \"<h6>\", \"<\")).split(\"; \"),\n            \"type\": text.remove_html(extr(\n                'class=\"min-info\">', \"</a>\")),\n            \"author\": text.unescape(text.remove_html(extr(\n                \"<span>Author:</span>\", \"</div>\"))).split(\" , \"),\n            \"published\": text.remove_html(extr(\n                \"<span>Published:</span>\", \"</div>\")),\n            \"tags\": text.split_html(extr(\n                \"<span>Genres:</span>\", \"</div>\"))[::2],\n            \"publisher\": text.unescape(text.remove_html(extr(\n                \"<span>Mangazines:</span>\", \"</div>\"))).split(\" , \"),\n            \"score\": text.parse_float(text.remove_html(extr(\n                'class=\"score\">', \" / \"))),\n            \"description\": text.remove_html(extr(\n                'id=\"synopsis\">', \"<script>\")),\n        }\n\n        if len(lst := manga[\"author\"]) == 1 and not lst[0]:\n            manga[\"author\"] = ()\n        if len(lst := manga[\"publisher\"]) == 1 and not lst[0]:\n            manga[\"publisher\"] = ()\n\n        return manga\n\n    def _manga_chapters(self, manga_info):\n        manga_id, type, lang = manga_info\n        url = f\"{self.root}/ajax/read/{manga_id}/{type}/{lang}\"\n        params = {\"vrf\": self.utils(\"vrf\").generate(\n            f\"{manga_id}@{type}@{lang}\")}\n        headers = {\"x-requested-with\": \"XMLHttpRequest\"}\n        data = self.request_json(url, params=params, headers=headers)\n\n        needle = f\"{manga_id}/{lang}/\"\n        return {\n            text.extr(anchor, needle, '\"'): anchor\n            for anchor in text.extract_iter(data[\"result\"][\"html\"], \"<a \", \">\")\n        }\n\n    def _chapter_info(self, info):\n        _, lang, chapter_info = text.extr(info, 'href=\"', '\"').rsplit(\"/\", 2)\n\n        if chapter_info.startswith(\"vol\"):\n            volume = text.extr(info, 'data-number=\"', '\"')\n            volume_id = text.parse_int(text.extr(info, 'data-id=\"', '\"'))\n            return {\n                \"volume\"        : text.parse_int(volume),\n                \"volume_id\"     : volume_id,\n                \"chapter\"       : 0,\n                \"chapter_minor\" : \"\",\n                \"chapter_string\": chapter_info,\n                \"chapter_id\"    : volume_id,\n                \"title\"         : text.unescape(text.extr(\n                    info, 'title=\"', '\"')),\n                \"lang\"          : lang,\n            }\n\n        chapter, sep, minor = text.extr(\n            info, 'data-number=\"', '\"').partition(\".\")\n        return {\n            \"chapter\"       : text.parse_int(chapter),\n            \"chapter_minor\" : sep + minor,\n            \"chapter_string\": chapter_info,\n            \"chapter_id\"    : text.parse_int(text.extr(\n                info, 'data-id=\"', '\"')),\n            \"title\"         : text.unescape(text.extr(\n                info, 'title=\"', '\"')),\n            \"lang\"          : lang,\n        }\n\n\nclass MangafireChapterExtractor(MangafireBase, ChapterExtractor):\n    \"\"\"Extractor for mangafire manga chapters\"\"\"\n    directory_fmt = (\n        \"{category}\", \"{manga}\",\n        \"{volume:?v/ />02}{chapter:?c//>03}{chapter_minor:?//}{title:?: //}\")\n    filename_fmt = (\n        \"{manga}{volume:?_v//>02}{chapter:?_c//>03}{chapter_minor:?//}_\"\n        \"{page:>03}.{extension}\")\n    archive_fmt = (\n        \"{manga_id}_{chapter_id}_{page}\")\n    pattern = (BASE_PATTERN + r\"/read/([\\w-]+\\.(\\w+))/([\\w-]+)\"\n               r\"/((chapter|volume)-\\d+(?:\\D.*)?)\")\n    example = \"https://mangafire.to/read/MANGA.ID/LANG/chapter-123\"\n\n    def metadata(self, _):\n        manga_path, manga_id, lang, chapter_info, self.type = self.groups\n\n        try:\n            chapters = self.cache(\n                self._manga_chapters, (manga_id, self.type, lang))\n            anchor = chapters[chapter_info]\n        except KeyError:\n            raise self.exc.NotFoundError(\"chapter\")\n        self.chapter_id = text.extr(anchor, 'data-id=\"', '\"')\n\n        return {\n            **self.cache(self._manga_info, manga_path),\n            **self.cache(self._chapter_info, anchor),\n        }\n\n    def images(self, page):\n        url = f\"{self.root}/ajax/read/{self.type}/{self.chapter_id}\"\n        params = {\"vrf\": self.utils(\"vrf\").generate(\n            f\"{self.type}@{self.chapter_id}\")}\n        headers = {\"x-requested-with\": \"XMLHttpRequest\"}\n        data = self.request_json(url, params=params, headers=headers)\n\n        return [\n            (image[0], None)\n            for image in data[\"result\"][\"images\"]\n        ]\n\n\nclass MangafireMangaExtractor(MangafireBase, MangaExtractor):\n    \"\"\"Extractor for mangafire manga\"\"\"\n    chapterclass = MangafireChapterExtractor\n    pattern = BASE_PATTERN + r\"/manga/([\\w-]+)\\.(\\w+)\"\n    example = \"https://mangafire.to/manga/MANGA.ID\"\n\n    def chapters(self, page):\n        manga_slug, manga_id = self.groups\n        lang = self.config(\"lang\") or \"en\"\n\n        manga = self.cache(\n            self._manga_info, f\"{manga_slug}.{manga_id}\")\n        chapters = self.cache(\n            self._manga_chapters, (manga_id, \"chapter\", lang))\n\n        return [\n            (self.root + text.extr(anchor, 'href=\"', '\"'), {\n                **manga,\n                **self.cache(self._chapter_info, anchor),\n            })\n            for anchor in chapters.values()\n        ]\n"
  },
  {
    "path": "gallery_dl/extractor/mangafox.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2017-2025 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://fanfox.net/\"\"\"\n\nfrom .common import ChapterExtractor, MangaExtractor\nfrom .. import text\n\nBASE_PATTERN = r\"(?:https?://)?(?:www\\.|m\\.)?(?:fanfox\\.net|mangafox\\.me)\"\n\n\nclass MangafoxChapterExtractor(ChapterExtractor):\n    \"\"\"Extractor for manga chapters from fanfox.net\"\"\"\n    category = \"mangafox\"\n    root = \"https://m.fanfox.net\"\n    pattern = BASE_PATTERN + \\\n        r\"(/manga/[^/?#]+/((?:v([^/?#]+)/)?c(\\d+)([^/?#]*)))\"\n    example = \"https://fanfox.net/manga/TITLE/v01/c001/1.html\"\n\n    def __init__(self, match):\n        base, self.cstr, self.volume, self.chapter, self.minor = match.groups()\n        self.urlbase = self.root + base\n        ChapterExtractor.__init__(self, match, self.urlbase + \"/1.html\")\n\n    def metadata(self, page):\n        manga, pos = text.extract(page, \"<title>\", \"</title>\")\n        count, pos = text.extract(\n            page, \">\", \"<\", page.find(\"</select>\", pos) - 40)\n        sid  , pos = text.extract(page, \"var series_id =\", \";\", pos)\n        cid  , pos = text.extract(page, \"var chapter_id =\", \";\", pos)\n\n        return {\n            \"manga\"         : text.unescape(manga),\n            \"volume\"        : text.parse_int(self.volume),\n            \"chapter\"       : text.parse_int(self.chapter),\n            \"chapter_minor\" : self.minor or \"\",\n            \"chapter_string\": self.cstr,\n            \"count\"         : text.parse_int(count),\n            \"sid\"           : text.parse_int(sid),\n            \"cid\"           : text.parse_int(cid),\n        }\n\n    def images(self, page):\n        pnum = 1\n        while True:\n            url, pos = text.extract(page, '<img src=\"', '\"')\n            yield text.ensure_http_scheme(text.unescape(url)), None\n            url, pos = text.extract(page, ' src=\"', '\"', pos)\n            yield text.ensure_http_scheme(text.unescape(url)), None\n\n            pnum += 2\n            page = self.request(f\"{self.urlbase}/{pnum}.html\").text\n\n\nclass MangafoxMangaExtractor(MangaExtractor):\n    \"\"\"Extractor for manga from fanfox.net\"\"\"\n    category = \"mangafox\"\n    root = \"https://m.fanfox.net\"\n    chapterclass = MangafoxChapterExtractor\n    pattern = BASE_PATTERN + r\"(/manga/[^/?#]+)/?$\"\n    example = \"https://fanfox.net/manga/TITLE\"\n\n    def chapters(self, page):\n        results = []\n        chapter_match = MangafoxChapterExtractor.pattern.match\n\n        extr = text.extract_from(page)\n        manga = extr('<p class=\"title\">', '</p>')\n        author = extr('<p>Author(s):', '</p>')\n        extr('<dd class=\"chlist\">', '')\n\n        genres, _, summary = text.extr(\n            page, '<div class=\"manga-genres\">', '</section>'\n        ).partition('<div class=\"manga-summary\">')\n\n        data = {\n            \"manga\"      : text.unescape(manga),\n            \"author\"     : text.remove_html(author),\n            \"description\": text.unescape(text.remove_html(summary)),\n            \"tags\"       : text.split_html(genres),\n            \"lang\"       : \"en\",\n            \"language\"   : \"English\",\n        }\n\n        while True:\n            url = \"https://\" + extr('<a href=\"//', '\"')\n            match = chapter_match(url)\n            if not match:\n                return results\n            _, cstr, volume, chapter, minor = match.groups()\n\n            chapter = {\n                \"volume\"        : text.parse_int(volume),\n                \"chapter\"       : text.parse_int(chapter),\n                \"chapter_minor\" : minor or \"\",\n                \"chapter_string\": cstr,\n                \"date\"          : self.parse_datetime(\n                    extr('right\">', '</span>'), \"%b %d, %Y\"),\n            }\n            chapter.update(data)\n            results.append((url, chapter))\n"
  },
  {
    "path": "gallery_dl/extractor/mangafreak.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://ww2.mangafreak.me/\"\"\"\n\nfrom .common import ChapterExtractor, MangaExtractor\nfrom .. import text\n\nBASE_PATTERN = r\"(?:https?://)?(?:ww[\\dw]\\.)?mangafreak\\.me\"\n\n\nclass MangafreakBase():\n    \"\"\"Base class for mangafreak extractors\"\"\"\n    category = \"mangafreak\"\n    root = \"https://ww2.mangafreak.me\"\n\n\nclass MangafreakChapterExtractor(MangafreakBase, ChapterExtractor):\n    \"\"\"Extractor for mangafreak manga chapters\"\"\"\n    pattern = BASE_PATTERN + r\"(/Read1_([^/?#]+)_((\\d+)([a-z])?))\"\n    example = \"https://ww2.mangafreak.me/Read1_Onepunch_Man_1\"\n\n    def metadata(self, page):\n        manga = text.extr(page, \"<title>Read \", \" Chapter \")\n        title = text.extr(page, 'selected=\"selected\">', \"<\").partition(\": \")[2]\n        _, manga_slug, chapter_string, chapter, minor = self.groups\n\n        return {\n            \"manga\"        : text.unescape(manga),\n            \"manga_slug\"   : manga_slug,\n            \"title\"        : text.unescape(title) if title else \"\",\n            \"chapter\"      : text.parse_int(chapter),\n            \"chapter_minor\": \"\" if minor is None else minor,\n            \"chapter_string\": chapter_string,\n            \"lang\"         : \"en\",\n            \"language\"     : \"English\",\n        }\n\n    def images(self, page):\n        base = \"https://images.mangafreak.me/mangas/\"\n        return [\n            (base + path, None)\n            for path in text.extract_iter(page, 'src=\"' + base, '\"')\n        ]\n\n\nclass MangafreakMangaExtractor(MangafreakBase, MangaExtractor):\n    \"\"\"Extractor for mangafreak manga series\"\"\"\n    chapterclass = MangafreakChapterExtractor\n    pattern = BASE_PATTERN + r\"(/Manga/([^/?#]+))\"\n    example = \"https://ww2.mangafreak.me/Manga/Onepunch_Man\"\n\n    def chapters(self, page):\n        table = text.extr(page, \"<table>\", \"</table>\")\n        if not table:\n            return ()\n\n        data = {\n            \"manga\"     : text.unescape(text.extr(page, \"<title>\", \" Manga\")),\n            \"manga_slug\": self.groups[1],\n            \"lang\"      : \"en\",\n            \"language\"  : \"English\",\n        }\n\n        results = []\n        chapter_match = text.re(r\"(\\d+)(\\w*)\").match\n        for row in text.extract_iter(table, \"<tr>\", \"</tr>\"):\n            href = text.extr(row, '<a href=\"', '\"')\n            if not href:\n                continue\n            url = self.root + href\n            chapter_string = href.rpartition(\"_\")[2]\n            chapter, minor = chapter_match(chapter_string).groups()\n            title = text.extr(row, '\">', '<').partition(\" - \")[2]\n            results.append((url, {\n                \"chapter\"       : text.parse_int(chapter),\n                \"chapter_minor\" : minor,\n                \"chapter_string\": chapter_string,\n                \"title\"         : text.unescape(title) if title else \"\",\n                **data,\n            }))\n        return results\n"
  },
  {
    "path": "gallery_dl/extractor/mangahere.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2015-2025 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://www.mangahere.cc/\"\"\"\n\nfrom .common import ChapterExtractor, MangaExtractor\nfrom .. import text\n\n\nclass MangahereBase():\n    \"\"\"Base class for mangahere extractors\"\"\"\n    category = \"mangahere\"\n    root = \"https://www.mangahere.cc\"\n    root_mobile = \"https://m.mangahere.cc\"\n\n\nclass MangahereChapterExtractor(MangahereBase, ChapterExtractor):\n    \"\"\"Extractor for manga-chapters from mangahere.cc\"\"\"\n    pattern = (r\"(?:https?://)?(?:www\\.|m\\.)?mangahere\\.c[co]/manga/\"\n               r\"([^/]+(?:/v0*(\\d+))?/c([^/?#]+))\")\n    example = \"https://www.mangahere.cc/manga/TITLE/c001/1.html\"\n\n    def __init__(self, match):\n        self.part, self.volume, self.chapter = match.groups()\n        self.base = f\"{self.root_mobile}/manga/{self.part}/\"\n        ChapterExtractor.__init__(self, match, self.base + \"1.html\")\n\n    def _init(self):\n        self.session.headers[\"Referer\"] = self.root_mobile + \"/\"\n\n    def metadata(self, page):\n        pos = page.index(\"</select>\")\n        count     , pos = text.extract(page, \">\", \"<\", pos - 40)\n        manga_id  , pos = text.extract(page, \"series_id = \", \";\", pos)\n        chapter_id, pos = text.extract(page, \"chapter_id = \", \";\", pos)\n        manga     , pos = text.extract(page, '\"name\":\"', '\"', pos)\n        chapter, dot, minor = self.chapter.partition(\".\")\n\n        return {\n            \"manga\": text.unescape(manga),\n            \"manga_id\": text.parse_int(manga_id),\n            \"title\": self._get_title(),\n            \"volume\": text.parse_int(self.volume),\n            \"chapter\": text.parse_int(chapter),\n            \"chapter_minor\": dot + minor,\n            \"chapter_id\": text.parse_int(chapter_id),\n            \"count\": text.parse_int(count),\n            \"lang\": \"en\",\n            \"language\": \"English\",\n        }\n\n    def images(self, page):\n        pnum = 1\n\n        while True:\n            url, pos = text.extract(page, '<img src=\"', '\"')\n            yield text.ensure_http_scheme(text.unescape(url)), None\n            url, pos = text.extract(page, ' src=\"', '\"', pos)\n            yield text.ensure_http_scheme(text.unescape(url)), None\n            pnum += 2\n            page = self.request(f\"{self.base}{pnum}.html\").text\n\n    def _get_title(self):\n        url = f\"{self.root}/manga/{self.part}/\"\n        page = self.request(url).text\n\n        try:\n            pos = page.index(self.part) + len(self.part)\n            pos = page.index(self.part, pos) + len(self.part)\n            return text.extract(page, ' title=\"', '\"', pos)[0]\n        except ValueError:\n            return \"\"\n\n\nclass MangahereMangaExtractor(MangahereBase, MangaExtractor):\n    \"\"\"Extractor for manga from mangahere.cc\"\"\"\n    chapterclass = MangahereChapterExtractor\n    pattern = (r\"(?:https?://)?(?:www\\.|m\\.)?mangahere\\.c[co]\"\n               r\"(/manga/[^/?#]+/?)(?:#.*)?$\")\n    example = \"https://www.mangahere.cc/manga/TITLE\"\n\n    def _init(self):\n        self.cookies.set(\"isAdult\", \"1\", domain=\"www.mangahere.cc\")\n\n    def chapters(self, page):\n        results = []\n        manga, pos = text.extract(page, '<meta name=\"og:title\" content=\"', '\"')\n        manga = text.unescape(manga)\n\n        page = text.extract(\n            page, 'id=\"chapterlist\"', 'class=\"detail-main-list-more\"', pos)[0]\n        pos = 0\n        while True:\n            url, pos = text.extract(page, ' href=\"', '\"', pos)\n            if not url:\n                return results\n            info, pos = text.extract(page, 'class=\"title3\">', '<', pos)\n            date, pos = text.extract(page, 'class=\"title2\">', '<', pos)\n\n            match = text.re(\n                r\"(?:Vol\\.0*(\\d+) )?Ch\\.0*(\\d+)(\\S*)(?: - (.*))?\").match(info)\n            if match:\n                volume, chapter, minor, title = match.groups()\n            else:\n                chapter, _, minor = url[:-1].rpartition(\"/c\")[2].partition(\".\")\n                minor = \".\" + minor\n                volume = 0\n                title = \"\"\n\n            results.append((text.urljoin(self.root, url), {\n                \"manga\": manga,\n                \"title\": text.unescape(title) if title else \"\",\n                \"volume\": text.parse_int(volume),\n                \"chapter\": text.parse_int(chapter),\n                \"chapter_minor\": minor,\n                \"date\": date,\n                \"lang\": \"en\",\n                \"language\": \"English\",\n            }))\n"
  },
  {
    "path": "gallery_dl/extractor/manganelo.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2020 Jake Mannens\n# Copyright 2021-2026 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://www.mangakakalot.gg/ and mirror sites\"\"\"\n\nfrom .common import BaseExtractor, ChapterExtractor, MangaExtractor, Message\nfrom .. import text, util\n\n\nclass ManganeloExtractor(BaseExtractor):\n    basecategory = \"manganelo\"\n\n\nBASE_PATTERN = ManganeloExtractor.update({\n    \"nelomanga\": {\n        \"root\"   : \"https://www.nelomanga.net\",\n        \"pattern\": r\"(?:www\\.)?nelomanga\\.net\",\n    },\n    \"natomanga\": {\n        \"root\"   : \"https://www.natomanga.com\",\n        \"pattern\": r\"(?:www\\.)?natomanga\\.com\",\n    },\n    \"manganato\": {\n        \"root\"   : \"https://www.manganato.gg\",\n        \"pattern\": r\"(?:www\\.)?manganato\\.gg\",\n    },\n    \"mangakakalot\": {\n        \"root\"   : \"https://www.mangakakalot.gg\",\n        \"pattern\": r\"(?:www\\.)?mangakakalot\\.gg\",\n    },\n})\n\n\nclass ManganeloChapterExtractor(ManganeloExtractor, ChapterExtractor):\n    \"\"\"Extractor for manganelo manga chapters\"\"\"\n    pattern = BASE_PATTERN + r\"(/manga/[^/?#]+/chapter-[^/?#]+)\"\n    example = \"https://www.mangakakalot.gg/manga/MANGA_NAME/chapter-123\"\n\n    def __init__(self, match):\n        ManganeloExtractor.__init__(self, match)\n        self.page_url = self.root + self.groups[-1]\n\n    def metadata(self, page):\n        extr = text.extract_from(page)\n\n        data = {\n            \"date\"        : self.parse_datetime_iso(extr(\n                '\"datePublished\": \"', '\"')[:19]),\n            \"date_updated\": self.parse_datetime_iso(extr(\n                '\"dateModified\": \"', '\"')[:19]),\n            \"manga_id\"    : text.parse_int(extr(\"comic_id =\", \";\")),\n            \"chapter_id\"  : text.parse_int(extr(\"chapter_id =\", \";\")),\n            \"manga\"       : extr(\"comic_name =\", \";\").strip('\" '),\n            \"lang\"        : \"en\",\n            \"language\"    : \"English\",\n        }\n\n        chapter_name = extr(\"chapter_name =\", \";\").strip('\" ')\n        chapter, sep, minor = chapter_name.rpartition(\" \")[2].partition(\".\")\n        data[\"chapter\"] = text.parse_int(chapter)\n        data[\"chapter_minor\"] = sep + minor\n        data[\"author\"] = extr(\". Author:\", \" already has \").strip()\n\n        return data\n\n    def images(self, page):\n        extr = text.extract_from(page)\n        cdns = util.json_loads(extr(\"var cdns =\", \";\"))[0]\n        imgs = util.json_loads(extr(\"var chapterImages =\", \";\"))\n\n        if cdns[-1] != \"/\":\n            cdns += \"/\"\n\n        return [\n            (cdns + path, None)\n            for path in imgs\n        ]\n\n\nclass ManganeloMangaExtractor(ManganeloExtractor, MangaExtractor):\n    \"\"\"Extractor for manganelo manga\"\"\"\n    chapterclass = ManganeloChapterExtractor\n    pattern = BASE_PATTERN + r\"(/manga/[^/?#]+)$\"\n    example = \"https://www.mangakakalot.gg/manga/MANGA_NAME\"\n\n    def __init__(self, match):\n        ManganeloExtractor.__init__(self, match)\n        self.page_url = self.root + self.groups[-1]\n\n    def chapters(self, page):\n        extr = text.extract_from(page)\n\n        url = extr('property=\"og:url\" content=\"', '\"')\n        slug = url[url.rfind(\"/\")+1:]\n\n        info = {\n            \"manga\" : text.unescape(extr(\"<h1>\", \"<\")),\n            \"manga_url\" : url,\n            \"manga_slug\": slug,\n            \"author\": text.remove_html(extr(\"<li>Author(s) :\", \"</li>\")),\n            \"status\": extr(\"<li>Status :\", \"<\").strip(),\n            \"date_updated\": self.parse_datetime(extr(\n                \"<li>Last updated :\", \"<\").strip(), \"%b-%d-%Y %I:%M:%S %p\"),\n            \"tags\"  : text.split_html(extr(\">Genres :\", \"</li>\"))[::2],\n            \"lang\"  : \"en\",\n        }\n        info[\"tags\"].sort()\n\n        base = url + \"/\"\n        url = f\"{self.root}/api/manga/{slug}/chapters?limit=-1\"\n\n        results = []\n        data = self.request_json(url)[\"data\"]\n        for ch in data[\"chapters\"]:\n            slug = ch[\"chapter_slug\"]\n            chapter, sep, minor = slug[8:].partition(\"-\")\n            results.append((base + slug, {\n                **info,\n                \"chapter\": text.parse_int(chapter),\n                \"chapter_minor\": (sep and \".\") + minor,\n                \"date\"   : self.parse_datetime_iso(ch[\"updated_at\"]),\n                \"views\"  : ch[\"view\"],\n            }))\n        return results\n\n\nclass ManganeloBookmarkExtractor(ManganeloExtractor):\n    \"\"\"Extractor for manganelo bookmarks\"\"\"\n    subcategory = \"bookmark\"\n    pattern = BASE_PATTERN + r\"/bookmark\"\n    example = \"https://www.mangakakalot.gg/bookmark\"\n\n    def items(self):\n        data = {\"_extractor\": ManganeloMangaExtractor}\n\n        url = self.root + \"/bookmark\"\n        params = {\"page\": 1}\n\n        response = self.request(url, params=params)\n        if response.history:\n            raise self.exc.AuthRequired(\n                \"authenticated cookies\", \"your bookmarks\")\n        page = response.text\n        last = text.parse_int(text.extr(page, \">Last(\", \")\"))\n\n        while True:\n            for bookmark in text.extract_iter(\n                    page, 'class=\"user-bookmark-item ', '</a>'):\n                yield Message.Queue, text.extr(bookmark, ' href=\"', '\"'), data\n\n            if params[\"page\"] >= last:\n                break\n            params[\"page\"] += 1\n            page = self.request(url, params=params).text\n"
  },
  {
    "path": "gallery_dl/extractor/mangapark.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2015-2026 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://mangapark.net/\"\"\"\n\nfrom .common import ChapterExtractor, Extractor, Message\nfrom .. import text, util\n\nBASE_PATTERN = (r\"(?:https?://)?(?:www\\.)?(?:\"\n                r\"(?:manga|comic|read)park\\.(?:com|net|org|me|io|to)|\"\n                r\"parkmanga\\.(?:com|net|org)|\"\n                r\"mpark\\.to)\")\n\n\nclass MangaparkBase():\n    \"\"\"Base class for mangapark extractors\"\"\"\n    category = \"mangapark\"\n\n    def _parse_chapter_title(self, title):\n        match = text.re(\n            r\"(?i)\"\n            r\"(?:vol(?:\\.|ume)?\\s*(\\d+)\\s*)?\"\n            r\"ch(?:\\.|apter)?\\s*(\\d+)([^\\s:]*)\"\n            r\"(?:\\s*:\\s*(.*))?\"\n        ).match(title)\n        return match.groups() if match else (0, 0, \"\", \"\")\n\n    def _extract_manga(self, manga_id):\n        variables = {\n            \"getComicNodeId\": manga_id,\n        }\n        return self._request_graphql(\"Get_comicNode\", variables)[\"data\"]\n\n    def _extract_chapter(self, chapter_id):\n        variables = {\n            \"getChapterNodeId\": chapter_id,\n        }\n        return self._request_graphql(\"Get_chapterNode\", variables)[\"data\"]\n\n    def _extract_chapters_all(self, manga_id):\n        variables = {\n            \"comicId\": manga_id,\n        }\n        return self._request_graphql(\"Get_comicChapterList\", variables)\n\n    def _extract_chapters_source(self, source_id):\n        variables = {\n            \"sourceId\": source_id,\n        }\n        return self._request_graphql(\n            \"get_content_source_chapterList\", variables)\n\n    def _request_graphql(self, opname, variables):\n        url = self.root + \"/apo/\"\n        data = {\n            \"query\"        : self.utils(\"graphql\", opname),\n            \"variables\"    : variables,\n            \"operationName\": opname,\n        }\n        return self.request_json(\n            url, method=\"POST\", json=data)[\"data\"].popitem()[1]\n\n\nclass MangaparkChapterExtractor(MangaparkBase, ChapterExtractor):\n    \"\"\"Extractor for manga-chapters from mangapark.net\"\"\"\n    pattern = (BASE_PATTERN +\n               r\"/(?:title/[^/?#]+/|comic/\\d+/[^/?#]+/[^/?#]+-i)(\\d+)\")\n    example = \"https://mangapark.net/title/MANGA/12345-en-ch.01\"\n\n    def __init__(self, match):\n        self.root = text.root_from_url(match[0])\n        ChapterExtractor.__init__(self, match, False)\n\n    def metadata(self, _):\n        chapter = self._extract_chapter(self.groups[0])\n        manga = self.cache(self._extract_manga, chapter[\"comicNode\"][\"id\"])\n\n        self._urls = chapter[\"imageFile\"][\"urlList\"]\n        vol, ch, minor, title = self._parse_chapter_title(chapter[\"dname\"])\n        lang = chapter.get(\"lang\") or \"en\"\n\n        return {\n            \"manga\"     : manga[\"name\"],\n            \"manga_id\"  : text.parse_int(manga[\"id\"]),\n            \"artist\"    : manga[\"artists\"],\n            \"author\"    : manga[\"authors\"],\n            \"genre\"     : manga[\"genres\"],\n            \"volume\"    : text.parse_int(vol),\n            \"chapter\"   : text.parse_int(ch),\n            \"chapter_minor\": minor,\n            \"chapter_id\": text.parse_int(chapter[\"id\"]),\n            \"title\"     : title or \"\",\n            \"lang\"      : lang,\n            \"language\"  : util.code_to_language(lang),\n            \"source\"    : chapter[\"srcTitle\"],\n            \"source_id\" : chapter[\"sourceId\"],\n            \"date\"      : self.parse_timestamp(chapter[\"dateCreate\"] / 1000),\n        }\n\n    def images(self, _):\n        return [(url, None) for url in self._urls]\n\n\nclass MangaparkMangaExtractor(MangaparkBase, Extractor):\n    \"\"\"Extractor for manga from mangapark.net\"\"\"\n    subcategory = \"manga\"\n    pattern = BASE_PATTERN + r\"/(?:title|comic)/(\\d+)(?:[/-][^/?#]*)?/?$\"\n    example = \"https://mangapark.net/title/12345-MANGA\"\n\n    def __init__(self, match):\n        self.root = text.root_from_url(match[0])\n        self.manga_id = int(match[1])\n        Extractor.__init__(self, match)\n\n    def items(self):\n        for chapter in self.chapters():\n            chapter = chapter[\"data\"]\n            url = self.root + chapter[\"urlPath\"]\n\n            vol, ch, minor, title = self._parse_chapter_title(chapter[\"dname\"])\n            lang = chapter.get(\"lang\") or \"en\"\n\n            data = {\n                \"manga_id\"  : self.manga_id,\n                \"volume\"    : text.parse_int(vol),\n                \"chapter\"   : text.parse_int(ch),\n                \"chapter_minor\": minor,\n                \"chapter_id\": chapter[\"id\"],\n                \"title\"     : chapter[\"title\"] or title or \"\",\n                \"lang\"      : lang,\n                \"language\"  : util.code_to_language(lang),\n                \"source\"    : chapter[\"srcTitle\"],\n                \"source_id\" : chapter[\"sourceId\"],\n                \"date\"      : self.parse_timestamp(\n                    chapter[\"dateCreate\"] / 1000),\n                \"_extractor\": MangaparkChapterExtractor,\n            }\n            yield Message.Queue, url, data\n\n    def chapters(self):\n        if source := self.config(\"source\"):\n            source_id = self._select_source(source)\n            self.log.debug(\"Requesting chapters for source_id %s\", source_id)\n            chapters = self._extract_chapters_source(source_id)\n        else:\n            chapters = self._extract_chapters_all(self.groups[0])\n\n        if self.config(\"chapter-reverse\"):\n            chapters.reverse()\n        return chapters\n\n    def _select_source(self, source):\n        if isinstance(source, int):\n            return source\n\n        group, _, lang = source.partition(\":\")\n        group = group.lower()\n\n        variables = {\n            \"comicId\"    : self.manga_id,\n            \"dbStatuss\"  : [\"normal\"],\n            \"haveChapter\": True,\n        }\n        for item in self._request_graphql(\n                \"get_content_comic_sources\", variables):\n            data = item[\"data\"]\n            if (not group or data[\"srcTitle\"].lower() == group) and (\n                    not lang or data[\"lang\"] == lang):\n                return data[\"id\"]\n\n        raise self.exc.AbortExtraction(\n            f\"'{source}' does not match any available source\")\n"
  },
  {
    "path": "gallery_dl/extractor/mangaread.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://mangaread.org/\"\"\"\n\nfrom .common import ChapterExtractor, MangaExtractor\nfrom .. import text\n\n\nclass MangareadBase():\n    \"\"\"Base class for Mangaread extractors\"\"\"\n    category = \"mangaread\"\n    root = \"https://www.mangaread.org\"\n\n    def parse_chapter_string(self, chapter_string, data):\n        match = text.re(\n            r\"(?:(.+)\\s*-\\s*)?[Cc]hapter\\s*(\\d+)(\\.\\d+)?(?:\\s*-\\s*(.+))?\"\n        ).match(text.unescape(chapter_string).strip())\n        manga, chapter, minor, title = match.groups()\n        manga = manga.strip() if manga else \"\"\n        data[\"manga\"] = data.pop(\"manga\", manga)\n        data[\"chapter\"] = text.parse_int(chapter)\n        data[\"chapter_minor\"] = minor or \"\"\n        data[\"title\"] = title or \"\"\n        data[\"lang\"] = \"en\"\n        data[\"language\"] = \"English\"\n\n\nclass MangareadChapterExtractor(MangareadBase, ChapterExtractor):\n    \"\"\"Extractor for manga-chapters from mangaread.org\"\"\"\n    pattern = (r\"(?:https?://)?(?:www\\.)?mangaread\\.org\"\n               r\"(/manga/[^/?#]+/[^/?#]+)\")\n    example = \"https://www.mangaread.org/manga/MANGA/chapter-01/\"\n\n    def metadata(self, page):\n        tags = text.extr(page, 'class=\"wp-manga-tags-list\">', '</div>')\n        data = {\"tags\": list(text.split_html(tags)[::2])}\n        info = text.extr(page, '<h1 id=\"chapter-heading\">', \"</h1>\")\n        if not info:\n            raise self.exc.NotFoundError(\"chapter\")\n        self.parse_chapter_string(info, data)\n        return data\n\n    def images(self, page):\n        page = text.extr(\n            page, '<div class=\"reading-content\">', '<div class=\"entry-header')\n        return [\n            (text.extr(img, 'src=\"', '\"').strip(), None)\n            for img in text.extract_iter(page, '<img id=\"image-', '>')\n        ]\n\n\nclass MangareadMangaExtractor(MangareadBase, MangaExtractor):\n    \"\"\"Extractor for manga from mangaread.org\"\"\"\n    chapterclass = MangareadChapterExtractor\n    pattern = r\"(?:https?://)?(?:www\\.)?mangaread\\.org(/manga/[^/?#]+)/?$\"\n    example = \"https://www.mangaread.org/manga/MANGA\"\n\n    def chapters(self, page):\n        if 'class=\"error404' in page:\n            raise self.exc.NotFoundError(\"manga\")\n        data = self.metadata(page)\n        results = []\n        for chapter in text.extract_iter(\n                page, '<li class=\"wp-manga-chapter', \"</li>\"):\n            url , pos = text.extract(chapter, '<a href=\"', '\"')\n            info, _ = text.extract(chapter, \">\", \"</a>\", pos)\n            self.parse_chapter_string(info, data)\n            results.append((url, data.copy()))\n        return results\n\n    def metadata(self, page):\n        extr = text.extract_from(text.extr(\n            page, 'class=\"summary_content\">', 'class=\"manga-action\"'))\n        return {\n            \"manga\"      : text.extr(page, \"<h1>\", \"</h1>\").strip(),\n            \"description\": text.unescape(text.remove_html(text.extract(\n                page, \">\", \"</div>\", page.index(\"summary__content\"))[0])),\n            \"rating\"     : text.parse_float(\n                extr('total_votes\">', \"</span>\").strip()),\n            \"manga_alt\"  : text.remove_html(\n                extr(\"Alternative\\t\\t</h5>\\n\\t</div>\", \"</div>\")).split(\"; \"),\n            \"author\"     : list(text.extract_iter(\n                extr('class=\"author-content\">', \"</div>\"), '\"tag\">', \"</a>\")),\n            \"artist\"     : list(text.extract_iter(\n                extr('class=\"artist-content\">', \"</div>\"), '\"tag\">', \"</a>\")),\n            \"genres\"     : list(text.extract_iter(\n                extr('class=\"genres-content\">', \"</div>\"), '\"tag\">', \"</a>\")),\n            \"type\"       : text.remove_html(\n                extr(\"\tType\t\", \"\\n</div>\")),\n            \"release\"    : text.parse_int(text.remove_html(\n                extr(\"\tRelease\t\", \"\\n</div>\"))),\n            \"status\"     : text.remove_html(\n                extr(\"\tStatus\t\", \"\\n</div>\")),\n        }\n"
  },
  {
    "path": "gallery_dl/extractor/mangareader.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2025-2026 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://mangareader.to/\"\"\"\n\nfrom .common import ChapterExtractor, MangaExtractor\nfrom .. import text, util\n\nBASE_PATTERN = r\"(?:https?://)?(?:www\\.)?mangareader\\.to\"\n\n\nclass MangareaderBase():\n    \"\"\"Base class for mangareader extractors\"\"\"\n    category = \"mangareader\"\n    root = \"https://mangareader.to\"\n\n    def _manga_info(self, manga_path):\n        url = f\"{self.root}/{manga_path}\"\n        html = self.request(url).text\n\n        slug, _, mid = manga_path.rpartition(\"-\")\n        extr = text.extract_from(html)\n        url = extr('property=\"og:url\" content=\"', '\"')\n        manga = {\n            \"manga_url\": url,\n            \"manga_slug\": url.rpartition(\"/\")[2].rpartition(\"-\")[0],\n            \"manga_id\": text.parse_int(mid),\n            \"manga\": text.unescape(extr('class=\"manga-name\">', \"<\")),\n            \"manga_alt\": text.unescape(extr('class=\"manga-name-or\">', \"<\")),\n            \"tags\": text.split_html(extr('class=\"genres\">', \"</div>\")),\n            \"type\": text.remove_html(extr('>Type:', \"</div>\")),\n            \"status\": text.remove_html(extr('>Status:', \"</div>\")),\n            \"author\": text.split_html(extr('>Authors:', \"</div>\"))[0::2],\n            \"published\": text.remove_html(extr('>Published:', \"</div>\")),\n            \"score\": text.parse_float(text.remove_html(extr(\n                '>Score:', \"</div>\"))),\n            \"views\": text.parse_int(text.remove_html(extr(\n                '>Views:', \"</div>\")).replace(\",\", \"\")),\n        }\n\n        base = self.root\n\n        # extract all chapters\n        html = extr('class=\"chapters-list-ul\">', \"    </div>\")\n        manga[\"_chapters\"] = chapters = {}\n        for group in text.extract_iter(html, \"<ul\", \"</ul>\"):\n            lang = text.extr(group, ' id=\"', '-chapters\"')\n\n            chapters[lang] = current = {}\n            lang = lang.partition(\"-\")[0]\n            for ch in text.extract_iter(group, \"<li \", \"</li>\"):\n                path = text.extr(ch, 'href=\"', '\"')\n                chap = text.extr(ch, 'data-number=\"', '\"')\n                name = text.unescape(text.extr(ch, 'class=\"name\">', \"<\"))\n\n                chapter, sep, minor = chap.partition(\".\")\n                current[chap] = {\n                    \"title\"         : name.partition(\":\")[2].strip(),\n                    \"chapter\"       : text.parse_int(chapter),\n                    \"chapter_minor\" : sep + minor,\n                    \"chapter_string\": chap,\n                    \"chapter_url\"   : base + path,\n                    \"lang\"          : lang,\n                }\n\n        # extract all volumes\n        html = extr('class=\"volume-list-ul\">', \"</section>\")\n        manga[\"_volumes\"] = volumes = {}\n        for group in html.split('<div class=\"manga_list-wrap')[1:]:\n            lang = text.extr(group, ' id=\"', '-volumes\"')\n\n            volumes[lang] = current = {}\n            lang = lang.partition(\"-\")[0]\n            for vol in text.extract_iter(group, 'class=\"item\">', \"</div>\"):\n                path = text.extr(vol, 'href=\"', '\"')\n                voln = text.extr(vol, 'tick-vol\">', '<').rpartition(\" \")[2]\n\n                current[voln] = {\n                    \"volume\"        : text.parse_int(voln),\n                    \"volume_cover\"  : text.extr(vol, ' src=\"', '\"'),\n                    \"chapter\"       : 0,\n                    \"chapter_minor\" : \"\",\n                    \"chapter_string\": voln,\n                    \"chapter_url\"   : base + path,\n                    \"lang\"          : lang,\n                }\n\n        # extract remaining metadata\n        manga[\"description\"] = text.unescape(extr(\n            'class=\"description-modal\">', \"</div>\")).strip()\n\n        return manga\n\n\nclass MangareaderChapterExtractor(MangareaderBase, ChapterExtractor):\n    \"\"\"Extractor for mangareader manga chapters\"\"\"\n    directory_fmt = (\n        \"{category}\", \"{manga}\",\n        \"{volume:?v/ />02}{chapter:?c//>03}{chapter_minor:?//}{title:?: //}\")\n    filename_fmt = (\n        \"{manga}{volume:?_v//>02}{chapter:?_c//>03}{chapter_minor:?//}_\"\n        \"{page:>03}.{extension}\")\n    archive_fmt = (\n        \"{manga_id}_{chapter_id}_{page}\")\n    pattern = (BASE_PATTERN + r\"/read/([\\w-]+-\\d+)/([^/?#]+)\"\n               r\"/(chapter|volume)-(\\d+[^/?#]*)\")\n    example = \"https://mangareader.to/read/MANGA-123/LANG/chapter-123\"\n\n    def metadata(self, _):\n        path, lang, type, chstr = self.groups\n\n        settings = util.json_dumps({\n            \"readingMode\"     : \"vertical\",\n            \"readingDirection\": \"rtl\",\n            \"quality\"         : \"high\",\n        })\n        self.cookies.set(\"mr_settings\", settings, domain=\"mangareader.to\")\n\n        url = f\"{self.root}/read/{path}/{lang}/{type}-{chstr}\"\n        page = self.request(url).text\n        self.cid = cid = text.extr(page, 'data-reading-id=\"', '\"')\n\n        manga = self.cache(self._manga_info, path)\n        return {\n            **manga,\n            **manga[f\"_{type}s\"][lang][chstr],\n            \"chapter_id\": text.parse_int(cid),\n        }\n\n    def images(self, page):\n        key = \"chap\" if self.groups[2] == \"chapter\" else \"vol\"\n        url = f\"{self.root}/ajax/image/list/{key}/{self.cid}\"\n        params = {\n            \"mode\"       : \"vertical,\",\n            \"quality\"    : \"high,\",\n            \"hozPageSize\": \"1,\",\n        }\n        headers = {\n            \"X-Requested-With\": \"XMLHttpRequest\",\n            \"Sec-Fetch-Dest\"  : \"empty\",\n            \"Sec-Fetch-Mode\"  : \"cors\",\n            \"Sec-Fetch-Site\"  : \"same-origin\",\n        }\n        html = self.request_json(url, params=params, headers=headers)[\"html\"]\n\n        return [\n            (url, None)\n            for url in text.extract_iter(html, 'data-url=\"', '\"')\n        ]\n\n\nclass MangareaderMangaExtractor(MangareaderBase, MangaExtractor):\n    \"\"\"Extractor for mangareader manga\"\"\"\n    chapterclass = MangareaderChapterExtractor\n    pattern = BASE_PATTERN + r\"/([\\w-]+-\\d+)\"\n    example = \"https://mangareader.to/MANGA-123\"\n\n    def chapters(self, page):\n        manga = self.cache(self._manga_info, self.groups[0])\n        lang = self.config(\"lang\") or \"en\"\n\n        return [\n            (info[\"chapter_url\"], {**manga, **info})\n            for info in manga[\"_chapters\"][lang].values()\n        ]\n"
  },
  {
    "path": "gallery_dl/extractor/mangataro.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2025-2026 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://mangataro.org/\"\"\"\n\nfrom .common import ChapterExtractor, MangaExtractor\nfrom .. import text\nimport hashlib\nimport time\n\nBASE_PATTERN = r\"(?:https?://)?mangataro\\.org\"\n\n\nclass MangataroBase():\n    \"\"\"Base class for mangataro extractors\"\"\"\n    category = \"mangataro\"\n    root = \"https://mangataro.org\"\n\n    def _manga_info(self, slug):\n        url = f\"{self.root}/manga/{slug}\"\n        page = self.request(url).text\n        manga = self._extract_jsonld(page)\n\n        return {\n            \"manga\"      : manga[\"name\"].rpartition(\n                \" | \")[0].rpartition(\" \")[0],\n            \"manga_id\"   : text.extr(page, 'data-manga-id=\"', '\"'),\n            \"manga_url\"  : manga[\"url\"],\n            \"cover\"      : manga[\"image\"],\n            \"author\"     : manga[\"author\"][\"name\"].split(\", \"),\n            \"genre\"      : manga[\"genre\"],\n            \"status\"     : manga[\"status\"],\n            \"description\": text.unescape(text.extr(\n                page, 'id=\"description-content-tab\">', \"</div></div>\")),\n            \"tags\"       : text.split_html(text.extr(\n                page, \">Genres</h4>\", \"</div>\")),\n            \"publisher\"  : text.remove_html(text.extr(\n                page, '>Serialization</h4>', \"</div>\")),\n        }\n\n\nclass MangataroChapterExtractor(MangataroBase, ChapterExtractor):\n    \"\"\"Extractor for mangataro manga chapters\"\"\"\n    pattern = BASE_PATTERN + r\"(/read/([^/?#]+)/(?:[^/?#]*-)?(\\d+))\"\n    example = \"https://mangataro.org/read/MANGA/ch123-12345\"\n\n    def metadata(self, page):\n        _, slug, chapter_id = self.groups\n        comic = self._extract_jsonld(page)[\"@graph\"][0]\n        chapter = comic[\"position\"]\n        minor = chapter - int(chapter)\n        desc = comic[\"description\"].split(\" - \", 3)\n\n        return {\n            **self.cache(self._manga_info, slug),\n            \"title\"    : desc[1] if len(desc) > 3 else \"\",\n            \"chapter\"  : int(chapter),\n            \"chapter_minor\": str(round(minor, 5))[1:] if minor else \"\",\n            \"chapter_id\"   : text.parse_int(chapter_id),\n            \"chapter_url\"  : comic[\"url\"],\n            \"date\"         : self.parse_datetime_iso(comic[\"datePublished\"]),\n            \"date_updated\" : self.parse_datetime_iso(comic[\"dateModified\"]),\n        }\n\n    def images(self, page):\n        url = f\"{self.root}/auth/chapter-content?chapter_id={self.groups[2]}\"\n        data = self.request_json(url)\n        return [(url, None) for url in data[\"images\"]]\n\n\nclass MangataroMangaExtractor(MangataroBase, MangaExtractor):\n    \"\"\"Extractor for mangataro manga\"\"\"\n    chapterclass = MangataroChapterExtractor\n    pattern = BASE_PATTERN + r\"/manga/([^/?#]+)\"\n    example = \"https://mangataro.org/manga/MANGA\"\n\n    def chapters(self, _):\n        manga = self.cache(self._manga_info, self.groups[0])\n\n        url = self.root + \"/auth/manga-chapters\"\n        params = {\n            \"manga_id\": manga[\"manga_id\"],\n            \"offset\"  : 0,\n            \"limit\"   : 500,  # values higher than 500 have no effect\n            \"order\"   : \"DESC\",\n        }\n        headers = {\n            \"Referer\"       : manga[\"manga_url\"],\n            \"Sec-Fetch-Dest\": \"empty\",\n            \"Sec-Fetch-Mode\": \"cors\",\n            \"Sec-Fetch-Site\": \"same-origin\",\n        }\n\n        results = []\n        while True:\n            self._update_params(params)\n            data = self.request_json(url, params=params, headers=headers)\n\n            for ch in data[\"chapters\"]:\n                chapter, sep, minor = ch[\"chapter\"].partition(\".\")\n                results.append((ch[\"url\"], {\n                    **manga,\n                    \"chapter_id\"   : text.parse_int(ch.pop(\"id\", None)),\n                    **ch,\n                    \"chapter\"      : text.parse_int(chapter),\n                    \"chapter_minor\": \".\" + minor if sep else \"\",\n                }))\n\n            if not data.get(\"has_more\"):\n                break\n            params[\"offset\"] += (data.get(\"limit\") or params[\"limit\"])\n        return results\n\n    def _update_params(self, params):\n        # adapted from dazedcat19/FMD2\n        # https://github.com/dazedcat19/FMD2/blob/master/lua/modules/MangaTaro.lua\n        if (ts := int(time.time())) == params.get(\"_ts\"):\n            return\n        Y, m, d, H, _, _, _, _, _ = time.gmtime(ts)\n        secret = f\"{ts}mng_ch_{Y:>04}{m:>02}{d:>02}{H:>02}\"\n        params[\"_t\"] = hashlib.md5(secret.encode()).hexdigest()[:16]\n        params[\"_ts\"] = ts\n"
  },
  {
    "path": "gallery_dl/extractor/mangatown.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://www.mangatown.com/\"\"\"\n\nfrom .common import ChapterExtractor, MangaExtractor\nfrom .. import text\n\nBASE_PATTERN = r\"(?:https?://)?(?:www\\.)?mangatown\\.com\"\n\n\nclass MangatownBase():\n    \"\"\"Base class for mangatown extractors\"\"\"\n    category = \"mangatown\"\n    root = \"https://www.mangatown.com\"\n\n\nclass MangatownChapterExtractor(MangatownBase, ChapterExtractor):\n    \"\"\"Extractor for manga-chapters from mangatown.com\"\"\"\n    pattern = BASE_PATTERN + r\"(/manga/[^/?#]+(?:/v0*(\\d+))?/c(\\d+[^/?#]*))\"\n    example = \"https://www.mangatown.com/manga/TITLE/c001/1.html\"\n\n    def __init__(self, match):\n        self.part, self.volume, self.chapter = match.groups()\n        self.base = f\"{self.root}{self.part}/\"\n        ChapterExtractor.__init__(self, match, self.base + \"1.html\")\n\n    def metadata(self, page):\n        manga, pos = text.extract(\n            page, 'property=\"og:title\" content=\"', '\"')\n        count     , pos = text.extract(page, \"total_pages = \", \";\", pos)\n        manga_id  , pos = text.extract(page, \"series_id=\", \";\", pos)\n        chapter_id, pos = text.extract(page, \"chapter_id=\", \";\", pos)\n\n        chapter, dot, minor = self.chapter.partition(\".\")\n\n        return {\n            \"manga\"   : text.unescape(manga),\n            \"manga_id\": text.parse_int(manga_id),\n            \"volume\"  : text.parse_int(self.volume),\n            \"chapter\" : text.parse_int(chapter),\n            \"chapter_minor\": dot + minor,\n            \"chapter_id\": text.parse_int(chapter_id),\n            \"count\"   : text.parse_int(count),\n            \"lang\"    : \"en\",\n            \"language\": \"English\",\n        }\n\n    def images(self, page):\n        pnum = 1\n\n        while True:\n            url = (text.extr(page, 'id=\"image\" src=\"', '\"') or\n                   text.extr(page, '<img src=\"', '\"'))\n            if not url:\n                return\n            yield text.ensure_http_scheme(url), None\n            pnum += 1\n            page = self.request(f\"{self.base}{pnum}.html\").text\n\n\nclass MangatownMangaExtractor(MangatownBase, MangaExtractor):\n    \"\"\"Extractor for manga from mangatown.com\"\"\"\n    chapterclass = MangatownChapterExtractor\n    pattern = BASE_PATTERN + r\"(/manga/[^/?#]+)\"\n    example = \"https://www.mangatown.com/manga/TITLE\"\n\n    def chapters(self, page):\n        results = []\n\n        manga, pos = text.extract(page, '<title>', '</title>')\n        manga = manga.partition(\" Manga\")[0].replace(\"Read \", \"\", 1)\n        manga = text.unescape(manga)\n\n        page = text.extract(page, 'class=\"chapter_list\"', '</ul>', pos)[0]\n        for ch in text.extract_iter(page, \"<li>\", \"</li>\"):\n            path , pos = text.extract(ch, '<a href=\"', '\"')\n            title, pos = text.extract(ch, \"<span>\", \"<\", pos)\n            date , pos = text.extract(ch, 'class=\"time\">', \"<\", pos)\n\n            chapter = text.extr(path, \"/c\", \"/\")\n            chapter, sep, minor = chapter.partition(\".\")\n\n            results.append((self.root + path, {\n                \"manga\"   : manga,\n                \"chapter\" : text.parse_int(chapter),\n                \"chapter_minor\": sep + minor,\n                \"title\"   : \"\" if title is None else text.unescape(title),\n                \"date\"    : date,\n                \"lang\"    : \"en\",\n                \"language\": \"English\",\n            }))\n        return results\n"
  },
  {
    "path": "gallery_dl/extractor/mangoxo.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2019-2026 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://www.mangoxo.com/\"\"\"\n\nfrom .common import Extractor, Message\nfrom .. import text\nimport hashlib\nimport time\n\n\nclass MangoxoExtractor(Extractor):\n    \"\"\"Base class for mangoxo extractors\"\"\"\n    category = \"mangoxo\"\n    root = \"https://www.mangoxo.com\"\n    cookies_domain = \"www.mangoxo.com\"\n    cookies_names = (\"SESSION\",)\n    _warning = True\n\n    def login(self):\n        username, password = self._get_auth_info()\n        if username:\n            self.cookies_update(self.cache(\n                self._login_impl, username, password, _exp=3*3600, _mem=False))\n        elif MangoxoExtractor._warning:\n            MangoxoExtractor._warning = False\n            self.log.warning(\"Unauthenticated users cannot see \"\n                             \"more than 5 images per album\")\n\n    def _login_impl(self, username, password):\n        self.log.info(\"Logging in as %s\", username)\n\n        url = self.root + \"/login\"\n        page = self.request(url).text\n        token = text.extr(page, 'id=\"loginToken\" value=\"', '\"')\n\n        url = self.root + \"/api/login\"\n        headers = {\n            \"X-Requested-With\": \"XMLHttpRequest\",\n            \"Referer\": self.root + \"/login\",\n        }\n        data = self._sign_by_md5(username, password, token)\n        response = self.request(url, method=\"POST\", headers=headers, data=data)\n\n        data = response.json()\n        if str(data.get(\"result\")) != \"1\":\n            raise self.exc.AuthenticationError(data.get(\"msg\"))\n        return {\"SESSION\": self.cookies.get(\"SESSION\")}\n\n    def _sign_by_md5(self, username, password, token):\n        # https://dns.mangoxo.com/libs/plugins/phoenix-ui/js/phoenix-ui.js\n        params = [\n            (\"username\" , username),\n            (\"password\" , password),\n            (\"token\"    , token),\n            (\"timestamp\", str(int(time.time()))),\n        ]\n        query = \"&\".join(\"=\".join(item) for item in sorted(params))\n        query += \"&secretKey=340836904\"\n        sign = hashlib.md5(query.encode()).hexdigest()\n        params.append((\"sign\", sign.upper()))\n        return params\n\n    def _total_pages(self, page):\n        return text.parse_int(text.extract(page, \"total :\", \",\")[0])\n\n\nclass MangoxoAlbumExtractor(MangoxoExtractor):\n    \"\"\"Extractor for albums on mangoxo.com\"\"\"\n    subcategory = \"album\"\n    filename_fmt = \"{album[id]}_{num:>03}.{extension}\"\n    directory_fmt = (\"{category}\", \"{channel[name]}\", \"{album[name]}\")\n    archive_fmt = \"{album[id]}_{num}\"\n    pattern = r\"(?:https?://)?(?:www\\.)?mangoxo\\.com/album/(\\w+)\"\n    example = \"https://www.mangoxo.com/album/ID\"\n\n    def __init__(self, match):\n        MangoxoExtractor.__init__(self, match)\n        self.album_id = match[1]\n\n    def items(self):\n        self.login()\n        url = f\"{self.root}/album/{self.album_id}/\"\n        page = self.request(url).text\n        data = self.metadata(page)\n        imgs = self.images(url, page)\n\n        yield Message.Directory, \"\", data\n\n        data[\"extension\"] = None\n        for data[\"num\"], path in enumerate(imgs, 1):\n            data[\"id\"] = text.parse_int(text.extr(path, \"=\", \"&\"))\n            url = self.root + \"/external/\" + path.rpartition(\"url=\")[2]\n            yield Message.Url, url, text.nameext_from_url(url, data)\n\n    def metadata(self, page):\n        \"\"\"Return general metadata\"\"\"\n        extr = text.extract_from(page)\n        title = extr('<img id=\"cover-img\" alt=\"', '\"')\n        cid = extr('href=\"https://www.mangoxo.com/user/', '\"')\n        cname = extr('<img alt=\"', '\"')\n        cover = extr(' src=\"', '\"')\n        count = extr('id=\"pic-count\">', '<')\n        date = extr('class=\"fa fa-calendar\"></i>', '<')\n        descr = extr('<pre>', '</pre>')\n\n        return {\n            \"channel\": {\n                \"id\": cid,\n                \"name\": text.unescape(cname),\n                \"cover\": cover,\n            },\n            \"album\": {\n                \"id\": self.album_id,\n                \"name\": text.unescape(title),\n                \"date\": self.parse_datetime(date.strip(), \"%Y.%m.%d %H:%M\"),\n                \"description\": text.unescape(descr),\n            },\n            \"count\": text.parse_int(count),\n        }\n\n    def images(self, url, page):\n        \"\"\"Generator; Yields all image URLs\"\"\"\n        total = self._total_pages(page)\n        num = 1\n\n        while True:\n            yield from text.extract_iter(\n                page, 'class=\"lightgallery-item\" href=\"', '\"')\n            if num >= total:\n                return\n            num += 1\n            page = self.request(url + str(num)).text\n\n\nclass MangoxoChannelExtractor(MangoxoExtractor):\n    \"\"\"Extractor for all albums on a mangoxo channel\"\"\"\n    subcategory = \"channel\"\n    pattern = r\"(?:https?://)?(?:www\\.)?mangoxo\\.com/(\\w+)/album\"\n    example = \"https://www.mangoxo.com/USER/album\"\n\n    def __init__(self, match):\n        MangoxoExtractor.__init__(self, match)\n        self.user = match[1]\n\n    def items(self):\n        self.login()\n        num = total = 1\n        url = f\"{self.root}/{self.user}/album/\"\n        data = {\"_extractor\": MangoxoAlbumExtractor}\n\n        while True:\n            page = self.request(url + str(num)).text\n\n            for album in text.extract_iter(\n                    page, '<a class=\"link black\" href=\"', '\"'):\n                yield Message.Queue, album, data\n\n            if num == 1:\n                total = self._total_pages(page)\n            if num >= total:\n                return\n            num += 1\n"
  },
  {
    "path": "gallery_dl/extractor/mastodon.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2019-2026 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for Mastodon instances\"\"\"\n\nfrom .common import BaseExtractor, Message\nfrom .. import text\n\n\nclass MastodonExtractor(BaseExtractor):\n    \"\"\"Base class for mastodon extractors\"\"\"\n    basecategory = \"mastodon\"\n    directory_fmt = (\"mastodon\", \"{instance}\", \"{account[username]}\")\n    filename_fmt = \"{category}_{id}_{media[id]}.{extension}\"\n    archive_fmt = \"{media[id]}\"\n\n    def __init__(self, match):\n        BaseExtractor.__init__(self, match)\n        self.item = self.groups[-1]\n\n    def _init(self):\n        self.instance = self.root.partition(\"://\")[2]\n        self.reblogs = self.config(\"reblogs\", False)\n        self.replies = self.config(\"replies\", True)\n        self.cards = self.config(\"cards\", False)\n\n    def items(self):\n        for status in self.statuses():\n\n            if self._check_moved:\n                self._check_moved(status[\"account\"])\n            if not self.reblogs and status[\"reblog\"]:\n                self.log.debug(\"Skipping %s (reblog)\", status[\"id\"])\n                continue\n            if not self.replies and status[\"in_reply_to_id\"]:\n                self.log.debug(\"Skipping %s (reply)\", status[\"id\"])\n                continue\n\n            attachments = status[\"media_attachments\"]\n            del status[\"media_attachments\"]\n\n            if status[\"reblog\"]:\n                attachments.extend(status[\"reblog\"][\"media_attachments\"])\n\n            if self.cards:\n                if card := status.get(\"card\"):\n                    if url := card.get(\"image\"):\n                        card[\"weburl\"] = card.get(\"url\")\n                        card[\"url\"] = url\n                        card[\"id\"] = \"card\" + \"\".join(\n                            url.split(\"/\")[6:-2]).lstrip(\"0\")\n                        attachments.append(card)\n\n            status[\"instance\"] = self.instance\n            acct = status[\"account\"][\"acct\"]\n            status[\"instance_remote\"] = \\\n                acct.rpartition(\"@\")[2] if \"@\" in acct else None\n\n            status[\"count\"] = len(attachments)\n            status[\"tags\"] = [tag[\"name\"] for tag in status[\"tags\"]]\n            status[\"date\"] = self.parse_datetime_iso(status[\"created_at\"][:19])\n\n            yield Message.Directory, \"\", status\n            for status[\"num\"], media in enumerate(attachments, 1):\n                status[\"media\"] = media\n                url = media[\"url\"]\n                yield Message.Url, url, text.nameext_from_url(url, status)\n\n    def statuses(self):\n        \"\"\"Return an iterable containing all relevant Status objects\"\"\"\n        return ()\n\n    def _check_moved(self, account):\n        self._check_moved = None\n        # Certain fediverse software (such as Iceshrimp and Sharkey) have a\n        # null account \"moved\" field instead of not having it outright.\n        # To handle this, check if the \"moved\" value is truthy instead\n        # if only it exists.\n        if account.get(\"moved\"):\n            self.log.warning(\"Account '%s' moved to '%s'\",\n                             account[\"acct\"], account[\"moved\"][\"acct\"])\n\n\nBASE_PATTERN = MastodonExtractor.update({\n    \"mastodon.social\": {\n        \"root\"         : \"https://mastodon.social\",\n        \"pattern\"      : r\"mastodon\\.social\",\n        \"access-token\" : \"Y06R36SMvuXXN5_wiPKFAEFiQaMSQg0o_hGgc86Jj48\",\n        \"client-id\"    : \"dBSHdpsnOUZgxOnjKSQrWEPakO3ctM7HmsyoOd4FcRo\",\n        \"client-secret\": \"DdrODTHs_XoeOsNVXnILTMabtdpWrWOAtrmw91wU1zI\",\n    },\n    \"pawoo\": {\n        \"root\"         : \"https://pawoo.net\",\n        \"pattern\"      : r\"pawoo\\.net\",\n        \"access-token\" : \"c12c9d275050bce0dc92169a28db09d7\"\n                         \"0d62d0a75a8525953098c167eacd3668\",\n        \"client-id\"    : \"978a25f843ec01e53d09be2c290cd75c\"\n                         \"782bc3b7fdbd7ea4164b9f3c3780c8ff\",\n        \"client-secret\": \"9208e3d4a7997032cf4f1b0e12e5df38\"\n                         \"8428ef1fadb446dcfeb4f5ed6872d97b\",\n    },\n    \"baraag\": {\n        \"root\"         : \"https://baraag.net\",\n        \"pattern\"      : r\"baraag\\.net\",\n        \"access-token\" : \"53P1Mdigf4EJMH-RmeFOOSM9gdSDztmrAYFgabOKKE0\",\n        \"client-id\"    : \"czxx2qilLElYHQ_sm-lO8yXuGwOHxLX9RYYaD0-nq1o\",\n        \"client-secret\": \"haMaFdMBgK_-BIxufakmI2gFgkYjqmgXGEO2tB-R2xY\",\n    }\n}) + \"(?:/web)?\"\n\n\nclass MastodonUserExtractor(MastodonExtractor):\n    \"\"\"Extractor for all images of an account/user\"\"\"\n    subcategory = \"user\"\n    pattern = BASE_PATTERN + r\"/(?:@|users/)([^/?#]+)(?:/media)?/?$\"\n    example = \"https://mastodon.social/@USER\"\n\n    def statuses(self):\n        api = MastodonAPI(self)\n\n        return api.account_statuses(\n            api.account_id_by_username(self.item),\n            only_media=(\n                not self.reblogs and\n                not self.cards and\n                not self.config(\"text-posts\", False)\n            ),\n            exclude_replies=not self.replies,\n        )\n\n\nclass MastodonBookmarkExtractor(MastodonExtractor):\n    \"\"\"Extractor for mastodon bookmarks\"\"\"\n    subcategory = \"bookmark\"\n    pattern = BASE_PATTERN + r\"/bookmarks\"\n    example = \"https://mastodon.social/bookmarks\"\n\n    def statuses(self):\n        return MastodonAPI(self).account_bookmarks()\n\n\nclass MastodonFavoriteExtractor(MastodonExtractor):\n    \"\"\"Extractor for mastodon favorites\"\"\"\n    subcategory = \"favorite\"\n    pattern = BASE_PATTERN + r\"/favourites\"\n    example = \"https://mastodon.social/favourites\"\n\n    def statuses(self):\n        return MastodonAPI(self).account_favorites()\n\n\nclass MastodonListExtractor(MastodonExtractor):\n    \"\"\"Extractor for mastodon lists\"\"\"\n    subcategory = \"list\"\n    pattern = BASE_PATTERN + r\"/lists/(\\w+)\"\n    example = \"https://mastodon.social/lists/12345\"\n\n    def statuses(self):\n        return MastodonAPI(self).timelines_list(self.item)\n\n\nclass MastodonHashtagExtractor(MastodonExtractor):\n    \"\"\"Extractor for mastodon hashtags\"\"\"\n    subcategory = \"hashtag\"\n    pattern = BASE_PATTERN + r\"/tags/(\\w+)\"\n    example = \"https://mastodon.social/tags/NAME\"\n\n    def statuses(self):\n        return MastodonAPI(self).timelines_tag(self.item)\n\n\nclass MastodonFollowingExtractor(MastodonExtractor):\n    \"\"\"Extractor for followed mastodon users\"\"\"\n    subcategory = \"following\"\n    pattern = BASE_PATTERN + r\"/(?:@|users/)([^/?#]+)/following\"\n    example = \"https://mastodon.social/@USER/following\"\n\n    def items(self):\n        api = MastodonAPI(self)\n        account_id = api.account_id_by_username(self.item)\n\n        for account in api.account_following(account_id):\n            account[\"_extractor\"] = MastodonUserExtractor\n            yield Message.Queue, account[\"url\"], account\n\n\nclass MastodonStatusExtractor(MastodonExtractor):\n    \"\"\"Extractor for images from a status\"\"\"\n    subcategory = \"status\"\n    pattern = (BASE_PATTERN + r\"/(?:@[^/?#]+|(?:users/[^/?#]+/)?\"\n               r\"(?:statuses|notice|objects()))/(?!following)([^/?#]+)\")\n    example = \"https://mastodon.social/@USER/12345\"\n\n    def statuses(self):\n        if self.groups[-2] is not None:\n            url = f\"{self.root}/objects/{self.item}\"\n            location = self.request_location(url)\n            self.item = location.rpartition(\"/\")[2]\n        return (MastodonAPI(self).status(self.item),)\n\n\nclass MastodonAPI():\n    \"\"\"Minimal interface for the Mastodon API\n\n    https://docs.joinmastodon.org/\n    https://github.com/tootsuite/mastodon\n    \"\"\"\n\n    def __init__(self, extractor):\n        self.root = extractor.root\n        self.extractor = extractor\n        self.exc = extractor.exc\n\n        access_token = extractor.config(\"access-token\")\n        if access_token is None or access_token == \"cache\":\n            access_token = self.extractor.cache(\n                _access_token_cache, extractor.instance, _mem=False)\n        if not access_token:\n            access_token = extractor.config_instance(\"access-token\")\n\n        if access_token:\n            self.headers = {\"Authorization\": \"Bearer \" + access_token}\n        else:\n            self.headers = None\n\n    def account_id_by_username(self, username):\n        if username.startswith(\"id:\"):\n            return username[3:]\n\n        try:\n            return self.account_lookup(username)[\"id\"]\n        except Exception:\n            # fall back to account search\n            pass\n\n        if \"@\" in username:\n            handle = \"@\" + username\n        else:\n            handle = f\"@{username}@{self.extractor.instance}\"\n\n        for account in self.account_search(handle, 1):\n            if account[\"acct\"] == username:\n                self.extractor._check_moved(account)\n                return account[\"id\"]\n        raise self.exc.NotFoundError(\"account\")\n\n    def account_bookmarks(self):\n        \"\"\"Statuses the user has bookmarked\"\"\"\n        endpoint = \"/v1/bookmarks\"\n        return self._pagination(endpoint, None)\n\n    def account_favorites(self):\n        \"\"\"Statuses the user has favourited\"\"\"\n        endpoint = \"/v1/favourites\"\n        return self._pagination(endpoint, None)\n\n    def account_following(self, account_id):\n        \"\"\"Accounts which the given account is following\"\"\"\n        endpoint = f\"/v1/accounts/{account_id}/following\"\n        return self._pagination(endpoint, None)\n\n    def account_lookup(self, username):\n        \"\"\"Quickly lookup a username to see if it is available\"\"\"\n        endpoint = \"/v1/accounts/lookup\"\n        params = {\"acct\": username}\n        return self._call(endpoint, params).json()\n\n    def account_search(self, query, limit=40):\n        \"\"\"Search for matching accounts by username or display name\"\"\"\n        endpoint = \"/v1/accounts/search\"\n        params = {\"q\": query, \"limit\": limit}\n        return self._call(endpoint, params).json()\n\n    def account_statuses(self, account_id, only_media=True,\n                         exclude_replies=False):\n        \"\"\"Statuses posted to the given account\"\"\"\n        endpoint = f\"/v1/accounts/{account_id}/statuses\"\n        params = {\"only_media\"     : \"true\" if only_media else \"false\",\n                  \"exclude_replies\": \"true\" if exclude_replies else \"false\"}\n        return self._pagination(endpoint, params)\n\n    def status(self, status_id):\n        \"\"\"Obtain information about a status\"\"\"\n        endpoint = \"/v1/statuses/\" + status_id\n        return self._call(endpoint).json()\n\n    def timelines_list(self, list_id):\n        \"\"\"View statuses in the given list timeline\"\"\"\n        endpoint = \"/v1/timelines/list/\" + list_id\n        return self._pagination(endpoint, None)\n\n    def timelines_tag(self, hashtag):\n        \"\"\"View public statuses containing the given hashtag\"\"\"\n        endpoint = \"/v1/timelines/tag/\" + hashtag\n        return self._pagination(endpoint, None)\n\n    def _call(self, endpoint, params=None):\n        if endpoint.startswith(\"http\"):\n            url = endpoint\n        else:\n            url = self.root + \"/api\" + endpoint\n\n        while True:\n            response = self.extractor.request(\n                url, params=params, headers=self.headers, fatal=None)\n            code = response.status_code\n\n            if code < 400:\n                return response\n            if code == 401:\n                raise self.exc.AbortExtraction(\n                    f\"Invalid or missing access token.\\nRun 'gallery-dl oauth:\"\n                    f\"mastodon:{self.extractor.instance}' to obtain one.\")\n            if code == 404:\n                raise self.exc.NotFoundError()\n            if code == 429:\n                self.extractor.wait(until=self.extractor.parse_datetime_iso(\n                    response.headers[\"x-ratelimit-reset\"]))\n                continue\n            raise self.exc.AbortExtraction(response.json().get(\"error\"))\n\n    def _pagination(self, endpoint, params):\n        url = endpoint\n        while url:\n            response = self._call(url, params)\n            yield from response.json()\n\n            url = response.links.get(\"next\")\n            if not url:\n                return\n            url = url[\"url\"]\n            params = None\n\n\ndef _access_token_cache(instance):\n    return None\n"
  },
  {
    "path": "gallery_dl/extractor/message.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2015-2025 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\nclass Message():\n    \"\"\"Enum for message identifiers\n\n    Extractors yield their results as message-tuples, where the first element\n    is one of the following identifiers. This message-identifier determines\n    the type and meaning of the other elements in such a tuple.\n\n    - Message.Version:  # obsolete\n      - Message protocol version (currently always '1')\n      - 2nd element specifies the version of all following messages as integer\n\n    - Message.Directory:\n      - Sets the target directory for all following images\n      - 2nd element is unused\n      - 3rd element is a dictionary containing general metadata\n\n    - Message.Url:\n      - Image URL and its metadata\n      - 2nd element is the URL as a string\n      - 3rd element is a dictionary with image-specific metadata\n\n    - Message.Headers:  # obsolete\n      - HTTP headers to use while downloading\n      - 2nd element is a dictionary with header-name and -value pairs\n\n    - Message.Cookies:  # obsolete\n      - Cookies to use while downloading\n      - 2nd element is a dictionary with cookie-name and -value pairs\n\n    - Message.Queue:\n      - (External) URL that should be handled by another extractor\n      - 2nd element is the (external) URL as a string\n      - 3rd element is a dictionary containing URL-specific metadata\n\n    - Message.Urllist:  # obsolete\n      - Same as Message.Url, but its 2nd element is a list of multiple URLs\n      - The additional URLs serve as a fallback if the primary one fails\n    \"\"\"\n\n    #  Version = 1\n    Directory = 2\n    Url = 3\n    #  Headers = 4\n    #  Cookies = 5\n    Queue = 6\n    #  Urllist = 7\n    #  Metadata = 8\n"
  },
  {
    "path": "gallery_dl/extractor/misskey.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for Misskey instances\"\"\"\n\nfrom .common import BaseExtractor, Message, Dispatch\nfrom .. import text, dt\n\n\nclass MisskeyExtractor(BaseExtractor):\n    \"\"\"Base class for Misskey extractors\"\"\"\n    basecategory = \"misskey\"\n    directory_fmt = (\"misskey\", \"{instance}\", \"{user[username]}\")\n    filename_fmt = \"{category}_{id}_{file[id]}.{extension}\"\n    archive_fmt = \"{id}_{file[id]}\"\n\n    def _init(self):\n        self.api = MisskeyAPI(self)\n        self.instance = self.root.rpartition(\"://\")[2]\n        self.renotes = True if self.config(\"renotes\", False) else False\n        self.replies = True if self.config(\"replies\", True) else False\n\n    def items(self):\n        for note in self.notes():\n            if \"note\" in note:\n                note = note[\"note\"]\n            files = note.pop(\"files\") or []\n            if renote := note.get(\"renote\"):\n                if not self.renotes:\n                    self.log.debug(\"Skipping %s (renote)\", note[\"id\"])\n                    continue\n                files.extend(renote.get(\"files\") or ())\n\n            if reply := note.get(\"reply\"):\n                if not self.replies:\n                    self.log.debug(\"Skipping %s (reply)\", note[\"id\"])\n                    continue\n                files.extend(reply.get(\"files\") or ())\n\n            note[\"instance\"] = self.instance\n            note[\"instance_remote\"] = note[\"user\"][\"host\"]\n            note[\"count\"] = len(files)\n            note[\"date\"] = self.parse_datetime_iso(note[\"createdAt\"])\n\n            yield Message.Directory, \"\", note\n            for note[\"num\"], file in enumerate(files, 1):\n                file[\"date\"] = self.parse_datetime_iso(file[\"createdAt\"])\n                note[\"file\"] = file\n                url = file[\"url\"]\n                yield Message.Url, url, text.nameext_from_url(url, note)\n\n    def notes(self):\n        \"\"\"Return an iterable containing all relevant Note objects\"\"\"\n        return ()\n\n    def _make_note(self, type, user, url):\n        # extract real URL from potential proxy\n        path, sep, query = url.partition(\"?\")\n        if sep:\n            url = text.parse_query(query).get(\"url\") or path\n\n        return {\n            \"id\"   : type,\n            \"user\" : user,\n            \"files\": ({\n                \"id\" : url.rpartition(\"/\")[2].partition(\".\")[0],  # ID from URL\n                \"url\": url,\n                \"createdAt\": \"\",\n            },),\n            \"createdAt\": \"\",\n        }\n\n\nBASE_PATTERN = MisskeyExtractor.update({\n    \"misskey.io\": {\n        \"root\": \"https://misskey.io\",\n        \"pattern\": r\"misskey\\.io\",\n    },\n    \"misskey.design\": {\n        \"root\": \"https://misskey.design\",\n        \"pattern\": r\"misskey\\.design\",\n    },\n    \"misskey.art\": {\n        \"root\": \"https://misskey.art\",\n        \"pattern\": r\"misskey\\.art\",\n    },\n    \"lesbian.energy\": {\n        \"root\": \"https://lesbian.energy\",\n        \"pattern\": r\"lesbian\\.energy\",\n    },\n    \"sushi.ski\": {\n        \"root\": \"https://sushi.ski\",\n        \"pattern\": r\"sushi\\.ski\",\n    },\n})\n\n\nclass MisskeyUserExtractor(Dispatch, MisskeyExtractor):\n    \"\"\"Extractor for all images of a Misskey user\"\"\"\n    subcategory = \"user\"\n    pattern = BASE_PATTERN + r\"/@([^/?#]+)/?$\"\n    example = \"https://misskey.io/@USER\"\n\n    def items(self):\n        base = f\"{self.root}/@{self.groups[-1]}/\"\n        return self._dispatch_extractors((\n            (MisskeyInfoExtractor      , base + \"info\"),\n            (MisskeyAvatarExtractor    , base + \"avatar\"),\n            (MisskeyBackgroundExtractor, base + \"banner\"),\n            (MisskeyNotesExtractor     , base + \"notes\"),\n        ), (\"notes\",))\n\n\nclass MisskeyNotesExtractor(MisskeyExtractor):\n    \"\"\"Extractor for a Misskey user's notes\"\"\"\n    subcategory = \"notes\"\n    pattern = BASE_PATTERN + r\"/@([^/?#]+)/notes\"\n    example = \"https://misskey.io/@USER/notes\"\n\n    def notes(self):\n        return self.api.users_notes(self.api.user_id_by_username(\n            self.groups[-1]))\n\n\nclass MisskeyInfoExtractor(MisskeyExtractor):\n    \"\"\"Extractor for a Misskey user's profile data\"\"\"\n    subcategory = \"info\"\n    pattern = BASE_PATTERN + r\"/@([^/?#]+)/info\"\n    example = \"https://misskey.io/@USER/info\"\n\n    def items(self):\n        user = self.api.users_show(self.groups[-1])\n        return iter(((Message.Directory, \"\", user),))\n\n\nclass MisskeyAvatarExtractor(MisskeyExtractor):\n    \"\"\"Extractor for a Misskey user's avatar\"\"\"\n    subcategory = \"avatar\"\n    pattern = BASE_PATTERN + r\"/@([^/?#]+)/avatar\"\n    example = \"https://misskey.io/@USER/avatar\"\n\n    def notes(self):\n        user = self.api.users_show(self.groups[-1])\n        url = user.get(\"avatarUrl\")\n        return (self._make_note(\"avatar\", user, url),) if url else ()\n\n\nclass MisskeyBackgroundExtractor(MisskeyExtractor):\n    \"\"\"Extractor for a Misskey user's banner image\"\"\"\n    subcategory = \"background\"\n    pattern = BASE_PATTERN + r\"/@([^/?#]+)/ba(?:nner|ckground)\"\n    example = \"https://misskey.io/@USER/banner\"\n\n    def notes(self):\n        user = self.api.users_show(self.groups[-1])\n        url = user.get(\"bannerUrl\")\n        return (self._make_note(\"background\", user, url),) if url else ()\n\n\nclass MisskeyFollowingExtractor(MisskeyExtractor):\n    \"\"\"Extractor for followed Misskey users\"\"\"\n    subcategory = \"following\"\n    pattern = BASE_PATTERN + r\"/@([^/?#]+)/following\"\n    example = \"https://misskey.io/@USER/following\"\n\n    def items(self):\n        user_id = self.api.user_id_by_username(self.groups[-1])\n        for user in self.api.users_following(user_id):\n            user = user[\"followee\"]\n            url = f\"{self.root}/@{user['username']}\"\n            if (host := user[\"host\"]) is not None:\n                url = f\"{url}@{host}\"\n            user[\"_extractor\"] = MisskeyUserExtractor\n            yield Message.Queue, url, user\n\n\nclass MisskeyNoteExtractor(MisskeyExtractor):\n    \"\"\"Extractor for images from a Note\"\"\"\n    subcategory = \"note\"\n    pattern = BASE_PATTERN + r\"/notes/(\\w+)\"\n    example = \"https://misskey.io/notes/98765\"\n\n    def notes(self):\n        return (self.api.notes_show(self.groups[-1]),)\n\n\nclass MisskeyFavoriteExtractor(MisskeyExtractor):\n    \"\"\"Extractor for favorited notes\"\"\"\n    subcategory = \"favorite\"\n    pattern = BASE_PATTERN + r\"/(?:my|api/i)/favorites\"\n    example = \"https://misskey.io/my/favorites\"\n\n    def notes(self):\n        return self.api.i_favorites()\n\n\nclass MisskeyAPI():\n    \"\"\"Interface for Misskey API\n\n    https://github.com/misskey-dev/misskey\n    https://misskey-hub.net/en/docs/api/\n    https://misskey-hub.net/docs/api/endpoints.html\n    \"\"\"\n\n    def __init__(self, extractor):\n        self.root = extractor.root\n        self.extractor = extractor\n        self.access_token = extractor.config(\"access-token\")\n\n    def user_id_by_username(self, username):\n        return self.users_show(username)[\"id\"]\n\n    def users_following(self, user_id):\n        endpoint = \"/users/following\"\n        data = {\"userId\": user_id}\n        return self._pagination(endpoint, data)\n\n    def users_notes(self, user_id):\n        endpoint = \"/users/notes\"\n        data = {\"userId\": user_id}\n        return self._pagination(endpoint, data)\n\n    def users_show(self, username):\n        return self.extractor.cache(self._users_show_impl, username)\n\n    def _users_show_impl(self, username):\n        endpoint = \"/users/show\"\n        username, _, host = username.partition(\"@\")\n        data = {\"username\": username, \"host\": host or None}\n        return self._call(endpoint, data)\n\n    def notes_show(self, note_id):\n        endpoint = \"/notes/show\"\n        data = {\"noteId\": note_id}\n        return self._call(endpoint, data)\n\n    def i_favorites(self):\n        endpoint = \"/i/favorites\"\n        if not self.access_token:\n            raise self.extractor.exc.AuthenticationError()\n        data = {\"i\": self.access_token}\n        return self._pagination(endpoint, data)\n\n    def _call(self, endpoint, data):\n        url = f\"{self.root}/api{endpoint}\"\n        return self.extractor.request_json(url, method=\"POST\", json=data)\n\n    def _pagination(self, endpoint, data):\n        extr = self.extractor\n        data[\"limit\"] = 100\n        data[\"withRenotes\"] = extr.renotes\n        data[\"withFiles\"] = False if extr.config(\"text-posts\") else True\n\n        date_min, date_max = extr._get_date_min_max()\n        if (order := extr.config(\"order-posts\")) and \\\n                order[0] in {\"a\", \"r\"}:\n            key = \"sinceId\"\n            data[\"sinceDate\"] = 1 if date_min is None else date_min * 1000\n            date_stop = None if date_max is None else date_max\n        else:\n            key = \"untilId\"\n            date_stop = None\n            if date_min is not None:\n                data[\"sinceDate\"] = date_min * 1000\n                if date_max is None:\n                    # ensure notes are returned in descending order\n                    data[\"untilDate\"] = (int(dt.time.time()) + 1000) * 1000\n            if date_max is not None:\n                data[\"untilDate\"] = date_max * 1000\n\n        while True:\n            notes = self._call(endpoint, data)\n            if not notes:\n                return\n            elif date_stop is not None and dt.to_ts(dt.parse_iso(\n                    notes[-1][\"createdAt\"])) > date_stop:\n                for idx, note in enumerate(notes):\n                    if dt.to_ts(dt.parse_iso(note[\"createdAt\"])) > date_stop:\n                        yield from notes[:idx]\n                        return\n            else:\n                yield from notes\n\n            data[key] = notes[-1][\"id\"]\n"
  },
  {
    "path": "gallery_dl/extractor/mixdrop.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://mixdrop.ag/\"\"\"\n\nfrom .common import Extractor, Message\nfrom .. import text\n\nBASE_PATTERN = r\"(?:https?://)?(?:www\\.)?m[1i]xdrop\\.(?:com|net|top|ag|bz)\"\n\n\nclass MixdropFileExtractor(Extractor):\n    \"\"\"Extractor for mixdrop files\"\"\"\n    category = \"mixdrop\"\n    subcategory = \"file\"\n    root = \"https://mixdrop.ag\"\n    filename_fmt = \"{title} ({id}).{extension}\"\n    archive_fmt = \"{id}\"\n    pattern = BASE_PATTERN + r\"/[fe]/([^/?#]+)\"\n    example = \"https://mixdrop.ag/f/0123456789abcdef\"\n\n    def items(self):\n        fid = self.groups[0]\n        page = self.request(f\"{self.root}/e/{fid}\").text\n        string, pos = text.extract(page, \"}}return p}('\", \"'\")\n        items = text.extract(page, \",'\", \"'.split(\", pos)[0].split(\"|\")\n        txt = text.re(r\"\\b\\w+\\b\").sub(lambda m: items[int(m[0])], string)\n\n        data = text.nameext_from_name(text.extr(txt, '.vfile=\"', '\"'), {\n            \"id\": fid,\n            \"title\": text.unescape(text.remove_html(text.extr(\n                page, '<div class=\"title\">', \"</div>\"))),\n            \"poster\": text.ensure_http_scheme(text.extr(\n                txt, '.poster=\"', '\"')),\n        })\n        yield Message.Directory, \"\", data\n        yield Message.Url, text.ensure_http_scheme(text.extr(\n            txt, '.wurl=\"', '\"')), data\n"
  },
  {
    "path": "gallery_dl/extractor/moebooru.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2020-2025 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for Moebooru based sites\"\"\"\n\nfrom .booru import BooruExtractor\nfrom .. import text, dt\nimport collections\n\n\nclass MoebooruExtractor(BooruExtractor):\n    \"\"\"Base class for Moebooru extractors\"\"\"\n    basecategory = \"moebooru\"\n    filename_fmt = \"{category}_{id}_{md5}.{extension}\"\n    page_start = 1\n\n    def _prepare(self, post):\n        post[\"date\"] = dt.parse_ts(post[\"created_at\"])\n\n    def _html(self, post):\n        url = f\"{self.root}/post/show/{post['id']}\"\n        return self.request(url).text\n\n    def _tags(self, post, page):\n        tag_container = text.extr(page, '<ul id=\"tag-', '</ul>')\n        if not tag_container:\n            return\n\n        tags = collections.defaultdict(list)\n        pattern = text.re(r\"tag-type-([^\\\"' ]+).*?[?;]tags=([^\\\"'+]+)\")\n        for tag_type, tag_name in pattern.findall(tag_container):\n            tags[tag_type].append(text.unquote(tag_name))\n        for key, value in tags.items():\n            post[\"tags_\" + key] = \" \".join(value)\n\n    def _notes(self, post, page):\n        note_container = text.extr(page, 'id=\"note-container\"', \"<img \")\n        if not note_container:\n            return\n\n        post[\"notes\"] = notes = []\n        for note in note_container.split('class=\"note-box\"')[1:]:\n            extr = text.extract_from(note)\n            notes.append({\n                \"width\" : int(extr(\"width:\", \"p\")),\n                \"height\": int(extr(\"height:\", \"p\")),\n                \"y\"     : int(extr(\"top:\", \"p\")),\n                \"x\"     : int(extr(\"left:\", \"p\")),\n                \"id\"    : int(extr('id=\"note-body-', '\"')),\n                \"body\"  : text.unescape(text.remove_html(extr(\">\", \"</div>\"))),\n            })\n\n    def _pagination(self, url, params):\n        params[\"page\"] = self.page_start\n        params[\"limit\"] = self.per_page\n\n        while True:\n            posts = self.request_json(url, params=params)\n            yield from posts\n\n            if len(posts) < self.per_page:\n                return\n            params[\"page\"] += 1\n\n\nBASE_PATTERN = MoebooruExtractor.update({\n    \"yandere\": {\n        \"root\": \"https://yande.re\",\n        \"pattern\": r\"yande\\.re\",\n    },\n    \"konachan\": {\n        \"root\": \"https://konachan.com\",\n        \"pattern\": r\"konachan\\.(?:com|net)\",\n    },\n    \"sakugabooru\": {\n        \"root\": \"https://www.sakugabooru.com\",\n        \"pattern\": r\"(?:www\\.)?sakugabooru\\.com\",\n    },\n    \"lolibooru\": {\n        \"root\": \"https://lolibooru.moe\",\n        \"pattern\": r\"lolibooru\\.moe\",\n    },\n})\n\n\nclass MoebooruTagExtractor(MoebooruExtractor):\n    subcategory = \"tag\"\n    directory_fmt = (\"{category}\", \"{search_tags}\")\n    archive_fmt = \"t_{search_tags}_{id}\"\n    pattern = BASE_PATTERN + r\"/post\\?(?:[^&#]*&)*tags=([^&#]*)\"\n    example = \"https://yande.re/post?tags=TAG\"\n\n    def __init__(self, match):\n        MoebooruExtractor.__init__(self, match)\n        self.tags = text.unquote(self.groups[-1].replace(\"+\", \" \"))\n\n    def metadata(self):\n        return {\"search_tags\": self.tags}\n\n    def posts(self):\n        params = {\"tags\": self.tags}\n        return self._pagination(self.root + \"/post.json\", params)\n\n\nclass MoebooruPoolExtractor(MoebooruExtractor):\n    subcategory = \"pool\"\n    directory_fmt = (\"{category}\", \"pool\", \"{pool}\")\n    archive_fmt = \"p_{pool}_{id}\"\n    pattern = BASE_PATTERN + r\"/pool/show/(\\d+)\"\n    example = \"https://yande.re/pool/show/12345\"\n\n    def __init__(self, match):\n        MoebooruExtractor.__init__(self, match)\n        self.pool_id = self.groups[-1]\n\n    def metadata(self):\n        if self.config(\"metadata\"):\n            url = f\"{self.root}/pool/show/{self.pool_id}.json\"\n            pool = self.request_json(url)\n            pool[\"name\"] = pool[\"name\"].replace(\"_\", \" \")\n            pool.pop(\"posts\", None)\n            return {\"pool\": pool}\n        return {\"pool\": text.parse_int(self.pool_id)}\n\n    def posts(self):\n        params = {\"tags\": \"pool:\" + self.pool_id}\n        return self._pagination(self.root + \"/post.json\", params)\n\n\nclass MoebooruPostExtractor(MoebooruExtractor):\n    subcategory = \"post\"\n    archive_fmt = \"{id}\"\n    pattern = BASE_PATTERN + r\"/post/show/(\\d+)\"\n    example = \"https://yande.re/post/show/12345\"\n\n    def posts(self):\n        params = {\"tags\": \"id:\" + self.groups[-1]}\n        return self.request_json(self.root + \"/post.json\", params=params)\n\n\nclass MoebooruPopularExtractor(MoebooruExtractor):\n    subcategory = \"popular\"\n    directory_fmt = (\"{category}\", \"popular\", \"{scale}\", \"{date}\")\n    archive_fmt = \"P_{scale[0]}_{date}_{id}\"\n    pattern = BASE_PATTERN + \\\n        r\"/post/popular_(by_(?:day|week|month)|recent)(?:\\?([^#]*))?\"\n    example = \"https://yande.re/post/popular_by_month?year=YYYY&month=MM\"\n\n    def __init__(self, match):\n        MoebooruExtractor.__init__(self, match)\n        self.scale = self.groups[-2]\n        self.query = self.groups[-1]\n\n    def metadata(self):\n        self.params = params = text.parse_query(self.query)\n\n        if \"year\" in params:\n            date = (f\"{params['year']:>04}-{params.get('month', '01'):>02}-\"\n                    f\"{params.get('day', '01'):>02}\")\n        else:\n            date = dt.date.today().isoformat()\n\n        scale = self.scale\n        if scale.startswith(\"by_\"):\n            scale = scale[3:]\n        if scale == \"week\":\n            date = dt.date.fromisoformat(date)\n            date = (date - dt.timedelta(days=date.weekday())).isoformat()\n        elif scale == \"month\":\n            date = date[:-3]\n\n        return {\"date\": date, \"scale\": scale}\n\n    def posts(self):\n        url = f\"{self.root}/post/popular_{self.scale}.json\"\n        return self.request_json(url, params=self.params)\n"
  },
  {
    "path": "gallery_dl/extractor/motherless.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2024-2026 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://motherless.com/\"\"\"\n\nfrom .common import Extractor, Message\nfrom .. import text, dt\n\nBASE_PATTERN = r\"(?:https?://)?motherless\\.com\"\n\n\nclass MotherlessExtractor(Extractor):\n    \"\"\"Base class for motherless extractors\"\"\"\n    category = \"motherless\"\n    root = \"https://motherless.com\"\n    filename_fmt = \"{id} {title}.{extension}\"\n    archive_fmt = \"{id}\"\n\n    def request(self, url, **kwargs):\n        response = Extractor.request(self, url, **kwargs)\n\n        content = response.content\n        if (b'<div class=\"error-page' in content or\n                b\">The page you're looking for cannot be found.<\" in content):\n            raise self.exc.NotFoundError(\"page\")\n\n        self.request = Extractor.request.__get__(self)\n        return response\n\n    def _extract_media(self, path):\n        url = f\"{self.root}/{path}\"\n        page = self.request(url).text\n        extr = text.extract_from(page)\n\n        path, _, media_id = path.rpartition(\"/\")\n        data = {\n            \"id\"   : media_id,\n            \"title\": text.unescape(\n                (t := extr(\"<title>\", \"<\")) and t[:t.rfind(\" | \")]),\n            \"type\" : extr(\"__mediatype = '\", \"'\"),\n            \"group\": extr(\"__group = '\", \"'\"),\n            \"url\"  : extr(\"__fileurl = '\", \"'\"),\n            \"tags\" : [\n                text.unescape(tag)\n                for tag in text.extract_iter(\n                    extr('class=\"media-meta-tags\">', \"</div>\"), \">#\", \"<\")\n            ],\n            \"views\": text.parse_int(extr(\n                'class=\"count\">', \" \").replace(\",\", \"\")),\n            \"favorites\": text.parse_int(extr(\n                'class=\"count\">', \" \").replace(\",\", \"\")),\n            \"date\" : self._parse_datetime(extr('class=\"count\">', \"<\")),\n            \"uploader\": text.unescape(extr('class=\"username\">', \"<\").strip()),\n        }\n\n        if not path:\n            pass\n        elif path[0] == \"G\":\n            data[\"gallery_id\"] = path[1:]\n            data[\"gallery_title\"] = self.cache(\n                self._extract_gallery_title, data[\"gallery_id\"], page)\n        elif path[0] == \"g\":\n            data[\"group_id\"] = path[2:]\n            data[\"group_title\"] = self.cache(\n                self._extract_group_title, data[\"group_id\"], page)\n\n        return data\n\n    def _pagination(self, page):\n        while True:\n            for thumb in text.extract_iter(\n                    page, 'class=\"thumb-container', \"</div>\"):\n                yield thumb\n\n            url = text.extr(page, '<link rel=\"next\" href=\"', '\"')\n            if not url:\n                return\n            page = self.request(text.unescape(url)).text\n\n    def _extract_data(self, page, category):\n        extr = text.extract_from(page)\n\n        gid = self.groups[-1]\n        if category == \"gallery\":\n            title = self.cache(self._extract_gallery_title, gid, page)\n        else:\n            title = self.cache(self._extract_group_title, gid, page)\n        creator = text.remove_html(extr(\n            f'class=\"{category}-member-username\">', \"</\"))\n\n        return {\n            category + \"_id\": gid,\n            category + \"_title\": title,\n            category + \"_creator\": creator,\n            \"uploader\": creator,\n            \"count\": text.parse_int(\n                extr('<span class=\"active\">', \")\")\n                .rpartition(\"(\")[2].replace(\",\", \"\")),\n        }\n\n    def _parse_thumb_data(self, thumb):\n        extr = text.extract_from(thumb)\n\n        data = {\n            \"id\"       : extr('data-codename=\"', '\"'),\n            \"type\"     : extr('data-mediatype=\"', '\"'),\n            \"thumbnail\": extr('class=\"static\" src=\"', '\"'),\n            \"title\"    : extr(' alt=\"', '\"'),\n        }\n        data[\"url\"] = data[\"thumbnail\"].replace(\"thumb\", data[\"type\"])\n\n        return data\n\n    def _parse_datetime(self, dt_string):\n        if \" ago\" not in dt_string:\n            return dt.parse(dt_string, \"%d  %b  %Y\")\n\n        value = text.parse_int(dt_string[:-5])\n        delta = (dt.timedelta(0, value*3600) if dt_string[-5] == \"h\" else\n                 dt.timedelta(value))\n        return (dt.now() - delta).replace(hour=0, minute=0, second=0)\n\n    def _extract_gallery_title(self, gallery_id, page):\n        title = text.extr(\n            text.extr(page, '<h1 class=\"content-title\">', \"</h1>\"),\n            \"From the gallery:\", \"<\")\n        if title:\n            return text.unescape(title.strip())\n\n        if f' href=\"/G{gallery_id}\"' in page:\n            return text.unescape(\n                (t := text.extr(page, \"<title>\", \"<\")) and t[:t.rfind(\" | \")])\n\n        return \"\"\n\n    def _extract_group_title(self, group_id, page):\n        title = text.extr(\n            text.extr(page, '<h1 class=\"group-bio-name\">', \"</h1>\"),\n            \">\", \"<\")\n        if title:\n            return text.unescape(title.strip())\n\n        return \"\"\n\n\nclass MotherlessMediaExtractor(MotherlessExtractor):\n    \"\"\"Extractor for a single image/video from motherless.com\"\"\"\n    subcategory = \"media\"\n    pattern = (BASE_PATTERN +\n               r\"/((?:g/[^/?#]+/|G[IV]?[A-Z0-9]+/)?\"\n               r\"(?!G)[A-Z0-9]+)\")\n    example = \"https://motherless.com/ABC123\"\n\n    def items(self):\n        file = self._extract_media(self.groups[0])\n        url = file[\"url\"]\n        yield Message.Directory, \"\", file\n        yield Message.Url, url, text.nameext_from_url(url, file)\n\n\nclass MotherlessGalleryExtractor(MotherlessExtractor):\n    \"\"\"Extractor for a motherless.com gallery\"\"\"\n    subcategory = \"gallery\"\n    directory_fmt = (\"{category}\", \"{uploader}\",\n                     \"{gallery_id} {gallery_title}\")\n    archive_fmt = \"{gallery_id}_{id}\"\n    pattern = BASE_PATTERN + \"/G([IVG])?([A-Z0-9]+)/?$\"\n    example = \"https://motherless.com/GABC123\"\n\n    def items(self):\n        type, gid = self.groups\n\n        if not type:\n            data = {\"_extractor\": MotherlessGalleryExtractor}\n            yield Message.Queue, f\"{self.root}/GI{gid}\", data\n            yield Message.Queue, f\"{self.root}/GV{gid}\", data\n            return\n\n        url = f\"{self.root}/G{type}{gid}\"\n        page = self.request(url).text\n        data = self._extract_data(page, \"gallery\")\n\n        for num, thumb in enumerate(self._pagination(page), 1):\n            file = self._parse_thumb_data(thumb)\n            thumbnail = file[\"thumbnail\"]\n\n            file = self._extract_media(file[\"id\"])\n\n            uploader = file.get(\"uploader\")\n            file.update(data)\n            file[\"num\"] = num\n            file[\"thumbnail\"] = thumbnail\n            file[\"uploader\"] = uploader\n            url = file[\"url\"]\n            yield Message.Directory, \"\", file\n            yield Message.Url, url, text.nameext_from_url(url, file)\n\n\nclass MotherlessGroupExtractor(MotherlessExtractor):\n    subcategory = \"group\"\n    directory_fmt = (\"{category}\", \"{uploader}\",\n                     \"{group_id} {group_title}\")\n    archive_fmt = \"{group_id}_{id}\"\n    pattern = BASE_PATTERN + \"/g([iv]?)/?([a-z0-9_]+)/?$\"\n    example = \"https://motherless.com/g/abc123\"\n\n    def items(self):\n        type, gid = self.groups\n\n        if not type:\n            data = {\"_extractor\": MotherlessGroupExtractor}\n            yield Message.Queue, f\"{self.root}/gi/{gid}\", data\n            yield Message.Queue, f\"{self.root}/gv/{gid}\", data\n            return\n\n        url = f\"{self.root}/g{type}/{gid}\"\n        page = self.request(url).text\n        data = self._extract_data(page, \"group\")\n\n        for num, thumb in enumerate(self._pagination(page), 1):\n            file = self._parse_thumb_data(thumb)\n            thumbnail = file[\"thumbnail\"]\n\n            file = self._extract_media(file[\"id\"])\n\n            uploader = file.get(\"uploader\")\n            file.update(data)\n            file[\"num\"] = num\n            file[\"thumbnail\"] = thumbnail\n            file[\"uploader\"] = uploader\n            file[\"group\"] = file[\"group_id\"]\n            url = file[\"url\"]\n            yield Message.Directory, \"\", file\n            yield Message.Url, url, text.nameext_from_url(url, file)\n"
  },
  {
    "path": "gallery_dl/extractor/myhentaigallery.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://myhentaigallery.com/\"\"\"\n\nfrom .common import Extractor, GalleryExtractor, Message\nfrom .. import text\n\nBASE_PATTERN = r\"(?:https?://)?myhentaigallery\\.com\"\n\n\nclass MyhentaigalleryBase():\n    category = \"myhentaigallery\"\n    root = \"https://myhentaigallery.com\"\n\n\nclass MyhentaigalleryGalleryExtractor(MyhentaigalleryBase, GalleryExtractor):\n    \"\"\"Extractor for image galleries from myhentaigallery.com\"\"\"\n    directory_fmt = (\"{category}\", \"{gallery_id} {artist:?[/] /J, }{title}\")\n    pattern = BASE_PATTERN + r\"/g(?:allery/(?:thumbnails|show))?/(\\d+)\"\n    example = \"https://myhentaigallery.com/g/12345\"\n\n    def __init__(self, match):\n        self.gallery_id = match[1]\n        url = f\"{self.root}/g/{self.gallery_id}\"\n        GalleryExtractor.__init__(self, match, url)\n\n    def _init(self):\n        self.session.headers[\"Referer\"] = self.page_url\n\n    def metadata(self, page):\n        extr = text.extract_from(page)\n        split = text.split_html\n\n        title = extr('<div class=\"comic-description\">\\n', '</h1>').lstrip()\n        if title.startswith(\"<h1>\"):\n            title = title[4:]\n\n        if not title:\n            raise self.exc.NotFoundError(\"gallery\")\n\n        return {\n            \"title\"     : text.unescape(title),\n            \"gallery_id\": text.parse_int(self.gallery_id),\n            \"tags\"      : split(extr(\"        Categories:\", \"</div>\")),\n            \"artist\"    : split(extr(\"        Artists:\"   , \"</div>\")),\n            \"group\"     : split(extr(\"        Groups:\"    , \"</div>\")),\n            \"parodies\"  : split(extr(\"        Parodies:\"  , \"</div>\")),\n        }\n\n    def images(self, page):\n        return [\n            (text.unescape(text.extr(url, 'src=\"', '\"')).replace(\n                \"/thumbnail/\", \"/original/\"), None)\n            for url in text.extract_iter(page, 'class=\"comic-thumb\"', '</div>')\n        ]\n\n\nclass MyhentaigalleryTagExtractor(MyhentaigalleryBase, Extractor):\n    \"\"\"Extractor for myhentaigallery tag searches\"\"\"\n    subcategory = \"tag\"\n    pattern = BASE_PATTERN + r\"(/g/(artist|category|group|parody)/(\\d+).*)\"\n    example = \"https://myhentaigallery.com/g/category/123\"\n\n    def items(self):\n        data = {\"_extractor\": MyhentaigalleryGalleryExtractor}\n        for url in self.galleries():\n            yield Message.Queue, url, data\n\n    def galleries(self):\n        root = self.root\n        url = root + self.groups[0]\n\n        while True:\n            page = self.request(url).text\n\n            for inner in text.extract_iter(\n                    page, '<div class=\"comic-inner\">', \"<div\"):\n                yield root + text.extr(inner, 'href=\"', '\"')\n\n            try:\n                pos = page.index(\">Next<\")\n            except ValueError:\n                return\n            url = root + text.rextr(page, 'href=\"', '\"', pos)\n"
  },
  {
    "path": "gallery_dl/extractor/myportfolio.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2018-2026 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://www.myportfolio.com/\"\"\"\n\nfrom .common import Extractor, Message\nfrom .. import text\n\n\nclass MyportfolioGalleryExtractor(Extractor):\n    \"\"\"Extractor for an image gallery on www.myportfolio.com\"\"\"\n    category = \"myportfolio\"\n    subcategory = \"gallery\"\n    directory_fmt = (\"{category}\", \"{user}\", \"{title}\")\n    filename_fmt = \"{num:>02}.{extension}\"\n    archive_fmt = \"{user}_{filename}\"\n    pattern = (r\"(?:myportfolio:(?:https?://)?([^/]+)|\"\n               r\"(?:https?://)?(?!cdn\\.)([\\w-]+\\.myportfolio\\.com))\"\n               r\"(/[^/?#]+)?\")\n    example = \"https://USER.myportfolio.com/TITLE\"\n\n    def items(self):\n        domain_alt, domain, path = self.groups\n        if domain is None:\n            domain = domain_alt\n            prefix = \"myportfolio:\"\n        else:\n            prefix = \"\"\n\n        url = f\"https://{domain}{path or ''}\"\n        response = self.request(url)\n        if response.history and response.url.endswith(\".adobe.com/missing\"):\n            raise self.exc.NotFoundError()\n        page = response.text\n\n        projects = text.extr(\n            page, '<section class=\"project-covers', '</section>')\n\n        if projects:\n            data = {\"_extractor\": MyportfolioGalleryExtractor}\n            base = f\"{prefix}https://{domain}\"\n            for path in text.extract_iter(projects, ' href=\"', '\"'):\n                yield Message.Queue, base + path, data\n        else:\n            data = self.metadata(page)\n            imgs = self.images(page)\n            data[\"count\"] = len(imgs)\n            yield Message.Directory, \"\", data\n            for data[\"num\"], url in enumerate(imgs, 1):\n                yield Message.Url, url, text.nameext_from_url(url, data)\n\n    def metadata(self, page):\n        \"\"\"Collect general image metadata\"\"\"\n        # og:title contains data as \"<user> - <title>\", but both\n        # <user> and <title> can contain a \"-\" as well, so we get the title\n        # from somewhere else and cut that amount from the og:title content\n\n        extr = text.extract_from(page)\n        user = extr('property=\"og:title\" content=\"', '\"') or \\\n            extr('property=og:title content=\"', '\"')\n        descr = extr('property=\"og:description\" content=\"', '\"') or \\\n            extr('property=og:description content=\"', '\"')\n        title = extr('<h1 ', '</h1>')\n\n        if title:\n            title = title.partition(\">\")[2]\n            user = user[:-len(title)-3]\n        elif user:\n            user, _, title = user.partition(\" - \")\n        else:\n            raise self.exc.NotFoundError()\n\n        return {\n            \"user\": text.unescape(user),\n            \"title\": text.unescape(title),\n            \"description\": text.unescape(descr),\n        }\n\n    def images(self, page):\n        \"\"\"Extract and return a list of all image-urls\"\"\"\n        return (\n            list(text.extract_iter(page, 'js-lightbox\" data-src=\"', '\"')) or\n            list(text.extract_iter(page, 'data-src=\"', '\"'))\n        )\n"
  },
  {
    "path": "gallery_dl/extractor/naverblog.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2019-2025 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://blog.naver.com/\"\"\"\n\nfrom .common import GalleryExtractor, Extractor, Message\nfrom .. import text, util, dt\nimport time\n\n\nclass NaverBlogBase():\n    \"\"\"Base class for blog.naver.com extractors\"\"\"\n    category = \"naver-blog\"\n    root = \"https://blog.naver.com\"\n\n\nclass NaverBlogPostExtractor(NaverBlogBase, GalleryExtractor):\n    \"\"\"Extractor for blog posts on blog.naver.com\"\"\"\n    subcategory = \"post\"\n    filename_fmt = \"{num:>03}.{extension}\"\n    directory_fmt = (\"{category}\", \"{blog[user]} {blog[id]}\",\n                     \"{post[date]:%Y-%m-%d} {post[title]}\")\n    archive_fmt = \"{blog[id]}_{post[num]}_{num}\"\n    pattern = (r\"(?:https?://)?blog\\.naver\\.com/\"\n               r\"(?:PostView\\.n(?:aver|hn)\\?blogId=(\\w+)&logNo=(\\d+)|\"\n               r\"(\\w+)/(\\d+)/?$)\")\n    example = \"https://blog.naver.com/BLOGID/12345\"\n\n    def __init__(self, match):\n        if blog_id := match[1]:\n            self.blog_id = blog_id\n            self.post_id = match[2]\n        else:\n            self.blog_id = match[3]\n            self.post_id = match[4]\n\n        url = (f\"{self.root}/PostView.nhn\"\n               f\"?blogId={self.blog_id}&logNo={self.post_id}\")\n        GalleryExtractor.__init__(self, match, url)\n\n    def metadata(self, page):\n        extr = text.extract_from(page)\n        data = {\n            \"post\": {\n                \"title\"      : text.unescape(extr(\n                    '\"og:title\" content=\"', '\"')),\n                \"description\": text.unescape(extr(\n                    '\"og:description\" content=\"', '\"')).replace(\"&nbsp;\", \" \"),\n                \"num\"        : text.parse_int(self.post_id),\n            },\n            \"blog\": {\n                \"id\"         : self.blog_id,\n                \"num\"        : text.parse_int(extr(\"var blogNo = '\", \"'\")),\n                \"user\"       : extr(\"var nickName = '\", \"'\"),\n            },\n        }\n\n        data[\"post\"][\"date\"] = self._parse_datetime(\n            extr('se_publishDate pcol2\">', '<') or\n            extr('_postAddDate\">', '<'))\n\n        return data\n\n    def _parse_datetime(self, dt_string):\n        if \"전\" in dt_string:\n            ts = time.gmtime()\n            return dt.datetime(ts.tm_year, ts.tm_mon, ts.tm_mday)\n        return dt.parse(dt_string, \"%Y. %m. %d. %H:%M\")\n\n    def images(self, page):\n        files = []\n        self._extract_images(files, page)\n        if self.config(\"videos\", True):\n            self._extract_videos(files, page)\n        return files\n\n    def _extract_images(self, files, page):\n        for url in text.extract_iter(page, 'data-lazy-src=\"', '\"'):\n            url = url.replace(\"://post\", \"://blog\", 1).partition(\"?\")[0]\n            if \"\\ufffd\" in text.unquote(url):\n                url = text.unquote(url, encoding=\"EUC-KR\")\n            files.append((url, None))\n\n    def _extract_videos(self, files, page):\n        for module in text.extract_iter(page, \" data-module='\", \"'\"):\n            if '\"v2_video\"' not in module:\n                continue\n            try:\n                media = util.json_loads(module)[\"data\"]\n                self._extract_media(files, media)\n            except Exception as exc:\n                self.log.warning(\"%s: Failed to extract video '%s' (%s: %s)\",\n                                 self.post_id, media.get(\"vid\"),\n                                 exc.__class__.__name__, exc)\n\n    def _extract_media(self, files, media):\n        url = (\"https://apis.naver.com/rmcnmv/rmcnmv/vod/play/v2.0/\" +\n               media[\"vid\"])\n        params = {\n            \"key\"  : media[\"inkey\"],\n            \"sid\"  : \"2\",\n            #  \"pid\": \"00000000-0000-0000-0000-000000000000\",\n            \"nonce\": int(time.time()),\n            \"devt\" : \"html5_pc\",\n            \"prv\"  : \"N\",\n            \"aup\"  : \"N\",\n            \"stpb\" : \"N\",\n            \"cpl\"  : \"ko_KR\",\n            \"providerEnv\": \"real\",\n            \"adt\"  : \"glad\",\n            \"lc\"   : \"ko_KR\",\n        }\n        data = self.request_json(url, params=params)\n        video = max(data[\"videos\"][\"list\"],\n                    key=lambda v: v.get(\"size\") or 0)\n        files.append((video[\"source\"], video))\n\n\nclass NaverBlogBlogExtractor(NaverBlogBase, Extractor):\n    \"\"\"Extractor for a user's blog on blog.naver.com\"\"\"\n    subcategory = \"blog\"\n    categorytransfer = True\n    pattern = (r\"(?:https?://)?blog\\.naver\\.com/\"\n               r\"(?:PostList\\.n(?:aver|hn)\\?(?:[^&#]+&)*blogId=([^&#]+)|\"\n               r\"(\\w+)/?$)\")\n    example = \"https://blog.naver.com/BLOGID\"\n\n    def __init__(self, match):\n        Extractor.__init__(self, match)\n        self.blog_id = match[1] or match[2]\n\n    def items(self):\n        # fetch first post number\n        url = f\"{self.root}/PostList.nhn?blogId={self.blog_id}\"\n        post_num = text.extr(\n            self.request(url).text, 'gnFirstLogNo = \"', '\"',\n        )\n\n        # setup params for API calls\n        url = self.root + \"/PostViewBottomTitleListAsync.nhn\"\n        params = {\n            \"blogId\"             : self.blog_id,\n            \"logNo\"              : post_num or \"0\",\n            \"viewDate\"           : \"\",\n            \"categoryNo\"         : \"\",\n            \"parentCategoryNo\"   : \"\",\n            \"showNextPage\"       : \"true\",\n            \"showPreviousPage\"   : \"false\",\n            \"sortDateInMilli\"    : \"\",\n            \"isThumbnailViewType\": \"false\",\n            \"countPerPage\"       : \"\",\n        }\n\n        # loop over all posts\n        while True:\n            data = self.request_json(url, params=params)\n\n            for post in data[\"postList\"]:\n                post[\"url\"] = (f\"{self.root}/PostView.nhn?blogId=\"\n                               f\"{self.blog_id}&logNo={post['logNo']}\")\n                post[\"_extractor\"] = NaverBlogPostExtractor\n                yield Message.Queue, post[\"url\"], post\n\n            if not data[\"hasNextPage\"]:\n                return\n            params[\"logNo\"] = data[\"nextIndexLogNo\"]\n            params[\"sortDateInMilli\"] = data[\"nextIndexSortDate\"]\n"
  },
  {
    "path": "gallery_dl/extractor/naverchzzk.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://chzzk.naver.com/\"\"\"\n\nfrom .common import Extractor, Message\nfrom .. import text, util\n\n\nclass NaverChzzkExtractor(Extractor):\n    \"\"\"Base class for chzzk.naver.com extractors\"\"\"\n    category = \"naver-chzzk\"\n    filename_fmt = \"{uid}_{id}_{num}.{extension}\"\n    directory_fmt = (\"{category}\", \"{user[userNickname]}\")\n    archive_fmt = \"{uid}_{id}_{num}\"\n\n    def request_api(self, uid, id=None, params=None):\n        return self.request_json(\n            f\"https://apis.naver.com/nng_main/nng_comment_api/v1/type\"\n            f\"/CHANNEL_POST/id/{uid}/comments/{id or ''}\",\n            params=params)[\"content\"]\n\n    def items(self):\n        for comment in self.comments():\n            data = comment[\"comment\"]\n            files = data.pop(\"attaches\") or ()\n            data[\"id\"] = data[\"commentId\"]\n            data[\"uid\"] = data[\"objectId\"]\n            data[\"user\"] = comment[\"user\"]\n            data[\"count\"] = len(files)\n            data[\"date\"] = self.parse_datetime(\n                data[\"createdDate\"], \"%Y%m%d%H%M%S\")\n\n            yield Message.Directory, \"\", data\n            for data[\"num\"], file in enumerate(files, 1):\n                if extra := file.get(\"extraJson\"):\n                    file.update(util.json_loads(extra))\n                file[\"date\"] = self.parse_datetime_iso(\n                    file[\"createdDate\"])\n                file[\"date_updated\"] = self.parse_datetime_iso(\n                    file[\"updatedDate\"])\n                data[\"file\"] = file\n                url = file[\"attachValue\"]\n                yield Message.Url, url, text.nameext_from_url(url, data)\n\n\nclass NaverChzzkCommentExtractor(NaverChzzkExtractor):\n    \"\"\"Extractor for individual comment from chzzk.naver.com\"\"\"\n    subcategory = \"comment\"\n    pattern = r\"(?:https?://)?chzzk\\.naver\\.com/(\\w+)/community/detail/(\\d+)\"\n    example = \"https://chzzk.naver.com/0123456789abcdef/community/detail/12345\"\n\n    def comments(self):\n        uid, id = self.groups\n        res = self.request_api(uid, id)\n        return ({\"comment\": res[\"comment\"], \"user\": res[\"user\"]},)\n\n\nclass NaverChzzkCommunityExtractor(NaverChzzkExtractor):\n    \"\"\"Extractor for comments from chzzk.naver.com\"\"\"\n    subcategory = \"community\"\n    pattern = r\"(?:https?://)?chzzk\\.naver\\.com/(\\w+)/community\"\n    example = \"https://chzzk.naver.com/0123456789abcdef/community\"\n    request_interval = (0.5, 1.5)\n\n    def comments(self):\n        uid = self.match[1]\n        params = {\n            \"limit\": 10,\n            \"offset\": text.parse_int(self.config(\"offset\")),\n            \"pagingType\": \"PAGE\",\n        }\n        while True:\n            comments = self.request_api(uid, params=params)[\"comments\"]\n            yield from comments[\"data\"]\n            if not comments[\"page\"][\"next\"]:\n                return\n            params[\"offset\"] += params[\"limit\"]\n"
  },
  {
    "path": "gallery_dl/extractor/naverwebtoon.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2021 Seonghyeon Cho\n# Copyright 2022-2025 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://comic.naver.com/\"\"\"\n\nfrom .common import GalleryExtractor, Extractor, Message\nfrom .. import text\n\nBASE_PATTERN = (r\"(?:https?://)?comic\\.naver\\.com\"\n                r\"/(webtoon|challenge|bestChallenge)\")\n\n\nclass NaverWebtoonBase():\n    \"\"\"Base class for comic.naver.com extractors\"\"\"\n    category = \"naver-webtoon\"\n    root = \"https://comic.naver.com\"\n\n\nclass NaverWebtoonEpisodeExtractor(NaverWebtoonBase, GalleryExtractor):\n    subcategory = \"episode\"\n    directory_fmt = (\"{category}\", \"{comic}\")\n    filename_fmt = \"{episode:>03}-{num:>02}.{extension}\"\n    archive_fmt = \"{title_id}_{episode}_{num}\"\n    pattern = BASE_PATTERN + r\"/detail(?:\\.nhn)?\\?([^#]+)\"\n    example = \"https://comic.naver.com/webtoon/detail?titleId=12345&no=1\"\n\n    def __init__(self, match):\n        path, query = match.groups()\n        url = f\"{self.root}/{path}/detail?{query}\"\n        GalleryExtractor.__init__(self, match, url)\n\n        query = text.parse_query(query)\n        self.title_id = query.get(\"titleId\")\n        self.episode = query.get(\"no\")\n\n    def metadata(self, page):\n        extr = text.extract_from(page)\n        return {\n            \"title_id\": self.title_id,\n            \"episode\" : self.episode,\n            \"comic\"   : extr('titleName: \"', '\"'),\n            \"tags\"    : [t.strip() for t in text.extract_iter(\n                extr(\"tagList: [\", \"],\"), '\"tagName\":\"', '\"')],\n            \"title\"   : extr('\"subtitle\":\"', '\"'),\n            \"author\"  : [a.strip() for a in text.extract_iter(\n                extr('\"writers\":[', ']'), '\"name\":\"', '\"')],\n            \"artist\"  : [a.strip() for a in text.extract_iter(\n                extr('\"painters\":[', ']'), '\"name\":\"', '\"')]\n        }\n\n    def images(self, page):\n        view_area = text.extr(page, 'id=\"comic_view_area\"', '</div>')\n        return [\n            (url, None)\n            for url in text.extract_iter(view_area, '<img src=\"', '\"')\n            if \"/static/\" not in url\n        ]\n\n\nclass NaverWebtoonComicExtractor(NaverWebtoonBase, Extractor):\n    subcategory = \"comic\"\n    categorytransfer = True\n    pattern = BASE_PATTERN + r\"/list(?:\\.nhn)?\\?([^#]+)\"\n    example = \"https://comic.naver.com/webtoon/list?titleId=12345\"\n\n    def __init__(self, match):\n        Extractor.__init__(self, match)\n        self.path, query = match.groups()\n        query = text.parse_query(query)\n        self.title_id = query.get(\"titleId\")\n        self.page_no = text.parse_int(query.get(\"page\"), 1)\n        self.sort = query.get(\"sort\", \"ASC\")\n\n    def items(self):\n        url = self.root + \"/api/article/list\"\n        headers = {\n            \"Accept\": \"application/json, text/plain, */*\",\n        }\n        params = {\n            \"titleId\": self.title_id,\n            \"page\"   : self.page_no,\n            \"sort\"   : self.sort,\n        }\n\n        while True:\n            data = self.request_json(url, headers=headers, params=params)\n\n            path = data[\"webtoonLevelCode\"].lower().replace(\"_c\", \"C\", 1)\n            base = f\"{self.root}/{path}/detail?titleId={data['titleId']}&no=\"\n\n            for article in data[\"articleList\"]:\n                article[\"_extractor\"] = NaverWebtoonEpisodeExtractor\n                yield Message.Queue, base + str(article[\"no\"]), article\n\n            params[\"page\"] = data[\"pageInfo\"][\"nextPage\"]\n            if not params[\"page\"]:\n                return\n"
  },
  {
    "path": "gallery_dl/extractor/nekohouse.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2025 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://nekohouse.su/\"\"\"\n\nfrom .common import Extractor, Message\nfrom .. import text\n\nBASE_PATTERN = r\"(?:https?://)?nekohouse\\.su\"\nUSER_PATTERN = BASE_PATTERN + r\"/([^/?#]+)/user/([^/?#]+)\"\n\n\nclass NekohouseExtractor(Extractor):\n    \"\"\"Base class for nekohouse extractors\"\"\"\n    category = \"nekohouse\"\n    root = \"https://nekohouse.su\"\n\n\nclass NekohousePostExtractor(NekohouseExtractor):\n    subcategory = \"post\"\n    directory_fmt = (\"{category}\", \"{service}\", \"{username} ({user_id})\",\n                     \"{post_id} {date} {title[b:230]}\")\n    filename_fmt = \"{num:>02} {id|filename}.{extension}\"\n    archive_fmt = \"{service}_{user_id}_{post_id}_{hash}\"\n    pattern = USER_PATTERN + r\"/post/([^/?#]+)\"\n    example = \"https://nekohouse.su/SERVICE/user/12345/post/12345\"\n\n    def items(self):\n        service, user_id, post_id = self.groups\n        url = f\"{self.root}/{service}/user/{user_id}/post/{post_id}\"\n        html = self.request(url).text\n\n        files = self._extract_files(html)\n        post = self._extract_post(html)\n        post[\"service\"] = service\n        post[\"user_id\"] = user_id\n        post[\"post_id\"] = post_id\n        post[\"count\"] = len(files)\n\n        yield Message.Directory, \"\", post\n        for post[\"num\"], file in enumerate(files, 1):\n            url = file[\"url\"]\n            text.nameext_from_url(url, file)\n            file[\"hash\"] = file[\"filename\"]\n            file.update(post)\n            if \"name\" in file:\n                text.nameext_from_url(file.pop(\"name\"), file)\n            yield Message.Url, url, file\n\n    def _extract_post(self, html):\n        extr = text.extract_from(html)\n        return {\n            \"username\": text.unescape(extr(\n                'class=\"scrape__user-name', '</').rpartition(\">\")[2].strip()),\n            \"title\"   : text.unescape(extr(\n                'class=\"scrape__title', '</').rpartition(\">\")[2]),\n            \"date\"   : self.parse_datetime_iso(extr(\n                'datetime=\"', '\"')[:19]),\n            \"content\": text.unescape(extr(\n                'class=\"scrape__content\">', \"</div>\").strip()),\n        }\n\n    def _extract_files(self, html):\n        files = []\n\n        extr = text.extract_from(text.extr(\n            html, 'class=\"scrape__files\"', \"<footer\"))\n        while True:\n            file_id = extr('<a href=\"/post/', '\"')\n            if not file_id:\n                break\n            files.append({\n                \"id\"  : file_id,\n                \"url\" : self.root + extr('href=\"', '\"'),\n                \"type\": \"file\",\n            })\n\n        extr = text.extract_from(text.extr(\n            html, 'class=\"scrape__attachments\"', \"</ul>\"))\n        while True:\n            url = extr('href=\"', '\"')\n            if not url:\n                break\n            files.append({\n                \"id\"  : \"\",\n                \"url\" : self.root + url,\n                \"name\": text.unescape(extr('download=\"', '\"')),\n                \"type\": \"attachment\",\n            })\n\n        return files\n\n\nclass NekohouseUserExtractor(NekohouseExtractor):\n    subcategory = \"user\"\n    pattern = USER_PATTERN + r\"/?(?:\\?([^#]+))?(?:$|\\?|#)\"\n    example = \"https://nekohouse.su/SERVICE/user/12345\"\n\n    def items(self):\n        service, user_id, _ = self.groups\n        creator_url = f\"{self.root}/{service}/user/{user_id}\"\n        params = {\"o\": 0}\n\n        data = {\"_extractor\": NekohousePostExtractor}\n        while True:\n            html = self.request(creator_url, params=params).text\n\n            cnt = 0\n            for post in text.extract_iter(html, \"<article\", \"</article>\"):\n                cnt += 1\n                post_url = self.root + text.extr(post, '<a href=\"', '\"')\n                yield Message.Queue, post_url, data\n\n            if cnt < 50:\n                return\n            params[\"o\"] += 50\n"
  },
  {
    "path": "gallery_dl/extractor/newgrounds.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2018-2026 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://www.newgrounds.com/\"\"\"\n\nfrom .common import Extractor, Message, Dispatch\nfrom .. import text, util, dt\nimport itertools\n\nBASE_PATTERN = r\"(?:https?://)?(?:www\\.)?newgrounds\\.com\"\nUSER_PATTERN = r\"(?:https?://)?([\\w-]+)\\.newgrounds\\.com\"\n\n\nclass NewgroundsExtractor(Extractor):\n    \"\"\"Base class for newgrounds extractors\"\"\"\n    category = \"newgrounds\"\n    directory_fmt = (\"{category}\", \"{artist[:10]:J, }\")\n    filename_fmt = \"{category}_{_index}_{title}.{extension}\"\n    archive_fmt = \"{_type}{_index}\"\n    root = \"https://www.newgrounds.com\"\n    cookies_domain = \".newgrounds.com\"\n    cookies_names = (\"NG_GG_username\", \"vmk1du5I8m\")\n    request_interval = (0.5, 1.5)\n\n    def __init__(self, match):\n        Extractor.__init__(self, match)\n        self.user = match[1]\n        self.user_root = f\"https://{self.user}.newgrounds.com\"\n\n    def _init(self):\n        self._extract_comment_urls = text.re(\n            r'(?:<img |data-smartload-)src=\"([^\"]+)').findall\n        self.flash = self.config(\"flash\", True)\n\n        fmt = self.config(\"format\")\n        if not fmt or fmt == \"original\":\n            self.format = (\"mp4\", \"webm\", \"m4v\", \"mov\", \"mkv\",\n                           1080, 720, 360)\n        elif isinstance(fmt, (list, tuple)):\n            self.format = fmt\n        else:\n            self._video_formats = self._video_formats_limit\n            self.format = (fmt if isinstance(fmt, int) else\n                           text.parse_int(fmt.rstrip(\"p\")))\n\n    def items(self):\n        self.login()\n        metadata = self.metadata()\n\n        for post_url in self.posts():\n            try:\n                post = self.extract_post(post_url)\n                url = post.get(\"url\")\n            except Exception as exc:\n                self.log.traceback(exc)\n                url = None\n\n            if url:\n                if metadata:\n                    post.update(metadata)\n                yield Message.Directory, \"\", post\n                post[\"num\"] = 0\n                yield Message.Url, url, text.nameext_from_url(url, post)\n\n                if \"_multi\" in post:\n                    for data in post[\"_multi\"]:\n                        post[\"num\"] += 1\n                        post[\"_index\"] = f\"{post['index']}_{post['num']:>02}\"\n                        post.update(data)\n                        url = data[\"image\"]\n\n                        text.nameext_from_url(url, post)\n                        yield Message.Url, url, post\n\n                        if \"_fallback\" in post:\n                            del post[\"_fallback\"]\n\n                for url in self._extract_comment_urls(post[\"_comment\"]):\n                    post[\"num\"] += 1\n                    post[\"_index\"] = f\"{post['index']}_{post['num']:>02}\"\n                    url = text.ensure_http_scheme(url)\n                    text.nameext_from_url(url, post)\n                    yield Message.Url, url, post\n            else:\n                self.status |= 1\n                self.log.warning(\n                    \"Unable to get download URL for '%s'\", post_url)\n\n    def posts(self):\n        \"\"\"Return URLs of all relevant post pages\"\"\"\n        return self._pagination(self.__class__.subcategory, self.groups[1])\n\n    def metadata(self):\n        \"\"\"Return general metadata\"\"\"\n\n    def login(self):\n        if self.cookies_check(self.cookies_names):\n            return\n\n        username, password = self._get_auth_info()\n        if username:\n            return self.cookies_update(self.cache(\n                self._login_impl, username, password,\n                _exp=365*86400, _mem=False))\n\n    def _login_impl(self, username, password):\n        self.log.info(\"Logging in as %s\", username)\n\n        url = self.root + \"/passport\"\n        response = self.request(url)\n        if response.history and response.url.endswith(\"/social\"):\n            return self.cookies\n\n        page = response.text\n        headers = {\n            \"Accept\": \"application/json, text/javascript, */*; q=0.01\",\n            \"Content-Type\": \"application/x-www-form-urlencoded; charset=UTF-8\",\n            \"X-Requested-With\": \"XMLHttpRequest\",\n            \"Origin\": self.root,\n            \"Referer\": url,\n        }\n        url = text.urljoin(self.root, text.extr(page, 'action=\"', '\"'))\n        data = {\n            \"auth\"    : text.extr(page, 'name=\"auth\" value=\"', '\"'),\n            \"remember\": \"1\",\n            \"username\": username,\n            \"password\": str(password),\n            \"code\"    : \"\",\n            \"codehint\": \"------\",\n            \"mfaCheck\": \"1\",\n        }\n\n        while True:\n            response = self.request(\n                url, method=\"POST\", headers=headers, data=data)\n            result = response.json()\n\n            if result.get(\"success\"):\n                break\n            if \"errors\" in result:\n                raise self.exc.AuthenticationError(\n                    '\"' + '\", \"'.join(result[\"errors\"]) + '\"')\n\n            if result.get(\"requiresMfa\"):\n                data[\"code\"] = self.input(\"Verification Code: \")\n                data[\"codehint\"] = \"      \"\n            elif result.get(\"requiresEmailMfa\"):\n                email = result.get(\"obfuscatedEmail\")\n                prompt = f\"Email Verification Code ({email}): \"\n                data[\"code\"] = self.input(prompt)\n                data[\"codehint\"] = \"      \"\n\n            data.pop(\"mfaCheck\", None)\n\n        return {\n            cookie.name: cookie.value\n            for cookie in response.cookies\n        }\n\n    def extract_post(self, post_url):\n        url = post_url\n        if \"/art/view/\" in post_url:\n            extract_data = self._extract_image_data\n        elif \"/audio/listen/\" in post_url:\n            extract_data = self._extract_audio_data\n        else:\n            extract_data = self._extract_media_data\n            if self.flash:\n                url += \"/format/flash\"\n\n        response = self.request(url, fatal=False)\n        page = response.text\n\n        pos = page.find('id=\"adults_only\"')\n        if pos >= 0:\n            msg = text.extract(page, 'class=\"highlight\">', '<', pos)[0]\n            self.log.warning('\"%s\"', msg)\n            return {}\n\n        if response.status_code >= 400:\n            return {}\n\n        extr = text.extract_from(page)\n        data = extract_data(extr, post_url)\n\n        data[\"comment_html\"] = data[\"_comment\"] = extr(\n            'id=\"author_comments\"', '</div>').partition(\">\")[2].strip()\n        data[\"comment\"] = text.unescape(text.remove_html(\n            data[\"_comment\"]\n            .replace(\"<p><br></p>\", \"\\n\\n\").replace(\"<br>\", \"\\n\"), \"\", \"\"))\n        data[\"favorites\"] = text.parse_int(extr(\n            'id=\"faves_load\">', '<').replace(\",\", \"\"))\n        data[\"score\"] = text.parse_float(extr('id=\"score_number\">', '<'))\n        data[\"tags\"] = [\n            t for t in text.split_html(extr('<dd class=\"tags\">', '</dd>'))\n            if \"(function(\" not in t\n        ]\n        data[\"artist\"] = [\n            text.extr(user, '//', '.')\n            for user in text.extract_iter(page, '<div class=\"item-user\">', '>')\n        ]\n\n        data[\"tags\"].sort()\n        data[\"user\"] = self.user or data[\"artist\"][0]\n        data[\"slug\"] = post_url[post_url.rfind(\"/\")+1:]\n        data[\"post_url\"] = post_url\n        return data\n\n    def _extract_image_data(self, extr, url):\n        full = text.extract_from(util.json_loads(extr(\n            '\"full_image_text\":', '});')))\n        data = {\n            \"title\"      : text.unescape(extr('\"og:title\" content=\"', '\"')),\n            \"description\": text.unescape(extr(':description\" content=\"', '\"')),\n            \"type\"       : \"art\",\n            \"_type\"      : \"i\",\n            \"date\"       : dt.parse_iso(extr(\n                'itemprop=\"datePublished\" content=\"', '\"')),\n            \"rating\"     : extr('class=\"rated-', '\"'),\n            \"url\"        : full('src=\"', '\"'),\n            \"width\"      : text.parse_int(full('width=\"', '\"')),\n            \"height\"     : text.parse_int(full('height=\"', '\"')),\n        }\n\n        if not data[\"url\"]:\n            data[\"url\"] = extr('<a href=\"', '\"')\n\n        index = data[\"url\"].rpartition(\"/\")[2].partition(\"_\")[0]\n        data[\"index\"] = text.parse_int(index)\n        data[\"_index\"] = index\n\n        if image_data := extr(\"let imageData =\", \"\\n];\"):\n            data[\"_multi\"] = self._extract_images_multi(image_data)\n        else:\n            if art_images := extr('<div class=\"art-images', '\\n\\t\\t</div>'):\n                data[\"_multi\"] = self._extract_images_art(art_images, data)\n\n        return data\n\n    def _extract_images_multi(self, html):\n        data = util.json_loads(html + \"]\")\n        yield from data[1:]\n\n    def _extract_images_art(self, html, data):\n        ext = text.ext_from_url(data[\"url\"])\n        for url in text.extract_iter(html, 'data-smartload-src=\"', '\"'):\n            url = text.ensure_http_scheme(url)\n            url = url.replace(\"/medium_views/\", \"/images/\", 1)\n            if text.ext_from_url(url) == \"webp\":\n                fallback = [url.replace(\".webp\", \".\" + e)\n                            for e in (\"jpg\", \"png\", \"gif\") if e != ext]\n                fallback.append(url)\n                yield {\n                    \"image\"    : url.replace(\".webp\", \".\" + ext),\n                    \"_fallback\": fallback,\n                }\n            else:\n                yield {\"image\": url}\n\n    def _extract_audio_data(self, extr, url):\n        index = url.split(\"/\")[5]\n        return {\n            \"title\"      : text.unescape(extr('\"og:title\" content=\"', '\"')),\n            \"description\": text.unescape(extr(':description\" content=\"', '\"')),\n            \"type\"       : \"audio\",\n            \"_type\"      : \"a\",\n            \"date\"       : dt.parse_iso(extr(\n                'itemprop=\"datePublished\" content=\"', '\"')),\n            \"url\"        : extr('{\"url\":\"', '\"').replace(\"\\\\/\", \"/\"),\n            \"index\"      : text.parse_int(index),\n            \"_index\"     : index,\n            \"rating\"     : \"\",\n        }\n\n    def _extract_media_data(self, extr, url):\n        index = url.split(\"/\")[5]\n        title = extr('\"og:title\" content=\"', '\"')\n        type = extr('og:type\" content=\"', '\"')\n        descr = extr('\"og:description\" content=\"', '\"')\n        src = extr('{\"url\":\"', '\"')\n\n        if src:\n            src = src.replace(\"\\\\/\", \"/\")\n            formats = ()\n            type = extr(',\"description\":\"', '\"')\n            date = dt.parse_iso(extr(\n                'itemprop=\"datePublished\" content=\"', '\"'))\n            if type:\n                type = type.rpartition(\" \")[2].lower()\n            else:\n                type = \"flash\" if text.ext_from_url(url) == \"swf\" else \"game\"\n        else:\n            url = self.root + \"/portal/video/\" + index\n            headers = {\n                \"Accept\": \"application/json, text/javascript, */*; q=0.01\",\n                \"X-Requested-With\": \"XMLHttpRequest\",\n            }\n            sources = self.request_json(url, headers=headers)[\"sources\"]\n            formats = self._video_formats(sources)\n            src = next(formats, \"\")\n            date = self.parse_timestamp(src.rpartition(\"?\")[2])\n            type = \"movie\"\n\n        return {\n            \"title\"      : text.unescape(title),\n            \"url\"        : src,\n            \"date\"       : date,\n            \"type\"       : type,\n            \"_type\"      : \"\",\n            \"description\": text.unescape(descr or extr(\n                'itemprop=\"description\" content=\"', '\"')),\n            \"rating\"     : extr('class=\"rated-', '\"'),\n            \"index\"      : text.parse_int(index),\n            \"_index\"     : index,\n            \"_fallback\"  : formats,\n        }\n\n    def _video_formats(self, sources):\n        src = sources[\"360p\"][0][\"src\"]\n        sub = text.re(r\"\\.360p\\.\\w+\").sub\n\n        for fmt in self.format:\n            try:\n                if isinstance(fmt, int):\n                    yield sources[str(fmt) + \"p\"][0][\"src\"]\n                elif fmt in sources:\n                    yield sources[fmt][0][\"src\"]\n                else:\n                    yield sub(\".\" + fmt, src, 1)\n            except Exception as exc:\n                self.log.debug(\"Video format '%s' not available (%s: %s)\",\n                               fmt, exc.__class__.__name__, exc)\n\n    def _video_formats_limit(self, sources):\n        formats = []\n        for fmt, src in sources.items():\n            width = text.parse_int(fmt.rstrip(\"p\"))\n            if width <= self.format:\n                formats.append((width, src))\n\n        formats.sort(reverse=True)\n        for fmt in formats:\n            yield fmt[1][0][\"src\"]\n\n    def _pagination(self, kind, pnum=1):\n        url = f\"{self.user_root}/{kind}\"\n        params = {\n            \"page\": text.parse_int(pnum, 1),\n            \"isAjaxRequest\": \"1\",\n        }\n        headers = {\n            \"Referer\": url,\n            \"X-Requested-With\": \"XMLHttpRequest\",\n        }\n\n        while True:\n            with self.request(\n                    url, params=params, headers=headers,\n                    fatal=False) as response:\n                try:\n                    data = response.json()\n                except ValueError:\n                    return\n                if not data:\n                    return\n                if \"errors\" in data:\n                    msg = \", \".join(text.unescape(e) for e in data[\"errors\"])\n                    raise self.exc.AbortExtraction(msg)\n\n            items = data.get(\"items\")\n            if not items:\n                return\n\n            for year, items in items.items():\n                for item in items:\n                    page_url = text.extr(item, 'href=\"', '\"')\n                    if page_url[0] == \"/\":\n                        page_url = self.root + page_url\n                    yield page_url\n\n            more = data.get(\"load_more\")\n            if not more or len(more) < 8:\n                return\n            params[\"page\"] += 1\n\n\nclass NewgroundsImageExtractor(NewgroundsExtractor):\n    \"\"\"Extractor for a single image from newgrounds.com\"\"\"\n    subcategory = \"image\"\n    pattern = (r\"(?:https?://)?(?:\"\n               r\"(?:www\\.)?newgrounds\\.com/art/view/([^/?#]+)/[^/?#]+\"\n               r\"|art\\.ngfiles\\.com/images/\\d+/\\d+_([^_]+)_([^.]+))\")\n    example = \"https://www.newgrounds.com/art/view/USER/TITLE\"\n\n    def __init__(self, match):\n        NewgroundsExtractor.__init__(self, match)\n        if match[2]:\n            self.user = match[2]\n            self.post_url = f\"{self.root}/art/view/{self.user}/{match[3]}\"\n        else:\n            self.post_url = text.ensure_http_scheme(match[0])\n\n    def posts(self):\n        return (self.post_url,)\n\n\nclass NewgroundsMediaExtractor(NewgroundsExtractor):\n    \"\"\"Extractor for a media file from newgrounds.com\"\"\"\n    subcategory = \"media\"\n    pattern = BASE_PATTERN + r\"(/(?:portal/view|audio/listen)/\\d+)\"\n    example = \"https://www.newgrounds.com/portal/view/12345\"\n\n    def __init__(self, match):\n        NewgroundsExtractor.__init__(self, match)\n        self.user = \"\"\n        self.post_url = self.root + match[1]\n\n    def posts(self):\n        return (self.post_url,)\n\n\nclass NewgroundsArtExtractor(NewgroundsExtractor):\n    \"\"\"Extractor for all images of a newgrounds user\"\"\"\n    subcategory = \"art\"\n    pattern = USER_PATTERN + r\"/art(?:(?:/page/|/?\\?page=)(\\d+))?/?$\"\n    example = \"https://USER.newgrounds.com/art\"\n\n\nclass NewgroundsAudioExtractor(NewgroundsExtractor):\n    \"\"\"Extractor for all audio submissions of a newgrounds user\"\"\"\n    subcategory = \"audio\"\n    pattern = USER_PATTERN + r\"/audio(?:(?:/page/|/?\\?page=)(\\d+))?/?$\"\n    example = \"https://USER.newgrounds.com/audio\"\n\n\nclass NewgroundsMoviesExtractor(NewgroundsExtractor):\n    \"\"\"Extractor for all movies of a newgrounds user\"\"\"\n    subcategory = \"movies\"\n    pattern = USER_PATTERN + r\"/movies(?:(?:/page/|/?\\?page=)(\\d+))?/?$\"\n    example = \"https://USER.newgrounds.com/movies\"\n\n\nclass NewgroundsGamesExtractor(NewgroundsExtractor):\n    \"\"\"Extractor for a newgrounds user's games\"\"\"\n    subcategory = \"games\"\n    pattern = USER_PATTERN + r\"/games(?:(?:/page/|/?\\?page=)(\\d+))?/?$\"\n    example = \"https://USER.newgrounds.com/games\"\n\n\nclass NewgroundsUserExtractor(Dispatch, NewgroundsExtractor):\n    \"\"\"Extractor for a newgrounds user profile\"\"\"\n    pattern = USER_PATTERN + r\"/?$\"\n    example = \"https://USER.newgrounds.com\"\n\n    def items(self):\n        base = self.user_root + \"/\"\n        return self._dispatch_extractors((\n            (NewgroundsArtExtractor   , base + \"art\"),\n            (NewgroundsAudioExtractor , base + \"audio\"),\n            (NewgroundsGamesExtractor , base + \"games\"),\n            (NewgroundsMoviesExtractor, base + \"movies\"),\n        ), (\"art\",))\n\n\nclass NewgroundsFavoriteExtractor(NewgroundsExtractor):\n    \"\"\"Extractor for posts favorited by a newgrounds user\"\"\"\n    subcategory = \"favorite\"\n    directory_fmt = (\"{category}\", \"{user}\", \"Favorites\")\n    pattern = (USER_PATTERN + r\"/favorites(?!/following)(?:/(art|audio|movies)\"\n               r\"(?:(?:/page/|/?\\?page=)(\\d+))?)?\")\n    example = \"https://USER.newgrounds.com/favorites\"\n\n    def posts(self):\n        _, kind, pnum = self.groups\n        if kind:\n            return self._pagination_favorites(kind, pnum)\n        return itertools.chain.from_iterable(\n            self._pagination_favorites(k) for k in (\"art\", \"audio\", \"movies\")\n        )\n\n    def _pagination_favorites(self, kind, pnum=1):\n        url = f\"{self.user_root}/favorites/{kind}\"\n        params = {\n            \"page\": text.parse_int(pnum, 1),\n            \"isAjaxRequest\": \"1\",\n        }\n        headers = {\n            \"Referer\": url,\n            \"X-Requested-With\": \"XMLHttpRequest\",\n        }\n\n        while True:\n            response = self.request(url, params=params, headers=headers)\n            if response.history:\n                return\n\n            data = response.json()\n            favs = self._extract_favorites(data.get(\"component\") or \"\")\n            yield from favs\n\n            if len(favs) < 24:\n                return\n            params[\"page\"] += 1\n\n    def _extract_favorites(self, page):\n        return [\n            self.root + path\n            for path in text.extract_iter(page, 'href=\"' + self.root, '\"')\n        ]\n\n\nclass NewgroundsFollowingExtractor(NewgroundsFavoriteExtractor):\n    \"\"\"Extractor for a newgrounds user's favorited users\"\"\"\n    subcategory = \"following\"\n    pattern = (USER_PATTERN + r\"/favorites/(following)\"\n               r\"(?:(?:/page/|/?\\?page=)(\\d+))?\")\n    example = \"https://USER.newgrounds.com/favorites/following\"\n\n    def items(self):\n        _, kind, pnum = self.groups\n        data = {\"_extractor\": NewgroundsUserExtractor}\n        for url in self._pagination_favorites(kind, pnum):\n            yield Message.Queue, url, data\n\n    def _extract_favorites(self, page):\n        return [\n            text.ensure_http_scheme(user.rpartition('\"')[2])\n            for user in text.extract_iter(page, 'class=\"item-user', '\"><img')\n        ]\n\n\nclass NewgroundsSearchExtractor(NewgroundsExtractor):\n    \"\"\"Extractor for newgrounds.com search reesults\"\"\"\n    subcategory = \"search\"\n    directory_fmt = (\"{category}\", \"search\", \"{search_tags}\")\n    pattern = BASE_PATTERN + r\"/search/conduct/([^/?#]+)/?\\?([^#]+)\"\n    example = \"https://www.newgrounds.com/search/conduct/art?terms=QUERY\"\n\n    def __init__(self, match):\n        NewgroundsExtractor.__init__(self, match)\n        self._path, query = self.groups\n        self.query = text.parse_query(query)\n\n    def posts(self):\n        if suitabilities := self.query.get(\"suitabilities\"):\n            data = {\"view_suitability_\" + s: \"on\"\n                    for s in suitabilities.split(\",\")}\n            self.request(self.root + \"/suitabilities\",\n                         method=\"POST\", data=data)\n        return self._pagination_search(\n            \"/search/conduct/\" + self._path, self.query)\n\n    def metadata(self):\n        return {\"search_tags\": self.query.get(\"terms\", \"\")}\n\n    def _pagination_search(self, path, params):\n        url = self.root + path\n        params[\"inner\"] = \"1\"\n        params[\"page\"] = text.parse_int(params.get(\"page\"), 1)\n        headers = {\n            \"Accept\": \"application/json, text/javascript, */*; q=0.01\",\n            \"X-Requested-With\": \"XMLHttpRequest\",\n        }\n\n        while True:\n            data = self.request_json(url, params=params, headers=headers)\n\n            post_url = None\n            for post_url in text.extract_iter(data[\"content\"], 'href=\"', '\"'):\n                if not post_url.startswith(\"/search/\"):\n                    yield post_url\n\n            if post_url is None:\n                return\n            params[\"page\"] += 1\n"
  },
  {
    "path": "gallery_dl/extractor/nhentai.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2015-2025 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://nhentai.net/\"\"\"\n\nfrom .common import GalleryExtractor, Extractor, Message\nfrom .. import text, util\nimport collections\nimport random\n\n\nclass NhentaiGalleryExtractor(GalleryExtractor):\n    \"\"\"Extractor for image galleries from nhentai.net\"\"\"\n    category = \"nhentai\"\n    root = \"https://nhentai.net\"\n    pattern = r\"(?:https?://)?nhentai\\.net/g/(\\d+)\"\n    example = \"https://nhentai.net/g/12345/\"\n\n    def __init__(self, match):\n        url = self.root + \"/api/gallery/\" + match[1]\n        GalleryExtractor.__init__(self, match, url)\n\n    def metadata(self, page):\n        self.data = data = util.json_loads(page)\n\n        title_en = data[\"title\"].get(\"english\", \"\")\n        title_ja = data[\"title\"].get(\"japanese\", \"\")\n\n        info = collections.defaultdict(list)\n        for tag in data[\"tags\"]:\n            info[tag[\"type\"]].append(tag[\"name\"])\n\n        language = \"\"\n        for language in info[\"language\"]:\n            if language != \"translated\":\n                language = language.capitalize()\n                break\n\n        return {\n            \"title\"     : title_en or title_ja,\n            \"title_en\"  : title_en,\n            \"title_ja\"  : title_ja,\n            \"gallery_id\": data[\"id\"],\n            \"media_id\"  : text.parse_int(data[\"media_id\"]),\n            \"date\"      : data[\"upload_date\"],\n            \"scanlator\" : data[\"scanlator\"],\n            \"artist\"    : info[\"artist\"],\n            \"group\"     : info[\"group\"],\n            \"parody\"    : info[\"parody\"],\n            \"characters\": info[\"character\"],\n            \"tags\"      : info[\"tag\"],\n            \"type\"      : info[\"category\"][0] if info[\"category\"] else \"\",\n            \"lang\"      : util.language_to_code(language),\n            \"language\"  : language,\n        }\n\n    def images(self, _):\n        exts = {\"j\": \"jpg\", \"p\": \"png\", \"g\": \"gif\", \"w\": \"webp\", \"a\": \"avif\"}\n\n        data = self.data\n        ufmt = (\"https://i{}.nhentai.net/galleries/\" +\n                data[\"media_id\"] + \"/{}.{}\").format\n\n        return [\n            (ufmt(random.randint(1, 4), num, exts.get(img[\"t\"], \"jpg\")), {\n                \"width\" : img[\"w\"],\n                \"height\": img[\"h\"],\n            })\n            for num, img in enumerate(data[\"images\"][\"pages\"], 1)\n        ]\n\n\nclass NhentaiExtractor(Extractor):\n    \"\"\"Base class for nhentai extractors\"\"\"\n    category = \"nhentai\"\n    root = \"https://nhentai.net\"\n\n    def __init__(self, match):\n        Extractor.__init__(self, match)\n        self.path, self.query = match.groups()\n\n    def items(self):\n        data = {\"_extractor\": NhentaiGalleryExtractor}\n        for gallery_id in self._pagination():\n            url = f\"{self.root}/g/{gallery_id}/\"\n            data[\"gallery_id\"] = text.parse_int(gallery_id)\n            yield Message.Queue, url, data\n\n    def _pagination(self):\n        url = self.root + self.path\n        params = text.parse_query(self.query)\n        params[\"page\"] = text.parse_int(params.get(\"page\"), 1)\n\n        while True:\n            page = self.request(url, params=params).text\n            yield from text.extract_iter(page, 'href=\"/g/', '/')\n            if 'class=\"next\"' not in page:\n                return\n            params[\"page\"] += 1\n\n\nclass NhentaiTagExtractor(NhentaiExtractor):\n    \"\"\"Extractor for nhentai tag searches\"\"\"\n    subcategory = \"tag\"\n    pattern = (r\"(?:https?://)?nhentai\\.net(\"\n               r\"/(?:artist|category|character|group|language|parody|tag)\"\n               r\"/[^/?#]+(?:/popular[^/?#]*)?/?)(?:\\?([^#]+))?\")\n    example = \"https://nhentai.net/tag/TAG/\"\n\n\nclass NhentaiSearchExtractor(NhentaiExtractor):\n    \"\"\"Extractor for nhentai search results\"\"\"\n    subcategory = \"search\"\n    pattern = r\"(?:https?://)?nhentai\\.net(/search/?)\\?([^#]+)\"\n    example = \"https://nhentai.net/search/?q=QUERY\"\n\n\nclass NhentaiFavoriteExtractor(NhentaiExtractor):\n    \"\"\"Extractor for nhentai favorites\"\"\"\n    subcategory = \"favorite\"\n    pattern = r\"(?:https?://)?nhentai\\.net(/favorites/?)(?:\\?([^#]+))?\"\n    example = \"https://nhentai.net/favorites/\"\n"
  },
  {
    "path": "gallery_dl/extractor/nijie.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2015-2026 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for nijie instances\"\"\"\n\nfrom .common import BaseExtractor, Message, Dispatch, AsynchronousMixin\nfrom .. import text, dt\n\n\nclass NijieExtractor(AsynchronousMixin, BaseExtractor):\n    \"\"\"Base class for nijie extractors\"\"\"\n    basecategory = \"Nijie\"\n    directory_fmt = (\"{category}\", \"{user_id}\")\n    filename_fmt = \"{image_id}_p{num}.{extension}\"\n    archive_fmt = \"{image_id}_{num}\"\n    request_interval = (2.0, 4.0)\n\n    def __init__(self, match):\n        BaseExtractor.__init__(self, match)\n        self.user_id = text.parse_int(self.groups[-1])\n\n    def initialize(self):\n        self.cookies_domain = \".\" + self.root.rpartition(\"/\")[2]\n        self.cookies_names = (self.category + \"_tok\",)\n\n        BaseExtractor.initialize(self)\n\n        self.user_name = None\n        if self.category == \"horne\":\n            self._extract_data = self._extract_data_horne\n\n    def items(self):\n        self.login()\n\n        for image_id in self.image_ids():\n\n            url = f\"{self.root}/view.php?id={image_id}\"\n            response = self.request(url, fatal=False)\n            if response.status_code >= 400:\n                continue\n            page = response.text\n\n            data = self._extract_data(page)\n            data[\"image_id\"] = text.parse_int(image_id)\n\n            if self.user_name:\n                data[\"user_id\"] = self.user_id\n                data[\"user_name\"] = self.user_name\n            else:\n                data[\"user_id\"] = data[\"artist_id\"]\n                data[\"user_name\"] = data[\"artist_name\"]\n\n            urls = self._extract_images(image_id, page)\n            data[\"count\"] = len(urls)\n\n            yield Message.Directory, \"\", data\n            for num, url in enumerate(urls):\n                image = text.nameext_from_url(url, {\n                    \"num\": num,\n                    \"url\": \"https:\" + url,\n                })\n                image.update(data)\n                if not image[\"extension\"]:\n                    image[\"extension\"] = \"jpg\"\n                yield Message.Url, image[\"url\"], image\n\n    def image_ids(self):\n        \"\"\"Collect all relevant image-ids\"\"\"\n\n    def _extract_data(self, page):\n        \"\"\"Extract image metadata from 'page'\"\"\"\n        extr = text.extract_from(page)\n        keywords = text.unescape(extr(\n            'name=\"keywords\" content=\"', '\" />')).split(\",\")\n        return {\n            \"title\"      : keywords[0].strip(),\n            \"description\": text.unescape(extr(\n                '\"description\": \"', '\"').replace(\"&amp;\", \"&\")),\n            \"date\"       : dt.parse(extr(\n                '\"datePublished\": \"', '\"'), \"%a %b %d %H:%M:%S %Y\"\n            ) - dt.timedelta(hours=9),\n            \"artist_id\"  : text.parse_int(extr('/members.php?id=', '\"')),\n            \"artist_name\": keywords[1],\n            \"tags\"       : keywords[2:-1],\n        }\n\n    def _extract_data_horne(self, page):\n        \"\"\"Extract image metadata from 'page'\"\"\"\n        extr = text.extract_from(page)\n        keywords = text.unescape(extr(\n            'name=\"keywords\" content=\"', '\" />')).split(\",\")\n        return {\n            \"title\"      : keywords[0].strip(),\n            \"description\": text.unescape(extr(\n                'property=\"og:description\" content=\"', '\"')),\n            \"artist_id\"  : text.parse_int(extr('members.php?id=', '\"')),\n            \"artist_name\": keywords[1],\n            \"tags\"       : keywords[2:-1],\n            \"date\"       : dt.parse_iso(extr(\n                \"itemprop='datePublished' content=\", \"<\").rpartition(\">\")[2]\n            ) - dt.timedelta(hours=9),\n        }\n\n    def _extract_images(self, image_id, page):\n        if '&#diff_1\" ' in page:\n            # multiple images\n            url = f\"{self.root}/view_popup.php?id={image_id}\"\n            page = self.request(url).text\n            return [\n                text.extr(media, ' src=\"', '\"')\n                for media in text.extract_iter(\n                    page, 'href=\"javascript:void(0);\"><', '>')\n                if ' src=\"' in media\n            ]\n        else:\n            pos = page.find('id=\"view-center\"') + 1\n            # do NOT use text.extr() here, as it doesn't support a pos argument\n            return (text.extract(page, 'itemprop=\"image\" src=\"', '\"', pos)[0],)\n\n    def _extract_user_name(self, page):\n        return text.unescape(text.extr(page, \"<br />\", \"<\"))\n\n    def login(self):\n        if self.cookies_check(self.cookies_names):\n            return\n\n        username, password = self._get_auth_info()\n        if username:\n            return self.cookies_update(self.cache(\n                self._login_impl, username, password,\n                _exp=90*86400, _mem=False))\n\n        raise self.exc.AuthenticationError(\"Username and password required\")\n\n    def _login_impl(self, username, password):\n        self.log.info(\"Logging in as %s\", username)\n\n        url = self.root + \"/login_int.php\"\n        data = {\"email\": username, \"password\": password, \"save\": \"on\"}\n\n        response = self.request(url, method=\"POST\", data=data)\n        if \"/login.php\" in response.text:\n            raise self.exc.AuthenticationError()\n        return self.cookies\n\n    def _pagination(self, path):\n        url = f\"{self.root}/{path}.php\"\n        params = {\"id\": self.user_id, \"p\": 1}\n\n        while True:\n            page = self.request(url, params=params, notfound=\"artist\").text\n\n            if self.user_name is None:\n                self.user_name = self._extract_user_name(page)\n            yield from text.extract_iter(page, 'illust_id=\"', '\"')\n\n            if '<a rel=\"next\"' not in page:\n                return\n            params[\"p\"] += 1\n\n\nBASE_PATTERN = NijieExtractor.update({\n    \"nijie\": {\n        \"root\": \"https://nijie.info\",\n        \"pattern\": r\"(?:www\\.)?nijie\\.info\",\n    },\n    \"horne\": {\n        \"root\": \"https://horne.red\",\n        \"pattern\": r\"(?:www\\.)?horne\\.red\",\n    },\n})\n\n\nclass NijieUserExtractor(Dispatch, NijieExtractor):\n    \"\"\"Extractor for nijie user profiles\"\"\"\n    pattern = BASE_PATTERN + r\"/members\\.php\\?id=(\\d+)\"\n    example = \"https://nijie.info/members.php?id=12345\"\n\n    def items(self):\n        fmt = f\"{self.root}/{{}}.php?id={self.user_id}\".format\n        return self._dispatch_extractors((\n            (NijieIllustrationExtractor, fmt(\"members_illust\")),\n            (NijieDoujinExtractor      , fmt(\"members_dojin\")),\n            (NijieFavoriteExtractor    , fmt(\"user_like_illust_view\")),\n            (NijieNuitaExtractor       , fmt(\"history_nuita\")),\n        ), (\"illustration\", \"doujin\"))\n\n\nclass NijieIllustrationExtractor(NijieExtractor):\n    \"\"\"Extractor for all illustrations of a nijie-user\"\"\"\n    subcategory = \"illustration\"\n    pattern = BASE_PATTERN + r\"/members_illust\\.php\\?id=(\\d+)\"\n    example = \"https://nijie.info/members_illust.php?id=12345\"\n\n    def image_ids(self):\n        return self._pagination(\"members_illust\")\n\n\nclass NijieDoujinExtractor(NijieExtractor):\n    \"\"\"Extractor for doujin entries of a nijie user\"\"\"\n    subcategory = \"doujin\"\n    pattern = BASE_PATTERN + r\"/members_dojin\\.php\\?id=(\\d+)\"\n    example = \"https://nijie.info/members_dojin.php?id=12345\"\n\n    def image_ids(self):\n        return self._pagination(\"members_dojin\")\n\n\nclass NijieFavoriteExtractor(NijieExtractor):\n    \"\"\"Extractor for all favorites/bookmarks of a nijie user\"\"\"\n    subcategory = \"favorite\"\n    directory_fmt = (\"{category}\", \"bookmarks\", \"{user_id}\")\n    archive_fmt = \"f_{user_id}_{image_id}_{num}\"\n    pattern = BASE_PATTERN + r\"/user_like_illust_view\\.php\\?id=(\\d+)\"\n    example = \"https://nijie.info/user_like_illust_view.php?id=12345\"\n\n    def image_ids(self):\n        return self._pagination(\"user_like_illust_view\")\n\n    def _extract_data(self, page):\n        data = NijieExtractor._extract_data(page)\n        data[\"user_id\"] = self.user_id\n        data[\"user_name\"] = self.user_name\n        return data\n\n\nclass NijieNuitaExtractor(NijieExtractor):\n    \"\"\"Extractor for a nijie user's 抜いた list\"\"\"\n    subcategory = \"nuita\"\n    directory_fmt = (\"{category}\", \"nuita\", \"{user_id}\")\n    archive_fmt = \"n_{user_id}_{image_id}_{num}\"\n    pattern = BASE_PATTERN + r\"/history_nuita\\.php\\?id=(\\d+)\"\n    example = \"https://nijie.info/history_nuita.php?id=12345\"\n\n    def image_ids(self):\n        return self._pagination(\"history_nuita\")\n\n    def _extract_data(self, page):\n        data = NijieExtractor._extract_data(page)\n        data[\"user_id\"] = self.user_id\n        data[\"user_name\"] = self.user_name\n        return data\n\n    def _extract_user_name(self, page):\n        return text.unescape(text.extr(page, \"<title>\", \"さんの抜いた\"))\n\n\nclass NijieFeedExtractor(NijieExtractor):\n    \"\"\"Extractor for nijie liked user feed\"\"\"\n    subcategory = \"feed\"\n    pattern = BASE_PATTERN + r\"/like_user_view\\.php\"\n    example = \"https://nijie.info/like_user_view.php\"\n\n    def image_ids(self):\n        return self._pagination(\"like_user_view\")\n\n    def _extract_user_name(self, page):\n        return \"\"\n\n\nclass NijieFollowedExtractor(NijieExtractor):\n    \"\"\"Extractor for followed nijie users\"\"\"\n    subcategory = \"followed\"\n    pattern = BASE_PATTERN + r\"/like_my\\.php\"\n    example = \"https://nijie.info/like_my.php\"\n\n    def items(self):\n        self.login()\n\n        url = self.root + \"/like_my.php\"\n        params = {\"p\": 1}\n        data = {\"_extractor\": NijieUserExtractor}\n\n        while True:\n            page = self.request(url, params=params).text\n\n            for user_id in text.extract_iter(\n                    page, '\"><a href=\"/members.php?id=', '\"'):\n                user_url = f\"{self.root}/members.php?id={user_id}\"\n                yield Message.Queue, user_url, data\n\n            if '<a rel=\"next\"' not in page:\n                return\n            params[\"p\"] += 1\n\n\nclass NijieImageExtractor(NijieExtractor):\n    \"\"\"Extractor for a nijie work/image\"\"\"\n    subcategory = \"image\"\n    pattern = BASE_PATTERN + r\"/view(?:_popup)?\\.php\\?id=(\\d+)\"\n    example = \"https://nijie.info/view.php?id=12345\"\n\n    def image_ids(self):\n        return (self.groups[-1],)\n"
  },
  {
    "path": "gallery_dl/extractor/nitter.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2022-2025 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for Nitter instances\"\"\"\n\nfrom .common import BaseExtractor, Message\nfrom .. import text, util\nimport binascii\n\n\nclass NitterExtractor(BaseExtractor):\n    \"\"\"Base class for nitter extractors\"\"\"\n    basecategory = \"nitter\"\n    directory_fmt = (\"nitter\", \"{user[name]}\")\n    filename_fmt = \"{tweet_id}_{num}.{extension}\"\n    archive_fmt = \"{tweet_id}_{num}\"\n    useragent = util.USERAGENT_GALLERYDL\n    request_interval = (0.5, 1.5)\n\n    def __init__(self, match):\n        self.cookies_domain = self.root.partition(\"://\")[2]\n        BaseExtractor.__init__(self, match)\n\n        self.user = self.groups[-2]\n        self.user_id = self.groups[-1]\n        self.user_obj = None\n\n    def items(self):\n        retweets = self.config(\"retweets\", False)\n        if videos := self.config(\"videos\", True):\n            ytdl = (videos == \"ytdl\")\n            videos = True\n            self.cookies.set(\"hlsPlayback\", \"on\", domain=self.cookies_domain)\n\n        for tweet in self.tweets():\n\n            if not retweets and tweet[\"retweet\"]:\n                self.log.debug(\"Skipping %s (retweet)\", tweet[\"tweet_id\"])\n                continue\n\n            if attachments := tweet.pop(\"_attach\", \"\"):\n                files = []\n                for url in text.extract_iter(\n                        attachments, 'href=\"', '\"'):\n\n                    if \"/i/broadcasts/\" in url:\n                        self.log.debug(\n                            \"Skipping unsupported broadcast '%s'\", url)\n                        continue\n\n                    if \"/enc/\" in url:\n                        name = binascii.a2b_base64(url.rpartition(\n                            \"/\")[2]).decode().rpartition(\"/\")[2]\n                    else:\n                        name = url.rpartition(\"%2F\")[2]\n\n                    if url[0] == \"/\":\n                        url = self.root + url\n                    file = {\"url\": url, \"_http_retry\": _retry_on_404}\n                    file[\"filename\"], _, file[\"extension\"] = \\\n                        name.rpartition(\".\")\n                    files.append(file)\n\n                if videos and not files:\n                    if ytdl:\n                        url = f\"ytdl:{self.root}/i/status/{tweet['tweet_id']}\"\n                        files.append({\"url\": url, \"extension\": \"mp4\"})\n                    else:\n                        for url in text.extract_iter(\n                                attachments, 'data-url=\"', '\"'):\n\n                            if \"/enc/\" in url:\n                                name = binascii.a2b_base64(url.rpartition(\n                                    \"/\")[2]).decode().rpartition(\"/\")[2]\n                            else:\n                                name = url.rpartition(\"%2F\")[2]\n\n                            if url[0] == \"/\":\n                                url = self.root + url\n                            files.append({\n                                \"url\"      : \"ytdl:\" + url,\n                                \"filename\" : name.rpartition(\".\")[0],\n                                \"extension\": \"mp4\",\n                            })\n\n                        for url in text.extract_iter(\n                                attachments, '<source src=\"', '\"'):\n                            if url[0] == \"/\":\n                                url = self.root + url\n                            files.append(\n                                text.nameext_from_url(url, {\"url\": url}))\n\n            else:\n                files = ()\n            tweet[\"count\"] = len(files)\n\n            yield Message.Directory, \"\", tweet\n            for tweet[\"num\"], file in enumerate(files, 1):\n                url = file[\"url\"]\n                file.update(tweet)\n                yield Message.Url, url, file\n\n    def _tweet_from_html(self, html):\n        extr = text.extract_from(html)\n        author = {\n            \"name\": extr('class=\"fullname\" href=\"/', '\"'),\n            \"nick\": extr('title=\"', '\"'),\n        }\n        extr('<span class=\"tweet-date', '')\n        link = extr('href=\"', '\"')\n        return {\n            \"author\"  : author,\n            \"user\"    : self.user_obj or author,\n            \"date\"    : self.parse_datetime(\n                extr('title=\"', '\"'), \"%b %d, %Y · %I:%M %p %Z\"),\n            \"tweet_id\": link.rpartition(\"/\")[2].partition(\"#\")[0],\n            \"content\": extr('class=\"tweet-content', \"</div\").partition(\">\")[2],\n            \"_attach\" : extr('class=\"attachments', 'class=\"tweet-stats'),\n            \"comments\": text.parse_int(extr(\n                'class=\"icon-comment', '</div>').rpartition(\">\")[2]),\n            \"retweets\": text.parse_int(extr(\n                'class=\"icon-retweet', '</div>').rpartition(\">\")[2]),\n            \"quotes\"  : text.parse_int(extr(\n                'class=\"icon-quote', '</div>').rpartition(\">\")[2]),\n            \"likes\"   : text.parse_int(extr(\n                'class=\"icon-heart', '</div>').rpartition(\">\")[2]),\n            \"retweet\" : 'class=\"retweet-header' in html,\n            \"quoted\"  : False,\n        }\n\n    def _tweet_from_quote(self, html):\n        extr = text.extract_from(html)\n        author = {\n            \"name\": extr('class=\"fullname\" href=\"/', '\"'),\n            \"nick\": extr('title=\"', '\"'),\n        }\n        extr('<span class=\"tweet-date', '')\n        link = extr('href=\"', '\"')\n        return {\n            \"author\"  : author,\n            \"user\"    : self.user_obj or author,\n            \"date\"    : self.parse_datetime(\n                extr('title=\"', '\"'), \"%b %d, %Y · %I:%M %p %Z\"),\n            \"tweet_id\": link.rpartition(\"/\")[2].partition(\"#\")[0],\n            \"content\" : extr('class=\"quote-text', \"</div\").partition(\">\")[2],\n            \"_attach\" : extr('class=\"attachments', '''\n                </div>'''),\n            \"retweet\" : False,\n            \"quoted\"  : True,\n        }\n\n    def _user_from_html(self, html):\n        extr = text.extract_from(html, html.index('class=\"profile-tabs'))\n        banner = extr('class=\"profile-banner\"><a href=\"', '\"')\n\n        try:\n            if \"/enc/\" in banner:\n                uid = binascii.a2b_base64(banner.rpartition(\n                    \"/\")[2]).decode().split(\"/\")[4]\n            else:\n                uid = banner.split(\"%2F\")[4]\n        except Exception:\n            uid = 0\n\n        return {\n            \"id\"              : uid,\n            \"profile_banner\"  : self.root + banner if banner else \"\",\n            \"profile_image\"   : self.root + extr(\n                'class=\"profile-card-avatar\" href=\"', '\"'),\n            \"nick\"            : extr('title=\"', '\"'),\n            \"name\"            : extr('title=\"@', '\"'),\n            \"description\"     : extr('<p dir=\"auto\">', '<'),\n            \"date\"            : self.parse_datetime(\n                extr('class=\"profile-joindate\"><span title=\"', '\"'),\n                \"%I:%M %p - %d %b %Y\"),\n            \"statuses_count\"  : text.parse_int(extr(\n                'class=\"profile-stat-num\">', '<').replace(\",\", \"\")),\n            \"friends_count\"   : text.parse_int(extr(\n                'class=\"profile-stat-num\">', '<').replace(\",\", \"\")),\n            \"followers_count\" : text.parse_int(extr(\n                'class=\"profile-stat-num\">', '<').replace(\",\", \"\")),\n            \"favourites_count\": text.parse_int(extr(\n                'class=\"profile-stat-num\">', '<').replace(\",\", \"\")),\n            \"verified\"        : 'title=\"Verified account\"' in html,\n        }\n\n    def _extract_quote(self, html):\n        html, _, quote = html.partition('class=\"quote')\n        if quote:\n            quote, _, tail = quote.partition('class=\"tweet-published')\n            return (html + tail, quote)\n        return (html, None)\n\n    def _pagination(self, path):\n        quoted = self.config(\"quoted\", False)\n\n        if self.user_id:\n            self.user = self.request(\n                f\"{self.root}/i/user/{self.user_id}\",\n                allow_redirects=False,\n            ).headers[\"location\"].rpartition(\"/\")[2]\n        base_url = url = f\"{self.root}/{self.user}{path}\"\n\n        while True:\n            tweets_html = self.request(url).text.split(\n                '<div class=\"timeline-item')\n\n            if self.user_obj is None:\n                self.user_obj = self._user_from_html(tweets_html[0])\n\n            for html, quote in map(self._extract_quote, tweets_html[1:]):\n                tweet = self._tweet_from_html(html)\n                if not tweet[\"date\"]:\n                    continue\n                yield tweet\n                if quoted and quote:\n                    yield self._tweet_from_quote(quote)\n\n            more = text.extr(\n                tweets_html[-1], '<div class=\"show-more\"><a href=\"?', '\"')\n            if not more:\n                return\n            url = base_url + \"?\" + text.unescape(more)\n\n\nBASE_PATTERN = NitterExtractor.update({\n    \"nitter.net\": {\n        \"root\": \"https://nitter.net\",\n        \"pattern\": r\"(?:www\\.)?nitter\\.net\",\n    },\n    \"nitter.space\": {\n        \"root\": \"https://nitter.space\",\n        \"pattern\": r\"(?:www\\.)?nitter\\.space\",\n    },\n    \"nitter.tiekoetter\": {\n        \"root\": \"https://nitter.tiekoetter\",\n        \"pattern\": r\"(?:www\\.)?nitter\\.tiekoetter\\.com\",\n    },\n    \"xcancel\": {\n        \"root\": \"https://xcancel.com\",\n        \"pattern\": r\"(?:www\\.)?xcancel\\.com\",\n    },\n    \"lightbrd\": {\n        \"root\": \"https://lightbrd.com\",\n        \"pattern\": r\"(?:www\\.)?lightbrd\\.com\",\n    },\n})\n\nUSER_PATTERN = BASE_PATTERN + r\"/(i(?:/user/|d:)(\\d+)|[^/?#]+)\"\n\n\nclass NitterTweetsExtractor(NitterExtractor):\n    subcategory = \"tweets\"\n    pattern = USER_PATTERN + r\"(?:/tweets)?(?:$|\\?|#)\"\n    example = \"https://nitter.net/USER\"\n\n    def tweets(self):\n        return self._pagination(\"\")\n\n\nclass NitterRepliesExtractor(NitterExtractor):\n    subcategory = \"replies\"\n    pattern = USER_PATTERN + r\"/with_replies\"\n    example = \"https://nitter.net/USER/with_replies\"\n\n    def tweets(self):\n        return self._pagination(\"/with_replies\")\n\n\nclass NitterMediaExtractor(NitterExtractor):\n    subcategory = \"media\"\n    pattern = USER_PATTERN + r\"/media\"\n    example = \"https://nitter.net/USER/media\"\n\n    def tweets(self):\n        return self._pagination(\"/media\")\n\n\nclass NitterSearchExtractor(NitterExtractor):\n    subcategory = \"search\"\n    pattern = USER_PATTERN + r\"/search\"\n    example = \"https://nitter.net/USER/search\"\n\n    def tweets(self):\n        return self._pagination(\"/search\")\n\n\nclass NitterTweetExtractor(NitterExtractor):\n    \"\"\"Extractor for nitter tweets\"\"\"\n    subcategory = \"tweet\"\n    directory_fmt = (\"nitter\", \"{user[name]}\")\n    filename_fmt = \"{tweet_id}_{num}.{extension}\"\n    archive_fmt = \"{tweet_id}_{num}\"\n    pattern = BASE_PATTERN + r\"/(i/web|[^/?#]+)/status/(\\d+())\"\n    example = \"https://nitter.net/USER/status/12345\"\n\n    def tweets(self):\n        url = f\"{self.root}/i/status/{self.user}\"\n        html = text.extr(self.request(url).text, 'class=\"main-tweet', '''\\\n                </div>\n              </div></div></div>''')\n        html, quote = self._extract_quote(html)\n        tweet = self._tweet_from_html(html)\n        if quote and self.config(\"quoted\", False):\n            quoted = self._tweet_from_quote(quote)\n            quoted[\"user\"] = tweet[\"user\"]\n            return (tweet, quoted)\n        return (tweet,)\n\n\ndef _retry_on_404(response):\n    return response.status_code == 404\n"
  },
  {
    "path": "gallery_dl/extractor/noop.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2024 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"noop extractor\"\"\"\n\nfrom .common import Extractor\n\n\nclass NoopExtractor(Extractor):\n    category = \"noop\"\n    pattern = r\"(?i)noo?p$\"\n    example = \"noop\"\n\n    def items(self):\n        # Save cookies manually, since it happens automatically only after\n        # extended extractor initialization, i.e. Message.Directory, which\n        # itself might cause some unintended effects.\n        if self.cookies:\n            self.cookies_store()\n        return iter(((-1, \"\", None),))\n"
  },
  {
    "path": "gallery_dl/extractor/nozomi.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2019-2025 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://nozomi.la/\"\"\"\n\nfrom .common import Extractor, Message\nfrom .. import text, dt\n\n\ndef decode_nozomi(n):\n    for i in range(0, len(n), 4):\n        yield (n[i] << 24) + (n[i+1] << 16) + (n[i+2] << 8) + n[i+3]\n\n\nclass NozomiExtractor(Extractor):\n    \"\"\"Base class for nozomi extractors\"\"\"\n    category = \"nozomi\"\n    root = \"https://nozomi.la\"\n    domain = \"gold-usergeneratedcontent.net\"\n    filename_fmt = \"{postid} {dataid}.{extension}\"\n    archive_fmt = \"{dataid}\"\n\n    def _init(self):\n        self.session.headers[\"Origin\"] = self.root\n\n    def items(self):\n        data = self.metadata()\n\n        for post_id in map(str, self.posts()):\n            url = (f\"https://j.{self.domain}/post\"\n                   f\"/{post_id[-1]}/{post_id[-3:-1]}/{post_id}.json\")\n            response = self.request(url, fatal=False)\n\n            if response.status_code >= 400:\n                self.log.warning(\n                    \"Skipping post %s ('%s %s')\",\n                    post_id, response.status_code, response.reason)\n                continue\n\n            post = response.json()\n            post[\"tags\"] = self._list(post.get(\"general\"))\n            post[\"artist\"] = self._list(post.get(\"artist\"))\n            post[\"copyright\"] = self._list(post.get(\"copyright\"))\n            post[\"character\"] = self._list(post.get(\"character\"))\n\n            try:\n                post[\"date\"] = dt.parse_iso(post[\"date\"] + \":00\")\n            except Exception:\n                post[\"date\"] = dt.NONE\n\n            post.update(data)\n\n            images = post[\"imageurls\"]\n            for key in (\"general\", \"imageurl\", \"imageurls\"):\n                if key in post:\n                    del post[key]\n\n            yield Message.Directory, \"\", post\n            for post[\"num\"], image in enumerate(images, 1):\n                post[\"filename\"] = post[\"dataid\"] = did = image[\"dataid\"]\n                post[\"is_video\"] = video = \\\n                    True if image.get(\"is_video\") else False\n\n                ext = image[\"type\"]\n                if video:\n                    subdomain = \"v\"\n                elif ext == \"gif\":\n                    subdomain = \"g\"\n                else:\n                    subdomain = \"w\"\n                    ext = \"webp\"\n\n                post[\"extension\"] = ext\n                post[\"url\"] = url = (f\"https://{subdomain}.{self.domain}\"\n                                     f\"/{did[-1]}/{did[-3:-1]}/{did}.{ext}\")\n                yield Message.Url, url, post\n\n    def posts(self):\n        url = \"https://n.nozomi.la\" + self.nozomi\n        offset = (text.parse_int(self.pnum, 1) - 1) * 256\n\n        while True:\n            headers = {\"Range\": f\"bytes={offset}-{offset + 255}\"}\n            response = self.request(url, headers=headers)\n            yield from decode_nozomi(response.content)\n\n            offset += 256\n            cr = response.headers.get(\"Content-Range\", \"\").rpartition(\"/\")[2]\n            if text.parse_int(cr, offset) <= offset:\n                return\n\n    def metadata(self):\n        return {}\n\n    def _list(self, src):\n        return [x[\"tagname_display\"] for x in src] if src else ()\n\n\nclass NozomiPostExtractor(NozomiExtractor):\n    \"\"\"Extractor for individual posts on nozomi.la\"\"\"\n    subcategory = \"post\"\n    pattern = r\"(?:https?://)?nozomi\\.la/post/(\\d+)\"\n    example = \"https://nozomi.la/post/12345.html\"\n\n    def __init__(self, match):\n        NozomiExtractor.__init__(self, match)\n        self.post_id = match[1]\n\n    def posts(self):\n        return (self.post_id,)\n\n\nclass NozomiIndexExtractor(NozomiExtractor):\n    \"\"\"Extractor for the nozomi.la index\"\"\"\n    subcategory = \"index\"\n    pattern = (r\"(?:https?://)?nozomi\\.la/\"\n               r\"(?:(index(?:-Popular)?)-(\\d+)\\.html)?(?:$|#|\\?)\")\n    example = \"https://nozomi.la/index-1.html\"\n\n    def __init__(self, match):\n        NozomiExtractor.__init__(self, match)\n        index, self.pnum = match.groups()\n        self.nozomi = f\"/{index or 'index'}.nozomi\"\n\n\nclass NozomiTagExtractor(NozomiExtractor):\n    \"\"\"Extractor for posts from tag searches on nozomi.la\"\"\"\n    subcategory = \"tag\"\n    directory_fmt = (\"{category}\", \"{search_tags}\")\n    archive_fmt = \"t_{search_tags}_{dataid}\"\n    pattern = r\"(?:https?://)?nozomi\\.la/tag/([^/?#]+)-(\\d+)\\.\"\n    example = \"https://nozomi.la/tag/TAG-1.html\"\n\n    def __init__(self, match):\n        NozomiExtractor.__init__(self, match)\n        tags, self.pnum = match.groups()\n        self.tags = text.unquote(tags)\n        self.nozomi = f\"/nozomi/{self.tags}.nozomi\"\n\n    def metadata(self):\n        return {\"search_tags\": self.tags}\n\n\nclass NozomiSearchExtractor(NozomiExtractor):\n    \"\"\"Extractor for search results on nozomi.la\"\"\"\n    subcategory = \"search\"\n    directory_fmt = (\"{category}\", \"{search_tags:J }\")\n    archive_fmt = \"t_{search_tags}_{dataid}\"\n    pattern = r\"(?:https?://)?nozomi\\.la/search\\.html\\?q=([^&#]+)\"\n    example = \"https://nozomi.la/search.html?q=QUERY\"\n\n    def __init__(self, match):\n        NozomiExtractor.__init__(self, match)\n        self.tags = text.unquote(match[1]).split()\n\n    def metadata(self):\n        return {\"search_tags\": self.tags}\n\n    def posts(self):\n        result = None\n        positive = []\n        negative = []\n\n        def nozomi(path):\n            url = f\"https://j.{self.domain}/{path}.nozomi\"\n            return decode_nozomi(self.request(url).content)\n\n        for tag in self.tags:\n            (negative if tag[0] == \"-\" else positive).append(\n                text.quote(tag.replace(\"/\", \"\")))\n\n        for tag in positive:\n            ids = nozomi(\"nozomi/\" + tag)\n            if result is None:\n                result = set(ids)\n            else:\n                result.intersection_update(ids)\n\n        if result is None:\n            result = set(nozomi(\"index\"))\n        for tag in negative:\n            result.difference_update(nozomi(\"nozomi/\" + tag[1:]))\n\n        return sorted(result, reverse=True) if result else ()\n"
  },
  {
    "path": "gallery_dl/extractor/nsfwalbum.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2019-2025 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://nsfwalbum.com/\"\"\"\n\nfrom .common import GalleryExtractor\nfrom .. import text\n\n\nclass NsfwalbumAlbumExtractor(GalleryExtractor):\n    \"\"\"Extractor for image albums on nsfwalbum.com\"\"\"\n    category = \"nsfwalbum\"\n    subcategory = \"album\"\n    root = \"https://nsfwalbum.com\"\n    filename_fmt = \"{album_id}_{num:>03}_{id}.{extension}\"\n    directory_fmt = (\"{category}\", \"{album_id} {title}\")\n    archive_fmt = \"{id}\"\n    referer = False\n    pattern = r\"(?:https?://)?(?:www\\.)?nsfwalbum\\.com(/album/(\\d+))\"\n    example = \"https://nsfwalbum.com/album/12345\"\n\n    def __init__(self, match):\n        self.album_id = match[2]\n        GalleryExtractor.__init__(self, match)\n\n    def metadata(self, page):\n        extr = text.extract_from(page)\n        return {\n            \"album_id\": text.parse_int(self.album_id),\n            \"title\"   : text.unescape(extr('<h6>', '</h6>')),\n            \"models\"  : text.split_html(extr('\"models\"> Models:', '</div>')),\n            \"studio\"  : text.remove_html(extr('\"models\"> Studio:', '</div>')),\n        }\n\n    def images(self, page):\n        iframe = self.root + \"/iframe_image.php?id=\"\n        backend = self.root + \"/backend.php\"\n        retries = self._retries\n\n        for image_id in text.extract_iter(page, 'data-img-id=\"', '\"'):\n            spirit = None\n            tries = 0\n\n            while tries <= retries:\n                try:\n                    if not spirit:\n                        spirit = self._annihilate(text.extract(\n                            self.request(iframe + image_id).text,\n                            'giraffe.annihilate(\"', '\"')[0])\n                        params = {\"spirit\": spirit, \"photo\": image_id}\n                    data = self.request_json(backend, params=params)\n                    break\n                except Exception:\n                    tries += 1\n            else:\n                self.log.warning(\"Unable to fetch image %s\", image_id)\n                continue\n\n            yield data[0], {\n                \"id\"    : text.parse_int(image_id),\n                \"width\" : text.parse_int(data[1]),\n                \"height\": text.parse_int(data[2]),\n                \"_http_validate\": self._validate_response,\n                \"_fallback\": (f\"{self.root}/imageProxy.php\"\n                              f\"?photoId={image_id}&spirit={spirit}\",),\n            }\n\n    def _validate_response(self, response):\n        return not response.url.endswith(\n            (\"/no_image.jpg\", \"/placeholder.png\", \"/error.jpg\"))\n\n    def _annihilate(self, value, base=6):\n        return \"\".join(\n            chr(ord(char) ^ base)\n            for char in value\n        )\n"
  },
  {
    "path": "gallery_dl/extractor/nudostar.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://nudostar.tv/\"\"\"\n\nfrom .common import GalleryExtractor, Message\nfrom .. import text\n\nBASE_PATTERN = r\"(?:https?://)?(?:[a-z]{2}.)?nudostar\\.tv\"\n\n\nclass NudostarExtractor(GalleryExtractor):\n    \"\"\"Base class for NudoStar extractors\"\"\"\n    category = \"nudostar\"\n    root = \"https://nudostar.tv\"\n\n\nclass NudostarModelExtractor(NudostarExtractor):\n    \"\"\"Extractor for NudoStar models\"\"\"\n    subcategory = \"model\"\n    pattern = BASE_PATTERN + r\"(/models/([^/?#]+)/?)$\"\n    example = \"https://nudostar.tv/models/MODEL/\"\n\n    def metadata(self, page):\n        names = text.extr(page, \"<title>\", \"<\").rpartition(\n            \" Nude \")[0].split(\" / \")\n        slug = self.groups[1]\n\n        return {\n            \"gallery_id\" : slug,\n            \"model_slug\" : slug,\n            \"model_names\": names,\n            \"model\"      : names[0],\n            \"title\"      : \"\",\n        }\n\n    def images(self, page):\n        path = text.extr(page, '\" src=\"https://nudostar.tv', '\"')\n        path, cnt, end = path.rsplit(\"_\", 2)\n\n        base = f\"{self.root}{path}_\"\n        ext = \".\" + end.rpartition(\".\")[2]\n\n        return [\n            (f\"{base}{i:04}{ext}\", None)\n            for i in range(1, int(cnt)+1)\n        ]\n\n\nclass NudostarImageExtractor(NudostarExtractor):\n    \"\"\"Extractor for NudoStar images\"\"\"\n    subcategory = \"image\"\n    pattern = BASE_PATTERN + r\"(/models/([^/?#]+)/(\\d+)/)\"\n    example = \"https://nudostar.tv/models/MODEL/123/\"\n\n    def items(self):\n        page = self.request(self.page_url, notfound=self.subcategory).text\n\n        img_url = text.extract(\n            page, 'src=\"', '\"', page.index('class=\"headline\"'))[0]\n\n        data = NudostarModelExtractor.metadata(self, page)\n        data = text.nameext_from_url(img_url, data)\n        data[\"num\"] = text.parse_int(self.groups[2])\n        data[\"url\"] = img_url\n\n        yield Message.Directory, \"\", data\n        yield Message.Url, img_url, data\n"
  },
  {
    "path": "gallery_dl/extractor/oauth.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2017-2026 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Utility classes to setup OAuth and link accounts to gallery-dl\"\"\"\n\nfrom .common import Extractor\nfrom .. import text, oauth, util, config\nfrom ..output import stdout_write\n\nREDIRECT_URI_LOCALHOST = \"http://localhost:6414/\"\nREDIRECT_URI_HTTPS = \"https://mikf.github.io/gallery-dl/oauth-redirect.html\"\nNOOP = ((-1, \"\", None),)\n\n\nclass OAuthBase(Extractor):\n    \"\"\"Base class for OAuth Helpers\"\"\"\n    category = \"oauth\"\n    redirect_uri = REDIRECT_URI_LOCALHOST\n\n    def __init__(self, match):\n        Extractor.__init__(self, match)\n        self.client = None\n\n    def _init(self):\n        from .. import cache\n        self._cache = config.get((\"extractor\", self.category), \"cache\", True)\n        if self._cache and cache.database() is None:\n            self.log.warning(\"cache file is not writeable\")\n            self._cache = False\n\n    def oauth_config(self, key, default=None):\n        value = config.interpolate((\"extractor\", self.subcategory), key)\n        return value if value is not None else default\n\n    def recv(self):\n        \"\"\"Open local HTTP server and recv callback parameters\"\"\"\n        import socket\n        stdout_write(\"Waiting for response. (Cancel with Ctrl+c)\\n\")\n        server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)\n        server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)\n        server.bind((self.config(\"host\", \"localhost\"),\n                     self.config(\"port\", 6414)))\n        server.listen(1)\n\n        # workaround for ctrl+c not working during server.accept on Windows\n        if util.WINDOWS:\n            server.settimeout(1.0)\n        while True:\n            try:\n                self.client = server.accept()[0]\n                break\n            except socket.timeout:\n                pass\n        server.close()\n\n        data = None\n        try:\n            data = self.client.recv(1024).decode()\n            path = data.split(\" \", 2)[1]\n            return text.parse_query(path.partition(\"?\")[2])\n        except Exception as exc:\n            if data is None:\n                msg = \"Failed to receive\"\n            elif not data:\n                exc = \"\"\n                msg = \"Received empty\"\n            else:\n                self.log.warning(\"Response: %r\", data)\n                msg = \"Received invalid\"\n            if exc:\n                exc = f\" ({exc.__class__.__name__}: {exc})\"\n            raise self.exc.AbortExtraction(f\"{msg} OAuth response{exc}\")\n\n    def send(self, msg):\n        \"\"\"Send 'msg' to the socket opened in 'recv()'\"\"\"\n        stdout_write(msg)\n        self.client.send(b\"HTTP/1.1 200 OK\\r\\n\\r\\n\" + msg.encode())\n        self.client.close()\n\n    def open(self, url, params, recv=None):\n        \"\"\"Open 'url' in browser amd return response parameters\"\"\"\n        url = f\"{url}?{text.build_query(params)}\"\n\n        if browser := self.config(\"browser\", True):\n            try:\n                import webbrowser\n                browser = webbrowser.get()\n            except Exception:\n                browser = None\n\n        if browser and browser.open(url):\n            if name := getattr(browser, \"name\", None):\n                self.log.info(\"Opening URL with %s:\", name.capitalize())\n        else:\n            self.log.info(\"Please open this URL in your browser:\")\n\n        stdout_write(f\"\\n{url}\\n\\n\")\n        return (recv or self.recv)()\n\n    def error(self, msg):\n        return self.send(\n            f\"Remote server reported an error:\\n\\n{msg}\\n\")\n\n    def _oauth1_authorization_flow(\n            self, default_key, default_secret,\n            request_token_url, authorize_url, access_token_url):\n        \"\"\"Perform the OAuth 1.0a authorization flow\"\"\"\n\n        api_key = self.oauth_config(\"api-key\") or default_key\n        api_secret = self.oauth_config(\"api-secret\") or default_secret\n        self.session = oauth.OAuth1Session(api_key, api_secret)\n\n        self.log.info(\"Using %s %s API key (%s)\",\n                      \"default\" if api_key == default_key else \"custom\",\n                      self.subcategory, api_key)\n\n        # get a request token\n        params = {\"oauth_callback\": self.redirect_uri}\n        data = self.request(request_token_url, params=params).text\n\n        data = text.parse_query(data)\n        self.session.auth.token_secret = data[\"oauth_token_secret\"]\n\n        # get the user's authorization\n        params = {\"oauth_token\": data[\"oauth_token\"], \"perms\": \"read\"}\n        data = self.open(authorize_url, params)\n\n        # exchange the request token for an access token\n        data = self.request(access_token_url, params=data).text\n        data = text.parse_query(data)\n        token = data[\"oauth_token\"]\n        token_secret = data[\"oauth_token_secret\"]\n\n        # write to cache\n        if self._cache:\n            key = (self.subcategory, self.session.auth.consumer_key)\n            self.cache_update(oauth._token_cache, key, (token, token_secret))\n            self.log.info(\"Writing tokens to cache\")\n\n        # display tokens\n        self.send(self._generate_message(\n            (\"access-token\", \"access-token-secret\"),\n            (token, token_secret),\n        ))\n\n    def _oauth2_authorization_code_grant(\n            self, client_id, client_secret, default_id, default_secret,\n            auth_url, token_url, scope=\"read\", duration=\"permanent\",\n            key=\"refresh_token\", auth=True, cache=None, instance=None):\n        \"\"\"Perform an OAuth2 authorization code grant\"\"\"\n\n        client_id = str(client_id) if client_id else default_id\n        client_secret = client_secret or default_secret\n\n        self.log.info(\"Using %s %s client ID (%s)\",\n                      \"default\" if client_id == default_id else \"custom\",\n                      instance or self.subcategory, client_id)\n\n        state = f\"gallery-dl_{self.subcategory}_{oauth.nonce(8)}\"\n\n        auth_params = {\n            \"client_id\"    : client_id,\n            \"response_type\": \"code\",\n            \"state\"        : state,\n            \"redirect_uri\" : self.redirect_uri,\n            \"duration\"     : duration,\n            \"scope\"        : scope,\n        }\n\n        # receive an authorization code\n        params = self.open(auth_url, auth_params)\n\n        # check authorization response\n        if state != params.get(\"state\"):\n            self.send(f\"'state' mismatch: expected {state}, \"\n                      f\"got {params.get('state')}.\\n\")\n            return\n        if \"error\" in params:\n            return self.error(params)\n\n        # exchange authorization code for a token\n        data = {\n            \"grant_type\"  : \"authorization_code\",\n            \"code\"        : params[\"code\"],\n            \"redirect_uri\": self.redirect_uri,\n        }\n\n        if auth:\n            auth = util.HTTPBasicAuth(client_id, client_secret)\n        else:\n            auth = None\n            data[\"client_id\"] = client_id\n            data[\"client_secret\"] = client_secret\n\n        data = self.request_json(\n            token_url, method=\"POST\", data=data, auth=auth)\n\n        # check token response\n        if \"error\" in data:\n            return self.error(data)\n\n        token = data[key]\n        token_name = key.replace(\"_\", \"-\")\n\n        # write to cache\n        if self._cache and cache:\n            self.cache_update(cache, instance or (\"#\" + str(client_id)), token)\n            self.log.info(\"Writing '%s' to cache\", token_name)\n\n        # display token\n        self.send(self._generate_message(\n            (token_name,), (token,),\n        ))\n\n    def _generate_message(self, names, values):\n        _vh, _va, _is, _it = (\n            (\"This value has\", \"this value\", \"is\", \"it\")\n            if len(names) == 1 else\n            (\"These values have\", \"these values\", \"are\", \"them\")\n        )\n\n        key = \" and \".join(f\"'{n}'\" for n in names)\n        val = \"\\n\".join(values)\n        msg = f\"\\nYour {key} {_is}\\n\\n{val}\\n\\n\"\n\n        opt = self.oauth_config(names[0])\n        if self._cache and (opt is None or opt == \"cache\"):\n            msg += _vh + \" been cached and will automatically be used.\\n\"\n        else:\n            msg += f\"Put {_va} into your configuration file as \\n\"\n            msg += \" and\\n\".join(\n                f\"'extractor.{self.subcategory}.{n}'\"\n                for n in names\n            )\n            if self._cache:\n                msg = (f\"{msg}\\nor set\\n'extractor.\"\n                       f\"{self.subcategory}.{names[0]}' to \\\"cache\\\"\")\n            msg = f\"{msg}\\nto use {_it}.\\n\"\n\n        return msg\n\n\n# --------------------------------------------------------------------\n# OAuth 1.0a\n\nclass OAuthFlickr(OAuthBase):\n    subcategory = \"flickr\"\n    pattern = \"oauth:flickr$\"\n    example = \"oauth:flickr\"\n    redirect_uri = REDIRECT_URI_HTTPS\n\n    def items(self):\n        #  from . import flickr\n\n        self._oauth1_authorization_flow(\n            #  flickr.FlickrAPI.API_KEY,\n            #  flickr.FlickrAPI.API_SECRET,\n            \"\",\n            \"\",\n            \"https://www.flickr.com/services/oauth/request_token\",\n            \"https://www.flickr.com/services/oauth/authorize\",\n            \"https://www.flickr.com/services/oauth/access_token\",\n        )\n        return iter(NOOP)\n\n\nclass OAuthSmugmug(OAuthBase):\n    subcategory = \"smugmug\"\n    pattern = \"oauth:smugmug$\"\n    example = \"oauth:smugmug\"\n\n    def items(self):\n        from . import smugmug\n\n        self._oauth1_authorization_flow(\n            smugmug.SmugmugAPI.API_KEY,\n            smugmug.SmugmugAPI.API_SECRET,\n            \"https://api.smugmug.com/services/oauth/1.0a/getRequestToken\",\n            \"https://api.smugmug.com/services/oauth/1.0a/authorize\",\n            \"https://api.smugmug.com/services/oauth/1.0a/getAccessToken\",\n        )\n        return iter(NOOP)\n\n\nclass OAuthTumblr(OAuthBase):\n    subcategory = \"tumblr\"\n    pattern = \"oauth:tumblr$\"\n    example = \"oauth:tumblr\"\n\n    def items(self):\n        from . import tumblr\n\n        self._oauth1_authorization_flow(\n            tumblr.TumblrAPI.API_KEY,\n            tumblr.TumblrAPI.API_SECRET,\n            \"https://www.tumblr.com/oauth/request_token\",\n            \"https://www.tumblr.com/oauth/authorize\",\n            \"https://www.tumblr.com/oauth/access_token\",\n        )\n        return iter(NOOP)\n\n\n# --------------------------------------------------------------------\n# OAuth 2.0\n\nclass OAuthDeviantart(OAuthBase):\n    subcategory = \"deviantart\"\n    pattern = \"oauth:deviantart$\"\n    example = \"oauth:deviantart\"\n    redirect_uri = REDIRECT_URI_HTTPS\n\n    def items(self):\n        from . import deviantart\n\n        self._oauth2_authorization_code_grant(\n            self.oauth_config(\"client-id\"),\n            self.oauth_config(\"client-secret\"),\n            deviantart.DeviantartOAuthAPI.CLIENT_ID,\n            deviantart.DeviantartOAuthAPI.CLIENT_SECRET,\n            \"https://www.deviantart.com/oauth2/authorize\",\n            \"https://www.deviantart.com/oauth2/token\",\n            scope=\"browse user.manage\",\n            cache=deviantart._refresh_token_cache,\n        )\n        return iter(NOOP)\n\n\nclass OAuthReddit(OAuthBase):\n    subcategory = \"reddit\"\n    pattern = \"oauth:reddit$\"\n    example = \"oauth:reddit\"\n\n    def items(self):\n        from . import reddit\n\n        self.session.headers[\"User-Agent\"] = reddit.RedditAPI.USER_AGENT\n        self._oauth2_authorization_code_grant(\n            self.oauth_config(\"client-id\"),\n            \"\",\n            reddit.RedditAPI.CLIENT_ID,\n            \"\",\n            \"https://www.reddit.com/api/v1/authorize\",\n            \"https://www.reddit.com/api/v1/access_token\",\n            scope=\"read history\",\n            cache=reddit._refresh_token_cache,\n        )\n        return iter(NOOP)\n\n\nclass OAuthMastodon(OAuthBase):\n    subcategory = \"mastodon\"\n    pattern = \"oauth:mastodon:(?:https?://)?([^/?#]+)\"\n    example = \"oauth:mastodon:mastodon.social\"\n\n    def __init__(self, match):\n        OAuthBase.__init__(self, match)\n        self.instance = match[1]\n\n    def items(self):\n        from . import mastodon\n\n        for _, root, application in mastodon.MastodonExtractor.instances:\n            if self.instance == root.partition(\"://\")[2]:\n                break\n        else:\n            application = self.cache(self._register, self.instance, _mem=False)\n\n        self._oauth2_authorization_code_grant(\n            application[\"client-id\"],\n            application[\"client-secret\"],\n            application[\"client-id\"],\n            application[\"client-secret\"],\n            f\"https://{self.instance}/oauth/authorize\",\n            f\"https://{self.instance}/oauth/token\",\n            instance=self.instance,\n            key=\"access_token\",\n            cache=mastodon._access_token_cache,\n        )\n        return iter(NOOP)\n\n    def _register(self, instance):\n        self.log.info(\"Registering application for '%s'\", instance)\n\n        url = f\"https://{instance}/api/v1/apps\"\n        data = {\n            \"client_name\": \"gdl:\" + oauth.nonce(8),\n            \"redirect_uris\": self.redirect_uri,\n            \"scopes\": \"read\",\n        }\n        data = self.request_json(url, method=\"POST\", data=data)\n\n        if \"client_id\" not in data or \"client_secret\" not in data:\n            raise self.exc.AbortExtraction(\n                f\"Failed to register new application: '{data}'\")\n\n        data[\"client-id\"] = data.pop(\"client_id\")\n        data[\"client-secret\"] = data.pop(\"client_secret\")\n\n        self.log.info(\"client-id:\\n%s\", data[\"client-id\"])\n        self.log.info(\"client-secret:\\n%s\", data[\"client-secret\"])\n\n        return data\n\n\n# --------------------------------------------------------------------\n\nclass OAuthPixiv(OAuthBase):\n    subcategory = \"pixiv\"\n    pattern = \"oauth:pixiv$\"\n    example = \"oauth:pixiv\"\n\n    def items(self):\n        from . import pixiv\n        import binascii\n        import hashlib\n\n        code_verifier = util.generate_token(32)\n        digest = hashlib.sha256(code_verifier.encode()).digest()\n        code_challenge = binascii.b2a_base64(\n            digest)[:-2].decode().replace(\"+\", \"-\").replace(\"/\", \"_\")\n\n        url = \"https://app-api.pixiv.net/web/v1/login\"\n        params = {\n            \"code_challenge\": code_challenge,\n            \"code_challenge_method\": \"S256\",\n            \"client\": \"pixiv-android\",\n        }\n        code = self.open(url, params, self._input_code)\n\n        url = \"https://oauth.secure.pixiv.net/auth/token\"\n        headers = {\n            \"User-Agent\": \"PixivAndroidApp/5.0.234 (Android 11; Pixel 5)\",\n        }\n        data = {\n            \"client_id\"     : self.oauth_config(\n                \"client-id\"    , pixiv.PixivAppAPI.CLIENT_ID),\n            \"client_secret\" : self.oauth_config(\n                \"client-secret\", pixiv.PixivAppAPI.CLIENT_SECRET),\n            \"code\"          : code,\n            \"code_verifier\" : code_verifier,\n            \"grant_type\"    : \"authorization_code\",\n            \"include_policy\": \"true\",\n            \"redirect_uri\"  : \"https://app-api.pixiv.net\"\n                              \"/web/v1/users/auth/pixiv/callback\",\n        }\n        data = self.request_json(\n            url, method=\"POST\", headers=headers, data=data)\n\n        if \"error\" in data:\n            stdout_write(f\"\\n{data}\\n\")\n            if data[\"error\"] in {\"invalid_request\", \"invalid_grant\"}:\n                stdout_write(\"'code' expired, try again\\n\\n\")\n            return\n\n        token = data[\"refresh_token\"]\n        if self._cache:\n            username = self.oauth_config(\"username\")\n            self.cache_update(pixiv._refresh_token_cache, username, token)\n            self.log.info(\"Writing 'refresh-token' to cache\")\n\n        stdout_write(self._generate_message((\"refresh-token\",), (token,)))\n        return iter(NOOP)\n\n    def _input_code(self):\n        stdout_write(\"\"\"\\\n1) Open your browser's Developer Tools (F12) and switch to the Network tab\n2) Login\n3) Select the last network monitor entry ('callback?state=...')\n4) Copy its 'code' query parameter, paste it below, and press Enter\n\n- This 'code' will expire 30 seconds after logging in.\n- Copy-pasting more than just the 'code' value will work as well,\n  like the entire URL or several query parameters.\n\n\"\"\")\n        code = self.input(\"code: \")\n        return code.rpartition(\"=\")[2].strip()\n"
  },
  {
    "path": "gallery_dl/extractor/okporn.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://ok.porn/\"\"\"\n\nfrom .common import GalleryExtractor\nfrom .. import text\n\n\nclass OkpornGalleryExtractor(GalleryExtractor):\n    \"\"\"Extractor for image galleries from ok.porn\"\"\"\n    category = \"okporn\"\n    root = \"https://ok.porn\"\n    pattern = r\"(?:https?://)?(?:www\\.)?ok\\.porn/albums/(\\d+)\"\n    example = \"https://ok.porn/albums/12345/\"\n\n    def __init__(self, match):\n        url = f\"{self.root}/albums/{match[1]}/\"\n        GalleryExtractor.__init__(self, match, url)\n\n    def metadata(self, page):\n        return {\n            \"gallery_id\" : text.parse_int(self.groups[0]),\n            \"title\"      : text.unescape(text.extr(\n                page, \"h1 class=title>\", \"</h1>\")),\n            \"description\": text.unescape(text.extr(\n                page, 'name=\"description\" content=\"', '\"')),\n            \"tags\": text.extr(\n                page, 'name=\"keywords\" content=\"', '\"').split(\", \"),\n        }\n\n    def images(self, page):\n        return [\n            (url, None)\n            for url in text.extract_iter(page, 'data-original=\"', '\"')\n        ]\n"
  },
  {
    "path": "gallery_dl/extractor/paheal.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2018-2026 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://rule34.paheal.net/\"\"\"\n\nfrom .common import Extractor, Message\nfrom .. import text\n\n\nclass PahealExtractor(Extractor):\n    \"\"\"Base class for paheal extractors\"\"\"\n    basecategory = \"shimmie2\"\n    category = \"paheal\"\n    filename_fmt = \"{category}_{id}_{md5}.{extension}\"\n    archive_fmt = \"{id}\"\n    root = \"https://rule34.paheal.net\"\n\n    def items(self):\n        self.cookies.set(\n            \"ui-tnc-agreed\", \"true\", domain=\"rule34.paheal.net\")\n        data = self.get_metadata()\n\n        for post in self.get_posts():\n            post[\"id\"] = text.parse_int(post[\"id\"])\n            post[\"tags\"] = text.unquote(post[\"tags\"])\n            post[\"width\"] = text.parse_int(post[\"width\"])\n            post[\"height\"] = text.parse_int(post[\"height\"])\n            post.update(data)\n            yield Message.Directory, \"\", post\n            yield Message.Url, post[\"file_url\"], post\n\n    def get_metadata(self):\n        \"\"\"Return general metadata\"\"\"\n        return {}\n\n    def get_posts(self):\n        \"\"\"Return an iterable containing data of all relevant posts\"\"\"\n\n    def _extract_post(self, post_id):\n        url = f\"{self.root}/post/view/{post_id}\"\n        extr = text.extract_from(self.request(url).text)\n\n        post = {\n            \"id\"      : post_id,\n            \"tags\"    : extr(\": \", \"<\"),\n            \"file_url\": (extr(\"id='main_image' src='\", \"'\") or\n                         extr(\"<source src='\", \"'\")),\n            \"uploader\": text.unquote(extr(\n                \"class='username' href='/user/\", \"'\")),\n            \"date\"    : self.parse_datetime_iso(extr(\"datetime='\", \"'\")),\n            \"source\"  : text.unescape(text.extr(\n                extr(\">Source Link<\", \"</td>\"), \"href='\", \"'\")),\n        }\n\n        dimensions, size, ext = extr(\"Info</th><td>\", \"<\").split(\" // \")\n        post[\"md5\"] = post[\"file_url\"].rpartition(\"/\")[2]\n        post[\"size\"] = text.parse_bytes(size[:-1])\n        post[\"width\"], _, height = dimensions.partition(\"x\")\n        post[\"height\"], _, duration = height.partition(\", \")\n        post[\"duration\"] = text.parse_float(duration[:-1])\n        post[\"filename\"] = f\"{post_id} - {post['tags']}\"\n        post[\"extension\"] = ext\n\n        return post\n\n\nclass PahealTagExtractor(PahealExtractor):\n    \"\"\"Extractor for images from rule34.paheal.net by search-tags\"\"\"\n    subcategory = \"tag\"\n    directory_fmt = (\"{category}\", \"{search_tags}\")\n    pattern = (r\"(?:https?://)?(?:rule34|rule63|cosplay)\\.paheal\\.net\"\n               r\"/post/list/([^/?#]+)\")\n    example = \"https://rule34.paheal.net/post/list/TAG/1\"\n    page_start = 1\n    per_page = 70\n\n    def _init(self):\n        if self.config(\"metadata\"):\n            self._extract_data = self._extract_data_ex\n\n    def skip_files(self, num):\n        pages = num // self.per_page\n        self.page_start += pages\n        return pages * self.per_page\n\n    def get_metadata(self):\n        return {\"search_tags\": text.unquote(self.groups[0])}\n\n    def get_posts(self):\n        pnum = self.page_start\n        base = f\"{self.root}/post/list/{self.groups[0]}/\"\n\n        while True:\n            try:\n                response = self.request(\n                    base + str(pnum), allow_redirects=False)\n                if response.status_code >= 300:\n                    pid = response.headers[\"location\"].rpartition(\"/\")[2]\n                    yield self._extract_post(pid)\n                    return\n                page = response.text\n            except self.exc.HttpError as exc:\n                if exc.status == 404:\n                    return\n                raise\n\n            pos = page.find(\"id='image-list'\")\n            for post in text.extract_iter(\n                    page, \"<img id='thumb_\", \"Only</a>\", pos):\n                yield self._extract_data(post)\n\n            if \">Next<\" not in page:\n                break\n            pnum += 1\n\n    def _extract_data(self, post):\n        pid , pos = text.extract(post, \"\", \"'\")\n        data, pos = text.extract(post, \"title='\", \"'\", pos)\n        url , pos = text.extract(post, \"<a href='\", \"'\", pos)\n\n        tags, data, date = data.split(\"\\n\")\n        dimensions, size, ext = data.split(\" // \")\n        width, _, height = dimensions.partition(\"x\")\n        height, _, duration = height.partition(\", \")\n\n        return {\n            \"id\"       : pid,\n            \"md5\"      : url[url.rfind(\"/\")+1:],\n            \"file_url\" : url,\n            \"width\"    : width,\n            \"height\"   : height,\n            \"duration\" : text.parse_float(duration[:-1]),\n            \"tags\"     : text.unescape(tags),\n            \"size\"     : text.parse_bytes(size[:-1]),\n            \"date\"     : self.parse_datetime(date, \"%B %d, %Y; %H:%M\"),\n            \"filename\" : f\"{pid} - {tags}\",\n            \"extension\": ext,\n        }\n\n    def _extract_data_ex(self, post):\n        pid = post[:post.index(\"'\")]\n        return self._extract_post(pid)\n\n\nclass PahealPostExtractor(PahealExtractor):\n    \"\"\"Extractor for single images from rule34.paheal.net\"\"\"\n    subcategory = \"post\"\n    pattern = (r\"(?:https?://)?(?:rule34|rule63|cosplay)\\.paheal\\.net\"\n               r\"/post/view/(\\d+)\")\n    example = \"https://rule34.paheal.net/post/view/12345\"\n\n    def get_posts(self):\n        try:\n            return (self._extract_post(self.groups[0]),)\n        except self.exc.HttpError as exc:\n            if exc.status == 404:\n                return ()\n            raise\n"
  },
  {
    "path": "gallery_dl/extractor/patreon.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2019-2026 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://www.patreon.com/\"\"\"\n\nfrom .common import Extractor, Message\nfrom .. import text, util, dt\nimport collections\nimport itertools\n\n\nclass PatreonExtractor(Extractor):\n    \"\"\"Base class for patreon extractors\"\"\"\n    category = \"patreon\"\n    root = \"https://www.patreon.com\"\n    cookies_domain = \".patreon.com\"\n    directory_fmt = (\"{category}\", \"{creator[full_name]}\")\n    filename_fmt = \"{id}_{title}_{num:>02}.{extension}\"\n    archive_fmt = \"{id}_{num}\"\n    useragent = \"Patreon/126.9.0.15 (Android; Android 14; Scale/2.10)\"\n    _warning = True\n\n    def _init(self):\n        if self.cookies_check((\"session_id\",), subdomains=True):\n            self._logged_in = True\n        else:\n            self._logged_in = False\n            if self._warning:\n                PatreonExtractor._warning = False\n                self.log.warning(\"no 'session_id' cookie set\")\n\n        if format_images := self.config(\"format-images\"):\n            self._images_fmt = format_images\n            self._images_url = self._images_url_fmt\n\n        self._cursor = None\n\n    def items(self):\n        generators = self._build_file_generators(self.config(\"files\"))\n\n        for post in self.posts():\n\n            yield Message.Directory, \"\", post\n            if not post.get(\"current_user_can_view\", True):\n                self.log.warning(\"Not allowed to view post %s\", post[\"id\"])\n                continue\n\n            post[\"num\"] = 0\n            hashes = set()\n            for kind, file, url, name in itertools.chain.from_iterable(\n                    g(post) for g in generators):\n                fhash = self._filehash(url)\n                if fhash not in hashes or not fhash:\n                    hashes.add(fhash)\n                    post[\"hash\"] = fhash\n                    post[\"type\"] = kind\n                    post[\"file\"] = file\n                    post[\"num\"] += 1\n                    text.nameext_from_url(name, post)\n                    if text.ext_from_url(url) == \"m3u8\":\n                        url = \"ytdl:\" + url\n                        headers = {\"referer\": self.root + \"/\"}\n                        post[\"_ytdl_manifest\"] = \"hls\"\n                        post[\"_ytdl_manifest_headers\"] = headers\n                        post[\"_ytdl_extra\"] = {\"http_headers\": headers}\n                        post[\"extension\"] = \"mp4\"\n                    yield Message.Url, url, post\n                else:\n                    self.log.debug(\"skipping %s (%s %s)\", url, fhash, kind)\n\n    def finalize(self, status):\n        if status and self._cursor:\n            self.log.info(\"Use '-o cursor=%s' to continue downloading \"\n                          \"from the current position\", self._cursor)\n\n    def _postfile(self, post):\n        if postfile := post.get(\"post_file\"):\n            url = postfile[\"url\"]\n            if not (name := postfile.get(\"name\")):\n                if url.startswith(\"https://stream.mux.com/\"):\n                    name = url\n                else:\n                    name = self._filename(url) or url\n            return ((\"postfile\", postfile, url, name),)\n        return ()\n\n    def _images(self, post):\n        if images := post.get(\"images\"):\n            for image in images:\n                if url := self._images_url(image):\n                    name = image.get(\"file_name\") or self._filename(url) or url\n                    yield \"image\", image, url, name\n\n    def _images_url(self, image):\n        return image.get(\"download_url\")\n\n    def _images_url_fmt(self, image):\n        try:\n            return image[\"image_urls\"][self._images_fmt]\n        except Exception:\n            return image.get(\"download_url\")\n\n    def _image_large(self, post):\n        if image := post.get(\"image\"):\n            if url := image.get(\"large_url\"):\n                name = image.get(\"file_name\") or self._filename(url) or url\n                return ((\"image_large\", image, url, name),)\n        return ()\n\n    def _attachments(self, post):\n        for attachment in post.get(\"attachments\") or ():\n            if url := self.request_location(attachment[\"url\"], fatal=False):\n                yield \"attachment\", attachment, url, attachment[\"name\"]\n\n        for attachment in post.get(\"attachments_media\") or ():\n            if url := attachment.get(\"download_url\"):\n                yield \"attachment\", attachment, url, attachment[\"file_name\"]\n\n    def _content(self, post):\n        if content := post.get(\"content\"):\n            for img in text.extract_iter(\n                    content, '<img data-media-id=\"', '>'):\n                if url := text.extr(img, 'src=\"', '\"'):\n                    yield \"content\", None, url, self._filename(url) or url\n\n    def posts(self):\n        \"\"\"Return all relevant post objects\"\"\"\n\n    def _pagination(self, url):\n        headers = {\n            \"Content-Type\": \"application/vnd.api+json\",\n        }\n\n        while url:\n            self._update_cursor(url)\n            url = text.ensure_http_scheme(url)\n            posts = self.request_json(url, headers=headers)\n\n            if \"included\" in posts:\n                included = self._transform(posts[\"included\"])\n                for post in posts[\"data\"]:\n                    yield self._process(post, included)\n\n            if \"links\" not in posts:\n                break\n            url = posts[\"links\"].get(\"next\")\n\n        self._update_cursor(\"\")\n\n    def _init_cursor(self):\n        if cursor := self.config(\"cursor\", True):\n            return \"\" if cursor is True else cursor\n        self._update_cursor = util.identity\n        return \"\"\n\n    def _update_cursor(self, url):\n        params = text.parse_query(url.partition(\"?\")[2])\n        self._cursor = cursor = params.get(\"page[cursor]\")\n        if cursor:\n            self.log.debug(\"Cursor: %s\", cursor)\n        return cursor\n\n    def _process(self, post, included):\n        \"\"\"Process and extend a 'post' object\"\"\"\n        attr = post[\"attributes\"]\n        attr[\"id\"] = text.parse_int(post[\"id\"])\n\n        relationships = post[\"relationships\"]\n        attr[\"images\"] = self._files(\n            post, included, \"images\")\n        attr[\"attachments\"] = self._files(\n            post, included, \"attachments\")\n        attr[\"attachments_media\"] = self._files(\n            post, included, \"attachments_media\")\n        attr[\"date\"] = self.parse_datetime_iso(attr[\"published_at\"])\n\n        try:\n            attr[\"campaign\"] = (included[\"campaign\"][\n                                relationships[\"campaign\"][\"data\"][\"id\"]])\n        except Exception:\n            attr[\"campaign\"] = None\n\n        tags = relationships.get(\"user_defined_tags\")\n        attr[\"tags\"] = [\n            tag[\"id\"].replace(\"user_defined;\", \"\")\n            for tag in tags[\"data\"]\n            if tag[\"type\"] == \"post_tag\"\n        ] if tags else []\n\n        user = relationships[\"user\"]\n        attr[\"creator\"] = (\n            self.cache(self._user, user[\"links\"][\"related\"]) or\n            included[\"user\"][user[\"data\"][\"id\"]])\n\n        if not attr.get(\"content\") and (\n                cjs := attr.pop(\"content_json_string\", None)):\n            try:\n                attr[\"content\"] = self.utils(\"tiptap\").to_html(cjs)\n            except Exception as exc:\n                self.log.traceback(exc)\n                self.log.warning(\n                    \"%s: Failed to parse content_json_string (%s: %s)\",\n                    attr[\"id\"], exc.__class__.__name__, exc)\n\n        return attr\n\n    def _transform(self, included):\n        \"\"\"Transform 'included' into an easier to handle format\"\"\"\n        result = collections.defaultdict(dict)\n        for inc in included:\n            result[inc[\"type\"]][inc[\"id\"]] = inc[\"attributes\"]\n        return result\n\n    def _files(self, post, included, key):\n        \"\"\"Build a list of files\"\"\"\n        files = post[\"relationships\"].get(key)\n        if files and files.get(\"data\"):\n            return [\n                included[file[\"type\"]][file[\"id\"]]\n                for file in files[\"data\"]\n            ]\n        return []\n\n    def _user(self, url):\n        \"\"\"Fetch user information\"\"\"\n        response = self.request(url, fatal=False)\n        if response.status_code >= 400:\n            return None\n        user = response.json()[\"data\"]\n        attr = user[\"attributes\"]\n        attr[\"id\"] = user[\"id\"]\n        attr[\"date\"] = self.parse_datetime_iso(attr[\"created\"])\n        return attr\n\n    def _collection(self, collection_id):\n        url = f\"{self.root}/api/collection/{collection_id}\"\n        data = self.request_json(url)\n        coll = data[\"data\"]\n        attr = coll[\"attributes\"]\n        attr[\"id\"] = coll[\"id\"]\n        attr[\"date\"] = self.parse_datetime_iso(attr[\"created_at\"])\n        return attr\n\n    def _filename(self, url):\n        \"\"\"Fetch filename from an URL's Content-Disposition header\"\"\"\n        response = self.request(url, method=\"HEAD\", fatal=False)\n        cd = response.headers.get(\"Content-Disposition\")\n        return text.extr(cd, 'filename=\"', '\"')\n\n    def _filehash(self, url):\n        \"\"\"Extract MD5 hash from a download URL\"\"\"\n        parts = url.partition(\"?\")[0].split(\"/\")\n        parts.reverse()\n\n        for part in parts:\n            if len(part) == 32:\n                return part\n        return \"\"\n\n    def _build_url(self, endpoint, sort, query):\n        return (\n            f\"https://www.patreon.com/api/{endpoint}\"\n\n            \"?include=campaign,access_rules,attachments,attachments_media,\"\n            \"audio,images,media,native_video_insights,poll.choices,\"\n            \"poll.current_user_responses.user,\"\n            \"poll.current_user_responses.choice,\"\n            \"poll.current_user_responses.poll,\"\n            \"user,user_defined_tags,ti_checks\"\n\n            \"&fields[campaign]=currency,show_audio_post_download_links,\"\n            \"avatar_photo_url,avatar_photo_image_urls,earnings_visibility,\"\n            \"is_nsfw,is_monthly,name,url\"\n\n            \"&fields[post]=change_visibility_at,comment_count,commenter_count,\"\n            \"content,content_json_string,current_user_can_comment,\"\n            \"current_user_can_delete,current_user_can_view,\"\n            \"current_user_has_liked,embed,image,insights_last_updated_at,\"\n            \"is_paid,like_count,meta_image_url,min_cents_pledged_to_view,\"\n            \"post_file,post_metadata,published_at,patreon_url,post_type,\"\n            \"pledge_url,preview_asset_type,thumbnail,thumbnail_url,\"\n            \"teaser_text,title,upgrade_url,url,was_posted_by_campaign_owner,\"\n            \"has_ti_violation,moderation_status,\"\n            \"post_level_suspension_removal_date,pls_one_liners_by_category,\"\n            \"video_preview,view_count\"\n\n            \"&fields[post_tag]=tag_type,value\"\n            \"&fields[user]=image_url,full_name,url\"\n            \"&fields[access_rule]=access_rule_type,amount_cents\"\n            \"&fields[media]=id,image_urls,download_url,metadata,file_name\"\n            \"&fields[native_video_insights]=average_view_duration,\"\n            \"average_view_pct,has_preview,id,last_updated_at,num_views,\"\n            \"preview_views,video_duration\"\n\n            f\"&page[cursor]={self._init_cursor()}\"\n            f\"{query}{self._order(sort)}\"\n\n            \"&json-api-version=1.0\"\n        )\n\n    def _order(self, sort):\n        if order := self.config(\"order-posts\"):\n            if order in {\"d\", \"desc\"}:\n                order = \"-published_at\"\n            elif order in {\"a\", \"asc\", \"r\", \"reverse\"}:\n                order = \"published_at\"\n            return \"&sort=\" + order\n        return \"&sort=\" + sort if sort else \"\"\n\n    def _build_file_generators(self, filetypes):\n        if filetypes is None:\n            return (self._images, self._image_large,\n                    self._attachments, self._postfile, self._content)\n        genmap = {\n            \"images\"     : self._images,\n            \"image_large\": self._image_large,\n            \"attachments\": self._attachments,\n            \"postfile\"   : self._postfile,\n            \"content\"    : self._content,\n        }\n        if isinstance(filetypes, str):\n            filetypes = filetypes.split(\",\")\n        return [genmap[ft] for ft in filetypes]\n\n    def _extract_bootstrap(self, page):\n        try:\n            data = self._extract_nextdata(page)\n            env = data[\"props\"][\"pageProps\"][\"bootstrapEnvelope\"]\n            return env.get(\"pageBootstrap\") or env[\"bootstrap\"]\n        except Exception as exc:\n            self.log.debug(\"%s: %s\", exc.__class__.__name__, exc)\n\n        bootstrap = text.extr(\n            page, 'window.patreon = {\"bootstrap\":', '},\"apiServer\"')\n        if bootstrap:\n            return util.json_loads(bootstrap + \"}\")\n\n        bootstrap = text.extr(\n            page,\n            'window.patreon = wrapInProxy({\"bootstrap\":',\n            '},\"apiServer\"')\n        if bootstrap:\n            return util.json_loads(bootstrap + \"}\")\n\n        if bootstrap := text.extr(page, \"window.patreon.bootstrap,\", \"});\"):\n            return util.json_loads(bootstrap + \"}\")\n\n        if data := text.extr(page, \"window.patreon = {\", \"};\\n\"):\n            try:\n                return util.json_loads(f\"{{{data}}}\")[\"bootstrap\"]\n            except Exception:\n                pass\n\n        raise self.exc.AbortExtraction(\"Unable to extract bootstrap data\")\n\n\nclass PatreonCollectionExtractor(PatreonExtractor):\n    \"\"\"Extractor for a patreon collection\"\"\"\n    subcategory = \"collection\"\n    directory_fmt = (\"{category}\", \"{creator[full_name]}\",\n                     \"Collections\", \"{collection[title]} ({collection[id]})\")\n    pattern = r\"(?:https?://)?(?:www\\.)?patreon\\.com/collection/(\\d+)\"\n    example = \"https://www.patreon.com/collection/12345\"\n\n    def posts(self):\n        collection_id = self.groups[0]\n        self.kwdict[\"collection\"] = collection = \\\n            self._collection(collection_id)\n        campaign_id = text.extr(\n            collection[\"thumbnail\"][\"url\"], \"/campaign/\", \"/\")\n\n        url = self._build_url(\"posts\", \"collection_order\", (\n            # patreon returns '400 Bad Request' without campaign_id filter\n            f\"&filter[campaign_id]={campaign_id}\"\n            \"&filter[contains_exclusive_posts]=true\"\n            \"&filter[is_draft]=false\"\n            f\"&filter[collection_id]={collection_id}\"\n            \"&filter[include_drops]=true\"\n        ))\n        return self._pagination(url)\n\n    def _order(self, sort):\n        if order := self.config(\"order-posts\"):\n            if order in {\"a\", \"asc\"}:\n                order = \"collection_order\"\n            elif order in {\"d\", \"desc\", \"r\", \"reverse\"}:\n                # \"-collection_order\" results in a '400 Bad Request' error\n                order = \"-published_at\"\n            return \"&sort=\" + order\n        return \"&sort=\" + sort if sort else \"\"\n\n\nclass PatreonCreatorExtractor(PatreonExtractor):\n    \"\"\"Extractor for a creator's works\"\"\"\n    subcategory = \"creator\"\n    pattern = (r\"(?:https?://)?(?:www\\.)?patreon\\.com\"\n               r\"/(?!(?:home|create|login|signup|search|posts|messages)\"\n               r\"(?:$|[/?#]))\"\n               r\"(?:profile/creators|(?:cw?/)?([^/?#]+)(?:/posts)?)\"\n               r\"/?(?:\\?([^#]+))?\")\n    example = \"https://www.patreon.com/c/USER\"\n\n    def posts(self):\n        creator, query = self.groups\n\n        params = text.parse_query(query)\n        campaign_id = self._get_campaign_id(creator, params)\n        self.log.debug(\"campaign_id: %s\", campaign_id)\n\n        url = self._build_url(\"posts\", params.get(\"sort\", \"-published_at\"), (\n            f\"&filter[campaign_id]={campaign_id}\"\n            \"&filter[contains_exclusive_posts]=true\"\n            \"&filter[is_draft]=false\"\n            f\"{self._get_filters(params)}\"\n        ))\n        return self._pagination(url)\n\n    def _get_campaign_id(self, creator, params):\n        if creator and creator.startswith(\"id:\"):\n            return creator[3:]\n\n        if campaign_id := params.get(\"c\") or params.get(\"campaign_id\"):\n            return campaign_id\n\n        if user_id := params.get(\"u\"):\n            url = f\"{self.root}/user?u={user_id}\"\n        else:\n            url = f\"{self.root}/{creator}\"\n        page = self.request(url, notfound=True).text\n\n        try:\n            data = None\n            data = self._extract_bootstrap(page)\n            return data[\"campaign\"][\"data\"][\"id\"]\n        except self.exc.ControlException:\n            pass\n        except Exception as exc:\n            if data:\n                self.log.debug(data)\n            raise self.exc.AbortExtraction(\n                f\"Unable to extract campaign ID \"\n                f\"({exc.__class__.__name__}: {exc})\")\n\n        # Next.js 13\n        if cid := text.extr(\n                page, r'{\\\"value\\\":{\\\"campaign\\\":{\\\"data\\\":{\\\"id\\\":\\\"', '\\\\\"'):\n            return cid\n\n        raise self.exc.AbortExtraction(\"Failed to extract campaign ID\")\n\n    def _get_filters(self, params):\n        return \"\".join(\n            f\"&filter[{key[8:]}={text.escape(value)}\"\n            for key, value in params.items()\n            if key.startswith(\"filters[\")\n        )\n\n\nclass PatreonUserExtractor(PatreonExtractor):\n    \"\"\"Extractor for media from creators supported by you\"\"\"\n    subcategory = \"user\"\n    pattern = r\"(?:https?://)?(?:www\\.)?patreon\\.com/home$\"\n    example = \"https://www.patreon.com/home\"\n\n    def skip_date(self, date):\n        self._cursor = cursor = dt.from_ts(date).isoformat()\n        self._init_cursor = lambda: cursor\n        return True\n\n    def posts(self):\n        if date_max := self._get_date_min_max(None, None)[1]:\n            self._cursor = cursor = dt.from_ts(date_max).isoformat()\n            self._init_cursor = lambda: cursor\n\n        url = self._build_url(\"stream\", None, (\n            \"&filter[is_following]=true\"\n            \"&json-api-use-default-includes=false\"\n        ))\n        return self._pagination(url)\n\n\nclass PatreonPostExtractor(PatreonExtractor):\n    \"\"\"Extractor for media from a single post\"\"\"\n    subcategory = \"post\"\n    pattern = r\"(?:https?://)?(?:www\\.)?patreon\\.com/posts/([^/?#]+)\"\n    example = \"https://www.patreon.com/posts/TITLE-12345\"\n\n    def posts(self):\n        if not self._logged_in and \\\n                self.session.headers[\"User-Agent\"] is self.useragent:\n            # enable `.m3u8` manifest downloads\n            headers = {\"User-Agent\":\n                       \"Patreon/14.2.1 (Android; Android 11; Scale/2.10)\"}\n        else:\n            headers = None\n\n        url = f\"{self.root}/posts/{self.groups[0]}\"\n        page = self.request(url, headers=headers, notfound=True).text\n        bootstrap = self._extract_bootstrap(page)\n\n        try:\n            post = bootstrap[\"post\"]\n        except KeyError:\n            self.log.debug(bootstrap)\n            if bootstrap.get(\"campaignDisciplinaryStatus\") == \"suspended\":\n                self.log.warning(\"Account suspended\")\n            return ()\n\n        included = self._transform(post[\"included\"])\n        return (self._process(post[\"data\"], included),)\n"
  },
  {
    "path": "gallery_dl/extractor/pexels.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2025-2026 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://pexels.com/\"\"\"\n\nfrom .common import Extractor, Message\nfrom .. import text\n\nBASE_PATTERN = r\"(?:https?://)?(?:www\\.)?pexels\\.com\"\n\n\nclass PexelsExtractor(Extractor):\n    \"\"\"Base class for pexels extractors\"\"\"\n    category = \"pexels\"\n    root = \"https://www.pexels.com\"\n    archive_fmt = \"{id}\"\n    request_interval = (1.0, 2.0)\n    request_interval_min = 0.5\n\n    def _init(self):\n        self.api = PexelsAPI(self)\n\n    def items(self):\n        metadata = self.metadata()\n\n        for post in self.posts():\n            if \"attributes\" in post:\n                attr = post\n                post = post[\"attributes\"]\n                post[\"type\"] = attr[\"type\"]\n\n            post.update(metadata)\n            post[\"date\"] = self.parse_datetime_iso(post[\"created_at\"][:-5])\n\n            if \"image\" in post:\n                url, _, query = post[\"image\"][\"download_link\"].partition(\"?\")\n                name = text.extr(query, \"&dl=\", \"&\")\n            elif \"video\" in post:\n                video = post[\"video\"]\n                name = video[\"src\"]\n                url = video[\"download_link\"]\n            else:\n                self.log.warning(\"%s: Unsupported post type\", post.get(\"id\"))\n                continue\n\n            yield Message.Directory, \"\", post\n            yield Message.Url, url, text.nameext_from_url(name, post)\n\n    def posts(self):\n        return ()\n\n    def metadata(self):\n        return {}\n\n\nclass PexelsCollectionExtractor(PexelsExtractor):\n    \"\"\"Extractor for a pexels.com collection\"\"\"\n    subcategory = \"collection\"\n    directory_fmt = (\"{category}\", \"Collections\", \"{collection}\")\n    pattern = BASE_PATTERN + r\"/collections/((?:[^/?#]*-)?(\\w+))\"\n    example = \"https://www.pexels.com/collections/SLUG-a1b2c3/\"\n\n    def metadata(self):\n        cname, cid = self.groups\n        return {\"collection\": cname, \"collection_id\": cid}\n\n    def posts(self):\n        return self.api.collections_media(self.groups[1])\n\n\nclass PexelsSearchExtractor(PexelsExtractor):\n    \"\"\"Extractor for pexels.com search results\"\"\"\n    subcategory = \"search\"\n    directory_fmt = (\"{category}\", \"Searches\", \"{search_tags}\")\n    pattern = BASE_PATTERN + r\"/search/([^/?#]+)\"\n    example = \"https://www.pexels.com/search/QUERY/\"\n\n    def metadata(self):\n        return {\"search_tags\": self.groups[0]}\n\n    def posts(self):\n        return self.api.search_photos(self.groups[0])\n\n\nclass PexelsUserExtractor(PexelsExtractor):\n    \"\"\"Extractor for pexels.com user galleries\"\"\"\n    subcategory = \"user\"\n    directory_fmt = (\"{category}\", \"@{user[slug]}\")\n    pattern = BASE_PATTERN + r\"/(@(?:(?:[^/?#]*-)?(\\d+)|[^/?#]+))\"\n    example = \"https://www.pexels.com/@USER-12345/\"\n\n    def posts(self):\n        return self.api.users_media_recent(self.groups[1] or self.groups[0])\n\n\nclass PexelsImageExtractor(PexelsExtractor):\n    subcategory = \"image\"\n    pattern = BASE_PATTERN + r\"/photo/((?:[^/?#]*-)?\\d+)\"\n    example = \"https://www.pexels.com/photo/SLUG-12345/\"\n\n    def posts(self):\n        url = f\"{self.root}/photo/{self.groups[0]}/\"\n        page = self.request(url).text\n        return (self._extract_nextdata(page)[\"props\"][\"pageProps\"][\"medium\"],)\n\n\nclass PexelsAPI():\n    \"\"\"Interface for the Pexels Web API\"\"\"\n\n    def __init__(self, extractor):\n        self.extractor = extractor\n        self.root = \"https://www.pexels.com/en-us/api\"\n        self.headers = {\n            \"Accept\"        : \"*/*\",\n            \"Content-Type\"  : \"application/json\",\n            \"secret-key\"    : \"H2jk9uKnhRmL6WPwh89zBezWvr\",\n            \"Authorization\" : \"\",\n            \"X-Forwarded-CF-Connecting-IP\" : \"\",\n            \"X-Forwarded-HTTP_CF_IPCOUNTRY\": \"\",\n            \"X-Forwarded-CF-IPRegionCode\"  : \"\",\n            \"X-Client-Type\" : \"react\",\n            \"Sec-Fetch-Dest\": \"empty\",\n            \"Sec-Fetch-Mode\": \"cors\",\n            \"Sec-Fetch-Site\": \"same-origin\",\n            \"Priority\"      : \"u=4\",\n        }\n\n    def collections_media(self, collection_id):\n        endpoint = f\"/v3/collections/{collection_id}/media\"\n        params = {\n            \"page\"    : \"1\",\n            \"per_page\": \"24\",\n        }\n        return self._pagination(endpoint, params)\n\n    def search_photos(self, query):\n        endpoint = \"/v3/search/photos\"\n        params = {\n            \"query\"      : query,\n            \"page\"       : \"1\",\n            \"per_page\"   : \"24\",\n            \"orientation\": \"all\",\n            \"size\"       : \"all\",\n            \"color\"      : \"all\",\n            \"sort\"       : \"popular\",\n        }\n        return self._pagination(endpoint, params)\n\n    def users_media_recent(self, user_id):\n        endpoint = f\"/v3/users/{user_id}/media/recent\"\n        params = {\n            \"page\"    : \"1\",\n            \"per_page\": \"24\",\n        }\n        return self._pagination(endpoint, params)\n\n    def _call(self, endpoint, params):\n        url = self.root + endpoint\n\n        while True:\n            response = self.extractor.request(\n                url, params=params, headers=self.headers, fatal=None)\n\n            if response.status_code < 300:\n                return response.json()\n\n            elif response.status_code == 429:\n                self.extractor.wait(seconds=600)\n\n            else:\n                self.extractor.log.debug(response.text)\n                raise self.extractor.exc.AbortExtraction(\"API request failed\")\n\n    def _pagination(self, endpoint, params):\n        while True:\n            data = self._call(endpoint, params)\n\n            yield from data[\"data\"]\n\n            pagination = data[\"pagination\"]\n            if pagination[\"current_page\"] >= pagination[\"total_pages\"]:\n                return\n            params[\"page\"] = pagination[\"current_page\"] + 1\n"
  },
  {
    "path": "gallery_dl/extractor/philomena.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2021-2026 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for Philomena sites\"\"\"\n\nfrom .booru import BooruExtractor\nfrom .. import text\n\n\nclass PhilomenaExtractor(BooruExtractor):\n    \"\"\"Base class for philomena extractors\"\"\"\n    basecategory = \"philomena\"\n    filename_fmt = \"{filename}.{extension}\"\n    archive_fmt = \"{id}\"\n    request_interval = (0.5, 1.5)\n    page_start = 1\n    per_page = 50\n\n    def _init(self):\n        self.api = PhilomenaAPI(self)\n        self.svg = self.config(\"svg\", True)\n\n    def _file_url(self, post):\n        try:\n            url = post[\"representations\"][\"full\"]\n        except Exception:\n            url = post[\"view_url\"]\n\n        if self.svg and post[\"format\"] == \"svg\":\n            return url.rpartition(\".\")[0] + \".svg\"\n        return url\n\n    def _prepare(self, post):\n        post[\"date\"] = self.parse_datetime_iso(post[\"created_at\"][:19])\n\n\nBASE_PATTERN = PhilomenaExtractor.update({\n    \"derpibooru\": {\n        \"root\": \"https://derpibooru.org\",\n        \"pattern\": r\"(?:www\\.)?derpibooru\\.org\",\n        \"filter_id\": \"56027\",\n    },\n    \"ponybooru\": {\n        \"root\": \"https://ponybooru.org\",\n        \"pattern\": r\"(?:www\\.)?ponybooru\\.org\",\n        \"filter_id\": \"3\",\n    },\n    \"furbooru\": {\n        \"root\": \"https://furbooru.org\",\n        \"pattern\": r\"furbooru\\.org\",\n        \"filter_id\": \"2\",\n    },\n})\n\n\nclass PhilomenaPostExtractor(PhilomenaExtractor):\n    \"\"\"Extractor for single posts on a Philomena booru\"\"\"\n    subcategory = \"post\"\n    pattern = BASE_PATTERN + r\"/(?:images/)?(\\d+)\"\n    example = \"https://derpibooru.org/images/12345\"\n\n    def posts(self):\n        return (self.api.image(self.groups[-1]),)\n\n\nclass PhilomenaSearchExtractor(PhilomenaExtractor):\n    \"\"\"Extractor for Philomena search results\"\"\"\n    subcategory = \"search\"\n    directory_fmt = (\"{category}\", \"{search_tags}\")\n    pattern = BASE_PATTERN + r\"/(?:search/?\\?([^#]+)|tags/([^/?#]+))\"\n    example = \"https://derpibooru.org/search?q=QUERY\"\n\n    def __init__(self, match):\n        PhilomenaExtractor.__init__(self, match)\n\n        if q := self.groups[-1]:\n            q = q.replace(\"+\", \" \")\n            for old, new in (\n                (\"-colon-\"  , \":\"),\n                (\"-dash-\"   , \"-\"),\n                (\"-dot-\"    , \".\"),\n                (\"-plus-\"   , \"+\"),\n                (\"-fwslash-\", \"/\"),\n                (\"-bwslash-\", \"\\\\\"),\n            ):\n                if old in q:\n                    q = q.replace(old, new)\n            self.params = {\"q\": text.unquote(text.unquote(q))}\n        else:\n            self.params = text.parse_query(self.groups[-2])\n\n    def metadata(self):\n        return {\"search_tags\": self.params.get(\"q\", \"\")}\n\n    def posts(self):\n        return self.api.search(self.params)\n\n\nclass PhilomenaGalleryExtractor(PhilomenaExtractor):\n    \"\"\"Extractor for Philomena galleries\"\"\"\n    subcategory = \"gallery\"\n    directory_fmt = (\"{category}\", \"galleries\",\n                     \"{gallery[id]} {gallery[title]}\")\n    pattern = BASE_PATTERN + r\"/galleries/(\\d+)\"\n    example = \"https://derpibooru.org/galleries/12345\"\n\n    def metadata(self):\n        try:\n            return {\"gallery\": self.api.gallery(self.groups[-1])}\n        except IndexError:\n            raise self.exc.NotFoundError(\"gallery\")\n\n    def posts(self):\n        gallery_id = \"gallery_id:\" + self.groups[-1]\n        params = {\"sd\": \"desc\", \"sf\": gallery_id, \"q\": gallery_id}\n        return self.api.search(params)\n\n\nclass PhilomenaAPI():\n    \"\"\"Interface for the Philomena API\n\n    https://www.derpibooru.org/pages/api\n    \"\"\"\n\n    def __init__(self, extractor):\n        self.extractor = extractor\n        self.root = extractor.root + \"/api\"\n\n    def gallery(self, gallery_id):\n        endpoint = \"/v1/json/search/galleries\"\n        params = {\"q\": \"id:\" + gallery_id}\n        return self._call(endpoint, params)[\"galleries\"][0]\n\n    def image(self, image_id):\n        endpoint = \"/v1/json/images/\" + image_id\n        return self._call(endpoint)[\"image\"]\n\n    def search(self, params):\n        endpoint = \"/v1/json/search/images\"\n        return self._pagination(endpoint, params)\n\n    def _call(self, endpoint, params=None):\n        url = self.root + endpoint\n\n        while True:\n            response = self.extractor.request(url, params=params, fatal=None)\n\n            if response.status_code < 400:\n                return response.json()\n\n            if response.status_code == 429:\n                self.extractor.wait(seconds=600)\n                continue\n\n            # error\n            self.extractor.log.debug(response.content)\n            raise self.extractor.exc.HttpError(\"\", response)\n\n    def _pagination(self, endpoint, params):\n        extr = self.extractor\n\n        if api_key := extr.config(\"api-key\"):\n            params[\"key\"] = api_key\n\n        if filter_id := extr.config(\"filter\"):\n            params[\"filter_id\"] = filter_id\n        elif not api_key:\n            params[\"filter_id\"] = extr.config_instance(\"filter_id\") or \"2\"\n\n        params[\"page\"] = extr.page_start\n        params[\"per_page\"] = extr.per_page\n\n        while True:\n            data = self._call(endpoint, params)\n            yield from data[\"images\"]\n\n            if len(data[\"images\"]) < extr.per_page:\n                return\n            params[\"page\"] += 1\n"
  },
  {
    "path": "gallery_dl/extractor/pholder.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://pholder.com/\"\"\"\n\nfrom .common import Extractor, Message\nfrom .. import text, util\n\nBASE_PATTERN = r\"(?:https?://)?(?:www\\.)?pholder\\.com\"\n\n\ndef _thumb_resolution(thumbnail):\n    try:\n        return int(thumbnail[\"width\"]) * int(thumbnail[\"height\"])\n    except Exception:\n        return 0\n\n\nclass PholderExtractor(Extractor):\n    \"\"\"Base class for pholder extractors\"\"\"\n    category = \"pholder\"\n    root = \"https://pholder.com\"\n    directory_fmt = (\"{category}\", \"{subredditTitle}\")\n    filename_fmt = \"{id}{gallery_id:? / /}{title:? //[:225]}.{extension}\"\n    archive_fmt = \"{id}_{filename}_{gallery_id:? / /}\"\n    request_interval = (2.0, 4.0)\n    referer = False\n\n    def _init(self):\n        if value := self.cache(util.noop, \"pholder-s\", _exp=86400, _mem=False):\n            self.cookies.set(\"_bcs\", value, domain=self.root[8:])\n        if value := self.cache(util.noop, \"pholder-c\", _exp=86400, _mem=False):\n            self.cookies.set(\"_bcc\", value, domain=self.root[8:])\n\n    def _parse_window_data(self, html):\n        # sometimes, window.data content is split across multiple script\n        # blocks.\n        tag_prefix = len(\"window_data = \")\n        window_data_content = \"\"\n        split_data = False\n\n        for tag in text.split_html(html):\n            if tag.startswith(\"window.data = \"):\n                try:\n                    return util.json_loads(tag[tag_prefix:])\n                except ValueError:\n                    split_data = True\n\n            if split_data:\n                try:\n                    window_data_content += tag\n                    return util.json_loads(window_data_content[tag_prefix:])\n                except ValueError:\n                    pass\n\n        raise self.exc.AbortExtraction(\"Could not locate window.data JSON.\")\n\n    def _solve_challenge(self, html):\n        extr = text.extract_from(html)\n        ts = text.parse_int(extr(\" ts=\", \";\"))\n        ip = text.parse_int(extr(\" ip=\", \";\"))\n        vl = text.parse_int(extr(\"^(\", \")\"))\n\n        # '& 0xFFFFFFFF' to replicate JS behavior\n        n = ts ^ ip ^ vl\n        n ^= (n & 0xFFFFFFFF) >> 16\n        n = (n ^ ((n << 7) & 0xFFFFFFFF)) & 0xFFFFFFFF\n        n ^= (n & 0xFFFFFFFF) >> 3\n        n = (n ^ ((n << 17) & 0xFFFFFFFF)) & 0xFFFFFFFF\n        n ^= (n & 0xFFFFFFFF) >> 11\n\n        ot = util.b36encode((ts ^ ip) & 0xFFFFFFFF)\n        return f\"{ot}.{util.b36encode(n)}\"\n\n    def _posts(self, page_url):\n        params = {\"page\": 1}\n        while True:\n            response = self.request(page_url, params=params)\n            if value := response.cookies.get(\"_bcs\"):\n                self.cache_update(util.noop, \"pholder-s\", value, _exp=86400)\n            html = response.text\n\n            if len(html) < 4096:\n                value = self._solve_challenge(html)\n                self.cache_update(util.noop, \"pholder-c\", value, _exp=86400)\n                self.cookies.set(\"_bcc\", value, domain=self.root[8:])\n                continue\n            window_data = self._parse_window_data(html)\n\n            for item in window_data[\"media\"]:\n                data = item[\"_source\"]\n                data[\"id\"] = item[\"_id\"]\n                data[\"date\"] = self.parse_timestamp(data.get(\"submitted_utc\"))\n                data[\"subredditTitle\"] = data.pop(\"sub\", \"\")\n\n                if \":\" in data[\"id\"]:\n                    # this is a gallery\n                    # (can also see from item[\"is_gallery\"])\n                    # pholder does not preserver gallery order, but assigns\n                    # each image a sub-id.\n                    data[\"id\"], _, data[\"gallery_id\"] = \\\n                        data[\"id\"].partition(\":\")\n                else:\n                    data[\"gallery_id\"] = \"\"\n\n                yield Message.Directory, \"\", data\n\n                for thumb in sorted(\n                        data[\"thumbnails\"],\n                        key=lambda e: _thumb_resolution(e), reverse=True):\n                    # try to use highest-resolution URLs from thumbnails first.\n                    url = thumb[\"url\"]\n                    if url.rindex(\":\") > url.index(\":\"):\n                        # sometimes, thumbnail image URLs end with \":large\" or\n                        # \":small\", so we have to strip out any trailing\n                        # \":word\" bits.\n                        url = url.rpartition(\":\")[0]\n                    yield Message.Url, url, text.nameext_from_url(url, data)\n                    break\n                else:\n                    # Fallback to origin\n                    url = data[\"origin\"]\n                    yield Message.Url, url, text.nameext_from_url(url, data)\n\n            if len(window_data[\"media\"]) < 150:\n                break\n\n            params[\"page\"] += 1\n\n    def items(self):\n        url = f\"{self.root}/{self.groups[0]}\"\n        return self._posts(url)\n\n\nclass PholderSubredditExtractor(PholderExtractor):\n    \"\"\"Extractor for media from pholder-stored posts for a subreddit\"\"\"\n    subcategory = \"subreddit\"\n    pattern = BASE_PATTERN + r\"(/r/([^/?#]+))(?:/?\\?([^#]+))?\"\n    example = \"https://pholder.com/r/SUBREDDIT\"\n\n\nclass PholderUserExtractor(PholderExtractor):\n    \"\"\"Extractor for URLs from pholder-stored posts for a reddit user\"\"\"\n    subcategory = \"user\"\n    pattern = BASE_PATTERN + r\"(/u/[^/?#]+)(?:/?\\?([^#]+))?\"\n    example = \"https://www.pholder.com/u/USER\"\n\n\nclass PholderSearchExtractor(PholderExtractor):\n    \"\"\"Extractor for URLs from pholder-stored posts for a search\"\"\"\n    subcategory = \"search\"\n    pattern = BASE_PATTERN + r\"/(.*)\"\n    example = \"https://www.pholder.com/SEARCH\"\n"
  },
  {
    "path": "gallery_dl/extractor/photovogue.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://www.vogue.com/photovogue/\"\"\"\n\nfrom .common import Extractor, Message\nfrom .. import text\n\nBASE_PATTERN = r\"(?:https?://)?(?:www\\.)?vogue\\.com/photovogue\"\n\n\nclass PhotovogueUserExtractor(Extractor):\n    category = \"photovogue\"\n    subcategory = \"user\"\n    directory_fmt = (\"{category}\", \"{photographer[id]} {photographer[name]}\")\n    filename_fmt = \"{id} {title}.{extension}\"\n    archive_fmt = \"{id}\"\n    pattern = BASE_PATTERN + r\"/photographers/(\\d+)\"\n    example = \"https://www.vogue.com/photovogue/photographers/12345\"\n\n    def __init__(self, match):\n        Extractor.__init__(self, match)\n        self.user_id = match[1]\n\n    def items(self):\n        for photo in self.photos():\n            url = photo[\"gallery_image\"]\n            photo[\"title\"] = photo[\"title\"].strip()\n            photo[\"date\"] = self.parse_datetime_iso(photo[\"date\"])\n\n            yield Message.Directory, \"\", photo\n            yield Message.Url, url, text.nameext_from_url(url, photo)\n\n    def photos(self):\n        url = \"https://api.vogue.com/production/photos\"\n        params = {\n            \"count\": \"50\",\n            \"order_by\": \"DESC\",\n            \"page\": 0,\n            \"photographer_id\": self.user_id,\n        }\n\n        while True:\n            data = self.request_json(url, params=params)\n            yield from data[\"items\"]\n\n            if not data[\"has_next\"]:\n                break\n            params[\"page\"] += 1\n"
  },
  {
    "path": "gallery_dl/extractor/picarto.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2021-2025 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://picarto.tv/\"\"\"\n\nfrom .common import Extractor, Message\nfrom .. import text\n\n\nclass PicartoGalleryExtractor(Extractor):\n    \"\"\"Extractor for picarto galleries\"\"\"\n    category = \"picarto\"\n    subcategory = \"gallery\"\n    root = \"https://picarto.tv\"\n    directory_fmt = (\"{category}\", \"{channel[name]}\")\n    filename_fmt = \"{id} {title}.{extension}\"\n    archive_fmt = \"{id}\"\n    pattern = r\"(?:https?://)?picarto\\.tv/([^/?#]+)/gallery\"\n    example = \"https://picarto.tv/USER/gallery/TITLE/\"\n\n    def __init__(self, match):\n        Extractor.__init__(self, match)\n        self.username = match[1]\n\n    def items(self):\n        for post in self.posts():\n            post[\"date\"] = self.parse_datetime_iso(post[\"created_at\"])\n            variations = post.pop(\"variations\", ())\n            yield Message.Directory, \"\", post\n\n            image = post[\"default_image\"]\n            if not image:\n                continue\n            url = \"https://images.picarto.tv/gallery/\" + image[\"name\"]\n            text.nameext_from_url(url, post)\n            yield Message.Url, url, post\n\n            for variation in variations:\n                post.update(variation)\n                image = post[\"default_image\"]\n                url = \"https://images.picarto.tv/gallery/\" + image[\"name\"]\n                text.nameext_from_url(url, post)\n                yield Message.Url, url, post\n\n    def posts(self):\n        url = \"https://ptvintern.picarto.tv/api/channel-gallery\"\n        params = {\n            \"first\": \"30\",\n            \"page\": 1,\n            \"filter_params[album_id]\": \"\",\n            \"filter_params[channel_name]\": self.username,\n            \"filter_params[q]\": \"\",\n            \"filter_params[visibility]\": \"\",\n            \"order_by[field]\": \"published_at\",\n            \"order_by[order]\": \"DESC\",\n        }\n\n        while True:\n            posts = self.request_json(url, params=params)\n            if not posts:\n                return\n            yield from posts\n            params[\"page\"] += 1\n"
  },
  {
    "path": "gallery_dl/extractor/picazor.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://picazor.com/\"\"\"\n\nfrom .common import Extractor, Message\nfrom .. import text\n\n\nclass PicazorUserExtractor(Extractor):\n    \"\"\"Extractor for picazor users\"\"\"\n    category = \"picazor\"\n    subcategory = \"user\"\n    root = \"https://picazor.com\"\n    browser = \"firefox\"\n    directory_fmt = (\"{category}\", \"{user}\")\n    filename_fmt = \"{id}_{num:>03}.{extension}\"\n    archive_fmt = \"{id}_{num}\"\n    pattern = r\"(?:https?://)?(?:www\\.)?picazor\\.com/[a-z]{2}/([^/?#]+)\"\n    example = \"https://picazor.com/en/USERNAME\"\n\n    def items(self):\n        user = self.groups[0]\n        first = True\n\n        url = f\"{self.root}/api/files/{user}/sfiles\"\n        params = {\"page\": 1}\n        headers = {\"Referer\": f\"{self.root}/en/{user}\"}\n\n        while True:\n            data = self.request_json(url, params=params, headers=headers)\n            if not data:\n                break\n\n            for item in data:\n                path = item.get(\"path\")\n                if not path:\n                    continue\n\n                if first:\n                    first = False\n                    self.kwdict[\"user\"] = user\n                    self.kwdict[\"count\"] = item.get(\"order\")\n                    yield Message.Directory, \"\", {\n                        \"subject\": item.get(\"subject\"),\n                        \"user\"   : user,\n                    }\n\n                item.pop(\"blurDataURL\", None)\n                item[\"num\"] = item[\"order\"]\n\n                file_url = self.root + path\n                text.nameext_from_url(file_url, item)\n                yield Message.Url, file_url, item\n\n            params[\"page\"] += 1\n"
  },
  {
    "path": "gallery_dl/extractor/pictoa.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://pictoa.com/\"\"\"\n\nfrom .common import Extractor, Message\nfrom .. import text\n\nBASE_PATTERN = r\"(?:https?://)?(?:[\\w]+\\.)?pictoa\\.com(?:\\.de)?\"\n\n\nclass PictoaExtractor(Extractor):\n    \"\"\"Base class for pictoa extractors\"\"\"\n    category = \"pictoa\"\n    root = \"https://pictoa.com\"\n    directory_fmt = (\"{category}\", \"{album_id} {album_title}\")\n    filename_fmt = \"{id}.{extension}\"\n    archive_fmt = \"{id}\"\n\n\nclass PictoaImageExtractor(PictoaExtractor):\n    \"\"\"Extractor for single images from pictoa.com\"\"\"\n    subcategory = \"image\"\n    pattern = BASE_PATTERN + r\"/albums/(?:[\\w-]+-)?(\\d+)/(\\d+)\"\n    example = \"https://www.pictoa.com/albums/NAME-12345/12345.html\"\n\n    def items(self):\n        album_id, image_id = self.groups\n\n        url = f\"{self.root}/albums/{album_id}/{image_id}.html\"\n        page = self.request(url).text\n        album_title = text.extr(page, 'property=\"og:title\" content=\"', '\"')\n        image_url = text.extr(page, 'property=\"og:image\" content=\"', '\"')\n\n        data = {\n            \"album_id\"   : album_id,\n            \"album_title\": album_title.rpartition(\" #\")[0],\n            \"id\"         : image_id,\n            \"url\"        : image_url,\n        }\n\n        text.nameext_from_url(image_url, data)\n        yield Message.Directory, \"\", data\n        yield Message.Url, image_url, data\n\n\nclass PictoaAlbumExtractor(PictoaExtractor):\n    \"\"\"Extractor for image albums from pictoa.com\"\"\"\n    subcategory = \"album\"\n    pattern = BASE_PATTERN + r\"/albums/(?:[\\w-]+-)?(\\d+).html\"\n    example = \"https://www.pictoa.com/albums/NAME-12345.html\"\n\n    def items(self):\n        album_id = self.groups[0]\n        url = f\"{self.root}/albums/{album_id}.html\"\n        page = self.request(url).text\n\n        album_data = {\n            \"album_id\"   : album_id,\n            \"album_title\": text.extr(page, \"<h1>\", \"<\"),\n            \"tags\"       : text.split_html(text.extr(\n                page, '<ol class=\"related-categories', '</ol>'))[1:],\n            \"_extractor\" : PictoaImageExtractor,\n        }\n\n        while True:\n            container = text.extr(page, '<main>', '<span id=\"flag\" >')\n            for url in text.extract_iter(\n                    container, '<a rel=\"nofollow\" href=\"', '\"'):\n                yield Message.Queue, url, album_data\n\n            url = text.extr(page, '<link rel=\"next\" href=\"', '\"')\n            if not url:\n                break\n            page = self.request(url).text\n"
  },
  {
    "path": "gallery_dl/extractor/piczel.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2018-2023 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://piczel.tv/\"\"\"\n\nfrom .common import Extractor, Message\nfrom .. import text\n\nBASE_PATTERN = r\"(?:https?://)?(?:www\\.)?piczel\\.tv\"\n\n\nclass PiczelExtractor(Extractor):\n    \"\"\"Base class for piczel extractors\"\"\"\n    category = \"piczel\"\n    directory_fmt = (\"{category}\", \"{user[username]}\")\n    filename_fmt = \"{category}_{id}_{title}_{num:>02}.{extension}\"\n    archive_fmt = \"{id}_{num}\"\n    root = \"https://piczel.tv\"\n    root_api = root\n\n    def items(self):\n        for post in self.posts():\n            post[\"tags\"] = [t[\"title\"] for t in post[\"tags\"] if t[\"title\"]]\n            post[\"date\"] = self.parse_datetime_iso(post[\"created_at\"])\n\n            if post[\"multi\"]:\n                images = post[\"images\"]\n                del post[\"images\"]\n                post[\"count\"] = len(images)\n                yield Message.Directory, \"\", post\n                for post[\"num\"], image in enumerate(images):\n                    if \"id\" in image:\n                        del image[\"id\"]\n                    post.update(image)\n                    url = post[\"image\"][\"url\"]\n                    yield Message.Url, url, text.nameext_from_url(url, post)\n\n            else:\n                post[\"count\"] = 1\n                yield Message.Directory, \"\", post\n                post[\"num\"] = 0\n                url = post[\"image\"][\"url\"]\n                yield Message.Url, url, text.nameext_from_url(url, post)\n\n    def posts(self):\n        \"\"\"Return an iterable with all relevant post objects\"\"\"\n\n    def _pagination(self, url, pnum=1):\n        params = {\"page\": pnum}\n\n        while True:\n            data = self.request_json(url, params=params)\n\n            yield from data[\"data\"]\n\n            params[\"page\"] = data[\"meta\"][\"next_page\"]\n            if not params[\"page\"]:\n                return\n\n\nclass PiczelUserExtractor(PiczelExtractor):\n    \"\"\"Extractor for all images from a user's gallery\"\"\"\n    subcategory = \"user\"\n    pattern = BASE_PATTERN + r\"/gallery/([^/?#]+)/?$\"\n    example = \"https://piczel.tv/gallery/USER\"\n\n    def posts(self):\n        url = f\"{self.root_api}/api/users/{self.groups[0]}/gallery\"\n        return self._pagination(url)\n\n\nclass PiczelFolderExtractor(PiczelExtractor):\n    \"\"\"Extractor for images inside a user's folder\"\"\"\n    subcategory = \"folder\"\n    directory_fmt = (\"{category}\", \"{user[username]}\", \"{folder[name]}\")\n    archive_fmt = \"f{folder[id]}_{id}_{num}\"\n    pattern = BASE_PATTERN + r\"/gallery/(?!image/)[^/?#]+/(\\d+)\"\n    example = \"https://piczel.tv/gallery/USER/12345\"\n\n    def posts(self):\n        url = f\"{self.root_api}/api/gallery/folder/{self.groups[0]}\"\n        return self._pagination(url)\n\n\nclass PiczelImageExtractor(PiczelExtractor):\n    \"\"\"Extractor for individual images\"\"\"\n    subcategory = \"image\"\n    pattern = BASE_PATTERN + r\"/gallery/image/(\\d+)\"\n    example = \"https://piczel.tv/gallery/image/12345\"\n\n    def posts(self):\n        url = f\"{self.root_api}/api/gallery/{self.groups[0]}\"\n        return (self.request_json(url),)\n"
  },
  {
    "path": "gallery_dl/extractor/pillowfort.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2021-2026 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://www.pillowfort.social/\"\"\"\n\nfrom .common import Extractor, Message\nfrom .. import text\n\nBASE_PATTERN = r\"(?:https?://)?www\\.pillowfort\\.social\"\n\n\nclass PillowfortExtractor(Extractor):\n    \"\"\"Base class for pillowfort extractors\"\"\"\n    category = \"pillowfort\"\n    root = \"https://www.pillowfort.social\"\n    directory_fmt = (\"{category}\", \"{username}\")\n    filename_fmt = (\"{post_id} {title|original_post[title]:?/ /}\"\n                    \"{num:>02}.{extension}\")\n    archive_fmt = \"{id}\"\n    cookies_domain = \"www.pillowfort.social\"\n\n    def __init__(self, match):\n        Extractor.__init__(self, match)\n        self.item = match[1]\n\n    def items(self):\n        self.login()\n        inline = self.config(\"inline\", True)\n        reblogs = self.config(\"reblogs\", False)\n        external = self.config(\"external\", False)\n\n        if inline:\n            inline = text.re(r'src=\"(https://img\\d+\\.pillowfort\\.social'\n                             r'/posts/[^\"]+)').findall\n\n        for post in self.posts():\n            if \"original_post\" in post and not reblogs:\n                continue\n\n            files = post.pop(\"media\")\n            if inline:\n                for url in inline(post[\"content\"]):\n                    files.append({\"url\": url})\n\n            post[\"date\"] = self.parse_datetime_iso(post[\"created_at\"])\n            post[\"post_id\"] = post.pop(\"id\")\n            post[\"count\"] = len(files)\n            yield Message.Directory, \"\", post\n\n            post[\"num\"] = 0\n            for file in files:\n                url = file[\"url\"] or file.get(\"b2_lg_url\")\n                if not url:\n                    continue\n\n                if file.get(\"embed_code\"):\n                    if not external:\n                        continue\n                    msgtype = Message.Queue\n                else:\n                    post[\"num\"] += 1\n                    msgtype = Message.Url\n\n                post.update(file)\n                text.nameext_from_url(url, post)\n                post[\"hash\"], _, post[\"filename\"] = \\\n                    post[\"filename\"].partition(\"_\")\n\n                if \"id\" not in file:\n                    post[\"id\"] = post[\"hash\"]\n                if \"created_at\" in file:\n                    post[\"date\"] = self.parse_datetime_iso(file[\"created_at\"])\n\n                yield msgtype, url, post\n\n    def login(self):\n        if self.cookies.get(\"_Pf_new_session\", domain=self.cookies_domain):\n            return\n        if self.cookies.get(\"remember_user_token\", domain=self.cookies_domain):\n            return\n\n        username, password = self._get_auth_info()\n        if username:\n            return self.cookies_update(self.cache(\n                self._login_impl, username, password,\n                _exp=14*86400, _mem=False))\n\n    def _login_impl(self, username, password):\n        self.log.info(\"Logging in as %s\", username)\n\n        url = \"https://www.pillowfort.social/users/sign_in\"\n        page = self.request(url).text\n        auth = text.extr(page, 'name=\"authenticity_token\" value=\"', '\"')\n\n        headers = {\"Origin\": self.root, \"Referer\": url}\n        data = {\n            \"utf8\"              : \"✓\",\n            \"authenticity_token\": auth,\n            \"user[email]\"       : username,\n            \"user[password]\"    : password,\n            \"user[remember_me]\" : \"1\",\n        }\n        response = self.request(url, method=\"POST\", headers=headers, data=data)\n\n        if not response.history:\n            raise self.exc.AuthenticationError()\n\n        return {\n            cookie.name: cookie.value\n            for cookie in response.history[0].cookies\n        }\n\n\nclass PillowfortPostExtractor(PillowfortExtractor):\n    \"\"\"Extractor for a single pillowfort post\"\"\"\n    subcategory = \"post\"\n    pattern = BASE_PATTERN + r\"/posts/(\\d+)\"\n    example = \"https://www.pillowfort.social/posts/12345\"\n\n    def posts(self):\n        url = f\"{self.root}/posts/{self.item}/json/\"\n        return (self.request_json(url),)\n\n\nclass PillowfortUserExtractor(PillowfortExtractor):\n    \"\"\"Extractor for all posts of a pillowfort user\"\"\"\n    subcategory = \"user\"\n    pattern = BASE_PATTERN + r\"/(?!posts/)([^/?#]+(?:/tagged/[^/?#]+)?)\"\n    example = \"https://www.pillowfort.social/USER\"\n\n    def posts(self):\n        url = f\"{self.root}/{self.item}/json/\"\n        params = {\"p\": 1}\n\n        while True:\n            posts = self.request_json(url, params=params)[\"posts\"]\n            yield from posts\n\n            if len(posts) < 20:\n                return\n            params[\"p\"] += 1\n"
  },
  {
    "path": "gallery_dl/extractor/pinterest.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2016-2026 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://www.pinterest.com/\"\"\"\n\nfrom .common import Extractor, Message\nfrom .. import text, util\nimport itertools\n\nBASE_PATTERN = r\"(?:https?://)?(?:\\w+\\.)?pinterest\\.[\\w.]+\"\n\n\nclass PinterestExtractor(Extractor):\n    \"\"\"Base class for pinterest extractors\"\"\"\n    category = \"pinterest\"\n    filename_fmt = \"{category}_{id}{media_id|page_id:?_//}.{extension}\"\n    archive_fmt = \"{id}{media_id|page_id}\"\n    root = \"https://www.pinterest.com\"\n\n    def _init(self):\n        domain = self.config(\"domain\")\n        if not domain or domain == \"auto\" :\n            self.root = text.root_from_url(self.url)\n        else:\n            self.root = text.ensure_http_scheme(domain)\n\n        self.api = PinterestAPI(self)\n        self.stories = self.config(\"stories\", True)\n        self.videos = self.config(\"videos\", True)\n\n    def items(self):\n        data = self.metadata()\n\n        for pin in self.pins():\n\n            if isinstance(pin, tuple):\n                url, data = pin\n                yield Message.Queue, url, data\n                continue\n\n            try:\n                files = self._extract_files(pin)\n            except Exception as exc:\n                self.log.traceback(exc)\n                self.log.warning(\n                    \"%s: Error when extracting download URLs (%s: %s)\",\n                    pin.get(\"id\"), exc.__class__.__name__, exc)\n                continue\n\n            pin.update(data)\n            pin[\"count\"] = len(files)\n\n            for key in (\n                \"description\",\n                \"closeup_description\",\n                \"closeup_unified_description\",\n            ):\n                if value := pin.get(key):\n                    pin[key] = value.strip()\n\n            yield Message.Directory, \"\", pin\n            for pin[\"num\"], file in enumerate(files, 1):\n                url = file[\"url\"]\n                text.nameext_from_url(url, pin)\n                pin.update(file)\n\n                if \"media_id\" not in file:\n                    pin[\"media_id\"] = \"\"\n                if \"page_id\" not in file:\n                    pin[\"page_id\"] = \"\"\n\n                if pin[\"extension\"] == \"m3u8\":\n                    url = \"ytdl:\" + url\n                    pin[\"_ytdl_manifest\"] = \"hls\"\n                    pin[\"extension\"] = \"mp4\"\n\n                yield Message.Url, url, pin\n\n    def metadata(self):\n        \"\"\"Return general metadata\"\"\"\n\n    def pins(self):\n        \"\"\"Return all relevant pin objects\"\"\"\n\n    def _extract_files(self, pin):\n        story_pin_data = pin.get(\"story_pin_data\")\n        if story_pin_data and self.stories:\n            return self._extract_story(pin, story_pin_data)\n\n        if carousel_data := pin.get(\"carousel_data\"):\n            return self._extract_carousel(pin, carousel_data)\n\n        videos = pin.get(\"videos\")\n        if videos and self.videos:\n            return (self._extract_video(videos),)\n\n        try:\n            return (pin[\"images\"][\"orig\"],)\n        except Exception:\n            self.log.debug(\"%s: No files found\", pin.get(\"id\"))\n            return ()\n\n    def _extract_story(self, pin, story):\n        files = []\n        story_id = story.get(\"id\")\n\n        for page in story[\"pages\"]:\n            page_id = page.get(\"id\")\n\n            for block in page[\"blocks\"]:\n                type = block.get(\"type\")\n\n                if type == \"story_pin_image_block\":\n                    if 1 == len(page[\"blocks\"]) == len(story[\"pages\"]):\n                        try:\n                            media = pin[\"images\"][\"orig\"]\n                        except Exception:\n                            media = self._extract_image(page, block)\n                    else:\n                        media = self._extract_image(page, block)\n\n                elif type == \"story_pin_video_block\" or \"video\" in block:\n                    video = block[\"video\"]\n                    media = self._extract_video(video)\n                    media[\"media_id\"] = video.get(\"id\") or \"\"\n\n                elif type == \"story_pin_music_block\" or \"audio\" in block:\n                    media = block[\"audio\"]\n                    media[\"url\"] = media[\"audio_url\"]\n                    media[\"media_id\"] = media.get(\"id\") or \"\"\n\n                elif type == \"story_pin_paragraph_block\":\n                    media = {\"url\": \"text:\" + block[\"text\"],\n                             \"extension\": \"txt\",\n                             \"media_id\": block.get(\"id\")}\n\n                elif type == \"story_pin_product_sticker_block\":\n                    continue\n\n                elif type == \"story_pin_static_sticker_block\":\n                    continue\n\n                else:\n                    self.log.warning(\"%s: Unsupported story block '%s'\",\n                                     pin.get(\"id\"), type)\n                    try:\n                        media = self._extract_image(page, block)\n                    except Exception:\n                        continue\n\n                media[\"story_id\"] = story_id\n                media[\"page_id\"] = page_id\n                files.append(media)\n\n        return files\n\n    def _extract_carousel(self, pin, carousel_data):\n        files = []\n        for slot in carousel_data[\"carousel_slots\"]:\n            size, image = next(iter(slot[\"images\"].items()))\n            slot[\"media_id\"] = slot.pop(\"id\")\n            slot[\"url\"] = image[\"url\"].replace(\n                \"/\" + size + \"/\", \"/originals/\", 1)\n            files.append(slot)\n        return files\n\n    def _extract_image(self, page, block):\n        sig = block.get(\"image_signature\") or page[\"image_signature\"]\n        url_base = (f\"https://i.pinimg.com/originals\"\n                    f\"/{sig[0:2]}/{sig[2:4]}/{sig[4:6]}/{sig}.\")\n        url_jpg = url_base + \"jpg\"\n        url_png = url_base + \"png\"\n        url_webp = url_base + \"webp\"\n\n        try:\n            media = block[\"image\"][\"images\"][\"originals\"]\n        except Exception:\n            media = {\"url\": url_jpg, \"_fallback\": (url_png, url_webp,)}\n\n        if media[\"url\"] == url_jpg:\n            media[\"_fallback\"] = (url_png, url_webp,)\n        else:\n            media[\"_fallback\"] = (url_jpg, url_png, url_webp,)\n        media[\"media_id\"] = sig\n\n        return media\n\n    def _extract_video(self, video):\n        video_formats = video[\"video_list\"]\n        for fmt in (\"V_HLSV4\", \"V_HLSV3_WEB\", \"V_HLSV3_MOBILE\"):\n            if fmt in video_formats:\n                media = video_formats[fmt]\n                break\n        else:\n            media = max(video_formats.values(),\n                        key=lambda x: x.get(\"width\", 0))\n        if \"V_720P\" in video_formats:\n            media[\"_fallback\"] = (video_formats[\"V_720P\"][\"url\"],)\n        return media\n\n\nclass PinterestUserExtractor(PinterestExtractor):\n    \"\"\"Extractor for a user's boards\"\"\"\n    subcategory = \"user\"\n    pattern = BASE_PATTERN + r\"/(?!pin/)([^/?#]+)(?:/_saved)?/?$\"\n    example = \"https://www.pinterest.com/USER/\"\n\n    def __init__(self, match):\n        PinterestExtractor.__init__(self, match)\n        self.user = text.unquote(match[1])\n\n    def items(self):\n        for board in self.api.boards(self.user):\n            if url := board.get(\"url\"):\n                board[\"_extractor\"] = PinterestBoardExtractor\n                yield Message.Queue, self.root + url, board\n\n\nclass PinterestAllpinsExtractor(PinterestExtractor):\n    \"\"\"Extractor for a user's 'All Pins' feed\"\"\"\n    subcategory = \"allpins\"\n    directory_fmt = (\"{category}\", \"{user}\")\n    pattern = BASE_PATTERN + r\"/(?!pin/)([^/?#]+)/pins/?$\"\n    example = \"https://www.pinterest.com/USER/pins/\"\n\n    def __init__(self, match):\n        PinterestExtractor.__init__(self, match)\n        self.user = text.unquote(match[1])\n\n    def metadata(self):\n        return {\"user\": self.user}\n\n    def pins(self):\n        return self.api.user_pins(self.user)\n\n\nclass PinterestCreatedExtractor(PinterestExtractor):\n    \"\"\"Extractor for a user's created pins\"\"\"\n    subcategory = \"created\"\n    directory_fmt = (\"{category}\", \"{user}\")\n    pattern = BASE_PATTERN + r\"/(?!pin/)([^/?#]+)/_created/?$\"\n    example = \"https://www.pinterest.com/USER/_created/\"\n\n    def __init__(self, match):\n        PinterestExtractor.__init__(self, match)\n        self.user = text.unquote(match[1])\n\n    def metadata(self):\n        return {\"user\": self.user}\n\n    def pins(self):\n        return self.api.user_activity_pins(self.user)\n\n\nclass PinterestSectionExtractor(PinterestExtractor):\n    \"\"\"Extractor for board sections on pinterest.com\"\"\"\n    subcategory = \"section\"\n    directory_fmt = (\"{category}\", \"{board[owner][username]}\",\n                     \"{board[name]}\", \"{section[title]}\")\n    archive_fmt = \"{board[id]}_{id}\"\n    pattern = BASE_PATTERN + r\"/(?!pin/)([^/?#]+)/([^/?#]+)/([^/?#]+)\"\n    example = \"https://www.pinterest.com/USER/BOARD/SECTION\"\n\n    def __init__(self, match):\n        PinterestExtractor.__init__(self, match)\n        self.user = text.unquote(match[1])\n        self.board_slug = text.unquote(match[2])\n        self.section_slug = text.unquote(match[3])\n        self.section = None\n\n    def metadata(self):\n        if self.section_slug.startswith(\"id:\"):\n            section = self.section = self.api.board_section(\n                self.section_slug[3:])\n        else:\n            section = self.section = self.api.board_section_by_name(\n                self.user, self.board_slug, self.section_slug)\n        section.pop(\"preview_pins\", None)\n        return {\"board\": section.pop(\"board\"), \"section\": section}\n\n    def pins(self):\n        return self.api.board_section_pins(self.section[\"id\"])\n\n\nclass PinterestSearchExtractor(PinterestExtractor):\n    \"\"\"Extractor for Pinterest search results\"\"\"\n    subcategory = \"search\"\n    directory_fmt = (\"{category}\", \"Search\", \"{search}\")\n    pattern = BASE_PATTERN + r\"/search/pins/?\\?q=([^&#]+)\"\n    example = \"https://www.pinterest.com/search/pins/?q=QUERY\"\n\n    def __init__(self, match):\n        PinterestExtractor.__init__(self, match)\n        self.search = text.unquote(match[1])\n\n    def metadata(self):\n        return {\"search\": self.search}\n\n    def pins(self):\n        return self.api.search(self.search)\n\n\nclass PinterestPinExtractor(PinterestExtractor):\n    \"\"\"Extractor for images from a single pin from pinterest.com\"\"\"\n    subcategory = \"pin\"\n    pattern = BASE_PATTERN + r\"/pin/([^/?#]+)(?!.*#related$)\"\n    example = \"https://www.pinterest.com/pin/12345/\"\n\n    def __init__(self, match):\n        PinterestExtractor.__init__(self, match)\n        self.pin_id = match[1]\n        self.pin = None\n\n    def metadata(self):\n        self.pin = self.api.pin(self.pin_id)\n        return self.pin\n\n    def pins(self):\n        return (self.pin,)\n\n\nclass PinterestBoardExtractor(PinterestExtractor):\n    \"\"\"Extractor for images from a board from pinterest.com\"\"\"\n    subcategory = \"board\"\n    directory_fmt = (\"{category}\", \"{board[owner][username]}\", \"{board[name]}\")\n    archive_fmt = \"{board[id]}_{id}\"\n    pattern = (BASE_PATTERN + r\"/(?!pin/)([^/?#]+)\"\n               r\"/([^/?#]+)/?(?!.*#related$)\")\n    example = \"https://www.pinterest.com/USER/BOARD/\"\n\n    def __init__(self, match):\n        PinterestExtractor.__init__(self, match)\n        self.user = text.unquote(match[1])\n        self.board_name = text.unquote(match[2])\n        self.board = None\n\n    def metadata(self):\n        self.board = self.api.board(self.user, self.board_name)\n        return {\"board\": self.board}\n\n    def pins(self):\n        board = self.board\n        pins = self.api.board_pins(board[\"id\"])\n\n        if board[\"section_count\"] and self.config(\"sections\", True):\n            base = f\"{self.root}{board['url']}id:\"\n            data = {\"_extractor\": PinterestSectionExtractor}\n            sections = [(base + section[\"id\"], data)\n                        for section in self.api.board_sections(board[\"id\"])]\n            pins = itertools.chain(pins, sections)\n\n        return pins\n\n\nclass PinterestRelatedPinExtractor(PinterestPinExtractor):\n    \"\"\"Extractor for related pins of another pin from pinterest.com\"\"\"\n    subcategory = \"related-pin\"\n    directory_fmt = (\"{category}\", \"related {original_pin[id]}\")\n    pattern = BASE_PATTERN + r\"/pin/([^/?#]+).*#related$\"\n    example = \"https://www.pinterest.com/pin/12345/#related\"\n\n    def metadata(self):\n        return {\"original_pin\": self.api.pin(self.pin_id)}\n\n    def pins(self):\n        return self.api.pin_related(self.pin_id)\n\n\nclass PinterestRelatedBoardExtractor(PinterestBoardExtractor):\n    \"\"\"Extractor for related pins of a board from pinterest.com\"\"\"\n    subcategory = \"related-board\"\n    directory_fmt = (\"{category}\", \"{board[owner][username]}\",\n                     \"{board[name]}\", \"related\")\n    pattern = BASE_PATTERN + r\"/(?!pin/)([^/?#]+)/([^/?#]+)/?#related$\"\n    example = \"https://www.pinterest.com/USER/BOARD/#related\"\n\n    def pins(self):\n        return self.api.board_content_recommendation(self.board[\"id\"])\n\n\nclass PinterestPinitExtractor(PinterestExtractor):\n    \"\"\"Extractor for images from a pin.it URL\"\"\"\n    subcategory = \"pinit\"\n    pattern = r\"(?:https?://)?pin\\.it/([^/?#]+)\"\n    example = \"https://pin.it/abcde\"\n\n    def items(self):\n        url = (f\"https://api.pinterest.com/url_shortener\"\n               f\"/{self.groups[0]}/redirect/\")\n        location = self.request_location(url)\n        if not location:\n            raise self.exc.NotFoundError(\"pin\")\n        elif PinterestPinExtractor.pattern.match(location):\n            yield Message.Queue, location, {\n                \"_extractor\": PinterestPinExtractor}\n        elif PinterestBoardExtractor.pattern.match(location):\n            yield Message.Queue, location, {\n                \"_extractor\": PinterestBoardExtractor}\n        else:\n            raise self.exc.NotFoundError(\"pin\")\n\n\nclass PinterestAPI():\n    \"\"\"Minimal interface for the Pinterest Web API\n\n    For a better and more complete implementation in PHP, see\n    - https://github.com/seregazhuk/php-pinterest-bot\n    \"\"\"\n\n    def __init__(self, extractor):\n        csrf_token = util.generate_token()\n\n        self.extractor = extractor\n        self.root = extractor.root\n        self.cookies = {\"csrftoken\": csrf_token}\n        self.headers = {\n            \"Accept\"                 : \"application/json, text/javascript, \"\n                                       \"*/*, q=0.01\",\n            \"X-Requested-With\"       : \"XMLHttpRequest\",\n            \"X-APP-VERSION\"          : \"a89153f\",\n            \"X-Pinterest-AppState\"   : \"active\",\n            \"X-Pinterest-Source-Url\" : None,\n            \"X-Pinterest-PWS-Handler\": \"www/[username].js\",\n            \"Alt-Used\"               : \"www.pinterest.com\",\n            \"Connection\"             : \"keep-alive\",\n            \"Cookie\"                 : None,\n            \"Sec-Fetch-Dest\"         : \"empty\",\n            \"Sec-Fetch-Mode\"         : \"cors\",\n            \"Sec-Fetch-Site\"         : \"same-origin\",\n        }\n\n    def pin(self, pin_id):\n        \"\"\"Query information about a pin\"\"\"\n        options = {\"id\": pin_id, \"field_set_key\": \"detailed\"}\n        return self._call(\"Pin\", options)[\"resource_response\"][\"data\"]\n\n    def pin_related(self, pin_id):\n        \"\"\"Yield related pins of another pin\"\"\"\n        options = {\"pin\": pin_id, \"add_vase\": True, \"pins_only\": True}\n        return self._pagination(\"RelatedPinFeed\", options)\n\n    def board(self, user, board_name):\n        \"\"\"Query information about a board\"\"\"\n        options = {\"slug\": board_name, \"username\": user,\n                   \"field_set_key\": \"detailed\"}\n        return self._call(\"Board\", options)[\"resource_response\"][\"data\"]\n\n    def boards(self, user):\n        \"\"\"Yield all boards from 'user'\"\"\"\n        options = {\n            \"sort\"            : \"last_pinned_to\",\n            \"field_set_key\"   : \"profile_grid_item\",\n            \"filter_stories\"  : False,\n            \"username\"        : user,\n            \"page_size\"       : 25,\n            \"include_archived\": True,\n        }\n        return self._pagination(\"Boards\", options)\n\n    def board_pins(self, board_id):\n        \"\"\"Yield all pins of a specific board\"\"\"\n        options = {\n            \"board_id\": board_id,\n            \"field_set_key\": \"react_grid_pin\",\n            \"prepend\": False,\n            \"bookmarks\": None,\n        }\n        return self._pagination(\"BoardFeed\", options)\n\n    def board_section(self, section_id):\n        \"\"\"Yield a specific board section\"\"\"\n        options = {\"section_id\": section_id}\n        return self._call(\"BoardSection\", options)[\"resource_response\"][\"data\"]\n\n    def board_section_by_name(self, user, board_slug, section_slug):\n        \"\"\"Yield a board section by name\"\"\"\n        options = {\"board_slug\": board_slug, \"section_slug\": section_slug,\n                   \"username\": user}\n        return self._call(\"BoardSection\", options)[\"resource_response\"][\"data\"]\n\n    def board_sections(self, board_id):\n        \"\"\"Yield all sections of a specific board\"\"\"\n        options = {\"board_id\": board_id}\n        return self._pagination(\"BoardSections\", options)\n\n    def board_section_pins(self, section_id):\n        \"\"\"Yield all pins from a board section\"\"\"\n        options = {\"section_id\": section_id}\n        return self._pagination(\"BoardSectionPins\", options)\n\n    def board_content_recommendation(self, board_id):\n        \"\"\"Yield related pins of a specific board\"\"\"\n        options = {\"id\": board_id, \"type\": \"board\", \"add_vase\": True}\n        return self._pagination(\"BoardContentRecommendation\", options)\n\n    def user_pins(self, user):\n        \"\"\"Yield all pins from 'user'\"\"\"\n        options = {\n            \"is_own_profile_pins\": False,\n            \"username\"           : user,\n            \"field_set_key\"      : \"grid_item\",\n            \"pin_filter\"         : None,\n        }\n        return self._pagination(\"UserPins\", options)\n\n    def user_activity_pins(self, user):\n        \"\"\"Yield pins created by 'user'\"\"\"\n        options = {\n            \"exclude_add_pin_rep\": True,\n            \"field_set_key\"      : \"grid_item\",\n            \"is_own_profile_pins\": False,\n            \"username\"           : user,\n        }\n        return self._pagination(\"UserActivityPins\", options)\n\n    def search(self, query):\n        \"\"\"Yield pins from searches\"\"\"\n        options = {\"query\": query, \"scope\": \"pins\", \"rs\": \"typed\"}\n        return self._pagination(\"BaseSearch\", options)\n\n    def _call(self, resource, options):\n        url = f\"{self.root}/resource/{resource}Resource/get/\"\n        params = {\n            \"data\"      : util.json_dumps({\"options\": options}),\n            \"source_url\": \"\",\n        }\n\n        response = self.extractor.request(\n            url, params=params, headers=self.headers,\n            cookies=self.cookies, fatal=False)\n\n        try:\n            data = response.json()\n        except ValueError:\n            data = {}\n\n        if response.history:\n            self.root = text.root_from_url(response.url)\n        if response.status_code < 400:\n            return data\n        if response.status_code == 404:\n            resource = self.extractor.subcategory.rpartition(\"-\")[2]\n            raise self.extractor.exc.NotFoundError(resource)\n        self.extractor.log.debug(\"Server response: %s\", response.text)\n        raise self.extractor.exc.AbortExtraction(\"API request failed\")\n\n    def _pagination(self, resource, options):\n        while True:\n            data = self._call(resource, options)\n            results = data[\"resource_response\"][\"data\"]\n            if isinstance(results, dict):\n                results = results[\"results\"]\n            yield from results\n\n            try:\n                bookmarks = data[\"resource\"][\"options\"][\"bookmarks\"]\n                if (not bookmarks or bookmarks[0] == \"-end-\" or\n                        bookmarks[0].startswith(\"Y2JOb25lO\")):\n                    return\n                options[\"bookmarks\"] = bookmarks\n            except KeyError:\n                return\n"
  },
  {
    "path": "gallery_dl/extractor/pixeldrain.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2023-2025 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://pixeldrain.com/\"\"\"\n\nfrom .common import Extractor, Message\nfrom .. import text, util\n\nBASE_PATTERN = r\"(?:https?://)?pixeldrain\\.com\"\n\n\nclass PixeldrainExtractor(Extractor):\n    \"\"\"Base class for pixeldrain extractors\"\"\"\n    category = \"pixeldrain\"\n    root = \"https://pixeldrain.com\"\n    archive_fmt = \"{id}\"\n\n    def _init(self):\n        if api_key := self.config(\"api-key\"):\n            self.session.auth = util.HTTPBasicAuth(\"\", api_key)\n\n    def _available(self, file):\n        if not file.get(\"availability\"):\n            return True\n\n        self.status |= 1\n        self.log.debug(file[\"availability\"])\n        self.log.warning(\n            \"%s: '%s'\", file[\"name\"], file[\"availability_message\"])\n        return False\n\n\nclass PixeldrainFileExtractor(PixeldrainExtractor):\n    \"\"\"Extractor for pixeldrain files\"\"\"\n    subcategory = \"file\"\n    filename_fmt = \"{filename[:230]} ({id}).{extension}\"\n    pattern = BASE_PATTERN + r\"/(?:u|api/file)/(\\w+)\"\n    example = \"https://pixeldrain.com/u/abcdefgh\"\n\n    def __init__(self, match):\n        Extractor.__init__(self, match)\n        self.file_id = match[1]\n\n    def items(self):\n        url = f\"{self.root}/api/file/{self.file_id}\"\n        file = self.request_json(url + \"/info\")\n\n        file[\"url\"] = url + \"?download\"\n        file[\"date\"] = self.parse_datetime_iso(file[\"date_upload\"])\n\n        text.nameext_from_name(file[\"name\"], file)\n        yield Message.Directory, \"\", file\n        if file.get(\"availability\"):\n            self.status |= 1\n            self.log.debug(file[\"availability\"])\n            self.log.error(\"'%s'\", file[\"availability_message\"])\n        else:\n            yield Message.Url, file[\"url\"], file\n\n\nclass PixeldrainAlbumExtractor(PixeldrainExtractor):\n    \"\"\"Extractor for pixeldrain albums\"\"\"\n    subcategory = \"album\"\n    directory_fmt = (\"{category}\",\n                     \"{album[date]:%Y-%m-%d} {album[title]} ({album[id]})\")\n    filename_fmt = \"{num:>03} {filename[:230]} ({id}).{extension}\"\n    pattern = BASE_PATTERN + r\"/(?:l|api/list)/(\\w+)(?:#item=(\\d+))?\"\n    example = \"https://pixeldrain.com/l/abcdefgh\"\n\n    def __init__(self, match):\n        Extractor.__init__(self, match)\n        self.album_id = match[1]\n        self.file_index = match[2]\n\n    def items(self):\n        url = f\"{self.root}/api/list/{self.album_id}\"\n        album = self.request_json(url)\n        album[\"date\"] = self.parse_datetime_iso(album[\"date_created\"])\n\n        if self.config(\"zip\", False):\n            self.directory_fmt = (\"{category}\",)\n            self.filename_fmt = \"{filename[:230]} ({id}).{extension}\"\n            del album[\"files\"]\n            album[\"count\"] = 1\n            url += \"/zip\"\n\n            file = {\n                \"id\"   : album[\"id\"],\n                \"url\"  : url,\n                \"num\"  : 0,\n                \"count\": 1,\n                \"name\" : album[\"title\"] + \".zip\",\n                \"date\" : album[\"date\"],\n                \"album\": album,\n                \"filename\" : album[\"title\"],\n                \"extension\": \"zip\",\n            }\n\n            yield Message.Directory, \"\", file\n            yield Message.Url, url, file\n            return\n\n        files = album.pop(\"files\")\n        album[\"count\"] = album.pop(\"file_count\")\n        if self.file_index:\n            idx = text.parse_int(self.file_index)\n            try:\n                files = (files[idx],)\n            except LookupError:\n                files = ()\n        else:\n            idx = 0\n\n        yield Message.Directory, \"\", {\"album\": album}\n        for num, file in enumerate(files, idx+1):\n            if not self._available(file):\n                continue\n            file[\"album\"] = album\n            file[\"num\"] = num\n            file[\"url\"] = url = f\"{self.root}/api/file/{file['id']}?download\"\n            file[\"date\"] = self.parse_datetime_iso(file[\"date_upload\"])\n            text.nameext_from_name(file[\"name\"], file)\n            yield Message.Url, url, file\n\n\nclass PixeldrainFolderExtractor(PixeldrainExtractor):\n    \"\"\"Extractor for pixeldrain filesystem files and directories\"\"\"\n    subcategory = \"folder\"\n    filename_fmt = \"{filename[:230]}.{extension}\"\n    archive_fmt = \"{path}_{num}\"\n    pattern = BASE_PATTERN + r\"/(?:d|api/filesystem)/([^?]+)\"\n    example = \"https://pixeldrain.com/d/abcdefgh\"\n\n    def metadata(self, data):\n        return {\n            \"type\"       : data[\"type\"],\n            \"path\"       : data[\"path\"],\n            \"name\"       : data[\"name\"],\n            \"mime_type\"  : data[\"file_type\"],\n            \"size\"       : data[\"file_size\"],\n            \"hash_sha256\": data[\"sha256_sum\"],\n            \"date\"       : self.parse_datetime_iso(data[\"created\"]),\n        }\n\n    def items(self):\n        recursive = self.config(\"recursive\", True)\n\n        url = f\"{self.root}/api/filesystem/{self.groups[0]}\"\n        stat = self.request_json(url + \"?stat\")\n\n        paths = stat[\"path\"]\n        path = paths[stat[\"base_index\"]]\n        if path[\"type\"] == \"dir\":\n            children = [\n                child\n                for child in stat[\"children\"]\n                if child[\"name\"] != \".search_index.gz\"\n            ]\n        else:\n            children = (path,)\n\n        folder = self.metadata(path)\n        folder[\"id\"] = paths[0][\"id\"]\n\n        yield Message.Directory, \"\", folder\n\n        num = 0\n        for child in children:\n            if child[\"type\"] == \"file\":\n                num += 1\n                if not self._available(child):\n                    continue\n                url = f\"{self.root}/api/filesystem{child['path']}?attach\"\n                share_url = f\"{self.root}/d{child['path']}\"\n                data = self.metadata(child)\n                data.update({\n                    \"id\"       : folder[\"id\"],\n                    \"num\"      : num,\n                    \"url\"      : url,\n                    \"share_url\": share_url,\n                })\n                data[\"filename\"], _, data[\"extension\"] = \\\n                    child[\"name\"].rpartition(\".\")\n                yield Message.Url, url, data\n\n            elif child[\"type\"] == \"dir\":\n                if recursive:\n                    url = f\"{self.root}/d{child['path']}\"\n                    child[\"_extractor\"] = PixeldrainFolderExtractor\n                    yield Message.Queue, url, child\n\n            else:\n                self.log.debug(\"'%s' is of unknown type (%s)\",\n                               child.get(\"name\"), child[\"type\"])\n"
  },
  {
    "path": "gallery_dl/extractor/pixiv.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2014-2026 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://www.pixiv.net/\"\"\"\n\nfrom .common import Extractor, Message, Dispatch\nfrom .. import text, util, dt\nimport itertools\nimport hashlib\n\nBASE_PATTERN = r\"(?:https?://)?(?:www\\.|touch\\.)?ph?ixiv\\.net\"\nUSER_PATTERN = BASE_PATTERN + r\"/(?:en/)?users/(\\d+)\"\n\n\nclass PixivExtractor(Extractor):\n    \"\"\"Base class for pixiv extractors\"\"\"\n    category = \"pixiv\"\n    root = \"https://www.pixiv.net\"\n    directory_fmt = (\"{category}\", \"{user[id]} {user[account]}\")\n    filename_fmt = \"{id}_p{num}.{extension}\"\n    archive_fmt = \"{id}{suffix}.{extension}\"\n    cookies_domain = \".pixiv.net\"\n    limit_url = \"https://s.pximg.net/common/images/limit_\"\n    # https://s.pximg.net/common/images/limit_sanity_level_360.png\n    # https://s.pximg.net/common/images/limit_unviewable_360.png\n    # https://s.pximg.net/common/images/limit_mypixiv_360.png\n\n    def _init(self):\n        self.api = PixivAppAPI(self)\n        self.load_ugoira = self.config(\"ugoira\", True)\n        self.load_ugoira_original = (self.load_ugoira == \"original\")\n        self.max_posts = self.config(\"max-posts\", 0)\n        self.sanity_workaround = self.config(\"sanity\", True)\n        self.meta_user = self.config(\"metadata\")\n        self.meta_bookmark = self.config(\"metadata-bookmark\")\n        self.meta_comments = self.config(\"comments\")\n        self.meta_captions = self.config(\"captions\")\n\n        if self.sanity_workaround or self.meta_captions:\n            self.meta_captions_sub = text.re(\n                r'<a href=\"/jump\\.php\\?([^\"]+)').sub\n\n    def items(self):\n        tags = self.config(\"tags\", \"japanese\")\n        if tags == \"original\":\n            transform_tags = None\n        elif tags == \"translated\":\n            def transform_tags(work):\n                work[\"tags\"] = list(dict.fromkeys(\n                    tag[\"translated_name\"] or tag[\"name\"]\n                    for tag in work[\"tags\"]))\n        else:\n            def transform_tags(work):\n                work[\"tags\"] = [tag[\"name\"] for tag in work[\"tags\"]]\n\n        ratings = {0: \"General\", 1: \"R-18\", 2: \"R-18G\"}\n        metadata = self.metadata()\n\n        works = self.works()\n        if self.max_posts:\n            works = itertools.islice(works, self.max_posts)\n        for work in works:\n            if not work[\"user\"][\"id\"]:\n                continue\n\n            files = self._extract_files(work)\n\n            if self.meta_user:\n                work.update(self.api.user_detail(str(work[\"user\"][\"id\"])))\n            if self.meta_comments:\n                if work[\"total_comments\"] and not work.get(\"_ajax\"):\n                    try:\n                        work[\"comments\"] = list(\n                            self.api.illust_comments(work[\"id\"]))\n                    except Exception:\n                        work[\"comments\"] = ()\n                else:\n                    work[\"comments\"] = ()\n            if self.meta_bookmark and work[\"is_bookmarked\"]:\n                detail = self.api.illust_bookmark_detail(work[\"id\"])\n                work[\"tags_bookmark\"] = [tag[\"name\"] for tag in detail[\"tags\"]\n                                         if tag[\"is_registered\"]]\n            if self.meta_captions and not work.get(\"caption\") and \\\n                    not work.get(\"_mypixiv\") and not work.get(\"_ajax\"):\n                if body := self._request_ajax(\"/illust/\" + str(work[\"id\"])):\n                    work[\"caption\"] = self._sanitize_ajax_caption(\n                        body[\"illustComment\"])\n\n            if transform_tags is not None:\n                transform_tags(work)\n            work[\"num\"] = 0\n            work[\"date\"] = dt.parse_iso(work[\"create_date\"])\n            work[\"count\"] = len(files)\n            work[\"rating\"] = ratings.get(work[\"x_restrict\"])\n            work[\"suffix\"] = \"\"\n            work.update(metadata)\n\n            yield Message.Directory, \"\", work\n            for work[\"num\"], file in enumerate(files):\n                url = file[\"url\"]\n                work.update(file)\n                text.nameext_from_url(url, work)\n                work[\"date_url\"] = self._date_from_url(url)\n                work[\"hash\"] = (name[name.find(\"-\")+1:name.rfind(\"_\")]\n                                if \"-\" in (name := work[\"filename\"]) else \"\")\n                yield Message.Url, url, work\n\n    def _extract_files(self, work):\n        meta_single_page = work[\"meta_single_page\"]\n        meta_pages = work[\"meta_pages\"]\n        del work[\"meta_single_page\"]\n        del work[\"image_urls\"]\n        del work[\"meta_pages\"]\n\n        if meta_pages:\n            return [\n                {\n                    \"url\"   : img[\"image_urls\"][\"original\"],\n                    \"suffix\": f\"_p{num:02}\",\n                    \"_fallback\": self._fallback_image(img),\n                }\n                for num, img in enumerate(meta_pages)\n            ]\n\n        url = meta_single_page[\"original_image_url\"]\n        if url.startswith(self.limit_url):\n            work_id = work[\"id\"]\n            self.log.debug(\"%s: %s\", work_id, url)\n\n            limit_type = url.rpartition(\"/\")[2]\n            if limit_type in {\n                \"limit_\",  # for '_extend_sanity()' inserts\n                \"limit_unviewable_360.png\",\n                \"limit_sanity_level_360.png\",\n            }:\n                work[\"_ajax\"] = True\n                self.log.warning(\"%s: 'limit_sanity_level' warning\", work_id)\n                if self.sanity_workaround:\n                    body = self._request_ajax(\"/illust/\" + str(work_id))\n                    if work[\"type\"] == \"ugoira\":\n                        if not self.load_ugoira:\n                            return ()\n                        self.log.info(\"%s: Retrieving Ugoira AJAX metadata\",\n                                      work[\"id\"])\n                        try:\n                            self._extract_ajax(work, body)\n                            return self._extract_ugoira(work, url)\n                        except Exception as exc:\n                            self.log.traceback(exc)\n                            self.log.warning(\n                                \"%s: Unable to extract Ugoira URL. Provide \"\n                                \"logged-in cookies to access it\", work[\"id\"])\n                    else:\n                        return self._extract_ajax(work, body)\n\n            elif limit_type == \"limit_mypixiv_360.png\":\n                work[\"_mypixiv\"] = True\n                self.log.warning(\"%s: 'My pixiv' locked\", work_id)\n\n            else:\n                work[\"_mypixiv\"] = True  # stop further processing\n                self.log.error(\"%s: Unknown 'limit' URL type: %s\",\n                               work_id, limit_type)\n\n        elif work[\"type\"] != \"ugoira\":\n            return ({\"url\": url, \"_fallback\": self._fallback_image(url)},)\n\n        elif self.load_ugoira:\n            try:\n                return self._extract_ugoira(work, url)\n            except Exception as exc:\n                self.log.warning(\n                    \"%s: Unable to retrieve Ugoira metatdata (%s - %s)\",\n                    work[\"id\"], exc.__class__.__name__, exc)\n\n        return ()\n\n    def _extract_ugoira(self, work, img_url):\n        if work.get(\"_ajax\"):\n            ugoira = self._request_ajax(\n                \"/illust/\" + str(work[\"id\"]) + \"/ugoira_meta\")\n            img_url = ugoira[\"src\"]\n        else:\n            ugoira = self.api.ugoira_metadata(work[\"id\"])\n        work[\"_ugoira_frame_data\"] = work[\"frames\"] = frames = ugoira[\"frames\"]\n        work[\"_ugoira_original\"] = self.load_ugoira_original\n        work[\"_http_adjust_extension\"] = False\n\n        if self.load_ugoira_original:\n            work[\"date_url\"] = self._date_from_url(img_url)\n\n            base, sep, ext = img_url.rpartition(\"_ugoira0.\")\n            if sep:\n                base += \"_ugoira\"\n            else:\n                base, sep, _ = img_url.rpartition(\"_ugoira\")\n                base = base.replace(\n                    \"/img-zip-ugoira/\", \"/img-original/\", 1) + sep\n\n                for ext in (\"jpg\", \"png\", \"gif\"):\n                    try:\n                        url = f\"{base}0.{ext}\"\n                        self.request(url, method=\"HEAD\")\n                        break\n                    except self.exc.HttpError:\n                        pass\n                else:\n                    self.log.warning(\n                        \"Unable to find Ugoira frame URLs (%s)\", work[\"id\"])\n\n            return [\n                {\n                    \"url\": f\"{base}{num}.{ext}\",\n                    \"suffix\": f\"_p{num:02}\",\n                    \"_ugoira_frame_index\": num,\n                }\n                for num in range(len(frames))\n            ]\n\n        else:\n            if work.get(\"_ajax\"):\n                zip_url = ugoira[\"originalSrc\"]\n            else:\n                zip_url = ugoira[\"zip_urls\"][\"medium\"]\n            work[\"date_url\"] = self._date_from_url(zip_url)\n            url = zip_url.replace(\"_ugoira600x600\", \"_ugoira1920x1080\", 1)\n            return ({\"url\": url},)\n\n    def _request_ajax(self, endpoint):\n        url = f\"{self.root}/ajax{endpoint}\"\n        try:\n            data = self.request_json(\n                url, headers=self.headers_web, fatal=False)\n            if not data.get(\"error\"):\n                return data[\"body\"]\n\n            self.log.debug(\"Server response: %s\", util.json_dumps(data))\n            if (msg := data.get(\"message\")) == \"An unknown error occurred\":\n                msg = \"Invalid 'PHPSESSID' cookie\"\n            else:\n                msg = f\"'{msg or 'General Error'}'\"\n            self.log.error(\"%s\", msg)\n        except Exception:\n            pass\n\n    def _extract_ajax(self, work, body):\n        work[\"_ajax\"] = True\n        url = self._extract_ajax_url(body)\n        if not url:\n            return ()\n\n        for key_app, key_ajax in (\n            (\"title\"            , \"illustTitle\"),\n            (\"image_urls\"       , \"urls\"),\n            (\"create_date\"      , \"createDate\"),\n            (\"width\"            , \"width\"),\n            (\"height\"           , \"height\"),\n            (\"sanity_level\"     , \"sl\"),\n            (\"total_view\"       , \"viewCount\"),\n            (\"total_comments\"   , \"commentCount\"),\n            (\"total_bookmarks\"  , \"bookmarkCount\"),\n            (\"restrict\"         , \"restrict\"),\n            (\"x_restrict\"       , \"xRestrict\"),\n            (\"illust_ai_type\"   , \"aiType\"),\n            (\"illust_book_style\", \"bookStyle\"),\n        ):\n            work[key_app] = body[key_ajax]\n\n        work[\"user\"] = {\n            \"account\"    : body[\"userAccount\"],\n            \"id\"         : int(body[\"userId\"]),\n            \"is_followed\": False,\n            \"name\"       : body[\"userName\"],\n            \"profile_image_urls\": {},\n        }\n\n        if \"is_bookmarked\" not in work:\n            work[\"is_bookmarked\"] = True if body.get(\"bookmarkData\") else False\n\n        work[\"tags\"] = tags = []\n        for tag in body[\"tags\"][\"tags\"]:\n            name = tag[\"tag\"]\n            try:\n                translated_name = tag[\"translation\"][\"en\"]\n            except Exception:\n                translated_name = None\n            tags.append({\"name\": name, \"translated_name\": translated_name})\n\n        work[\"caption\"] = self._sanitize_ajax_caption(body[\"illustComment\"])\n        work[\"page_count\"] = count = body[\"pageCount\"]\n        if count == 1:\n            return ({\"url\": url},)\n\n        base, _, ext = url.rpartition(\"_p0.\")\n        return [\n            {\n                \"url\"   : f\"{base}_p{num}.{ext}\",\n                \"suffix\": f\"_p{num:02}\",\n            }\n            for num in range(count)\n        ]\n\n    def _extract_ajax_url(self, body):\n        try:\n            if original := body[\"urls\"][\"original\"]:\n                return original\n        except Exception:\n            pass\n\n        try:\n            square1200 = body[\"userIllusts\"][body[\"id\"]][\"url\"]\n        except Exception:\n            return\n\n        parts = square1200.rpartition(\"_p0\")[0].split(\"/\")\n        if len(parts) < 6:\n            return self.log.warning(\n                \"%s: %s\", body[\"id\"], square1200.rpartition(\"/\")[2])\n\n        del parts[3:5]\n        parts[3] = \"img-original\"\n        base = \"/\".join(parts)\n\n        for ext in (\"jpg\", \"png\", \"gif\"):\n            try:\n                url = f\"{base}_p0.{ext}\"\n                self.request(url, method=\"HEAD\")\n                return url\n            except self.exc.HttpError:\n                pass\n\n    def _sanitize_ajax_caption(self, caption):\n        if not caption:\n            return \"\"\n        return text.unescape(self.meta_captions_sub(\n            lambda m: '<a href=\"' + text.unquote(m[1]), caption))\n\n    def _fallback_image(self, src):\n        if isinstance(src, str):\n            urls = None\n            orig = src\n        else:\n            urls = src[\"image_urls\"]\n            orig = urls[\"original\"]\n\n        base = orig.rpartition(\".\")[0]\n        yield base.replace(\"-original/\", \"-master/\", 1) + \"_master1200.jpg\"\n\n        if urls is None:\n            return\n\n        for fmt in (\"large\", \"medium\", \"square_medium\"):\n            if fmt in urls:\n                yield urls[fmt]\n\n    def _date_from_url(self, url, offset=dt.timedelta(hours=9)):\n        try:\n            _, _, _, _, _, y, m, d, H, M, S, _ = url.split(\"/\")\n            return dt.datetime(\n                int(y), int(m), int(d), int(H), int(M), int(S)) - offset\n        except Exception:\n            return None\n\n    def _make_work(self, kind, url, user):\n        p = url.split(\"/\")\n        return {\n            \"create_date\"     : (f\"{p[5]}-{p[6]}-{p[7]}T{p[8]}:{p[9]}:{p[10]}\"\n                                 f\"+09:00\" if len(p) > 9 else None),\n            \"height\"          : 0,\n            \"id\"              : kind,\n            \"image_urls\"      : None,\n            \"meta_pages\"      : (),\n            \"meta_single_page\": {\"original_image_url\": url},\n            \"page_count\"      : 1,\n            \"sanity_level\"    : 0,\n            \"total_comments\"  : 0,\n            \"is_bookmarked\"   : False,\n            \"tags\"            : (),\n            \"title\"           : kind,\n            \"type\"            : kind,\n            \"user\"            : user,\n            \"width\"           : 0,\n            \"x_restrict\"      : 0,\n        }\n\n    def works(self):\n        \"\"\"Return an iterable containing all relevant 'work' objects\"\"\"\n\n    def metadata(self):\n        \"\"\"Collect metadata for extractor job\"\"\"\n        return {}\n\n\nclass PixivUserExtractor(Dispatch, PixivExtractor):\n    \"\"\"Extractor for a pixiv user profile\"\"\"\n    pattern = (BASE_PATTERN + r\"/(?:\"\n               r\"(?:en/)?u(?:sers)?/|member\\.php\\?id=|(?:mypage\\.php)?#id=\"\n               r\")(\\d+)(?:$|[?#])\")\n    example = \"https://www.pixiv.net/en/users/12345\"\n\n    def items(self):\n        if (inc := self.config(\"include\")) and (\n                \"sketch\" in inc or inc == \"all\"):\n            Extractor.initialize(self)\n            user = PixivAppAPI(self).user_detail(self.groups[0])\n            sketch = \"https://sketch.pixiv.net/@\" + user[\"user\"][\"account\"]\n        else:\n            sketch = \"\"\n\n        base = f\"{self.root}/users/{self.groups[0]}/\"\n        return self._dispatch_extractors((\n            (PixivAvatarExtractor       , base + \"avatar\"),\n            (PixivBackgroundExtractor   , base + \"background\"),\n            (PixivArtworksExtractor     , base + \"artworks\"),\n            (PixivFavoriteExtractor     , base + \"bookmarks/artworks\"),\n            (PixivNovelBookmarkExtractor, base + \"bookmarks/novels\"),\n            (PixivNovelUserExtractor    , base + \"novels\"),\n            (PixivSketchExtractor       , sketch),\n        ), (\"artworks\",), (\n            (\"bookmark\", \"novel-bookmark\", None),\n            (\"user\"    , \"novel-user\"    , None),\n        ))\n\n\nclass PixivArtworksExtractor(PixivExtractor):\n    \"\"\"Extractor for artworks of a pixiv user\"\"\"\n    subcategory = \"artworks\"\n    pattern = (BASE_PATTERN + r\"/(?:\"\n               r\"(?:en/)?users/(\\d+)/(?:artworks|illustrations|manga)\"\n               r\"(?:/([^/?#]+))?/?(?:$|[?#])\"\n               r\"|member_illust\\.php\\?id=(\\d+)(?:&([^#]+))?)\")\n    example = \"https://www.pixiv.net/en/users/12345/artworks\"\n    _warn_phpsessid = True\n\n    def _init(self):\n        PixivExtractor._init(self)\n\n        u1, t1, u2, t2 = self.groups\n        if t1:\n            t1 = text.unquote(t1)\n        elif t2:\n            t2 = text.parse_query(t2).get(\"tag\")\n        self.user_id = u1 or u2\n        self.tag = t1 or t2\n\n        if self.sanity_workaround and self._warn_phpsessid:\n            PixivArtworksExtractor._warn_phpsessid = False\n            if not self.cookies.get(\"PHPSESSID\", domain=self.cookies_domain):\n                self.log.warning(\"No 'PHPSESSID' cookie set. Can detect only \"\n                                 \"non R-18 'limit_sanity_level' works.\")\n\n    def metadata(self):\n        if self.config(\"metadata\"):\n            self.api.user_detail(self.user_id)\n        return {}\n\n    def works(self):\n        works = self.api.user_illusts(self.user_id)\n\n        if self.sanity_workaround and (body := self._request_ajax(\n                f\"/user/{self.user_id}/profile/all\")):\n            try:\n                ajax_ids = list(map(int, body[\"illusts\"]))\n                ajax_ids.extend(map(int, body[\"manga\"]))\n                ajax_ids.sort()\n            except Exception as exc:\n                self.log.traceback(exc)\n                self.log.warning(\"u%s: Failed to collect artwork IDs \"\n                                 \"using AJAX API\", self.user_id)\n            else:\n                works = self._extend_sanity(works, ajax_ids)\n\n        if self.tag:\n            tag = self.tag.lower()\n            works = (\n                work for work in works\n                if tag in [t[\"name\"].lower() for t in work[\"tags\"]]\n            )\n\n        return works\n\n    def _extend_sanity(self, works, ajax_ids):\n        user = {\"id\": 1}\n        index = len(ajax_ids) - 1\n\n        for work in works:\n            while index >= 0:\n                work_id = work[\"id\"]\n                ajax_id = ajax_ids[index]\n\n                if ajax_id == work_id:\n                    index -= 1\n                    break\n\n                elif ajax_id > work_id:\n                    index -= 1\n                    self.log.debug(\"Inserting work %s\", ajax_id)\n                    yield self._make_work(ajax_id, self.limit_url, user)\n\n                else:  # ajax_id < work_id\n                    break\n\n            yield work\n\n        while index >= 0:\n            ajax_id = ajax_ids[index]\n            self.log.debug(\"Inserting work %s\", ajax_id)\n            yield self._make_work(ajax_id, self.limit_url, user)\n            index -= 1\n\n\nclass PixivAvatarExtractor(PixivExtractor):\n    \"\"\"Extractor for pixiv avatars\"\"\"\n    subcategory = \"avatar\"\n    filename_fmt = \"avatar{date:?_//%Y-%m-%d}.{extension}\"\n    archive_fmt = \"avatar_{user[id]}_{date}\"\n    pattern = USER_PATTERN + r\"/avatar\"\n    example = \"https://www.pixiv.net/en/users/12345/avatar\"\n\n    def _init(self):\n        PixivExtractor._init(self)\n        self.sanity_workaround = \\\n            self.meta_bookmark = \\\n            self.meta_comments = \\\n            self.meta_captions = False\n\n    def works(self):\n        user = self.api.user_detail(self.groups[0])[\"user\"]\n        url = user[\"profile_image_urls\"][\"medium\"].replace(\"_170.\", \".\")\n        return (self._make_work(\"avatar\", url, user),)\n\n\nclass PixivBackgroundExtractor(PixivExtractor):\n    \"\"\"Extractor for pixiv background banners\"\"\"\n    subcategory = \"background\"\n    filename_fmt = \"background{date:?_//%Y-%m-%d}.{extension}\"\n    archive_fmt = \"background_{user[id]}_{date}\"\n    pattern = USER_PATTERN + \"/background\"\n    example = \"https://www.pixiv.net/en/users/12345/background\"\n\n    _init = PixivAvatarExtractor._init\n\n    def works(self):\n        detail = self.api.user_detail(self.groups[0])\n        url = detail[\"profile\"][\"background_image_url\"]\n        if not url:\n            return ()\n        if \"/c/\" in url:\n            parts = url.split(\"/\")\n            del parts[3:5]\n            url = \"/\".join(parts)\n        url = url.replace(\"_master1200.\", \".\")\n        work = self._make_work(\"background\", url, detail[\"user\"])\n        if url.endswith(\".jpg\"):\n            url = url[:-4]\n            work[\"_fallback\"] = (url + \".png\", url + \".gif\")\n        return (work,)\n\n\nclass PixivMeExtractor(PixivExtractor):\n    \"\"\"Extractor for pixiv.me URLs\"\"\"\n    subcategory = \"me\"\n    pattern = r\"(?:https?://)?pixiv\\.me/([^/?#]+)\"\n    example = \"https://pixiv.me/USER\"\n\n    def items(self):\n        url = \"https://pixiv.me/\" + self.groups[0]\n        location = self.request_location(url, notfound=\"user\")\n        yield Message.Queue, location, {\"_extractor\": PixivUserExtractor}\n\n\nclass PixivWorkExtractor(PixivExtractor):\n    \"\"\"Extractor for a single pixiv work/illustration\"\"\"\n    subcategory = \"work\"\n    pattern = (r\"(?:https?://)?(?:(?:www\\.|touch\\.)?ph?ixiv\\.net\"\n               r\"/(?:(?:en/)?artworks/\"\n               r\"|member_illust\\.php\\?(?:[^&]+&)*illust_id=)(\\d+)\"\n               r\"|(?:i(?:\\d+\\.pixiv|\\.pximg)\\.net\"\n               r\"/(?:(?:.*/)?img-[^/]+/img/\\d{4}(?:/\\d\\d){5}|img\\d+/img/[^/]+)\"\n               r\"|img\\d*\\.pixiv\\.net/img/[^/]+|(?:www\\.)?pixiv\\.net/i)/(\\d+))\")\n    example = \"https://www.pixiv.net/artworks/12345\"\n\n    def __init__(self, match):\n        PixivExtractor.__init__(self, match)\n        self.illust_id = match[1] or match[2]\n\n    def works(self):\n        works = (self.api.illust_detail(self.illust_id),)\n        if self.config(\"related\", False):\n            related = self.api.illust_related(self.illust_id)\n            works = itertools.chain(works, related)\n        return works\n\n\nclass PixivUnlistedExtractor(PixivExtractor):\n    \"\"\"Extractor for a unlisted pixiv illustrations\"\"\"\n    subcategory = \"unlisted\"\n    pattern = BASE_PATTERN + r\"/(?:en/)?artworks/unlisted/(\\w+)\"\n    example = \"https://www.pixiv.net/en/artworks/unlisted/a1b2c3d4e5f6g7h8i9j0\"\n\n    def _extract_files(self, work):\n        body = self._request_ajax(\"/illust/unlisted/\" + work[\"id\"])\n        work[\"id_unlisted\"] = work[\"id\"]\n        work[\"id\"] = text.parse_int(body[\"illustId\"])\n        return self._extract_ajax(work, body)\n\n    def works(self):\n        return ({\"id\": self.groups[0], \"user\": {\"id\": 1}},)\n\n\nclass PixivFavoriteExtractor(PixivExtractor):\n    \"\"\"Extractor for all favorites/bookmarks of a pixiv user\"\"\"\n    subcategory = \"favorite\"\n    directory_fmt = (\"{category}\", \"bookmarks\",\n                     \"{user_bookmark[id]} {user_bookmark[account]}\")\n    archive_fmt = \"f_{user_bookmark[id]}_{id}{num}.{extension}\"\n    pattern = (BASE_PATTERN + r\"/(?:(?:en/)?\"\n               r\"users/(\\d+)/(bookmarks/artworks|following)(?:/([^/?#]+))?\"\n               r\"|bookmark\\.php)(?:\\?([^#]*))?\")\n    example = \"https://www.pixiv.net/en/users/12345/bookmarks/artworks\"\n\n    def __init__(self, match):\n        uid, kind, self.tag, query = match.groups()\n        query = text.parse_query(query)\n\n        if not uid:\n            uid = query.get(\"id\")\n            if not uid:\n                self.subcategory = \"bookmark\"\n\n        if kind == \"following\" or query.get(\"type\") == \"user\":\n            self.subcategory = \"following\"\n            self.items = self._items_following\n\n        PixivExtractor.__init__(self, match)\n        self.query = query\n        self.user_id = uid\n\n    def works(self):\n        tag = None\n        if \"tag\" in self.query:\n            tag = text.unquote(self.query[\"tag\"])\n        elif self.tag:\n            tag = text.unquote(self.tag)\n\n        restrict = \"public\"\n        if self.query.get(\"rest\") == \"hide\":\n            restrict = \"private\"\n\n        return self.api.user_bookmarks_illust(self.user_id, tag, restrict)\n\n    def metadata(self):\n        if self.user_id:\n            user = self.api.user_detail(self.user_id)[\"user\"]\n        else:\n            self.api.login()\n            user = self.api.user\n\n        self.user_id = user[\"id\"]\n        return {\"user_bookmark\": user}\n\n    def _items_following(self):\n        restrict = \"public\"\n        if self.query.get(\"rest\") == \"hide\":\n            restrict = \"private\"\n\n        for preview in self.api.user_following(self.user_id, restrict):\n            user = preview[\"user\"]\n            user[\"_extractor\"] = PixivUserExtractor\n            url = \"https://www.pixiv.net/users/\" + str(user[\"id\"])\n            yield Message.Queue, url, user\n\n\nclass PixivRankingExtractor(PixivExtractor):\n    \"\"\"Extractor for pixiv ranking pages\"\"\"\n    subcategory = \"ranking\"\n    archive_fmt = \"r_{ranking[mode]}_{ranking[date]}_{id}{num}.{extension}\"\n    directory_fmt = (\"{category}\", \"rankings\",\n                     \"{ranking[mode]}\", \"{ranking[date]}\")\n    pattern = BASE_PATTERN + r\"/ranking\\.php(?:\\?([^#]*))?\"\n    example = \"https://www.pixiv.net/ranking.php\"\n\n    def __init__(self, match):\n        PixivExtractor.__init__(self, match)\n        self.query = match[1]\n        self.mode = self.date = None\n\n    def works(self):\n        ranking = self.ranking\n\n        works = self.api.illust_ranking(self.mode, self.date)\n        if self.type:\n            works = filter(lambda work, t=self.type: work[\"type\"] == t, works)\n\n        for ranking[\"rank\"], work in enumerate(works, 1):\n            yield work\n\n    def metadata(self):\n        query = text.parse_query(self.query)\n\n        mode = query.get(\"mode\", \"daily\").lower()\n        mode_map = {\n            \"daily\": \"day\",\n            \"daily_r18\": \"day_r18\",\n            \"daily_ai\": \"day_ai\",\n            \"daily_r18_ai\": \"day_r18_ai\",\n            \"weekly\": \"week\",\n            \"weekly_r18\": \"week_r18\",\n            \"monthly\": \"month\",\n            \"male\": \"day_male\",\n            \"male_r18\": \"day_male_r18\",\n            \"female\": \"day_female\",\n            \"female_r18\": \"day_female_r18\",\n            \"original\": \"week_original\",\n            \"rookie\": \"week_rookie\",\n            \"r18g\": \"week_r18g\",\n        }\n        try:\n            self.mode = mode = mode_map[mode]\n        except KeyError:\n            raise self.exc.AbortExtraction(f\"Invalid mode '{mode}'\")\n\n        if date := query.get(\"date\"):\n            if len(date) == 8 and date.isdecimal():\n                date = f\"{date[0:4]}-{date[4:6]}-{date[6:8]}\"\n            else:\n                self.log.warning(\"invalid date '%s'\", date)\n                date = None\n        if not date:\n            date = (dt.now() - dt.timedelta(days=1)).strftime(\"%Y-%m-%d\")\n        self.date = date\n\n        self.type = type = query.get(\"content\")\n\n        self.ranking = ranking = {\n            \"mode\": mode,\n            \"date\": self.date,\n            \"rank\": 0,\n            \"type\": type or \"all\",\n        }\n        return {\"ranking\": ranking}\n\n\nclass PixivSearchExtractor(PixivExtractor):\n    \"\"\"Extractor for pixiv search results\"\"\"\n    subcategory = \"search\"\n    archive_fmt = \"s_{search[word]}_{id}{num}.{extension}\"\n    directory_fmt = (\"{category}\", \"search\", \"{search[word]}\")\n    pattern = (BASE_PATTERN + r\"/(?:(?:en/)?tags/([^/?#]+)(?:/[^/?#]+)?/?\"\n               r\"|search\\.php)(?:\\?([^#]+))?\")\n    example = \"https://www.pixiv.net/en/tags/TAG\"\n\n    def __init__(self, match):\n        PixivExtractor.__init__(self, match)\n        self.word, self.query = match.groups()\n        self.sort = self.target = None\n\n    def works(self):\n        return self.api.search_illust(\n            self.word, self.sort, self.target,\n            date_start=self.date_start, date_end=self.date_end)\n\n    def metadata(self):\n        query = text.parse_query(self.query)\n\n        if self.word:\n            self.word = text.unquote(self.word)\n        else:\n            try:\n                self.word = query[\"word\"]\n            except KeyError:\n                raise self.exc.AbortExtraction(\"Missing search term\")\n\n        sort = query.get(\"order\", \"date_d\")\n        sort_map = {\n            \"date\": \"date_asc\",\n            \"date_d\": \"date_desc\",\n            \"popular_d\": \"popular_desc\",\n            \"popular_male_d\": \"popular_male_desc\",\n            \"popular_female_d\": \"popular_female_desc\",\n        }\n        try:\n            self.sort = sort = sort_map[sort]\n        except KeyError:\n            raise self.exc.AbortExtraction(f\"Invalid search order '{sort}'\")\n\n        target = query.get(\"s_mode\", \"s_tag_full\")\n        target_map = {\n            \"s_tag\": \"partial_match_for_tags\",\n            \"s_tag_full\": \"exact_match_for_tags\",\n            \"s_tc\": \"title_and_caption\",\n        }\n        try:\n            self.target = target = target_map[target]\n        except KeyError:\n            raise self.exc.AbortExtraction(f\"Invalid search mode '{target}'\")\n\n        self.date_start = query.get(\"scd\")\n        self.date_end = query.get(\"ecd\")\n\n        return {\"search\": {\n            \"word\": self.word,\n            \"sort\": self.sort,\n            \"target\": self.target,\n            \"date_start\": self.date_start,\n            \"date_end\": self.date_end,\n        }}\n\n\nclass PixivFollowedExtractor(PixivExtractor):\n    \"\"\"Extractor for new illustrations from your followed artists\"\"\"\n    subcategory = \"followed\"\n    archive_fmt = \"F_{user_follow[id]}_{id}{num}.{extension}\"\n    directory_fmt = (\"{category}\", \"following\")\n    pattern = BASE_PATTERN + r\"/bookmark_new_illust\\.php\"\n    example = \"https://www.pixiv.net/bookmark_new_illust.php\"\n\n    def works(self):\n        return self.api.illust_follow()\n\n    def metadata(self):\n        self.api.login()\n        return {\"user_follow\": self.api.user}\n\n\nclass PixivPixivisionExtractor(PixivExtractor):\n    \"\"\"Extractor for illustrations from a pixivision article\"\"\"\n    subcategory = \"pixivision\"\n    directory_fmt = (\"{category}\", \"pixivision\",\n                     \"{pixivision_id} {pixivision_title}\")\n    archive_fmt = \"V{pixivision_id}_{id}{suffix}.{extension}\"\n    pattern = r\"(?:https?://)?(?:www\\.)?pixivision\\.net/(?:en/)?a/(\\d+)\"\n    example = \"https://www.pixivision.net/en/a/12345\"\n\n    def __init__(self, match):\n        PixivExtractor.__init__(self, match)\n        self.pixivision_id = match[1]\n\n    def works(self):\n        return (\n            self.api.illust_detail(illust_id.partition(\"?\")[0])\n            for illust_id in util.unique_sequence(text.extract_iter(\n                self.page, '<a href=\"https://www.pixiv.net/en/artworks/', '\"'))\n        )\n\n    def metadata(self):\n        url = \"https://www.pixivision.net/en/a/\" + self.pixivision_id\n        headers = {\"User-Agent\": \"Mozilla/5.0\"}\n        self.page = self.request(url, headers=headers).text\n\n        title = text.extr(self.page, '<title>', '<')\n        return {\n            \"pixivision_id\"   : self.pixivision_id,\n            \"pixivision_title\": text.unescape(title),\n        }\n\n\nclass PixivSeriesExtractor(PixivExtractor):\n    \"\"\"Extractor for illustrations from a Pixiv series\"\"\"\n    subcategory = \"series\"\n    directory_fmt = (\"{category}\", \"{user[id]} {user[account]}\",\n                     \"{series[id]} {series[title]}\")\n    filename_fmt = \"{num_series:>03}_{id}_p{num}.{extension}\"\n    pattern = BASE_PATTERN + r\"/user/(\\d+)/series/(\\d+)\"\n    example = \"https://www.pixiv.net/user/12345/series/12345\"\n\n    def __init__(self, match):\n        PixivExtractor.__init__(self, match)\n        self.user_id, self.series_id = match.groups()\n\n    def works(self):\n        series = None\n\n        for work in self.api.illust_series(self.series_id):\n            if series is None:\n                series = self.api.data\n                series[\"total\"] = num_series = series.pop(\"series_work_count\")\n            else:\n                num_series -= 1\n\n            work[\"num_series\"] = num_series\n            work[\"series\"] = series\n            yield work\n\n\nclass PixivSketchExtractor(Extractor):\n    \"\"\"Extractor for user pages on sketch.pixiv.net\"\"\"\n    category = \"pixiv\"\n    subcategory = \"sketch\"\n    directory_fmt = (\"{category}\", \"sketch\", \"{user[unique_name]}\")\n    filename_fmt = \"{post_id} {id}.{extension}\"\n    archive_fmt = \"S{user[id]}_{id}\"\n    root = \"https://sketch.pixiv.net\"\n    cookies_domain = \".pixiv.net\"\n    pattern = r\"(?:https?://)?sketch\\.pixiv\\.net/@([^/?#]+)\"\n    example = \"https://sketch.pixiv.net/@USER\"\n\n    def items(self):\n        self.username = self.groups[0]\n        headers = {\"Referer\": f\"{self.root}/@{self.username}\"}\n\n        for post in self.posts():\n            media = post[\"media\"]\n            post[\"post_id\"] = post[\"id\"]\n            post[\"date\"] = dt.parse_iso(post[\"created_at\"])\n            util.delete_items(post, (\"id\", \"media\", \"_links\"))\n\n            yield Message.Directory, \"\", post\n            post[\"_http_headers\"] = headers\n\n            for photo in media:\n                original = photo[\"photo\"][\"original\"]\n                post[\"id\"] = photo[\"id\"]\n                post[\"width\"] = original[\"width\"]\n                post[\"height\"] = original[\"height\"]\n\n                url = original[\"url\"]\n                text.nameext_from_url(url, post)\n                yield Message.Url, url, post\n\n    def posts(self):\n        url = f\"{self.root}/api/walls/@{self.username}/posts/public.json\"\n        headers = {\n            \"Accept\": \"application/vnd.sketch-v4+json\",\n            \"Referer\": self.root + \"/\",\n            \"X-Requested-With\": f\"{self.root}/@{self.username}\",\n        }\n\n        while True:\n            data = self.request_json(url, headers=headers)\n            yield from data[\"data\"][\"items\"]\n\n            next_url = data[\"_links\"].get(\"next\")\n            if not next_url:\n                return\n            url = self.root + next_url[\"href\"]\n\n\n###############################################################################\n# Novels ######################################################################\n\nclass PixivNovelExtractor(PixivExtractor):\n    \"\"\"Base class for pixiv novel extractors\"\"\"\n    category = \"pixiv-novel\"\n    request_interval = (0.5, 1.5)\n\n    def items(self):\n        self.novel_id = self.groups[0]\n\n        tags = self.config(\"tags\", \"japanese\")\n        if tags == \"original\":\n            transform_tags = None\n        elif tags == \"translated\":\n            def transform_tags(work):\n                work[\"tags\"] = list(dict.fromkeys(\n                    tag[\"translated_name\"] or tag[\"name\"]\n                    for tag in work[\"tags\"]))\n        else:\n            def transform_tags(work):\n                work[\"tags\"] = [tag[\"name\"] for tag in work[\"tags\"]]\n\n        ratings = {0: \"General\", 1: \"R-18\", 2: \"R-18G\"}\n        embeds = self.config(\"embeds\")\n        covers = self.config(\"covers\")\n\n        novels = self.novels()\n        if self.max_posts:\n            novels = itertools.islice(novels, self.max_posts)\n        for novel in novels:\n            if self.meta_user:\n                novel.update(self.api.user_detail(str(novel[\"user\"][\"id\"])))\n            if self.meta_comments:\n                if novel[\"total_comments\"]:\n                    novel[\"comments\"] = list(\n                        self.api.novel_comments(novel[\"id\"]))\n                else:\n                    novel[\"comments\"] = ()\n            if self.meta_bookmark and novel[\"is_bookmarked\"]:\n                detail = self.api.novel_bookmark_detail(novel[\"id\"])\n                novel[\"tags_bookmark\"] = [tag[\"name\"] for tag in detail[\"tags\"]\n                                          if tag[\"is_registered\"]]\n            if transform_tags:\n                transform_tags(novel)\n            novel[\"num\"] = 0\n            novel[\"date\"] = dt.parse_iso(novel[\"create_date\"])\n            novel[\"rating\"] = ratings.get(novel[\"x_restrict\"])\n            novel[\"suffix\"] = \"\"\n\n            yield Message.Directory, \"\", novel\n\n            try:\n                content = self.api.novel_webview(novel[\"id\"])[\"text\"]\n            except Exception:\n                self.log.warning(\"Unable to download novel %s\", novel[\"id\"])\n                continue\n\n            novel[\"extension\"] = \"txt\"\n            yield Message.Url, \"text:\" + content, novel\n\n            if covers:\n                path = novel[\"image_urls\"][\"large\"].partition(\"/img/\")[2]\n                url = (\"https://i.pximg.net/novel-cover-original/img/\" +\n                       path.rpartition(\".\")[0].replace(\"_master1200\", \"\"))\n                novel[\"date_url\"] = self._date_from_url(url)\n                novel[\"num\"] += 1\n                novel[\"suffix\"] = f\"_p{novel['num']:02}\"\n                novel[\"_fallback\"] = (url + \".png\",)\n                url_jpg = url + \".jpg\"\n                text.nameext_from_url(url_jpg, novel)\n                yield Message.Url, url_jpg, novel\n                del novel[\"_fallback\"]\n\n            if embeds:\n                desktop = False\n                illusts = {}\n\n                for marker in text.extract_iter(content, \"[\", \"]\"):\n                    if marker.startswith(\"uploadedimage:\"):\n                        desktop = True\n                    elif marker.startswith(\"pixivimage:\"):\n                        illusts[marker[11:].partition(\"-\")[0]] = None\n\n                if desktop:\n                    try:\n                        body = self._request_ajax(\"/novel/\" + str(novel[\"id\"]))\n                        images = body[\"textEmbeddedImages\"].values()\n                    except Exception as exc:\n                        self.log.warning(\n                            \"%s: Failed to get embedded novel images (%s: %s)\",\n                            novel[\"id\"], exc.__class__.__name__, exc)\n                        images = ()\n\n                    for image in images:\n                        url = image.pop(\"urls\")[\"original\"]\n                        novel.update(image)\n                        novel[\"date_url\"] = self._date_from_url(url)\n                        novel[\"num\"] += 1\n                        novel[\"suffix\"] = f\"_p{novel['num']:02}\"\n                        text.nameext_from_url(url, novel)\n                        yield Message.Url, url, novel\n\n                if illusts:\n                    novel[\"_extractor\"] = PixivWorkExtractor\n                    novel[\"date_url\"] = None\n                    for illust_id in illusts:\n                        novel[\"num\"] += 1\n                        novel[\"suffix\"] = f\"_p{novel['num']:02}\"\n                        url = f\"{self.root}/artworks/{illust_id}\"\n                        yield Message.Queue, url, novel\n\n\nclass PixivNovelNovelExtractor(PixivNovelExtractor):\n    \"\"\"Extractor for pixiv novels\"\"\"\n    subcategory = \"novel\"\n    pattern = BASE_PATTERN + r\"/n(?:ovel/show\\.php\\?id=|/)(\\d+)\"\n    example = \"https://www.pixiv.net/novel/show.php?id=12345\"\n\n    def novels(self):\n        novel = self.api.novel_detail(self.novel_id)\n        if self.config(\"full-series\") and novel[\"series\"]:\n            self.subcategory = PixivNovelSeriesExtractor.subcategory\n            return self.api.novel_series(novel[\"series\"][\"id\"])\n        return (novel,)\n\n\nclass PixivNovelUserExtractor(PixivNovelExtractor):\n    \"\"\"Extractor for pixiv users' novels\"\"\"\n    subcategory = \"user\"\n    pattern = USER_PATTERN + r\"/novels\"\n    example = \"https://www.pixiv.net/en/users/12345/novels\"\n\n    def novels(self):\n        return self.api.user_novels(self.novel_id)\n\n\nclass PixivNovelSeriesExtractor(PixivNovelExtractor):\n    \"\"\"Extractor for pixiv novel series\"\"\"\n    subcategory = \"series\"\n    pattern = BASE_PATTERN + r\"/novel/series/(\\d+)\"\n    example = \"https://www.pixiv.net/novel/series/12345\"\n\n    def novels(self):\n        return self.api.novel_series(self.novel_id)\n\n\nclass PixivNovelBookmarkExtractor(PixivNovelExtractor):\n    \"\"\"Extractor for bookmarked pixiv novels\"\"\"\n    subcategory = \"bookmark\"\n    pattern = (USER_PATTERN + r\"/bookmarks/novels\"\n               r\"(?:/([^/?#]+))?(?:/?\\?([^#]+))?\")\n    example = \"https://www.pixiv.net/en/users/12345/bookmarks/novels\"\n\n    def novels(self):\n        user_id, tag, query = self.groups\n        tag = text.unquote(tag) if tag else None\n\n        if text.parse_query(query).get(\"rest\") == \"hide\":\n            restrict = \"private\"\n        else:\n            restrict = \"public\"\n\n        return self.api.user_bookmarks_novel(user_id, tag, restrict)\n\n\n###############################################################################\n# API #########################################################################\n\nclass PixivAppAPI():\n    \"\"\"Minimal interface for the Pixiv App API for mobile devices\n\n    For a more complete implementation or documentation, see\n    - https://github.com/upbit/pixivpy\n    - https://gist.github.com/ZipFile/3ba99b47162c23f8aea5d5942bb557b1\n    \"\"\"\n    CLIENT_ID = \"MOBrBDS8blbauoSck0ZfDbtuzpyT\"\n    CLIENT_SECRET = \"lsACyCD94FhDUtGTXi3QzcFE2uU1hqtDaKeqrdwj\"\n    HASH_SECRET = (\"28c1fdd170a5204386cb1313c7077b34\"\n                   \"f83e4aaf4aa829ce78c231e05b0bae2c\")\n\n    def __init__(self, extractor):\n        self.extractor = extractor\n        self.log = extractor.log\n        self.exc = extractor.exc\n        self.username = extractor._get_auth_info()[0]\n        self.user = None\n\n        extractor.headers_web = extractor.session.headers.copy()\n        extractor.session.headers.update({\n            \"App-OS\"        : \"ios\",\n            \"App-OS-Version\": \"16.7.2\",\n            \"App-Version\"   : \"7.19.1\",\n            \"User-Agent\"    : \"PixivIOSApp/7.19.1 (iOS 16.7.2; iPhone12,8)\",\n            \"Referer\"       : \"https://app-api.pixiv.net/\",\n        })\n\n        self.client_id = extractor.config(\n            \"client-id\", self.CLIENT_ID)\n        self.client_secret = extractor.config(\n            \"client-secret\", self.CLIENT_SECRET)\n\n        token = extractor.config(\"refresh-token\")\n        if token is None or token == \"cache\":\n            token = extractor.cache(\n                _refresh_token_cache, self.username, _mem=False)\n        self.refresh_token = token\n\n    def login(self):\n        \"\"\"Login and gain an access token\"\"\"\n        self.user, auth = self.extractor.cache(\n            self._login_impl, self.username, _exp=3600, _mem=False)\n        self.extractor.session.headers[\"Authorization\"] = auth\n\n    def _login_impl(self, username):\n        if not self.refresh_token:\n            raise self.exc.AuthenticationError(\n                \"'refresh-token' required.\\n\"\n                \"Run `gallery-dl oauth:pixiv` to get one.\")\n\n        self.log.info(\"Refreshing access token\")\n        url = \"https://oauth.secure.pixiv.net/auth/token\"\n        data = {\n            \"client_id\"     : self.client_id,\n            \"client_secret\" : self.client_secret,\n            \"grant_type\"    : \"refresh_token\",\n            \"refresh_token\" : self.refresh_token,\n            \"get_secure_url\": \"1\",\n        }\n\n        time = dt.now().strftime(\"%Y-%m-%dT%H:%M:%S+00:00\")\n        headers = {\n            \"X-Client-Time\": time,\n            \"X-Client-Hash\": hashlib.md5(\n                (time + self.HASH_SECRET).encode()).hexdigest(),\n        }\n\n        response = self.extractor.request(\n            url, method=\"POST\", headers=headers, data=data, fatal=False)\n        if response.status_code >= 400:\n            self.log.debug(response.text)\n            raise self.exc.AuthenticationError(\"Invalid refresh token\")\n\n        data = response.json()[\"response\"]\n        return data[\"user\"], \"Bearer \" + data[\"access_token\"]\n\n    def illust_detail(self, illust_id):\n        params = {\"illust_id\": illust_id}\n        return self._call(\"/v1/illust/detail\", params)[\"illust\"]\n\n    def illust_bookmark_detail(self, illust_id):\n        params = {\"illust_id\": illust_id}\n        return self._call(\n            \"/v2/illust/bookmark/detail\", params)[\"bookmark_detail\"]\n\n    def illust_comments(self, illust_id):\n        params = {\"illust_id\": illust_id}\n        return self._pagination(\"/v3/illust/comments\", params, \"comments\")\n\n    def illust_follow(self, restrict=\"all\"):\n        params = {\"restrict\": restrict}\n        return self._pagination(\"/v2/illust/follow\", params)\n\n    def illust_ranking(self, mode=\"day\", date=None):\n        params = {\"mode\": mode, \"date\": date}\n        return self._pagination(\"/v1/illust/ranking\", params)\n\n    def illust_related(self, illust_id):\n        params = {\"illust_id\": illust_id}\n        return self._pagination(\"/v2/illust/related\", params)\n\n    def illust_series(self, series_id, offset=0):\n        params = {\"illust_series_id\": series_id, \"offset\": offset}\n        return self._pagination(\"/v1/illust/series\", params,\n                                key_data=\"illust_series_detail\")\n\n    def novel_bookmark_detail(self, novel_id):\n        params = {\"novel_id\": novel_id}\n        return self._call(\n            \"/v2/novel/bookmark/detail\", params)[\"bookmark_detail\"]\n\n    def novel_comments(self, novel_id):\n        params = {\"novel_id\": novel_id}\n        return self._pagination(\"/v1/novel/comments\", params, \"comments\")\n\n    def novel_detail(self, novel_id):\n        params = {\"novel_id\": novel_id}\n        return self._call(\"/v2/novel/detail\", params)[\"novel\"]\n\n    def novel_series(self, series_id):\n        params = {\"series_id\": series_id}\n        return self._pagination(\"/v1/novel/series\", params, \"novels\")\n\n    def novel_text(self, novel_id):\n        params = {\"novel_id\": novel_id}\n        return self._call(\"/v1/novel/text\", params)\n\n    def novel_webview(self, novel_id):\n        params = {\"id\": novel_id, \"viewer_version\": \"20221031_ai\"}\n        return self._call(\n            \"/webview/v2/novel\", params, self._novel_webview_parse)\n\n    def _novel_webview_parse(self, response):\n        return util.json_loads(text.extr(\n            response.text, \"novel: \", \",\\n\"))\n\n    def search_illust(self, word, sort=None, target=None, duration=None,\n                      date_start=None, date_end=None):\n        params = {\"word\": word, \"search_target\": target,\n                  \"sort\": sort, \"duration\": duration,\n                  \"start_date\": date_start, \"end_date\": date_end}\n        return self._pagination_search(\"/v1/search/illust\", params)\n\n    def user_bookmarks_illust(self, user_id, tag=None, restrict=\"public\"):\n        \"\"\"Return illusts bookmarked by a user\"\"\"\n        params = {\"user_id\": user_id, \"tag\": tag, \"restrict\": restrict}\n        return self._pagination(\"/v1/user/bookmarks/illust\", params)\n\n    def user_bookmarks_novel(self, user_id, tag=None, restrict=\"public\"):\n        \"\"\"Return novels bookmarked by a user\"\"\"\n        params = {\"user_id\": user_id, \"tag\": tag, \"restrict\": restrict}\n        return self._pagination(\"/v1/user/bookmarks/novel\", params, \"novels\")\n\n    def user_bookmark_tags_illust(self, user_id, restrict=\"public\"):\n        \"\"\"Return bookmark tags defined by a user\"\"\"\n        params = {\"user_id\": user_id, \"restrict\": restrict}\n        return self._pagination(\n            \"/v1/user/bookmark-tags/illust\", params, \"bookmark_tags\")\n\n    def user_detail(self, user_id, fatal=True):\n        return self.extractor.cache(self._user_detail_impl, user_id, fatal)\n\n    def _user_detail_impl(self, user_id, fatal):\n        params = {\"user_id\": user_id}\n        return self._call(\"/v1/user/detail\", params, fatal=fatal)\n\n    def user_following(self, user_id, restrict=\"public\"):\n        params = {\"user_id\": user_id, \"restrict\": restrict}\n        return self._pagination(\"/v1/user/following\", params, \"user_previews\")\n\n    def user_illusts(self, user_id):\n        params = {\"user_id\": user_id}\n        return self._pagination(\"/v1/user/illusts\", params, key_user=\"user\")\n\n    def user_novels(self, user_id):\n        params = {\"user_id\": user_id}\n        return self._pagination(\"/v1/user/novels\", params, \"novels\")\n\n    def ugoira_metadata(self, illust_id):\n        params = {\"illust_id\": illust_id}\n        return self._call(\"/v1/ugoira/metadata\", params)[\"ugoira_metadata\"]\n\n    def _call(self, endpoint, params=None, parse=None, fatal=True):\n        url = \"https://app-api.pixiv.net\" + endpoint\n\n        while True:\n            self.login()\n            response = self.extractor.request(url, params=params, fatal=False)\n\n            if parse:\n                data = parse(response)\n            else:\n                data = response.json()\n\n            if \"error\" not in data or not fatal:\n                return data\n\n            self.log.debug(data)\n\n            if response.status_code == 404:\n                raise self.exc.NotFoundError()\n\n            error = data[\"error\"]\n            if \"rate limit\" in (error.get(\"message\") or \"\").lower():\n                self.extractor.wait(seconds=300)\n                continue\n\n            msg = (f\"'{msg}'\" if (msg := error.get(\"user_message\")) else\n                   f\"'{msg}'\" if (msg := error.get(\"message\")) else\n                   error)\n            raise self.exc.AbortExtraction(\"API request failed: \" + msg)\n\n    def _pagination(self, endpoint, params,\n                    key_items=\"illusts\", key_data=None, key_user=None):\n        data = self._call(endpoint, params)\n\n        if key_data is not None:\n            self.data = data.get(key_data)\n        if key_user is not None and not data[key_user].get(\"id\"):\n            user = self.user_detail(self.extractor.user_id, fatal=False)\n            if user.get(\"error\"):\n                raise self.exc.NotFoundError(\"user\")\n            return\n\n        while True:\n            yield from data[key_items]\n\n            if not data[\"next_url\"]:\n                return\n            query = data[\"next_url\"].rpartition(\"?\")[2]\n            params = text.parse_query(query)\n            data = self._call(endpoint, params)\n\n    def _pagination_search(self, endpoint, params):\n        sort = params[\"sort\"]\n        if sort == \"date_desc\":\n            date_key = \"end_date\"\n            date_off = dt.timedelta(days=1)\n            date_cmp = lambda lhs, rhs: lhs >= rhs  # noqa E731\n        elif sort == \"date_asc\":\n            date_key = \"start_date\"\n            date_off = dt.timedelta(days=-1)\n            date_cmp = lambda lhs, rhs: lhs <= rhs  # noqa E731\n        else:\n            date_key = None\n        date_last = None\n\n        while True:\n            data = self._call(endpoint, params)\n\n            if date_last is None:\n                yield from data[\"illusts\"]\n            else:\n                works = data[\"illusts\"]\n                if date_cmp(date_last, works[-1][\"create_date\"]):\n                    for work in works:\n                        if date_last is None:\n                            yield work\n                        elif date_cmp(date_last, work[\"create_date\"]):\n                            date_last = None\n\n            if not (next_url := data.get(\"next_url\")):\n                return\n            query = next_url.rpartition(\"?\")[2]\n            params = text.parse_query(query)\n\n            if date_key and text.parse_int(params.get(\"offset\")) >= 5000:\n                date_last = data[\"illusts\"][-1][\"create_date\"]\n                date_val = (dt.parse_iso(date_last) + date_off).strftime(\n                    \"%Y-%m-%d\")\n                self.log.info(\"Reached 'offset' >= 5000; \"\n                              \"Updating '%s' to '%s'\", date_key, date_val)\n                params[date_key] = date_val\n                params.pop(\"offset\", None)\n\n\ndef _refresh_token_cache(username):\n    return None\n"
  },
  {
    "path": "gallery_dl/extractor/pixnet.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2016-2023 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://www.pixnet.net/\"\"\"\n\nfrom .common import Extractor, Message\nfrom .. import text\n\nBASE_PATTERN = r\"(?:https?://)?(?!www\\.)([\\w-]+)\\.pixnet.net\"\n\n\nclass PixnetExtractor(Extractor):\n    \"\"\"Base class for pixnet extractors\"\"\"\n    category = \"pixnet\"\n    filename_fmt = \"{num:>03}_{id}.{extension}\"\n    archive_fmt = \"{id}\"\n\n    def __init__(self, match):\n        Extractor.__init__(self, match)\n        self.blog, self.item_id = self.groups\n        self.root = f\"https://{self.blog}.pixnet.net\"\n\n    def items(self):\n        url = self.url_fmt.format(self.root, self.item_id)\n        page = self.request(url, encoding=\"utf-8\").text\n        user = text.extr(page, '<meta name=\"author\" content=\"', '\";')\n        data = {\n            \"blog\": self.blog,\n            \"user\": user.rpartition(\" (\")[0],\n        }\n\n        for info in self._pagination(page):\n            url, pos = text.extract(info, ' href=\"', '\"')\n            alt, pos = text.extract(info, ' alt=\"', '\"', pos)\n            item = {\n                \"id\"        : text.parse_int(url.rpartition(\"/\")[2]),\n                \"title\"     : text.unescape(alt),\n                \"_extractor\": (PixnetFolderExtractor if \"/folder/\" in url else\n                               PixnetSetExtractor),\n            }\n            item.update(data)\n            yield Message.Queue, url, item\n\n    def _pagination(self, page):\n        while True:\n            yield from text.extract_iter(page, '<li id=\"', '</li>')\n\n            pnext = text.extr(page, 'class=\"nextBtn\"', '>')\n            if pnext is None and 'name=\"albumpass\">' in page:\n                raise self.exc.AbortExtraction(\n                    f\"Album {self.item_id} is password-protected.\")\n            if \"href\" not in pnext:\n                return\n            url = self.root + text.extr(pnext, 'href=\"', '\"')\n            page = self.request(url, encoding=\"utf-8\").text\n\n\nclass PixnetImageExtractor(PixnetExtractor):\n    \"\"\"Extractor for a single photo from pixnet.net\"\"\"\n    subcategory = \"image\"\n    filename_fmt = \"{id}.{extension}\"\n    directory_fmt = (\"{category}\", \"{blog}\")\n    pattern = BASE_PATTERN + r\"/album/photo/(\\d+)\"\n    example = \"https://USER.pixnet.net/album/photo/12345\"\n\n    def items(self):\n        url = \"https://api.pixnet.cc/oembed\"\n        params = {\n            \"url\": (f\"https://{self.blog}.pixnet.net\"\n                    f\"/album/photo/{self.item_id}\"),\n            \"format\": \"json\",\n        }\n\n        data = self.request_json(url, params=params)\n        data[\"id\"] = text.parse_int(\n            data[\"url\"].rpartition(\"/\")[2].partition(\"-\")[0])\n        data[\"filename\"], _, data[\"extension\"] = data[\"title\"].rpartition(\".\")\n        data[\"blog\"] = self.blog\n        data[\"user\"] = data.pop(\"author_name\")\n\n        yield Message.Directory, \"\", data\n        yield Message.Url, data[\"url\"], data\n\n\nclass PixnetSetExtractor(PixnetExtractor):\n    \"\"\"Extractor for images from a pixnet set\"\"\"\n    subcategory = \"set\"\n    directory_fmt = (\"{category}\", \"{blog}\",\n                     \"{folder_id} {folder_title}\", \"{set_id} {set_title}\")\n    pattern = BASE_PATTERN + r\"/album/set/(\\d+)\"\n    example = \"https://USER.pixnet.net/album/set/12345\"\n\n    def items(self):\n        url = f\"{self.root}/album/set/{self.item_id}\"\n        page = self.request(url, encoding=\"utf-8\").text\n        data = self.metadata(page)\n\n        yield Message.Directory, \"\", data\n        for num, info in enumerate(self._pagination(page), 1):\n            url, pos = text.extract(info, ' href=\"', '\"')\n            src, pos = text.extract(info, ' src=\"', '\"', pos)\n            alt, pos = text.extract(info, ' alt=\"', '\"', pos)\n\n            photo = {\n                \"id\": text.parse_int(url.rpartition(\"/\")[2].partition(\"#\")[0]),\n                \"url\": src.replace(\"_s.\", \".\"),\n                \"num\": num,\n                \"filename\": alt,\n                \"extension\": src.rpartition(\".\")[2],\n            }\n            photo.update(data)\n            yield Message.Url, photo[\"url\"], photo\n\n    def metadata(self, page):\n        user , pos = text.extract(page, '<meta name=\"author\" content=\"', '\";')\n        _    , pos = text.extract(page, 'id=\"breadcrumb\"', '', pos)\n        fid  , pos = text.extract(page, '/folder/', '\"', pos)\n        fname, pos = text.extract(page, '>', '<', pos)\n        sid  , pos = text.extract(page, '/set/', '\"', pos)\n        sname, pos = text.extract(page, '>', '<', pos)\n        return {\n            \"blog\": self.blog,\n            \"user\": user.rpartition(\" (\")[0],\n            \"folder_id\"   : text.parse_int(fid, \"\"),\n            \"folder_title\": text.unescape(fname).strip(),\n            \"set_id\"      : text.parse_int(sid),\n            \"set_title\"   : text.unescape(sname),\n        }\n\n\nclass PixnetFolderExtractor(PixnetExtractor):\n    \"\"\"Extractor for all sets in a pixnet folder\"\"\"\n    subcategory = \"folder\"\n    url_fmt = \"{}/album/folder/{}\"\n    pattern = BASE_PATTERN + r\"/album/folder/(\\d+)\"\n    example = \"https://USER.pixnet.net/album/folder/12345\"\n\n\nclass PixnetUserExtractor(PixnetExtractor):\n    \"\"\"Extractor for all sets and folders of a pixnet user\"\"\"\n    subcategory = \"user\"\n    url_fmt = \"{}{}/album/list\"\n    pattern = BASE_PATTERN + r\"()(?:/blog|/album(?:/list)?)?/?(?:$|[?#])\"\n    example = \"https://USER.pixnet.net/\"\n"
  },
  {
    "path": "gallery_dl/extractor/plurk.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2019-2025 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://www.plurk.com/\"\"\"\n\nfrom .common import Extractor, Message\nfrom .. import text, util, dt\n\n\nclass PlurkExtractor(Extractor):\n    \"\"\"Base class for plurk extractors\"\"\"\n    category = \"plurk\"\n    root = \"https://www.plurk.com\"\n    request_interval = (0.5, 1.5)\n\n    def items(self):\n        urls = self._urls_ex if self.config(\"comments\", False) else self._urls\n        for plurk in self.plurks():\n            for url in urls(plurk):\n                yield Message.Queue, url, plurk\n\n    def plurks(self):\n        \"\"\"Return an iterable with all relevant 'plurk' objects\"\"\"\n\n    def _urls(self, obj):\n        \"\"\"Extract URLs from a 'plurk' object\"\"\"\n        return text.extract_iter(obj[\"content\"], ' href=\"', '\"')\n\n    def _urls_ex(self, plurk):\n        \"\"\"Extract URLs from a 'plurk' and its comments\"\"\"\n        yield from self._urls(plurk)\n        for comment in self._comments(plurk):\n            yield from self._urls(comment)\n\n    def _comments(self, plurk):\n        \"\"\"Return an iterable with a 'plurk's comments\"\"\"\n        url = \"https://www.plurk.com/Responses/get\"\n        data = {\"plurk_id\": plurk[\"id\"], \"count\": \"200\"}\n        headers = {\n            \"Origin\": self.root,\n            \"Referer\": self.root,\n            \"X-Requested-With\": \"XMLHttpRequest\",\n        }\n\n        while True:\n            info = self.request_json(\n                url, method=\"POST\", headers=headers, data=data)\n            yield from info[\"responses\"]\n            if not info[\"has_newer\"]:\n                return\n            elif info[\"has_newer\"] < 200:\n                del data[\"count\"]\n            data[\"from_response_id\"] = info[\"responses\"][-1][\"id\"] + 1\n\n    def _load(self, data):\n        if not data:\n            raise self.exc.NotFoundError(\"user\")\n        return util.json_loads(\n            text.re(r\"new Date\\(([^)]+)\\)\").sub(r\"\\1\", data))\n\n\nclass PlurkTimelineExtractor(PlurkExtractor):\n    \"\"\"Extractor for URLs from all posts in a Plurk timeline\"\"\"\n    subcategory = \"timeline\"\n    pattern = r\"(?:https?://)?(?:www\\.)?plurk\\.com/(?!p/)(\\w+)/?(?:$|[?#])\"\n    example = \"https://www.plurk.com/USER\"\n\n    def __init__(self, match):\n        PlurkExtractor.__init__(self, match)\n        self.user = match[1]\n\n    def plurks(self):\n        url = f\"{self.root}/{self.user}\"\n        page = self.request(url).text\n        user_id, pos = text.extract(page, '\"page_user\": {\"id\":', ',')\n        plurks = self._load(text.extract(page, \"_PLURKS = \", \";\\n\", pos)[0])\n\n        headers = {\"Referer\": url, \"X-Requested-With\": \"XMLHttpRequest\"}\n        data = {\"user_id\": user_id.strip()}\n        url = \"https://www.plurk.com/TimeLine/getPlurks\"\n\n        while plurks:\n            yield from plurks\n\n            offset = dt.parse(plurks[-1][\"posted\"], \"%a, %d %b %Y %H:%M:%S %Z\")\n            data[\"offset\"] = offset.strftime(\"%Y-%m-%dT%H:%M:%S.000Z\")\n            plurks = self.request_json(\n                url, method=\"POST\", headers=headers, data=data)[\"plurks\"]\n\n\nclass PlurkPostExtractor(PlurkExtractor):\n    \"\"\"Extractor for URLs from a Plurk post\"\"\"\n    subcategory = \"post\"\n    pattern = r\"(?:https?://)?(?:www\\.)?plurk\\.com/p/(\\w+)\"\n    example = \"https://www.plurk.com/p/12345\"\n\n    def plurks(self):\n        url = f\"{self.root}/p/{self.groups[0]}\"\n        page = self.request(url).text\n        user, pos = text.extract(page, \" GLOBAL=\", \"\\n\")\n        data, pos = text.extract(page, \"plurk =\", \";\\n\", pos)\n\n        data = self._load(data)\n        try:\n            data[\"user\"] = self._load(user)[\"page_user\"]\n        except Exception:\n            self.log.warning(\"%s: Failed to extract 'user' data\",\n                             self.groups[0])\n        return (data,)\n"
  },
  {
    "path": "gallery_dl/extractor/poipiku.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2022-2025 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://poipiku.com/\"\"\"\n\nfrom .common import Extractor, Message\nfrom .. import text\n\nBASE_PATTERN = r\"(?:https?://)?poipiku\\.com\"\n\n\nclass PoipikuExtractor(Extractor):\n    \"\"\"Base class for poipiku extractors\"\"\"\n    category = \"poipiku\"\n    root = \"https://poipiku.com\"\n    directory_fmt = (\"{category}\", \"{user_id} {user_name}\")\n    filename_fmt = \"{post_id}_{num}.{extension}\"\n    archive_fmt = \"{post_id}_{num}\"\n    cookies_domain = \"poipiku.com\"\n    cookies_warning = True\n    request_interval = (0.5, 1.5)\n\n    def _init(self):\n        self.cookies.set(\n            \"LANG\", \"en\", domain=self.cookies_domain)\n        self.cookies.set(\n            \"POIPIKU_CONTENTS_VIEW_MODE\", \"1\", domain=self.cookies_domain)\n        self.headers = {\n            \"Accept\" : \"application/json, text/javascript, */*; q=0.01\",\n            \"X-Requested-With\": \"XMLHttpRequest\",\n            \"Origin\" : self.root,\n            \"Referer\": None,\n            \"Sec-Fetch-Dest\": \"empty\",\n            \"Sec-Fetch-Mode\": \"cors\",\n            \"Sec-Fetch-Site\": \"same-origin\",\n        }\n        self.password = self.config(\"password\", \"\")\n\n    def items(self):\n        if self.cookies_check((\"POIPIKU_LK\",)):\n            extract_files = self._extract_files_auth\n            logged_in = True\n        else:\n            extract_files = self._extract_files_noauth\n            logged_in = False\n            if self.cookies_warning:\n                self.log.warning(\"no 'POIPIKU_LK' cookie set\")\n                PoipikuExtractor.cookies_warning = False\n\n        for post_url in self.posts():\n            if post_url[0] == \"/\":\n                post_url = self.root + post_url\n            page = self.request(post_url).text\n            extr = text.extract_from(page)\n            parts = post_url.rsplit(\"/\", 2)\n            post = {\n                \"post_category\": extr(\"<title>[\", \"]\"),\n                \"count\"      : text.parse_int(extr(\"(\", \" \")),\n                \"post_id\"    : parts[2].partition(\".\")[0],\n                \"user_id\"    : parts[1],\n                \"user_name\"  : text.unescape(extr(\n                    '<h2 class=\"UserInfoUserName\">', '</').rpartition(\">\")[2]),\n                \"description\": text.unescape(extr(\n                    'class=\"IllustItemDesc\" >', '</h1>')),\n                \"warning\"    : False,\n                \"password\"   : False,\n                \"requires\"   : None,\n                \"original\"   : logged_in,\n                \"_http_headers\": {\"Referer\": post_url},\n            }\n\n            thumb = self._extract_thumb(post, extr)\n            self.headers[\"Referer\"] = post_url\n\n            if post[\"requires\"] and not post[\"password\"] and extr(\n                    \"PasswordIcon\", \">\"):\n                post[\"password\"] = True\n\n            yield Message.Directory, \"\", post\n            for post[\"num\"], url in enumerate(extract_files(\n                    post, thumb, extr), 1):\n                yield Message.Url, url, text.nameext_from_url(url, post)\n\n    def _extract_thumb(self, post, extr):\n        thumb = \"\"\n\n        while True:\n            img = extr('class=\"IllustItemThumbImg\" src=\"', '\"')\n\n            if not img:\n                return thumb\n            elif img.startswith(\"https://cdn.poipiku.com/img/\"):\n                self.log.debug(\"%s: %s\", post[\"post_id\"], img)\n                type = text.rextr(img, \"/\", \".\")\n                if type == \"warning\":\n                    post[\"warning\"] = True\n                elif type == \"publish_pass\":\n                    post[\"password\"] = True\n                elif type == \"publish_login\":\n                    post[\"requires\"] = \"login\"\n                elif type == \"publish_follower\":\n                    post[\"requires\"] = \"follow\"\n                elif type == \"publish_t_rt\":\n                    post[\"requires\"] = \"retweet\"\n            elif img.startswith((\n                \"https://img.poipiku.com/img/\",\n                \"//img.poipiku.com/img/\",\n                \"/img/\",\n            )):\n                self.log.debug(\"%s: %s\", post[\"post_id\"], img)\n                if \"/warning\" in img:\n                    post[\"warning\"] = True\n            else:\n                thumb = img\n\n    def _extract_files_auth(self, post, thumb, extr):\n        data = self._show_illust_detail(post)\n\n        if data.get(\"error_code\"):\n            data = self._show_append_file(post)\n            html = data[\"html\"]\n            self.log.warning(\"%s: '%s'\",\n                             post[\"post_id\"], html.replace(\"<br/>\", \" \"))\n            return ()\n        return text.extract_iter(data[\"html\"], 'src=\"', '\"')\n\n    def _extract_files_noauth(self, post, thumb, extr):\n        if thumb:\n            if not extr('ShowAppendFile', '<'):\n                return (thumb,)\n            files = [thumb]\n        else:\n            files = []\n\n        data = self._show_append_file(post)\n        html = data[\"html\"]\n        if (data.get(\"result_num\") or 0) < 0:\n            self.log.warning(\"%s: '%s'\",\n                             post[\"post_id\"], html.replace(\"<br/>\", \" \"))\n\n        files.extend(text.extract_iter(\n            html, 'class=\"IllustItemThumbImg\" src=\"', '\"'))\n        return files\n\n    def _show_illust_detail(self, post):\n        url = self.root + \"/f/ShowIllustDetailF.jsp\"\n        data = {\n            \"ID\" : post[\"user_id\"],\n            \"TD\" : post[\"post_id\"],\n            \"AD\" : \"-1\",\n            \"PAS\": self.password,\n        }\n        return self.request_json(\n            url, method=\"POST\", headers=self.headers, data=data,\n            interval=False)\n\n    def _show_append_file(self, post):\n        url = self.root + \"/f/ShowAppendFileF.jsp\"\n        data = {\n            \"UID\": post[\"user_id\"],\n            \"IID\": post[\"post_id\"],\n            \"PAS\": self.password,\n            \"MD\" : \"0\",\n            \"TWF\": \"-1\",\n        }\n        return self.request_json(\n            url, method=\"POST\", headers=self.headers, data=data,\n            interval=False)\n\n\nclass PoipikuUserExtractor(PoipikuExtractor):\n    \"\"\"Extractor for posts from a poipiku user\"\"\"\n    subcategory = \"user\"\n    pattern = (BASE_PATTERN + r\"/(?:IllustListPcV\\.jsp\\?PG=(\\d+)&ID=)?\"\n               r\"(\\d+)/?(?:$|[?&#])\")\n    example = \"https://poipiku.com/12345/\"\n\n    def posts(self):\n        pnum, user_id = self.groups\n\n        url = self.root + \"/IllustListPcV.jsp\"\n        params = {\n            \"PG\" : text.parse_int(pnum, 0),\n            \"ID\" : user_id,\n            \"KWD\": \"\",\n        }\n\n        while True:\n            page = self.request(url, params=params).text\n\n            cnt = 0\n            for path in text.extract_iter(\n                    page, 'class=\"IllustInfo\" href=\"', '\"'):\n                yield path\n                cnt += 1\n\n            if cnt < 48:\n                return\n            params[\"PG\"] += 1\n\n\nclass PoipikuPostExtractor(PoipikuExtractor):\n    \"\"\"Extractor for a poipiku post\"\"\"\n    subcategory = \"post\"\n    pattern = BASE_PATTERN + r\"/(\\d+)/(\\d+)\"\n    example = \"https://poipiku.com/12345/12345.html\"\n\n    def posts(self):\n        user_id, post_id = self.groups\n        return (f\"/{user_id}/{post_id}.html\",)\n"
  },
  {
    "path": "gallery_dl/extractor/poringa.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for http://www.poringa.net/\"\"\"\n\nfrom .common import Extractor, Message\nfrom .. import text\nimport itertools\n\nBASE_PATTERN = r\"(?:https?://)?(?:www\\.)?poringa\\.net\"\n\n\nclass PoringaExtractor(Extractor):\n    category = \"poringa\"\n    directory_fmt = (\"{category}\", \"{user}\", \"{post_id}\")\n    filename_fmt = \"{post_id}_{title}_{num:>03}_{filename}.{extension}\"\n    archive_fmt = \"{post_id}_{num}\"\n    root = \"http://www.poringa.net\"\n\n    def __init__(self, match):\n        Extractor.__init__(self, match)\n        self.item = match[1]\n        self.__cookies = True\n\n    def items(self):\n        for post_id in self.posts():\n            url = f\"{self.root}/posts/imagenes/{post_id}\"\n\n            try:\n                response = self.request(url)\n            except self.exc.HttpError as exc:\n                self.log.warning(\n                    \"Unable to fetch posts for '%s' (%s)\", post_id, exc)\n                continue\n\n            if \"/registro-login?\" in response.url:\n                self.log.warning(\"Private post '%s'\", post_id)\n                continue\n\n            page = response.text\n            title, pos = text.extract(\n                page, 'property=\"og:title\" content=\"', '\"')\n\n            try:\n                pos = page.index('<div class=\"main-info', pos)\n                user, pos = text.extract(\n                    page, 'href=\"http://www.poringa.net/', '\"', pos)\n            except ValueError:\n                user = None\n\n            if not user:\n                user = \"poringa\"\n\n            data = {\n                \"post_id\"      : post_id,\n                \"title\"        : text.unescape(title),\n                \"user\"         : text.unquote(user),\n                \"_http_headers\": {\"Referer\": url},\n            }\n\n            main_post = text.extr(\n                page, 'property=\"dc:content\" role=\"main\">', '</div>')\n            urls = list(text.extract_iter(\n                main_post, '<img class=\"imagen\" border=\"0\" src=\"', '\"'))\n            data[\"count\"] = len(urls)\n\n            yield Message.Directory, \"\", data\n            for data[\"num\"], url in enumerate(urls, 1):\n                yield Message.Url, url, text.nameext_from_url(url, data)\n\n    def posts(self):\n        return ()\n\n    def request(self, url, **kwargs):\n        if self.__cookies:\n            self.__cookies = False\n            self.cookies_update(self.cache(\n                _cookie_cache, _key=None, _mem=False))\n\n        for _ in range(5):\n            response = Extractor.request(self, url, **kwargs)\n            if response.cookies:\n                self.cache_update(_cookie_cache, None, response.cookies)\n            if response.content.find(\n                    b\"<title>Please wait a few moments</title>\", 0, 600) < 0:\n                return response\n            self.sleep(5.0, \"check\")\n\n    def _pagination(self, url, params):\n        for params[\"p\"] in itertools.count(1):\n            page = self.request(url, params=params).text\n\n            posts_ids = PoringaPostExtractor.pattern.findall(page)\n            posts_ids = list(dict.fromkeys(posts_ids))\n            yield from posts_ids\n\n            if len(posts_ids) < 19:\n                return\n\n\nclass PoringaPostExtractor(PoringaExtractor):\n    \"\"\"Extractor for posts on poringa.net\"\"\"\n    subcategory = \"post\"\n    pattern = BASE_PATTERN + r\"/posts/imagenes/(\\d+)\"\n    example = \"http://www.poringa.net/posts/imagenes/12345/TITLE.html\"\n\n    def posts(self):\n        return (self.item,)\n\n\nclass PoringaUserExtractor(PoringaExtractor):\n    subcategory = \"user\"\n    pattern = BASE_PATTERN + r\"/(\\w+)$\"\n    example = \"http://www.poringa.net/USER\"\n\n    def posts(self):\n        url = self.root + \"/buscar/\"\n        params = {\"q\": self.item}\n        return self._pagination(url, params)\n\n\nclass PoringaSearchExtractor(PoringaExtractor):\n    subcategory = \"search\"\n    pattern = BASE_PATTERN + r\"/buscar/\\?&?q=([^&#]+)\"\n    example = \"http://www.poringa.net/buscar/?q=QUERY\"\n\n    def posts(self):\n        url = self.root + \"/buscar/\"\n        params = {\"q\": self.item}\n        return self._pagination(url, params)\n\n\ndef _cookie_cache():\n    return ()\n"
  },
  {
    "path": "gallery_dl/extractor/pornhub.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2019-2026 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://www.pornhub.com/\"\"\"\n\nfrom .common import Extractor, Message, Dispatch\nfrom .. import text\n\nBASE_PATTERN = r\"(?:https?://)?(?:[\\w-]+\\.)?pornhub\\.com\"\n\n\nclass PornhubExtractor(Extractor):\n    \"\"\"Base class for pornhub extractors\"\"\"\n    category = \"pornhub\"\n    root = \"https://www.pornhub.com\"\n\n    def _init(self):\n        self.cookies.set(\n            \"accessAgeDisclaimerPH\", \"1\", domain=\".pornhub.com\")\n\n    def _pagination(self, user, path):\n        if \"/\" not in path:\n            path += \"/public\"\n\n        url = f\"{self.root}/{user}/{path}/ajax\"\n        params = {\"page\": 1}\n        headers = {\n            \"Referer\": url[:-5],\n            \"X-Requested-With\": \"XMLHttpRequest\",\n        }\n\n        while True:\n            response = self.request(\n                url, method=\"POST\", headers=headers, params=params,\n                allow_redirects=False)\n\n            if 300 <= response.status_code < 400:\n                url = f\"{self.root}{response.headers['location']}/{path}/ajax\"\n                continue\n\n            yield response.text\n\n            params[\"page\"] += 1\n\n\nclass PornhubGalleryExtractor(PornhubExtractor):\n    \"\"\"Extractor for image galleries on pornhub.com\"\"\"\n    subcategory = \"gallery\"\n    directory_fmt = (\"{category}\", \"{user}\", \"{gallery[id]} {gallery[title]}\")\n    filename_fmt = \"{num:>03}_{id}.{extension}\"\n    archive_fmt = \"{id}\"\n    pattern = BASE_PATTERN + r\"/album/(\\d+)\"\n    example = \"https://www.pornhub.com/album/12345\"\n\n    def __init__(self, match):\n        PornhubExtractor.__init__(self, match)\n        self.gallery_id = match[1]\n        self._first = None\n\n    def items(self):\n        data = self.metadata()\n        yield Message.Directory, \"\", data\n        for num, img in enumerate(self.images(), 1):\n\n            image = {\n                \"url\"    : img[\"img_large\"],\n                \"caption\": img[\"caption\"],\n                \"id\"     : text.parse_int(img[\"id\"]),\n                \"views\"  : text.parse_int(img[\"times_viewed\"]),\n                \"score\"  : text.parse_int(img[\"vote_percent\"]),\n                \"num\"    : num,\n            }\n\n            url = image[\"url\"]\n            image.update(data)\n            yield Message.Url, url, text.nameext_from_url(url, image)\n\n    def metadata(self):\n        url = f\"{self.root}/album/{self.gallery_id}\"\n        extr = text.extract_from(self.request(url).text)\n\n        title = extr(\"<title>\", \"</title>\")\n        self._token = extr('data-token=\"', '\"')\n        score = extr('<div id=\"albumGreenBar\" style=\"width:', '\"')\n        views = extr('<div id=\"viewsPhotAlbumCounter\">', '<')\n        tags = extr('<div id=\"photoTagsBox\"', '<script')\n        self._first = extr('<a href=\"/photo/', '\"')\n        title, _, user = title.rpartition(\" - \")\n\n        return {\n            \"user\" : text.unescape(user[:-14]),\n            \"gallery\": {\n                \"id\"   : text.parse_int(self.gallery_id),\n                \"title\": text.unescape(title),\n                \"score\": text.parse_int(score.partition(\"%\")[0]),\n                \"views\": text.parse_int(views.partition(\" \")[0]),\n                \"tags\" : text.split_html(tags)[2:],\n            },\n        }\n\n    def images(self):\n        url = f\"{self.root}/api/v1/album/{self.gallery_id}/show_album_json\"\n        params = {\"token\": self._token}\n        data = self.request_json(url, params=params)\n\n        if not (images := data.get(\"photos\")):\n            raise self.exc.AuthorizationError()\n        key = end = self._first\n\n        results = []\n        try:\n            while True:\n                img = images[key]\n                results.append(img)\n                key = str(img[\"next\"])\n                if key == end:\n                    break\n        except KeyError:\n            self.log.warning(\"%s: Unable to ensure correct file order\",\n                             self.gallery_id)\n            return images.values()\n\n        return results\n\n\nclass PornhubGifExtractor(PornhubExtractor):\n    \"\"\"Extractor for pornhub.com gifs\"\"\"\n    subcategory = \"gif\"\n    directory_fmt = (\"{category}\", \"{user}\", \"gifs\")\n    filename_fmt = \"{id} {title}.{extension}\"\n    archive_fmt = \"{id}\"\n    pattern = BASE_PATTERN + r\"/gif/(\\d+)\"\n    example = \"https://www.pornhub.com/gif/12345\"\n\n    def __init__(self, match):\n        PornhubExtractor.__init__(self, match)\n        self.gallery_id = match[1]\n\n    def items(self):\n        url = f\"{self.root}/gif/{self.gallery_id}\"\n        extr = text.extract_from(self.request(url).text)\n\n        gif = {\n            \"id\"   : self.gallery_id,\n            \"tags\" : extr(\"data-context-tag='\", \"'\").split(\",\"),\n            \"title\": extr('\"name\": \"', '\"'),\n            \"url\"  : extr('\"contentUrl\": \"', '\"'),\n            \"date\" : self.parse_datetime_iso(extr('\"uploadDate\": \"', '\"')),\n            \"viewkey\"  : extr('From this video: '\n                              '<a href=\"/view_video.php?viewkey=', '\"'),\n            \"timestamp\": extr('lass=\"directLink tstamp\" rel=\"nofollow\">', '<'),\n            \"user\" : text.remove_html(extr(\"Created by:\", \"</div>\")),\n        }\n\n        yield Message.Directory, \"\", gif\n        yield Message.Url, gif[\"url\"], text.nameext_from_url(gif[\"url\"], gif)\n\n\nclass PornhubUserExtractor(Dispatch, PornhubExtractor):\n    \"\"\"Extractor for a pornhub user\"\"\"\n    pattern = BASE_PATTERN + r\"/((?:users|model|pornstar)/[^/?#]+)/?$\"\n    example = \"https://www.pornhub.com/model/USER\"\n\n    def items(self):\n        base = f\"{self.root}/{self.groups[0]}/\"\n        return self._dispatch_extractors((\n            (PornhubPhotosExtractor, base + \"photos\"),\n            (PornhubGifsExtractor  , base + \"gifs\"),\n        ), (\"photos\",))\n\n\nclass PornhubPhotosExtractor(PornhubExtractor):\n    \"\"\"Extractor for all galleries of a pornhub user\"\"\"\n    subcategory = \"photos\"\n    pattern = (BASE_PATTERN + r\"/((?:users|model|pornstar)/[^/?#]+)\"\n               \"/(photos(?:/[^/?#]+)?)\")\n    example = \"https://www.pornhub.com/model/USER/photos\"\n\n    def __init__(self, match):\n        PornhubExtractor.__init__(self, match)\n        self.user, self.path = match.groups()\n\n    def items(self):\n        data = {\"_extractor\": PornhubGalleryExtractor}\n        for page in self._pagination(self.user, self.path):\n            gid = None\n            for gid in text.extract_iter(page, 'id=\"albumphoto', '\"'):\n                yield Message.Queue, self.root + \"/album/\" + gid, data\n            if gid is None:\n                return\n\n\nclass PornhubGifsExtractor(PornhubExtractor):\n    \"\"\"Extractor for a pornhub user's gifs\"\"\"\n    subcategory = \"gifs\"\n    pattern = (BASE_PATTERN + r\"/((?:users|model|pornstar)/[^/?#]+)\"\n               \"/(gifs(?:/[^/?#]+)?)\")\n    example = \"https://www.pornhub.com/model/USER/gifs\"\n\n    def __init__(self, match):\n        PornhubExtractor.__init__(self, match)\n        self.user, self.path = match.groups()\n\n    def items(self):\n        data = {\"_extractor\": PornhubGifExtractor}\n        for page in self._pagination(self.user, self.path):\n            gid = None\n            for gid in text.extract_iter(page, 'id=\"gif', '\"'):\n                yield Message.Queue, self.root + \"/gif/\" + gid, data\n            if gid is None:\n                return\n"
  },
  {
    "path": "gallery_dl/extractor/pornpics.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2023-2025 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://www.pornpics.com/\"\"\"\n\nfrom .common import GalleryExtractor, Extractor, Message\nfrom .. import text\n\nBASE_PATTERN = r\"(?:https?://)?(?:www\\.)?pornpics\\.com(?:/\\w\\w)?\"\n\n\nclass PornpicsExtractor(Extractor):\n    \"\"\"Base class for pornpics extractors\"\"\"\n    category = \"pornpics\"\n    root = \"https://www.pornpics.com\"\n    request_interval = (0.5, 1.5)\n\n    def items(self):\n        for gallery in self.galleries():\n            gallery[\"_extractor\"] = PornpicsGalleryExtractor\n            yield Message.Queue, gallery[\"g_url\"], gallery\n\n    def _pagination(self, url, params=None):\n        if params is None:\n            # fetch first 20 galleries from HTML\n            # since '\"offset\": 0' does not return a JSON response\n            page = self.request(url).text\n            for href in text.extract_iter(\n                    page, 'class=\"rel-link\" href=\"', '\"'):\n                if href[0] == \"/\":\n                    href = self.root + href\n                yield {\"g_url\": href}\n            del page\n            params = {\"offset\": 20}\n\n        limit = params[\"limit\"] = 20\n        limit //= 2\n\n        headers = {\n            \"Accept\": \"application/json, text/javascript, */*; q=0.01\",\n            \"Referer\": url if params[\"offset\"] else self.root + \"/\",\n            \"X-Requested-With\": \"XMLHttpRequest\",\n        }\n\n        while True:\n            galleries = self.request_json(\n                url, params=params, headers=headers)\n            yield from galleries\n\n            if len(galleries) < limit:\n                return\n            params[\"offset\"] += limit\n\n\nclass PornpicsGalleryExtractor(PornpicsExtractor, GalleryExtractor):\n    \"\"\"Extractor for pornpics galleries\"\"\"\n    pattern = BASE_PATTERN + r\"/galleries/((?:[^/?#]+-)?(\\d+))\"\n    example = \"https://www.pornpics.com/galleries/TITLE-12345/\"\n\n    def __init__(self, match):\n        url = f\"{self.root}/galleries/{match[1]}/\"\n        GalleryExtractor.__init__(self, match, url)\n\n    items = GalleryExtractor.items\n\n    def metadata(self, page):\n        extr = text.extract_from(page)\n\n        return {\n            \"gallery_id\": text.parse_int(self.groups[1]),\n            \"slug\"      : extr(\"/galleries/\", \"/\").rpartition(\"-\")[0],\n            \"title\"     : text.unescape(extr(\"<h1>\", \"<\")),\n            \"channel\"   : text.split_html(extr(\">Channel:&nbsp;\", '</div>')),\n            \"models\"    : text.split_html(extr(\n                \">Models:\", '<span class=\"suggest')),\n            \"categories\": text.split_html(extr(\n                \">Categories:\", '<span class=\"suggest')),\n            \"tags\"      : text.split_html(extr(\n                \">Tags List:\", ' </div>')),\n            \"views\"    : text.parse_int(extr(\">Views:\", \"<\").replace(\",\", \"\")),\n        }\n\n    def images(self, page):\n        return [\n            (url, None)\n            for url in text.extract_iter(page, \"class='rel-link' href='\", \"'\")\n        ]\n\n\nclass PornpicsTagExtractor(PornpicsExtractor):\n    \"\"\"Extractor for galleries from pornpics tag searches\"\"\"\n    subcategory = \"tag\"\n    pattern = BASE_PATTERN + r\"/tags/([^/?#]+)\"\n    example = \"https://www.pornpics.com/tags/TAGS/\"\n\n    def galleries(self):\n        url = f\"{self.root}/tags/{self.groups[0]}/\"\n        return self._pagination(url)\n\n\nclass PornpicsSearchExtractor(PornpicsExtractor):\n    \"\"\"Extractor for galleries from pornpics search results\"\"\"\n    subcategory = \"search\"\n    pattern = BASE_PATTERN + r\"/(?:\\?q=|pornstars/|channels/)([^/&#]+)\"\n    example = \"https://www.pornpics.com/?q=QUERY\"\n\n    def galleries(self):\n        url = self.root + \"/search/srch.php\"\n        params = {\n            \"q\"     : self.groups[0].replace(\"-\", \" \"),\n            \"lang\"  : \"en\",\n            \"offset\": 0,\n        }\n        return self._pagination(url, params)\n\n\nclass PornpicsListingExtractor(PornpicsExtractor):\n    \"\"\"Extractor for galleries from pornpics listing pages\n\n    These pages (popular, recent, etc.) don't support JSON pagination\n    and use single quotes in HTML, unlike category pages.\n    \"\"\"\n    subcategory = \"listing\"\n    pattern = (BASE_PATTERN +\n               r\"/(popular|recent|rating|likes|views|comments)/?$\")\n    example = \"https://www.pornpics.com/popular/\"\n\n    def galleries(self):\n        url = f\"{self.root}/{self.groups[0]}/\"\n        page = self.request(url).text\n        return [\n            {\"g_url\": href}\n            for href in text.extract_iter(\n                page, \"class='rel-link' href='\", \"'\")\n        ]\n\n\nclass PornpicsCategoryExtractor(PornpicsExtractor):\n    \"\"\"Extractor for galleries from pornpics categories\"\"\"\n    subcategory = \"category\"\n    pattern = BASE_PATTERN + r\"/([^/?#]+)/?$\"\n    example = \"https://www.pornpics.com/ass/\"\n\n    def galleries(self):\n        url = f\"{self.root}/{self.groups[0]}/\"\n        return self._pagination(url)\n"
  },
  {
    "path": "gallery_dl/extractor/pornstarstube.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://pornstars.tube/\"\"\"\n\nfrom .common import GalleryExtractor\nfrom .. import text\n\n\nclass PornstarstubeGalleryExtractor(GalleryExtractor):\n    \"\"\"Extractor for image galleries from pornstars.tube\"\"\"\n    category = \"pornstarstube\"\n    root = \"https://pornstars.tube\"\n    pattern = (r\"(?:https?://)?(?:www\\.)?pornstars\\.tube\"\n               r\"/albums/(\\d+)(?:/([\\w-]+))?\")\n    example = \"https://pornstars.tube/albums/12345/SLUG/\"\n\n    def __init__(self, match):\n        url = f\"{self.root}/albums/{match[1]}/{match[2] or 'a'}/\"\n        GalleryExtractor.__init__(self, match, url)\n\n    def metadata(self, page):\n        gid, slug = self.groups\n        return {\n            \"gallery_id\": text.parse_int(gid),\n            \"slug\"      : slug or \"\",\n            \"title\"     : text.unescape(text.extr(\n                page, \"<title>\", \" - PORNSTARS.TUBE</title>\")),\n            \"description\": text.unescape(text.extr(\n                page, 'name=\"description\" content=\"', '\"')),\n            \"tags\": text.extr(\n                page, 'name=\"keywords\" content=\"', '\"').split(\", \"),\n        }\n\n    def images(self, page):\n        album = text.extr(page, 'class=\"block-album\"', \"\\n</div>\")\n        return [\n            (url, None)\n            for url in text.extract_iter(album, ' href=\"', '\"')\n        ]\n"
  },
  {
    "path": "gallery_dl/extractor/postmill.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for Postmill instances\"\"\"\n\nfrom .common import BaseExtractor, Message\nfrom .. import text\n\n\nclass PostmillExtractor(BaseExtractor):\n    \"\"\"Base class for Postmill extractors\"\"\"\n    basecategory = \"postmill\"\n    directory_fmt = (\"{category}\", \"{instance}\", \"{forum}\")\n    filename_fmt = \"{id}_{title[:220]}.{extension}\"\n    archive_fmt = \"{filename}\"\n\n    def _init(self):\n        self.instance = self.root.partition(\"://\")[2]\n        self.save_link_post_body = self.config(\"save-link-post-body\", False)\n        self._search_canonical_url = text.re(r\"/f/([\\w\\d_]+)/(\\d+)/\").search\n        self._search_image_tag = text.re(\n            r'<a href=\"[^\"]+\"\\n +class=\"submission__image-link\"').search\n\n    def items(self):\n        for post_url in self.post_urls():\n            page = self.request(post_url).text\n            extr = text.extract_from(page)\n\n            title = text.unescape(extr(\n                '<meta property=\"og:title\" content=\"', '\">'))\n            date = self.parse_datetime_iso(extr(\n                '<meta property=\"og:article:published_time\" content=\"', '\">'))\n            username = extr(\n                '<meta property=\"og:article:author\" content=\"', '\">')\n            post_canonical_url = text.unescape(extr(\n                '<link rel=\"canonical\" href=\"', '\">'))\n\n            url = text.unescape(extr(\n                '<h1 class=\"submission__title unheaderize inline\"><a href=\"',\n                '\"'))\n            body = extr(\n                '<div class=\"submission__body break-text text-flow\">',\n                '</div>')\n\n            match = self._search_canonical_url(post_canonical_url)\n            forum = match[1]\n            id = int(match[2])\n\n            is_text_post = (url[0] == \"/\")\n            is_image_post = self._search_image_tag(page) is not None\n            data = {\n                \"title\": title,\n                \"date\": date,\n                \"username\": username,\n                \"forum\": forum,\n                \"id\": id,\n                \"flair\": [text.unescape(i) for i in text.extract_iter(\n                    page, '<span class=\"flair__label\">', '</span>')],\n                \"instance\": self.instance,\n            }\n\n            urls = []\n            if is_text_post or self.save_link_post_body:\n                urls.append((Message.Url, \"text:\" + body))\n\n            if is_image_post:\n                urls.append((Message.Url, url))\n            elif not is_text_post:\n                urls.append((Message.Queue, url))\n\n            data[\"count\"] = len(urls)\n            yield Message.Directory, \"\", data\n            for data[\"num\"], (msg, url) in enumerate(urls, 1):\n                if url.startswith(\"text:\"):\n                    data[\"filename\"], data[\"extension\"] = \"\", \"htm\"\n                else:\n                    data = text.nameext_from_url(url, data)\n\n                yield msg, url, data\n\n\nclass PostmillSubmissionsExtractor(PostmillExtractor):\n    \"\"\"Base class for Postmill submissions extractors\"\"\"\n    whitelisted_parameters = ()\n\n    def __init__(self, match):\n        PostmillExtractor.__init__(self, match)\n        groups = match.groups()\n        self.base = groups[-3]\n        self.sorting_path = groups[-2] or \"\"\n        self.query = {key: value for key, value in text.parse_query(\n            groups[-1]).items() if self.acceptable_query(key)}\n\n    def items(self):\n        url = f\"{self.root}{self.base}{self.sorting_path}\"\n\n        while url:\n            response = self.request(url, params=self.query)\n            if response.history:\n                redirect_url = response.url\n                if redirect_url == self.root + \"/login\":\n                    raise self.exc.AbortExtraction(\n                        f\"HTTP redirect to login page ({redirect_url})\")\n            page = response.text\n\n            for nav in text.extract_iter(page,\n                                         '<nav class=\"submission__nav\">',\n                                         '</nav>'):\n                post_url = text.unescape(text.extr(nav, '<a href=\"', '\"'))\n                yield Message.Queue, text.urljoin(url, post_url), \\\n                    {\"_extractor\": PostmillPostExtractor}\n\n            url = text.unescape(text.extr(page,\n                                          '<link rel=\"next\" href=\"', '\">'))\n\n    def acceptable_query(self, key):\n        return key in self.whitelisted_parameters or key == \"t\" or \\\n            (key.startswith(\"next[\") and key.endswith(\"]\"))\n\n\nBASE_PATTERN = PostmillExtractor.update({\n    \"raddle\": {\n        \"root\"   : None,\n        \"pattern\": (r\"(?:raddle\\.me|\"\n                    r\"c32zjeghcp5tj3kb72pltz56piei66drc63vkhn5yixiyk4cmerrjtid\"\n                    r\"\\.onion)\"),\n    }\n})\nQUERY_RE = r\"(?:\\?([^#]+))?$\"\nSORTING_RE = (r\"(/(?:hot|new|active|top|controversial|most_commented))?\" +\n              QUERY_RE)\n\n\nclass PostmillPostExtractor(PostmillExtractor):\n    \"\"\"Extractor for a single submission URL\"\"\"\n    subcategory = \"post\"\n    pattern = BASE_PATTERN + r\"/f/(\\w+)/(\\d+)\"\n    example = \"https://raddle.me/f/FORUM/123/TITLE\"\n\n    def __init__(self, match):\n        PostmillExtractor.__init__(self, match)\n        self.forum = match[3]\n        self.post_id = match[4]\n\n    def post_urls(self):\n        return (f\"{self.root}/f/{self.forum}/{self.post_id}\",)\n\n\nclass PostmillShortURLExtractor(PostmillExtractor):\n    \"\"\"Extractor for short submission URLs\"\"\"\n    subcategory = \"shorturl\"\n    pattern = BASE_PATTERN + r\"(/\\d+)$\"\n    example = \"https://raddle.me/123\"\n\n    def items(self):\n        url = self.root + self.groups[2]\n        location = self.request_location(url)\n        full_url = text.urljoin(url, location)\n        yield Message.Queue, full_url, {\"_extractor\": PostmillPostExtractor}\n\n\nclass PostmillHomeExtractor(PostmillSubmissionsExtractor):\n    \"\"\"Extractor for the home page\"\"\"\n    subcategory = \"home\"\n    pattern = rf\"{BASE_PATTERN}(/(?:featured|subscribed|all)?){SORTING_RE}\"\n    example = \"https://raddle.me/\"\n\n\nclass PostmillForumExtractor(PostmillSubmissionsExtractor):\n    \"\"\"Extractor for submissions on a forum\"\"\"\n    subcategory = \"forum\"\n    pattern = rf\"{BASE_PATTERN}(/f/\\w+){SORTING_RE}\"\n    example = \"https://raddle.me/f/FORUM\"\n\n\nclass PostmillUserSubmissionsExtractor(PostmillSubmissionsExtractor):\n    \"\"\"Extractor for submissions made by a user\"\"\"\n    subcategory = \"usersubmissions\"\n    pattern = rf\"{BASE_PATTERN}(/user/\\w+/submissions)(){QUERY_RE}\"\n    example = \"https://raddle.me/user/USER/submissions\"\n\n\nclass PostmillTagExtractor(PostmillSubmissionsExtractor):\n    \"\"\"Extractor for submissions on a forum with a specific tag\"\"\"\n    subcategory = \"tag\"\n    pattern = rf\"{BASE_PATTERN}(/tag/\\w+){SORTING_RE}\"\n    example = \"https://raddle.me/tag/TAG\"\n\n\nclass PostmillSearchExtractor(PostmillSubmissionsExtractor):\n    \"\"\"Extractor for search results\"\"\"\n    subcategory = \"search\"\n    pattern = BASE_PATTERN + r\"(/search)()\\?(q=[^#]+)$\"\n    example = \"https://raddle.me/search?q=QUERY\"\n    whitelisted_parameters = (\"q\",)\n"
  },
  {
    "path": "gallery_dl/extractor/rawkuma.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://rawkuma.net/\"\"\"\n\nfrom .common import MangaExtractor, ChapterExtractor\nfrom .. import text\n\nBASE_PATTERN = r\"(?:https?://)?rawkuma\\.(?:net|com)\"\n\n\nclass RawkumaBase():\n    \"\"\"Base class for rawkuma extractors\"\"\"\n    category = \"rawkuma\"\n    root = \"https://rawkuma.net\"\n\n\nclass RawkumaChapterExtractor(RawkumaBase, ChapterExtractor):\n    \"\"\"Extractor for manga chapters from rawkuma.net\"\"\"\n    archive_fmt = \"{chapter_id}_{page}\"\n    pattern = BASE_PATTERN + r\"(/manga/[^/?#]+/chapter-\\d+(?:.\\d+)?\\.(\\d+))\"\n    example = \"https://rawkuma.net/manga/7TITLE/chapter-123.321\"\n\n    def __init__(self, match):\n        url = f\"{self.root}/{match[1]}/\"\n        ChapterExtractor.__init__(self, match, url)\n\n    def metadata(self, page):\n        manga, _, chapter = text.extr(\n            page, '<title>', \"<\").rpartition(\" Chapter \")\n        chapter, sep, minor = chapter.partition(\" &#8211; \")[0].partition(\".\")\n\n        return {\n            \"manga\"        : text.unescape(manga),\n            \"manga_id\"     : text.parse_int(text.extr(page, \"manga_id=\", \"&\")),\n            \"chapter\"      : text.parse_int(chapter),\n            \"chapter_minor\": sep + minor,\n            \"chapter_id\"   : text.parse_int(self.groups[-1]),\n            #  \"title\"        : text.unescape(title),\n            \"date\"         : self.parse_datetime_iso(text.extr(\n                page, 'datetime=\"', '\"')),\n            \"lang\"         : \"ja\",\n            \"language\"     : \"Japanese\",\n        }\n\n    def images(self, page):\n        return [(url, None) for url in text.extract_iter(\n                page, \"<img src='\", \"'\")]\n\n\nclass RawkumaMangaExtractor(RawkumaBase, MangaExtractor):\n    \"\"\"Extractor for manga from rawkuma.net\"\"\"\n    chapterclass = RawkumaChapterExtractor\n    pattern = BASE_PATTERN + r\"/manga/([^/?#]+)\"\n    example = \"https://rawkuma.net/manga/TITLE/\"\n\n    def __init__(self, match):\n        url = f\"{self.root}/manga/{match[1]}/\"\n        MangaExtractor.__init__(self, match, url)\n\n    def chapters(self, page):\n        manga = text.unescape(text.extr(page, \"<title>\", \" &#8211; \"))\n        manga_id = text.parse_int(text.extr(page, \"manga_id=\", \"&\"))\n\n        url = self.root + \"/wp-admin/admin-ajax.php\"\n        params = {\n            \"manga_id\": manga_id,\n            \"page\"    : \"1\",\n            \"action\"  : \"chapter_list\",\n        }\n        headers = {\n            \"HX-Request\"    : \"true\",\n            \"HX-Trigger\"    : \"chapter-list\",\n            \"HX-Target\"     : \"chapter-list\",\n            \"HX-Current-URL\": self.page_url,\n            \"Sec-Fetch-Dest\": \"empty\",\n            \"Sec-Fetch-Mode\": \"cors\",\n            \"Sec-Fetch-Site\": \"same-origin\",\n        }\n        html = self.request(url, params=params, headers=headers).text\n\n        results = []\n        for url in text.extract_iter(html, '<a href=\"', '\"'):\n            info = url[url.rfind(\"-\")+1:-1]\n            chapter, _, chapter_id = info.rpartition(\".\")\n            chapter, sep, minor = chapter.partition(\".\")\n\n            results.append((url, {\n                \"manga\"        : manga,\n                \"manga_id\"     : manga_id,\n                \"chapter\"      : text.parse_int(chapter),\n                \"chapter-minor\": sep + minor,\n                \"chapter_id\"   : text.parse_int(chapter_id),\n            }))\n        return results\n"
  },
  {
    "path": "gallery_dl/extractor/reactor.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2019-2026 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Generic extractors for *reactor sites\"\"\"\n\nfrom .common import BaseExtractor, Message\nfrom .. import text, util\nimport urllib.parse\n\n\nclass ReactorExtractor(BaseExtractor):\n    \"\"\"Base class for *reactor.cc extractors\"\"\"\n    basecategory = \"reactor\"\n    filename_fmt = \"{post_id}_{num:>02}{title[:100]:?_//}.{extension}\"\n    archive_fmt = \"{post_id}_{num}\"\n    request_interval = (3.0, 6.0)\n\n    def __init__(self, match):\n        BaseExtractor.__init__(self, match)\n\n        url = text.ensure_http_scheme(match[0], \"http://\")\n        pos = url.index(\"/\", 10)\n        self.root = url[:pos]\n        self.path = url[pos:]\n\n        if self.category == \"reactor\":\n            # set category based on domain name\n            netloc = urllib.parse.urlsplit(self.root).netloc\n            self.category = netloc.rpartition(\".\")[0]\n\n    def _init(self):\n        self.gif = self.config(\"gif\", False)\n\n    def items(self):\n        data = self.metadata()\n        yield Message.Directory, \"\", data\n        for post in self.posts():\n            for image in self._parse_post(post):\n                url = image[\"url\"]\n                image.update(data)\n                yield Message.Url, url, text.nameext_from_url(url, image)\n\n    def metadata(self):\n        \"\"\"Collect metadata for extractor-job\"\"\"\n        return {}\n\n    def posts(self):\n        \"\"\"Return all relevant post-objects\"\"\"\n        return self._pagination(self.root + self.path)\n\n    def _pagination(self, url):\n        while True:\n            response = self.request(url)\n            if response.history:\n                # sometimes there is a redirect from\n                # the last page of a listing (.../tag/<tag>/1)\n                # to the first page (.../tag/<tag>)\n                # which could cause an endless loop\n                cnt_old = response.history[0].url.count(\"/\")\n                cnt_new = response.url.count(\"/\")\n                if cnt_old == 5 and cnt_new == 4:\n                    return\n            page = response.text\n\n            yield from text.extract_iter(\n                page, '<div class=\"uhead\">', '<div class=\"ufoot\">')\n\n            try:\n                pos = page.index(\"class='next'\")\n                pos = page.rindex(\"class='current'\", 0, pos)\n                url = self.root + text.extract(page, \"href='\", \"'\", pos)[0]\n            except (ValueError, TypeError):\n                return\n\n    def _parse_post(self, post):\n        post, _, script = post.partition('<script type=\"application/ld+json\">')\n        if not script:\n            return\n        images = text.extract_iter(post, '<div class=\"image\">', '</div>')\n        script = script[:script.index(\"</\")].strip()\n\n        try:\n            data = util.json_loads(script)\n        except ValueError:\n            try:\n                # remove control characters and escape backslashes\n                mapping = dict.fromkeys(range(32))\n                script = script.translate(mapping).replace(\"\\\\\", \"\\\\\\\\\")\n                data = util.json_loads(script)\n            except ValueError as exc:\n                self.log.warning(\"Unable to parse JSON data: %s\", exc)\n                return\n\n        num = 0\n        date = self.parse_datetime_iso(data[\"datePublished\"])\n        user = data[\"author\"][\"name\"]\n        description = text.unescape(data[\"description\"])\n        title, _, tags = text.unescape(data[\"headline\"]).partition(\" / \")\n        post_id = text.parse_int(\n            data[\"mainEntityOfPage\"][\"@id\"].rpartition(\"/\")[2])\n\n        if not tags:\n            title, tags = tags, title\n        tags = tags.split(\" :: \")\n        tags.sort()\n\n        for image in images:\n            url = text.extr(image, ' src=\"', '\"')\n            if not url:\n                continue\n            if url.startswith(\"//\"):\n                url = \"http:\" + url\n            width = text.extr(image, ' width=\"', '\"')\n            height = text.extr(image, ' height=\"', '\"')\n            image_id = url.rpartition(\"-\")[2].partition(\".\")[0]\n            num += 1\n\n            if image.startswith(\"<iframe \"):  # embed\n                url = \"ytdl:\" + text.unescape(url)\n            elif \"/post/webm/\" not in url and \"/post/mp4/\" not in url:\n                url = url.replace(\"/post/\", \"/post/full/\")\n\n            if self.gif and (\"/post/webm/\" in url or \"/post/mp4/\" in url):\n                gif_url = text.extr(image, '<a href=\"', '\"')\n                if not gif_url:\n                    continue\n                url = gif_url\n\n            yield {\n                \"url\": url,\n                \"post_id\": post_id,\n                \"image_id\": text.parse_int(image_id),\n                \"width\": text.parse_int(width),\n                \"height\": text.parse_int(height),\n                \"title\": title,\n                \"description\": description,\n                \"tags\": tags,\n                \"date\": date,\n                \"user\": user,\n                \"num\": num,\n            }\n\n\nBASE_PATTERN = ReactorExtractor.update({\n    \"reactor\"    : {\n        \"root\": \"http://reactor.cc\",\n        \"pattern\": r\"(?:[^/.]+\\.)?reactor\\.cc\",\n    },\n    \"pornreactor\": {\n        \"root\": \"http://pornreactor.cc\",\n        \"pattern\": r\"(?:www\\.)?(?:pornreactor\\.cc|fapreactor.com)\",\n    },\n    \"thatpervert\": {\n        \"root\": \"http://thatpervert.com\",\n        \"pattern\": r\"thatpervert\\.com\",\n    },\n})\n\n\nclass ReactorTagExtractor(ReactorExtractor):\n    \"\"\"Extractor for tag searches on *reactor.cc sites\"\"\"\n    subcategory = \"tag\"\n    directory_fmt = (\"{category}\", \"{search_tags}\")\n    archive_fmt = \"{search_tags}_{post_id}_{num}\"\n    pattern = BASE_PATTERN + r\"/tag/([^/?#]+)(?:/[^/?#]+)?\"\n    example = \"http://reactor.cc/tag/TAG\"\n\n    def __init__(self, match):\n        ReactorExtractor.__init__(self, match)\n        self.tag = self.groups[-1]\n\n    def metadata(self):\n        return {\"search_tags\": text.unescape(self.tag).replace(\"+\", \" \")}\n\n\nclass ReactorSearchExtractor(ReactorExtractor):\n    \"\"\"Extractor for search results on *reactor.cc sites\"\"\"\n    subcategory = \"search\"\n    directory_fmt = (\"{category}\", \"search\", \"{search_tags}\")\n    archive_fmt = \"s_{search_tags}_{post_id}_{num}\"\n    pattern = BASE_PATTERN + r\"/search(?:/|\\?q=)([^/?#]+)\"\n    example = \"http://reactor.cc/search?q=QUERY\"\n\n    def __init__(self, match):\n        ReactorExtractor.__init__(self, match)\n        self.tag = self.groups[-1]\n\n    def metadata(self):\n        return {\"search_tags\": text.unescape(self.tag).replace(\"+\", \" \")}\n\n\nclass ReactorUserExtractor(ReactorExtractor):\n    \"\"\"Extractor for all posts of a user on *reactor.cc sites\"\"\"\n    subcategory = \"user\"\n    directory_fmt = (\"{category}\", \"user\", \"{user}\")\n    pattern = BASE_PATTERN + r\"/user/([^/?#]+)\"\n    example = \"http://reactor.cc/user/USER\"\n\n    def __init__(self, match):\n        ReactorExtractor.__init__(self, match)\n        self.user = self.groups[-1]\n\n    def metadata(self):\n        return {\"user\": text.unescape(self.user).replace(\"+\", \" \")}\n\n\nclass ReactorPostExtractor(ReactorExtractor):\n    \"\"\"Extractor for single posts on *reactor.cc sites\"\"\"\n    subcategory = \"post\"\n    pattern = BASE_PATTERN + r\"/post/(\\d+)\"\n    example = \"http://reactor.cc/post/12345\"\n\n    def __init__(self, match):\n        ReactorExtractor.__init__(self, match)\n        self.post_id = self.groups[-1]\n\n    def items(self):\n        post = self.request(self.root + self.path).text\n        pos = post.find('class=\"uhead\">')\n        for image in self._parse_post(post[pos:]):\n            if image[\"num\"] == 1:\n                yield Message.Directory, \"\", image\n            url = image[\"url\"]\n            yield Message.Url, url, text.nameext_from_url(url, image)\n"
  },
  {
    "path": "gallery_dl/extractor/readcomiconline.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2016-2026 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://readcomiconline.li/\"\"\"\n\nfrom .common import Extractor, ChapterExtractor, MangaExtractor, Message\nfrom .. import text\nimport binascii\n\nBASE_PATTERN = r\"(?i)(?:https?://)?(?:www\\.)?readcomiconline\\.(?:li|to)\"\n\n\nclass ReadcomiconlineBase():\n    \"\"\"Base class for readcomiconline extractors\"\"\"\n    category = \"readcomiconline\"\n    directory_fmt = (\"{category}\", \"{comic}\", \"{issue:>03}\")\n    filename_fmt = \"{comic}_{issue:>03}_{page:>03}.{extension}\"\n    archive_fmt = \"{issue_id}_{page}\"\n    root = \"https://readcomiconline.li\"\n    request_interval = (3.0, 6.0)\n\n    def request(self, url, **kwargs):\n        \"\"\"Detect and handle redirects to CAPTCHA pages\"\"\"\n        while True:\n            response = Extractor.request(self, url, **kwargs)\n            if not response.history or \"/AreYouHuman\" not in response.url:\n                return response\n            if self.config(\"captcha\", \"stop\") == \"wait\":\n                self.log.warning(\n                    \"Redirect to \\n%s\\nVisit this URL in your browser, solve \"\n                    \"the CAPTCHA, and press ENTER to continue\", response.url)\n                self.input()\n            else:\n                raise self.exc.AbortExtraction(\n                    f\"Redirect to \\n{response.url}\\nVisit this URL in your \"\n                    f\"browser and solve the CAPTCHA to continue\")\n\n\nclass ReadcomiconlineIssueExtractor(ReadcomiconlineBase, ChapterExtractor):\n    \"\"\"Extractor for comic-issues from readcomiconline.li\"\"\"\n    subcategory = \"issue\"\n    pattern = BASE_PATTERN + r\"(/Comic/[^/?#]+/[^/?#]+\\?)([^#]+)\"\n    example = \"https://readcomiconline.li/Comic/TITLE/Issue-123?id=12345\"\n\n    def _init(self):\n        params = text.parse_query(self.groups[1])\n        quality = self.config(\"quality\")\n\n        if quality is None or quality == \"auto\":\n            if \"quality\" not in params:\n                params[\"quality\"] = \"hq\"\n        else:\n            params[\"quality\"] = str(quality)\n        params[\"readType\"] = \"0\"  # force \"One page\" Reading mode (#7890)\n\n        self.page_url += \"&\".join(f\"{k}={v}\" for k, v in params.items())\n        self.issue_id = params.get(\"id\")\n\n    def metadata(self, page):\n        comic, pos = text.extract(page, \"   - Read\\r\\n    \", \"\\r\\n\")\n        iinfo, pos = text.extract(page, \"    \", \"\\r\\n\", pos)\n        match = text.re(r\"(?:Issue )?#(\\d+)|(.+)\").match(iinfo)\n        return {\n            \"comic\": comic,\n            \"issue\": match[1] or match[2],\n            \"issue_id\": text.parse_int(self.issue_id),\n            \"lang\": \"en\",\n            \"language\": \"English\",\n        }\n\n    def images(self, page):\n        results = []\n        referer = {\"_http_headers\": {\"Referer\": self.page_url}}\n        root, pos = text.extract(page, \"return baeu(l, '\", \"'\")\n        _   , pos = text.extract(page, \"var pth = '\", \"\", pos)\n        var , pos = text.extract(page, \"var \", \"= '\", pos)\n\n        replacements = text.re(\n            r\"l = l\\.replace\\(/([^/]+)/g, [\\\"']([^\\\"']*)\").findall(page)\n\n        for path in page.split(var)[2:]:\n            path = text.extr(path, \"= '\", \"'\")\n\n            for needle, repl in replacements:\n                path = path.replace(needle, repl)\n\n            results.append((baeu(path, root), referer))\n\n        return results\n\n\nclass ReadcomiconlineComicExtractor(ReadcomiconlineBase, MangaExtractor):\n    \"\"\"Extractor for comics from readcomiconline.li\"\"\"\n    chapterclass = ReadcomiconlineIssueExtractor\n    subcategory = \"comic\"\n    pattern = BASE_PATTERN + r\"(/Comic/[^/?#]+/?)$\"\n    example = \"https://readcomiconline.li/Comic/TITLE\"\n\n    def chapters(self, page):\n        results = []\n        comic, pos = text.extract(page, ' class=\"barTitle\">', '<')\n        page , pos = text.extract(page, ' class=\"listing\">', '</table>', pos)\n\n        comic = comic.rpartition(\"information\")[0].strip()\n        needle = f' title=\"Read {comic} '\n        comic = text.unescape(comic)\n\n        for item in text.extract_iter(page, ' href=\"', ' comic online '):\n            url, _, issue = item.partition(needle)\n            url = url.rpartition('\"')[0]\n            if issue.startswith('Issue #'):\n                issue = issue[7:]\n            results.append((self.root + url, {\n                \"comic\": comic, \"issue\": issue,\n                \"issue_id\": text.parse_int(url.rpartition(\"=\")[2]),\n                \"lang\": \"en\", \"language\": \"English\",\n            }))\n        return results\n\n\nclass ReadcomiconlineTagExtractor(ReadcomiconlineBase, Extractor):\n    \"\"\"Extractor for comics from readcomiconline.li lists\"\"\"\n    subcategory = \"tag\"\n    pattern = (BASE_PATTERN +\n               r\"(/(Artist|Genre|Publisher|Writer|)/([^/?#]+)(?:/[^/?#]+)?)\")\n    example = \"https://readcomiconline.li/Artist/NAME\"\n\n    def __init__(self, match):\n        self.subcategory = match[2].lower()\n        Extractor.__init__(self, match)\n\n    def items(self):\n        path, _, name = self.groups\n        self.kwdict[\"search_tags\"] = text.unquote(name)\n\n        self.cookies.set(\"list-view\", \"list\", domain=self.root[8:])\n\n        base = self.root + \"/Comic/\"\n        data = {\"_extractor\": ReadcomiconlineComicExtractor}\n        url = f\"{self.root}/{path}\"\n        params = {\"page\": 1}\n\n        while True:\n            page = self.request(url, params=params).text\n\n            for href in text.extract_iter(page, '<a href=\"/Comic/', '\"'):\n                if \"?id=\" not in href:\n                    yield Message.Queue, base + href, data\n\n            if \">&rsaquo; Next <\" not in page:\n                break\n            params[\"page\"] += 1\n\n\ndef baeu(url, root=\"\", root_blogspot=\"https://2.bp.blogspot.com\"):\n    \"\"\"https://readcomiconline.li/Scripts/rguard.min.js?v=1.5.4\"\"\"\n    if not root:\n        root = root_blogspot\n\n    url = url.replace(\"pw_.g28x\", \"b\")\n    url = url.replace(\"d2pr.x_27\", \"h\")\n\n    if url.startswith(\"https\"):\n        return url.replace(root_blogspot, root, 1)\n\n    path, sep, query = url.partition(\"?\")\n\n    contains_s0 = \"=s0\" in path\n    path = path[:-3 if contains_s0 else -6]\n    path = path[15:33] + path[50:]  # step1()\n    path = path[0:-11] + path[-2:]  # step2()\n    path = binascii.a2b_base64(path).decode()  # atob()\n    path = path[0:13] + path[17:]\n    path = path[0:-2] + (\"=s0\" if contains_s0 else \"=s1600\")\n    return root + \"/\" + path + sep + query\n"
  },
  {
    "path": "gallery_dl/extractor/realbooru.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2024-2025 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://realbooru.com/\"\"\"\n\nfrom . import booru\nfrom .. import text, util\nimport collections\n\nBASE_PATTERN = r\"(?:https?://)?realbooru\\.com\"\n\n\nclass RealbooruExtractor(booru.BooruExtractor):\n    basecategory = \"booru\"\n    category = \"realbooru\"\n    root = \"https://realbooru.com\"\n\n    def _parse_post(self, post_id):\n        url = f\"{self.root}/index.php?page=post&s=view&id={post_id}\"\n        page = self.request(url).text\n        extr = text.extract_from(page)\n        rating = extr('name=\"rating\" content=\"', '\"')\n        extr('class=\"container\"', '>')\n\n        post = {\n            \"id\"        : post_id,\n            \"rating\"    : \"e\" if rating == \"adult\" else (rating or \"?\")[0],\n            \"file_url\"  : (s := extr('src=\"', '\"')),\n            \"_fallback\" : (extr('src=\"', '\"'),) if s.endswith(\".mp4\") else (),\n            \"created_at\": extr(\">Posted at \", \" by \"),\n            \"uploader\"  : extr(\">\", \"<\"),\n            \"score\"     : extr('\">', \"<\"),\n            \"tags\"      : extr('<br />', \"</div>\"),\n            \"title\"     : extr('id=\"title\" style=\"width: 100%;\" value=\"', '\"'),\n            \"source\"    : extr('d=\"source\" style=\"width: 100%;\" value=\"', '\"'),\n        }\n\n        tags_container = post[\"tags\"]\n        tags = []\n        tags_categories = collections.defaultdict(list)\n        pattern = text.re(r'<a class=\"(?:tag-type-)?([^\"]+).*?;tags=([^\"&]+)')\n        for tag_type, tag_name in pattern.findall(tags_container):\n            tag = text.unescape(text.unquote(tag_name))\n            tags.append(tag)\n            tags_categories[tag_type].append(tag)\n        for key, value in tags_categories.items():\n            post[\"tags_\" + key] = \", \".join(value)\n        tags.sort()\n\n        post[\"tags\"] = \", \".join(tags)\n        post[\"md5\"] = post[\"file_url\"].rpartition(\"/\")[2].partition(\".\")[0]\n        return post\n\n    def skip_files(self, num):\n        self.page_start += num\n        return num\n\n    def _prepare(self, post):\n        post[\"date\"] = self.parse_datetime(post[\"created_at\"], \"%b, %d %Y\")\n\n    def _pagination(self, params, begin, end):\n        url = self.root + \"/index.php\"\n        params[\"pid\"] = self.page_start\n\n        while True:\n            page = self.request(url, params=params).text\n\n            cnt = 0\n            for post_id in text.extract_iter(page, begin, end):\n                cnt += 1\n                yield self._parse_post(post_id)\n\n            if cnt < self.per_page:\n                return\n            params[\"pid\"] += self.per_page\n\n\nclass RealbooruTagExtractor(RealbooruExtractor):\n    subcategory = \"tag\"\n    directory_fmt = (\"{category}\", \"{search_tags}\")\n    archive_fmt = \"t_{search_tags}_{id}\"\n    per_page = 42\n    pattern = BASE_PATTERN + r\"/index\\.php\\?page=post&s=list&tags=([^&#]*)\"\n    example = \"https://realbooru.com/index.php?page=post&s=list&tags=TAG\"\n\n    def metadata(self):\n        self.tags = text.unquote(self.groups[0].replace(\"+\", \" \"))\n        return {\"search_tags\": self.tags}\n\n    def posts(self):\n        return self._pagination({\n            \"page\": \"post\",\n            \"s\"   : \"list\",\n            \"tags\": self.tags,\n        }, '<a id=\"p', '\"')\n\n\nclass RealbooruFavoriteExtractor(RealbooruExtractor):\n    subcategory = \"favorite\"\n    directory_fmt = (\"{category}\", \"favorites\", \"{favorite_id}\")\n    archive_fmt = \"f_{favorite_id}_{id}\"\n    per_page = 50\n    pattern = BASE_PATTERN + r\"/index\\.php\\?page=favorites&s=view&id=(\\d+)\"\n    example = \"https://realbooru.com/index.php?page=favorites&s=view&id=12345\"\n\n    def metadata(self):\n        return {\"favorite_id\": text.parse_int(self.groups[0])}\n\n    def posts(self):\n        return self._pagination({\n            \"page\": \"favorites\",\n            \"s\"   : \"view\",\n            \"id\"  : self.groups[0],\n        }, '\" id=\"p', '\"')\n\n\nclass RealbooruPoolExtractor(RealbooruExtractor):\n    subcategory = \"pool\"\n    directory_fmt = (\"{category}\", \"pool\", \"{pool} {pool_name}\")\n    archive_fmt = \"p_{pool}_{id}\"\n    pattern = BASE_PATTERN + r\"/index\\.php\\?page=pool&s=show&id=(\\d+)\"\n    example = \"https://realbooru.com/index.php?page=pool&s=show&id=12345\"\n\n    def metadata(self):\n        pool_id = self.groups[0]\n        url = f\"{self.root}/index.php?page=pool&s=show&id={pool_id}\"\n        page = self.request(url).text\n\n        name, pos = text.extract(page, \"<h4>Pool: \", \"</h4>\")\n        self.post_ids = text.extract_iter(\n            page, 'class=\"thumb\" id=\"p', '\"', pos)\n\n        return {\n            \"pool\": text.parse_int(pool_id),\n            \"pool_name\": text.unescape(name),\n        }\n\n    def posts(self):\n        return map(\n            self._parse_post,\n            util.advance(self.post_ids, self.page_start)\n        )\n\n\nclass RealbooruPostExtractor(RealbooruExtractor):\n    subcategory = \"post\"\n    archive_fmt = \"{id}\"\n    pattern = BASE_PATTERN + r\"/index\\.php\\?page=post&s=view&id=(\\d+)\"\n    example = \"https://realbooru.com/index.php?page=post&s=view&id=12345\"\n\n    def posts(self):\n        return (self._parse_post(self.groups[0]),)\n"
  },
  {
    "path": "gallery_dl/extractor/recursive.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2015-2025 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Recursive extractor\"\"\"\n\nfrom .common import Extractor, Message\nfrom .. import text\n\n\nclass RecursiveExtractor(Extractor):\n    \"\"\"Extractor that fetches URLs from a remote or local source\"\"\"\n    category = \"recursive\"\n    pattern = r\"r(?:ecursive)?:\"\n    example = \"recursive:https://pastebin.com/raw/FLwrCYsT\"\n\n    def items(self):\n        url = self.url.partition(\":\")[2]\n\n        if url.startswith(\"file://\"):\n            with open(url[7:], encoding=\"utf-8\") as fp:\n                page = fp.read()\n        else:\n            page = self.request(text.ensure_http_scheme(url)).text\n\n        for match in text.re(r\"https?://[^\\s\\\"']+\").finditer(page):\n            yield Message.Queue, match[0], {}\n"
  },
  {
    "path": "gallery_dl/extractor/reddit.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2017-2026 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://www.reddit.com/\"\"\"\n\nfrom .common import Extractor, Message\nfrom .. import text, util\n\nBASE_PATTERN = r\"(?:https?://)?(?:www\\.)?(?:\\w+\\.)?reddit\\.com\"\n\n\nclass RedditExtractor(Extractor):\n    \"\"\"Base class for reddit extractors\"\"\"\n    category = \"reddit\"\n    directory_fmt = (\"{category}\", \"{subreddit}\")\n    filename_fmt = \"{id}{num:? //>02} {title|link_title:[:220]}.{extension}\"\n    archive_fmt = \"{filename}\"\n    cookies_domain = \".reddit.com\"\n    request_interval = 0.6\n\n    def items(self):\n        self.api = RedditAPI(self)\n        match_submission = RedditSubmissionExtractor.pattern.match\n        match_subreddit = RedditSubredditExtractor.pattern.match\n        match_user = RedditUserExtractor.pattern.match\n\n        parentdir = self.config(\"parent-directory\")\n        max_depth = self.config(\"recursion\", 0)\n        previews = self.config(\"previews\", True)\n        embeds = self.config(\"embeds\", True)\n\n        if videos := self.config(\"videos\", \"dash\"):\n            if videos == \"dash\":\n                self._extract_video = self._extract_video_dash\n            elif videos == \"ytdl\":\n                self._extract_video = self._extract_video_ytdl\n            videos = True\n\n        selftext = self.config(\"selftext\")\n        if selftext is None:\n            selftext = self.api.comments\n        selftext = True if selftext else False\n\n        submissions = self.submissions()\n        visited = set()\n        depth = 0\n\n        while True:\n            extra = []\n\n            for submission, comments in submissions:\n                urls = []\n\n                if submission and submission.get(\"_media\", True):\n                    submission[\"comment\"] = None\n                    submission[\"date\"] = self.parse_timestamp(\n                        submission[\"created_utc\"])\n                    yield Message.Directory, \"\", submission\n                    visited.add(submission[\"id\"])\n                    submission[\"num\"] = 0\n\n                    if \"crosspost_parent_list\" in submission:\n                        try:\n                            media = submission[\"crosspost_parent_list\"][-1]\n                        except Exception:\n                            media = submission\n                    else:\n                        media = submission\n\n                    url = media[\"url\"]\n                    if url and url.startswith((\n                        \"https://i.redd.it/\",\n                        \"https://preview.redd.it/\",\n                    )):\n                        text.nameext_from_url(url, submission)\n                        yield Message.Url, url, submission\n\n                    elif \"gallery_data\" in media:\n                        for url in self._extract_gallery(media):\n                            submission[\"num\"] += 1\n                            text.nameext_from_url(url, submission)\n                            yield Message.Url, url, submission\n\n                    elif embeds and \"media_metadata\" in media:\n                        for embed in self._extract_embed(submission, media):\n                            submission[\"num\"] += 1\n                            text.nameext_from_url(embed, submission)\n                            yield Message.Url, embed, submission\n\n                    elif media[\"is_video\"]:\n                        if videos:\n                            text.nameext_from_url(url, submission)\n                            if not submission[\"extension\"]:\n                                submission[\"extension\"] = \"mp4\"\n                            url = \"ytdl:\" + self._extract_video(media)\n                            yield Message.Url, url, submission\n\n                    elif not submission[\"is_self\"]:\n                        urls.append((url, submission))\n\n                    if selftext and (txt := submission[\"selftext_html\"]):\n                        for url in text.extract_iter(txt, ' href=\"', '\"'):\n                            urls.append((url, submission))\n\n                elif parentdir:\n                    yield Message.Directory, \"\", comments[0]\n\n                if self.api.comments:\n                    if comments and not submission:\n                        submission = comments[0]\n                        submission.setdefault(\"num\", 0)\n                        if not parentdir:\n                            yield Message.Directory, \"\", submission\n\n                    for comment in comments:\n                        media = (embeds and \"media_metadata\" in comment)\n                        html = comment[\"body_html\"] or \"\"\n                        href = (' href=\"' in html)\n\n                        if not media and not href:\n                            continue\n\n                        data = submission.copy()\n                        data[\"comment\"] = comment\n                        comment[\"date\"] = data[\"date\"] = self.parse_timestamp(\n                            comment[\"created_utc\"])\n\n                        if media:\n                            for url in self._extract_embed(data, comment):\n                                data[\"num\"] += 1\n                                text.nameext_from_url(url, data)\n                                yield Message.Url, url, data\n                            submission[\"num\"] = data[\"num\"]\n\n                        if href:\n                            for url in text.extract_iter(html, ' href=\"', '\"'):\n                                urls.append((url, data))\n\n                for url, data in urls:\n                    if not url or url[0] == \"#\":\n                        continue\n                    if url[0] == \"/\":\n                        url = \"https://www.reddit.com\" + url\n                    if url.startswith((\n                        \"https://www.reddit.com/message/compose\",\n                        \"https://reddit.com/message/compose\",\n                        \"https://preview.redd.it/\",\n                    )):\n                        continue\n\n                    if match := match_submission(url):\n                        extra.append(match[1])\n                    elif not match_user(url) and not match_subreddit(url):\n                        if previews and \"preview\" in data:\n                            data[\"_fallback\"] = self._previews(data)\n                        yield Message.Queue, text.unescape(url), data\n                        if \"_fallback\" in data:\n                            del data[\"_fallback\"]\n\n            if not extra or depth == max_depth:\n                return\n            depth += 1\n            submissions = (\n                self.api.submission(sid) for sid in extra\n                if sid not in visited\n            )\n\n    def submissions(self):\n        \"\"\"Return an iterable containing all (submission, comments) tuples\"\"\"\n\n    def _extract_gallery(self, submission):\n        gallery = submission[\"gallery_data\"]\n        if gallery is None:\n            self.log.warning(\"gallery %s: deleted\", submission[\"id\"])\n            return\n\n        meta = submission.get(\"media_metadata\")\n        if meta is None:\n            self.log.warning(\"gallery %s: missing 'media_metadata'\",\n                             submission[\"id\"])\n            return\n\n        for item in gallery[\"items\"]:\n            data = meta[item[\"media_id\"]]\n            if data[\"status\"] != \"valid\" or \"s\" not in data:\n                self.log.warning(\n                    \"gallery %s: skipping item %s (status: %s)\",\n                    submission[\"id\"], item[\"media_id\"], data.get(\"status\"))\n                continue\n            src = data[\"s\"]\n            if url := src.get(\"u\") or src.get(\"gif\") or src.get(\"mp4\"):\n                yield url.partition(\"?\")[0].replace(\"/preview.\", \"/i.\", 1)\n            else:\n                self.log.error(\n                    \"gallery %s: unable to fetch download URL for item %s\",\n                    submission[\"id\"], item[\"media_id\"])\n                self.log.debug(src)\n\n    def _extract_embed(self, submission, media):\n        meta = media[\"media_metadata\"]\n        if not meta:\n            return\n\n        for mid, data in meta.items():\n            if data[\"status\"] != \"valid\":\n                self.log.warning(\n                    \"embed %s: skipping item %s (status: %s)\",\n                    submission[\"id\"], mid, data.get(\"status\"))\n                continue\n\n            if src := data.get(\"s\"):\n                if url := src.get(\"u\") or src.get(\"gif\") or src.get(\"mp4\"):\n                    if \"//external\" not in url:\n                        url = url.partition(\"?\")[0].replace(\n                            \"/preview.\", \"/i.\", 1)\n                    yield url\n                else:\n                    self.log.error(\n                        \"embed %s: unable to fetch download URL for item %s\",\n                        submission[\"id\"], mid)\n                    self.log.debug(src)\n            elif url := data.get(\"dashUrl\"):\n                submission[\"_ytdl_manifest\"] = \"dash\"\n                yield \"ytdl:\" + url\n            elif url := data.get(\"hlsUrl\"):\n                submission[\"_ytdl_manifest\"] = \"hls\"\n                yield \"ytdl:\" + url\n\n    def _extract_video_ytdl(self, submission):\n        return \"https://www.reddit.com\" + submission[\"permalink\"]\n\n    def _extract_video_dash(self, submission):\n        submission[\"_ytdl_extra\"] = {\"title\": submission[\"title\"]}\n        try:\n            url = submission[\"secure_media\"][\"reddit_video\"][\"dash_url\"]\n            submission[\"_ytdl_manifest\"] = \"dash\"\n            return url\n        except Exception:\n            return submission[\"url\"]\n\n    def _extract_video(self, submission):\n        submission[\"_ytdl_extra\"] = {\"title\": submission[\"title\"]}\n        return submission[\"url\"]\n\n    def _previews(self, post):\n        try:\n            if \"reddit_video_preview\" in post[\"preview\"]:\n                video = post[\"preview\"][\"reddit_video_preview\"]\n                if \"fallback_url\" in video:\n                    yield video[\"fallback_url\"]\n                if \"dash_url\" in video:\n                    yield \"ytdl:\" + video[\"dash_url\"]\n                if \"hls_url\" in video:\n                    yield \"ytdl:\" + video[\"hls_url\"]\n        except Exception as exc:\n            self.log.debug(\"%s: %s\", exc.__class__.__name__, exc)\n\n        try:\n            for image in post[\"preview\"][\"images\"]:\n                if variants := image.get(\"variants\"):\n                    if \"gif\" in variants:\n                        yield variants[\"gif\"][\"source\"][\"url\"]\n                    if \"mp4\" in variants:\n                        yield variants[\"mp4\"][\"source\"][\"url\"]\n                yield image[\"source\"][\"url\"]\n        except Exception as exc:\n            self.log.debug(\"%s: %s\", exc.__class__.__name__, exc)\n\n\nclass RedditSubredditExtractor(RedditExtractor):\n    \"\"\"Extractor for URLs from subreddits on reddit.com\"\"\"\n    subcategory = \"subreddit\"\n    pattern = (BASE_PATTERN +\n               r\"(/r/[^/?#]+(?:/([a-z]+))?)/?(?:\\?([^#]*))?(?:$|#)\")\n    example = \"https://www.reddit.com/r/SUBREDDIT/\"\n\n    def __init__(self, match):\n        self.subreddit, sub, params = match.groups()\n        self.params = text.parse_query(params)\n        if sub:\n            if sub == \"search\" and \"restrict_sr\" not in self.params:\n                self.params[\"restrict_sr\"] = \"1\"\n            self.subcategory += \"-\" + sub\n        RedditExtractor.__init__(self, match)\n\n    def submissions(self):\n        return self.api.submissions_subreddit(self.subreddit, self.params)\n\n\nclass RedditHomeExtractor(RedditSubredditExtractor):\n    \"\"\"Extractor for submissions from your home feed on reddit.com\"\"\"\n    subcategory = \"home\"\n    pattern = BASE_PATTERN + r\"((?:/([a-z]+))?)/?(?:\\?([^#]*))?(?:$|#)\"\n    example = \"https://www.reddit.com/\"\n\n\nclass RedditUserExtractor(RedditExtractor):\n    \"\"\"Extractor for URLs from posts by a reddit user\"\"\"\n    subcategory = \"user\"\n    directory_fmt = (\"{category}\", \"Users\", \"{user[name]}\")\n    pattern = (BASE_PATTERN +\n               r\"/u(?:ser)?/([^/?#]+)(/[a-z]+)?/?(?:\\?([^#]*))?$\")\n    example = \"https://www.reddit.com/user/USER/\"\n\n    def __init__(self, match):\n        if sub := match[2]:\n            self.subcategory += \"-\" + sub[1:]\n        RedditExtractor.__init__(self, match)\n\n    def submissions(self):\n        username, sub, qs = self.groups\n        username = text.unquote(username)\n        self.kwdict[\"user\"] = user = self.api.user_about(username)\n\n        submissions = self.api.submissions_user(\n            (user.get(\"name\") or username) + (sub or \"\"), text.parse_query(qs))\n        only = sub not in {\"/upvoted\", \"/downvoted\", \"/saved\"}\n        if self.config(\"only\", only):\n            submissions = self._only(submissions, user)\n        return submissions\n\n    def _only(self, submissions, user):\n        try:\n            uid = \"t2_\" + user[\"id\"]\n        except Exception:\n            if user.get(\"is_suspended\"):\n                raise self.exc.NotFoundError(\"Suspended User\", False)\n            raise self.exc.NotFoundError(\"user\")\n        for submission, comments in submissions:\n            if submission and submission.get(\"author_fullname\") != uid:\n                submission[\"_media\"] = False\n            comments = [\n                comment\n                for comment in (comments or ())\n                if comment.get(\"author_fullname\") == uid\n            ]\n            if submission or comments:\n                yield submission, comments\n\n\nclass RedditSubmissionExtractor(RedditExtractor):\n    \"\"\"Extractor for URLs from a submission on reddit.com\"\"\"\n    subcategory = \"submission\"\n    pattern = (r\"(?:https?://)?(?:\"\n               r\"(?:www\\.)?(?:\\w+\\.)?reddit\\.com/(?:(?:(?:r|u|user)/[^/?#]+/)?\"\n               r\"comments|gallery)|redd\\.it)/([a-z0-9]+)\")\n    example = \"https://www.reddit.com/r/SUBREDDIT/comments/id/\"\n\n    def submissions(self):\n        return (self.api.submission(self.groups[0]),)\n\n\nclass RedditImageExtractor(Extractor):\n    \"\"\"Extractor for reddit-hosted images\"\"\"\n    category = \"reddit\"\n    subcategory = \"image\"\n    archive_fmt = \"{filename}\"\n    pattern = (r\"(?:https?://)?((?:i|preview)\\.redd\\.it|i\\.reddituploads\\.com)\"\n               r\"/([^/?#]+)(\\?[^#]*)?\")\n    example = \"https://i.redd.it/NAME.EXT\"\n\n    def __init__(self, match):\n        Extractor.__init__(self, match)\n        domain = match[1]\n        self.path = match[2]\n        if domain == \"preview.redd.it\":\n            self.domain = \"i.redd.it\"\n            self.query = \"\"\n        else:\n            self.domain = domain\n            self.query = match[3] or \"\"\n\n    def items(self):\n        url = f\"https://{self.domain}/{self.path}{self.query}\"\n        data = text.nameext_from_url(url)\n        yield Message.Directory, \"\", data\n        yield Message.Url, url, data\n\n\nclass RedditRedirectExtractor(Extractor):\n    \"\"\"Extractor for personalized share URLs produced by the mobile app\"\"\"\n    category = \"reddit\"\n    subcategory = \"redirect\"\n    pattern = BASE_PATTERN + r\"/(?:(r|u|user)/([^/?#]+))/s/([a-zA-Z0-9]{10})\"\n    example = \"https://www.reddit.com/r/SUBREDDIT/s/abc456GHIJ\"\n\n    def items(self):\n        sub_type, subreddit, share_url = self.groups\n        if sub_type == \"u\":\n            sub_type = \"user\"\n        url = f\"https://www.reddit.com/{sub_type}/{subreddit}/s/{share_url}\"\n        location = self.request_location(url, notfound=\"submission\")\n        data = {\"_extractor\": RedditSubmissionExtractor}\n        yield Message.Queue, location, data\n\n\nclass RedditAPI():\n    \"\"\"Interface for the Reddit API\n\n    Ref: https://www.reddit.com/dev/api/\n    \"\"\"\n    ROOT = \"https://oauth.reddit.com\"\n    CLIENT_ID = \"6N9uN0krSDE-ig\"\n    USER_AGENT = \"Python:gallery-dl:0.8.4 (by /u/mikf1)\"\n\n    def __init__(self, extractor):\n        self.extractor = extractor\n        self.log = extractor.log\n\n        config = extractor.config\n\n        self.comments = text.parse_int(config(\"comments\", 0))\n        self.morecomments = config(\"morecomments\", False)\n        self._warn_429 = False\n\n        if config(\"api\") != \"oauth\":\n            self.root = \"https://www.reddit.com\"\n            self.headers = None\n            self.authenticate = util.noop\n            self.log.debug(\"Using REST API\")\n        else:\n            self.root = self.ROOT\n\n            client_id = config(\"client-id\")\n            if client_id is None:\n                self.client_id = self.CLIENT_ID\n                self.headers = {\"User-Agent\": self.USER_AGENT}\n            else:\n                self.client_id = client_id\n                self.headers = {\"User-Agent\": config(\"user-agent\")}\n\n            if self.client_id == self.CLIENT_ID:\n                client_id = self.client_id\n                self._warn_429 = True\n                kind = \"default\"\n            else:\n                client_id = client_id[:5] + \"*\" * (len(client_id)-5)\n                kind = \"custom\"\n\n            self.log.debug(\n                \"Using %s API credentials (client-id %s)\", kind, client_id)\n\n            token = config(\"refresh-token\")\n            if token is None or token == \"cache\":\n                self.refresh_token = extractor._cache(\n                    _refresh_token_cache, \"#\"+self.client_id, _mem=False)\n            else:\n                self.refresh_token = token\n\n            if not self.refresh_token:\n                # allow downloading from quarantined subreddits (#2180)\n                extractor.cookies.set(\n                    \"_options\", '%7B%22pref_quarantine_optin%22%3A%20true%7D',\n                    domain=extractor.cookies_domain)\n\n    def submission(self, submission_id):\n        \"\"\"Fetch the (submission, comments)=-tuple for a submission id\"\"\"\n        endpoint = \"/comments/\" + submission_id + \"/.json\"\n        link_id = \"t3_\" + submission_id if self.morecomments else None\n        submission, comments = self._call(endpoint, {\"limit\": self.comments})\n        return (submission[\"data\"][\"children\"][0][\"data\"],\n                self._flatten(comments, link_id) if self.comments else ())\n\n    def submissions_subreddit(self, subreddit, params):\n        \"\"\"Collect all (submission, comments)-tuples of a subreddit\"\"\"\n        endpoint = subreddit + \"/.json\"\n        return self._pagination(endpoint, params)\n\n    def submissions_user(self, username, params):\n        \"\"\"Collect all (submission, comments)-tuples posted by a user\"\"\"\n        endpoint = f\"/user/{username}/.json\"\n        return self._pagination(endpoint, params)\n\n    def morechildren(self, link_id, children):\n        \"\"\"Load additional comments from a submission\"\"\"\n        endpoint = \"/api/morechildren\"\n        params = {\"link_id\": link_id, \"api_type\": \"json\"}\n        index, done = 0, False\n        while not done:\n            if len(children) - index < 100:\n                done = True\n            params[\"children\"] = \",\".join(children[index:index + 100])\n            index += 100\n\n            data = self._call(endpoint, params)[\"json\"]\n            for thing in data[\"data\"][\"things\"]:\n                if thing[\"kind\"] == \"more\":\n                    if more := thing[\"data\"].get(\"children\"):\n                        children.extend(more)\n                else:\n                    yield thing[\"data\"]\n\n    def user_about(self, username):\n        endpoint = f\"/user/{username}/about.json\"\n        return self._call(endpoint, {})[\"data\"]\n\n    def authenticate(self):\n        \"\"\"Authenticate the application by requesting an access token\"\"\"\n        self.headers[\"Authorization\"] = self.extractor.cache(\n            self._authenticate_impl, self.refresh_token, _exp=3600, _mem=False)\n\n    def _authenticate_impl(self, refresh_token=None):\n        \"\"\"Actual authenticate implementation\"\"\"\n        url = \"https://www.reddit.com/api/v1/access_token\"\n        self.headers[\"Authorization\"] = None\n\n        if refresh_token:\n            self.log.info(\"Refreshing private access token\")\n            data = {\"grant_type\": \"refresh_token\",\n                    \"refresh_token\": refresh_token}\n        else:\n            self.log.info(\"Requesting public access token\")\n            data = {\"grant_type\": (\"https://oauth.reddit.com/\"\n                                   \"grants/installed_client\"),\n                    \"device_id\": \"DO_NOT_TRACK_THIS_DEVICE\"}\n\n        auth = util.HTTPBasicAuth(self.client_id, \"\")\n        response = self.extractor.request(\n            url, method=\"POST\", headers=self.headers,\n            data=data, auth=auth, fatal=False)\n        data = response.json()\n\n        if response.status_code != 200:\n            self.log.debug(\"Server response: %s\", data)\n            raise self.extractor.exc.AuthenticationError(\n                f\"\\\"{data.get('error')}: {data.get('message')}\\\"\")\n        return \"Bearer \" + data[\"access_token\"]\n\n    def _call(self, endpoint, params):\n        url = self.root + endpoint\n        params[\"raw_json\"] = \"1\"\n\n        while True:\n            self.authenticate()\n            response = self.extractor.request(\n                url, params=params, headers=self.headers, fatal=None)\n\n            remaining = response.headers.get(\"x-ratelimit-remaining\")\n            if remaining and float(remaining) < 2:\n                self.log.warning(\"API rate limit exceeded\")\n                if self._warn_429 and self.client_id == self.CLIENT_ID:\n                    self.log.info(\n                        \"Register your own OAuth application and use its \"\n                        \"credentials to prevent this error: \"\n                        \"https://gdl-org.github.io/docs/configuration.html\"\n                        \"#extractor-reddit-client-id-user-agent\")\n                self._warn_429 = False\n                self.extractor.wait(\n                    seconds=response.headers[\"x-ratelimit-reset\"])\n                continue\n\n            try:\n                data = response.json()\n            except ValueError:\n                raise self.extractor.exc.AbortExtraction(\n                    text.remove_html(response.text))\n\n            if \"error\" in data:\n                exc = self.extractor.exc\n                if data[\"error\"] == 403:\n                    raise exc.AuthorizationError()\n                if data[\"error\"] == 404:\n                    raise exc.NotFoundError(self.extractor.subcategory)\n                self.log.debug(data)\n                raise exc.AbortExtraction(data.get(\"message\"))\n            return data\n\n    def _pagination(self, endpoint, params):\n        id_min = self._parse_id(\"id-min\", 0)\n        id_max = self._parse_id(\"id-max\", float(\"inf\"))\n        if id_max == 2147483647:\n            self.log.debug(\"Ignoring 'id-max' setting \\\"zik0zj\\\"\")\n            id_max = float(\"inf\")\n        date_min, date_max = self.extractor._get_date_min_max(0, 253402210800)\n\n        if limit := self.extractor.config(\"limit\"):\n            params[\"limit\"] = limit\n\n        while True:\n            data = self._call(endpoint, params)[\"data\"]\n\n            for child in data[\"children\"]:\n                kind = child[\"kind\"]\n                post = child[\"data\"]\n\n                if (date_min <= post[\"created_utc\"] <= date_max and\n                        id_min <= self._decode(post[\"id\"]) <= id_max):\n\n                    if kind == \"t3\":\n                        if post[\"num_comments\"] and self.comments:\n                            try:\n                                yield self.submission(post[\"id\"])\n                            except self.extractor.exc.AuthorizationError:\n                                pass\n                        else:\n                            yield post, ()\n\n                    elif kind == \"t1\" and self.comments:\n                        yield None, (post,)\n\n            if not data[\"after\"]:\n                return\n            params[\"after\"] = data[\"after\"]\n\n    def _flatten(self, comments, link_id=None):\n        extra = []\n        queue = comments[\"data\"][\"children\"]\n        while queue:\n            comment = queue.pop(0)\n            if comment[\"kind\"] == \"more\":\n                if link_id:\n                    extra.extend(comment[\"data\"][\"children\"])\n                continue\n            comment = comment[\"data\"]\n            yield comment\n            if comment[\"replies\"]:\n                queue += comment[\"replies\"][\"data\"][\"children\"]\n        if link_id and extra:\n            yield from self.morechildren(link_id, extra)\n\n    def _parse_id(self, key, default):\n        sid = self.extractor.config(key)\n        return self._decode(sid.rpartition(\"_\")[2].lower()) if sid else default\n\n    def _decode(self, sid):\n        return util.bdecode(sid, \"0123456789abcdefghijklmnopqrstuvwxyz\")\n\n\ndef _refresh_token_cache(token):\n    if token and token[0] == \"#\":\n        return None\n    return token\n"
  },
  {
    "path": "gallery_dl/extractor/redgifs.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2020-2026 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://redgifs.com/\"\"\"\n\nfrom .common import Extractor, Message\nfrom .. import text\n\n\nclass RedgifsExtractor(Extractor):\n    \"\"\"Base class for redgifs extractors\"\"\"\n    category = \"redgifs\"\n    filename_fmt = \\\n        \"{category}_{gallery:?//[:11]}{num:?_/_/>02}{id}.{extension}\"\n    archive_fmt = \"{id}\"\n    root = \"https://www.redgifs.com\"\n\n    def __init__(self, match):\n        Extractor.__init__(self, match)\n        self.key = match[1]\n\n    def _init(self):\n        self.api = RedgifsAPI(self)\n\n        formats = self.config(\"format\")\n        if formats is None:\n            formats = (\"hd\", \"sd\", \"gif\")\n        elif isinstance(formats, str):\n            formats = (formats, \"hd\", \"sd\", \"gif\")\n        self.formats = formats\n\n    def items(self):\n        metadata = self.metadata()\n\n        for gif in self.gifs():\n\n            if gallery := gif.get(\"gallery\"):\n                gifs = self.api.gallery(gallery)[\"gifs\"]\n                enum = 1\n                cnt = len(gifs)\n            else:\n                gifs = (gif,)\n                enum = 0\n                cnt = 1\n\n            gif.update(metadata)\n            gif[\"count\"] = cnt\n            gif[\"date\"] = self.parse_timestamp(gif.get(\"createDate\"))\n            yield Message.Directory, \"\", gif\n\n            for num, gif in enumerate(gifs, enum):\n                gif[\"_fallback\"] = formats = self._formats(gif)\n                url = next(formats, None)\n\n                if not url:\n                    self.log.warning(\n                        \"Skipping '%s' (format not available)\", gif[\"id\"])\n                    continue\n\n                gif[\"num\"] = num\n                gif[\"count\"] = cnt\n                yield Message.Url, url, gif\n\n    def _formats(self, gif):\n        urls = gif[\"urls\"]\n        for fmt in self.formats:\n            if url := urls.get(fmt):\n                url = url.replace(\"//thumbs2.\", \"//thumbs3.\", 1)\n                text.nameext_from_url(url, gif)\n                yield url\n\n    def metadata(self):\n        return {}\n\n    def gifs(self):\n        return ()\n\n\nclass RedgifsUserExtractor(RedgifsExtractor):\n    \"\"\"Extractor for redgifs user profiles\"\"\"\n    subcategory = \"user\"\n    directory_fmt = (\"{category}\", \"{userName}\")\n    pattern = (r\"(?:https?://)?(?:\\w+\\.)?redgifs\\.com/users/([^/?#]+)/?\"\n               r\"(?:\\?([^#]+))?$\")\n    example = \"https://www.redgifs.com/users/USER\"\n\n    def __init__(self, match):\n        RedgifsExtractor.__init__(self, match)\n        self.query = match[2]\n\n    def metadata(self):\n        return {\"userName\": self.key}\n\n    def gifs(self):\n        order = text.parse_query(self.query).get(\"order\")\n        return self.api.user(self.key, order or \"new\")\n\n\nclass RedgifsCollectionExtractor(RedgifsExtractor):\n    \"\"\"Extractor for an individual user collection\"\"\"\n    subcategory = \"collection\"\n    directory_fmt = (\n        \"{category}\", \"{collection[userName]}\", \"{collection[folderName]}\")\n    archive_fmt = \"{collection[folderId]}_{id}\"\n    pattern = (r\"(?:https?://)?(?:www\\.)?redgifs\\.com/users\"\n               r\"/([^/?#]+)/collections/([^/?#]+)\")\n    example = \"https://www.redgifs.com/users/USER/collections/ID\"\n\n    def __init__(self, match):\n        RedgifsExtractor.__init__(self, match)\n        self.collection_id = match[2]\n\n    def metadata(self):\n        collection = self.api.collection_info(self.key, self.collection_id)\n        collection[\"userName\"] = self.key\n        return {\"collection\": collection}\n\n    def gifs(self):\n        return self.api.collection(self.key, self.collection_id)\n\n\nclass RedgifsCollectionsExtractor(RedgifsExtractor):\n    \"\"\"Extractor for redgifs user collections\"\"\"\n    subcategory = \"collections\"\n    pattern = (r\"(?:https?://)?(?:www\\.)?redgifs\\.com/users\"\n               r\"/([^/?#]+)/collections/?$\")\n    example = \"https://www.redgifs.com/users/USER/collections\"\n\n    def items(self):\n        base = f\"{self.root}/users/{self.key}/collections/\"\n        for collection in self.api.collections(self.key):\n            url = base + collection[\"folderId\"]\n            collection[\"_extractor\"] = RedgifsCollectionExtractor\n            yield Message.Queue, url, collection\n\n\nclass RedgifsNichesExtractor(RedgifsExtractor):\n    \"\"\"Extractor for redgifs niches\"\"\"\n    subcategory = \"niches\"\n    pattern = (r\"(?:https?://)?(?:www\\.)?redgifs\\.com/niches/([^/?#]+)/?\"\n               r\"(?:\\?([^#]+))?$\")\n    example = \"https://www.redgifs.com/niches/NAME\"\n\n    def __init__(self, match):\n        RedgifsExtractor.__init__(self, match)\n        self.query = match[2]\n\n    def gifs(self):\n        order = text.parse_query(self.query).get(\"order\")\n        return self.api.niches(self.key, order or \"new\")\n\n\nclass RedgifsSearchExtractor(RedgifsExtractor):\n    \"\"\"Extractor for redgifs search results\"\"\"\n    subcategory = \"search\"\n    directory_fmt = (\"{category}\", \"Search\", \"{search}\")\n    pattern = (r\"(?:https?://)?(?:\\w+\\.)?redgifs\\.com\"\n               r\"/(?:gifs/([^/?#]+)|search(?:/gifs)?()|browse)\"\n               r\"(?:/?\\?([^#]+))?\")\n    example = \"https://www.redgifs.com/gifs/TAG\"\n\n    def metadata(self):\n        tag, self.search, query = self.groups\n\n        self.params = params = text.parse_query(query)\n        if tag is not None:\n            params[\"tags\"] = text.unquote(tag)\n\n        return {\"search\": (params.get(\"query\") or\n                           params.get(\"tags\") or\n                           params.get(\"order\") or\n                           \"trending\")}\n\n    def gifs(self):\n        if self.search is None:\n            return self.api.gifs_search(self.params)\n        else:\n            return self.api.search_gifs(self.params)\n\n\nclass RedgifsImageExtractor(RedgifsExtractor):\n    \"\"\"Extractor for individual gifs from redgifs.com\"\"\"\n    subcategory = \"image\"\n    pattern = (r\"(?:https?://)?(?:\"\n               r\"(?:\\w+\\.)?redgifs\\.com/(?:watch|ifr)|\"\n               r\"(?:\\w+\\.)?gfycat\\.com(?:/gifs/detail|/\\w+)?|\"\n               r\"(?:www\\.)?gifdeliverynetwork\\.com|\"\n               r\"i\\.redgifs\\.com/i)/([A-Za-z0-9]+)\")\n    example = \"https://redgifs.com/watch/ID\"\n\n    def gifs(self):\n        return (self.api.gif(self.key),)\n\n\nclass RedgifsAPI():\n    \"\"\"https://api.redgifs.com/docs/index.html\"\"\"\n\n    API_ROOT = \"https://api.redgifs.com\"\n\n    def __init__(self, extractor):\n        self.extractor = extractor\n        self.headers = {\n            \"Accept\"        : \"application/json, text/plain, */*\",\n            \"Referer\"       : extractor.root + \"/\",\n            \"Authorization\" : None,\n            \"Origin\"        : extractor.root,\n        }\n\n    def gif(self, gif_id):\n        endpoint = \"/v2/gifs/\" + gif_id.lower()\n        return self._call(endpoint)[\"gif\"]\n\n    def gallery(self, gallery_id):\n        endpoint = \"/v2/gallery/\" + gallery_id\n        return self._call(endpoint)\n\n    def user(self, user, order=\"new\"):\n        endpoint = f\"/v2/users/{user.lower()}/search\"\n        params = {\"order\": order}\n        return self._pagination(endpoint, params)\n\n    def collection(self, user, collection_id):\n        endpoint = f\"/v2/users/{user}/collections/{collection_id}/gifs\"\n        return self._pagination(endpoint)\n\n    def collection_info(self, user, collection_id):\n        endpoint = f\"/v2/users/{user}/collections/{collection_id}\"\n        return self._call(endpoint)\n\n    def collections(self, user):\n        endpoint = f\"/v2/users/{user}/collections\"\n        return self._pagination(endpoint, key=\"collections\")\n\n    def niches(self, niche, order):\n        endpoint = f\"/v2/niches/{niche}/gifs\"\n        params = {\"count\": 30, \"order\": order}\n        return self._pagination(endpoint, params)\n\n    def gifs_search(self, params):\n        endpoint = \"/v2/gifs/search\"\n        params[\"search_text\"] = params.pop(\"tags\", None)\n        return self._pagination(endpoint, params)\n\n    def search_gifs(self, params):\n        endpoint = \"/v2/search/gifs\"\n        return self._pagination(endpoint, params)\n\n    def _call(self, endpoint, params=None):\n        url = self.API_ROOT + endpoint\n        self.headers[\"Authorization\"] = self.extractor.cache(\n            self._auth, _key=None, _exp=600)\n        return self.extractor.request_json(\n            url, params=params, headers=self.headers)\n\n    def _pagination(self, endpoint, params=None, key=\"gifs\"):\n        if params is None:\n            params = {}\n        params[\"page\"] = 1\n\n        while True:\n            data = self._call(endpoint, params)\n            yield from data[key]\n\n            if params[\"page\"] >= data[\"pages\"]:\n                return\n            params[\"page\"] += 1\n\n    def _auth(self):\n        # https://github.com/Redgifs/api/wiki/Temporary-tokens\n        url = self.API_ROOT + \"/v2/auth/temporary\"\n        self.headers[\"Authorization\"] = None\n        return \"Bearer \" + self.extractor.request_json(\n            url, headers=self.headers)[\"token\"]\n"
  },
  {
    "path": "gallery_dl/extractor/rule34us.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2021-2025 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://rule34.us/\"\"\"\n\nfrom .booru import BooruExtractor\nfrom .. import text\nimport collections\n\n\nclass Rule34usExtractor(BooruExtractor):\n    category = \"rule34us\"\n    root = \"https://rule34.us\"\n    per_page = 42\n\n    def _init(self):\n        self._find_tags = text.re(\n            r'<li class=\"([^-\"]+)-tag\"[^>]*><a href=\"[^;\"]+;q=([^\"]+)').findall\n\n    def _parse_post(self, post_id):\n        url = f\"{self.root}/index.php?r=posts/view&id={post_id}\"\n        page = self.request(url).text\n        extr = text.extract_from(page)\n\n        post = {\n            \"id\"      : post_id,\n            \"tags\"    : text.unescape(extr(\n                'name=\"keywords\" content=\"', '\"').rstrip(\", \")),\n            \"uploader\": text.extract(extr('Added by: ', '</li>'), \">\", \"<\")[0],\n            \"score\"   : text.extract(extr('Score: ', '> - <'), \">\", \"<\")[0],\n            \"width\"   : extr('Size: ', 'w'),\n            \"height\"  : extr(' x ', 'h'),\n            \"file_url\": extr('<source src=\"', '\"') or extr('<img src=\"', '\"'),\n        }\n\n        url = post[\"file_url\"]\n        if \"//video-cdn1.\" in url:\n            post[\"file_url\"] = url.replace(\"//video-cdn1.\", \"//video.\")\n            post[\"_fallback\"] = (url,)\n        post[\"md5\"] = url.rpartition(\"/\")[2].partition(\".\")[0]\n\n        tags = collections.defaultdict(list)\n        for tag_type, tag_name in self._find_tags(page):\n            tags[tag_type].append(text.unquote(tag_name))\n        for key, value in tags.items():\n            post[\"tags_\" + key] = \" \".join(value)\n\n        return post\n\n\nclass Rule34usTagExtractor(Rule34usExtractor):\n    subcategory = \"tag\"\n    directory_fmt = (\"{category}\", \"{search_tags}\")\n    archive_fmt = \"t_{search_tags}_{id}\"\n    pattern = r\"(?:https?://)?rule34\\.us/index\\.php\\?r=posts/index&q=([^&#]*)\"\n    example = \"https://rule34.us/index.php?r=posts/index&q=TAG\"\n\n    def __init__(self, match):\n        Rule34usExtractor.__init__(self, match)\n        self.tags = text.unquote(match[1].replace(\"+\", \" \"))\n\n    def metadata(self):\n        return {\"search_tags\": self.tags}\n\n    def posts(self):\n        url = self.root + \"/index.php\"\n        params = {\n            \"r\"   : \"posts/index\",\n            \"q\"   : self.tags,\n            \"page\": self.page_start,\n        }\n\n        while True:\n            page = self.request(url, params=params).text\n\n            cnt = 0\n            for post_id in text.extract_iter(page, '><a id=\"', '\"'):\n                yield self._parse_post(post_id)\n                cnt += 1\n\n            if cnt < self.per_page:\n                return\n\n            if \"page\" in params:\n                del params[\"page\"]\n            params[\"q\"] = self.tags + \" id:<\" + post_id\n\n\nclass Rule34usPostExtractor(Rule34usExtractor):\n    subcategory = \"post\"\n    archive_fmt = \"{id}\"\n    pattern = r\"(?:https?://)?rule34\\.us/index\\.php\\?r=posts/view&id=(\\d+)\"\n    example = \"https://rule34.us/index.php?r=posts/view&id=12345\"\n\n    def __init__(self, match):\n        Rule34usExtractor.__init__(self, match)\n        self.post_id = match[1]\n\n    def posts(self):\n        return (self._parse_post(self.post_id),)\n"
  },
  {
    "path": "gallery_dl/extractor/rule34vault.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://rule34vault.com/\"\"\"\n\nfrom .booru import BooruExtractor\nfrom .. import text\nimport collections\n\nBASE_PATTERN = r\"(?:https?://)?rule34vault\\.com\"\n\n\nclass Rule34vaultExtractor(BooruExtractor):\n    category = \"rule34vault\"\n    root = \"https://rule34vault.com\"\n    root_cdn = \"https://r34xyz.b-cdn.net\"\n    filename_fmt = \"{category}_{id}.{extension}\"\n    per_page = 100\n\n    TAG_TYPES = {\n        1: \"general\",\n        2: \"copyright\",\n        4: \"character\",\n        8: \"artist\",\n    }\n\n    def _file_url(self, post):\n        post_id = post[\"id\"]\n        extension = \"jpg\" if post[\"type\"] == 0 else \"mp4\"\n        post[\"file_url\"] = url = (f\"{self.root_cdn}/posts/{post_id // 1000}/\"\n                                  f\"{post_id}/{post_id}.{extension}\")\n        return url\n\n    def _prepare(self, post):\n        post.pop(\"files\", None)\n        post[\"date\"] = self.parse_datetime_iso(post[\"created\"])\n        if \"tags\" in post:\n            post[\"tags\"] = [t[\"value\"] for t in post[\"tags\"]]\n\n    def _tags(self, post, _):\n        if \"tags\" not in post:\n            post.update(self._fetch_post(post[\"id\"]))\n\n        tags = collections.defaultdict(list)\n        for tag in post[\"tags\"]:\n            tags[tag[\"type\"]].append(tag[\"value\"])\n        types = self.TAG_TYPES\n        for type, values in tags.items():\n            post[\"tags_\" + types[type]] = values\n\n    def _fetch_post(self, post_id):\n        url = f\"{self.root}/api/v2/post/{post_id}\"\n        return self.request_json(url)\n\n    def _pagination(self, endpoint, params=None):\n        url = f\"{self.root}/api{endpoint}\"\n\n        if params is None:\n            params = {}\n        params[\"CountTotal\"] = False\n        params[\"Skip\"] = self.page_start * self.per_page\n        params[\"take\"] = self.per_page\n        threshold = self.per_page\n\n        while True:\n            data = self.request_json(url, method=\"POST\", json=params)\n\n            yield from data[\"items\"]\n\n            if len(data[\"items\"]) < threshold:\n                return\n            params[\"cursor\"] = data.get(\"cursor\")\n            params[\"Skip\"] += params[\"take\"]\n\n\nclass Rule34vaultPostExtractor(Rule34vaultExtractor):\n    subcategory = \"post\"\n    archive_fmt = \"{id}\"\n    pattern = BASE_PATTERN + r\"/post/(\\d+)\"\n    example = \"https://rule34vault.com/post/12345\"\n\n    def posts(self):\n        return (self._fetch_post(self.groups[0]),)\n\n\nclass Rule34vaultPlaylistExtractor(Rule34vaultExtractor):\n    subcategory = \"playlist\"\n    directory_fmt = (\"{category}\", \"{playlist_id}\")\n    archive_fmt = \"p_{playlist_id}_{id}\"\n    pattern = BASE_PATTERN + r\"/playlists/view/(\\d+)\"\n    example = \"https://rule34vault.com/playlists/view/12345\"\n\n    def metadata(self):\n        return {\"playlist_id\": self.groups[0]}\n\n    def posts(self):\n        endpoint = \"/v2/post/search/playlist/\" + self.groups[0]\n        return self._pagination(endpoint)\n\n\nclass Rule34vaultTagExtractor(Rule34vaultExtractor):\n    subcategory = \"tag\"\n    directory_fmt = (\"{category}\", \"{search_tags}\")\n    archive_fmt = \"t_{search_tags}_{id}\"\n    pattern = BASE_PATTERN + r\"/(?!p(?:ost|laylists)/)([^/?#]+)\"\n    example = \"https://rule34vault.com/TAG\"\n\n    def metadata(self):\n        self.tags = text.unquote(self.groups[0]).split(\"%7C\")\n        return {\"search_tags\": \" \".join(self.tags)}\n\n    def posts(self):\n        endpoint = \"/v2/post/search/root\"\n        params = {\"includeTags\": [t.replace(\"_\", \" \") for t in self.tags]}\n        return self._pagination(endpoint, params)\n"
  },
  {
    "path": "gallery_dl/extractor/rule34xyz.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2024-2026 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://rule34.world/ and https://rule34.xyz/\"\"\"\n\nfrom .booru import BooruExtractor\nfrom .. import text\nimport collections\n\nBASE_PATTERN = r\"(?:https?://)?(?:www\\.)?rule34\\.(xyz|world)\"\n\n\nclass Rule34xyzExtractor(BooruExtractor):\n    category = \"rule34xyz\"\n    root = \"https://rule34.xyz\"\n    root_cdn = \"https://rule34xyz.b-cdn.net\"\n    filename_fmt = \"{category}_{id}.{extension}\"\n    per_page = 60\n\n    TAG_TYPES = {\n        None: \"general\",\n        0   : \"general\",\n        1   : \"general\",\n        2   : \"copyright\",\n        4   : \"character\",\n        8   : \"artist\",\n        16  : \"system\",\n        32  : \"meta\",\n    }\n    FORMATS = {\n        \"10\" : \"pic.jpg\",\n        \"100\": \"mov.mp4\",\n        \"101\": \"mov720.mp4\",\n        \"102\": \"mov480.mp4\",\n    }\n\n    def __init__(self, match):\n        if match[1] == \"world\":\n            self.category = \"rule34world\"\n            self.root = \"https://rule34.world\"\n            self.root_cdn = \"https://rule34storage.b-cdn.net\"\n        BooruExtractor.__init__(self, match)\n\n    def _init(self):\n        if formats := self.config(\"format\"):\n            if isinstance(formats, str):\n                formats = formats.split(\",\")\n            self.formats = formats\n        else:\n            self.formats = (\"100\", \"101\", \"102\", \"10\")\n\n    def _file_url(self, post):\n        files = post[\"files\"]\n\n        for fmt in self.formats:\n            if fmt in files:\n                extension = self.FORMATS.get(fmt)\n                break\n        else:\n            self.log.warning(\"%s: Requested format not available\", post[\"id\"])\n            fmt = next(iter(files))\n\n        post_id = post[\"id\"]\n        root = self.root_cdn if files[fmt][0] else self.root\n        post[\"file_url\"] = url = \\\n            f\"{root}/posts/{post_id // 1000}/{post_id}/{post_id}.{extension}\"\n        post[\"format_id\"] = fmt\n        post[\"format\"] = extension.partition(\".\")[0]\n\n        return url\n\n    def _prepare(self, post):\n        post.pop(\"files\", None)\n        post[\"date\"] = self.parse_datetime_iso(post[\"created\"])\n        post[\"filename\"], _, post[\"format\"] = post[\"filename\"].rpartition(\".\")\n        if \"tags\" in post:\n            post[\"tags\"] = [t[\"value\"] for t in post[\"tags\"]]\n\n    def _tags(self, post, _):\n        if \"tags\" not in post:\n            post.update(self._fetch_post(post[\"id\"]))\n\n        tags = collections.defaultdict(list)\n        for tag in post[\"tags\"]:\n            tags[tag[\"type\"]].append(tag[\"value\"])\n        types = self.TAG_TYPES\n        for type, values in tags.items():\n            post[\"tags_\" + types[type]] = values\n\n    def _fetch_post(self, post_id):\n        url = f\"{self.root}/api/v2/post/{post_id}\"\n        return self.request_json(url)\n\n    def _pagination(self, endpoint, params=None):\n        url = f\"{self.root}/api{endpoint}\"\n\n        if params is None:\n            params = {}\n        params[\"Skip\"] = self.page_start * self.per_page\n        params[\"take\"] = self.per_page\n        params[\"CountTotal\"] = False\n        params[\"IncludeLinks\"] = True\n        params[\"OrderBy\"] = 0\n        threshold = self.per_page\n\n        while True:\n            data = self.request_json(url, method=\"POST\", json=params)\n\n            yield from data[\"items\"]\n\n            if len(data[\"items\"]) < threshold:\n                return\n            params[\"Skip\"] += self.per_page\n            params[\"cursor\"] = data[\"cursor\"]\n\n    def login(self):\n        username, password = self._get_auth_info()\n        if username:\n            self.session.headers[\"Authorization\"] = self.cache(\n                self._login_impl, username, password,\n                _exp=3650*86400, _mem=False)\n\n    def _login_impl(self, username, password):\n        self.log.info(\"Logging in as %s\", username)\n\n        url = self.root + \"/api/v2/auth/signin\"\n        data = {\"email\": username, \"password\": password}\n        response = self.request_json(\n            url, method=\"POST\", json=data, fatal=False)\n\n        if jwt := response.get(\"jwt\"):\n            return \"Bearer \" + jwt\n        raise self.exc.AuthenticationError(\n            (msg := response.get(\"message\")) and f'\"{msg}\"')\n\n\nclass Rule34xyzPostExtractor(Rule34xyzExtractor):\n    subcategory = \"post\"\n    archive_fmt = \"{id}\"\n    pattern = BASE_PATTERN + r\"/post/(\\d+)\"\n    example = \"https://rule34.xyz/post/12345\"\n\n    def posts(self):\n        return (self._fetch_post(self.groups[1]),)\n\n\nclass Rule34xyzPlaylistExtractor(Rule34xyzExtractor):\n    subcategory = \"playlist\"\n    directory_fmt = (\"{category}\", \"{playlist_id}\")\n    archive_fmt = \"p_{playlist_id}_{id}\"\n    pattern = BASE_PATTERN + r\"/playlists/view/(\\d+)\"\n    example = \"https://rule34.xyz/playlists/view/12345\"\n\n    def metadata(self):\n        return {\"playlist_id\": self.groups[1]}\n\n    def posts(self):\n        endpoint = \"/v2/post/search/playlist/\" + self.groups[1]\n        return self._pagination(endpoint)\n\n\nclass Rule34xyzTagExtractor(Rule34xyzExtractor):\n    subcategory = \"tag\"\n    directory_fmt = (\"{category}\", \"{search_tags}\")\n    archive_fmt = \"t_{search_tags}_{id}\"\n    pattern = BASE_PATTERN + r\"/([^/?#]+)$\"\n    example = \"https://rule34.xyz/TAG\"\n\n    def metadata(self):\n        self.tags = text.unquote(text.unquote(\n            self.groups[1]).replace(\"_\", \" \")).split(\"|\")\n        return {\"search_tags\": \", \".join(self.tags)}\n\n    def posts(self):\n        endpoint = \"/v2/post/search/root\"\n        params = {\"includeTags\": self.tags}\n        return self._pagination(endpoint, params)\n"
  },
  {
    "path": "gallery_dl/extractor/s3ndpics.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2025 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://s3nd.pics/\"\"\"\n\nfrom .common import Extractor, Message\nfrom .. import text\n\nBASE_PATTERN = r\"(?:https?://)?(?:www\\.)?s3nd\\.pics\"\n\n\nclass S3ndpicsExtractor(Extractor):\n    \"\"\"Base class for s3ndpics extractors\"\"\"\n    category = \"s3ndpics\"\n    root = \"https://s3nd.pics\"\n    root_api = root + \"/api\"\n    directory_fmt = (\"{category}\", \"{user[username]}\",\n                     \"{date} {title:?/ /}({id})\")\n    filename_fmt = \"{num:>02}.{extension}\"\n    archive_fmt = \"{id}_{num}\"\n\n    def items(self):\n        base = \"https://s3.s3nd.pics/s3nd-pics/\"\n\n        for post in self.posts():\n            post[\"id\"] = post.pop(\"_id\", None)\n            post[\"user\"] = post.pop(\"userId\", None)\n            post[\"date\"] = self.parse_datetime_iso(post[\"createdAt\"])\n            post[\"date_updated\"] = self.parse_datetime_iso(post[\"updatedAt\"])\n\n            files = post.pop(\"files\", ())\n            post[\"count\"] = len(files)\n\n            yield Message.Directory, \"\", post\n            for post[\"num\"], file in enumerate(files, 1):\n                post[\"type\"] = file[\"type\"]\n                path = file[\"url\"]\n                text.nameext_from_url(path, post)\n                yield Message.Url, base + path, post\n\n    def _pagination(self, url, params):\n        params[\"page\"] = 1\n\n        while True:\n            data = self.request_json(url, params=params)\n\n            self.kwdict[\"total\"] = data[\"pagination\"][\"total\"]\n            yield from data[\"posts\"]\n\n            if params[\"page\"] >= data[\"pagination\"][\"pages\"]:\n                return\n            params[\"page\"] += 1\n\n\nclass S3ndpicsPostExtractor(S3ndpicsExtractor):\n    subcategory = \"post\"\n    pattern = BASE_PATTERN + r\"/post/([0-9a-f]+)\"\n    example = \"https://s3nd.pics/post/0123456789abcdef01234567\"\n\n    def posts(self):\n        url = f\"{self.root_api}/posts/{self.groups[0]}\"\n        return (self.request_json(url)[\"post\"],)\n\n\nclass S3ndpicsUserExtractor(S3ndpicsExtractor):\n    subcategory = \"user\"\n    pattern = BASE_PATTERN + r\"/user/(\\w+)\"\n    example = \"https://s3nd.pics/user/USER\"\n\n    def posts(self):\n        url = f\"{self.root_api}/users/username/{self.groups[0]}\"\n        self.kwdict[\"user\"] = user = self.request_json(url)[\"user\"]\n\n        url = self.root_api + \"/posts\"\n        params = {\n            \"userId\": user[\"_id\"],\n            \"limit\" : \"12\",\n            \"sortBy\": \"newest\",\n        }\n        return self._pagination(url, params)\n\n\nclass S3ndpicsSearchExtractor(S3ndpicsExtractor):\n    subcategory = \"search\"\n    pattern = BASE_PATTERN + r\"/search/?\\?([^#]+)\"\n    example = \"https://s3nd.pics/search?QUERY\"\n\n    def posts(self):\n        url = self.root_api + \"/posts\"\n        params = text.parse_query(self.groups[0])\n        params.setdefault(\"limit\", \"20\")\n        self.kwdict[\"search_tags\"] = \\\n            params.get(\"tag\") or params.get(\"tags\") or params.get(\"q\")\n        return self._pagination(url, params)\n"
  },
  {
    "path": "gallery_dl/extractor/sankaku.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2014-2026 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://sankaku.app/\"\"\"\n\nfrom .booru import BooruExtractor\nfrom .common import Message\nfrom .. import text, util\nimport collections\n\nBASE_PATTERN = r\"(?:https?://)?\" \\\n    r\"(?:(?:chan|www|beta|black|white)\\.sankakucomplex\\.com|sankaku\\.app)\" \\\n    r\"(?:/[a-z]{2}(?:[-_][A-Z]{2})?)?\"\n\n\nclass SankakuExtractor(BooruExtractor):\n    \"\"\"Base class for sankaku channel extractors\"\"\"\n    basecategory = \"booru\"\n    category = \"sankaku\"\n    root = \"https://sankaku.app\"\n    filename_fmt = \"{category}_{id}_{md5}.{extension}\"\n    _warning = True\n\n    TAG_TYPES = {\n        0: \"general\",\n        1: \"artist\",\n        2: \"studio\",\n        3: \"copyright\",\n        4: \"character\",\n        5: \"genre\",\n        6: \"\",\n        7: \"\",\n        8: \"medium\",\n        9: \"meta\",\n    }\n\n    def skip_files(self, num):\n        return 0\n\n    def _init(self):\n        self.api = SankakuAPI(self)\n        if self.config(\"tags\") == \"extended\":\n            self._tags = self._tags_extended\n            self._tags_findall = text.re(\n                r\"tag-type-([^\\\"' ]+).*?\\?tags=([^\\\"'&]+)\").findall\n\n    def _file_url(self, post):\n        url = post[\"file_url\"]\n        if not url:\n            if post[\"status\"] != \"active\":\n                self.log.warning(\n                    \"Unable to download post %s (%s)\",\n                    post[\"id\"], post[\"status\"])\n            elif self._warning:\n                self.log.warning(\n                    \"Login required to download 'contentious_content' posts\")\n                SankakuExtractor._warning = False\n        elif url[4] != \"s\":\n            url = \"https\" + url[4:]\n        return url\n\n    def _prepare(self, post):\n        post[\"created_at\"] = post[\"created_at\"][\"s\"]\n        post[\"date\"] = self.parse_timestamp(post[\"created_at\"])\n        post[\"tags\"] = post.pop(\"tag_names\", ())\n        post[\"tag_string\"] = \" \".join(post[\"tags\"])\n        post[\"_http_validate\"] = self._check_expired\n\n    def _check_expired(self, response):\n        return not response.history or '.com/expired.png' not in response.url\n\n    def _tags(self, post, page):\n        tags = collections.defaultdict(list)\n        for tag in self.api.tags(post[\"id\"]):\n            if name := tag[\"name\"]:\n                tags[tag[\"type\"]].append(name.lower().replace(\" \", \"_\"))\n        types = self.TAG_TYPES\n        for type, values in tags.items():\n            name = types[type]\n            post[\"tags_\" + name] = values\n            post[\"tag_string_\" + name] = \" \".join(values)\n\n    def _tags_extended(self, post, page):\n        try:\n            url = \"https://chan.sankakucomplex.com/posts/\" + post[\"id\"]\n            headers = {\"Referer\": url}\n            page = self.request(url, headers=headers).text\n        except Exception as exc:\n            return self.log.warning(\n                \"%s: Failed to extract extended tag categories (%s: %s)\",\n                post[\"id\"], exc.__class__.__name__, exc)\n\n        tags = collections.defaultdict(list)\n        tag_sidebar = text.extr(page, '<ul id=\"tag-sidebar\"', \"</ul>\")\n        for tag_type, tag_name in self._tags_findall(tag_sidebar):\n            tags[tag_type].append(text.unescape(text.unquote(tag_name)))\n        for type, values in tags.items():\n            post[\"tags_\" + type] = values\n            post[\"tag_string_\" + type] = \" \".join(values)\n\n    def _notes(self, post, page):\n        if post.get(\"has_notes\"):\n            post[\"notes\"] = self.api.notes(post[\"id\"])\n            for note in post[\"notes\"]:\n                note[\"created_at\"] = note[\"created_at\"][\"s\"]\n                note[\"updated_at\"] = note[\"updated_at\"][\"s\"]\n        else:\n            post[\"notes\"] = ()\n\n\nclass SankakuTagExtractor(SankakuExtractor):\n    \"\"\"Extractor for images from sankaku.app by search-tags\"\"\"\n    subcategory = \"tag\"\n    directory_fmt = (\"{category}\", \"{search_tags}\")\n    archive_fmt = \"t_{search_tags}_{id}\"\n    pattern = BASE_PATTERN + r\"(?:/posts)?/?\\?([^#]*)\"\n    example = \"https://sankaku.app/?tags=TAG\"\n\n    def __init__(self, match):\n        SankakuExtractor.__init__(self, match)\n        query = text.parse_query(match[1])\n        self.tags = text.unquote(query.get(\"tags\", \"\").replace(\"+\", \" \"))\n\n        if \"date:\" in self.tags:\n            # rewrite 'date:' tags (#1790)\n            self.tags = text.re(\n                r\"date:(\\d\\d)[.-](\\d\\d)[.-](\\d\\d\\d\\d)(?!T)\").sub(\n                r\"date:\\3-\\2-\\1T00:00\", self.tags)\n            self.tags = text.re(\n                r\"date:(\\d\\d\\d\\d)[.-](\\d\\d)[.-](\\d\\d)(?!T)\").sub(\n                r\"date:\\1-\\2-\\3T00:00\", self.tags)\n\n    def metadata(self):\n        return {\"search_tags\": self.tags}\n\n    def posts(self):\n        posts = self.api.posts_keyset({\"tags\": self.tags})\n\n        if \"parent:\" in self.tags:\n            import itertools\n            parent = self.api.posts_keyset({\"tags\": text.re(\n                r\"\\bparent:(\\w+)\").sub(r\"id_range:\\1\", self.tags)})\n            posts = itertools.chain(parent, posts)\n\n        return posts\n\n\nclass SankakuPoolExtractor(SankakuExtractor):\n    \"\"\"Extractor for image pools or books from sankaku.app\"\"\"\n    subcategory = \"pool\"\n    directory_fmt = (\"{category}\", \"pool\", \"{pool[id]} {pool[name_en]}\")\n    archive_fmt = \"p_{pool}_{id}\"\n    pattern = BASE_PATTERN + r\"/(?:books|pools?/show)/(\\w+)\"\n    example = \"https://sankaku.app/books/12345\"\n\n    def metadata(self):\n        pool = self.api.pools(self.groups[0])\n        pool[\"tags\"] = [tag[\"name\"] for tag in pool[\"tags\"]]\n        pool[\"artist_tags\"] = [tag[\"name\"] for tag in pool[\"artist_tags\"]]\n\n        self._posts = pool.pop(\"posts\")\n        for num, post in enumerate(self._posts, 1):\n            post[\"num\"] = num\n\n        return {\"pool\": pool}\n\n    def posts(self):\n        return self._posts\n\n\nclass SankakuPostExtractor(SankakuExtractor):\n    \"\"\"Extractor for single posts from sankaku.app\"\"\"\n    subcategory = \"post\"\n    archive_fmt = \"{id}\"\n    pattern = BASE_PATTERN + r\"/posts?(?:/show)?/(\\w+)\"\n    example = \"https://sankaku.app/post/show/12345\"\n\n    def posts(self):\n        return self.api.posts(self.groups[0])\n\n\nclass SankakuBooksExtractor(SankakuExtractor):\n    \"\"\"Extractor for books by tag search on sankaku.app\"\"\"\n    subcategory = \"books\"\n    pattern = BASE_PATTERN + r\"/books/?\\?([^#]*)\"\n    example = \"https://sankaku.app/books?tags=TAG\"\n\n    def __init__(self, match):\n        SankakuExtractor.__init__(self, match)\n        query = text.parse_query(match[1])\n        self.tags = text.unquote(query.get(\"tags\", \"\").replace(\"+\", \" \"))\n\n    def items(self):\n        params = {\"tags\": self.tags, \"pool_type\": \"0\"}\n        for pool in self.api.pools_keyset(params):\n            pool[\"_extractor\"] = SankakuPoolExtractor\n            url = \"https://sankaku.app/books/\" + pool[\"id\"]\n            yield Message.Queue, url, pool\n\n\nclass SankakuAPI():\n    \"\"\"Interface for the sankaku.app API\"\"\"\n    ROOT = \"https://sankakuapi.com\"\n    VERSION = None\n\n    def __init__(self, extractor):\n        self.extractor = extractor\n        self.headers = {\n            \"Accept\"     : \"application/vnd.sankaku.api+json;v=2\",\n            \"Api-Version\": self.VERSION,\n            \"Origin\"     : extractor.root,\n        }\n\n        self.username, self.password = extractor._get_auth_info()\n        if not self.username:\n            self.authenticate = util.noop\n\n    def notes(self, post_id):\n        params = {\"lang\": \"en\"}\n        return self._call(f\"/posts/{post_id}/notes\", params)\n\n    def tags(self, post_id):\n        endpoint = f\"/posts/{post_id}/tags\"\n        params = {\n            \"lang\" : \"en\",\n            \"page\" : 1,\n            \"limit\": 100,\n        }\n\n        tags = None\n        while True:\n            data = self._call(endpoint, params)\n\n            tags_new = data[\"data\"]\n            if not tags_new:\n                return tags or []\n            elif tags is None:\n                tags = tags_new\n            else:\n                tags.extend(tags_new)\n\n            if len(tags_new) < 80 or len(tags) >= data[\"total\"]:\n                return tags\n            params[\"page\"] += 1\n\n    def pools(self, pool_id):\n        params = {\"lang\": \"en\"}\n        return self._call(\"/pools/\" + pool_id, params)\n\n    def pools_keyset(self, params):\n        return self._pagination(\"/pools/keyset\", params)\n\n    def pools_series(self, params):\n        params_ = {\n            \"lang\"       : \"en\",\n            \"filledPools\": \"true\",\n            \"includes[]\" : \"pools\",\n        }\n        params_.update(params)\n        return self._pagination(\"/poolseriesv2\", params)\n\n    def posts(self, post_id):\n        params = {\n            \"lang\" : \"en\",\n            \"page\" : \"1\",\n            \"limit\": \"1\",\n            \"tags\" : (\"md5:\" if len(post_id) == 32 else \"id_range:\") + post_id,\n        }\n        return self._call(\"/v2/posts\", params)\n\n    def posts_keyset(self, params):\n        return self._pagination(\"/v2/posts/keyset\", params)\n\n    def authenticate(self):\n        self.headers[\"Authorization\"] = self.extractor.cache(\n            self._authenticate_impl, self.username, self.password,\n            _exp=365*86400, _mem=False)\n\n    def _authenticate_impl(self, username, password):\n        self.extractor.log.info(\"Logging in as %s\", username)\n\n        self.headers[\"Authorization\"] = None\n        url = self.ROOT + \"/auth/token\"\n        data = {\"login\": username, \"password\": password}\n\n        response = self.extractor.request(\n            url, method=\"POST\", headers=self.headers, json=data, fatal=False)\n        data = response.json()\n\n        if response.status_code >= 400 or not data.get(\"success\"):\n            raise self.extractor.exc.AuthenticationError(data.get(\"error\"))\n        return \"Bearer \" + data[\"access_token\"]\n\n    def _call(self, endpoint, params=None):\n        url = self.ROOT + endpoint\n        for _ in range(5):\n            self.authenticate()\n            response = self.extractor.request(\n                url, params=params, headers=self.headers, fatal=None)\n\n            if response.status_code == 429:\n                until = response.headers.get(\"X-RateLimit-Reset\")\n                if not until and b\"_tags-explicit-limit\" in response.content:\n                    raise self.extractor.exc.AuthorizationError(\n                        \"Search tag limit exceeded\")\n                seconds = None if until else 600\n                self.extractor.wait(until=until, seconds=seconds)\n                continue\n\n            data = response.json()\n            try:\n                success = data.get(\"success\", True)\n            except AttributeError:\n                success = True\n            if not success:\n                code = data.get(\"code\")\n                if code and code.endswith(\n                        (\"unauthorized\", \"invalid-token\", \"invalid_token\")):\n                    self.extractor.cache_update(\n                        self._authenticate_impl, self.username)\n                    continue\n                try:\n                    code = f\"'{code.rpartition('__')[2].replace('-', ' ')}'\"\n                except Exception:\n                    pass\n                raise self.extractor.exc.AbortExtraction(code)\n            return data\n\n    def _pagination(self, endpoint, params):\n        params[\"lang\"] = \"en\"\n        params[\"limit\"] = str(self.extractor.per_page)\n\n        if refresh := self.extractor.config(\"refresh\", False):\n            offset = expires = 0\n            from time import time\n\n        while True:\n            data = self._call(endpoint, params)\n\n            if refresh:\n                posts = data[\"data\"]\n                if offset:\n                    posts = util.advance(posts, offset)\n\n                for post in posts:\n                    if not expires:\n                        if url := post[\"file_url\"]:\n                            expires = text.parse_int(\n                                text.extr(url, \"e=\", \"&\")) - 60\n\n                    if 0 < expires <= time():\n                        self.extractor.log.debug(\"Refreshing download URLs\")\n                        expires = None\n                        break\n\n                    offset += 1\n                    yield post\n\n                if expires is None:\n                    expires = 0\n                    continue\n                offset = expires = 0\n\n            else:\n                yield from data[\"data\"]\n\n            params[\"next\"] = data[\"meta\"][\"next\"]\n            if not params[\"next\"]:\n                return\n"
  },
  {
    "path": "gallery_dl/extractor/sankakucomplex.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2019-2025 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://news.sankakucomplex.com/\"\"\"\n\nfrom .common import Extractor, Message\nfrom .. import text, util\n\n\nclass SankakucomplexExtractor(Extractor):\n    \"\"\"Base class for sankakucomplex extractors\"\"\"\n    category = \"sankakucomplex\"\n    root = \"https://news.sankakucomplex.com\"\n\n    def __init__(self, match):\n        Extractor.__init__(self, match)\n        self.path = match[1]\n\n\nclass SankakucomplexArticleExtractor(SankakucomplexExtractor):\n    \"\"\"Extractor for articles on news.sankakucomplex.com\"\"\"\n    subcategory = \"article\"\n    directory_fmt = (\"{category}\", \"{date:%Y-%m-%d} {title}\")\n    filename_fmt = \"{filename}.{extension}\"\n    archive_fmt = \"{date:%Y%m%d}_{filename}\"\n    pattern = (r\"(?:https?://)?(?:news|www)\\.sankakucomplex\\.com\"\n               r\"/(\\d\\d\\d\\d/\\d\\d/\\d\\d/[^/?#]+)\")\n    example = \"https://news.sankakucomplex.com/1970/01/01/TITLE\"\n\n    def items(self):\n        url = f\"{self.root}/{self.path}/?pg=X\"\n        extr = text.extract_from(self.request(url).text)\n        data = {\n            \"title\"      : text.unescape(\n                extr('property=\"og:title\" content=\"', '\"')),\n            \"description\": text.unescape(\n                extr('property=\"og:description\" content=\"', '\"')),\n            \"date\"       : self.parse_datetime_iso(\n                extr('property=\"article:published_time\" content=\"', '\"')),\n        }\n        content = extr('<div class=\"entry-content\">', '</article>')\n        data[\"tags\"] = text.split_html(extr('=\"meta-tags\">', '</div>'))[::2]\n\n        files = self._extract_images(content)\n        if self.config(\"videos\", True):\n            files += self._extract_videos(content)\n        if self.config(\"embeds\", False):\n            files += self._extract_embeds(content)\n        data[\"count\"] = len(files)\n\n        yield Message.Directory, \"\", data\n        for num, url in enumerate(files, 1):\n            file = text.nameext_from_url(url)\n            if url[0] == \"/\":\n                url = text.urljoin(self.root, url)\n            file[\"url\"] = url\n            file[\"num\"] = num\n            file.update(data)\n            yield Message.Url, url, file\n\n    def _extract_images(self, content):\n        orig_sub = text.re(r\"-\\d+x\\d+\\.\").sub\n        return [\n            orig_sub(\".\", url) for url in\n            util.unique(text.extract_iter(content, 'data-lazy-src=\"', '\"'))\n        ]\n\n    def _extract_videos(self, content):\n        return text.re(r\"<source [^>]*src=[\\\"']([^\\\"']+)\").findall(content)\n\n    def _extract_embeds(self, content):\n        return [\n            \"ytdl:\" + url for url in\n            text.re(r\"<iframe [^>]*src=[\\\"']([^\\\"']+)\").findall(content)\n        ]\n\n\nclass SankakucomplexTagExtractor(SankakucomplexExtractor):\n    \"\"\"Extractor for sankakucomplex blog articles by tag or author\"\"\"\n    subcategory = \"tag\"\n    pattern = (r\"(?:https?://)?(?:news|www)\\.sankakucomplex\\.com\"\n               r\"/((?:tag|category|author)/[^/?#]+)\")\n    example = \"https://news.sankakucomplex.com/tag/TAG/\"\n\n    def items(self):\n        pnum = 1\n        data = {\"_extractor\": SankakucomplexArticleExtractor}\n\n        while True:\n            url = f\"{self.root}/{self.path}/page/{pnum}/\"\n            response = self.request(url, fatal=False)\n            if response.status_code >= 400:\n                return\n            for url in util.unique_sequence(text.extract_iter(\n                    response.text, 'data-direct=\"', '\"')):\n                yield Message.Queue, url, data\n            pnum += 1\n"
  },
  {
    "path": "gallery_dl/extractor/schalenetwork.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2024-2025 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://niyaniya.moe/\"\"\"\n\nfrom .common import GalleryExtractor, Extractor, Message\nfrom .. import text\nimport collections\n\nBASE_PATTERN = (\n    r\"(?i)(?:https?://)?(\"\n    r\"(?:niyaniya|shupogaki)\\.moe|\"\n    r\"(?:koharu|anchira|seia)\\.to|\"\n    r\"(?:hoshino)\\.one\"\n    r\")\"\n)\n\n\nclass SchalenetworkExtractor(Extractor):\n    \"\"\"Base class for schale.network extractors\"\"\"\n    category = \"schalenetwork\"\n    root = \"https://niyaniya.moe\"\n    root_api = \"https://api.schale.network\"\n    root_auth = \"https://auth.schale.network\"\n    extr_class = None\n    request_interval = (0.5, 1.5)\n\n    def _init(self):\n        self.headers = {\n            \"Accept\" : \"*/*\",\n            \"Referer\": self.root + \"/\",\n            \"Origin\" : self.root,\n        }\n\n    def _pagination(self, endpoint, params):\n        url_api = self.root_api + endpoint\n        cls = self.extr_class\n\n        while True:\n            data = self.request_json(\n                url_api, params=params, headers=self.headers)\n\n            try:\n                entries = data[\"entries\"]\n            except KeyError:\n                return\n\n            for entry in entries:\n                url = f\"{self.root}/g/{entry['id']}/{entry['key']}\"\n                entry[\"_extractor\"] = cls\n                yield Message.Queue, url, entry\n\n            try:\n                if data[\"limit\"] * data[\"page\"] >= data[\"total\"]:\n                    return\n            except Exception:\n                pass\n            params[\"page\"] += 1\n\n    def _token(self, required=True):\n        if token := self.config(\"token\"):\n            return \"Bearer \" + token.rpartition(' ')[2]\n        if required:\n            raise self.exc.AuthRequired(\"'token'\", \"your favorites\")\n\n    def _crt(self):\n        crt = self.config(\"crt\")\n        if not crt:\n            self._require_auth()\n\n        if not text.re(r\"^[0-9a-f-]+$\").match(crt):\n            path, _, qs = crt.partition(\"?\")\n            if not qs:\n                qs = path\n            crt = text.parse_query(qs).get(\"crt\")\n            if not crt:\n                self._require_auth()\n\n        return crt\n\n    def _require_auth(self, exc=None):\n        if exc is None:\n            msg = None\n        else:\n            msg = f\"{exc.status} {exc.response.reason}\"\n        raise self.exc.AuthRequired(\n            \"'crt' query parameter & matching 'user-agent'\", None, msg)\n\n\nclass SchalenetworkGalleryExtractor(SchalenetworkExtractor, GalleryExtractor):\n    \"\"\"Extractor for schale.network galleries\"\"\"\n    filename_fmt = \"{num:>03}.{extension}\"\n    directory_fmt = (\"{category}\", \"{id} {title}\")\n    archive_fmt = \"{id}_{num}\"\n    request_interval = 0.0\n    pattern = BASE_PATTERN + r\"/(?:g|reader)/(\\d+)/(\\w+)\"\n    example = \"https://niyaniya.moe/g/12345/67890abcde/\"\n\n    TAG_TYPES = {\n        0 : \"general\",\n        1 : \"artist\",\n        2 : \"circle\",\n        3 : \"parody\",\n        4 : \"magazine\",\n        5 : \"character\",\n        6 : \"\",\n        7 : \"uploader\",\n        8 : \"male\",\n        9 : \"female\",\n        10: \"mixed\",\n        11: \"language\",\n        12: \"other\",\n        13: \"reclass\",\n    }\n\n    def metadata(self, _):\n        _, gid, gkey = self.groups\n\n        url = f\"{self.root_api}/books/detail/{gid}/{gkey}\"\n        headers = self.headers\n        data = self.request_json(url, headers=headers)\n\n        try:\n            data[\"date\"] = self.parse_timestamp(data[\"created_at\"] / 1000)\n            data[\"count\"] = len(data[\"thumbnails\"][\"entries\"])\n            del data[\"thumbnails\"]\n        except Exception:\n            pass\n\n        tags = []\n        types = self.TAG_TYPES\n        for tag in data[\"tags\"]:\n            name = tag[\"name\"]\n            namespace = tag.get(\"namespace\", 0)\n            tags.append(types[namespace] + \":\" + name)\n        if self.config(\"tags\", True):\n            categories = collections.defaultdict(list)\n            for tag in data[\"tags\"]:\n                categories[tag.get(\"namespace\", 0)].append(tag[\"name\"])\n            for type, values in categories.items():\n                data[\"tags_\" + types[type]] = values\n        data[\"tags\"] = tags\n\n        url = f\"{self.root_api}/books/detail/{gid}/{gkey}?crt={self._crt()}\"\n        if token := self._token(False):\n            headers = headers.copy()\n            headers[\"Authorization\"] = token\n        try:\n            data_fmt = self.request_json(\n                url, method=\"POST\", headers=headers)\n        except self.exc.HttpError as exc:\n            self._require_auth(exc)\n\n        self.fmt = self._select_format(data_fmt[\"data\"])\n        data[\"source\"] = data_fmt.get(\"source\")\n\n        return data\n\n    def images(self, _):\n        _, gid, gkey = self.groups\n        fmt = self.fmt\n\n        url = (f\"{self.root_api}/books/data/{gid}/{gkey}\"\n               f\"/{fmt['id']}/{fmt['key']}/{fmt['w']}?crt={self._crt()}\")\n        headers = self.headers\n\n        if self.config(\"cbz\", False):\n            headers[\"Authorization\"] = self._token()\n            dl = self.request_json(\n                url + \"&action=dl\", method=\"POST\", headers=headers)\n            # 'crt' parameter here is necessary for 'hdoujin' downloads\n            url = f\"{dl['base']}?crt={self._crt()}\"\n            info = text.nameext_from_url(url)\n            if \"fallback\" in dl:\n                info[\"_fallback\"] = (dl[\"fallback\"],)\n            if not info[\"extension\"]:\n                info[\"extension\"] = \"cbz\"\n            return ((url, info),)\n\n        data = self.request_json(url, headers=headers)\n        base = data[\"base\"]\n\n        results = []\n        for entry in data[\"entries\"]:\n            dimensions = entry[\"dimensions\"]\n            info = {\n                \"width\" : dimensions[0],\n                \"height\": dimensions[1],\n                \"_http_headers\": headers,\n            }\n            results.append((base + entry[\"path\"], info))\n        return results\n\n    def _select_format(self, formats):\n        fmt = self.config(\"format\")\n\n        if not fmt or fmt == \"best\":\n            fmtids = (\"0\", \"1600\", \"1280\", \"980\", \"780\")\n        elif isinstance(fmt, str):\n            fmtids = fmt.split(\",\")\n        elif isinstance(fmt, list):\n            fmtids = fmt\n        else:\n            fmtids = (str(fmt),)\n\n        for fmtid in fmtids:\n            try:\n                fmt = formats[fmtid]\n                if fmt[\"id\"]:\n                    break\n            except KeyError:\n                self.log.debug(\"%s: Format %s is not available\",\n                               self.groups[1], fmtid)\n        else:\n            raise self.exc.NotFoundError(\"format\")\n\n        self.log.debug(\"%s: Selected format %s\", self.groups[1], fmtid)\n        fmt[\"w\"] = fmtid\n        return fmt\n\n\nclass SchalenetworkSearchExtractor(SchalenetworkExtractor):\n    \"\"\"Extractor for schale.network search results\"\"\"\n    subcategory = \"search\"\n    pattern = BASE_PATTERN + r\"/(?:tag/([^/?#]+)|browse)?(?:/?\\?([^#]*))?$\"\n    example = \"https://niyaniya.moe/browse?s=QUERY\"\n\n    def items(self):\n        _, tag, qs = self.groups\n\n        params = text.parse_query(qs)\n        params[\"page\"] = text.parse_int(params.get(\"page\"), 1)\n\n        if tag is not None:\n            ns, sep, tag = text.unquote(tag).partition(\":\")\n            if \"+\" in tag:\n                tag = tag.replace(\"+\", \" \")\n                q = '\"'\n            else:\n                q = \"\"\n            q = '\"' if \" \" in tag else \"\"\n            params[\"s\"] = f\"{ns}{sep}{q}^{tag}${q}\"\n\n        return self._pagination(\"/books\", params)\n\n\nclass SchalenetworkFavoriteExtractor(SchalenetworkExtractor):\n    \"\"\"Extractor for schale.network favorites\"\"\"\n    subcategory = \"favorite\"\n    pattern = BASE_PATTERN + r\"/favorites(?:\\?([^#]*))?\"\n    example = \"https://niyaniya.moe/favorites\"\n\n    def items(self):\n        params = text.parse_query(self.groups[1])\n        params[\"page\"] = text.parse_int(params.get(\"page\"), 1)\n        self.headers[\"Authorization\"] = self._token()\n        return self._pagination(\"/books/favorites?crt=\" + self._crt(), params)\n\n\nSchalenetworkExtractor.extr_class = SchalenetworkGalleryExtractor\n"
  },
  {
    "path": "gallery_dl/extractor/scrolller.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2024-2026 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://scrolller.com/\"\"\"\n\nfrom .common import Extractor, Message\nfrom .. import text, util\n\nBASE_PATTERN = r\"(?:https?://)?(?:www\\.)?scrolller\\.com\"\n\n\nclass ScrolllerExtractor(Extractor):\n    \"\"\"Base class for scrolller extractors\"\"\"\n    category = \"scrolller\"\n    root = \"https://scrolller.com\"\n    directory_fmt = (\"{category}\", \"{subredditTitle}\")\n    filename_fmt = \"{id}{num:?_//>03}{title:? //[:230]}.{extension}\"\n    archive_fmt = \"{id}_{num}\"\n    request_interval = (0.5, 1.5)\n\n    def _init(self):\n        self.auth_token = None\n\n    def items(self):\n        self.login()\n\n        for post in self.posts():\n            files = self._extract_files(post)\n            post[\"count\"] = len(files)\n\n            yield Message.Directory, \"\", post\n            for file in files:\n                url = file[\"url\"]\n                post.update(file)\n                yield Message.Url, url, text.nameext_from_url(url, post)\n\n    def posts(self):\n        return ()\n\n    def _extract_files(self, post):\n        album = post.pop(\"albumContent\", None)\n        if not album:\n            sources = post.get(\"mediaSources\")\n            if not sources:\n                self.log.warning(\"%s: No media files\", post.get(\"id\"))\n                return ()\n            src = max(sources, key=self._sort_key)\n            src[\"num\"] = 0\n            return (src,)\n\n        files = []\n        for num, media in enumerate(album, 1):\n            sources = media.get(\"mediaSources\")\n            if not sources:\n                self.log.warning(\"%s/%s: Missing media file\",\n                                 post.get(\"id\"), num)\n                continue\n            src = max(sources, key=self._sort_key)\n            src[\"num\"] = num\n            files.append(src)\n        return files\n\n    def login(self):\n        username, password = self._get_auth_info()\n        if username:\n            self.auth_token = self.cache(\n                self._login_impl, username, password,\n                _exp=28*86400, _mem=False)\n\n    def _login_impl(self, username, password):\n        self.log.info(\"Logging in as %s\", username)\n\n        variables = {\n            \"username\": username,\n            \"password\": password,\n        }\n\n        try:\n            data = self._request_graphql(\"LoginQuery\", variables, False)\n        except self.exc.HttpError as exc:\n            if exc.status == 403:\n                raise self.exc.AuthenticationError()\n            raise\n\n        return data[\"login\"][\"token\"]\n\n    def _request_graphql(self, opname, variables, admin=True):\n        headers = {\n            \"Content-Type\"  : None,\n            \"Origin\"        : self.root,\n            \"Sec-Fetch-Dest\": \"empty\",\n            \"Sec-Fetch-Mode\": \"cors\",\n            \"Sec-Fetch-Site\": \"same-site\",\n        }\n        data = {\n            \"query\"        : self.utils(\"graphql\", opname),\n            \"variables\"    : variables,\n            \"authorization\": self.auth_token,\n        }\n\n        if admin:\n            url = \"https://api.scrolller.com/admin\"\n            headers[\"Content-Type\"] = \"application/json\"\n        else:\n            url = \"https://api.scrolller.com/api/v2/graphql\"\n            headers[\"Content-Type\"] = \"text/plain;charset=UTF-8\"\n\n        return self.request_json(\n            url, method=\"POST\", headers=headers, data=util.json_dumps(data),\n        )[\"data\"]\n\n    def _pagination(self, opname, variables, data=None):\n        if data is None or not data.get(\"items\"):\n            data = self._request_graphql(opname, variables)\n\n        while True:\n            while \"items\" not in data:\n                data = data.popitem()[1]\n            yield from data[\"items\"]\n\n            if not data[\"iterator\"]:\n                return\n            variables[\"iterator\"] = data[\"iterator\"]\n\n            data = self._request_graphql(opname, variables)\n\n    def _sort_key(self, src):\n        return src[\"width\"], not src[\"isOptimized\"]\n\n\nclass ScrolllerSubredditExtractor(ScrolllerExtractor):\n    \"\"\"Extractor for media from a scrolller subreddit\"\"\"\n    subcategory = \"subreddit\"\n    pattern = BASE_PATTERN + r\"(/r/[^/?#]+)(?:/?\\?([^#]+))?\"\n    example = \"https://scrolller.com/r/SUBREDDIT\"\n\n    def posts(self):\n        url, query = self.groups\n        filter = None\n        sort = \"RANDOM\"\n\n        if query:\n            params = text.parse_query(query)\n            if \"filter\" in params:\n                filter = params[\"filter\"].upper().rstrip(\"S\")\n\n        variables = {\n            \"url\"   : url,\n            \"filter\": filter,\n            \"sortBy\": sort,\n            \"limit\" : 50,\n        }\n        subreddit = self._request_graphql(\n            \"SubredditQuery\", variables)[\"getSubreddit\"]\n\n        variables = {\n            \"subredditId\": subreddit[\"id\"],\n            \"iterator\": None,\n            \"filter\"  : filter,\n            \"sortBy\"  : sort,\n            \"limit\"   : 50,\n            \"isNsfw\"  : subreddit[\"isNsfw\"],\n        }\n        return self._pagination(\n            \"SubredditChildrenQuery\", variables, subreddit[\"children\"])\n\n\nclass ScrolllerUserExtractor(ScrolllerExtractor):\n    \"\"\"Extractor for media from a scrolller Reddit user\"\"\"\n    subcategory = \"user\"\n    directory_fmt = (\"{category}\", \"User\", \"{posted_by}\")\n    pattern = BASE_PATTERN + r\"/reddit-user/([^/?#]+)(?:/?\\?([^#]+))?\"\n    example = \"https://scrolller.com/reddit-user/USER\"\n\n    def posts(self):\n        query = \"UserPostsQuery\"\n        variables = {\n            \"username\": text.unquote(self.groups[0]),\n            \"iterator\": None,\n            \"limit\"   : 40,\n            \"filter\"  : None,\n            \"sortBy\"  : \"RANDOM\",\n            \"isNsfw\"  : True,\n        }\n\n        posts = self._request_graphql(query, variables)[\"getUserPosts\"]\n        if not posts.get(\"items\"):\n            posts = None\n            variables[\"isNsfw\"] = False\n\n        return self._pagination(query, variables, posts)\n\n\nclass ScrolllerFollowingExtractor(ScrolllerExtractor):\n    \"\"\"Extractor for followed scrolller subreddits\"\"\"\n    subcategory = \"following\"\n    pattern = BASE_PATTERN + r\"/following\"\n    example = \"https://scrolller.com/following\"\n\n    def items(self):\n        self.login()\n\n        if not self.auth_token:\n            raise self.exc.AuthorizationError(\"Login required\")\n\n        variables = {\n            \"iterator\": None,\n            \"filter\"  : None,\n            \"limit\"   : 10,\n            \"isNsfw\"  : False,\n            \"sortBy\"  : \"RANDOM\",\n        }\n\n        for subreddit in self._pagination(\"GetFollowingSubreddits\", variables):\n            url = self.root + subreddit[\"url\"]\n            subreddit[\"_extractor\"] = ScrolllerSubredditExtractor\n            yield Message.Queue, url, subreddit\n\n\nclass ScrolllerPostExtractor(ScrolllerExtractor):\n    \"\"\"Extractor for media from a single scrolller post\"\"\"\n    subcategory = \"post\"\n    pattern = BASE_PATTERN + r\"/(?!r/|following$)([^/?#]+)\"\n    example = \"https://scrolller.com/TITLE-SLUG-a1b2c3d4f5\"\n\n    def posts(self):\n        variables = {\"url\": \"/\" + self.groups[0]}\n        data = self._request_graphql(\"SubredditPostQuery\", variables)\n        return (data[\"getPost\"],)\n"
  },
  {
    "path": "gallery_dl/extractor/seiga.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2016-2026 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://seiga.nicovideo.jp/\"\"\"\n\nfrom .common import Extractor, Message\nfrom .. import text, util\n\n\nclass SeigaExtractor(Extractor):\n    \"\"\"Base class for seiga extractors\"\"\"\n    category = \"seiga\"\n    archive_fmt = \"{image_id}\"\n    cookies_domain = \".nicovideo.jp\"\n    cookies_names = (\"user_session\",)\n    root = \"https://seiga.nicovideo.jp\"\n\n    def __init__(self, match):\n        Extractor.__init__(self, match)\n        self.start_image = 0\n\n    def items(self):\n        self.login()\n\n        images = iter(self.get_images())\n        data = next(images)\n\n        yield Message.Directory, \"\", data\n        for image in util.advance(images, self.start_image):\n            data.update(image)\n            data[\"extension\"] = None\n            yield Message.Url, self.get_image_url(data[\"image_id\"]), data\n\n    def get_images(self):\n        \"\"\"Return iterable containing metadata and images\"\"\"\n\n    def get_image_url(self, image_id):\n        \"\"\"Get url for an image with id 'image_id'\"\"\"\n        url = f\"{self.root}/image/source/{image_id}\"\n        location = self.request_location(url, notfound=\"image\")\n        if \"nicovideo.jp/login\" in location:\n            raise self.exc.AbortExtraction(\n                f\"HTTP redirect to login page ({location.partition('?')[0]})\")\n        return location.replace(\"/o/\", \"/priv/\", 1)\n\n    def login(self):\n        if self.cookies_check(self.cookies_names):\n            return\n\n        username, password = self._get_auth_info()\n        if username:\n            return self.cookies_update(self.cache(\n                self._login_impl, username, password,\n                _exp=365*86400, _mem=False))\n\n        raise self.exc.AuthorizationError(\n            \"username & password or 'user_session' cookie required\")\n\n    def _login_impl(self, username, password):\n        self.log.info(\"Logging in as %s\", username)\n\n        root = \"https://account.nicovideo.jp\"\n        response = self.request(root + \"/login?site=seiga\")\n        page = response.text\n\n        data = {\n            \"mail_tel\": username,\n            \"password\": password,\n        }\n        url = root + text.unescape(text.extr(page, '<form action=\"', '\"'))\n        response = self.request(url, method=\"POST\", data=data)\n\n        if \"message=cant_login\" in response.url:\n            raise self.exc.AuthenticationError()\n\n        if \"/mfa\" in response.url:\n            page = response.text\n            email = text.extr(page, 'class=\"userAccount\">', \"<\")\n            code = self.input(f\"Email Confirmation Code ({email}): \")\n\n            data = {\n                \"otp\": code,\n                \"loginBtn\": \"Login\",\n                \"device_name\": \"gdl\",\n            }\n            url = root + text.unescape(text.extr(page, '<form action=\"', '\"'))\n            response = self.request(url, method=\"POST\", data=data)\n\n            if not response.history and \\\n                    b\"Confirmation code is incorrect\" in response.content:\n                raise self.exc.AuthenticationError(\n                    \"Incorrect Confirmation Code\")\n\n        return {\n            cookie.name: cookie.value\n            for cookie in self.cookies\n            if cookie.expires and cookie.domain == self.cookies_domain\n        }\n\n\nclass SeigaUserExtractor(SeigaExtractor):\n    \"\"\"Extractor for images of a user from seiga.nicovideo.jp\"\"\"\n    subcategory = \"user\"\n    directory_fmt = (\"{category}\", \"{user[id]}\")\n    filename_fmt = \"{category}_{user[id]}_{image_id}.{extension}\"\n    pattern = (r\"(?:https?://)?(?:www\\.|(?:sp\\.)?seiga\\.)?nicovideo\\.jp/\"\n               r\"user/illust/(\\d+)(?:\\?(?:[^&]+&)*sort=([^&#]+))?\")\n    example = \"https://seiga.nicovideo.jp/user/illust/12345\"\n\n    def __init__(self, match):\n        SeigaExtractor.__init__(self, match)\n        self.user_id, self.order = match.groups()\n        self.start_page = 1\n\n    def skip_files(self, num):\n        pages, images = divmod(num, 40)\n        self.start_page += pages\n        self.start_image += images\n        return num\n\n    def get_metadata(self, page):\n        \"\"\"Collect metadata from 'page'\"\"\"\n        data = text.extract_all(page, (\n            (\"name\" , '<img alt=\"', '\"'),\n            (\"msg\"  , '<li class=\"user_message\">', '</li>'),\n            (None   , '<span class=\"target_name\">すべて</span>', ''),\n            (\"count\", '<span class=\"count \">', '</span>'),\n        ))[0]\n\n        if not data[\"name\"] and \"ユーザー情報が取得出来ませんでした\" in page:\n            raise self.exc.NotFoundError(\"user\")\n\n        return {\n            \"user\": {\n                \"id\": text.parse_int(self.user_id),\n                \"name\": data[\"name\"],\n                \"message\": (data[\"msg\"] or \"\").strip(),\n            },\n            \"count\": text.parse_int(data[\"count\"]),\n        }\n\n    def get_images(self):\n        url = f\"{self.root}/user/illust/{self.user_id}\"\n        params = {\"sort\": self.order, \"page\": self.start_page,\n                  \"target\": \"illust_all\"}\n\n        while True:\n            cnt = 0\n            page = self.request(url, params=params).text\n\n            if params[\"page\"] == self.start_page:\n                yield self.get_metadata(page)\n\n            for info in text.extract_iter(\n                    page, '<li class=\"list_item', '</a></li> '):\n                data = text.extract_all(info, (\n                    (\"image_id\", '/seiga/im', '\"'),\n                    (\"title\"   , '<li class=\"title\">', '</li>'),\n                    (\"views\"   , '</span>', '</li>'),\n                    (\"comments\", '</span>', '</li>'),\n                    (\"clips\"   , '</span>', '</li>'),\n                ))[0]\n                for key in (\"image_id\", \"views\", \"comments\", \"clips\"):\n                    data[key] = text.parse_int(data[key])\n                yield data\n                cnt += 1\n\n            if cnt < 40:\n                return\n            params[\"page\"] += 1\n\n\nclass SeigaImageExtractor(SeigaExtractor):\n    \"\"\"Extractor for single images from seiga.nicovideo.jp\"\"\"\n    subcategory = \"image\"\n    filename_fmt = \"{category}_{image_id}.{extension}\"\n    pattern = (r\"(?:https?://)?(?:\"\n               r\"(?:seiga\\.|www\\.)?nicovideo\\.jp/(?:seiga/im|image/source/)\"\n               r\"|sp\\.seiga\\.nicovideo\\.jp/seiga/#!/im\"\n               r\"|lohas\\.nicoseiga\\.jp/(?:thumb|(?:priv|o)/[^/]+/\\d+)/)(\\d+)\")\n    example = \"https://seiga.nicovideo.jp/seiga/im12345\"\n\n    def __init__(self, match):\n        SeigaExtractor.__init__(self, match)\n        self.image_id = match[1]\n\n    def skip_files(self, num):\n        self.start_image += num\n        return num\n\n    def get_images(self):\n        self.cookies.set(\n            \"skip_fetish_warning\", \"1\", domain=\"seiga.nicovideo.jp\")\n\n        url = f\"{self.root}/seiga/im{self.image_id}\"\n        page = self.request(url, notfound=True).text\n\n        data = text.extract_all(page, (\n            (\"date\"        , '<li class=\"date\"><span class=\"created\">', '<'),\n            (\"title\"       , '<h1 class=\"title\">', '</h1>'),\n            (\"description\" , '<p class=\"discription\">', '</p>'),\n        ))[0]\n\n        data[\"user\"] = text.extract_all(page, (\n            (\"id\"  , '<a href=\"/user/illust/' , '\"'),\n            (\"name\", '<span itemprop=\"title\">', '<'),\n        ))[0]\n\n        data[\"description\"] = text.remove_html(data[\"description\"])\n        data[\"image_id\"] = text.parse_int(self.image_id)\n        data[\"date\"] = self.parse_datetime(\n            data[\"date\"] + \":00+0900\", \"%Y年%m月%d日 %H:%M:%S%z\")\n\n        return (data, data)\n"
  },
  {
    "path": "gallery_dl/extractor/senmanga.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2016-2023 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://raw.senmanga.com/\"\"\"\n\nfrom .common import ChapterExtractor\nfrom .. import text\n\n\nclass SenmangaChapterExtractor(ChapterExtractor):\n    \"\"\"Extractor for manga chapters from raw.senmanga.com\"\"\"\n    category = \"senmanga\"\n    root = \"https://raw.senmanga.com\"\n    pattern = r\"(?:https?://)?raw\\.senmanga\\.com(/[^/?#]+/[^/?#]+)\"\n    example = \"https://raw.senmanga.com/MANGA/CHAPTER\"\n\n    def _init(self):\n        self.session.headers[\"Referer\"] = self.page_url\n\n        # select \"All pages\" viewer\n        self.cookies.set(\"viewer\", \"1\", domain=\"raw.senmanga.com\")\n\n    def metadata(self, page):\n        title = text.extr(page, \"<title>\", \"</title>\")\n        manga, _, chapter = title.partition(\" - Chapter \")\n\n        return {\n            \"manga\"        : text.unescape(manga).replace(\"-\", \" \"),\n            \"chapter\"      : chapter.partition(\" - Page \")[0],\n            \"chapter_minor\": \"\",\n            \"lang\"         : \"ja\",\n            \"language\"     : \"Japanese\",\n        }\n\n    def images(self, page):\n        return [\n            (text.ensure_http_scheme(url), None)\n            for url in text.extract_iter(\n                page, '<img class=\"picture\" src=\"', '\"')\n        ]\n"
  },
  {
    "path": "gallery_dl/extractor/sexcom.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2019-2025 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://www.sex.com/\"\"\"\n\nfrom .common import Extractor, Message\nfrom .. import text, dt\n\nBASE_PATTERN = r\"(?:https?://)?(?:www\\.)?sex\\.com(?:/[a-z]{2})?\"\n\n\nclass SexcomExtractor(Extractor):\n    \"\"\"Base class for sexcom extractors\"\"\"\n    category = \"sexcom\"\n    directory_fmt = (\"{category}\")\n    filename_fmt = \"{pin_id}{title:? //}.{extension}\"\n    archive_fmt = \"{pin_id}\"\n    root = \"https://www.sex.com\"\n\n    def items(self):\n        self.gifs = self.config(\"gifs\", True)\n\n        yield Message.Directory, \"\", self.metadata()\n        for pin in map(self._parse_pin, self.pins()):\n            if not pin:\n                continue\n\n            url = pin[\"url\"]\n            parts = url.rsplit(\"/\", 4)\n            try:\n                pin[\"date_url\"] = d = dt.datetime(\n                    int(parts[1]), int(parts[2]), int(parts[3]))\n                if \"date\" not in pin:\n                    pin[\"date\"] = d\n            except Exception:\n                pass\n            pin[\"tags\"] = [t[1:] if t[0] == \"@\" else t for t in pin[\"tags\"]]\n\n            yield Message.Url, url, pin\n\n    def metadata(self):\n        return {}\n\n    def pins(self):\n        return ()\n\n    def _pagination(self, url):\n        while True:\n            extr = text.extract_from(self.request(url).text)\n            url = extr('<link rel=\"next\" href=\"', '\"')\n\n            while True:\n                href = extr('<a class=\"image_wrapper\" href=\"', '\"')\n                if not href:\n                    break\n                yield self.root + href\n\n            if not url:\n                return\n            url = text.urljoin(self.root, text.unescape(url))\n\n    def _parse_pin(self, url):\n        if \"/pin/\" in url:\n            if url[-1] != \"/\":\n                url += \"/\"\n        elif url[-1] == \"/\":\n            url = url[:-1]\n\n        response = self.request(url, fatal=False, allow_redirects=False)\n        location = response.headers.get(\"location\")\n\n        if location:\n            if location[0] == \"/\":\n                location = self.root + location\n            if len(location) <= 25:\n                return self.log.warning(\n                    'Unable to fetch %s: Redirect to homepage', url)\n            response = self.request(location, fatal=False)\n\n        if response.status_code >= 400:\n            return self.log.warning('Unable to fetch %s: %s %s',\n                                    url, response.status_code, response.reason)\n\n        if \"/pin/\" in response.url:\n            return self._parse_pin_legacy(response)\n        if \"/videos/\" in response.url:\n            return self._parse_pin_video(response)\n        return self._parse_pin_image(response)\n\n    def _parse_pin_legacy(self, response):\n        extr = text.extract_from(response.text)\n        data = {}\n\n        data[\"_http_headers\"] = {\"Referer\": response.url}\n        data[\"thumbnail\"] = extr('itemprop=\"thumbnail\" content=\"', '\"')\n        data[\"type\"] = extr('<h1>' , '<').rstrip(\" -\").strip().lower()\n        data[\"title\"] = text.unescape(extr('itemprop=\"name\">' , '<'))\n        data[\"repins\"] = text.parse_int(text.extract(\n            extr('\"btn-group\"', '</div>'), '\"btn btn-primary\">' , '<')[0])\n        data[\"likes\"] = text.parse_int(text.extract(\n            extr('\"btn-group\"', '</div>'), '\"btn btn-default\">' , '<')[0])\n        data[\"pin_id\"] = text.parse_int(extr('data-id=\"', '\"'))\n\n        if data[\"type\"] == \"video\":\n            info = extr(\"player.updateSrc(\", \");\")\n\n            if info:\n                try:\n                    path = text.rextr(\n                        info, \"src: '\", \"'\", info.index(\"label: 'HD'\"))\n                except ValueError:\n                    path = text.extr(info, \"src: '\", \"'\")\n                text.nameext_from_url(path, data)\n                data[\"url\"] = path\n            else:\n                iframe = extr('<iframe', '>')\n                src = (text.extr(iframe, ' src=\"', '\"') or\n                       text.extr(iframe, \" src='\", \"'\"))\n                if not src:\n                    self.log.warning(\n                        \"Unable to fetch media from %s\", response.url)\n                    return None\n                data[\"extension\"] = None\n                data[\"url\"] = \"ytdl:\" + src\n        else:\n            data[\"_http_validate\"] = _check_empty\n            url = text.unescape(extr(' src=\"', '\"'))\n            data[\"url\"] = url.partition(\"?\")[0]\n            data[\"_fallback\"] = (url,)\n            text.nameext_from_url(data[\"url\"], data)\n\n        data[\"uploader\"] = extr('itemprop=\"author\">', '<')\n        data[\"date\"] = dt.parse_iso(extr('datetime=\"', '\"'))\n        data[\"tags\"] = text.split_html(extr('class=\"tags\"> Tags', '</div>'))\n        data[\"comments\"] = text.parse_int(extr('Comments (', ')'))\n\n        return data\n\n    def _parse_pin_image(self, response):\n        extr = text.extract_from(response.text)\n        href = extr(' href=\"', '\"').partition(\"?\")[0]\n        title, _, type = extr(\"<title>\", \" | \").rpartition(\" \")\n\n        data = {\n            \"_http_headers\": {\"Referer\": response.url},\n            \"url\": href,\n            \"title\": text.unescape(title),\n            \"pin_id\": text.parse_int(extr(\n                'rel=\"canonical\" href=\"', '\"').rpartition(\"/\")[2]),\n            \"tags\": text.split_html(extr(\"</h1>\", \"</section>\")),\n        }\n\n        text.nameext_from_url(href, data)\n        if type.lower() == \"pic\":\n            data[\"type\"] = \"picture\"\n        else:\n            data[\"type\"] = \"gif\"\n            if self.gifs and data[\"extension\"] == \"webp\":\n                data[\"extension\"] = \"gif\"\n                data[\"_fallback\"] = (href,)\n                data[\"url\"] = href[:-4] + \"gif\"\n\n        return data\n\n    def _parse_pin_video(self, response):\n        extr = text.extract_from(response.text)\n\n        if not self.cookies.get(\"CloudFront-Key-Pair-Id\", domain=\".sex.com\"):\n            self.log.warning(\"CloudFront cookies required for video downloads\")\n\n        data = {\n            \"_ytdl_manifest\": \"hls\",\n            \"_ytdl_manifest_headers\": {\"Referer\": response.url},\n            \"extension\": \"mp4\",\n            \"type\": \"video\",\n            \"title\": text.unescape(extr(\"<title>\", \" | Sex.com<\")),\n            \"pin_id\": text.parse_int(extr(\n                'rel=\"canonical\" href=\"', '\"').rpartition(\"/\")[2]),\n            \"tags\": text.split_html(extr(\n                'event_name=\"video_tags_click\"', \"<div data-testid=\")\n                .partition(\">\")[2]),\n            \"url\": \"ytdl:\" + extr('<source src=\"', '\"'),\n        }\n\n        return data\n\n\nclass SexcomPinExtractor(SexcomExtractor):\n    \"\"\"Extractor for a pinned image or video on www.sex.com\"\"\"\n    subcategory = \"pin\"\n    directory_fmt = (\"{category}\",)\n    pattern = (BASE_PATTERN +\n               r\"(/(?:\\w\\w/(?:pic|gif|video)s|pin)/\\d+/?)(?!.*#related$)\")\n    example = \"https://www.sex.com/pin/12345-TITLE/\"\n\n    def pins(self):\n        return (self.root + self.groups[0],)\n\n\nclass SexcomRelatedPinExtractor(SexcomPinExtractor):\n    \"\"\"Extractor for related pins on www.sex.com\"\"\"\n    subcategory = \"related-pin\"\n    directory_fmt = (\"{category}\", \"related {original_pin[pin_id]}\")\n    pattern = BASE_PATTERN + r\"(/pin/(\\d+)/?).*#related$\"\n    example = \"https://www.sex.com/pin/12345#related\"\n\n    def metadata(self):\n        pin = self._parse_pin(SexcomPinExtractor.pins(self)[0])\n        return {\"original_pin\": pin}\n\n    def pins(self):\n        url = (f\"{self.root}/pin/related?pinId={self.groups[1]}\"\n               f\"&limit=24&offset=0\")\n        return self._pagination(url)\n\n\nclass SexcomPinsExtractor(SexcomExtractor):\n    \"\"\"Extractor for a user's pins on www.sex.com\"\"\"\n    subcategory = \"pins\"\n    directory_fmt = (\"{category}\", \"{user}\")\n    pattern = BASE_PATTERN + r\"/user/([^/?#]+)/pins/\"\n    example = \"https://www.sex.com/user/USER/pins/\"\n\n    def metadata(self):\n        return {\"user\": text.unquote(self.groups[0])}\n\n    def pins(self):\n        url = f\"{self.root}/user/{self.groups[0]}/pins/\"\n        return self._pagination(url)\n\n\nclass SexcomLikesExtractor(SexcomExtractor):\n    \"\"\"Extractor for a user's liked pins on www.sex.com\"\"\"\n    subcategory = \"likes\"\n    directory_fmt = (\"{category}\", \"{user}\", \"Likes\")\n    pattern = BASE_PATTERN + r\"/user/([^/?#]+)/likes/\"\n    example = \"https://www.sex.com/user/USER/likes/\"\n\n    def metadata(self):\n        return {\"user\": text.unquote(self.groups[0])}\n\n    def pins(self):\n        url = f\"{self.root}/user/{self.groups[0]}/likes/\"\n        return self._pagination(url)\n\n\nclass SexcomBoardExtractor(SexcomExtractor):\n    \"\"\"Extractor for pins from a board on www.sex.com\"\"\"\n    subcategory = \"board\"\n    directory_fmt = (\"{category}\", \"{user}\", \"{board}\")\n    pattern = (BASE_PATTERN + r\"/user\"\n               r\"/([^/?#]+)/(?!(?:following|pins|repins|likes)/)([^/?#]+)\")\n    example = \"https://www.sex.com/user/USER/BOARD/\"\n\n    def metadata(self):\n        self.user, self.board = self.groups\n        return {\n            \"user\" : text.unquote(self.user),\n            \"board\": text.unquote(self.board),\n        }\n\n    def pins(self):\n        url = f\"{self.root}/user/{self.user}/{self.board}/\"\n        return self._pagination(url)\n\n\nclass SexcomFeedExtractor(SexcomExtractor):\n    \"\"\"Extractor for pins from your account's main feed on www.sex.com\"\"\"\n    subcategory = \"feed\"\n    directory_fmt = (\"{category}\", \"feed\")\n    pattern = BASE_PATTERN + r\"/feed\"\n    example = \"https://www.sex.com/feed/\"\n\n    def metadata(self):\n        return {\"feed\": True}\n\n    def pins(self):\n        if not self.cookies_check((\"sess_sex\",)):\n            self.log.warning(\"no 'sess_sex' cookie set\")\n        url = self.root + \"/feed/\"\n        return self._pagination(url)\n\n\nclass SexcomSearchExtractor(SexcomExtractor):\n    \"\"\"Extractor for search results on www.sex.com\"\"\"\n    subcategory = \"search\"\n    directory_fmt = (\"{category}\", \"search\", \"{search[search]}\")\n    pattern = (BASE_PATTERN + r\"/(?:\"\n               r\"(pic|gif|video)s(?:\\?(search=[^#]+)$|/([^/?#]*))\"\n               r\"|search/(pic|gif|video)s\"\n               r\")/?(?:\\?([^#]+))?\")\n    example = \"https://www.sex.com/search/pics?query=QUERY\"\n\n    def _init(self):\n        t1, qs1, search_alt, t2, qs2 = self.groups\n\n        self.params = params = text.parse_query(qs1 or qs2)\n        if \"query\" in params:\n            params[\"search\"] = params.pop(\"query\")\n        params.setdefault(\"sexual-orientation\", \"straight\")\n        params.setdefault(\"order\", \"likeCount\")\n        params.setdefault(\"search\", search_alt or \"\")\n\n        self.kwdict[\"search\"] = search = params.copy()\n        search[\"type\"] = self.type = t1 or t2\n\n    def items(self):\n        root = \"https://imagex1.sx.cdn.live\"\n        type = self.type\n        gifs = self.config(\"gifs\", True)\n\n        url = (f\"{self.root}/portal/api/\"\n               f\"{'picture' if type == 'pic' else type}s/search\")\n        params = self.params\n        params[\"page\"] = text.parse_int(params.get(\"page\"), 1)\n        params[\"limit\"] = 40\n\n        while True:\n            data = self.request_json(url, params=params)\n\n            for pin in data[\"data\"]:\n                path = pin[\"uri\"]\n                pin[\"pin_id\"] = pin.pop(\"id\")\n                text.nameext_from_url(path, pin)\n\n                parts = path.rsplit(\"/\", 4)\n                try:\n                    pin[\"date_url\"] = pin[\"date\"] = dt.datetime(\n                        int(parts[1]), int(parts[2]), int(parts[3]))\n                except Exception:\n                    pass\n\n                if type == \"pic\":\n                    pin[\"type\"] = \"picture\"\n                else:\n                    pin[\"type\"] = \"gif\"\n                    if gifs and pin[\"extension\"] == \"webp\":\n                        pin[\"extension\"] = \"gif\"\n                        pin[\"_fallback\"] = (root + path,)\n                        path = path[:-4] + \"gif\"\n\n                pin[\"url\"] = root + path\n                yield Message.Directory, \"\", pin\n                yield Message.Url, pin[\"url\"], pin\n\n            if params[\"page\"] >= data[\"paging\"][\"numberOfPages\"]:\n                break\n            params[\"page\"] += 1\n\n\ndef _check_empty(response):\n    return response.headers.get(\"content-length\") != \"0\"\n"
  },
  {
    "path": "gallery_dl/extractor/shimmie2.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2023-2025 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for Shimmie2 instances\"\"\"\n\nfrom .common import BaseExtractor, Message\nfrom .. import text\n\n\nclass Shimmie2Extractor(BaseExtractor):\n    \"\"\"Base class for shimmie2 extractors\"\"\"\n    basecategory = \"shimmie2\"\n    filename_fmt = \"{category}_{id}{md5:?_//}.{extension}\"\n    archive_fmt = \"{id}\"\n\n    def _init(self):\n        if cookies := self.config_instance(\"cookies\"):\n            domain = self.root.rpartition(\"/\")[2]\n            self.cookies_update_dict(cookies, domain=domain)\n\n        if file_url := self.config_instance(\"file_url\"):\n            self.file_url_fmt = file_url\n        if quote := self.config_instance(\"quote\"):\n            self._quote_type = lambda _: quote\n\n    def items(self):\n        data = self.metadata()\n\n        for post in self.posts():\n\n            post[\"id\"] = text.parse_int(post[\"id\"])\n            post[\"width\"] = text.parse_int(post[\"width\"])\n            post[\"height\"] = text.parse_int(post[\"height\"])\n            post[\"tags\"] = text.unquote(post[\"tags\"])\n            post.update(data)\n\n            url = post[\"file_url\"]\n            if \"/index.php?\" in url:\n                post[\"filename\"], _, post[\"extension\"] = \\\n                    url.rpartition(\"/\")[2].rpartition(\".\")\n            else:\n                text.nameext_from_url(url, post)\n\n            yield Message.Directory, \"\", post\n            yield Message.Url, url, post\n\n    def metadata(self):\n        \"\"\"Return general metadata\"\"\"\n        return ()\n\n    def posts(self):\n        \"\"\"Return an iterable containing data of all relevant posts\"\"\"\n        return ()\n\n    def _quote_type(self, page):\n        \"\"\"Return quoting character used in 'page' (' or \")\"\"\"\n        try:\n            return page[page.index(\"<link rel=\")+10]\n        except Exception:\n            return \"'\"\n\n\nBASE_PATTERN = Shimmie2Extractor.update({\n    \"cavemanon\": {\n        \"root\": \"https://booru.cavemanon.xyz\",\n        \"pattern\": r\"booru\\.cavemanon\\.xyz\",\n        \"file_url\": \"{0}/index.php?q=image/{2}.{4}\",\n    },\n    \"rule34hentai\": {\n        \"root\": \"https://rule34hentai.net\",\n        \"pattern\": r\"rule34hentai\\.net\",\n    },\n    \"vidyapics\": {\n        \"root\": \"https://vidya.pics\",\n        \"pattern\": r\"vidya\\.pics\",\n    },\n    \"nozrip\": {\n        \"root\": \"https://noz.rip/booru\",\n        \"pattern\": r\"noz\\.rip/booru\",\n    },\n    \"thecollectionS\": {\n        \"root\": \"https://co.llection.pics\",\n        \"pattern\": r\"co\\.llection\\.pics\",\n    },\n    \"soybooru\": {\n        \"root\": \"https://soybooru.com\",\n        \"pattern\": r\"soybooru\\.com\",\n        \"quote\": \"'\",\n    },\n}) + r\"/(?:index\\.php\\?q=/?)?\"\n\n\nclass Shimmie2TagExtractor(Shimmie2Extractor):\n    \"\"\"Extractor for shimmie2 posts by tag search\"\"\"\n    subcategory = \"tag\"\n    directory_fmt = (\"{category}\", \"{search_tags}\")\n    file_url_fmt = \"{}/_images/{}/{}%20-%20{}.{}\"\n    pattern = BASE_PATTERN + r\"post/list/([^/?#]+)(?:/(\\d+))?\"\n    example = \"https://vidya.pics/post/list/TAG/1\"\n\n    def metadata(self):\n        self.tags = text.unquote(self.groups[-2])\n        return {\"search_tags\": self.tags}\n\n    def posts(self):\n        pnum = text.parse_int(self.groups[-1], 1)\n        file_url_fmt = self.file_url_fmt.format\n\n        init = True\n        mime = \"\"\n\n        while True:\n            url = f\"{self.root}/post/list/{self.tags}/{pnum}\"\n            page = self.request(url).text\n            extr = text.extract_from(page)\n\n            if init:\n                init = False\n                quote = self._quote_type(page)\n                has_mime = (\" data-mime=\" in page)\n                has_pid = (\" data-post-id=\" in page)\n\n            while True:\n                if has_mime:\n                    mime = extr(\" data-mime=\"+quote, quote)\n                if has_pid:\n                    pid = extr(\" data-post-id=\"+quote, quote)\n                else:\n                    pid = extr(\" href='/post/view/\", quote)\n\n                if not pid:\n                    break\n\n                data = extr(\"title=\"+quote, quote).split(\" // \")\n                tags = data[0]\n                dimensions = data[1]\n                size = data[2]\n\n                width, _, height = dimensions.partition(\"x\")\n                md5 = extr(\"/_thumbs/\", \"/\")\n\n                yield {\n                    \"file_url\": file_url_fmt(\n                        self.root, md5, pid, text.quote(tags),\n                        mime.rpartition(\"/\")[2] if mime else \"jpg\"),\n                    \"id\": pid,\n                    \"md5\": md5,\n                    \"tags\": tags,\n                    \"width\": width,\n                    \"height\": height,\n                    \"size\": text.parse_bytes(size[:-1]),\n                }\n\n            pnum += 1\n            if not extr(f\"/{pnum}{quote}>Next</\", \">\"):\n                return\n\n\nclass Shimmie2PostExtractor(Shimmie2Extractor):\n    \"\"\"Extractor for single shimmie2 posts\"\"\"\n    subcategory = \"post\"\n    pattern = BASE_PATTERN + r\"post/view/(\\d+)\"\n    example = \"https://vidya.pics/post/view/12345\"\n\n    def posts(self):\n        post_id = self.groups[-1]\n        root = self.root\n        base = root if (pos := root.find(\"/\", 8)) < 0 else root[:pos]\n\n        url = f\"{root}/post/view/{post_id}\"\n        page = self.request(url).text\n        extr = text.extract_from(page)\n        qt = self._quote_type(page)\n\n        post = {\n            \"id\"      : post_id,\n            \"tags\"    : extr(\": \", \"<\").partition(\" - \")[0].rstrip(\")\"),\n            \"md5\"     : extr(\"/_thumbs/\", \"/\"),\n            \"file_url\": base + (\n                extr(f\"id={qt}main_image{qt} src={qt}\", qt) or\n                extr(\"<source src=\"+qt, qt)).lstrip(\".\"),\n            \"width\"   : extr(\"data-width=\", \" \").strip(\"\\\"'\"),\n            \"height\"  : extr(\"data-height=\", \">\").partition(\n                \" \")[0].strip(\"\\\"'\"),\n            \"size\"    : 0,\n        }\n\n        if not post[\"md5\"]:\n            post[\"md5\"] = text.extr(post[\"file_url\"], \"/_images/\", \"/\")\n\n        return (post,)\n"
  },
  {
    "path": "gallery_dl/extractor/shopify.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2019-2025 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for Shopify instances\"\"\"\n\nfrom .common import BaseExtractor, Message\nfrom .. import text\n\n\nclass ShopifyExtractor(BaseExtractor):\n    \"\"\"Base class for Shopify extractors\"\"\"\n    basecategory = \"shopify\"\n    filename_fmt = \"{product[title]}_{num:>02}_{id}.{extension}\"\n    archive_fmt = \"{id}\"\n\n    def items(self):\n        data = self.metadata()\n        yield Message.Directory, \"\", data\n\n        for product in self.products():\n            for num, image in enumerate(product.pop(\"images\"), 1):\n                text.nameext_from_url(image[\"src\"], image)\n                image.update(data)\n                image[\"product\"] = product\n                image[\"num\"] = num\n                yield Message.Url, image[\"src\"], image\n\n    def metadata(self):\n        \"\"\"Return general metadata\"\"\"\n        return {}\n\n    def products(self):\n        \"\"\"Return an iterable with all relevant product URLs\"\"\"\n\n\nBASE_PATTERN = ShopifyExtractor.update({\n    \"chelseacrew\": {\n        \"root\": \"https://chelseacrew.com\",\n        \"pattern\": r\"(?:www\\.)?chelseacrew\\.com\",\n    },\n    \"fashionnova\": {\n        \"root\": \"https://www.fashionnova.com\",\n        \"pattern\": r\"(?:www\\.)?fashionnova\\.com\",\n    },\n    \"loungeunderwear\": {\n        \"root\": \"https://loungeunderwear.com\",\n        \"pattern\": r\"(?:[a-z]+\\.)?loungeunderwear\\.com\",\n    },\n    \"michaelscameras\": {\n        \"root\": \"https://michaels.com.au\",\n        \"pattern\": r\"michaels\\.com\\.au\",\n    },\n    \"modcloth\": {\n        \"root\": \"https://modcloth.com\",\n        \"pattern\": r\"modcloth\\.com\",\n    },\n    \"ohpolly\": {\n        \"root\": \"https://www.ohpolly.com\",\n        \"pattern\": r\"(?:www\\.)?ohpolly\\.com\",\n    },\n    \"omgmiamiswimwear\": {\n        \"root\": \"https://www.omgmiamiswimwear.com\",\n        \"pattern\": r\"(?:www\\.)?omgmiamiswimwear\\.com\",\n    },\n    \"pinupgirlclothing\": {\n        \"root\": \"https://pinupgirlclothing.com\",\n        \"pattern\": r\"pinupgirlclothing\\.com\",\n    },\n    \"raidlondon\": {\n        \"root\": \"https://www.raidlondon.com\",\n        \"pattern\": r\"(?:www\\.)?raidlondon\\.com\",\n    },\n    \"unique-vintage\": {\n        \"root\": \"https://www.unique-vintage.com\",\n        \"pattern\": r\"(?:www\\.)?unique\\-vintage\\.com\",\n    },\n    \"windsorstore\": {\n        \"root\": \"https://www.windsorstore.com\",\n        \"pattern\": r\"(?:www\\.)?windsorstore\\.com\",\n    },\n}) + \"(?:/[a-z]{2}(?:-[^/?#]+)?)?\"\n\n\nclass ShopifyCollectionExtractor(ShopifyExtractor):\n    \"\"\"Base class for collection extractors for Shopify based sites\"\"\"\n    subcategory = \"collection\"\n    directory_fmt = (\"{category}\", \"{collection[title]}\")\n    pattern = BASE_PATTERN + r\"(/collections/[\\w-]+)/?(?:$|[?#])\"\n    example = \"https://www.fashionnova.com/collections/TITLE\"\n\n    def metadata(self):\n        url = f\"{self.root}{self.groups[-1]}.json\"\n        return self.request_json(url)\n\n    def products(self):\n        url = f\"{self.root}{self.groups[-1]}/products.json\"\n        params = {\"page\": 1}\n\n        while True:\n            data = self.request_json(url, params=params)[\"products\"]\n            if not data:\n                return\n            yield from data\n            params[\"page\"] += 1\n\n\nclass ShopifyProductExtractor(ShopifyExtractor):\n    \"\"\"Base class for product extractors for Shopify based sites\"\"\"\n    subcategory = \"product\"\n    directory_fmt = (\"{category}\", \"Products\")\n    pattern = BASE_PATTERN + r\"((?:/collections/[\\w-]+)?/products/[\\w-]+)\"\n    example = \"https://www.fashionnova.com/collections/TITLE/products/NAME\"\n\n    def products(self):\n        url = f\"{self.root}{self.groups[-1]}.json\"\n        product = self.request_json(url)[\"product\"]\n        del product[\"image\"]\n        return (product,)\n"
  },
  {
    "path": "gallery_dl/extractor/simplyhentai.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2018-2026 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://www.simply-hentai.com/\"\"\"\n\nfrom .common import Extractor, GalleryExtractor, Message\nfrom .. import text, util\n\nBASE_PATTERN = r\"(?:https?://)?(?:www\\.)?simply-hentai\\.com\"\n\n\nclass SimplyhentaiExtractor(Extractor):\n    \"\"\"Base class for simplyhentai extractors\"\"\"\n    category = \"simplyhentai\"\n    root = \"https://www.simply-hentai.com\"\n    root_api = \"https://api-v3.simply-hentai.com\"\n    browser = \"firefox\"\n\n    def items(self):\n        for gallery in self.galleries():\n            gallery[\"_extractor\"] = SimplyhentaiGalleryExtractor\n            series = (s[\"slug\"] if (s := gallery.get(\"series\")) else\n                      \"8-original-work\")\n            url = f\"{self.root}/{series}/{gallery['slug']}\"\n            yield Message.Queue, url, gallery\n\n    def request_api(self, endpoint, params=None):\n        url = f\"{self.root_api}/v3{endpoint}\"\n        return self.request_json(url, params=params, headers={\n            \"Referer\": self.root + \"/\",\n            \"Origin\" : self.root,\n        })\n\n    def _pagination(self, endpoint, params):\n        params[\"page\"] = text.parse_int(params.get(\"page\"), 1)\n\n        while True:\n            data = self.request_api(endpoint, params)\n\n            if isinstance(data[\"data\"], dict):\n                yield from data[\"data\"][\"albums\"]\n            else:\n                yield from data[\"data\"]\n\n            try:\n                if params[\"page\"] >= data[\"pagination\"][\"pages\"]:\n                    break\n            except Exception:\n                break\n            params[\"page\"] += 1\n\n\nclass SimplyhentaiSeriesExtractor(SimplyhentaiExtractor):\n    subcategory = \"series\"\n    pattern = (BASE_PATTERN + r\"/series/([^/?#]+)\"\n               r\"(?:/tag-([^/?#]+))?(?:/sort-([^/?#]+))?(?:/page-(\\d+))?\"\n               r\"(?:\\?([^#]+))?\")\n    example = \"https://www.simply-hentai.com/series/SLUG\"\n\n    def galleries(self):\n        slug, tag, sort, pnum, qs = self.groups\n        params = text.parse_query(qs)\n        if tag is not None:\n            params[\"tag_slug\"] = tag\n        if sort is not None:\n            params[\"sort\"] = sort\n        if pnum is not None:\n            params[\"page\"] = pnum\n        return self._pagination(\"/series/\" + slug, params)\n\n\nclass SimplyhentaiMangaExtractor(SimplyhentaiExtractor):\n    subcategory = \"manga\"\n    pattern = (BASE_PATTERN + r\"/2-mangas\"\n               r\"(?:/sort-([^/?#]+))?(?:/page-(\\d+))?(?:\\?([^#]+))?\")\n    example = \"https://www.simply-hentai.com/2-mangas\"\n\n    def galleries(self):\n        sort, pnum, qs = self.groups\n        params = text.parse_query(qs)\n        if sort is not None:\n            params[\"sort\"] = sort\n        if pnum is not None:\n            params[\"page\"] = pnum\n        return self._pagination(\"/mangas\", params)\n\n\nclass SimplyhentaiTagExtractor(SimplyhentaiExtractor):\n    subcategory = \"tag\"\n    pattern = (BASE_PATTERN +\n               r\"/(parody|tag|character|collection|artist|translator)\"\n               r\"/([^/?#]+)\"\n               r\"(?:/tag-([^/?#]+))?(?:/sort-([^/?#]+))?(?:/page-(\\d+))?\"\n               r\"(?:\\?([^#]+))?\")\n    example = \"https://www.simply-hentai.com/tag/TAG\"\n\n    def galleries(self):\n        type, slug, tag, sort, pnum, qs = self.groups\n        params = text.parse_query(qs)\n        if type == \"collection\":\n            endpoint = \"/collection/\" + slug\n        else:\n            endpoint = \"/tag/\" + slug\n            params[\"type\"] = type\n        if tag is not None:\n            params[\"tag_slug\"] = tag\n        if sort is not None:\n            params[\"sort\"] = sort\n        if pnum is not None:\n            params[\"page\"] = pnum\n        return self._pagination(endpoint, params)\n\n\nclass SimplyhentaiLanguageExtractor(SimplyhentaiExtractor):\n    subcategory = \"language\"\n    pattern = (BASE_PATTERN + r\"/language/([^/?#]+)\"\n               r\"(?:/sort-([^/?#]+))?(?:/page-(\\d+))?(?:\\?([^#]+))?\")\n    example = \"https://www.simply-hentai.com/language/LANG\"\n\n    def galleries(self):\n        language, sort, pnum, qs = self.groups\n        params = text.parse_query(qs)\n        params[\"type\"] = \"language\"\n        if sort is not None:\n            params[\"sort\"] = sort\n        if pnum is not None:\n            params[\"page\"] = pnum\n        return self._pagination(\"/tag/\" + language, params)\n\n\nclass SimplyhentaiGalleryExtractor(GalleryExtractor, SimplyhentaiExtractor):\n    \"\"\"Extractor for simplyhentai galleries\"\"\"\n    archive_fmt = \"{id}\"\n    pattern = BASE_PATTERN + r\"/[^/?#]+/([^/?#]+)\"\n    example = \"https://www.simply-hentai.com/SERIES/SLUG\"\n\n    def metadata(self, _):\n        endpoint = \"/manga/\" + self.groups[0]\n        data = self.request_api(endpoint)[\"data\"]\n        data[\"gallery_id\"] = data.pop(\"id\")\n        data[\"series\"] = (s := data.get(\"series\")) and s.get(\"title\")\n        data[\"date\"] = self.parse_datetime_iso(data.get(\"created_at\"))\n\n        for key in (\"artists\", \"characters\", \"parodies\",\n                    \"tags\", \"translators\"):\n            data[key] = [t[\"title\"] for t in data.get(key) or ()]\n\n        data.pop(\"related\", None)\n        self._images = data.pop(\"images\", ())\n        if data.get(\"image_count\", 32) > 12:\n            self._images = endpoint + \"/pages\"\n\n        try:\n            data[\"language\"] = language = data[\"language\"][\"name\"]\n            data[\"lang\"] = util.language_to_code(language)\n        except Exception:\n            pass\n\n        try:\n            data[\"cover\"] = data.pop(\"preview\")[\"sizes\"][\"full\"]\n        except Exception:\n            pass\n\n        return data\n\n    def images(self, _):\n        imgs = self._images\n        if isinstance(imgs, str):\n            imgs = self.request_api(imgs)[\"data\"][\"pages\"]\n        return [(img[\"sizes\"][\"full\"], {\"id\": img[\"id\"]}) for img in imgs]\n"
  },
  {
    "path": "gallery_dl/extractor/sizebooru.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2025 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://sizebooru.com/\"\"\"\n\nfrom .booru import BooruExtractor\nfrom .. import text\n\nBASE_PATTERN = r\"(?:https?://)?(?:www\\.)?sizebooru\\.com\"\n\n\nclass SizebooruExtractor(BooruExtractor):\n    \"\"\"Base class for sizebooru extractors\"\"\"\n    category = \"sizebooru\"\n    root = \"https://sizebooru.com\"\n    filename_fmt = \"{id}.{extension}\"\n    archive_fmt = \"{id}\"\n    page_start = 1\n    request_interval = (0.5, 1.5)\n\n    def _init(self):\n        if self.config(\"metadata\", False):\n            self._prepare = self._prepare_metadata\n\n    def _file_url(self, post):\n        post[\"file_url\"] = url = f\"{self.root}/Picture/{post['id']}\"\n        return url\n\n    def _prepare(self, post):\n        post_id = post[\"id\"]\n        post[\"id\"] = text.parse_int(post_id)\n        post[\"filename\"] = post_id\n        if not post[\"extension\"]:\n            post[\"extension\"] = \"jpg\"\n\n    def _prepare_metadata(self, post):\n        post_id = post[\"id\"]\n        url = f\"{self.root}/Details/{post_id}\"\n        extr = text.extract_from(self.request(url).text)\n\n        post.update({\n            \"id\"       : text.parse_int(post_id),\n            \"date\"     : self.parse_datetime(\n                extr(\"<b>Posted Date:</b> \", \"<\"), \"%m/%d/%Y\"),\n            \"date_approved\": self.parse_datetime(\n                extr(\"<b>Approved Date:</b> \", \"<\"), \"%m/%d/%Y\"),\n            \"approver\" : text.remove_html(extr(\"<b>Approved By:</b>\", \"</\")),\n            \"uploader\" : text.remove_html(extr(\"<b>Posted By:</b>\", \"</\")),\n            \"artist\"   : None\n                if (artist := extr(\"<b>Artist:</b> \", \"</\")) == \"N/A\" else  # noqa: E131 E501\n                text.remove_html(artist),  # noqa: E131\n            \"views\"    : text.parse_int(extr(\"<b>Views:</b>\", \"<\")),\n            \"source\"   : text.extr(extr(\n                \"<b>Source Link:</b>\", \"</\"), ' href=\"', '\"') or None,\n            \"tags\"     : text.split_html(extr(\n                \"<h6>Related Tags</h6>\", \"</ul>\")),\n            \"favorite\" : text.split_html(extr(\n                \"<h6>Favorited By</h6>\", \"</ul>\")),\n        })\n\n        post[\"filename\"], _, ext = extr('\" alt=\"', '\"').rpartition(\".\")\n        if not post[\"extension\"]:\n            post[\"extension\"] = ext.lower()\n\n        return post\n\n    def _pagination(self, url, callback=None):\n        params = {\n            \"pageNo\"  : self.page_start,\n            \"pageSize\": self.per_page,\n        }\n\n        page = self.request(url, params=params).text\n        if callback is not None:\n            callback(page)\n\n        while True:\n            thumb = None\n            for thumb in text.extract_iter(\n                    page, '<a href=\"/Details/', ';base64'):\n                yield {\n                    \"id\"       : thumb[:thumb.find('\"')],\n                    \"extension\": thumb[thumb.rfind(\"/\")+1:],\n                }\n\n            if \"disabled\" in text.extr(page, 'area-label=\"Next\"', \">\") or \\\n                    thumb is None:\n                return\n            params[\"pageNo\"] += 1\n            page = self.request(url, params=params).text\n\n\nclass SizebooruPostExtractor(SizebooruExtractor):\n    \"\"\"Extractor for sizebooru posts\"\"\"\n    subcategory = \"post\"\n    pattern = BASE_PATTERN + r\"/Details/(\\d+)\"\n    example = \"https://sizebooru.com/Details/12345\"\n\n    def posts(self):\n        return ({\"id\": self.groups[0], \"extension\": None},)\n\n\nclass SizebooruTagExtractor(SizebooruExtractor):\n    \"\"\"Extractor for sizebooru tag searches\"\"\"\n    subcategory = \"tag\"\n    directory_fmt = (\"{category}\", \"{search_tags}\")\n    pattern = BASE_PATTERN + r\"/Search/([^/?#]+)\"\n    example = \"https://sizebooru.com/Search/TAG\"\n\n    def posts(self):\n        tag = self.groups[0]\n        self.kwdict[\"search_tags\"] = text.unquote(tag)\n        return self._pagination(f\"{self.root}/Search/{tag}\")\n\n\nclass SizebooruGalleryExtractor(SizebooruExtractor):\n    \"\"\"Extractor for sizebooru galleries\"\"\"\n    subcategory = \"gallery\"\n    directory_fmt = (\"{category}\", \"{gallery_name} ({gallery_id})\")\n    pattern = BASE_PATTERN + r\"/Galleries/List/(\\d+)\"\n    example = \"https://sizebooru.com/Galleries/List/123\"\n\n    def posts(self):\n        gid = self.groups[0]\n        self.kwdict[\"gallery_id\"] = text.parse_int(gid)\n        return self._pagination(\n            f\"{self.root}/Galleries/List/{gid}\", self._extract_name)\n\n    def _extract_name(self, page):\n        self.kwdict[\"gallery_name\"] = text.unescape(text.extr(\n            page, \"<title>Gallery: \", \" - Size Booru<\"))\n\n\nclass SizebooruUserExtractor(SizebooruExtractor):\n    \"\"\"Extractor for a sizebooru user's uploads\"\"\"\n    subcategory = \"user\"\n    directory_fmt = (\"{category}\", \"Uploads {user}\")\n    pattern = BASE_PATTERN + r\"/Profile/Uploads/([^/?#]+)\"\n    example = \"https://sizebooru.com/Profile/Uploads/USER\"\n\n    def posts(self):\n        user = self.groups[0]\n        self.kwdict[\"user\"] = text.unquote(user)\n        return self._pagination(f\"{self.root}/Profile/Uploads/{user}\",)\n\n\nclass SizebooruFavoriteExtractor(SizebooruExtractor):\n    \"\"\"Extractor for a sizebooru user's favorites\"\"\"\n    subcategory = \"favorite\"\n    directory_fmt = (\"{category}\", \"Favorites {user}\")\n    pattern = BASE_PATTERN + r\"/Profile/Favorites/([^/?#]+)\"\n    example = \"https://sizebooru.com/Profile/Favorites/USER\"\n\n    def posts(self):\n        user = self.groups[0]\n        self.kwdict[\"user\"] = text.unquote(user)\n        return self._pagination(f\"{self.root}/Profile/Favorites/{user}\",)\n"
  },
  {
    "path": "gallery_dl/extractor/skeb.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://skeb.jp/\"\"\"\n\nfrom .common import Extractor, Message, Dispatch\nfrom .. import text\n\nBASE_PATTERN = r\"(?:https?://)?skeb\\.jp\"\nUSER_PATTERN = BASE_PATTERN + r\"/@([^/?#]+)\"\n\n\nclass SkebExtractor(Extractor):\n    \"\"\"Base class for skeb extractors\"\"\"\n    category = \"skeb\"\n    directory_fmt = (\"{category}\", \"{creator[screen_name]}\")\n    filename_fmt = \"{post_num}_{file_id}.{extension}\"\n    archive_fmt = \"{post_num}_{_file_id}_{content_category}\"\n    root = \"https://skeb.jp\"\n\n    def _init(self):\n        self.thumbnails = self.config(\"thumbnails\", False)\n        self.article = self.config(\"article\", False)\n        self.headers = {\n            \"Accept\": \"application/json, text/plain, */*\",\n            \"sec-fetch-mode\": \"cors\",\n            \"sec-fetch-site\": \"same-origin\",\n        }\n\n        if \"Authorization\" not in self.session.headers:\n            self.headers[\"Authorization\"] = \"Bearer null\"\n\n    def _handle_429(self, response):\n        if \"request_key\" in response.cookies:\n            return True\n\n        request_key = text.extr(\n            response.text, \"request_key=\", \";\")\n        if request_key:\n            self.cookies.set(\"request_key\", request_key, domain=\"skeb.jp\")\n            return True\n\n    def items(self):\n        metadata = self.metadata()\n        for user_name, post_num in self.posts():\n            try:\n                response, post = self._get_post_data(user_name, post_num)\n            except Exception as exc:\n                self.log.error(\"@%s/%s: %s: %s\", user_name, post_num,\n                               exc.__class__.__name__, exc)\n                continue\n            if metadata:\n                post.update(metadata)\n\n            files = self._get_files_from_post(response)\n            post[\"count\"] = len(files)\n            yield Message.Directory, \"\", post\n            for post[\"num\"], file in enumerate(files, 1):\n                post.update(file)\n                url = file[\"file_url\"]\n                yield Message.Url, url, text.nameext_from_url(url, post)\n\n    def items_users(self):\n        base = self.root + \"/@\"\n        for user in self.users():\n            user[\"_extractor\"] = SkebUserExtractor\n            yield Message.Queue, base + user[\"screen_name\"], user\n\n    def posts(self):\n        \"\"\"Return post number\"\"\"\n\n    def metadata(self):\n        \"\"\"Return additional metadata\"\"\"\n\n    def _pagination(self, url, params):\n        params[\"offset\"] = 0\n\n        while True:\n            posts = self.request_json(\n                url, params=params, headers=self.headers)\n\n            for post in posts:\n                parts = post[\"path\"].split(\"/\")\n                user_name = parts[1][1:]\n                post_num = parts[3]\n\n                if post[\"private\"]:\n                    self.log.debug(\"Skipping @%s/%s (private)\",\n                                   user_name, post_num)\n                    continue\n                yield user_name, post_num\n\n            if len(posts) < 30:\n                return\n            params[\"offset\"] += 30\n\n    def _pagination_users(self, endpoint, params):\n        url = f\"{self.root}/api{endpoint}\"\n        params[\"offset\"] = 0\n        params[\"limit\"] = 90\n\n        while True:\n            data = self.request_json(\n                url, params=params, headers=self.headers)\n            yield from data\n\n            if len(data) < params[\"limit\"]:\n                return\n            params[\"offset\"] += params[\"limit\"]\n\n    def _get_post_data(self, user_name, post_num):\n        url = f\"{self.root}/api/users/{user_name}/works/{post_num}\"\n        resp = self.request_json(url, headers=self.headers)\n        creator = resp[\"creator\"]\n        post = {\n            \"post_id\"          : resp[\"id\"],\n            \"post_num\"         : post_num,\n            \"post_url\"         : self.root + resp[\"path\"],\n            \"body\"             : resp[\"body\"],\n            \"source_body\"      : resp[\"source_body\"],\n            \"translated_body\"  : resp[\"translated\"],\n            \"nsfw\"             : resp[\"nsfw\"],\n            \"anonymous\"        : resp[\"anonymous\"],\n            \"tags\"             : resp[\"tag_list\"],\n            \"genre\"            : resp[\"genre\"],\n            \"thanks\"           : resp[\"thanks\"],\n            \"source_thanks\"    : resp[\"source_thanks\"],\n            \"translated_thanks\": resp[\"translated_thanks\"],\n            \"creator\": {\n                \"id\"           : creator[\"id\"],\n                \"name\"         : creator[\"name\"],\n                \"screen_name\"  : creator[\"screen_name\"],\n                \"avatar_url\"   : creator[\"avatar_url\"],\n                \"header_url\"   : creator[\"header_url\"],\n            }\n        }\n        if not resp[\"anonymous\"] and \"client\" in resp:\n            client = resp[\"client\"]\n            post[\"client\"] = {\n                \"id\"           : client[\"id\"],\n                \"name\"         : client[\"name\"],\n                \"screen_name\"  : client[\"screen_name\"],\n                \"avatar_url\"   : client[\"avatar_url\"],\n                \"header_url\"   : client[\"header_url\"],\n            }\n        return resp, post\n\n    def _get_files_from_post(self, resp):\n        files = []\n\n        if self.thumbnails and \"og_image_url\" in resp:\n            files.append({\n                \"content_category\": \"thumb\",\n                \"file_id\" : \"thumb\",\n                \"_file_id\": str(resp[\"id\"]) + \"t\",\n                \"file_url\": resp[\"og_image_url\"],\n            })\n\n        if self.article and \"article_image_url\" in resp:\n            if url := resp[\"article_image_url\"]:\n                files.append({\n                    \"content_category\": \"article\",\n                    \"file_id\" : \"article\",\n                    \"_file_id\": str(resp[\"id\"]) + \"a\",\n                    \"file_url\": url,\n                })\n\n        for preview in resp[\"previews\"]:\n            info = preview[\"information\"]\n            files.append({\n                \"content_category\": \"preview\",\n                \"file_id\" : preview[\"id\"],\n                \"_file_id\": preview[\"id\"],\n                \"file_url\": preview[\"url\"],\n                \"original\": {\n                    \"width\"     : info[\"width\"],\n                    \"height\"    : info[\"height\"],\n                    \"byte_size\" : info[\"byte_size\"],\n                    \"duration\"  : info[\"duration\"],\n                    \"frame_rate\": info.get(\"frame_rate\"),\n                    \"software\"  : info[\"software\"],\n                    \"extension\" : info[\"extension\"],\n                    \"is_movie\"  : info[\"is_movie\"],\n                    \"transcoder\": info[\"transcoder\"],\n                },\n            })\n\n        return files\n\n\nclass SkebPostExtractor(SkebExtractor):\n    \"\"\"Extractor for a single skeb post\"\"\"\n    subcategory = \"post\"\n    pattern = USER_PATTERN + r\"/works/(\\d+)\"\n    example = \"https://skeb.jp/@USER/works/123\"\n\n    def posts(self):\n        return (self.groups,)\n\n\nclass SkebWorksExtractor(SkebExtractor):\n    \"\"\"Extractor for a skeb user's works\"\"\"\n    subcategory = \"works\"\n    pattern = USER_PATTERN + r\"/works\"\n    example = \"https://skeb.jp/@USER/works\"\n\n    def posts(self):\n        url = f\"{self.root}/api/users/{self.groups[0]}/works\"\n        params = {\"role\": \"creator\", \"sort\": \"date\"}\n        return self._pagination(url, params)\n\n\nclass SkebSentrequestsExtractor(SkebExtractor):\n    \"\"\"Extractor for a skeb user's sent requests\"\"\"\n    subcategory = \"sentrequests\"\n    pattern = USER_PATTERN + r\"/sent[ _-]?requests\"\n    example = \"https://skeb.jp/@USER/sentrequests\"\n\n    def posts(self):\n        url = f\"{self.root}/api/users/{self.groups[0]}/works\"\n        params = {\"role\": \"client\", \"sort\": \"date\"}\n        return self._pagination(url, params)\n\n\nclass SkebUserExtractor(Dispatch, SkebExtractor):\n    \"\"\"Extractor for a skeb user profile\"\"\"\n    pattern = USER_PATTERN + r\"/?$\"\n    example = \"https://skeb.jp/@USER\"\n\n    def items(self):\n        if self.config(\"sent-requests\", False):\n            default = (\"works\", \"sentrequests\")\n        else:\n            default = (\"works\",)\n\n        base = f\"{self.root}/@{self.groups[0]}/\"\n        return self._dispatch_extractors((\n            (SkebWorksExtractor       , base + \"works\"),\n            (SkebSentrequestsExtractor, base + \"sentrequests\"),\n        ), default)\n\n\nclass SkebSearchExtractor(SkebExtractor):\n    \"\"\"Extractor for skeb search results\"\"\"\n    subcategory = \"search\"\n    pattern = BASE_PATTERN + r\"/search\\?q=([^&#]+)\"\n    example = \"https://skeb.jp/search?q=QUERY\"\n\n    def metadata(self):\n        return {\"search_tags\": text.unquote(self.groups[0])}\n\n    def posts(self):\n        url = \"https://hb1jt3kre9-2.algolianet.com/1/indexes/*/queries\"\n        params = {\n            \"x-algolia-agent\": \"Algolia for JavaScript (4.13.1); Browser\",\n        }\n        headers = {\n            \"Origin\": self.root,\n            \"x-algolia-api-key\": \"9a4ce7d609e71bf29e977925e4c6740c\",\n            \"x-algolia-application-id\": \"HB1JT3KRE9\",\n        }\n\n        filters = self.config(\"filters\")\n        if filters is None:\n            filters = (\"genre:art OR genre:voice OR genre:novel OR \"\n                       \"genre:video OR genre:music OR genre:correction\")\n        elif not isinstance(filters, str):\n            filters = \" OR \".join(filters)\n\n        page = 0\n        pams = \"hitsPerPage=40&filters=\" + text.quote(filters) + \"&page=\"\n\n        request = {\n            \"indexName\": \"Request\",\n            \"query\": text.unquote(self.groups[0]),\n            \"params\": pams + str(page),\n        }\n        data = {\"requests\": (request,)}\n\n        while True:\n            result = self.request_json(\n                url, method=\"POST\", params=params, headers=headers, json=data,\n            )[\"results\"][0]\n\n            for post in result[\"hits\"]:\n                parts = post[\"path\"].split(\"/\")\n                yield parts[1][1:], parts[3]\n\n            if page >= result[\"nbPages\"]:\n                return\n            page += 1\n            request[\"params\"] = pams + str(page)\n\n\nclass SkebFollowingExtractor(SkebExtractor):\n    \"\"\"Extractor for all creators followed by a skeb user\"\"\"\n    subcategory = \"following\"\n    pattern = USER_PATTERN + r\"/following_creators\"\n    example = \"https://skeb.jp/@USER/following_creators\"\n\n    items = SkebExtractor.items_users\n\n    def users(self):\n        endpoint = f\"/users/{self.groups[0]}/following_creators\"\n        params = {\"sort\": \"date\"}\n        return self._pagination_users(endpoint, params)\n\n\nclass SkebFollowingUsersExtractor(SkebExtractor):\n    \"\"\"Extractor for your followed users\"\"\"\n    subcategory = \"following-users\"\n    pattern = BASE_PATTERN + r\"/following_users\"\n    example = \"https://skeb.jp/following_users\"\n\n    items = SkebExtractor.items_users\n\n    def users(self):\n        endpoint = \"/following_users\"\n        return self._pagination_users(endpoint, {})\n"
  },
  {
    "path": "gallery_dl/extractor/slickpic.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2019-2025 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://www.slickpic.com/\"\"\"\n\nfrom .common import Extractor, Message\nfrom .. import text\nimport time\n\nBASE_PATTERN = r\"(?:https?://)?([\\w-]+)\\.slickpic\\.com\"\n\n\nclass SlickpicExtractor(Extractor):\n    \"\"\"Base class for slickpic extractors\"\"\"\n    category = \"slickpic\"\n\n    def __init__(self, match):\n        Extractor.__init__(self, match)\n        self.user = match[1]\n        self.root = f\"https://{self.user}.slickpic.com\"\n\n\nclass SlickpicAlbumExtractor(SlickpicExtractor):\n    \"\"\"Extractor for albums on slickpic.com\"\"\"\n    subcategory = \"album\"\n    directory_fmt = (\"{category}\", \"{user[name]}\",\n                     \"{album[id]} {album[title]}\")\n    filename_fmt = \"{num:>03}_{id}{title:?_//}.{extension}\"\n    archive_fmt = \"{id}\"\n    pattern = BASE_PATTERN + r\"/albums/([^/?#]+)\"\n    example = \"https://USER.slickpic.com/albums/TITLE/\"\n\n    def __init__(self, match):\n        SlickpicExtractor.__init__(self, match)\n        self.album = match[2]\n\n    def items(self):\n        data = self.metadata()\n        imgs = self.images(data)\n\n        data = {\n            \"album\": {\n                \"id\"   : text.parse_int(data[\"aid\"]),\n                \"title\": text.unescape(data[\"title\"]),\n            },\n            \"user\": {\n                \"id\"  : text.parse_int(data[\"uid\"]),\n                \"name\": text.unescape(data[\"user\"]),\n                \"nick\": self.user\n            },\n            \"count\": len(imgs),\n        }\n\n        yield Message.Directory, \"\", data\n        for num, img in enumerate(imgs, 1):\n            url = img[\"url_rsz\"] + \"/o/\" + img[\"fname\"]\n            img = text.nameext_from_url(img[\"fname\"], {\n                \"url\"        : url,\n                \"num\"        : num,\n                \"id\"         : text.parse_int(img[\"id\"]),\n                \"width\"      : text.parse_int(img[\"width\"]),\n                \"height\"     : text.parse_int(img[\"height\"]),\n                \"title\"      : img[\"title\"],\n                \"description\": img[\"descr\"],\n            })\n            img.update(data)\n            yield Message.Url, url, img\n\n    def metadata(self):\n        url = f\"{self.root}/albums/{self.album}/?wallpaper\"\n        extr = text.extract_from(self.request(url).text)\n\n        title = text.unescape(extr(\"<title>\", \"</title>\"))\n        title, _, user = title.rpartition(\" by \")\n\n        return {\n            \"title\": title,\n            \"user\" : user,\n            \"tk\"   : extr('tk = \"', '\"'),\n            \"shd\"  : extr('shd = \"', '\"'),\n            \"aid\"  : extr('data-aid=\"', '\"', ),\n            \"uid\"  : extr('data-uid=\"', '\"', ),\n        }\n\n    def images(self, data):\n        url = self.root + \"/xhr/photo/get/list\"\n        data = {\n            \"tm\"    : time.time(),\n            \"tk\"    : data[\"tk\"],\n            \"shd\"   : data[\"shd\"],\n            \"aid\"   : data[\"aid\"],\n            \"uid\"   : data[\"uid\"],\n            \"col\"   : \"0\",\n            \"sys\"   : self.album,\n            \"vw\"    : \"1280\",\n            \"vh\"    : \"1024\",\n            \"skey\"  : \"\",\n            \"viewer\": \"false\",\n            \"pub\"   : \"1\",\n            \"sng\"   : \"0\",\n            \"whq\"   : \"1\",\n        }\n        return self.request_json(url, method=\"POST\", data=data)[\"list\"]\n\n\nclass SlickpicUserExtractor(SlickpicExtractor):\n    subcategory = \"user\"\n    pattern = BASE_PATTERN + r\"(?:/gallery)?/?(?:$|[?#])\"\n    example = \"https://USER.slickpic.com/\"\n\n    def items(self):\n        page = self.request(self.root + \"/gallery?viewer\").text\n        data = {\"_extractor\": SlickpicAlbumExtractor}\n        base = self.root + \"/albums/\"\n\n        for album in text.extract_iter(page, 'href=\"' + base, '\"'):\n            yield Message.Queue, base + album, data\n"
  },
  {
    "path": "gallery_dl/extractor/slideshare.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2016-2017 Leonardo Taccari\n# Copyright 2017-2025 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://www.slideshare.net/\"\"\"\n\nfrom .common import GalleryExtractor\n\n\nclass SlidesharePresentationExtractor(GalleryExtractor):\n    \"\"\"Extractor for images from a presentation on slideshare.net\"\"\"\n    category = \"slideshare\"\n    subcategory = \"presentation\"\n    directory_fmt = (\"{category}\", \"{user}\")\n    filename_fmt = \"{presentation}-{num:>02}.{extension}\"\n    archive_fmt = \"{presentation}_{num}\"\n    pattern = (r\"(?:https?://)?(?:www\\.)?slideshare\\.net\"\n               r\"/(?:mobile/)?([^/?#]+)/([^/?#]+)\")\n    example = \"https://www.slideshare.net/USER/PRESENTATION\"\n\n    def __init__(self, match):\n        self.user, self.presentation = match.groups()\n        url = f\"https://www.slideshare.net/{self.user}/{self.presentation}\"\n        GalleryExtractor.__init__(self, match, url)\n\n    def metadata(self, page):\n        data = self._extract_nextdata(page)\n        self.slideshow = slideshow = data[\"props\"][\"pageProps\"][\"slideshow\"]\n\n        return {\n            \"user\"        : slideshow[\"username\"],\n            \"presentation\": self.presentation,\n            \"title\"       : slideshow[\"title\"].strip(),\n            \"description\" : slideshow[\"description\"].strip(),\n            \"views\"       : slideshow[\"views\"],\n            \"likes\"       : slideshow[\"likes\"],\n            \"date\"        : self.parse_datetime_iso(\n                slideshow[\"createdAt\"][:19]),\n        }\n\n    def images(self, page):\n        slides = self.slideshow[\"slides\"]\n        begin = (f\"{slides['host']}/{slides['imageLocation']}\"\n                 f\"/95/{slides['title']}-\")\n        end = \"-1024.jpg\"\n\n        return [\n            (begin + str(n) + end, None)\n            for n in range(1, self.slideshow[\"totalSlides\"]+1)\n        ]\n"
  },
  {
    "path": "gallery_dl/extractor/smugmug.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2018-2026 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://www.smugmug.com/\"\"\"\n\nfrom .common import Extractor, Message\nfrom .. import text, oauth\n\nBASE_PATTERN = (\n    r\"(?:smugmug:(?!album:)(?:https?://)?([^/]+)|\"\n    r\"(?:https?://)?([\\w-]+)\\.smugmug\\.com)\")\n\n\nclass SmugmugExtractor(Extractor):\n    \"\"\"Base class for smugmug extractors\"\"\"\n    category = \"smugmug\"\n    filename_fmt = (\"{category}_{User[NickName]:?/_/}\"\n                    \"{Image[UploadKey]}_{Image[ImageKey]}.{extension}\")\n    empty_user = {\n        \"Uri\": \"\",\n        \"ResponseLevel\": \"Public\",\n        \"Name\": \"\",\n        \"NickName\": \"\",\n        \"QuickShare\": False,\n        \"RefTag\": \"\",\n        \"ViewPassHint\": \"\",\n        \"WebUri\": \"\",\n        \"Uris\": None,\n    }\n\n    def _init(self):\n        self.api = SmugmugAPI(self)\n        self.videos = self.config(\"videos\", True)\n        self.session = self.api.session\n\n    def _select_format(self, image):\n        details = image[\"Uris\"][\"ImageSizeDetails\"]\n        media = None\n\n        if self.videos and image[\"IsVideo\"]:\n            fltr = \"VideoSize\"\n        elif \"ImageSizeOriginal\" in details:\n            media = details[\"ImageSizeOriginal\"]\n        else:\n            fltr = \"ImageSize\"\n\n        if not media:\n            sizes = filter(lambda s: s[0].startswith(fltr), details.items())\n            media = max(sizes, key=lambda s: s[1][\"Width\"])[1]\n        del image[\"Uris\"]\n\n        for key in (\"Url\", \"Width\", \"Height\", \"MD5\", \"Size\", \"Watermarked\",\n                    \"Bitrate\", \"Duration\"):\n            if key in media:\n                image[key] = media[key]\n        return image[\"Url\"]\n\n\nclass SmugmugAlbumExtractor(SmugmugExtractor):\n    \"\"\"Extractor for smugmug albums\"\"\"\n    subcategory = \"album\"\n    directory_fmt = (\"{category}\", \"{User[NickName]}\", \"{Album[Name]}\")\n    archive_fmt = \"a_{Album[AlbumKey]}_{Image[ImageKey]}\"\n    pattern = r\"smugmug:album:([^:]+)$\"\n    example = \"smugmug:album:ID\"\n\n    def __init__(self, match):\n        SmugmugExtractor.__init__(self, match)\n        self.album_id = match[1]\n\n    def items(self):\n        album = self.api.album(self.album_id, \"User\")\n        user = album[\"Uris\"].get(\"User\") or self.empty_user.copy()\n\n        del user[\"Uris\"]\n        del album[\"Uris\"]\n        data = {\"Album\": album, \"User\": user}\n\n        yield Message.Directory, \"\", data\n\n        for image in self.api.album_images(self.album_id, \"ImageSizeDetails\"):\n            url = self._select_format(image)\n            data[\"Image\"] = image\n            yield Message.Url, url, text.nameext_from_url(url, data)\n\n\nclass SmugmugImageExtractor(SmugmugExtractor):\n    \"\"\"Extractor for individual smugmug images\"\"\"\n    subcategory = \"image\"\n    archive_fmt = \"{Image[ImageKey]}\"\n    pattern = BASE_PATTERN + r\"(?:/[^/?#]+)+/i-([^/?#-]+)\"\n    example = \"https://USER.smugmug.com/PATH/i-ID\"\n\n    def __init__(self, match):\n        SmugmugExtractor.__init__(self, match)\n        self.image_id = match[3]\n\n    def items(self):\n        image = self.api.image(self.image_id, \"ImageSizeDetails\")\n        url = self._select_format(image)\n\n        data = {\"Image\": image}\n        text.nameext_from_url(url, data)\n\n        yield Message.Directory, \"\", data\n        yield Message.Url, url, data\n\n\nclass SmugmugPathExtractor(SmugmugExtractor):\n    \"\"\"Extractor for smugmug albums from URL paths and users\"\"\"\n    subcategory = \"path\"\n    pattern = BASE_PATTERN + r\"((?:/[^/?#a-fh-mo-z][^/?#]*)*)/?$\"\n    example = \"https://USER.smugmug.com/PATH\"\n\n    def __init__(self, match):\n        SmugmugExtractor.__init__(self, match)\n        self.domain, self.user, self.path = match.groups()\n\n    def items(self):\n        if not self.user:\n            self.user = self.api.site_user(self.domain)[\"NickName\"]\n\n        if self.path:\n            if self.path.startswith(\"/gallery/n-\"):\n                node = self.api.node(self.path[11:])\n            else:\n                data = self.api.user_urlpathlookup(self.user, self.path)\n                node = data[\"Uris\"][\"Node\"]\n\n            if node[\"Type\"] == \"Album\":\n                nodes = (node,)\n            elif node[\"Type\"] == \"Folder\":\n                nodes = self.album_nodes(node)\n            else:\n                nodes = ()\n\n            for node in nodes:\n                album_id = node[\"Uris\"][\"Album\"].rpartition(\"/\")[2]\n                node[\"_extractor\"] = SmugmugAlbumExtractor\n                yield Message.Queue, \"smugmug:album:\" + album_id, node\n\n        else:\n            for album in self.api.user_albums(self.user):\n                uri = \"smugmug:album:\" + album[\"AlbumKey\"]\n                album[\"_extractor\"] = SmugmugAlbumExtractor\n                yield Message.Queue, uri, album\n\n    def album_nodes(self, root):\n        \"\"\"Yield all descendant album nodes of 'root'\"\"\"\n        for node in self.api.node_children(root[\"NodeID\"]):\n            if node[\"Type\"] == \"Album\":\n                yield node\n            elif node[\"Type\"] == \"Folder\":\n                yield from self.album_nodes(node)\n\n\nclass SmugmugAPI(oauth.OAuth1API):\n    \"\"\"Minimal interface for the smugmug API v2\"\"\"\n    API_DOMAIN = \"api.smugmug.com\"\n    API_KEY = \"RCVHDGjcbc4Fhzq4qzqLdZmvwmwB6LM2\"\n    API_SECRET = (\"jGrdndvJqhTx8XSNs7TFTSSthhZHq92d\"\n                  \"dMpbpDpkDVNM7TDgnvLFMtfB5Mg5kH73\")\n    HEADERS = {\"Accept\": \"application/json\"}\n\n    def album(self, album_id, expands=None):\n        return self._expansion(\"album/\" + album_id, expands)\n\n    def image(self, image_id, expands=None):\n        return self._expansion(\"image/\" + image_id, expands)\n\n    def node(self, node_id, expands=None):\n        return self._expansion(\"node/\" + node_id, expands)\n\n    def user(self, username, expands=None):\n        return self._expansion(\"user/\" + username, expands)\n\n    def album_images(self, album_id, expands=None):\n        return self._pagination(\"album/\" + album_id + \"!images\", expands)\n\n    def node_children(self, node_id, expands=None):\n        return self._pagination(\"node/\" + node_id + \"!children\", expands)\n\n    def user_albums(self, username, expands=None):\n        return self._pagination(\"user/\" + username + \"!albums\", expands)\n\n    def site_user(self, domain):\n        return self._call(\"!siteuser\", domain=domain)[\"Response\"][\"User\"]\n\n    def user_urlpathlookup(self, username, path):\n        endpoint = \"user/\" + username + \"!urlpathlookup\"\n        params = {\"urlpath\": path}\n        return self._expansion(endpoint, \"Node\", params)\n\n    def _call(self, endpoint, params=None, domain=API_DOMAIN):\n        url = f\"https://{domain}/api/v2/{endpoint}\"\n        params = params or {}\n        if self.api_key:\n            params[\"APIKey\"] = self.api_key\n        params[\"_verbosity\"] = \"1\"\n\n        response = self.request(url, params=params, headers=self.HEADERS)\n        try:\n            data = response.json()\n        except ValueError:\n            raise self.exc.NotFoundError(self.extractor.__class__.subcategory)\n\n        if 200 <= data[\"Code\"] < 400:\n            return data\n        if data[\"Code\"] == 404:\n            raise self.exc.NotFoundError(self.extractor.__class__.subcategory)\n        if data[\"Code\"] == 429:\n            raise self.exc.AbortExtraction(\"Rate limit reached\")\n        self.log.debug(data)\n        raise self.exc.AbortExtraction(\"API request failed\")\n\n    def _expansion(self, endpoint, expands, params=None):\n        endpoint = self._extend(endpoint, expands)\n        result = self._apply_expansions(self._call(endpoint, params), expands)\n        if not result:\n            raise self.exc.NotFoundError()\n        return result[0]\n\n    def _pagination(self, endpoint, expands=None):\n        endpoint = self._extend(endpoint, expands)\n        params = {\"start\": 1, \"count\": 100}\n\n        while True:\n            data = self._call(endpoint, params)\n            yield from self._apply_expansions(data, expands)\n\n            if \"NextPage\" not in data[\"Response\"][\"Pages\"]:\n                return\n            params[\"start\"] += params[\"count\"]\n\n    def _extend(self, endpoint, expands):\n        if expands:\n            endpoint += \"?_expand=\" + expands\n        return endpoint\n\n    def _apply_expansions(self, data, expands):\n\n        def unwrap(response):\n            locator = response[\"Locator\"]\n            return response[locator] if locator in response else []\n\n        objs = unwrap(data[\"Response\"])\n        if not isinstance(objs, list):\n            objs = (objs,)\n\n        if \"Expansions\" in data:\n            expansions = data[\"Expansions\"]\n            expands = expands.split(\",\")\n\n            for obj in objs:\n                uris = obj[\"Uris\"]\n\n                for name in expands:\n                    if name in uris:\n                        uri = uris[name]\n                        uris[name] = unwrap(expansions[uri])\n\n        return objs\n"
  },
  {
    "path": "gallery_dl/extractor/soundgasm.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2022-2025 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://soundgasm.net/\"\"\"\n\nfrom .common import Extractor, Message\nfrom .. import text\n\nBASE_PATTERN = r\"(?:https?://)?(?:www\\.)?soundgasm\\.net/u(?:ser)?\"\n\n\nclass SoundgasmExtractor(Extractor):\n    \"\"\"Base class for soundgasm extractors\"\"\"\n    category = \"soundgasm\"\n    root = \"https://soundgasm.net\"\n    request_interval = (0.5, 1.5)\n    directory_fmt = (\"{category}\", \"{user}\")\n    filename_fmt = \"{title}.{extension}\"\n    archive_fmt = \"{user}_{slug}\"\n\n    def items(self):\n        for sound in map(self._extract_sound, self.sounds()):\n            url = sound[\"url\"]\n            yield Message.Directory, \"\", sound\n            yield Message.Url, url, text.nameext_from_url(url, sound)\n\n    def _extract_sound(self, url):\n        extr = text.extract_from(self.request(url).text)\n\n        _, user, slug = url.rstrip(\"/\").rsplit(\"/\", 2)\n        data = {\n            \"user\" : user,\n            \"slug\" : slug,\n            \"title\": text.unescape(extr('aria-label=\"title\">', \"<\")),\n            \"description\": text.unescape(text.remove_html(extr(\n                'class=\"jp-description\">', '</div>'))),\n        }\n\n        formats = extr('\"setMedia\", {', '}')\n        data[\"url\"] = text.extr(formats, ': \"', '\"')\n\n        return data\n\n\nclass SoundgasmAudioExtractor(SoundgasmExtractor):\n    \"\"\"Extractor for audio clips from soundgasm.net\"\"\"\n    subcategory = \"audio\"\n    pattern = BASE_PATTERN + r\"/([^/?#]+)/([^/?#]+)\"\n    example = \"https://soundgasm.net/u/USER/TITLE\"\n\n    def __init__(self, match):\n        SoundgasmExtractor.__init__(self, match)\n        self.user, self.slug = match.groups()\n\n    def sounds(self):\n        return (f\"{self.root}/u/{self.user}/{self.slug}\",)\n\n\nclass SoundgasmUserExtractor(SoundgasmExtractor):\n    \"\"\"Extractor for all sounds from a soundgasm user\"\"\"\n    subcategory = \"user\"\n    pattern = BASE_PATTERN + r\"/([^/?#]+)/?$\"\n    example = \"https://soundgasm.net/u/USER\"\n\n    def __init__(self, match):\n        SoundgasmExtractor.__init__(self, match)\n        self.user = match[1]\n\n    def sounds(self):\n        page = self.request(self.root + \"/user/\" + self.user).text\n        return [\n            text.extr(sound, '<a href=\"', '\"')\n            for sound in text.extract_iter(\n                page, 'class=\"sound-details\">', \"</a>\")\n        ]\n"
  },
  {
    "path": "gallery_dl/extractor/speakerdeck.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2020 Leonardo Taccari\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://speakerdeck.com/\"\"\"\n\nfrom .common import GalleryExtractor\nfrom .. import text\n\n\nclass SpeakerdeckPresentationExtractor(GalleryExtractor):\n    \"\"\"Extractor for images from a presentation on speakerdeck.com\"\"\"\n    category = \"speakerdeck\"\n    subcategory = \"presentation\"\n    directory_fmt = (\"{category}\", \"{user}\")\n    filename_fmt = \"{presentation}-{num:>02}.{extension}\"\n    archive_fmt = \"{presentation}_{num}\"\n    root = \"https://speakerdeck.com\"\n    pattern = r\"(?:https?://)?(?:www\\.)?speakerdeck\\.com/([^/?#]+)/([^/?#]+)\"\n    example = \"https://speakerdeck.com/USER/PRESENTATION\"\n\n    def metadata(self, _):\n        user, presentation = self.groups\n\n        url = self.root + \"/oembed.json\"\n        params = {\n            \"url\": f\"{self.root}/{user}/{presentation}\",\n        }\n        data = self.request_json(url, params=params)\n\n        self.presentation_id = text.extr(\n            data[\"html\"], 'src=\"//speakerdeck.com/player/', '\"')\n\n        return {\n            \"user\": user,\n            \"presentation\": presentation,\n            \"presentation_id\": self.presentation_id,\n            \"title\": data[\"title\"],\n            \"author\": data[\"author_name\"],\n        }\n\n    def images(self, _):\n        url = f\"{self.root}/player/{self.presentation_id}\"\n        page = self.request(url).text\n        page = text.re(r\"\\s+\").sub(\" \", page)\n        return [\n            (url, None)\n            for url in text.extract_iter(page, 'js-sd-slide\" data-url=\"', '\"')\n        ]\n"
  },
  {
    "path": "gallery_dl/extractor/steamgriddb.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://www.steamgriddb.com\"\"\"\n\nfrom .common import Extractor, Message\nfrom .. import text\n\n\nBASE_PATTERN = r\"(?:https?://)?(?:www\\.)?steamgriddb\\.com\"\nLANGUAGE_CODES = (\n    \"aa\", \"ab\", \"ae\", \"af\", \"ak\", \"am\", \"an\", \"ar\", \"as\", \"av\", \"ay\", \"az\",\n    \"ba\", \"be\", \"bg\", \"bh\", \"bi\", \"bm\", \"bn\", \"bo\", \"br\", \"bs\", \"ca\", \"ce\",\n    \"ch\", \"co\", \"cr\", \"cs\", \"cu\", \"cv\", \"cy\", \"da\", \"de\", \"dv\", \"dz\", \"ee\",\n    \"el\", \"en\", \"eo\", \"es\", \"et\", \"eu\", \"fa\", \"ff\", \"fi\", \"fj\", \"fo\", \"fr\",\n    \"fy\", \"ga\", \"gd\", \"gl\", \"gn\", \"gu\", \"gv\", \"ha\", \"he\", \"hi\", \"ho\", \"hr\",\n    \"ht\", \"hu\", \"hy\", \"hz\", \"ia\", \"id\", \"ie\", \"ig\", \"ii\", \"ik\", \"io\", \"is\",\n    \"it\", \"iu\", \"ja\", \"jv\", \"ka\", \"kg\", \"ki\", \"kj\", \"kk\", \"kl\", \"km\", \"kn\",\n    \"ko\", \"kr\", \"ks\", \"ku\", \"kv\", \"kw\", \"ky\", \"la\", \"lb\", \"lg\", \"li\", \"ln\",\n    \"lo\", \"lt\", \"lu\", \"lv\", \"mg\", \"mh\", \"mi\", \"mk\", \"ml\", \"mn\", \"mr\", \"ms\",\n    \"mt\", \"my\", \"na\", \"nb\", \"nd\", \"ne\", \"ng\", \"nl\", \"nn\", \"no\", \"nr\", \"nv\",\n    \"ny\", \"oc\", \"oj\", \"om\", \"or\", \"os\", \"pa\", \"pi\", \"pl\", \"ps\", \"pt\", \"qu\",\n    \"rm\", \"rn\", \"ro\", \"ru\", \"rw\", \"sa\", \"sc\", \"sd\", \"se\", \"sg\", \"si\", \"sk\",\n    \"sl\", \"sm\", \"sn\", \"so\", \"sq\", \"sr\", \"ss\", \"st\", \"su\", \"sv\", \"sw\", \"ta\",\n    \"te\", \"tg\", \"th\", \"ti\", \"tk\", \"tl\", \"tn\", \"to\", \"tr\", \"ts\", \"tt\", \"tw\",\n    \"ty\", \"ug\", \"uk\", \"ur\", \"uz\", \"ve\", \"vi\", \"vo\", \"wa\", \"wo\", \"xh\", \"yi\",\n    \"yo\", \"za\", \"zh\", \"zu\",\n)\nFILE_EXT_TO_MIME = {\n    \"png\": \"image/png\",\n    \"jpeg\": \"image/jpeg\",\n    \"jpg\": \"image/jpeg\",\n    \"webp\": \"image/webp\",\n    \"ico\": \"image/vnd.microsoft.icon\",\n    \"all\": \"all\",\n}\n\n\nclass SteamgriddbExtractor(Extractor):\n    \"\"\"Base class for SteamGridDB\"\"\"\n    category = \"steamgriddb\"\n    directory_fmt = (\"{category}\", \"{subcategory}\", \"{game[id]}\")\n    filename_fmt = \"{game[id]}_{id}_{num:>02}.{extension}\"\n    archive_fmt = \"{filename}\"\n    root = \"https://www.steamgriddb.com\"\n\n    def _init(self):\n        self.cookies_update({\n            \"userprefs\": \"%7B%22adult%22%3Afalse%7D\",\n        })\n\n    def items(self):\n        download_fake_png = self.config(\"download-fake-png\", True)\n\n        for asset in self.assets():\n            fake_png = download_fake_png and asset.get(\"fake_png\")\n\n            asset[\"count\"] = 2 if fake_png else 1\n            yield Message.Directory, \"\", asset\n\n            asset[\"num\"] = 1\n            url = asset[\"url\"]\n            yield Message.Url, url, text.nameext_from_url(url, asset)\n\n            if fake_png:\n                asset[\"num\"] = 2\n                asset[\"_http_adjust_extension\"] = False\n                url = fake_png\n                yield Message.Url, url, text.nameext_from_url(url, asset)\n\n    def _call(self, endpoint, **kwargs):\n        data = self.request_json(self.root + endpoint, **kwargs)\n        if not data[\"success\"]:\n            raise self.exc.AbortExtraction(data[\"error\"])\n        return data[\"data\"]\n\n\nclass SteamgriddbAssetsExtractor(SteamgriddbExtractor):\n    \"\"\"Base class for extracting a list of assets\"\"\"\n\n    def __init__(self, match):\n        SteamgriddbExtractor.__init__(self, match)\n        list_type = match[1]\n        id = int(match[2])\n        self.game_id = id if list_type == \"game\" else None\n        self.collection_id = id if list_type == \"collection\" else None\n        self.page = int(p) if (p := match[3]) else 1\n\n    def assets(self):\n        limit = 48\n        page = min(self.page - 1, 0)\n\n        sort = self.config(\"sort\", \"score_desc\")\n        if sort not in (\"score_desc\", \"score_asc\", \"score_old_desc\",\n                        \"score_old_asc\", \"age_desc\", \"age_asc\"):\n            raise self.exc.AbortExtraction(f\"Invalid sort '{sort}'\")\n\n        json = {\n            \"static\"  : self.config(\"static\", True),\n            \"animated\": self.config(\"animated\", True),\n            \"humor\"   : self.config(\"humor\", True),\n            \"nsfw\"    : self.config(\"nsfw\", True),\n            \"epilepsy\": self.config(\"epilepsy\", True),\n            \"untagged\": self.config(\"untagged\", True),\n\n            \"asset_type\": self.asset_type,\n            \"limit\": limit,\n            \"order\": sort,\n        }\n        if self.valid_dimensions:\n            json[\"dimensions\"] = self.config_list(\n                \"dimensions\", \"dimension\", self.valid_dimensions)\n        json[\"styles\"] = self.config_list(\"styles\", \"style\", self.valid_styles)\n        json[\"languages\"] = self.config_list(\n            \"languages\", \"language\", LANGUAGE_CODES)\n        file_types = self.config_list(\n            \"file-types\", \"file type\", self.valid_file_types)\n        json[\"mime\"] = [FILE_EXT_TO_MIME[i] for i in file_types]\n\n        if self.game_id:\n            json[\"game_id\"] = [self.game_id]\n        else:\n            json[\"collection_id\"] = self.collection_id\n\n        while True:\n            json[\"page\"] = page\n\n            data = self._call(\n                \"/api/public/search/assets\", method=\"POST\", json=json)\n            for asset in data[\"assets\"]:\n                if not asset.get(\"game\"):\n                    asset[\"game\"] = data[\"game\"]\n                yield asset\n\n            if data[\"total\"] <= limit * page:\n                break\n            page += 1\n\n    def config_list(self, key, type_name, valid_values):\n        value = self.config(key)\n        if isinstance(value, str):\n            value = value.split(\",\")\n\n        if value is None or \"all\" in value:\n            return [\"all\"]\n\n        for i in value:\n            if i not in valid_values:\n                raise self.exc.AbortExtraction(f\"Invalid {type_name} '{i}'\")\n\n        return value\n\n\nclass SteamgriddbAssetExtractor(SteamgriddbExtractor):\n    \"\"\"Extractor for a single asset\"\"\"\n    subcategory = \"asset\"\n    pattern = BASE_PATTERN + r\"/(grid|hero|logo|icon)/(\\d+)\"\n    example = \"https://www.steamgriddb.com/grid/1234\"\n\n    def __init__(self, match):\n        SteamgriddbExtractor.__init__(self, match)\n        self.asset_type = match[1]\n        self.asset_id = match[2]\n\n    def assets(self):\n        endpoint = \"/api/public/asset/\" + self.asset_type + \"/\" + self.asset_id\n        asset = self._call(endpoint)[\"asset\"]\n        if asset is None:\n            raise self.exc.NotFoundError(\n                f\"asset ({self.asset_type}:{self.asset_id})\")\n        return (asset,)\n\n\nclass SteamgriddbGridsExtractor(SteamgriddbAssetsExtractor):\n    subcategory = \"grids\"\n    asset_type = \"grid\"\n    pattern = BASE_PATTERN + r\"/(game|collection)/(\\d+)/grids(?:/(\\d+))?\"\n    example = \"https://www.steamgriddb.com/game/1234/grids\"\n    valid_dimensions = (\"460x215\", \"920x430\", \"600x900\", \"342x482\", \"660x930\",\n                        \"512x512\", \"1024x1024\")\n    valid_styles = (\"alternate\", \"blurred\", \"no_logo\", \"material\",\n                    \"white_logo\")\n    valid_file_types = (\"png\", \"jpeg\", \"jpg\", \"webp\")\n\n\nclass SteamgriddbHeroesExtractor(SteamgriddbAssetsExtractor):\n    subcategory = \"heroes\"\n    asset_type = \"hero\"\n    pattern = BASE_PATTERN + r\"/(game|collection)/(\\d+)/heroes(?:/(\\d+))?\"\n    example = \"https://www.steamgriddb.com/game/1234/heroes\"\n    valid_dimensions = (\"1920x620\", \"3840x1240\", \"1600x650\")\n    valid_styles = (\"alternate\", \"blurred\", \"material\")\n    valid_file_types = (\"png\", \"jpeg\", \"jpg\", \"webp\")\n\n\nclass SteamgriddbLogosExtractor(SteamgriddbAssetsExtractor):\n    subcategory = \"logos\"\n    asset_type = \"logo\"\n    pattern = BASE_PATTERN + r\"/(game|collection)/(\\d+)/logos(?:/(\\d+))?\"\n    example = \"https://www.steamgriddb.com/game/1234/logos\"\n    valid_dimensions = None\n    valid_styles = (\"official\", \"white\", \"black\", \"custom\")\n    valid_file_types = (\"png\", \"webp\")\n\n\nclass SteamgriddbIconsExtractor(SteamgriddbAssetsExtractor):\n    subcategory = \"icons\"\n    asset_type = \"icon\"\n    pattern = BASE_PATTERN + r\"/(game|collection)/(\\d+)/icons(?:/(\\d+))?\"\n    example = \"https://www.steamgriddb.com/game/1234/icons\"\n    valid_dimensions = [f\"{i}x{i}\" for i in (8, 10, 14, 16, 20, 24,\n                        28, 32, 35, 40, 48, 54, 56, 57, 60, 64, 72, 76, 80, 90,\n                        96, 100, 114, 120, 128, 144, 150, 152, 160, 180, 192,\n                        194, 256, 310, 512, 768, 1024)]\n    valid_styles = (\"official\", \"custom\")\n    valid_file_types = (\"png\", \"ico\")\n"
  },
  {
    "path": "gallery_dl/extractor/subscribestar.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2020-2026 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://www.subscribestar.com/\"\"\"\n\nfrom .common import Extractor, Message\nfrom .. import text, util\n\nBASE_PATTERN = r\"(?:https?://)?(?:www\\.)?subscribestar\\.(com|adult)\"\n\n\nclass SubscribestarExtractor(Extractor):\n    \"\"\"Base class for subscribestar extractors\"\"\"\n    category = \"subscribestar\"\n    root = \"https://www.subscribestar.com\"\n    directory_fmt = (\"{category}\", \"{author_name}\")\n    filename_fmt = \"{post_id}_{id}.{extension}\"\n    archive_fmt = \"{id}\"\n    cookies_domain = \".subscribestar.com\"\n    cookies_names = (\"_personalization_id\",)\n    _warning = True\n\n    def __init__(self, match):\n        if match[1] == \"adult\":\n            self.root = \"https://subscribestar.adult\"\n            self.cookies_domain = \".subscribestar.adult\"\n            self.subcategory += \"-adult\"\n        Extractor.__init__(self, match)\n        self.item = match[2]\n\n    def items(self):\n        self.login()\n        for post_html in self.posts():\n            media = self._media_from_post(post_html)\n            data = self._data_from_post(post_html)\n\n            content = data[\"content\"]\n            if \"<html><body>\" in content:\n                data[\"content\"] = content = text.extr(\n                    content, \"<body>\", \"</body>\")\n            data[\"title\"] = text.unescape(text.rextr(content, \"<h1>\", \"</h1>\"))\n\n            yield Message.Directory, \"\", data\n            for num, item in enumerate(media, 1):\n                item.update(data)\n                item[\"num\"] = num\n\n                url = item[\"url\"]\n                if name := (item.get(\"name\") or item.get(\"original_filename\")):\n                    text.nameext_from_name(name, item)\n                else:\n                    text.nameext_from_url(url, item)\n\n                if url[0] == \"/\":\n                    url = self.root + url\n                yield Message.Url, url, item\n\n    def posts(self):\n        \"\"\"Yield HTML content of all relevant posts\"\"\"\n\n    def request(self, url, **kwargs):\n        while True:\n            response = Extractor.request(self, url, **kwargs)\n\n            if response.history and (\n                    \"/verify_subscriber\" in response.url or\n                    \"/age_confirmation_warning\" in response.url):\n                raise self.exc.AbortExtraction(\n                    \"HTTP redirect to \" + response.url)\n\n            content = response.content\n            if len(content) < 250 and b\">redirected<\" in content:\n                url = text.unescape(text.extr(\n                    content, b'href=\"', b'\"').decode())\n                self.log.debug(\"HTML redirect message for %s\", url)\n                continue\n\n            return response\n\n    def login(self):\n        if self.cookies_check(self.cookies_names):\n            return\n\n        username, password = self._get_auth_info()\n        if username:\n            self.cookies_update(self.cache(\n                self._login_impl, (username, self.cookies_domain), password,\n                _exp=28*86400, _mem=False))\n\n        if self._warning:\n            if not username or not self.cookies_check(self.cookies_names):\n                self.log.warning(\"no '_personalization_id' cookie set\")\n            SubscribestarExtractor._warning = False\n\n    def _login_impl(self, username, password):\n        username = username[0]\n        self.log.info(\"Logging in as %s\", username)\n\n        if self.root.endswith(\".adult\"):\n            self.cookies.set(\"18_plus_agreement_generic\", \"true\",\n                             domain=self.cookies_domain)\n\n        # load login page\n        url = self.root + \"/login\"\n        page = self.request(url).text\n\n        headers = {\n            \"Accept\": \"*/*;q=0.5, text/javascript, application/javascript, \"\n                      \"application/ecmascript, application/x-ecmascript\",\n            \"Referer\": self.root + \"/login\",\n            \"X-CSRF-Token\": text.unescape(text.extr(\n                page, '<meta name=\"csrf-token\" content=\"', '\"')),\n            \"Content-Type\": \"application/x-www-form-urlencoded; charset=UTF-8\",\n            \"X-Requested-With\": \"XMLHttpRequest\",\n        }\n\n        def check_errors(response):\n            if errors := response.json().get(\"errors\"):\n                self.log.debug(errors)\n                try:\n                    msg = f'\"{errors.popitem()[1]}\"'\n                except Exception:\n                    msg = None\n                raise self.exc.AuthenticationError(msg)\n            return response\n\n        # submit username / email\n        url = self.root + \"/session.json\"\n        data = {\"email\": username}\n        response = check_errors(self.request(\n            url, method=\"POST\", headers=headers, data=data, fatal=False))\n\n        # submit password\n        url = self.root + \"/session/password.json\"\n        data = {\"password\": password}\n        response = check_errors(self.request(\n            url, method=\"POST\", headers=headers, data=data, fatal=False))\n\n        # return cookies\n        return {\n            cookie.name: cookie.value\n            for cookie in response.cookies\n        }\n\n    def _pagination(self, url, params=None):\n        needle_next_page = 'data-role=\"infinite_scroll-next_page\" href=\"'\n        page = self.request(url, params=params).text\n\n        while True:\n            posts = page.split('<div class=\"post ')[1:]\n            if not posts:\n                return\n            yield from posts\n\n            url = text.extr(posts[-1], needle_next_page, '\"')\n            if not url:\n                return\n            page = self.request_json(self.root + text.unescape(url))[\"html\"]\n\n    def _media_from_post(self, html):\n        media = []\n\n        if gallery := text.extr(html, 'data-gallery=\"', '\"'):\n            for item in util.json_loads(text.unescape(gallery)):\n                if \"/previews\" in item[\"url\"]:\n                    self._warn_preview()\n                else:\n                    media.append(item)\n\n        attachments = text.extr(\n            html, 'class=\"uploads-docs\"', 'class=\"post-edit_form\"')\n        if attachments:\n            for att in text.re(r'class=\"doc_preview[\" ]').split(\n                    attachments)[1:]:\n                media.append({\n                    \"id\"  : text.parse_int(text.extr(\n                        att, 'data-upload-id=\"', '\"')),\n                    \"name\": text.unescape(text.extr(\n                        att, 'doc_preview-title\">', '<')),\n                    \"url\" : text.unescape(text.extr(att, 'href=\"', '\"')),\n                    \"type\": \"attachment\",\n                })\n\n        audios = text.extr(\n            html, 'class=\"uploads-audios\"', 'class=\"post-edit_form\"')\n        if audios:\n            for audio in text.re(r'class=\"audio_preview-data[\" ]').split(\n                    audios)[1:]:\n                media.append({\n                    \"id\"  : text.parse_int(text.extr(\n                        audio, 'data-upload-id=\"', '\"')),\n                    \"name\": text.unescape(text.extr(\n                        audio, 'audio_preview-title\">', '<')),\n                    \"url\" : text.unescape(text.extr(audio, 'src=\"', '\"')),\n                    \"type\": \"audio\",\n                })\n\n        return media\n\n    def _data_from_post(self, html):\n        extr = text.extract_from(html)\n        return {\n            \"post_id\"    : text.parse_int(extr('data-id=\"', '\"')),\n            \"author_id\"  : text.parse_int(extr('data-user-id=\"', '\"')),\n            \"author_name\": text.unescape(extr('href=\"/', '\"')),\n            \"author_nick\": text.unescape(extr('>', '<')),\n            \"date\"       : self._parse_datetime(extr(\n                'class=\"post-date\">', '</').rpartition(\">\")[2]),\n            \"content\"    : extr(\n                '<div class=\"post-content\" data-role=\"post_content-text\">',\n                '</div><div class=\"post-uploads for-youtube\"').strip(),\n            \"tags\"       : list(text.extract_iter(extr(\n                '<div class=\"post_tags for-post\">',\n                '<div class=\"post-actions\">'), '?tag=', '\"')),\n        }\n\n    def _parse_datetime(self, dt):\n        if dt.startswith(\"Updated on \"):\n            dt = dt[11:]\n        date = self.parse_datetime(dt, \"%b %d, %Y %I:%M %p\")\n        if date is dt:\n            date = self.parse_datetime(dt, \"%B %d, %Y %I:%M %p\")\n        return date\n\n    def _warn_preview(self):\n        self.log.warning(\"Preview image detected\")\n        self._warn_preview = util.noop\n\n\nclass SubscribestarUserExtractor(SubscribestarExtractor):\n    \"\"\"Extractor for media from a subscribestar user\"\"\"\n    subcategory = \"user\"\n    pattern = BASE_PATTERN + r\"/(?!posts/)([^/?#]+)(?:\\?([^#]+))?\"\n    example = \"https://www.subscribestar.com/USER\"\n\n    def posts(self):\n        _, user, qs = self.groups\n        url = f\"{self.root}/{user}\"\n\n        if qs is None:\n            params = None\n        else:\n            params = text.parse_query(qs)\n            if \"tag\" in params:\n                self.kwdict[\"search_tags\"] = params[\"tag\"]\n\n        return self._pagination(url, params)\n\n\nclass SubscribestarPostExtractor(SubscribestarExtractor):\n    \"\"\"Extractor for media from a single subscribestar post\"\"\"\n    subcategory = \"post\"\n    pattern = BASE_PATTERN + r\"/posts/(\\d+)\"\n    example = \"https://www.subscribestar.com/posts/12345\"\n\n    def posts(self):\n        url = f\"{self.root}/posts/{self.item}\"\n        return (self.request(url).text,)\n\n    def _data_from_post(self, html):\n        extr = text.extract_from(html)\n        return {\n            \"post_id\"    : text.parse_int(extr('data-id=\"', '\"')),\n            \"date\"       : self._parse_datetime(extr(\n                '<div class=\"section-title_date\">', '<')),\n            \"content\"    : extr(\n                '<div class=\"post-content\" data-role=\"post_content-text\">',\n                '</div><div class=\"post-uploads for-youtube\"').strip(),\n            \"tags\"       : list(text.extract_iter(extr(\n                '<div class=\"post_tags for-post\">',\n                '<div class=\"post-actions\">'), '?tag=', '\"')),\n            \"author_name\": text.unescape(extr(\n                'class=\"star_link\" href=\"/', '\"')),\n            \"author_id\"  : text.parse_int(extr('data-user-id=\"', '\"')),\n            \"author_nick\": text.unescape(extr('alt=\"', '\"')),\n        }\n"
  },
  {
    "path": "gallery_dl/extractor/sxypix.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://sxypix.com/\"\"\"\n\nfrom .common import GalleryExtractor\nfrom .. import text\n\n\nclass SxypixGalleryExtractor(GalleryExtractor):\n    \"\"\"Extractor for image galleries from sxypix.com\"\"\"\n    category = \"sxypix\"\n    root = \"https://sxypix.com\"\n    pattern = r\"(?:https?://)?(?:www\\.)?sxypix\\.com(/w/(\\w+))\"\n    example = \"https://sxypix.com/w/2bbaf1b24a5863d0e73436619bbaa7ee\"\n\n    def metadata(self, page):\n        return {\n            \"gallery_id\": self.groups[1],\n            \"title\": text.unescape(text.extr(\n                page, '<meta name=\"keywords\" content=\"', '\"')),\n        }\n\n    def images(self, page):\n        data = {\n            \"aid\"  : text.extr(page, \"data-aid='\", \"'\"),\n            \"ghash\": text.extr(page, \"data-ghash='\", \"'\"),\n        }\n        gallery = self.request_json(\n            \"https://sxypix.com/php/gall.php\", method=\"POST\", data=data)\n\n        base = \"https://x.\"\n        return [\n            (base + text.extr(entry, \"data-src='//.\", \"'\"), None)\n            for entry in gallery[\"r\"]\n        ]\n"
  },
  {
    "path": "gallery_dl/extractor/szurubooru.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2023-2025 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for szurubooru instances\"\"\"\n\nfrom . import booru\nfrom .. import text\n\nimport collections\nimport binascii\n\n\nclass SzurubooruExtractor(booru.BooruExtractor):\n    basecategory = \"szurubooru\"\n    filename_fmt = \"{id}_{version}_{checksumMD5}.{extension}\"\n    per_page = 100\n\n    def _init(self):\n        self.headers = {\n            \"Accept\": \"application/json\",\n            \"Content-Type\": \"application/json\",\n        }\n\n        if username := self.config(\"username\"):\n            if token := self.config(\"token\"):\n                value = username + \":\" + token\n                self.headers[\"Authorization\"] = \"Token \" + \\\n                    binascii.b2a_base64(value.encode())[:-1].decode()\n\n    def _api_request(self, endpoint, params=None):\n        url = self.root + \"/api\" + endpoint\n        return self.request_json(url, headers=self.headers, params=params)\n\n    def _pagination(self, endpoint, params):\n        params[\"offset\"] = 0\n        params[\"limit\"] = self.per_page\n\n        while True:\n            data = self._api_request(endpoint, params)\n            results = data[\"results\"]\n\n            yield from results\n\n            if len(results) < self.per_page:\n                return\n            params[\"offset\"] += len(results)\n\n    def _file_url(self, post):\n        url = post[\"contentUrl\"]\n        if not url.startswith(\"http\"):\n            url = self.root + \"/\" + url\n        return url\n\n    def _prepare(self, post):\n        post[\"date\"] = self.parse_datetime_iso(post[\"creationTime\"])\n\n        tags = []\n        tags_categories = collections.defaultdict(list)\n        for tag in post[\"tags\"]:\n            tag_type = tag[\"category\"].rpartition(\"_\")[2]\n            tag_name = tag[\"names\"][0]\n            tags_categories[tag_type].append(tag_name)\n            tags.append(tag_name)\n\n        post[\"tags\"] = tags\n        for category, tags in tags_categories.items():\n            post[\"tags_\" + category] = tags\n\n\nBASE_PATTERN = SzurubooruExtractor.update({\n    \"bcbnsfw\": {\n        \"root\": \"https://booru.bcbnsfw.space\",\n        \"pattern\": r\"booru\\.bcbnsfw\\.space\",\n        \"query-all\": \"*\",\n    },\n    \"snootbooru\": {\n        \"root\": \"https://snootbooru.com\",\n        \"pattern\": r\"snootbooru\\.com\",\n    },\n    \"visuabusters\": {\n        \"root\": \"https://www.visuabusters.com/booru\",\n        \"pattern\": r\"(?:www\\.)?visuabusters\\.com/booru\",\n    },\n})\n\n\nclass SzurubooruTagExtractor(SzurubooruExtractor):\n    subcategory = \"tag\"\n    directory_fmt = (\"{category}\", \"{search_tags}\")\n    archive_fmt = \"t_{search_tags}_{id}_{version}\"\n    pattern = BASE_PATTERN + r\"/posts(?:/query=([^/?#]*))?\"\n    example = \"https://booru.bcbnsfw.space/posts/query=TAG\"\n\n    def __init__(self, match):\n        SzurubooruExtractor.__init__(self, match)\n        query = self.groups[-1]\n        self.query = text.unquote(query.replace(\"+\", \" \")) if query else \"\"\n\n    def metadata(self):\n        return {\"search_tags\": self.query}\n\n    def posts(self):\n        if self.query.strip():\n            query = self.query\n        else:\n            query = self.config_instance(\"query-all\")\n\n        return self._pagination(\"/posts/\", {\"query\": query})\n\n\nclass SzurubooruPostExtractor(SzurubooruExtractor):\n    subcategory = \"post\"\n    archive_fmt = \"{id}_{version}\"\n    pattern = BASE_PATTERN + r\"/post/(\\d+)\"\n    example = \"https://booru.bcbnsfw.space/post/12345\"\n\n    def posts(self):\n        return (self._api_request(\"/post/\" + self.groups[-1]),)\n"
  },
  {
    "path": "gallery_dl/extractor/tapas.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2021-2026 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://tapas.io/\"\"\"\n\nfrom .common import Extractor, Message\nfrom .. import text\n\nBASE_PATTERN = r\"(?:https?://)?tapas\\.io\"\n\n\nclass TapasExtractor(Extractor):\n    \"\"\"Base class for tapas.io extractors\"\"\"\n    category = \"tapas\"\n    root = \"https://tapas.io\"\n    directory_fmt = (\"{category}\", \"{series[title]}\", \"{id} {title}\")\n    filename_fmt = \"{num:>02}.{extension}\"\n    archive_fmt = \"{id}_{num}\"\n    cookies_domain = \".tapas.io\"\n    cookies_names = (\"_cpc_\",)\n    _cache = None\n\n    def _init(self):\n        if self._cache is None:\n            TapasExtractor._cache = {}\n\n    def login(self):\n        if self.cookies_check(self.cookies_names):\n            return\n\n        username, password = self._get_auth_info()\n        if username:\n            return self.cookies_update(self.cache(\n                self._login_impl, username, password,\n                _exp=14*86400, _mem=False))\n\n        self.cookies.set(\n            \"birthDate\"        , \"1981-02-03\", domain=self.cookies_domain)\n        self.cookies.set(\n            \"adjustedBirthDate\", \"1981-02-03\", domain=self.cookies_domain)\n\n    def _login_impl(self, username, password):\n        self.log.info(\"Logging in as %s\", username)\n\n        url = self.root + \"/account/authenticate\"\n        headers = {\n            \"Referer\" : url,\n        }\n        data = {\n            \"from\"    : \"https://tapas.io/\",\n            \"email\"   : username,\n            \"password\": password,\n        }\n        response = self.request(\n            url, method=\"POST\", headers=headers, data=data)\n\n        if not response.history or \\\n                \"/account/signin_fail\" in response.history[-1].url:\n            raise self.exc.AuthenticationError()\n\n        return {\"_cpc_\": response.history[0].cookies.get(\"_cpc_\")}\n\n    def request_api(self, url, params=None):\n        headers = {\"Accept\": \"application/json, text/javascript, */*;\"}\n        return self.request_json(url, params=params, headers=headers)[\"data\"]\n\n\nclass TapasEpisodeExtractor(TapasExtractor):\n    subcategory = \"episode\"\n    pattern = BASE_PATTERN + r\"/episode/(\\d+)\"\n    example = \"https://tapas.io/episode/12345\"\n\n    def items(self):\n        self.login()\n\n        episode_id = self.groups[0]\n        url = f\"{self.root}/episode/{episode_id}\"\n        data = self.request_api(url)\n\n        episode = data[\"episode\"]\n        if not episode.get(\"free\") and not episode.get(\"unlocked\"):\n            raise self.exc.AuthorizationError(\n                f\"{episode_id}: Episode '{episode['title']}' not unlocked\")\n\n        html = data[\"html\"]\n        episode[\"series\"] = self._extract_series(html)\n        episode[\"date\"] = self.parse_datetime_iso(episode[\"publish_date\"])\n        yield Message.Directory, \"\", episode\n\n        if episode[\"book\"]:\n            content = text.extr(\n                html, '<div class=\"viewer\">', '<div class=\"viewer-bottom')\n            episode[\"num\"] = 1\n            episode[\"extension\"] = \"html\"\n            yield Message.Url, \"text:\" + content, episode\n\n        else:  # comic\n            for episode[\"num\"], url in enumerate(text.extract_iter(\n                    html, 'data-src=\"', '\"'), 1):\n                url = text.unescape(url)\n                yield Message.Url, url, text.nameext_from_url(url, episode)\n\n    def _extract_series(self, html):\n        series_id = text.rextr(html, 'data-series-id=\"', '\"')\n        try:\n            return self._cache[series_id]\n        except KeyError:\n            url = f\"{self.root}/series/{series_id}\"\n            series = self._cache[series_id] = self.request_api(url)\n            return series\n\n\nclass TapasSeriesExtractor(TapasExtractor):\n    subcategory = \"series\"\n    pattern = BASE_PATTERN + r\"/series/([^/?#]+)\"\n    example = \"https://tapas.io/series/TITLE\"\n\n    def items(self):\n        self.login()\n\n        url = f\"{self.root}/series/{self.groups[0]}\"\n        series_id, _, episode_id = text.extr(\n            self.request(url).text, 'content=\"tapastic://series/', '\"',\n        ).partition(\"/episodes/\")\n\n        url = f\"{self.root}/series/{series_id}/episodes\"\n        params = {\n            \"eid\"        : episode_id,\n            \"page\"       : 1,\n            \"sort\"       : \"OLDEST\",\n            \"last_access\": \"0\",\n            \"max_limit\"  : \"20\",\n        }\n\n        base = self.root + \"/episode/\"\n        while True:\n            data = self.request_api(url, params)\n            for episode in data[\"episodes\"]:\n                episode[\"_extractor\"] = TapasEpisodeExtractor\n                yield Message.Queue, base + str(episode[\"id\"]), episode\n\n            if not data[\"pagination\"][\"has_next\"]:\n                return\n            params[\"page\"] += 1\n\n\nclass TapasCreatorExtractor(TapasExtractor):\n    subcategory = \"creator\"\n    pattern = BASE_PATTERN + r\"/(?!series|episode)([^/?#]+)\"\n    example = \"https://tapas.io/CREATOR\"\n\n    def items(self):\n        self.login()\n\n        url = f\"{self.root}/{self.groups[0]}/series\"\n        page = self.request(url).text\n        page = text.extr(page, '<ul class=\"content-list-wrap', \"</ul>\")\n\n        data = {\"_extractor\": TapasSeriesExtractor}\n        for path in text.extract_iter(page, ' href=\"', '\"'):\n            yield Message.Queue, self.root + path, data\n"
  },
  {
    "path": "gallery_dl/extractor/tcbscans.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://tcbscans.me/\"\"\"\n\nfrom .common import ChapterExtractor, MangaExtractor\nfrom .. import text\n\nBASE_PATTERN = (r\"(?:https?://)?(?:tcb(?:-backup\\.bihar-mirchi|scans)\"\n                r\"|onepiecechapters)\\.(?:com|me)\")\n\n\nclass TcbscansChapterExtractor(ChapterExtractor):\n    category = \"tcbscans\"\n    pattern = BASE_PATTERN + r\"(/chapters/\\d+/[^/?#]+)\"\n    example = \"https://tcbscans.me/chapters/12345/MANGA-chapter-123\"\n\n    def __init__(self, match):\n        self.root = text.root_from_url(match[0])\n        ChapterExtractor.__init__(self, match)\n\n    def images(self, page):\n        return [\n            (url, None)\n            for url in text.extract_iter(\n                page, '<img class=\"fixed-ratio-content\" src=\"', '\"')\n        ]\n\n    def metadata(self, page):\n        manga, _, chapter = text.extr(\n            page, 'font-bold mt-8\">', \"</h1>\").rpartition(\" - Chapter \")\n        chapter, sep, minor = chapter.partition(\".\")\n        return {\n            \"manga\": text.unescape(manga).strip(),\n            \"chapter\": text.parse_int(chapter),\n            \"chapter_minor\": sep + minor,\n            \"lang\": \"en\", \"language\": \"English\",\n        }\n\n\nclass TcbscansMangaExtractor(MangaExtractor):\n    category = \"tcbscans\"\n    chapterclass = TcbscansChapterExtractor\n    pattern = BASE_PATTERN + r\"(/mangas/\\d+/[^/?#]+)\"\n    example = \"https://tcbscans.me/mangas/123/MANGA\"\n\n    def __init__(self, match):\n        self.root = text.root_from_url(match[0])\n        MangaExtractor.__init__(self, match)\n\n    def chapters(self, page):\n        data = {\n            \"manga\": text.unescape(text.extr(\n                page, 'class=\"my-3 font-bold text-3xl\">', \"</h1>\")),\n            \"lang\": \"en\", \"language\": \"English\",\n        }\n\n        results = []\n        page = text.extr(page, 'class=\"col-span-2\"', 'class=\"order-1')\n        for chapter in text.extract_iter(page, \"<a\", \"</a>\"):\n            url = text.extr(chapter, 'href=\"', '\"')\n            data[\"title\"] = text.unescape(text.extr(\n                chapter, 'text-gray-500\">', \"</div>\"))\n            chapter = text.extr(\n                chapter, 'font-bold\">', \"</div>\").rpartition(\" Chapter \")[2]\n            chapter, sep, minor = chapter.partition(\".\")\n            data[\"chapter\"] = text.parse_int(chapter)\n            data[\"chapter_minor\"] = sep + minor\n            results.append((self.root + url, data.copy()))\n        return results\n"
  },
  {
    "path": "gallery_dl/extractor/telegraph.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractor for https://telegra.ph/\"\"\"\n\nfrom .common import GalleryExtractor\nfrom .. import text\n\n\nclass TelegraphGalleryExtractor(GalleryExtractor):\n    \"\"\"Extractor for articles from telegra.ph\"\"\"\n    category = \"telegraph\"\n    root = \"https://telegra.ph\"\n    directory_fmt = (\"{category}\", \"{slug}\")\n    filename_fmt = \"{num_formatted}_{filename}.{extension}\"\n    archive_fmt = \"{slug}_{num}\"\n    pattern = r\"(?:https?://)(?:www\\.)??telegra\\.ph(/[^/?#]+)\"\n    example = \"https://telegra.ph/TITLE\"\n\n    def metadata(self, page):\n        extr = text.extract_from(page)\n        data = {\n            \"title\": text.unescape(extr(\n                'property=\"og:title\" content=\"', '\"')),\n            \"description\": text.unescape(extr(\n                'property=\"og:description\" content=\"', '\"')),\n            \"date\": self.parse_datetime_iso(extr(\n                'property=\"article:published_time\" content=\"', '\"')),\n            \"author\": text.unescape(extr(\n                'property=\"article:author\" content=\"', '\"')),\n            \"post_url\": text.unescape(extr(\n                'rel=\"canonical\" href=\"', '\"')),\n        }\n        data[\"slug\"] = data[\"post_url\"][19:]\n        return data\n\n    def images(self, page):\n        figures = (tuple(text.extract_iter(page, \"<figure>\", \"</figure>\")) or\n                   tuple(text.extract_iter(page, \"<img\", \">\")))\n        num_zeroes = len(str(len(figures)))\n        num = 0\n\n        results = []\n        for figure in figures:\n            url, pos = text.extract(figure, 'src=\"', '\"')\n            if url.startswith(\"/embed/\"):\n                continue\n            elif url[0] == \"/\":\n                url = self.root + url\n            caption, pos = text.extract(figure, \"<figcaption>\", \"<\", pos)\n            num += 1\n\n            results.append((url, {\n                \"url\"          : url,\n                \"caption\"      : text.unescape(caption) if caption else \"\",\n                \"num\"          : num,\n                \"num_formatted\": str(num).zfill(num_zeroes),\n            }))\n        return results\n"
  },
  {
    "path": "gallery_dl/extractor/tenor.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2025 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://tenor.com/\"\"\"\n\nfrom .common import Extractor, Message\nfrom .. import text, util\n\nBASE_PATTERN = r\"(?:https?://)?tenor\\.com/(?:\\w\\w(?:-\\w\\w)?/)?\"\n\n\nclass TenorExtractor(Extractor):\n    \"\"\"Base class for tenor extractors\"\"\"\n    category = \"tenor\"\n    root = \"https://tenor.com\"\n    filename_fmt = \"{id}{title:? //}.{extension}\"\n    archive_fmt = \"{id}\"\n    request_interval = (0.5, 1.5)\n\n    def _init(self):\n        formats = self.config(\"format\")\n        if formats is None:\n            self.formats = (\"gif\", \"mp4\", \"webm\", \"webp\")\n        else:\n            if isinstance(formats, str):\n                formats = formats.split(\",\")\n            self.formats = formats\n\n    def items(self):\n        for gif in self.gifs():\n\n            if not (fmt := self._extract_format(gif)):\n                self.log.warning(\"%s: Selected format(s) not available\",\n                                 gif.get(\"id\"))\n                continue\n\n            url = fmt[\"url\"]\n            title = gif.pop(\"h1_title\", \"\")\n            gif[\"title\"] = title[:-4] if title.endswith(\" GIF\") else title\n            gif[\"width\"], gif[\"height\"] = fmt.pop(\"dims\") or (0, 0)\n            gif[\"description\"] = gif.pop(\"content_description\", \"\")\n            gif[\"id_format\"] = url.rsplit(\"/\", 2)[1]\n            gif[\"format\"] = fmt[\"name\"]\n            gif[\"duration\"] = fmt[\"duration\"]\n            gif[\"size\"] = fmt[\"size\"]\n            gif[\"date\"] = self.parse_timestamp(gif[\"created\"])\n\n            yield Message.Directory, \"\", gif\n            yield Message.Url, url, text.nameext_from_url(url, gif)\n\n    def _extract_format(self, gif):\n        media_formats = gif[\"media_formats\"]\n        for fmt in self.formats:\n            if fmt in media_formats:\n                media = media_formats[fmt]\n                media[\"name\"] = fmt\n                return media\n\n    def _search_results(self, query):\n        url = \"https://tenor.googleapis.com/v2/search\"\n        params = {\n            \"appversion\": \"browser-r20250225-1\",\n            \"prettyPrint\": \"false\",\n            \"key\": \"AIzaSyC-P6_qz3FzCoXGLk6tgitZo4jEJ5mLzD8\",\n            \"client_key\": \"tenor_web\",\n            \"locale\": \"en\",\n            \"anon_id\": \"\",\n            \"q\": query,\n            \"limit\": \"50\",\n            \"contentfilter\": \"low\",\n            \"media_filter\": \"gif,gif_transparent,mediumgif,tinygif,\"\n                            \"tinygif_transparent,webp,webp_transparent,\"\n                            \"tinywebp,tinywebp_transparent,tinymp4,mp4,webm,\"\n                            \"originalgif,gifpreview\",\n            \"fields\": \"next,results.id,results.media_formats,results.title,\"\n                      \"results.h1_title,results.long_title,results.itemurl,\"\n                      \"results.url,results.created,results.user,\"\n                      \"results.shares,results.embed,results.hasaudio,\"\n                      \"results.policy_status,results.source_id,results.flags,\"\n                      \"results.tags,results.content_rating,results.bg_color,\"\n                      \"results.legacy_info,results.geographic_restriction,\"\n                      \"results.content_description\",\n            \"pos\": None,\n            \"component\": \"web_desktop\",\n        }\n        headers = {\n            \"Referer\": self.root + \"/\",\n            \"Origin\" : self.root,\n        }\n\n        while True:\n            data = self.request_json(url, params=params, headers=headers)\n\n            yield from data[\"results\"]\n\n            params[\"pos\"] = data.get(\"next\")\n            if not params[\"pos\"]:\n                return\n\n    def metadata(self):\n        return False\n\n    def gifs(self):\n        return ()\n\n\nclass TenorImageExtractor(TenorExtractor):\n    subcategory = \"image\"\n    pattern = BASE_PATTERN + r\"view/(?:[^/?#]*-)?(\\d+)\"\n    example = \"https://tenor.com/view/SLUG-1234567890\"\n\n    def gifs(self):\n        url = f\"{self.root}/view/{self.groups[0]}\"\n        page = self.request(url).text\n        pos = page.index('id=\"store-cache\"')\n        data = util.json_loads(text.extract(page, \">\", \"</script>\", pos)[0])\n        return (data[\"gifs\"][\"byId\"].popitem()[1][\"results\"][0],)\n\n\nclass TenorSearchExtractor(TenorExtractor):\n    subcategory = \"search\"\n    directory_fmt = (\"{category}\", \"{search_tags}\")\n    pattern = BASE_PATTERN + r\"search/([^/?#]+)\"\n    example = \"https://tenor.com/search/QUERY\"\n\n    def gifs(self):\n        query = text.unquote(self.groups[0])\n        rest, _, last = query.rpartition(\"-\")\n        if last == \"gifs\":\n            query = rest\n        self.kwdict[\"search_tags\"] = search_tags = query.replace(\"-\", \" \")\n\n        return self._search_results(search_tags)\n\n\nclass TenorUserExtractor(TenorExtractor):\n    subcategory = \"user\"\n    directory_fmt = (\"{category}\", \"@{user[username]}\")\n    pattern = BASE_PATTERN + r\"(?:users|official)/([^/?#]+)\"\n    example = \"https://tenor.com/users/USER\"\n\n    def gifs(self):\n        return self._search_results(\"@\" + self.groups[0])\n"
  },
  {
    "path": "gallery_dl/extractor/thefap.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://thefap.net/\"\"\"\n\nfrom .common import Extractor, Message\nfrom .. import text\n\nBASE_PATTERN = r\"(?:https?://)?(?:www\\.)?thefap\\.net\"\n\n\nclass ThefapExtractor(Extractor):\n    \"\"\"Base class for thefap extractors\"\"\"\n    category = \"thefap\"\n    root = \"https://thefap.net\"\n    directory_fmt = (\"{category}\", \"{model_name} ({model_id})\")\n    filename_fmt = \"{model}_{num:>03}.{extension}\"\n    archive_fmt = \"{model_id}_{filename}\"\n\n    def _normalize_url(self, url):\n        if not url:\n            return \"\"\n        url = url.strip()\n        if \"?w=\" in url:\n            url = url[:url.rfind(\"?\")]\n        elif url.endswith(\":small\"):\n            url = url[:-6] + \":orig\"\n        if url.startswith(\"//\"):\n            url = \"https:\" + url\n        elif url.startswith(\"/\"):\n            url = self.root + url\n        return url\n\n\nclass ThefapPostExtractor(ThefapExtractor):\n    \"\"\"Extractor for individual thefap.net posts\"\"\"\n    subcategory = \"post\"\n    pattern = (BASE_PATTERN +\n               r\"(/([^/?#]+)-(\\d+)/([^/?#]+)/i(\\d+))\")\n    example = \"https://thefap.net/MODEL-12345/KIND/i12345\"\n\n    def items(self):\n        path, model, model_id, kind, post_id = self.groups\n\n        page = self.request(self.root + path).text\n        if \"Not Found\" in page:\n            raise self.exc.NotFoundError(\"post\")\n\n        if model_name := text.extr(page, \"<title>\", \" / \"):\n            model_name = text.unescape(model_name)\n        else:\n            model_name = text.unquote(model).replace(\".\", \" \")\n\n        data = {\n            \"model\"     : model,\n            \"model_id\"  : text.parse_int(model_id),\n            \"model_name\": model_name,\n            \"kind\"      : kind,\n            \"post_id\"   : text.parse_int(post_id),\n            \"_http_headers\": {\"Referer\": None},\n        }\n        yield Message.Directory, \"\", data\n\n        data[\"num\"] = 0\n        page = text.extract(\n            page, \"\\n</div>\", \"\\n<!---->\", page.index(\"</header>\"))[0]\n        for url in text.extract_iter(page, '<img src=\"', '\"'):\n            if url := self._normalize_url(url):\n                data[\"num\"] += 1\n                yield Message.Url, url, text.nameext_from_url(url, data)\n\n\nclass ThefapModelExtractor(ThefapExtractor):\n    \"\"\"Extractor for thefap.net model pages\"\"\"\n    subcategory = \"model\"\n    pattern = BASE_PATTERN + r\"/([^/?#]+)-(\\d+)\"\n    example = \"https://thefap.net/MODEL-12345/\"\n\n    def items(self):\n        model, model_id = self.groups\n\n        url = f\"{self.root}/{model}-{model_id}/\"\n        page = self.request(url).text\n\n        if 'id=\"content\"' not in page:\n            raise self.exc.NotFoundError(\"model\")\n\n        if model_name := text.extr(page, \"<h2\", \"</h2>\"):\n            model_name = text.unescape(model_name[model_name.find(\">\")+1:])\n        else:\n            model_name = text.unquote(model).replace(\".\", \" \")\n\n        data = {\n            \"model\"     : model,\n            \"model_id\"  : text.parse_int(model_id),\n            \"model_name\": model_name,\n            \"_http_headers\": {\"Referer\": None},\n        }\n        yield Message.Directory, \"\", data\n\n        base = f\"{self.root}/ajax/model/{model_id}/page-\"\n        headers = {\n            \"X-Requested-With\": \"XMLHttpRequest\",\n            \"Sec-Fetch-Dest\"  : \"empty\",\n            \"Sec-Fetch-Mode\"  : \"cors\",\n            \"Sec-Fetch-Site\"  : \"same-origin\",\n        }\n\n        page = text.extr(page, '<div id=\"content\"', '<div id=\"showmore\"')\n        imgs = text.extract_iter(page, 'data-src=\"', '\"')\n        pnum = 1\n        data[\"num\"] = 0\n\n        while True:\n            for url in imgs:\n                if url := self._normalize_url(url):\n                    data[\"num\"] += 1\n                    yield Message.Url, url, text.nameext_from_url(url, data)\n\n            pnum += 1\n            page = self.request(base + str(pnum), headers=headers).text\n            if not page:\n                break\n            imgs = text.extract_iter(page, '<img src=\"', '\"')\n"
  },
  {
    "path": "gallery_dl/extractor/thehentaiworld.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2025 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://thehentaiworld.com/\"\"\"\n\nfrom .common import Extractor, Message\nfrom .. import text, util\nimport collections\n\nBASE_PATTERN = r\"(?:https?://)?(?:www\\.)?thehentaiworld\\.com\"\n\n\nclass ThehentaiworldExtractor(Extractor):\n    \"\"\"Base class for thehentaiworld extractors\"\"\"\n    category = \"thehentaiworld\"\n    root = \"https://thehentaiworld.com\"\n    filename_fmt = \"{title} ({id}{num:?-//}).{extension}\"\n    archive_fmt = \"{id}_{num}\"\n    request_interval = (0.5, 1.5)\n\n    def items(self):\n        for url in self.posts():\n            try:\n                post = self._extract_post(url)\n            except Exception as exc:\n                self.status |= 1\n                self.log.warning(\"Failed to extract post %s (%s: %s)\",\n                                 url, exc.__class__.__name__, exc)\n                continue\n\n            if \"file_urls\" in post:\n                urls = post[\"file_urls\"]\n                post[\"count\"] = len(urls)\n                yield Message.Directory, \"\", post\n                for post[\"num\"], url in enumerate(urls, 1):\n                    text.nameext_from_url(url, post)\n                    yield Message.Url, url, post\n            else:\n                yield Message.Directory, \"\", post\n                url = post[\"file_url\"]\n                text.nameext_from_url(url, post)\n                yield Message.Url, url, post\n\n    def _extract_post(self, url):\n        extr = text.extract_from(self.request(url).text)\n\n        post = {\n            \"num\"     : 0,\n            \"count\"   : 1,\n            \"title\"   : text.unescape(extr(\"<title>\", \"<\").strip()),\n            \"id\"      : text.parse_int(extr(\" postid-\", \" \")),\n            \"slug\"    : extr(\" post-\", '\"'),\n            \"tags\"    : extr('id=\"tagsHead\">', \"</ul>\"),\n            \"date\"    : self.parse_datetime_iso(extr(\"<li>Posted: \", \"<\")),\n        }\n\n        if (c := url[27]) == \"v\":\n            post[\"type\"] = \"video\"\n            post[\"width\"] = post[\"height\"] = 0\n            post[\"votes\"] = text.parse_int(extr(\"(<strong>\", \"</strong>\"))\n            post[\"score\"] = text.parse_float(extr(\"<strong>\", \"<\"))\n            post[\"file_url\"] = extr('<source src=\"', '\"')\n        else:\n            post[\"type\"] = (\"animated\" if c == \"g\" else\n                            \"3d cgi\" if c == \"3\" else\n                            \"image\")\n            post[\"width\"] = text.parse_int(extr(\"<li>Size: \", \" \"))\n            post[\"height\"] = text.parse_int(extr(\"x \", \"<\"))\n            post[\"file_url\"] = extr('a href=\"', '\"')\n            post[\"votes\"] = text.parse_int(extr(\"(<strong>\", \"</strong>\"))\n            post[\"score\"] = text.parse_float(extr(\"<strong>\", \"<\"))\n\n            if doujin := extr('<a id=\"prev-page\"', \"</div></div><\"):\n                repl = text.re(r\"-220x\\d+\\.\").sub\n                post[\"file_urls\"] = [\n                    repl(\".\", url)\n                    for url in text.extract_iter(\n                        doujin, 'class=\"border\" src=\"', '\"')\n                ]\n\n        tags = collections.defaultdict(list)\n        pattern = text.re(r'<li><a class=\"([^\"]*)\" href=\"[^\"]*\">([^<]+)')\n        for tag_type, tag_name in pattern.findall(post[\"tags\"]):\n            tags[tag_type].append(tag_name)\n        post[\"tags\"] = tags_list = []\n        for key, value in tags.items():\n            tags_list.extend(value)\n            post[\"tags_\" + key if key else \"tags_general\"] = value\n\n        return post\n\n    def _pagination(self, endpoint):\n        base = self.root + endpoint\n        pnum = self.page_start\n\n        while True:\n            url = base if pnum < 2 else f\"{base}page/{pnum}/\"\n            page = self.request(url).text\n\n            yield from text.extract_iter(text.extr(\n                page, 'id=\"thumbContainer\"', \"<script\"), ' href=\"', '\"')\n\n            if 'class=\"next\"' not in page:\n                return\n            pnum += 1\n\n\nclass ThehentaiworldTagExtractor(ThehentaiworldExtractor):\n    subcategory = \"tag\"\n    per_page = 24\n    page_start = 1\n    post_start = 0\n    directory_fmt = (\"{category}\", \"{search_tags}\")\n    pattern = BASE_PATTERN + r\"/tag/([^/?#]+)\"\n    example = \"https://thehentaiworld.com/tag/TAG/\"\n\n    def posts(self):\n        self.kwdict[\"search_tags\"] = tag = self.groups[0]\n        return util.advance(self._pagination(f\"/tag/{tag}/\"), self.post_start)\n\n    def skip_files(self, num):\n        pages, posts = divmod(num, self.per_page)\n        self.page_start += pages\n        self.post_start += posts\n        return num\n\n\nclass ThehentaiworldPostExtractor(ThehentaiworldExtractor):\n    subcategory = \"post\"\n    pattern = (BASE_PATTERN +\n               r\"(/(?:video|(?:[\\w-]+-)?hentai-image)s/([^/?#]+))\")\n    example = \"https://thehentaiworld.com/hentai-images/SLUG/\"\n\n    def posts(self):\n        return (f\"{self.root}{self.groups[0]}/\",)\n"
  },
  {
    "path": "gallery_dl/extractor/tiktok.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://www.tiktok.com/\"\"\"\n\nfrom .common import Extractor, Message, Dispatch\nfrom .. import text, util, ytdl\nimport functools\nimport itertools\nimport binascii\nimport hashlib\nimport random\nimport time\n\nBASE_PATTERN = r\"(?:https?://)?(?:www\\.)?tiktokv?\\.com\"\nUSER_PATTERN = BASE_PATTERN + r\"/@([\\w_.-]+)\"\n\n\nclass TiktokExtractor(Extractor):\n    \"\"\"Base class for TikTok extractors\"\"\"\n    category = \"tiktok\"\n    directory_fmt = (\"{category}\", \"{user}\")\n    filename_fmt = (\n        \"{id}{num:?_//>02} {title[b:150]}{file_id:? [/]/}.{extension}\")\n    archive_fmt = \"{id}_{num}_{file_id}\"\n    root = \"https://www.tiktok.com\"\n    cookies_domain = \".tiktok.com\"\n    rehydration_data_cache = {}\n    rehydration_data_app_context_cache = {}\n\n    def _init(self):\n        self.photo = self.config(\"photos\", True)\n        self.audio = self.config(\"audio\", True)\n        self.video = self.config(\"videos\", True)\n        self.cover = self.config(\"covers\", False)\n        self.subtitles = self.config(\"subtitles\", False)\n\n        self.range = self.config(\"tiktok-range\") or \"\"\n        self.range_predicate = util.predicate_range_parse(self.range)\n\n        # If one of these fields is None, the filter for it is disabled.\n        # Therefore, if both fields are none, all subtitles are extracted.\n        self.subtitle_sources = None\n        self.subtitle_langs = None\n\n        if self.subtitles and self.subtitles != \"all\":\n            if self.subtitles is True or not isinstance(self.subtitles, str):\n                self.subtitles = \"ASR\"\n\n            known_sources = {\"ASR\", \"MT\", \"LC\"}\n            filters = set(self.subtitles.split(\",\"))\n            self.subtitle_sources = known_sources.intersection(filters) or None\n            self.subtitle_langs = filters.difference(known_sources) or None\n\n    def items(self):\n        for tiktok_url in self.posts():\n            try:\n                tiktok_url = self._sanitize_url(tiktok_url)\n\n                data = self._extract_rehydration_data(tiktok_url)\n                if \"webapp.video-detail\" not in data:\n                    # Only /video/ links result in the video-detail dict we\n                    # need. Try again using that form of link.\n                    tiktok_url = self._sanitize_url(\n                        data[\"seo.abtest\"][\"canonical\"])\n                    data = self._extract_rehydration_data(tiktok_url)\n                video_detail = data[\"webapp.video-detail\"]\n                if not self._check_status_code(\n                        video_detail, tiktok_url, \"post\"):\n                    continue\n                post = video_detail[\"itemInfo\"][\"itemStruct\"]\n\n                post[\"user\"] = \\\n                    (a := post.get(\"author\")) and a[\"uniqueId\"] or \"\"\n                post[\"date\"] = self.parse_timestamp(post[\"createTime\"])\n                post[\"post_type\"] = \"image\" if \"imagePost\" in post else \"video\"\n                original_title = title = post[\"desc\"]\n\n                yield Message.Directory, \"\", post\n                ytdl_media = False\n\n                if \"imagePost\" in post:\n                    if self.photo:\n                        if not original_title:\n                            title = f\"TikTok photo #{post['id']}\"\n                        img_list = post[\"imagePost\"][\"images\"]\n                        for i, img in enumerate(img_list, 1):\n                            url = img[\"imageURL\"][\"urlList\"][0]\n                            text.nameext_from_url(url, post)\n                            post.update({\n                                \"type\"   : \"image\",\n                                \"image\"  : img,\n                                \"title\"  : title,\n                                \"num\"    : i,\n                                \"file_id\": post[\"filename\"].partition(\"~\")[0],\n                                \"width\"  : img[\"imageWidth\"],\n                                \"height\" : img[\"imageHeight\"],\n                            })\n                            yield Message.Url, url, post\n\n                    if self.audio and \"music\" in post:\n                        if self.audio == \"ytdl\":\n                            ytdl_media = \"audio\"\n                        elif url := self._extract_audio(post):\n                            yield Message.Url, url, post\n\n                elif \"video\" in post:\n                    if self.video == \"ytdl\":\n                        ytdl_media = \"video\"\n                    elif self.video and (url := self._extract_video(post)):\n                        yield Message.Url, url, post\n                        del post[\"_fallback\"]\n\n                    if self.cover:\n                        for url in self._extract_covers(post, \"video\"):\n                            yield Message.Url, url, post\n                            if self.cover != \"all\":\n                                break\n\n                    if self.subtitles:\n                        for url in self._extract_subtitles(post, \"video\"):\n                            yield Message.Url, url, post\n\n                        # remove the subtitle related fields for the next item\n                        post.pop(\"subtitle_lang_id\", None)\n                        post.pop(\"subtitle_lang_codename\", None)\n                        post.pop(\"subtitle_format\", None)\n                        post.pop(\"subtitle_version\", None)\n                        post.pop(\"subtitle_source\", None)\n                else:\n                    self.log.info(\"%s: Skipping post\", tiktok_url)\n\n                if ytdl_media:\n                    if not original_title:\n                        title = f\"TikTok {ytdl_media} #{post['id']}\"\n                    post.update({\n                        \"type\"      : ytdl_media,\n                        \"image\"     : None,\n                        \"filename\"  : \"\",\n                        \"extension\" :\n                        \"mp3\" if ytdl_media == \"audio\" else \"mp4\",\n                        \"title\"     : title,\n                        \"num\"       : 0,\n                        \"file_id\"   : \"\",\n                        \"width\"     : 0,\n                        \"height\"    : 0,\n                    })\n                    yield Message.Url, \"ytdl:\" + tiktok_url, post\n            except Exception as exc:\n                self.log.traceback(exc)\n                self.log.error(\"%s: Failed to extract post (%s: %s)\",\n                               tiktok_url, exc.__class__.__name__, exc)\n\n    def _sanitize_url(self, url):\n        return text.ensure_http_scheme(url.replace(\"/photo/\", \"/video/\", 1))\n\n    def _extract_rehydration_data(self, url, additional_keys=[], *,\n                                  has_keys=[]):\n        tries = 0\n        html = None\n        challenge_attempt = False\n        while True:\n            try:\n                response = self.request(url)\n                if response.history and \"/login\" in response.url:\n                    raise self.exc.AuthorizationError(\n                        \"HTTP redirect to login page \"\n                        f\"('{response.url.partition('?')[0]}')\")\n                html = response.text\n                data = text.extr(\n                    html, '<script id=\"__UNIVERSAL_DATA_FOR_REHYDRATION__\" '\n                    'type=\"application/json\">', '</script>')\n                data = util.json_loads(data)[\"__DEFAULT_SCOPE__\"]\n                for key in additional_keys:\n                    data = data[key]\n                for assert_key in has_keys:\n                    if assert_key not in data:\n                        raise KeyError(assert_key)\n                return data\n            except (ValueError, KeyError):\n                # Even if the retries option has been set to 0, we should\n                # always at least try to solve the JS challenge and go again\n                # immediately.\n                if not challenge_attempt:\n                    challenge_attempt = True\n                    self.log.info(\"Solving JavaScript challenge\")\n                    try:\n                        self._solve_challenge(html)\n                        html = None\n                        continue\n                    except Exception as exc:\n                        self.log.traceback(exc)\n                        self.log.warning(\n                            \"%s: Failed to solve JavaScript challenge. If you \"\n                            \"keep encountering this issue, please try again \"\n                            \"with the --write-pages option and include the \"\n                            \"resulting page in your bug report\",\n                            url.rpartition(\"/\")[2])\n\n                # We've already tried resolving the challenge, and either\n                # resolving it failed, or resolving it didn't get us the\n                # rehydration data, so fail this attempt.\n                self.log.warning(\"%s: Failed to retrieve rehydration data \"\n                                 \"(%s/%s)\", url.rpartition(\"/\")[2], tries + 1,\n                                 self._retries)\n                if tries >= self._retries:\n                    raise\n                tries += 1\n                self.sleep(self._timeout, \"retry\")\n                challenge_attempt = False\n                html = None\n\n    def _extract_rehydration_data_user(self, profile_url, additional_keys=()):\n        if profile_url in self.rehydration_data_cache:\n            data = self.rehydration_data_cache[profile_url]\n        else:\n            data = self._extract_rehydration_data(\n                profile_url,\n                has_keys=[\"webapp.user-detail\", \"webapp.app-context\"]\n            )\n            self.rehydration_data_cache[profile_url] = \\\n                data[\"webapp.user-detail\"]\n            self.rehydration_data_app_context_cache = \\\n                data[\"webapp.app-context\"]\n            data = data[\"webapp.user-detail\"]\n        if not self._check_status_code(data, profile_url, \"profile\"):\n            raise self.exc.ExtractionError(\n                f\"{profile_url}: could not extract rehydration data\")\n        try:\n            for key in additional_keys:\n                data = data[key]\n        except KeyError as exc:\n            self.log.traceback(exc)\n            raise self.exc.ExtractionError(\n                \"%s: could not extract rehydration data (%s)\",\n                profile_url, \", \".join(additional_keys))\n        return data\n\n    def _ensure_rehydration_data_app_context_cache_is_populated(self):\n        if not self.rehydration_data_app_context_cache:\n            self.rehydration_data_app_context_cache = \\\n                self._extract_rehydration_data(\n                    \"https://www.tiktok.com/\", [\"webapp.app-context\"])\n\n    def _solve_challenge(self, html):\n        cs = text.extr(text.extr(html, 'id=\"cs\"', '>'), 'class=\"', '\"')\n        c = util.json_loads(binascii.a2b_base64(cs + \"==\").decode())\n\n        # find index of expected digest\n        expected = binascii.a2b_base64(c[\"v\"][\"c\"] + \"==\")\n        base = hashlib.sha256(binascii.a2b_base64(c[\"v\"][\"a\"] + \"==\"))\n        for idx in range(1_000_000):\n            test = base.copy()\n            test.update(str(idx).encode())\n            if test.digest() == expected:\n                break\n        else:\n            raise self.exc.ExtractionError(\"failed to find matching digest\")\n\n        # extract cookie names\n        wci = text.extr(text.extr(html, 'id=\"wci\"', '>'), 'class=\"', '\"')\n        rci = text.extr(text.extr(html, 'id=\"rci\"', '>'), 'class=\"', '\"')\n        rs = text.extr(text.extr(html, 'id=\"rs\"', '>'), 'class=\"', '\"')\n\n        # set cookie values\n        domain = self.cookies_domain\n        expires = int(time.time()) + 5\n        c[\"d\"] = binascii.b2a_base64(str(idx).encode(), newline=False).decode()\n        v = binascii.b2a_base64(util.json_dumps(c).encode(), newline=False)\n        self.cookies.set(wci, v.decode(), domain=domain, expires=expires)\n        if rs:\n            self.cookies.set(rci, rs, domain=domain, expires=expires)\n\n    def _extract_sec_uid(self, profile_url, user_name):\n        sec_uid = self._extract_id(\n            profile_url, user_name, r\"MS4wLjABAAAA[\\w-]{32,64}\", \"secUid\")\n        if sec_uid is None:\n            raise self.exc.AbortExtraction(\n                f\"{user_name}: unable to extract secondary user ID\")\n        return sec_uid\n\n    def _extract_author_id(self, profile_url, user_name):\n        author_id = self._extract_id(\n            profile_url, user_name, r\"[0-9]+\", \"id\")\n        if author_id is None:\n            raise self.exc.AbortExtraction(\n                f\"{user_name}: unable to extract user ID\")\n        return author_id\n\n    def _extract_id(self, profile_url, user_name, regex, id_key):\n        match = text.re(regex).fullmatch\n\n        if match(user_name) is not None:\n            # If it was provided in the URL, then we can skip extracting it\n            # from the rehydration data.\n            return user_name\n\n        id = self._extract_rehydration_data_user(\n            profile_url, (\"userInfo\", \"user\", id_key))\n        return None if match(id) is None else id\n\n    def _extract_video(self, post):\n        video = post[\"video\"]\n        urls = self._extract_video_urls(video)\n        if not urls:\n            raise self.exc.ExtractionError(\n                f\"{post['id']}: Failed to extract video URLs. \"\n                f\"You may need cookies to continue.\")\n\n        url = urls[0]\n        text.nameext_from_url(url, post)\n        post.update({\n            \"_fallback\": urls[1:],\n            \"type\"     : \"video\",\n            \"image\"    : None,\n            \"title\"    : post[\"desc\"] or f\"TikTok video #{post['id']}\",\n            \"duration\" : video.get(\"duration\"),\n            \"num\"      : 0,\n            \"file_id\"  : \"\",\n            \"width\"    : video.get(\"width\"),\n            \"height\"   : video.get(\"height\"),\n        })\n        if not post[\"extension\"]:\n            post[\"extension\"] = video.get(\"format\", \"mp4\")\n        return url\n\n    def _extract_video_urls(self, video):\n        # First, look for bitrateInfo.\n        # This will include URLs pointing to the best quality videos.\n        if \"bitrateInfo\" in video:\n            bitrate_info = video[\"bitrateInfo\"]\n            if not isinstance(bitrate_info, list):\n                bitrate_info = [bitrate_info]\n            bitrate_urls = {}\n            for video_info in bitrate_info:\n                play_addr = video_info[\"PlayAddr\"]\n                width = text.parse_int(play_addr.get(\"Width\"))\n                height = text.parse_int(play_addr.get(\"Height\"))\n                size = width * height\n                if size in bitrate_urls:\n                    bitrate_urls[size] += play_addr.get(\"UrlList\")\n                else:\n                    bitrate_urls[size] = play_addr.get(\"UrlList\").copy()\n            # Sort the URLs by descending quality.\n            sizes = list(bitrate_urls)\n            sizes.sort(reverse=True)\n            urls = [url for size in sizes for url in bitrate_urls[size]]\n        else:\n            urls = []\n\n        # As a fallback, try to look for the root playAddr,\n        # which won't necessarily point to the best quality.\n        if \"playAddr\" in video:\n            urls.append(video[\"playAddr\"])\n\n        return urls\n\n    def _extract_audio(self, post):\n        audio = post[\"music\"]\n        url = audio[\"playUrl\"]\n        text.nameext_from_url(url, post)\n        post.update({\n            \"type\"     : \"audio\",\n            \"image\"    : None,\n            \"title\"    : post[\"desc\"] or f\"TikTok audio #{post['id']}\",\n            \"duration\" : audio.get(\"duration\"),\n            \"num\"      : 0,\n            \"file_id\"  : audio.get(\"id\"),\n            \"width\"    : 0,\n            \"height\"   : 0,\n        })\n        if not post[\"extension\"]:\n            post[\"extension\"] = \"mp3\"\n        return url\n\n    def _extract_covers(self, post, type):\n        media = post[type]\n\n        for cover_id in (\"thumbnail\", \"cover\", \"originCover\", \"dynamicCover\"):\n            if url := media.get(cover_id):\n                text.nameext_from_url(url, post)\n                post.update({\n                    \"type\"     : \"cover\",\n                    \"extension\": \"jpg\",\n                    \"image\"    : url,\n                    \"title\"    : post[\"desc\"] or\n                                 f\"TikTok {type} cover #{post['id']}\",\n                    \"duration\" : media.get(\"duration\"),\n                    \"num\"      : 0,\n                    \"file_id\"  : cover_id,\n                    \"width\"    : 0,\n                    \"height\"   : 0,\n                })\n                yield url\n\n    def _extract_subtitles(self, post, type):\n        media = post[type]\n        sources_filtered = self.subtitle_sources is not None\n        langs_filtered = self.subtitle_langs is not None\n\n        for subtitle in media.get(\"subtitleInfos\", ()):\n            sub_lang_id = subtitle.get(\"LanguageID\")\n            sub_lang_codename = subtitle.get(\"LanguageCodeName\")\n            sub_format = subtitle.get(\"Format\")\n            sub_version = subtitle.get(\"Version\")\n            sub_source = subtitle.get(\"Source\")\n\n            # guard the iterable access\n            sources_match = sources_filtered and \\\n                sub_source in self.subtitle_sources\n            langs_match = langs_filtered and \\\n                sub_lang_codename in self.subtitle_langs\n\n            # Subtitles will be extracted when either filter matches.\n            if not sources_match and not langs_match and \\\n                    (sources_filtered or langs_filtered):\n                continue\n\n            if url := subtitle.get(\"Url\"):\n                text.nameext_from_url(url, post)\n\n                # subtitle urls may not specify a filename,\n                # so the metadata can be used to build one.\n                if not post[\"filename\"]:\n                    post[\"filename\"] = (f\"{post['id']}_{sub_lang_codename}_\"\n                                        f\"{sub_version}_{sub_source}\")\n                    post[\"extension\"] = sub_format.lower()\n\n                    # replace extensions for known formats\n                    if post[\"extension\"] == \"webvtt\":\n                        post[\"extension\"] = \"vtt\"\n                    elif post[\"extension\"] == \"creator_caption\":\n                        post[\"extension\"] = \"json\"\n\n                post.update({\n                    \"type\"                  : \"subtitle\",\n                    \"image\"                 : None,\n                    \"title\"                 :\n                        post[\"desc\"] or\n                        f\"TikTok {type} subtitle #{post['id']}\",\n                    \"duration\"              : media.get(\"duration\"),\n                    \"num\"                   : 0,\n                    \"file_id\"               :\n                        f\"{sub_lang_id}_{sub_lang_codename}_{sub_source}_\"\n                        f\"{sub_version}_{sub_format}\",\n                    \"subtitle_lang_id\"      : sub_lang_id,\n                    \"subtitle_lang_codename\": sub_lang_codename,\n                    \"subtitle_format\"       : sub_format,\n                    \"subtitle_version\"      : sub_version,\n                    \"subtitle_source\"       : sub_source,\n                    \"width\"                 : 0,\n                    \"height\"                : 0,\n                })\n                yield url\n\n    def _check_status_code(self, detail, url, type_of_url):\n        status = detail.get(\"statusCode\")\n        if not status:\n            return True\n\n        if status == 10222:\n            # Video count workaround ported from yt-dlp: sometimes TikTok\n            # reports a profile as private even though we have the cookies to\n            # access it. We know that we can access it if we can see the\n            # videos stats. If we can't, we assume that we don't have access\n            # to the profile.\n            # We only care about this workaround for webapp.user-detail\n            # objects, so always fail the workaround for e.g.\n            # webapp.video-detail objects.\n            video_count = self._extract_video_count_from_user_detail(detail)\n            if video_count is None:\n                self.log.error(\"%s: Login required to access this %s\", url,\n                               type_of_url)\n            elif video_count > 0:\n                return True\n            else:\n                self.log.error(\"%s: Login required to access this %s, or this \"\n                               \"profile has no videos posted\", url,\n                               type_of_url)\n        elif status == 10221:\n            self.log.error(\"%s: User account could not be found\", url)\n        elif status == 10204:\n            self.log.error(\"%s: Requested %s not available\", url, type_of_url)\n        elif status == 10231:\n            self.log.error(\"%s: Region locked - Try downloading with a \"\n                           \"VPN/proxy connection\", url)\n        else:\n            self.log.error(\n                \"%s: Received unknown error code %s ('%s')\",\n                url, status, detail.get(\"statusMsg\") or \"\")\n        return False\n\n    def _extract_video_count_from_user_detail(self, detail):\n        user_info = detail.get(\"userInfo\")\n        if not user_info:\n            return None\n        stats = user_info.get(\"stats\") or user_info.get(\"statsV2\")\n        try:\n            # stats.videoCount is an int, but statsV2.videoCount is a\n            # string, so we must explicitly convert the attribute.\n            return int(stats[\"videoCount\"])\n        except (KeyError, ValueError):\n            return None\n\n\nclass TiktokPostExtractor(TiktokExtractor):\n    \"\"\"Extract a single video or photo TikTok link\"\"\"\n    subcategory = \"post\"\n    pattern = BASE_PATTERN + r\"/(?:@([\\w_.-]*)|share)/(?:phot|vide)o/(\\d+)\"\n    example = \"https://www.tiktok.com/@USER/photo/1234567890\"\n\n    def posts(self):\n        user, post_id = self.groups\n        url = f\"{self.root}/@{user or ''}/video/{post_id}\"\n        return {url: None}\n\n\nclass TiktokVmpostExtractor(TiktokExtractor):\n    \"\"\"Extract a single video or photo TikTok VM link\"\"\"\n    subcategory = \"vmpost\"\n    pattern = (r\"(?:https?://)?(?:\"\n               r\"(?:v[mt]\\.)?tiktok\\.com|(?:www\\.)?tiktok\\.com/t\"\n               r\")/(?!@)([^/?#]+)\")\n    example = \"https://vm.tiktok.com/1a2B3c4E5\"\n\n    def items(self):\n        url = text.ensure_http_scheme(self.url)\n        headers = {\"User-Agent\": \"facebookexternalhit/1.1\"}\n\n        url = self.request_location(url, headers=headers, notfound=\"post\")\n        if not url or len(url) <= 28:\n            # https://www.tiktok.com/?_r=1\n            raise self.exc.NotFoundError(\"post\")\n\n        data = {\"_extractor\": TiktokPostExtractor}\n        yield Message.Queue, url.partition(\"?\")[0], data\n\n\nclass TiktokUserExtractor(Dispatch, TiktokExtractor):\n    \"\"\"Extractor for a TikTok user profile\"\"\"\n    pattern = USER_PATTERN + r\"/?(?:$|\\?|#)\"\n    example = \"https://www.tiktok.com/@USER\"\n\n    def items(self):\n        base = f\"{self.root}/@{self.groups[0]}/\"\n        return self._dispatch_extractors((\n            (TiktokAvatarExtractor , base + \"avatar\"),\n            (TiktokPostsExtractor  , base + \"posts\"),\n            (TiktokRepostsExtractor, base + \"reposts\"),\n            (TiktokStoriesExtractor, base + \"stories\"),\n            (TiktokLikesExtractor  , base + \"likes\"),\n            (TiktokSavedExtractor  , base + \"saved\"),\n        ), (\"avatar\", \"posts\"))\n\n\nclass TiktokAvatarExtractor(TiktokExtractor):\n    subcategory = \"avatar\"\n    pattern = USER_PATTERN + r\"/avatar\"\n    example = \"https://www.tiktok.com/@USER/avatar\"\n\n    def items(self):\n        user_name = self.groups[0]\n        profile_url = f\"{self.root}/@{user_name}\"\n\n        data = self._extract_rehydration_data_user(\n            profile_url, (\"userInfo\", \"user\"))\n        data[\"user\"] = data.get(\"uniqueId\", user_name)\n        avatar_url = data.get(\"avatarLarger\") or data.get(\"avatarMedium\") \\\n            or data[\"avatarThumb\"]\n        avatar = text.nameext_from_url(avatar_url, data.copy())\n        avatar.update({\n            \"type\"   : \"avatar\",\n            \"title\"  : \"@\" + data[\"user\"],\n            \"id\"     : data[\"id\"],\n            \"file_id\": avatar[\"filename\"].partition(\"~\")[0],\n            \"num\"    : 0,\n        })\n\n        yield Message.Directory, \"\", avatar\n        yield Message.Url, avatar_url, avatar\n\n\nclass TiktokPostsExtractor(TiktokExtractor):\n    subcategory = \"posts\"\n    pattern = USER_PATTERN + r\"/posts\"\n    example = \"https://www.tiktok.com/@USER/posts\"\n\n    def posts(self):\n        user_name = self.groups[0]\n        profile_url = f\"{self.root}/@{user_name}\"\n        self.user_provided_cookies = bool(self.cookies)\n\n        # If set to \"ytdl\", we shall first go via yt-dlp. If that fails,\n        # we shall attempt to extract directly.\n        if self.config(\"ytdl\", False):\n            if posts := self._extract_posts_ytdl(profile_url):\n                return posts\n            ytdl = True\n            self.log.warning(\"Could not extract TikTok user \"\n                             f\"{user_name} via yt-dlp or youtube-dl, \"\n                             \"attempting the extraction directly\")\n        else:\n            ytdl = False\n\n        if posts := self._extract_posts_api(profile_url, user_name):\n            return posts\n\n        message = \"Could not extract any posts from TikTok user \" \\\n                  f\"{user_name}\"\n        if not ytdl:\n            message += \", try extracting post information using \" \\\n                       \"yt-dlp with the -o ytdl=true argument\"\n        self.log.warning(message)\n        return ()\n\n    def _extract_posts_ytdl(self, profile_url):\n        try:\n            module = ytdl.import_module(self.config(\"module\"))\n        except (ImportError, SyntaxError) as exc:\n            self.log.error(\"Cannot import module '%s'\",\n                           getattr(exc, \"name\", \"\"))\n            self.log.traceback(exc)\n            return []\n\n        extr_opts = {\n            \"extract_flat\"           : True,\n            \"ignore_no_formats_error\": True,\n        }\n        user_opts = {\n            \"retries\"                : self._retries,\n            \"socket_timeout\"         : self._timeout,\n            \"nocheckcertificate\"     : not self._verify,\n            \"playlist_items\"         : str(self.range),\n        }\n        if self._proxies:\n            user_opts[\"proxy\"] = self._proxies.get(\"http\")\n\n        ytdl_instance = ytdl.construct_YoutubeDL(\n            module, self, user_opts, extr_opts)\n\n        # Transfer cookies to ytdl.\n        if self.cookies:\n            set_cookie = ytdl_instance.cookiejar.set_cookie\n            for cookie in self.cookies:\n                set_cookie(cookie)\n\n        with ytdl_instance as ydl:\n            info_dict = ydl._YoutubeDL__extract_info(\n                profile_url, ydl.get_info_extractor(\"TikTokUser\"),\n                False, {}, True)\n            # This should be a list of video and photo post URLs in /video/\n            # format.\n            return [video[\"url\"].partition(\"?\")[0]\n                    for video in info_dict[\"entries\"]]\n\n    def _extract_posts_api(self, profile_url, user_name):\n        self.post_order = self.config(\"order-posts\") or \"desc\"\n        if self.post_order not in [\"desc\", \"asc\", \"reverse\", \"popular\"]:\n            self.post_order = \"desc\"\n        sec_uid = self._extract_sec_uid(profile_url, user_name)\n\n        # If descending order is requested, opt for the more reliable legacy\n        # endpoint instead of trying with the \"newer\", flakier endpoint.\n        if self.post_order == \"desc\":\n            return self._extract_posts_api_legacy(\n                profile_url, sec_uid, self.range_predicate)\n\n        if not self.user_provided_cookies:\n            self.log.warning(\n                \"%s: no cookies have been provided so the order-posts \"\n                \"option will not take effect. You must provide cookies in \"\n                \"order to extract a profile's posts in non-descending \"\n                \"order\",\n                profile_url\n            )\n            return self._extract_posts_api_legacy(\n                profile_url, sec_uid, self.range_predicate)\n\n        try:\n            urls = self._extract_posts_api_order(\n                profile_url, sec_uid, self.range_predicate)\n            if urls:\n                return urls\n        except Exception as exc:\n            self.log.traceback(exc)\n\n        self.log.error(\n            \"%s: failed to extract user posts using post/item_list (make sure \"\n            \"you provide valid cookies). Attempting with legacy \"\n            \"creator/item_list endpoint that does not support post ordering\",\n            profile_url\n        )\n        return self._extract_posts_api_legacy(\n            profile_url, sec_uid, self.range_predicate)\n\n    def _extract_posts_api_order(self, profile_url, sec_uid, range_predicate):\n        post_item_list_request_type = \"0\"\n        if self.post_order in [\"asc\", \"reverse\"]:\n            post_item_list_request_type = \"2\"\n        elif self.post_order in [\"popular\"]:\n            post_item_list_request_type = \"1\"\n        query_parameters = {\n            \"secUid\": sec_uid,\n            \"post_item_list_request_type\": post_item_list_request_type,\n            \"count\": \"15\",\n            \"needPinnedItemIds\": \"false\",\n        }\n        request = TiktokPostItemListRequest(range_predicate)\n        if not request.execute(self, profile_url, query_parameters):\n            return []\n        return request.generate_urls(profile_url, self.video, self.photo,\n                                     self.audio)\n\n    def _extract_posts_api_legacy(self, profile_url, sec_uid, range_predicate):\n        query_parameters = {\n            \"secUid\": sec_uid,\n            \"type\": \"1\",\n            \"count\": \"15\",\n        }\n        request = TiktokCreatorItemListRequest(range_predicate)\n        request.execute(self, profile_url, query_parameters)\n        return request.generate_urls(profile_url, self.video, self.photo,\n                                     self.audio)\n\n\nclass TiktokRepostsExtractor(TiktokExtractor):\n    subcategory = \"reposts\"\n    pattern = USER_PATTERN + r\"/reposts\"\n    example = \"https://www.tiktok.com/@USER/reposts\"\n\n    def posts(self):\n        user_name = self.groups[0]\n        profile_url = f\"{self.root}/@{user_name}\"\n\n        query_parameters = {\n            \"secUid\": self._extract_sec_uid(profile_url, user_name),\n            \"post_item_list_request_type\": \"0\",\n            \"needPinnedItemIds\": \"false\",\n            \"count\": \"15\",\n        }\n        request = TiktokRepostItemListRequest(self.range_predicate)\n        request.execute(self, profile_url, query_parameters)\n        return request.generate_urls(profile_url, self.video, self.photo,\n                                     self.audio)\n\n\nclass TiktokStoriesExtractor(TiktokExtractor):\n    subcategory = \"stories\"\n    pattern = USER_PATTERN + r\"/stories\"\n    example = \"https://www.tiktok.com/@USER/stories\"\n\n    def posts(self):\n        user_name = self.groups[0]\n        profile_url = f\"{self.root}/@{user_name}\"\n\n        query_parameters = {\n            \"authorId\": self._extract_author_id(profile_url, user_name),\n            \"loadBackward\": \"false\",\n            \"count\": \"5\",\n        }\n        request = TiktokStoryItemListRequest()\n        request.execute(self, profile_url, query_parameters)\n        return request.generate_urls(profile_url, self.video, self.photo,\n                                     self.audio)\n\n\nclass TiktokLikesExtractor(TiktokExtractor):\n    subcategory = \"likes\"\n    pattern = USER_PATTERN + r\"/like[sd]\"\n    example = \"https://www.tiktok.com/@USER/liked\"\n\n    def posts(self):\n        user_name = self.groups[0]\n        profile_url = f\"{self.root}/@{user_name}\"\n\n        query_parameters = {\n            \"secUid\": self._extract_sec_uid(profile_url, user_name),\n            \"post_item_list_request_type\": \"0\",\n            \"needPinnedItemIds\": \"false\",\n            \"count\": \"15\",\n        }\n        request = TiktokFavoriteItemListRequest(self.range_predicate)\n        request.execute(self, profile_url, query_parameters)\n        return request.generate_urls(profile_url, self.video, self.photo,\n                                     self.audio)\n\n\nclass TiktokSavedExtractor(TiktokExtractor):\n    subcategory = \"saved\"\n    pattern = USER_PATTERN + r\"/saved\"\n    example = \"https://www.tiktok.com/@USER/saved\"\n\n    def posts(self):\n        user_name = self.groups[0]\n        profile_url = f\"{self.root}/@{user_name}\"\n\n        query_parameters = {\n            \"secUid\": self._extract_sec_uid(profile_url, user_name),\n            \"post_item_list_request_type\": \"0\",\n            \"needPinnedItemIds\": \"false\",\n            \"count\": \"15\",\n        }\n        request = TiktokSavedPostItemListRequest(self.range_predicate)\n        request.execute(self, profile_url, query_parameters)\n        return request.generate_urls(profile_url, self.video, self.photo,\n                                     self.audio)\n\n\nclass TiktokFollowingExtractor(TiktokExtractor):\n    \"\"\"Extract all of the stories of all of the users you follow\"\"\"\n    subcategory = \"following\"\n    pattern = rf\"{BASE_PATTERN}/following\"\n    example = \"https://www.tiktok.com/following\"\n\n    def posts(self):\n        \"\"\"Attempt to extract all of the stories of all of the accounts\n        the user follows\"\"\"\n\n        query_parameters = {\n            \"storyFeedScene\": \"3\",\n            \"count\": \"15\",\n        }\n        request = TiktokStoryUserListRequest()\n        if not request.execute(self, self.url, query_parameters):\n            self.log.error(\"%s: could not extract follower list, make sure \"\n                           \"you are using logged-in cookies\", self.url)\n        users = request.generate_urls()\n        if len(users) == 0:\n            self.log.warning(\"%s: No followers with stories could be \"\n                             \"extracted\", self.url)\n\n        entries = {}\n        # Batch all of the users up into groups of at most ten and use the\n        # batch endpoint to improve performance. The response to the story user\n        # list request may also include the user themselves, so skip them if\n        # they ever turn up.\n        for b in range((len(users) - 1) // 10 + 1):\n            batch_number = b + 1\n            user_batch = users[b*10:batch_number*10]\n\n            # Handle edge case where final batch is composed of a single user\n            # and that user is the one we need to skip. If we don't handle this\n            # here (or when we generate the author ID list later), we will\n            # trigger an AssertionError for an empty author ID list.\n            if len(user_batch) == 1:\n                if self._is_current_user(user_batch[0][0]):\n                    continue\n\n            self.log.info(\"TikTok user stories, batch %d: %s\", batch_number,\n                          \", \".join([profile_url for user_id, profile_url in\n                                     user_batch if not self._is_current_user(\n                                         user_id)]))\n\n            # Since we've already extracted all of the author IDs, we should be\n            # able to avoid having to request rehydration data (except for one\n            # time, since it's required to make _is_current_user() work), but\n            # we should keep this mechanism in place for safety.\n            author_ids = [self._extract_author_id(profile_url, user_id)\n                          for user_id, profile_url in user_batch\n                          if not self._is_current_user(user_id)]\n            query_parameters = {\n                \"authorIds\": \",\".join(author_ids),\n                \"storyCallScene\": \"2\",\n            }\n            request = TiktokStoryBatchItemListRequest()\n            request.execute(self, f\"Batch {batch_number}\", query_parameters)\n            # We technically don't need to have the correct user name in the\n            # URL and it's easier to just ignore it here.\n            entries.update(request.generate_urls(\"https://www.tiktok.com/@_\",\n                                                 self.video, self.photo,\n                                                 self.audio))\n\n        return entries\n\n    def _is_current_user(self, user_id):\n        self._ensure_rehydration_data_app_context_cache_is_populated()\n        if \"user\" not in self.rehydration_data_app_context_cache:\n            return False\n        if \"uid\" not in self.rehydration_data_app_context_cache[\"user\"]:\n            return False\n        return self.rehydration_data_app_context_cache[\"user\"][\"uid\"] == \\\n            user_id\n\n\nclass TiktokPaginationCursor:\n    def current_page(self):\n        \"\"\"Must return the page the cursor is currently pointing to.\n\n        Returns\n        -------\n        int\n            The current value of the cursor.\n        \"\"\"\n\n        return 0\n\n    def next_page(self, data, query_parameters):\n        \"\"\"Must progress the cursor to the next page.\n\n        Parameters\n        ----------\n        data : dict\n            The response of the most recent request.\n        query_parameters : dict\n            All of the query parameters used for the most recent\n            request.\n\n        Returns\n        -------\n        bool\n            True if the cursor detects that we've reached the end, False\n            otherwise.\n        \"\"\"\n\n        return True\n\n\nclass TiktokTimeCursor(TiktokPaginationCursor):\n    def __init__(self, *, reverse=True, has_more_attribute=\"hasMore\",\n                 cursor_attribute=\"cursor\"):\n        super().__init__()\n        self.cursor = 0\n        # If we expect the cursor to go up or down as we go to the next page.\n        # True for down, False for up.\n        self.reverse = reverse\n        self.has_more_key = has_more_attribute\n        self.cursor_key = cursor_attribute\n\n    def current_page(self):\n        return self.cursor\n\n    def next_page(self, data, query_parameters):\n        skip_fallback_logic = self.cursor == 0\n        new_cursor = int(data.get(self.cursor_key, 0))\n        no_cursor = not new_cursor\n        if not skip_fallback_logic:\n            # If the new cursor doesn't go in the direction we expect, use the\n            # fallback logic instead.\n            if self.reverse and (new_cursor > self.cursor or no_cursor):\n                new_cursor = self.fallback_cursor(data)\n            elif not self.reverse and (new_cursor < self.cursor or no_cursor):\n                new_cursor = self.fallback_cursor(data)\n        elif no_cursor:\n            raise self.exc.ExtractionError(\"Could not extract next cursor\")\n        self.cursor = new_cursor\n        return not data.get(self.has_more_key, False)\n\n    def fallback_cursor(self, data):\n        try:\n            return int(data[\"itemList\"][-1][\"createTime\"]) * 1000\n        except Exception:\n            return 7 * 86_400_000 * (-1 if self.reverse else 1)\n\n\nclass TiktokForwardTimeCursor(TiktokTimeCursor):\n    def __init__(self):\n        super().__init__(reverse=False)\n\n\nclass TiktokBackwardTimeCursor(TiktokTimeCursor):\n    def __init__(self):\n        super().__init__(reverse=True)\n\n\nclass TiktokPopularTimeCursor(TiktokTimeCursor):\n    def __init__(self):\n        super().__init__(reverse=True)\n\n    def fallback_cursor(self, data):\n        # Don't really know what to do here, all I know is that the cursor\n        # for the popular item feed goes down and it does not appear to be\n        # based on item list timestamps at all.\n        return -50_000\n\n\nclass TiktokStoryTimeCursor(TiktokTimeCursor):\n    def __init__(self):\n        super().__init__(reverse=False, has_more_attribute=\"HasMoreAfter\",\n                         cursor_attribute=\"MaxCursor\")\n\n\nclass TiktokLegacyTimeCursor(TiktokPaginationCursor):\n    def __init__(self):\n        super().__init__()\n        self.cursor = int(time.time()) * 1000\n\n    def current_page(self):\n        return self.cursor\n\n    def next_page(self, data, query_parameters):\n        old_cursor = self.cursor\n        try:\n            self.cursor = int(data[\"itemList\"][-1][\"createTime\"]) * 1000\n        except Exception:\n            self.cursor = 0\n        if not self.cursor or old_cursor == self.cursor:\n            # User may not have posted within this ~1 week look back,\n            # so manually adjust the cursor.\n            self.cursor = old_cursor - 7 * 86_400_000\n        # In case 'hasMorePrevious' is wrong, break if we have\n        # gone back before TikTok existed.\n        has_more_previous = data.get(\"hasMorePrevious\")\n        return self.cursor < 1472706000000 or not has_more_previous\n\n\nclass TiktokItemCursor(TiktokPaginationCursor):\n    def __init__(self, list_key: str = \"itemList\"):\n        super().__init__()\n        self.cursor = 0\n        self.list_key = list_key\n\n    def current_page(self):\n        return self.cursor\n\n    def next_page(self, data, query_parameters):\n        # We should offset the cursor by the number of items in the response.\n        # Sometimes less items are returned than what was requested in the\n        # count parameter! We could fall back onto the count query parameter\n        # but we could miss out on some posts.\n        self.cursor += len(data.get(self.list_key, ()))\n        if \"hasMore\" in data:\n            return not data[\"hasMore\"]\n        return not data.get(\"HasMoreAfter\", False)\n\n\nclass TiktokPaginationRequest:\n    def __init__(self, endpoint):\n        self.endpoint = endpoint\n        self._regenerate_device_id()\n        self.items = {}\n\n    def execute(self, extractor, url, query_parameters):\n        \"\"\"Performs requests until all pages have been retrieved.\n\n        The items retrieved from this request are stored in self.items.\n        Each call to execute() will clear the previous value of\n        self.items.\n\n        Usually extractors want a simple list of URLs. For this, each\n        request subtype is to implement generate_urls().\n\n        Parameters\n        ----------\n        extractor : TiktokExtractor\n            The TikTok extractor performing the request.\n        url : str\n            The URL associated with this request for logging purposes.\n        query_parameters : dict[str, str]\n            The query parameters to apply to this request.\n\n        Returns\n        -------\n        bool\n            True if the request was performed successfully and all items\n            were retrieved, False if no items or only some items could\n            be retrieved.\n        \"\"\"\n\n        self.validate_query_parameters(query_parameters)\n        self.items = {}\n        cursor_type = self.cursor_type(query_parameters)\n        cursor = cursor_type() if cursor_type else None\n        for page in itertools.count(start=1):\n            item_count = len(self.items)\n            extractor.log.info(\"%s: retrieving %s page %d (%d item%s)\", url,\n                               self.endpoint, page, item_count,\n                               \"\" if item_count == 1 else \"s\")\n            tries = 0\n            while True:\n                try:\n                    data, final_parameters = self._request_data(\n                        extractor,\n                        cursor,\n                        query_parameters\n                    )\n                    incoming_items = self.extract_items(data)\n                    self._detect_duplicate_pages(extractor, url,\n                                                 set(self.items.keys()),\n                                                 set(incoming_items.keys()))\n                    self.items.update(incoming_items)\n                    if cursor:\n                        final_page_reached = cursor.next_page(data,\n                                                              final_parameters)\n                        exit_early = self.exit_early(extractor, url)\n                        if exit_early or final_page_reached:\n                            return True\n                        # Continue to next page and reset tries counter.\n                        break\n                    else:\n                        # This request has no cursor: return immediately.\n                        return True\n                except Exception as exc:\n                    if tries >= extractor._retries:\n                        extractor.log.error(\"%s: failed to retrieve %s page \"\n                                            \"%d\", url, self.endpoint, page)\n                        extractor.log.traceback(exc)\n                        return False\n                    tries += 1\n                    extractor.log.warning(\"%s: failed to retrieve %s page %d\",\n                                          url, self.endpoint, page)\n                    extractor.sleep(extractor._timeout, \"retry\")\n\n    def validate_query_parameters(self, query_parameters):\n        \"\"\"Used to validate the given parameters for this type of\n        pagination request.\n\n        For developer purposes only. You should call\n        super().validate_query_parameters() for most requests as they\n        will usually have a count parameter.\n\n        Parameters\n        ----------\n        query_parameters : dict[str, str]\n            The query parameters to validate.\n\n        Raises\n        -------\n        AssertionError\n            If mandatory query parameters are not given, or they are\n            given in the wrong format.\n        \"\"\"\n\n        assert \"count\" in query_parameters\n        assert type(query_parameters[\"count\"]) is str\n        assert query_parameters[\"count\"].isdigit()\n        assert query_parameters[\"count\"] != \"0\"\n\n    def cursor_type(self, query_parameters):\n        \"\"\"Used to determine which type of cursor to use for this\n        request, if any.\n\n        Parameters\n        ----------\n        query_parameters : dict[str, str]\n            The query parameters given to the execute() call.\n\n        Returns\n        -------\n        Type[TiktokPaginationCursor] | None\n            The type of cursor to use, if any.\n        \"\"\"\n\n        return None\n\n    def extract_items(self, data):\n        \"\"\"Used to extract data from the response of a request.\n\n        Parameters\n        ----------\n        data : dict\n            The data given by TikTok.\n\n        Returns\n        -------\n        dict\n            Each item from the response data, keyed on a unique ID.\n\n        Raises\n        ------\n        Exception\n            If items could not be extracted.\n        \"\"\"\n\n        return {}\n\n    def exit_early(self, extractor, url):\n        \"\"\"Used to determine if we should exit early from the request.\n\n        You have access to the items extracted so far (self.items).\n\n        Parameters\n        ----------\n        extractor : TiktokExtractor\n            The extractor making the requests.\n        url : str\n            The URL associated with the executing request for logging\n            purposes.\n\n        Returns\n        -------\n        bool\n            True if we should exit early, False otherwise.\n        \"\"\"\n\n        return False\n\n    def generate_urls(self):\n        \"\"\"Used to convert the items retrieved from the request into a\n        list of URLs.\n\n        Returns\n        -------\n        dict\n            Ideally one URL for each item, that points to a video detail\n            object, although subclasses are permitted to return a list\n            or dict of any format they wish.\n        \"\"\"\n\n        return []\n\n    def _regenerate_device_id(self):\n        self.device_id = str(random.randint(\n            7_250_000_000_000_000_000, 7_325_099_899_999_994_577))\n\n    def _request_data(self, extractor, cursor, query_parameters):\n        # Implement simple 1 retry mechanism without delays that handles the\n        # flaky post/item_list endpoint.\n        retries = 0\n        while True:\n            try:\n                url, final_parameters = self._build_api_request_url(\n                    cursor,\n                    query_parameters\n                )\n                response = extractor.request(url)\n                return (util.json_loads(response.text), final_parameters)\n            except ValueError:\n                if retries == 1:\n                    raise\n                extractor.log.warning(\n                    \"Could not decode response for this page, trying again\"\n                )\n                retries += 1\n\n    def _build_api_request_url(self, cursor, extra_parameters):\n        query_parameters = {\n            \"aid\": \"1988\",\n            \"app_language\": \"en\",\n            \"app_name\": \"tiktok_web\",\n            \"browser_language\": \"en-US\",\n            \"browser_name\": \"Mozilla\",\n            \"browser_online\": \"true\",\n            \"browser_platform\": \"Win32\",\n            \"browser_version\": \"5.0 (Windows)\",\n            \"channel\": \"tiktok_web\",\n            \"cookie_enabled\": \"true\",\n            \"device_id\": self.device_id,\n            \"device_platform\": \"web_pc\",\n            \"focus_state\": \"true\",\n            \"from_page\": \"user\",\n            \"history_len\": \"2\",\n            \"is_fullscreen\": \"false\",\n            \"is_page_visible\": \"true\",\n            \"language\": \"en\",\n            \"os\": \"windows\",\n            \"priority_region\": \"\",\n            \"referer\": \"\",\n            \"region\": \"US\",\n            \"screen_height\": \"1080\",\n            \"screen_width\": \"1920\",\n            \"tz_name\": \"UTC\",\n            \"verifyFp\": \"verify_\" + \"\".join(random.choices(\n                \"0123456789abcdef\", k=7)),\n            \"webcast_language\": \"en\",\n        }\n        if cursor:\n            # We must not write this as a floating-point number:\n            query_parameters[\"cursor\"] = str(int(cursor.current_page()))\n        for key, value in extra_parameters.items():\n            query_parameters[key] = f\"{value}\"\n        query_str = text.build_query(query_parameters)\n        return (f\"https://www.tiktok.com/api/{self.endpoint}/?{query_str}\",\n                query_parameters)\n\n    def _detect_duplicate_pages(self, extractor, url, seen_ids, incoming_ids):\n        if incoming_ids and incoming_ids == seen_ids:\n            # TikTok API keeps sending the same page, likely due to\n            # a bad device ID. Generate a new one and try again.\n            self._regenerate_device_id()\n            extractor.log.warning(\"%s: TikTok API keeps sending the same \"\n                                  \"page. Taking measures to avoid an infinite \"\n                                  \"loop\", url)\n            raise self.exc.ExtractionError(\n                \"TikTok API keeps sending the same page\")\n\n\nclass TiktokItemListRequest(TiktokPaginationRequest):\n    def __init__(self, endpoint, type_of_items, range_predicate):\n        super().__init__(endpoint)\n        self.type_of_items = type_of_items\n        self.range_predicate = range_predicate\n        self.exit_early_due_to_no_items = False\n\n    def extract_items(self, data):\n        if \"itemList\" not in data:\n            if not data.get(\"hasMorePrevious\", data.get(\"hasMore\", False)):\n                self.exit_early_due_to_no_items = True\n            return {}\n        return {item[\"id\"]: item for item in data[\"itemList\"]}\n\n    def exit_early(self, extractor, url):\n        if self.exit_early_due_to_no_items:\n            extractor.log.warning(\"%s: could not extract any %s for this user\",\n                                  url, self.type_of_items)\n            return True\n        if not self.range_predicate:\n            # No range predicates given.\n            return False\n        # If our current selection of items can't satisfy the upper bound of\n        # the predicate, we must continue extracting them until we can.\n        return len(self.items) > max(r.stop for r in self.range_predicate) - 1\n\n    def generate_urls(self, profile_url, video, photo, audio):\n        urls = {}\n        for index, id in enumerate(self.items.keys()):\n            if not self._matches_filters(self.items.get(id), index + 1, video,\n                                         photo, audio):\n                continue\n            # Try to grab the author's unique ID, but don't cause the\n            # extraction to fail if we can't, it's not imperative that the\n            # URLs include the actual poster's unique ID.\n            try:\n                url = f\"https://www.tiktok.com/@\" \\\n                      f\"{self.items[id]['author']['uniqueId']}/video/{id}\"\n            except KeyError:\n                # Use the given profile URL as a back up.\n                url = f\"{profile_url}/video/{id}\"\n            urls[url] = self.items.get(id)\n        return urls\n\n    def _matches_filters(self, item, index, video, photo, audio):\n        # First, check if this index falls within any of our configured ranges.\n        # If it doesn't, we filter it out.\n        if self.range_predicate:\n            range_match = False\n            for range in self.range_predicate:\n                if index in range:\n                    range_match = True\n                    break\n            if not range_match:\n                return False\n\n        # Then, we apply basic video/photo filtering.\n        if not item:\n            return True\n        is_image_post = \"imagePost\" in item\n        if not photo and not audio and is_image_post:\n            return False\n        if not video and not is_image_post:\n            return False\n        return True\n\n\nclass TiktokCreatorItemListRequest(TiktokItemListRequest):\n    \"\"\"A less flaky version of the post/item_list endpoint that doesn't\n    support latest/popular/oldest ordering.\"\"\"\n\n    def __init__(self, range_predicate):\n        super().__init__(\"creator/item_list\", \"posts\", range_predicate)\n\n    def validate_query_parameters(self, query_parameters):\n        super().validate_query_parameters(query_parameters)\n        assert \"secUid\" in query_parameters\n        assert \"type\" in query_parameters\n        # Pagination type: 0 == oldest-to-newest, 1 == newest-to-oldest.\n        # NOTE: ^ this type parameter doesn't seem to do what yt-dlp thinks it\n        #       does. post/item_list is the only way to get an ordered feed\n        #       based on latest/popular/oldest.\n        assert query_parameters[\"type\"] == \"0\" or \\\n            query_parameters[\"type\"] == \"1\"\n\n    def cursor_type(self, query_parameters):\n        return TiktokLegacyTimeCursor\n\n\nclass TiktokPostItemListRequest(TiktokItemListRequest):\n    \"\"\"Retrieves posts in latest/popular/oldest ordering.\n\n    Very often, this request will just return an empty response, making\n    it quite flaky, but the next attempt to make the request usually\n    does return a response. For this reason creator/item_list was kept\n    as a backup, though it doesn't seem to support ordering.\n\n    It also doesn't work without cookies.\n    \"\"\"\n\n    def __init__(self, range_predicate):\n        super().__init__(\"post/item_list\", \"posts\", range_predicate)\n\n    def validate_query_parameters(self, query_parameters):\n        super().validate_query_parameters(query_parameters)\n        assert \"secUid\" in query_parameters\n        assert \"post_item_list_request_type\" in query_parameters\n        # Pagination type:\n        # 0 == newest-to-oldest.\n        # 1 == popular.\n        # 2 == oldest-to-newest.\n        assert query_parameters[\"post_item_list_request_type\"] in \\\n            [\"0\", \"1\", \"2\"]\n        assert \"needPinnedItemIds\" in query_parameters\n        # If this value is set to \"true\", and \"post_item_list_request_type\" is\n        # set to \"0\", pinned posts will always show up first in the resulting\n        # itemList. It keeps our logic simpler if we avoid this behavior by\n        # setting this parameter to \"false\" (especially if we were to use a\n        # really small \"count\" value like \"1\" or \"2\").\n        assert query_parameters[\"needPinnedItemIds\"] in [\"false\"]\n\n    def cursor_type(self, query_parameters):\n        request_type = query_parameters[\"post_item_list_request_type\"]\n        if request_type == \"2\":\n            return TiktokForwardTimeCursor\n        elif request_type == \"1\":\n            return TiktokPopularTimeCursor\n        else:\n            return TiktokBackwardTimeCursor\n\n\nclass TiktokFavoriteItemListRequest(TiktokItemListRequest):\n    \"\"\"Retrieves a user's liked posts.\n\n    Appears to only support descending order, but it can work without\n    cookies.\n    \"\"\"\n\n    def __init__(self, range_predicate):\n        super().__init__(\"favorite/item_list\", \"liked posts\", range_predicate)\n\n    def validate_query_parameters(self, query_parameters):\n        super().validate_query_parameters(query_parameters)\n        assert \"secUid\" in query_parameters\n        assert \"post_item_list_request_type\" in query_parameters\n        assert query_parameters[\"post_item_list_request_type\"] == \"0\"\n        assert \"needPinnedItemIds\" in query_parameters\n        assert query_parameters[\"needPinnedItemIds\"] in [\"false\"]\n\n    def cursor_type(self, query_parameters):\n        return TiktokPopularTimeCursor\n\n\nclass TiktokRepostItemListRequest(TiktokItemListRequest):\n    \"\"\"Retrieves a user's reposts.\n\n    Appears to only support descending order, but it can work without\n    cookies.\n    \"\"\"\n\n    def __init__(self, range_predicate):\n        super().__init__(\"repost/item_list\", \"reposts\", range_predicate)\n\n    def validate_query_parameters(self, query_parameters):\n        super().validate_query_parameters(query_parameters)\n        assert \"secUid\" in query_parameters\n        assert \"post_item_list_request_type\" in query_parameters\n        assert query_parameters[\"post_item_list_request_type\"] == \"0\"\n        assert \"needPinnedItemIds\" in query_parameters\n        assert query_parameters[\"needPinnedItemIds\"] in [\"false\"]\n\n    def cursor_type(self, query_parameters):\n        return TiktokItemCursor\n\n\nclass TiktokSavedPostItemListRequest(TiktokItemListRequest):\n    \"\"\"Retrieves a user's saved posts.\n\n    Appears to only support descending order, but it can work without\n    cookies.\n    \"\"\"\n\n    def __init__(self, range_predicate):\n        super().__init__(\"user/collect/item_list\", \"saved posts\",\n                         range_predicate)\n\n    def validate_query_parameters(self, query_parameters):\n        super().validate_query_parameters(query_parameters)\n        assert \"secUid\" in query_parameters\n        assert \"post_item_list_request_type\" in query_parameters\n        assert query_parameters[\"post_item_list_request_type\"] == \"0\"\n        assert \"needPinnedItemIds\" in query_parameters\n        assert query_parameters[\"needPinnedItemIds\"] in [\"false\"]\n\n    def cursor_type(self, query_parameters):\n        return TiktokPopularTimeCursor\n\n\nclass TiktokStoryItemListRequest(TiktokItemListRequest):\n    def __init__(self):\n        super().__init__(\"story/item_list\", \"stories\", None)\n\n    def validate_query_parameters(self, query_parameters):\n        super().validate_query_parameters(query_parameters)\n        assert \"authorId\" in query_parameters\n        assert \"loadBackward\" in query_parameters\n        assert query_parameters[\"loadBackward\"] in [\"true\", \"false\"]\n\n    def cursor_type(self, query_parameters):\n        return TiktokStoryTimeCursor\n\n\nclass TiktokStoryBatchItemListRequest(TiktokItemListRequest):\n    def __init__(self):\n        super().__init__(\"story/batch/item_list\", \"stories\", None)\n\n    def validate_query_parameters(self, query_parameters):\n        # This request type does not need a count parameter so don't invoke\n        # super().validate_query_parameters().\n        assert \"authorIds\" in query_parameters\n        # I'd recommend between 1-10 users at a time, as that's what I see in\n        # the webapp.\n        author_count = query_parameters[\"authorIds\"].count(\",\") + 1\n        assert author_count >= 1 and author_count <= 10\n        # Not sure what this parameter does.\n        assert \"storyCallScene\" in query_parameters\n        assert query_parameters[\"storyCallScene\"] == \"2\"\n\n    def extract_items(self, data):\n        # We need to extract each itemList within the response and combine each\n        # of them into a single list of items. If even one of the users doesn't\n        # have an item list, \"exit early,\" but continue to gather the rest\n        # (this request doesn't use a cursor anyway so there is no concept of\n        # exiting early).\n        items = {}\n        if type(data.get(\"batchStoryItemLists\")) is not list:\n            self.exit_early_due_to_no_items = True\n            return items\n        for userStories in data[\"batchStoryItemLists\"]:\n            items.update(super().extract_items(userStories))\n        return items\n\n\nclass TiktokStoryUserListRequest(TiktokPaginationRequest):\n    def __init__(self):\n        super().__init__(\"story/user_list\")\n        self.exit_early_due_to_no_cookies = False\n\n    def validate_query_parameters(self, query_parameters):\n        super().validate_query_parameters(query_parameters)\n        # Not sure what this parameter does.\n        assert \"storyFeedScene\" in query_parameters\n        assert query_parameters[\"storyFeedScene\"] == \"3\"\n\n    def cursor_type(self, query_parameters):\n        return functools.partial(TiktokItemCursor, \"storyUsers\")\n\n    def extract_items(self, data):\n        if \"storyUsers\" not in data:\n            self.exit_early_due_to_no_cookies = True\n            return {}\n        return {item[\"user\"][\"id\"]: item[\"user\"][\"uniqueId\"]\n                for item in data[\"storyUsers\"]}\n\n    def exit_early(self, extractor, url):\n        if self.exit_early_due_to_no_cookies:\n            extractor.log.error(\"You must provide cookies to extract the \"\n                                \"stories of your following list\")\n        return self.exit_early_due_to_no_cookies\n\n    def generate_urls(self):\n        return [(id, f\"https://www.tiktok.com/@{name}\")\n                for id, name in self.items.items()]\n"
  },
  {
    "path": "gallery_dl/extractor/tmohentai.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://tmohentai.com/\"\"\"\n\nfrom .common import GalleryExtractor\nfrom .. import text\n\nBASE_PATTERN = r\"(?:https?://)?tmohentai\\.com\"\n\n\nclass TmohentaiGalleryExtractor(GalleryExtractor):\n    category = \"tmohentai\"\n    root = \"http://tmohentai.com\"\n    directory_fmt = (\"{category}\", \"{title} ({gallery_id})\")\n    pattern = BASE_PATTERN + r\"/(?:contents|reader)/(\\w+)\"\n    example = \"https://tmohentai.com/contents/12345a67b89c0\"\n\n    def __init__(self, match):\n        self.gallery_id = match[1]\n        url = f\"{self.root}/contents/{self.gallery_id}\"\n        GalleryExtractor.__init__(self, match, url)\n\n    def images(self, page):\n        base = f\"https://imgrojo.tmohentai.com/contents/{self.gallery_id}/\"\n        cnt = page.count('class=\"lanzador')\n        return [(f\"{base}{i:>03}.webp\", None) for i in range(0, cnt)]\n\n    def metadata(self, page):\n        extr = text.extract_from(page)\n\n        return {\n            \"gallery_id\": self.gallery_id,\n            \"title\"     : text.unescape(extr(\"<h3>\", \"<\").strip()),\n            \"artists\"   : text.split_html(extr(\n                \"<label>Artists and Artists Groups</label>\", \"</ul>\")),\n            \"genres\"    : text.split_html(extr(\n                \"<label>Genders</label>\", \"</ul>\")),\n            \"tags\"      : text.split_html(extr(\n                \"<label>Tags</label>\", \"</ul>\")),\n            \"uploader\"  : text.remove_html(extr(\n                \"<label>Uploaded By</label>\", \"</ul>\")),\n            \"language\"  : extr(\"&nbsp;\", \"\\n\"),\n        }\n"
  },
  {
    "path": "gallery_dl/extractor/toyhouse.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2022-2025 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://toyhou.se/\"\"\"\n\nfrom .common import Extractor, Message\nfrom .. import text, util\n\nBASE_PATTERN = r\"(?:https?://)?(?:www\\.)?toyhou\\.se\"\n\n\nclass ToyhouseExtractor(Extractor):\n    \"\"\"Base class for toyhouse extractors\"\"\"\n    category = \"toyhouse\"\n    root = \"https://toyhou.se\"\n    directory_fmt = (\"{category}\", \"{user|artists!S}\")\n    archive_fmt = \"{id}\"\n\n    def __init__(self, match):\n        Extractor.__init__(self, match)\n        self.user = match[1]\n        self.offset = 0\n\n    def items(self):\n        metadata = self.metadata()\n\n        for post in util.advance(self.posts(), self.offset):\n            if metadata:\n                post.update(metadata)\n            text.nameext_from_url(post[\"url\"], post)\n            post[\"id\"], _, post[\"hash\"] = post[\"filename\"].partition(\"_\")\n            yield Message.Directory, \"\", post\n            yield Message.Url, post[\"url\"], post\n\n    def posts(self):\n        return ()\n\n    def metadata(self):\n        return None\n\n    def skip_files(self, num):\n        self.offset += num\n        return num\n\n    def _parse_post(self, post, needle='<a href=\"'):\n        extr = text.extract_from(post)\n        return {\n            \"url\": extr(needle, '\"'),\n            \"date\": self.parse_datetime(extr(\n                '</h2>\\n            <div class=\"mb-1\">', '<'),\n                \"%d %b %Y, %I:%M:%S %p\"),\n            \"artists\": [\n                text.remove_html(artist)\n                for artist in extr(\n                    '<div class=\"artist-credit\">',\n                    '</div>\\n                    </div>').split(\n                    '<div class=\"ar tist-credit\">')\n            ],\n            \"characters\": text.split_html(extr(\n                '<div class=\"image-characters',\n                '<div class=\"image-comments\">'))[2:],\n        }\n\n    def _pagination(self, path):\n        url = self.root + path\n        params = {\"page\": 1}\n\n        while True:\n            page = self.request(url, params=params).text\n\n            cnt = 0\n            for post in text.extract_iter(\n                    page, '<li class=\"gallery-item\">', '</li>'):\n                cnt += 1\n                yield self._parse_post(post)\n\n            if not cnt and params[\"page\"] == 1:\n                if self._accept_content_warning(page):\n                    continue\n                return\n\n            if cnt < 18:\n                return\n            params[\"page\"] += 1\n\n    def _accept_content_warning(self, page):\n        pos = page.find(' name=\"_token\"') + 1\n        token, pos = text.extract(page, ' value=\"', '\"', pos)\n        user , pos = text.extract(page, ' value=\"', '\"', pos)\n        if not token or not user:\n            return False\n\n        data = {\"_token\": token, \"user\": user}\n        self.request(self.root + \"/~account/warnings/accept\",\n                     method=\"POST\", data=data, allow_redirects=False)\n        return True\n\n\nclass ToyhouseArtExtractor(ToyhouseExtractor):\n    \"\"\"Extractor for artworks of a toyhouse user\"\"\"\n    subcategory = \"art\"\n    pattern = BASE_PATTERN + r\"/([^/?#]+)/art\"\n    example = \"https://www.toyhou.se/USER/art\"\n\n    def posts(self):\n        return self._pagination(f\"/{self.user}/art\")\n\n    def metadata(self):\n        return {\"user\": self.user}\n\n\nclass ToyhouseImageExtractor(ToyhouseExtractor):\n    \"\"\"Extractor for individual toyhouse images\"\"\"\n    subcategory = \"image\"\n    pattern = (r\"(?:https?://)?(?:\"\n               r\"(?:www\\.)?toyhou\\.se/~images|\"\n               r\"f\\d+\\.toyhou\\.se/file/[^/?#]+/(?:image|watermark)s\"\n               r\")/(\\d+)\")\n    example = \"https://toyhou.se/~images/12345\"\n\n    def posts(self):\n        url = f\"{self.root}/~images/{self.user}\"\n        return (self._parse_post(\n            self.request(url).text, '<img class=\"mw-100\" src=\"'),)\n"
  },
  {
    "path": "gallery_dl/extractor/tumblr.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2016-2026 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://www.tumblr.com/\"\"\"\n\nfrom .common import Extractor, Message\nfrom .. import text, util, dt, oauth\n\n\nBASE_PATTERN = (\n    r\"(?:tumblr:(?:https?://)?([^/]+)|\"\n    r\"(?:https?://)?\"\n    r\"(?:(?:www\\.)?tumblr\\.com/(?:blog/(?:view/)?)?([\\w-]+)|\"\n    r\"([\\w-]+\\.tumblr\\.com)))\"\n)\n\nPOST_TYPES = frozenset((\"text\", \"quote\", \"link\", \"answer\", \"video\",\n                        \"audio\", \"photo\", \"chat\", \"search\"))\n\n\nclass TumblrExtractor(Extractor):\n    \"\"\"Base class for tumblr extractors\"\"\"\n    category = \"tumblr\"\n    directory_fmt = (\"{category}\", \"{blog_name}\")\n    filename_fmt = \"{category}_{blog_name}_{id}_{num:>02}.{extension}\"\n    archive_fmt = \"{id}_{num}\"\n\n    def _init(self):\n        if name := self.groups[1]:\n            self.blog = name + \".tumblr.com\"\n        else:\n            self.blog = self.groups[0] or self.groups[2]\n\n        self.api = TumblrAPI(self)\n        self.types = self._setup_posttypes()\n        self.avatar = self.config(\"avatar\", False)\n        self.inline = self.config(\"inline\", True)\n        self.reblogs = self.config(\"reblogs\", True)\n        self.external = self.config(\"external\", False)\n        self.original = self.config(\"original\", True)\n        self.fallback_delay = self.config(\"fallback-delay\", 120.0)\n        self.fallback_retries = self.config(\"fallback-retries\", 2)\n\n        if len(self.types) == 1:\n            self.api.posts_type = next(iter(self.types))\n        elif not self.types:\n            self.log.warning(\"no valid post types selected\")\n\n        if self.reblogs == \"same-blog\":\n            self._skip_reblog = self._skip_reblog_same_blog\n\n        self.date_min, self.api.before = self._get_date_min_max(0, None)\n\n    def skip_date(self, date):\n        self.api.before = int(dt.to_ts(date))\n        return True\n\n    def items(self):\n        blog = None\n\n        # pre-compile regular expressions\n        self._sub_video = text.re(\n            r\"https?://((?:vt|vtt|ve)(?:\\.media)?\\.tumblr\\.com\"\n            r\"/tumblr_[^_]+)_\\d+\\.([0-9a-z]+)\").sub\n        if self.inline:\n            self._sub_image = text.re(\n                r\"https?://(\\d+\\.media\\.tumblr\\.com(?:/[0-9a-f]+)?\"\n                r\"/tumblr(?:_inline)?_[^_]+)_\\d+\\.([0-9a-z]+)\").sub\n            self._subn_orig_image = text.re(r\"/s\\d+x\\d+/\").subn\n            _findall_image = text.re('<img src=\"([^\"]+)\"').findall\n            _findall_video = text.re('<source src=\"([^\"]+)\"').findall\n\n        for post in self.posts():\n            if self.date_min > post[\"timestamp\"]:\n                return\n            if post[\"type\"] not in self.types:\n                continue\n\n            if \"blog\" in post:\n                blog = post[\"blog\"]\n                self.blog = blog[\"name\"] + \".tumblr.com\"\n            else:\n                if not blog:\n                    blog = self.api.info(self.blog)\n                    blog[\"uuid\"] = self.blog\n\n                    if self.avatar:\n                        url = self.api.avatar(self.blog)\n                        yield Message.Directory, \"\", {\"blog\": blog}\n                        yield self._prepare_avatar(url, post.copy(), blog)\n\n                post[\"blog\"] = blog\n\n            reblog = \"reblogged_from_id\" in post\n            if reblog and self._skip_reblog(post):\n                continue\n            post[\"reblogged\"] = reblog\n\n            if \"trail\" in post:\n                del post[\"trail\"]\n            post[\"date\"] = self.parse_timestamp(post[\"timestamp\"])\n            posts = []\n\n            if \"photos\" in post:  # type \"photo\" or \"link\"\n                photos = post[\"photos\"]\n                del post[\"photos\"]\n\n                for photo in photos:\n                    post[\"photo\"] = photo\n\n                    best_photo = photo[\"original_size\"]\n                    for alt_photo in photo[\"alt_sizes\"]:\n                        if (alt_photo[\"height\"] > best_photo[\"height\"] or\n                                alt_photo[\"width\"] > best_photo[\"width\"]):\n                            best_photo = alt_photo\n                    photo.update(best_photo)\n\n                    if self.original and \"/s2048x3072/\" in photo[\"url\"] and (\n                            photo[\"width\"] == 2048 or photo[\"height\"] == 3072):\n                        photo[\"url\"], fb = self._original_photo(photo[\"url\"])\n                        if fb:\n                            post[\"_fallback\"] = self._original_image_fallback(\n                                photo[\"url\"], post[\"id\"])\n\n                    del photo[\"original_size\"]\n                    del photo[\"alt_sizes\"]\n                    posts.append(\n                        self._prepare_image(photo[\"url\"], post.copy()))\n                    del post[\"photo\"]\n                    post.pop(\"_fallback\", None)\n\n            url = post.get(\"audio_url\")  # type \"audio\"\n            if url and url.startswith(\"https://a.tumblr.com/\"):\n                posts.append(self._prepare(url, post.copy()))\n\n            if url := post.get(\"video_url\"):  # type \"video\"\n                posts.append(self._prepare(\n                    self._original_video(url), post.copy()))\n\n            if self.inline and \"reblog\" in post:  # inline media\n                # only \"chat\" posts are missing a \"reblog\" key in their\n                # API response, but they can't contain images/videos anyway\n                body = post[\"reblog\"][\"comment\"] + post[\"reblog\"][\"tree_html\"]\n                if \"question\" in post:\n                    body = (f\"{body} {post['question']} \"\n                            f\"{post.get('answer') or ''}\")\n                for url in _findall_image(body):\n                    url, fb = self._original_inline_image(url)\n                    if fb:\n                        post[\"_fallback\"] = self._original_image_fallback(\n                            url, post[\"id\"])\n                    posts.append(self._prepare_image(url, post.copy()))\n                    post.pop(\"_fallback\", None)\n                for url in _findall_video(body):\n                    url = self._original_video(url)\n                    posts.append(self._prepare(url, post.copy()))\n\n            if self.external:  # external links\n                if url := post.get(\"permalink_url\") or post.get(\"url\"):\n                    post[\"extension\"] = None\n                    posts.append((Message.Queue, url, post.copy()))\n                    del post[\"extension\"]\n\n            post[\"count\"] = len(posts)\n            yield Message.Directory, \"\", post\n\n            for num, (msg, url, post) in enumerate(posts, 1):\n                post[\"num\"] = num\n                post[\"count\"] = len(posts)\n                yield msg, url, post\n\n    def items_blogs(self):\n        for blog in self.blogs():\n            blog[\"_extractor\"] = TumblrUserExtractor\n            yield Message.Queue, blog[\"url\"], blog\n\n    def posts(self):\n        \"\"\"Return an iterable containing all relevant posts\"\"\"\n\n    def _setup_posttypes(self):\n        types = self.config(\"posts\", \"all\")\n\n        if types == \"all\":\n            return POST_TYPES\n\n        elif not types:\n            return frozenset()\n\n        else:\n            if isinstance(types, str):\n                types = types.split(\",\")\n            types = frozenset(types)\n\n            if invalid := types - POST_TYPES:\n                types = types & POST_TYPES\n                self.log.warning(\"Invalid post types: '%s'\",\n                                 \"', '\".join(sorted(invalid)))\n            return types\n\n    def _prepare(self, url, post):\n        text.nameext_from_url(url, post)\n        post[\"hash\"] = post[\"filename\"].partition(\"_\")[2]\n        return Message.Url, url, post\n\n    def _prepare_image(self, url, post):\n        text.nameext_from_url(url, post)\n\n        # try \".gifv\" (#3095)\n        # it's unknown whether all gifs in this case are actually webps\n        # incorrect extensions will be corrected by 'adjust-extensions'\n        if post[\"extension\"] == \"gif\":\n            post[\"_fallback\"] = (url + \"v\",)\n            post[\"_http_headers\"] = {\"Accept\":  # copied from chrome 106\n                                     \"image/avif,image/webp,image/apng,\"\n                                     \"image/svg+xml,image/*,*/*;q=0.8\"}\n\n        parts = post[\"filename\"].split(\"_\")\n        try:\n            post[\"hash\"] = parts[1] if parts[1] != \"inline\" else parts[2]\n        except IndexError:\n            # filename doesn't follow the usual pattern (#129)\n            post[\"hash\"] = post[\"filename\"]\n\n        return Message.Url, url, post\n\n    def _prepare_avatar(self, url, post, blog):\n        text.nameext_from_url(url, post)\n        post[\"num\"] = post[\"count\"] = 1\n        post[\"blog\"] = blog\n        post[\"reblogged\"] = False\n        post[\"type\"] = post[\"id\"] = post[\"hash\"] = \"avatar\"\n        return Message.Url, url, post\n\n    def _skip_reblog(self, _):\n        return not self.reblogs\n\n    def _skip_reblog_same_blog(self, post):\n        return self.blog != post.get(\"reblogged_root_uuid\")\n\n    def _original_photo(self, url):\n        resized = url.replace(\"/s2048x3072/\", \"/s99999x99999/\", 1)\n        return self._update_image_token(resized)\n\n    def _original_inline_image(self, url):\n        if self.original:\n            resized, n = self._subn_orig_image(\"/s99999x99999/\", url, 1)\n            if n:\n                return self._update_image_token(resized)\n        return self._sub_image(r\"https://\\1_1280.\\2\", url), False\n\n    def _original_video(self, url):\n        return self._sub_video(r\"https://\\1.\\2\", url)\n\n    def _update_image_token(self, resized):\n        headers = {\"Accept\": \"text/html,*/*;q=0.8\"}\n        try:\n            response = self.request(resized, headers=headers)\n        except Exception:\n            return resized, True\n        else:\n            updated = text.extr(response.text, '\" src=\"', '\"')\n            return updated, (resized == updated)\n\n    def _original_image_fallback(self, url, post_id):\n        for _ in util.repeat(self.fallback_retries):\n            self.sleep(self.fallback_delay, \"image token\")\n            yield self._update_image_token(url)[0]\n        self.log.warning(\"Unable to fetch higher-resolution \"\n                         \"version of %s (%s)\", url, post_id)\n\n\nclass TumblrUserExtractor(TumblrExtractor):\n    \"\"\"Extractor for a Tumblr user's posts\"\"\"\n    subcategory = \"user\"\n    pattern = BASE_PATTERN + r\"(?:/page/\\d+|/archive)?/?$\"\n    example = \"https://www.tumblr.com/BLOG\"\n\n    def posts(self):\n        return self.api.posts(self.blog, {})\n\n\nclass TumblrPostExtractor(TumblrExtractor):\n    \"\"\"Extractor for a single Tumblr post\"\"\"\n    subcategory = \"post\"\n    pattern = BASE_PATTERN + r\"/(?:post/|image/)?(\\d+)\"\n    example = \"https://www.tumblr.com/BLOG/12345\"\n\n    def posts(self):\n        self.reblogs = True\n        self.date_min = 0\n        return self.api.posts(self.blog, {\"id\": self.groups[3]})\n\n    def _setup_posttypes(self):\n        return POST_TYPES\n\n\nclass TumblrTagExtractor(TumblrExtractor):\n    \"\"\"Extractor for Tumblr user's posts by tag\"\"\"\n    subcategory = \"tag\"\n    pattern = BASE_PATTERN + r\"(?:/archive)?/tagged/([^/?#]+)\"\n    example = \"https://www.tumblr.com/BLOG/tagged/TAG\"\n\n    def posts(self):\n        self.kwdict[\"search_tags\"] = tag = text.unquote(\n            self.groups[3].replace(\"-\", \" \"))\n        return self.api.posts(self.blog, {\"tag\": tag})\n\n\nclass TumblrDayExtractor(TumblrExtractor):\n    \"\"\"Extractor for Tumblr user's posts by day\"\"\"\n    subcategory = \"day\"\n    pattern = BASE_PATTERN + r\"/day/(\\d\\d\\d\\d/\\d\\d/\\d\\d)\"\n    example = \"https://www.tumblr.com/BLOG/day/1970/01/01\"\n\n    def posts(self):\n        year, month, day = self.groups[3].split(\"/\")\n        ordinal = dt.date(int(year), int(month), int(day)).toordinal()\n\n        # 719163 == date(1970, 1, 1).toordinal()\n        self.date_min = (ordinal - 719163) * 86400\n        self.api.before = self.date_min + 86400\n        return self.api.posts(self.blog, {})\n\n\nclass TumblrLikesExtractor(TumblrExtractor):\n    \"\"\"Extractor for a Tumblr user's liked posts\"\"\"\n    subcategory = \"likes\"\n    directory_fmt = (\"{category}\", \"{blog_name}\", \"likes\")\n    archive_fmt = \"f_{blog[name]}_{id}_{num}\"\n    pattern = BASE_PATTERN + r\"/likes\"\n    example = \"https://www.tumblr.com/BLOG/likes\"\n\n    def posts(self):\n        return self.api.likes(self.blog)\n\n\nclass TumblrFollowingExtractor(TumblrExtractor):\n    \"\"\"Extractor for a Tumblr user's followed blogs\"\"\"\n    subcategory = \"following\"\n    pattern = BASE_PATTERN + r\"/following\"\n    example = \"https://www.tumblr.com/BLOG/following\"\n\n    items = TumblrExtractor.items_blogs\n\n    def blogs(self):\n        return self.api.following(self.blog)\n\n\nclass TumblrFollowersExtractor(TumblrExtractor):\n    \"\"\"Extractor for a Tumblr user's followers\"\"\"\n    subcategory = \"followers\"\n    pattern = BASE_PATTERN + r\"/followers\"\n    example = \"https://www.tumblr.com/BLOG/followers\"\n\n    items = TumblrExtractor.items_blogs\n\n    def blogs(self):\n        return self.api.followers(self.blog)\n\n\nclass TumblrSearchExtractor(TumblrExtractor):\n    \"\"\"Extractor for a Tumblr search\"\"\"\n    subcategory = \"search\"\n    pattern = (r\"(?:https?://)?(?:www\\.)?tumblr\\.com/search/([^/?#]+)\"\n               r\"(?:/([^/?#]+)(?:/([^/?#]+))?)?(?:/?\\?([^#]+))?\")\n    example = \"https://www.tumblr.com/search/QUERY\"\n\n    def posts(self):\n        search, mode, post_type, query = self.groups\n        params = text.parse_query(query)\n        return self.api.search(text.unquote(search), params, mode, post_type)\n\n\nclass TumblrAPI(oauth.OAuth1API):\n    \"\"\"Interface for the Tumblr API v2\n\n    https://github.com/tumblr/docs/blob/master/api.md\n    \"\"\"\n    ROOT = \"https://api.tumblr.com\"\n    API_KEY = \"O3hU2tMi5e4Qs5t3vezEi6L0qRORJ5y9oUpSGsrWu8iA3UCc3B\"\n    API_SECRET = \"sFdsK3PDdP2QpYMRAoq0oDnw0sFS24XigXmdfnaeNZpJpqAn03\"\n    BLOG_CACHE = {}\n\n    def __init__(self, extractor):\n        oauth.OAuth1API.__init__(self, extractor)\n        self.posts_type = self.before = None\n\n    def info(self, blog):\n        \"\"\"Return general information about a blog\"\"\"\n        try:\n            return self.BLOG_CACHE[blog]\n        except KeyError:\n            endpoint = f\"/v2/blog/{blog}/info\"\n            params = {\"api_key\": self.api_key} if self.api_key else None\n            self.BLOG_CACHE[blog] = blog = self._call(endpoint, params)[\"blog\"]\n            return blog\n\n    def avatar(self, blog, size=\"512\"):\n        \"\"\"Retrieve a blog avatar\"\"\"\n        if self.api_key:\n            return (f\"{self.ROOT}/v2/blog/{blog}/avatar/{size}\"\n                    f\"?api_key={self.api_key}\")\n        endpoint = f\"/v2/blog/{blog}/avatar\"\n        params = {\"size\": size}\n        return self._call(\n            endpoint, params, allow_redirects=False)[\"avatar_url\"]\n\n    def posts(self, blog, params):\n        \"\"\"Retrieve published posts\"\"\"\n        params[\"offset\"] = self.extractor.config(\"offset\")\n        params[\"limit\"] = 50\n        params[\"reblog_info\"] = \"true\"\n        params[\"type\"] = self.posts_type\n        params[\"before\"] = self.before\n\n        if self.before and params[\"offset\"]:\n            self.log.warning(\"'offset' and 'date-max' cannot be used together\")\n\n        endpoint = f\"/v2/blog/{blog}/posts\"\n        return self._pagination(endpoint, params, blog=blog, cache=True)\n\n    def likes(self, blog):\n        \"\"\"Retrieve liked posts\"\"\"\n        endpoint = f\"/v2/blog/{blog}/likes\"\n        params = {\"limit\": \"50\", \"before\": self.before}\n        if self.api_key:\n            params[\"api_key\"] = self.api_key\n\n        while True:\n            posts = self._call(endpoint, params)[\"liked_posts\"]\n            if not posts:\n                return\n            yield from posts\n            params[\"before\"] = posts[-1][\"liked_timestamp\"]\n\n    def following(self, blog):\n        endpoint = f\"/v2/blog/{blog}/following\"\n        return self._pagination_blogs(endpoint)\n\n    def followers(self, blog):\n        endpoint = f\"/v2/blog/{blog}/followers\"\n        return self._pagination_blogs(endpoint)\n\n    def search(self, query, params, mode=\"top\", post_type=None):\n        \"\"\"Retrieve search results\"\"\"\n        endpoint = \"/v2/timeline/search\"\n\n        params[\"limit\"] = \"50\"\n        params[\"days\"] = params.pop(\"t\", None)\n        params[\"query\"] = query\n        params[\"mode\"] = mode\n        params[\"reblog_info\"] = \"true\" if self.extractor.reblogs else \"false\"\n        if post_type:\n            params[\"post_type_filter\"] = post_type\n\n        return self._pagination(endpoint, params)\n\n    def _call(self, endpoint, params, **kwargs):\n        url = self.ROOT + endpoint\n        kwargs[\"params\"] = params\n\n        while True:\n            response = self.request(url, **kwargs)\n\n            try:\n                data = response.json()\n            except ValueError:\n                data = response.text\n                status = response.status_code\n            else:\n                status = data[\"meta\"][\"status\"]\n                if 200 <= status < 400:\n                    return data[\"response\"]\n\n            self.log.debug(data)\n\n            if status == 403:\n                msg = data.get(\"response\")\n                if msg == \"You do not have permission to view this blog\" and \\\n                        self.api_key is None:\n                    self.log.debug(\"Retrying with 'api_key' authentication\")\n                    self.api_key = params[\"api_key\"] = \\\n                        self.session.auth.consumer_key\n                    self.session = self.extractor.session\n                    continue\n                msg = f\"'{msg}'\" if isinstance(msg, str) else None\n                raise self.exc.AuthorizationError(msg)\n\n            elif status == 404:\n                try:\n                    error = data[\"errors\"][0][\"detail\"]\n                    board = (\"only viewable within the Tumblr dashboard\"\n                             in error)\n                except Exception:\n                    board = False\n\n                if board:\n                    if self.api_key is None:\n                        self.log.info(\n                            \"Ensure your 'access-token' and \"\n                            \"'access-token-secret' belong to the same \"\n                            \"application as 'api-key' and 'api-secret'\")\n                    else:\n                        self.log.info(\"Run 'gallery-dl oauth:tumblr' \"\n                                      \"to access dashboard-only blogs\")\n                    raise self.exc.AuthorizationError(error)\n                raise self.exc.NotFoundError(\"user or post\")\n\n            elif status == 429:\n                # daily rate limit\n                if response.headers.get(\"x-ratelimit-perday-remaining\") == \"0\":\n                    self.log.info(\"Daily API rate limit exceeded\")\n                    reset = response.headers.get(\"x-ratelimit-perday-reset\")\n\n                    api_key = self.api_key or self.session.auth.consumer_key\n                    if api_key == self.API_KEY:\n                        self.log.info(\n                            \"Register your own OAuth application and use its \"\n                            \"credentials to prevent this error: \"\n                            \"https://gdl-org.github.io/docs/configuration.html\"\n                            \"#extractor-tumblr-api-key-api-secret\")\n\n                    if self.extractor.config(\"ratelimit\") == \"wait\":\n                        self.extractor.wait(seconds=reset)\n                        continue\n\n                    t = (dt.now() + dt.timedelta(0, float(reset))).time()\n                    raise self.exc.AbortExtraction(\n                        f\"Aborting - Rate limit will reset at \"\n                        f\"{t.hour:02}:{t.minute:02}:{t.second:02}\")\n\n                # hourly rate limit\n                if reset := response.headers.get(\"x-ratelimit-perhour-reset\"):\n                    self.log.info(\"Hourly API rate limit exceeded\")\n                    self.extractor.wait(seconds=reset)\n                    continue\n\n            raise self.exc.AbortExtraction(data)\n\n    def _pagination(self, endpoint, params,\n                    blog=None, key=\"posts\", cache=False):\n        if self.api_key:\n            params[\"api_key\"] = self.api_key\n\n        if strategy := self.extractor.config(\"pagination\"):\n            if strategy not in {\"api\", \"before\"} and \"offset\" not in params:\n                self.log.warning('Unable to use \"pagination\": \"%s\". '\n                                 'Falling back to \"api\".', strategy)\n                strategy = \"api\"\n        elif params.get(\"before\"):\n            strategy = \"before\"\n        elif \"offset\" not in params:\n            strategy = \"api\"\n        self.log.debug(\"Pagination strategy '%s'\", strategy or \"offset\")\n\n        while True:\n            data = self._call(endpoint, params)\n\n            if \"timeline\" in data:\n                data = data[\"timeline\"]\n                posts = data[\"elements\"]\n\n            else:\n                if cache:\n                    self.BLOG_CACHE[blog] = data[\"blog\"]\n                    cache = False\n                posts = data[key]\n\n            yield from posts\n\n            if strategy == \"api\":\n                try:\n                    endpoint = data[\"_links\"][\"next\"][\"href\"]\n                except KeyError:\n                    return\n                if params is not None and self.api_key:\n                    endpoint = f\"{endpoint}&api_key={self.api_key}\"\n                    params = None\n\n            elif strategy == \"before\":\n                if not posts:\n                    return\n                timestamp = posts[-1][\"timestamp\"] + 1\n                if params[\"before\"] and timestamp >= params[\"before\"]:\n                    return\n                params[\"before\"] = timestamp\n                params[\"offset\"] = None\n\n            else:  # offset\n                params[\"offset\"] = \\\n                    text.parse_int(params[\"offset\"]) + params[\"limit\"]\n                params[\"before\"] = None\n                if params[\"offset\"] >= data[\"total_posts\"]:\n                    return\n\n    def _pagination_blogs(self, endpoint, params=None):\n        if params is None:\n            params = {}\n        if self.api_key:\n            params[\"api_key\"] = self.api_key\n        params[\"limit\"] = 20\n        params[\"offset\"] = text.parse_int(params.get(\"offset\"), 0)\n\n        while True:\n            data = self._call(endpoint, params)\n\n            blogs = data[\"blogs\"]\n            yield from blogs\n\n            params[\"offset\"] = params[\"offset\"] + params[\"limit\"]\n            if params[\"offset\"] >= data[\"total_blogs\"]:\n                return\n"
  },
  {
    "path": "gallery_dl/extractor/tumblrgallery.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://tumblrgallery.xyz/\"\"\"\n\nfrom .common import GalleryExtractor\nfrom .. import text\n\nBASE_PATTERN = r\"(?:https?://)?tumblrgallery\\.xyz\"\n\n\nclass TumblrgalleryExtractor(GalleryExtractor):\n    \"\"\"Base class for tumblrgallery extractors\"\"\"\n    category = \"tumblrgallery\"\n    filename_fmt = \"{category}_{gallery_id}_{num:>03}_{id}.{extension}\"\n    directory_fmt = (\"{category}\", \"{gallery_id} {title}\")\n    root = \"https://tumblrgallery.xyz\"\n    referer = False\n\n    def _urls_from_page(self, page):\n        return text.extract_iter(\n            page, '<div class=\"report\"> <a class=\"xx-co-me\" href=\"', '\"')\n\n    def _data_from_url(self, url):\n        filename = text.nameext_from_url(url)[\"filename\"]\n        parts = filename.split(\"_\")\n        try:\n            return {\"id\": parts[1] if parts[1] != \"inline\" else parts[2]}\n        except IndexError:\n            return {\"id\": filename}\n\n\nclass TumblrgalleryTumblrblogExtractor(TumblrgalleryExtractor):\n    \"\"\"Extractor for Tumblrblog on tumblrgallery.xyz\"\"\"\n    subcategory = \"tumblrblog\"\n    pattern = BASE_PATTERN + r\"(/tumblrblog/gallery/(\\d+)\\.html)\"\n    example = \"https://tumblrgallery.xyz/tumblrblog/gallery/12345.html\"\n\n    def __init__(self, match):\n        TumblrgalleryExtractor.__init__(self, match)\n        self.gallery_id = text.parse_int(match[2])\n\n    def metadata(self, page):\n        return {\n            \"title\" : text.unescape(text.extr(page, \"<h1>\", \"</h1>\")),\n            \"gallery_id\": self.gallery_id,\n        }\n\n    def images(self, _):\n        base = f\"{self.root}/tumblrblog/gallery/{self.gallery_id}/\"\n        pnum = 1\n\n        while True:\n            url = f\"{base}{pnum}.html\"\n            response = self.request(url, allow_redirects=False, fatal=False)\n\n            if response.status_code >= 300:\n                return\n\n            for url in self._urls_from_page(response.text):\n                yield url, self._data_from_url(url)\n            pnum += 1\n\n\nclass TumblrgalleryPostExtractor(TumblrgalleryExtractor):\n    \"\"\"Extractor for Posts on tumblrgallery.xyz\"\"\"\n    subcategory = \"post\"\n    pattern = BASE_PATTERN + r\"(/post/(\\d+)\\.html)\"\n    example = \"https://tumblrgallery.xyz/post/12345.html\"\n\n    def __init__(self, match):\n        TumblrgalleryExtractor.__init__(self, match)\n        self.gallery_id = text.parse_int(match[2])\n\n    def metadata(self, page):\n        return {\n            \"title\" : text.remove_html(\n                text.unescape(text.extr(page, \"<title>\", \"</title>\"))\n            ).replace(\"_\", \"-\"),\n            \"gallery_id\": self.gallery_id,\n        }\n\n    def images(self, page):\n        for url in self._urls_from_page(page):\n            yield url, self._data_from_url(url)\n\n\nclass TumblrgallerySearchExtractor(TumblrgalleryExtractor):\n    \"\"\"Extractor for Search result on tumblrgallery.xyz\"\"\"\n    subcategory = \"search\"\n    filename_fmt = \"{category}_{num:>03}_{gallery_id}_{id}_{title}.{extension}\"\n    directory_fmt = (\"{category}\", \"{search_term}\")\n    pattern = BASE_PATTERN + r\"(/s\\.php\\?q=([^&#]+))\"\n    example = \"https://tumblrgallery.xyz/s.php?q=QUERY\"\n\n    def __init__(self, match):\n        TumblrgalleryExtractor.__init__(self, match)\n        self.search_term = match[2]\n\n    def metadata(self, page):\n        return {\n            \"search_term\": self.search_term,\n        }\n\n    def images(self, _):\n        page_url = \"s.php?q=\" + self.search_term\n        while True:\n            page = self.request(self.root + \"/\" + page_url).text\n\n            for gallery_id in text.extract_iter(\n                    page, '<div class=\"title\"><a href=\"post/', '.html'):\n\n                url = f\"{self.root}/post/{gallery_id}.html\"\n                post_page = self.request(url).text\n\n                for url in self._urls_from_page(post_page):\n                    data = self._data_from_url(url)\n                    data[\"gallery_id\"] = gallery_id\n                    data[\"title\"] = text.remove_html(text.unescape(\n                        text.extr(post_page, \"<title>\", \"</title>\")\n                    )).replace(\"_\", \"-\")\n                    yield url, data\n\n            next_url = text.extr(\n                page, '</span> <a class=\"btn btn-primary\" href=\"', '\"')\n            if not next_url or page_url == next_url:\n                return\n            page_url = next_url\n"
  },
  {
    "path": "gallery_dl/extractor/tungsten.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://tungsten.run/\"\"\"\n\nfrom .common import Extractor, Message\nfrom .. import text\n\nBASE_PATTERN = r\"(?:https?://)?(?:www\\.)?tungsten\\.run\"\n\n\nclass TungstenExtractor(Extractor):\n    \"\"\"Base class for tungsten extractors\"\"\"\n    category = \"tungsten\"\n    root = \"https://tungsten.run\"\n    directory_fmt = (\"{category}\", \"{user[username]}\")\n    filename_fmt = \"{date} {title:?/ /}{uuid}.{extension}\"\n    archive_fmt = \"{uuid}\"\n\n    def items(self):\n        for post in self.posts():\n            url = post[\"original_url\"]\n            post[\"date\"] = self.parse_datetime_iso(post[\"created_at\"])\n            post[\"filename\"] = url[url.rfind(\"/\")+1:]\n            post[\"extension\"] = \"webp\"\n            yield Message.Directory, \"\", post\n            yield Message.Url, url, post\n\n    def _pagination(self, url, params):\n        params[\"page\"] = 1\n        params[\"per_page\"] = 40\n\n        headers = {\n            \"Origin\": self.root,\n            \"Sec-Fetch-Dest\": \"empty\",\n            \"Sec-Fetch-Mode\": \"cors\",\n            \"Sec-Fetch-Site\": \"same-site\",\n        }\n\n        while True:\n            data = self.request_json(url, params=params, headers=headers)\n\n            yield from data\n\n            if len(data) < params[\"per_page\"]:\n                break\n            params[\"page\"] += 1\n\n\nclass TungstenPostExtractor(TungstenExtractor):\n    subcategory = \"post\"\n    pattern = BASE_PATTERN + r\"/post/(\\w+)\"\n    example = \"https://tungsten.run/post/AbCdEfGhIjKlMnOp\"\n\n    def posts(self):\n        url = f\"{self.root}/post/{self.groups[0]}\"\n        page = self.request(url).text\n        data = self._extract_nextdata(page)\n        return (data[\"props\"][\"pageProps\"][\"post\"],)\n\n\nclass TungstenModelExtractor(TungstenExtractor):\n    subcategory = \"model\"\n    pattern = BASE_PATTERN + r\"/model/(\\w+)(?:/?\\?model_version=(\\w+))?\"\n    example = \"https://tungsten.run/model/AbCdEfGhIjKlM\"\n\n    def posts(self):\n        uuid_model, uuid_version = self.groups\n\n        if uuid_version is None:\n            url = f\"{self.root}/model/{uuid_model}/\"\n            page = self.request(url).text\n            uuid_version = text.extr(page, '\"modelVersionUUID\":\"', '\"')\n\n        url = \"https://api.tungsten.run/v1/posts\"\n        params = {\n            \"sort\"          : \"top_all_time\",\n            \"tweakable_only\": \"false\",\n            \"following\"     : \"false\",\n            \"model_version_uuid\": uuid_version,\n        }\n        return self._pagination(url, params)\n\n\nclass TungstenUserExtractor(TungstenExtractor):\n    subcategory = \"user\"\n    pattern = BASE_PATTERN + r\"/user/([^/?#]+)(?:/posts)?/?(?:\\?([^#]+))?\"\n    example = \"https://tungsten.run/user/USER\"\n\n    def posts(self):\n        user, qs = self.groups\n        url = f\"{self.root}/user/{user}\"\n        page = self.request(url).text\n        uuid_user = text.extr(page, '\"user\":{\"uuid\":\"', '\"')\n\n        url = f\"https://api.tungsten.run/v1/users/{uuid_user}/posts\"\n        params = text.parse_query(qs)\n        params.setdefault(\"sort\", \"top_all_time\")\n        self.kwdict[\"search_tags\"] = params.get(\"tag\", \"\")\n        return self._pagination(url, params)\n"
  },
  {
    "path": "gallery_dl/extractor/turbo.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2024-2026 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://turbo.cr/\"\"\"\n\nfrom .lolisafe import LolisafeAlbumExtractor\nfrom .. import text\n\nBASE_PATTERN = (r\"(?:https?://)?(?:\"\n                r\"(?:www\\.)?turbo(?:vid)?\\.cr|\"\n                r\"saint\\d*\\.(?:su|pk|cr|to))\")\n\n\nclass TurboAlbumExtractor(LolisafeAlbumExtractor):\n    \"\"\"Extractor for turbo.cr albums\"\"\"\n    category = \"turbo\"\n    root = \"https://turbo.cr\"\n    pattern = BASE_PATTERN + r\"/a/([^/?#]+)\"\n    example = \"https://turbo.cr/a/ID\"\n\n    def fetch_album(self, album_id):\n        url = f\"{self.root}/a/{album_id}\"\n        extr = text.extract_from(self.request(url).text)\n        title = extr(\"<h1 \", \"<\")\n        descr = extr(\"<p \", \"<\")\n        tbody = extr('id=\"fileTbody\"', '</tbody>')\n        headers = {\"Referer\": url}\n\n        return self._extract_files(tbody, headers), {\n            \"album_id\"     : album_id,\n            \"album_name\"   : text.unescape(title[title.find(\">\")+1:]),\n            \"description\"  : text.unescape(descr[descr.find(\">\")+1:]),\n            \"album_size\"   : sum(map(text.parse_int, text.extract_iter(\n                tbody, 'data-size=\"', '\"'))),\n            \"count\"        : tbody.count(\"data-id=\"),\n            \"_http_headers\": headers,\n        }\n\n    def _extract_files(self, body, headers):\n        for file in text.extract_iter(body, \"<tr\", \"</tr>\"):\n            data_id = text.extr(file, 'data-id=\"', '\"')\n            url = f\"{self.root}/api/sign?v={data_id}\"\n            data = self.request_json(url, headers=headers)\n            name = data.get(\"original_filename\") or data.get(\"filename\")\n            yield text.nameext_from_name(name, {\n                \"id\"  : data_id,\n                \"file\": data.get(\"url\"),\n                \"size\": text.parse_int(text.extr(file, 'data-size=\"', '\"')),\n                \"_http_headers\": headers,\n            })\n\n\nclass TurboMediaExtractor(TurboAlbumExtractor):\n    \"\"\"Extractor for turbo.cr media links\"\"\"\n    subcategory = \"media\"\n    directory_fmt = (\"{category}\",)\n    pattern = BASE_PATTERN + r\"/(?:embe)?[dv]/([^/?#]+)\"\n    example = \"https://turbo.cr/embed/ID\"\n\n    def fetch_album(self, album_id):\n        try:\n            return (self._extract_file(album_id),), {\n                \"album_id\"   : \"\",\n                \"album_name\" : \"\",\n                \"album_size\" : -1,\n                \"description\": \"\",\n                \"count\"      : 1,\n            }\n        except Exception as exc:\n            self.log.error(\"%s: %s\", exc.__class__.__name__, exc)\n            return (), {}\n\n    def _extract_file(self, data_id):\n        url = f\"{self.root}/d/{data_id}\"\n        headers = {\"Referer\": url}\n        page = self.request(url).text\n        size = text.extr(page, 'id=\"fileSizeBytes\">', '<')\n        date = text.extract(page, \"<span>\", \"<\", page.find(\"File ID:\"))[0]\n\n        url = f\"{self.root}/api/sign?v={data_id}\"\n        data = self.request_json(url, headers=headers)\n        name = data.get(\"original_filename\") or data.get(\"filename\")\n        return text.nameext_from_name(name, {\n            \"id\"  : data_id,\n            \"file\": data.get(\"url\"),\n            \"size\": int(text.parse_float(size.replace(\"&#43;\", \"+\"))),\n            \"date\": self.parse_datetime_iso(date),\n            \"_http_headers\": headers,\n        })\n"
  },
  {
    "path": "gallery_dl/extractor/twibooru.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2022-2026 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://twibooru.org/\"\"\"\n\nfrom .booru import BooruExtractor\nfrom .. import text\nimport operator\n\nBASE_PATTERN = r\"(?:https?://)?(?:www\\.)?twibooru\\.org\"\n\n\nclass TwibooruExtractor(BooruExtractor):\n    \"\"\"Base class for twibooru extractors\"\"\"\n    category = \"twibooru\"\n    basecategory = \"philomena\"\n    root = \"https://twibooru.org\"\n    filename_fmt = \"{id}_{filename}.{extension}\"\n    archive_fmt = \"{id}\"\n    request_interval = (6.0, 6.1)\n    page_start = 1\n    per_page = 50\n\n    def _init(self):\n        self.api = TwibooruAPI(self)\n        if not self.config(\"svg\", True):\n            self._file_url = operator.itemgetter(\"view_url\")\n\n    def _file_url(self, post):\n        if post[\"format\"] == \"svg\":\n            return post[\"view_url\"].rpartition(\".\")[0] + \".svg\"\n        return post[\"view_url\"]\n\n    def _prepare(self, post):\n        post[\"date\"] = self.parse_datetime_iso(post[\"created_at\"])\n\n        if \"name\" in post:\n            name, sep, rest = post[\"name\"].rpartition(\".\")\n            post[\"filename\"] = name if sep else rest\n\n\nclass TwibooruPostExtractor(TwibooruExtractor):\n    \"\"\"Extractor for single twibooru posts\"\"\"\n    subcategory = \"post\"\n    request_interval = (0.5, 1.5)\n    pattern = BASE_PATTERN + r\"/(\\d+)\"\n    example = \"https://twibooru.org/12345\"\n\n    def __init__(self, match):\n        TwibooruExtractor.__init__(self, match)\n        self.post_id = match[1]\n\n    def posts(self):\n        return (self.api.post(self.post_id),)\n\n\nclass TwibooruSearchExtractor(TwibooruExtractor):\n    \"\"\"Extractor for twibooru search results\"\"\"\n    subcategory = \"search\"\n    directory_fmt = (\"{category}\", \"{search_tags}\")\n    pattern = BASE_PATTERN + r\"/(?:search/?\\?([^#]+)|tags/([^/?#]+))\"\n    example = \"https://twibooru.org/search?q=TAG\"\n\n    def __init__(self, match):\n        TwibooruExtractor.__init__(self, match)\n        query, tag = match.groups()\n        if tag:\n            q = tag.replace(\"+\", \" \")\n            for old, new in (\n                (\"-colon-\"  , \":\"),\n                (\"-dash-\"   , \"-\"),\n                (\"-dot-\"    , \".\"),\n                (\"-plus-\"   , \"+\"),\n                (\"-fwslash-\", \"/\"),\n                (\"-bwslash-\", \"\\\\\"),\n            ):\n                if old in q:\n                    q = q.replace(old, new)\n            self.params = {\"q\": text.unquote(text.unquote(q))}\n        else:\n            self.params = text.parse_query(query)\n\n    def metadata(self):\n        return {\"search_tags\": self.params.get(\"q\", \"\")}\n\n    def posts(self):\n        return self.api.search(self.params)\n\n\nclass TwibooruGalleryExtractor(TwibooruExtractor):\n    \"\"\"Extractor for twibooru galleries\"\"\"\n    subcategory = \"gallery\"\n    directory_fmt = (\"{category}\", \"galleries\",\n                     \"{gallery[id]} {gallery[title]}\")\n    pattern = BASE_PATTERN + r\"/galleries/(\\d+)\"\n    example = \"https://twibooru.org/galleries/12345\"\n\n    def __init__(self, match):\n        TwibooruExtractor.__init__(self, match)\n        self.gallery_id = match[1]\n\n    def metadata(self):\n        return {\"gallery\": self.api.gallery(self.gallery_id)}\n\n    def posts(self):\n        gallery_id = \"gallery_id:\" + self.gallery_id\n        params = {\"sd\": \"desc\", \"sf\": gallery_id, \"q\" : gallery_id}\n        return self.api.search(params)\n\n\nclass TwibooruAPI():\n    \"\"\"Interface for the Twibooru API\n\n    https://twibooru.org/pages/api\n    \"\"\"\n\n    def __init__(self, extractor):\n        self.extractor = extractor\n        self.root = \"https://twibooru.org/api\"\n\n    def gallery(self, gallery_id):\n        endpoint = \"/v3/galleries/\" + gallery_id\n        return self._call(endpoint)[\"gallery\"]\n\n    def post(self, post_id):\n        endpoint = \"/v3/posts/\" + post_id\n        return self._call(endpoint)[\"post\"]\n\n    def search(self, params):\n        endpoint = \"/v3/search/posts\"\n        return self._pagination(endpoint, params)\n\n    def _call(self, endpoint, params=None):\n        url = self.root + endpoint\n\n        while True:\n            response = self.extractor.request(url, params=params, fatal=None)\n\n            if response.status_code < 400:\n                return response.json()\n\n            if response.status_code == 429:\n                until = self.parse_datetime_iso(\n                    response.headers[\"X-RL-Reset\"][:19])\n                # wait an extra minute, just to be safe\n                self.extractor.wait(until=until, adjust=60.0)\n                continue\n\n            # error\n            self.extractor.log.debug(response.content)\n            raise self.extractor.exc.HttpError(\"\", response)\n\n    def _pagination(self, endpoint, params):\n        extr = self.extractor\n\n        if api_key := extr.config(\"api-key\"):\n            params[\"key\"] = api_key\n\n        if filter_id := extr.config(\"filter\"):\n            params[\"filter_id\"] = filter_id\n        elif not api_key:\n            params[\"filter_id\"] = \"2\"\n\n        params[\"page\"] = extr.page_start\n        params[\"per_page\"] = per_page = extr.per_page\n\n        while True:\n            data = self._call(endpoint, params)\n            yield from data[\"posts\"]\n\n            if len(data[\"posts\"]) < per_page:\n                return\n            params[\"page\"] += 1\n"
  },
  {
    "path": "gallery_dl/extractor/twitter.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2016-2026 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://x.com/\"\"\"\n\nfrom .common import Extractor, Message, Dispatch\nfrom .. import text, util, dt\nimport itertools\nimport random\n\nBASE_PATTERN = (r\"(?:https?://)?(?:www\\.|mobile\\.)?\"\n                r\"(?:(?:[fv]x)?twitter|(?:fix(?:up|v))?x)\\.com\")\nUSER_PATTERN = BASE_PATTERN + r\"/([^/?#]+)\"\n\n\nclass TwitterExtractor(Extractor):\n    \"\"\"Base class for twitter extractors\"\"\"\n    category = \"twitter\"\n    directory_fmt = (\"{category}\", \"{user[name]}\")\n    filename_fmt = \"{tweet_id}_{num}.{extension}\"\n    archive_fmt = \"{tweet_id}_{retweet_id}_{num}\"\n    cookies_domain = \".x.com\"\n    cookies_names = (\"auth_token\",)\n    root = \"https://x.com\"\n    browser = \"firefox\"\n\n    def __init__(self, match):\n        Extractor.__init__(self, match)\n        self.user = match[1]\n\n    def _init(self):\n        self.unavailable = self.config(\"unavailable\", False)\n        self.textonly = self.config(\"text-tweets\", False)\n        self.articles = self.config(\"articles\", True)\n        self.retweets = self.config(\"retweets\", False)\n        self.replies = self.config(\"replies\", True)\n        self.twitpic = self.config(\"twitpic\", False)\n        self.pinned = self.config(\"pinned\", False)\n        self.quoted = self.config(\"quoted\", False)\n        self.ads = self.config(\"ads\", False)\n        self.cards = self.config(\"cards\", False)\n        self.cards_blacklist = self.config(\"cards-blacklist\")\n        self.videos_files = self.config(\"videos\", True)\n        self.videos_previews = self.config(\"previews\", False)\n        self.videos_ytdl = self.videos_files == \"ytdl\"\n        self.videos = self.videos_files or self.videos_previews\n\n        if not self.config(\"transform\", True):\n            self._transform_community = \\\n                self._transform_tweet = \\\n                self._transform_user = util.identity\n\n        self._cursor = None\n        self._user = None\n        self._user_obj = None\n        self._user_cache = {}\n        self._init_sizes()\n\n    def _init_sizes(self):\n        size = self.config(\"size\")\n        if size is None:\n            self._size_image = \"orig\"\n            self._size_fallback = (\"4096x4096\", \"large\", \"medium\", \"small\")\n        else:\n            if isinstance(size, str):\n                size = size.split(\",\")\n            self._size_image = size[0]\n            self._size_fallback = size[1:]\n\n    def items(self):\n        self.login()\n        self.api = TwitterAPI(self)\n        metadata = self.metadata()\n        seen_tweets = set() if self.config(\"unique\", True) else None\n\n        if self.twitpic:\n            self._find_twitpic = text.re(\n                r\"https?(://twitpic\\.com/(?!photos/)\\w+)\").findall\n\n        tweets = self.tweets()\n        if self.config(\"expand\"):\n            tweets = self._expand_tweets(tweets)\n        for tweet in tweets:\n\n            if \"legacy\" in tweet:\n                data = tweet[\"legacy\"]\n            else:\n                data = tweet\n\n            if not self.retweets and \"retweeted_status_id_str\" in data:\n                self.log.debug(\"Skipping %s (retweet)\", data[\"id_str\"])\n                continue\n            if not self.quoted and \"quoted_by_id_str\" in data:\n                self.log.debug(\"Skipping %s (quoted tweet)\", data[\"id_str\"])\n                continue\n            if \"in_reply_to_user_id_str\" in data and (\n                not self.replies or (\n                    self.replies == \"self\" and\n                    data[\"user_id_str\"] !=\n                    (self._user_obj[\"rest_id\"] if self._user else\n                     data[\"in_reply_to_user_id_str\"])\n                )\n            ):\n                self.log.debug(\"Skipping %s (reply)\", data[\"id_str\"])\n                continue\n\n            if seen_tweets is not None:\n                if data[\"id_str\"] in seen_tweets:\n                    self.log.debug(\n                        \"Skipping %s (previously seen)\", data[\"id_str\"])\n                    continue\n                seen_tweets.add(data[\"id_str\"])\n\n            if \"withheld_scope\" in data:\n                txt = data.get(\"full_text\") or data.get(\"text\") or \"\"\n                self.log.warning(\"'%s' (%s)\", txt, data[\"id_str\"])\n\n            files = self._extract_files(data, tweet)\n            if not files and not self.textonly:\n                continue\n\n            tdata = self._transform_tweet(tweet)\n            tdata.update(metadata)\n            tdata[\"count\"] = len(files)\n            yield Message.Directory, \"\", tdata\n\n            tdata.pop(\"source_id\", None)\n            tdata.pop(\"source_user\", None)\n            tdata.pop(\"sensitive_flags\", None)\n\n            for tdata[\"num\"], file in enumerate(files, 1):\n                file.update(tdata)\n                url = file.pop(\"url\")\n                if \"extension\" not in file:\n                    text.nameext_from_url(url, file)\n                yield Message.Url, url, file\n\n    def _extract_files(self, data, tweet):\n        files = []\n\n        if \"extended_entities\" in data:\n            try:\n                self._extract_media(\n                    data, data[\"extended_entities\"][\"media\"], files)\n            except Exception as exc:\n                self.log.traceback(exc)\n                self.log.warning(\n                    \"%s: Error while extracting media files (%s: %s)\",\n                    data[\"id_str\"], exc.__class__.__name__, exc)\n\n        if self.cards and \"card\" in tweet:\n            try:\n                self._extract_card(tweet, files)\n            except Exception as exc:\n                self.log.traceback(exc)\n                self.log.warning(\n                    \"%s: Error while extracting Card files (%s: %s)\",\n                    data[\"id_str\"], exc.__class__.__name__, exc)\n\n        if self.articles and \"article\" in tweet:\n            try:\n                self._extract_article(tweet, files)\n            except Exception as exc:\n                self.log.traceback(exc)\n                self.log.warning(\n                    \"%s: Error while extracting article files (%s: %s)\",\n                    data[\"id_str\"], exc.__class__.__name__, exc)\n\n        if self.twitpic:\n            try:\n                self._extract_twitpic(data, files)\n            except Exception as exc:\n                self.log.traceback(exc)\n                self.log.warning(\n                    \"%s: Error while extracting TwitPic files (%s: %s)\",\n                    data[\"id_str\"], exc.__class__.__name__, exc)\n\n        return files\n\n    def _extract_media(self, tweet, entities, files):\n        flags_tweet = None\n\n        for media in entities:\n\n            if \"sensitive_media_warning\" in media:\n                flags_media = media[\"sensitive_media_warning\"]\n\n                flags = []\n                if \"adult_content\" in flags_media:\n                    flags.append(\"Nudity\")\n                if \"other\" in flags_media:\n                    flags.append(\"Sensitive\")\n                if \"graphic_violence\" in flags_media:\n                    flags.append(\"Violence\")\n\n                if flags_tweet is None:\n                    flags_tweet = set(flags)\n                else:\n                    flags_tweet.update(flags)\n                flags_media = flags\n            else:\n                flags_media = ()\n\n            if \"ext_media_availability\" in media:\n                ext = media[\"ext_media_availability\"]\n                if ext.get(\"status\") == \"Unavailable\":\n                    self.log.warning(\"Media unavailable (%s - '%s')\",\n                                     tweet[\"id_str\"], ext.get(\"reason\"))\n                    if not self.unavailable:\n                        continue\n\n            if \"video_info\" in media:\n                if self.videos_ytdl:\n                    url = f\"ytdl:{self.root}/i/web/status/{tweet['id_str']}\"\n                    file = {\"url\": url, \"extension\": \"mp4\"}\n                elif self.videos_files:\n                    video_info = media[\"video_info\"]\n                    variant = max(\n                        video_info[\"variants\"],\n                        key=lambda v: v.get(\"bitrate\", 0),\n                    )\n                    file = {\n                        \"url\"     : variant[\"url\"],\n                        \"bitrate\" : variant.get(\"bitrate\", 0),\n                        \"duration\": video_info.get(\n                            \"duration_millis\", 0) / 1000,\n                    }\n                else:\n                    file = None\n\n                if file is not None:\n                    file[\"type\"] = media.get(\"type\")\n                    file[\"width\"] = media[\"original_info\"].get(\"width\", 0)\n                    file[\"height\"] = media[\"original_info\"].get(\"height\", 0)\n                    file[\"description\"] = media.get(\"ext_alt_text\")\n                    file[\"sensitive_flags\"] = flags_media\n                    self._extract_media_source(file, media)\n                    files.append(file)\n\n                if not self.videos_previews:\n                    continue\n                media[\"type\"] = \"preview\"\n\n            if \"media_url_https\" in media:\n                url = media[\"media_url_https\"]\n                if url[-4] == \".\":\n                    base, _, fmt = url.rpartition(\".\")\n                    base += \"?format=\" + fmt + \"&name=\"\n                else:\n                    base = url.rpartition(\"=\")[0] + \"=\"\n                file = text.nameext_from_url(url, {\n                    \"url\"      : base + self._size_image,\n                    \"_fallback\": self._image_fallback(base),\n                })\n            else:\n                files.append({\"url\": media[\"media_url\"]})\n                continue\n\n            file[\"type\"] = media.get(\"type\")\n            file[\"width\"] = media[\"original_info\"].get(\"width\", 0)\n            file[\"height\"] = media[\"original_info\"].get(\"height\", 0)\n            file[\"description\"] = media.get(\"ext_alt_text\")\n            file[\"sensitive_flags\"] = flags_media\n            self._extract_media_source(file, media)\n            files.append(file)\n\n        tweet[\"sensitive_flags\"] = \\\n            () if flags_tweet is None else sorted(flags_tweet)\n\n    def _extract_media_source(self, dest, media):\n        dest[\"source_id\"] = 0\n\n        if \"source_status_id_str\" in media:\n            try:\n                dest[\"source_id\"] = text.parse_int(\n                    media[\"source_status_id_str\"])\n                dest[\"source_user\"] = self._transform_user(\n                    media[\"additional_media_info\"][\"source_user\"]\n                    [\"user_results\"][\"result\"])\n            except Exception:\n                pass\n\n    def _image_fallback(self, base):\n        for fmt in self._size_fallback:\n            yield base + fmt\n\n    def _extract_components(self, tweet, data, files):\n        for component_id in data[\"components\"]:\n            com = data[\"component_objects\"][component_id]\n            for conv in com[\"data\"].get(\"conversation_preview\") or ():\n                for url in conv.get(\"mediaUrls\") or ():\n                    files.append({\"url\": url})\n\n    def _extract_card(self, tweet, files):\n        card = tweet[\"card\"]\n        if \"legacy\" in card:\n            card = card[\"legacy\"]\n\n        name = card[\"name\"].rpartition(\":\")[2]\n        bvals = card[\"binding_values\"]\n        if isinstance(bvals, list):\n            bvals = {bval[\"key\"]: bval[\"value\"]\n                     for bval in card[\"binding_values\"]}\n\n        if cbl := self.cards_blacklist:\n            if name in cbl:\n                return\n            if \"vanity_url\" in bvals:\n                domain = bvals[\"vanity_url\"][\"string_value\"]\n                if domain in cbl or name + \":\" + domain in cbl:\n                    return\n\n        if name in {\"summary\", \"summary_large_image\"}:\n            for prefix in (\"photo_image_full_size_\",\n                           \"summary_photo_image_\",\n                           \"thumbnail_image_\"):\n                for size in (\"original\", \"x_large\", \"large\", \"small\"):\n                    key = prefix + size\n                    if key in bvals:\n                        value = bvals[key].get(\"image_value\")\n                        if value and \"url\" in value:\n                            base, sep, size = value[\"url\"].rpartition(\"&name=\")\n                            if sep:\n                                base += sep\n                                value[\"url\"] = base + self._size_image\n                                value[\"_fallback\"] = self._image_fallback(base)\n                            files.append(value)\n                            return\n        elif name == \"unified_card\":\n            data = util.json_loads(bvals[\"unified_card\"][\"string_value\"])\n            if \"media_entities\" in data:\n                self._extract_media(\n                    tweet, data[\"media_entities\"].values(), files)\n            if \"component_objects\" in data:\n                self._extract_components(tweet, data, files)\n            return\n\n        if self.cards == \"ytdl\":\n            tweet_id = tweet.get(\"rest_id\") or tweet[\"id_str\"]\n            url = f\"ytdl:{self.root}/i/web/status/{tweet_id}\"\n            files.append({\"url\": url})\n\n    def _extract_article(self, tweet, files):\n        article = tweet[\"article\"][\"article_results\"][\"result\"]\n\n        if media := article.get(\"cover_media\"):\n            info = media[\"media_info\"]\n            files.append({\n                \"media_id\" : media[\"media_id\"],\n                \"media_key\": media[\"media_key\"],\n                \"url\"      : info[\"original_img_url\"],\n                \"width\"    : info[\"original_img_width\"],\n                \"height\"   : info[\"original_img_height\"],\n                \"type\"     : \"article:cover\",\n            })\n\n        for media in article[\"media_entities\"]:\n            info = media[\"media_info\"]\n            files.append({\n                \"media_id\" : media[\"media_id\"],\n                \"media_key\": media[\"media_key\"],\n                \"url\"      : info[\"original_img_url\"],\n                \"width\"    : info[\"original_img_width\"],\n                \"height\"   : info[\"original_img_height\"],\n                \"type\"     : \"article:cover\",\n            })\n\n    def _extract_twitpic(self, tweet, files):\n        urls = {}\n\n        # collect URLs from entities\n        for url in tweet[\"entities\"].get(\"urls\") or ():\n            url = url.get(\"expanded_url\") or url.get(\"url\") or \"\"\n            if not url or \"//twitpic.com/\" not in url or \"/photos/\" in url:\n                continue\n            if url.startswith(\"http:\"):\n                url = \"https\" + url[4:]\n            urls[url] = None\n\n        # collect URLs from text\n        for url in self._find_twitpic(\n                tweet.get(\"full_text\") or tweet.get(\"text\") or \"\"):\n            urls[\"https\" + url] = None\n\n        # extract actual URLs\n        for url in urls:\n            response = self.request(url, fatal=False)\n            if response.status_code >= 400:\n                continue\n            if url := text.extr(\n                    response.text, 'name=\"twitter:image\" value=\"', '\"'):\n                files.append({\"url\": url})\n\n    def _transform_tweet(self, tweet):\n        if \"legacy\" in tweet:\n            legacy = tweet[\"legacy\"]\n        else:\n            legacy = tweet\n        tweet_id = int(legacy[\"id_str\"])\n\n        if \"author\" in tweet:\n            author = tweet[\"author\"]\n        elif \"core\" in tweet:\n            try:\n                author = tweet[\"core\"][\"user_results\"][\"result\"]\n            except KeyError:\n                self.log.warning(\"%s: Missing 'author' data\", tweet_id)\n                author = util.NONE\n        else:\n            author = tweet[\"user\"]\n        author = self._transform_user(author)\n\n        if tweet_id >= 300_000_000_000_000:\n            date = self._tweetid_to_datetime(tweet_id)\n        else:\n            try:\n                date = dt.parse(\n                    legacy[\"created_at\"], \"%a %b %d %H:%M:%S %z %Y\")\n            except Exception:\n                date = dt.NONE\n        source = tweet.get(\"source\")\n\n        tget = legacy.get\n        tdata = {\n            \"tweet_id\"      : tweet_id,\n            \"retweet_id\"    : text.parse_int(\n                tget(\"retweeted_status_id_str\")),\n            \"quote_id\"      : text.parse_int(\n                tget(\"quoted_by_id_str\")),\n            \"reply_id\"      : text.parse_int(\n                tget(\"in_reply_to_status_id_str\")),\n            \"conversation_id\": text.parse_int(\n                tget(\"conversation_id_str\")),\n            \"source_id\"     : 0,\n            \"date\"          : date,\n            \"author\"        : author,\n            \"user\"          : self._user or author,\n            \"lang\"          : legacy[\"lang\"],\n            \"source\"        : text.extr(source, \">\", \"<\") if source else \"\",\n            \"sensitive\"     : tget(\"possibly_sensitive\"),\n            \"sensitive_flags\": tget(\"sensitive_flags\"),\n            \"favorite_count\": tget(\"favorite_count\"),\n            \"quote_count\"   : tget(\"quote_count\"),\n            \"reply_count\"   : tget(\"reply_count\"),\n            \"retweet_count\" : tget(\"retweet_count\"),\n            \"bookmark_count\": tget(\"bookmark_count\"),\n        }\n\n        if \"views\" in tweet:\n            try:\n                tdata[\"view_count\"] = int(tweet[\"views\"][\"count\"])\n            except Exception:\n                tdata[\"view_count\"] = 0\n        else:\n            tdata[\"view_count\"] = 0\n\n        if \"note_tweet\" in tweet:\n            note = tweet[\"note_tweet\"][\"note_tweet_results\"][\"result\"]\n            content = note[\"text\"]\n            entities = note[\"entity_set\"]\n        else:\n            content = tget(\"full_text\") or tget(\"text\") or \"\"\n            entities = legacy[\"entities\"]\n\n        if \"author_community_relationship\" in tweet:\n            tdata[\"community\"] = self._transform_community(\n                tweet[\"author_community_relationship\"]\n                [\"community_results\"][\"result\"])\n\n        if hashtags := entities.get(\"hashtags\"):\n            tdata[\"hashtags\"] = [t[\"text\"] for t in hashtags]\n\n        if mentions := entities.get(\"user_mentions\"):\n            tdata[\"mentions\"] = [{\n                \"id\": text.parse_int(u[\"id_str\"]),\n                \"name\": u[\"screen_name\"],\n                \"nick\": u[\"name\"],\n            } for u in mentions]\n\n        content = text.unescape(content)\n        if urls := entities.get(\"urls\"):\n            for url in urls:\n                try:\n                    content = content.replace(url[\"url\"], url[\"expanded_url\"])\n                except KeyError:\n                    pass\n        txt, _, tco = content.rpartition(\" \")\n        tdata[\"content\"] = txt if tco.startswith(\"https://t.co/\") else content\n\n        if \"pinned\" in tweet:\n            tdata[\"pinned\"] = True\n        if \"birdwatch_pivot\" in tweet:\n            try:\n                tdata[\"birdwatch\"] = \\\n                    tweet[\"birdwatch_pivot\"][\"subtitle\"][\"text\"]\n            except KeyError:\n                self.log.debug(\"Unable to extract 'birdwatch' note from %s\",\n                               tweet[\"birdwatch_pivot\"])\n        if \"in_reply_to_screen_name\" in legacy:\n            tdata[\"reply_to\"] = legacy[\"in_reply_to_screen_name\"]\n        if \"quoted_by\" in legacy:\n            tdata[\"quote_by\"] = legacy[\"quoted_by\"]\n        if \"extended_entities\" in legacy:\n            self._extract_media_source(\n                tdata, legacy[\"extended_entities\"][\"media\"][0])\n        if tdata[\"retweet_id\"]:\n            tdata[\"content\"] = f\"RT @{author['name']}: {tdata['content']}\"\n            tdata[\"date_original\"] = self._tweetid_to_datetime(\n                tdata[\"retweet_id\"])\n\n        return tdata\n\n    def _transform_community(self, com):\n        try:\n            cid = com.get(\"id_str\") or com[\"rest_id\"]\n        except KeyError:\n            return {}\n\n        try:\n            return self._user_cache[f\"C#{cid}\"]\n        except KeyError:\n            pass\n\n        admin = creator = banner = None\n        try:\n            if results := com.get(\"admin_results\"):\n                admin = results[\"result\"][\"core\"][\"screen_name\"]\n        except Exception:\n            pass\n        try:\n            if results := com.get(\"creator_results\"):\n                creator = results[\"result\"][\"core\"][\"screen_name\"]\n        except Exception:\n            pass\n        try:\n            if results := com.get(\"custom_banner_media\"):\n                banner = results[\"media_info\"][\"original_img_url\"]\n        except Exception:\n            pass\n\n        self._user_cache[f\"C#{cid}\"] = cdata = {\n            \"id\": text.parse_int(cid),\n            \"name\": com.get(\"name\"),\n            \"description\": com.get(\"description\"),\n            \"date\": dt.parse_ts(com.get(\"created_at\", 0) / 1000),\n            \"nsfw\": com.get(\"is_nsfw\"),\n            \"role\": com.get(\"role\"),\n            \"member_count\": com.get(\"member_count\"),\n            \"rules\": [rule[\"name\"] for rule in com.get(\"rules\", ())],\n            \"admin\"  : admin,\n            \"creator\": creator,\n            \"banner\" : banner,\n        }\n\n        return cdata\n\n    def _transform_user(self, user):\n        try:\n            uid = user.get(\"rest_id\") or user[\"id_str\"]\n        except KeyError:\n            # private/invalid user (#4349)\n            return {}\n\n        try:\n            return self._user_cache[uid]\n        except KeyError:\n            pass\n\n        if \"core\" in user:\n            core = user[\"core\"]\n            legacy = user[\"legacy\"]\n        else:\n            core = legacy = user\n\n        lget = legacy.get\n        if lget(\"withheld_scope\"):\n            self.log.warning(\"'%s'\", lget(\"description\"))\n\n        entities = legacy[\"entities\"]\n        self._user_cache[uid] = udata = {\n            \"id\"              : text.parse_int(uid),\n            \"name\"            : core.get(\"screen_name\"),\n            \"nick\"            : core.get(\"name\"),\n            \"date\"            : dt.parse(\n                core[\"created_at\"], \"%a %b %d %H:%M:%S %z %Y\"),\n            \"profile_banner\"  : lget(\"profile_banner_url\", \"\"),\n            \"favourites_count\": lget(\"favourites_count\"),\n            \"followers_count\" : lget(\"followers_count\"),\n            \"friends_count\"   : lget(\"friends_count\"),\n            \"listed_count\"    : lget(\"listed_count\"),\n            \"media_count\"     : lget(\"media_count\"),\n            \"statuses_count\"  : lget(\"statuses_count\"),\n        }\n\n        if \"core\" in user:\n            udata[\"location\"] = user[\"location\"].get(\"location\")\n            udata[\"verified\"] = user[\"verification\"][\"verified\"]\n            udata[\"protected\"] = user[\"privacy\"][\"protected\"]\n            udata[\"profile_image\"] = user[\"avatar\"].get(\n                \"image_url\", \"\").replace(\"_normal.\", \".\")\n        else:\n            udata[\"location\"] = user[\"location\"]\n            udata[\"verified\"] = user[\"verified\"]\n            udata[\"protected\"] = user[\"protected\"]\n            udata[\"profile_image\"] = user[\"profile_image_url_https\"].replace(\n                \"_normal.\", \".\")\n\n        descr = legacy[\"description\"]\n        if urls := entities[\"description\"].get(\"urls\"):\n            for url in urls:\n                try:\n                    descr = descr.replace(url[\"url\"], url[\"expanded_url\"])\n                except KeyError:\n                    pass\n        udata[\"description\"] = descr\n\n        if \"url\" in entities:\n            url = entities[\"url\"][\"urls\"][0]\n            udata[\"url\"] = url.get(\"expanded_url\") or url.get(\"url\")\n\n        if self.config(\"metadata-user\", False) and (about := self.cache(\n                self.api.user_about_account, udata[\"name\"]).get(\n                \"about_profile\")):\n            udata[\"source\"] = about.get(\"source\")\n            udata[\"based_in\"] = about.get(\"account_based_in\")\n            udata[\"location_accurate\"] = about.get(\"location_accurate\")\n            udata[\"name_changes\"] = (d := about.get(\n                \"username_changes\")) and d.get(\"count\") or 0\n\n        return udata\n\n    def _assign_user(self, user):\n        self._user_obj = user\n        self._user = self._transform_user(user)\n\n    def _users_result(self, users):\n        userfmt = self.config(\"users\")\n        if not userfmt or userfmt == \"user\":\n            cls = TwitterUserExtractor\n            fmt = (self.root + \"/i/user/{rest_id}\").format_map\n        elif userfmt == \"timeline\":\n            cls = TwitterTimelineExtractor\n            fmt = (self.root + \"/id:{rest_id}/timeline\").format_map\n        elif userfmt == \"media\":\n            cls = TwitterMediaExtractor\n            fmt = (self.root + \"/id:{rest_id}/media\").format_map\n        elif userfmt == \"tweets\":\n            cls = TwitterTweetsExtractor\n            fmt = (self.root + \"/id:{rest_id}/tweets\").format_map\n        else:\n            cls = None\n            fmt = userfmt.format_map\n\n        for user in users:\n            user[\"_extractor\"] = cls\n            yield Message.Queue, fmt(user), user\n\n    def _expand_tweets(self, tweets):\n        seen = set()\n        for tweet in tweets:\n            obj = tweet[\"legacy\"] if \"legacy\" in tweet else tweet\n            cid = obj.get(\"conversation_id_str\")\n            if not cid:\n                if cid is False:\n                    yield tweet\n                else:\n                    tid = obj[\"id_str\"]\n                    self.log.warning(\n                        \"Unable to expand %s (no 'conversation_id')\", tid)\n                continue\n            if cid in seen:\n                self.log.debug(\n                    \"Skipping expansion of %s (previously seen)\", cid)\n                continue\n            seen.add(cid)\n            try:\n                yield from self.api.tweet_detail(cid)\n            except Exception:\n                yield tweet\n\n    def _make_tweet(self, user, url, id_str):\n        return {\n            \"id_str\": id_str,\n            \"conversation_id_str\": False,\n            \"lang\": None,\n            \"user\": user,\n            \"source\": \"><\",\n            \"entities\": {},\n            \"extended_entities\": {\n                \"media\": [\n                    {\n                        \"original_info\": {},\n                        \"media_url\": url,\n                    },\n                ],\n            },\n        }\n\n    def _init_cursor(self):\n        cursor = self.config(\"cursor\", True)\n        if not cursor:\n            self._update_cursor = util.identity\n        elif isinstance(cursor, str):\n            return cursor\n\n    def _update_cursor(self, cursor):\n        self.log.debug(\"Cursor: %s\", cursor)\n        self._cursor = cursor\n        return cursor\n\n    def _tweetid_to_datetime(self, tweet_id):\n        return dt.parse_ts(((tweet_id >> 22) + 1_288_834_974_657) / 1000)\n\n    def metadata(self):\n        \"\"\"Return general metadata\"\"\"\n        return {}\n\n    def tweets(self):\n        \"\"\"Yield all relevant tweet objects\"\"\"\n\n    def finalize(self, status):\n        if status and self._cursor:\n            self.log.info(\"Use '-o cursor=%s' to continue downloading \"\n                          \"from the current position\", self._cursor)\n\n    def login(self):\n        if self.cookies_check(self.cookies_names):\n            return\n\n        username, password = self._get_auth_info()\n        if username:\n            return self.cookies_update(self.cache(\n                self._login_impl, username, password, _mem=False))\n\n    def _login_impl(self, username, password):\n        self.log.error(\"Login with username & password is no longer \"\n                       \"supported. Use browser cookies instead.\")\n        return {}\n\n\nclass TwitterHomeExtractor(TwitterExtractor):\n    \"\"\"Extractor for Twitter home timelines\"\"\"\n    subcategory = \"home\"\n    pattern = BASE_PATTERN + r\"/home(?:/fo(?:llowing|r[-_ ]?you()))?/?$\"\n    example = \"https://x.com/home\"\n\n    def tweets(self):\n        if self.groups[0] is None:\n            return self.api.home_latest_timeline()\n        return self.api.home_timeline()\n\n\nclass TwitterNotificationsExtractor(TwitterExtractor):\n    \"\"\"Extractor for Twitter notifications timelines\"\"\"\n    subcategory = \"notifications\"\n    pattern = BASE_PATTERN + r\"/(?:notifications|i/timeline())\"\n    example = \"https://x.com/notifications\"\n\n    def tweets(self):\n        return self.api.notifications_devicefollow()\n\n\nclass TwitterSearchExtractor(TwitterExtractor):\n    \"\"\"Extractor for Twitter search results\"\"\"\n    subcategory = \"search\"\n    pattern = BASE_PATTERN + r\"/search/?\\?(?:[^&#]+&)*q=([^&#]+)\"\n    example = \"https://x.com/search?q=QUERY\"\n\n    def metadata(self):\n        return {\"search\": text.unquote(self.user)}\n\n    def tweets(self):\n        query = text.unquote(self.user.replace(\"+\", \" \"))\n\n        user = None\n        for item in query.split():\n            item = item.strip(\"()\")\n            if item.startswith(\"from:\"):\n                if user:\n                    user = None\n                    break\n                else:\n                    user = item[5:]\n\n        if user is not None:\n            try:\n                self._assign_user(self.api.user_by_screen_name(user))\n            except KeyError:\n                pass\n\n        return self.api.search_timeline(query)\n\n\nclass TwitterHashtagExtractor(TwitterExtractor):\n    \"\"\"Extractor for Twitter hashtags\"\"\"\n    subcategory = \"hashtag\"\n    pattern = BASE_PATTERN + r\"/hashtag/([^/?#]+)\"\n    example = \"https://x.com/hashtag/NAME\"\n\n    def items(self):\n        url = f\"{self.root}/search?q=%23{self.user}\"\n        data = {\"_extractor\": TwitterSearchExtractor}\n        yield Message.Queue, url, data\n\n\nclass TwitterUserExtractor(Dispatch, TwitterExtractor):\n    \"\"\"Extractor for a Twitter user\"\"\"\n    pattern = (BASE_PATTERN + r\"/(?:\"\n               r\"([^/?#]+)/?(?:$|\\?|#)\"\n               r\"|i(?:/user/|ntent/user\\?user_id=)(\\d+))\")\n    example = \"https://x.com/USER\"\n\n    def items(self):\n        user, user_id = self.groups\n        if user_id is not None:\n            user = \"id:\" + user_id\n\n        base = f\"{self.root}/{user}/\"\n        return self._dispatch_extractors((\n            (TwitterInfoExtractor      , base + \"info\"),\n            (TwitterAvatarExtractor    , base + \"photo\"),\n            (TwitterBackgroundExtractor, base + \"header_photo\"),\n            (TwitterTimelineExtractor  , base + \"timeline\"),\n            (TwitterTweetsExtractor    , base + \"tweets\"),\n            (TwitterMediaExtractor     , base + \"media\"),\n            (TwitterWithRepliesExtractor, base + \"with_replies\"),\n            (TwitterHighlightsExtractor, base + \"highlights\"),\n            (TwitterLikesExtractor     , base + \"likes\"),\n        ), (\"timeline\",), (\n            (\"with-replies\", \"replies\", None),\n        ))\n\n\nclass TwitterTimelineExtractor(TwitterExtractor):\n    \"\"\"Extractor for a Twitter user timeline\"\"\"\n    subcategory = \"timeline\"\n    pattern = USER_PATTERN + r\"/timeline(?!\\w)\"\n    example = \"https://x.com/USER/timeline\"\n\n    def _init_cursor(self):\n        if self._cursor:\n            return self._cursor.partition(\"/\")[2] or None\n        return None\n\n    def _update_cursor(self, cursor):\n        if cursor:\n            self._cursor = self._cursor_prefix + cursor\n            self.log.debug(\"Cursor: %s\", self._cursor)\n        else:\n            self._cursor = None\n        return cursor\n\n    def tweets(self):\n        reset = False\n\n        cursor = self.config(\"cursor\", True)\n        if not cursor:\n            self._update_cursor = util.identity\n        elif isinstance(cursor, str):\n            self._cursor = cursor\n        else:\n            cursor = None\n\n        if cursor:\n            state = cursor.partition(\"/\")[0]\n            state, _, tweet_id = state.partition(\"_\")\n            state = text.parse_int(state, 1)\n        else:\n            state = 1\n\n        if state <= 1:\n            self._cursor_prefix = \"1/\"\n\n            # yield initial batch of (media) tweets\n            tweet = None\n            for tweet in self._select_tweet_source()(self.user):\n                yield tweet\n            if tweet is None and not cursor:\n                return\n            tweet_id = tweet[\"rest_id\"]\n\n            state = reset = 2\n        else:\n            self.api._user_id_by_screen_name(self.user)\n\n        # build search query\n        query = f\"from:{self._user['name']} max_id:{tweet_id}\"\n        if self.retweets:\n            query += \" include:retweets include:nativeretweets\"\n\n        if state <= 2:\n            self._cursor_prefix = f\"2_{tweet_id}/\"\n            if reset:\n                self._cursor = self._cursor_prefix\n\n            if not self.textonly:\n                # try to search for media-only tweets\n                tweet = None\n                for tweet in self.api.search_timeline(query + \" filter:links\"):\n                    yield tweet\n                if tweet is not None:\n                    return self._update_cursor(None)\n\n            state = reset = 3\n\n        if state <= 3:\n            # yield unfiltered search results\n            self._cursor_prefix = f\"3_{tweet_id}/\"\n            if reset:\n                self._cursor = self._cursor_prefix\n\n            yield from self.api.search_timeline(query)\n            return self._update_cursor(None)\n\n    def _select_tweet_source(self):\n        strategy = self.config(\"strategy\")\n        if strategy is None or strategy == \"auto\":\n            if self.retweets or self.textonly:\n                return self.api.user_tweets\n            else:\n                return self.api.user_media\n        if strategy == \"tweets\":\n            return self.api.user_tweets\n        if strategy == \"media\":\n            return self.api.user_media\n        if strategy == \"with_replies\":\n            return self.api.user_tweets_and_replies\n        raise self.exc.AbortExtraction(f\"Invalid strategy '{strategy}'\")\n\n\nclass TwitterTweetsExtractor(TwitterExtractor):\n    \"\"\"Extractor for Tweets from a user's Posts timeline\"\"\"\n    subcategory = \"tweets\"\n    pattern = USER_PATTERN + r\"/tweets(?!\\w)\"\n    example = \"https://x.com/USER/tweets\"\n\n    def tweets(self):\n        return self.api.user_tweets(self.user)\n\n\nclass TwitterWithRepliesExtractor(TwitterExtractor):\n    \"\"\"Extractor for Tweets from a user's Replies timeline\"\"\"\n    subcategory = \"with-replies\"\n    pattern = USER_PATTERN + r\"/with_replies(?!\\w)\"\n    example = \"https://x.com/USER/with_replies\"\n\n    def tweets(self):\n        return self.api.user_tweets_and_replies(self.user)\n\n\nclass TwitterHighlightsExtractor(TwitterExtractor):\n    \"\"\"Extractor for Tweets from a user's Highlights timeline\"\"\"\n    subcategory = \"highlights\"\n    pattern = USER_PATTERN + r\"/highlights(?!\\w)\"\n    example = \"https://x.com/USER/highlights\"\n\n    def tweets(self):\n        return self.api.user_highlights(self.user)\n\n\nclass TwitterMediaExtractor(TwitterExtractor):\n    \"\"\"Extractor for Tweets from a user's Media timeline\"\"\"\n    subcategory = \"media\"\n    pattern = USER_PATTERN + r\"/media(?!\\w)\"\n    example = \"https://x.com/USER/media\"\n\n    def tweets(self):\n        return self.api.user_media(self.user)\n\n\nclass TwitterLikesExtractor(TwitterExtractor):\n    \"\"\"Extractor for liked tweets\"\"\"\n    subcategory = \"likes\"\n    pattern = USER_PATTERN + r\"/likes(?!\\w)\"\n    example = \"https://x.com/USER/likes\"\n\n    def metadata(self):\n        return {\"user_likes\": self.user}\n\n    def tweets(self):\n        return self.api.user_likes(self.user)\n\n\nclass TwitterBookmarkExtractor(TwitterExtractor):\n    \"\"\"Extractor for bookmarked tweets\"\"\"\n    subcategory = \"bookmark\"\n    pattern = BASE_PATTERN + r\"/i/bookmarks()\"\n    example = \"https://x.com/i/bookmarks\"\n\n    def tweets(self):\n        return self.api.user_bookmarks()\n\n    def _transform_tweet(self, tweet):\n        tdata = TwitterExtractor._transform_tweet(self, tweet)\n        tdata[\"date_bookmarked\"] = dt.parse_ts(\n            (int(tweet[\"sortIndex\"] or 0) >> 20) / 1000)\n        return tdata\n\n\nclass TwitterListExtractor(TwitterExtractor):\n    \"\"\"Extractor for Twitter lists\"\"\"\n    subcategory = \"list\"\n    pattern = BASE_PATTERN + r\"/i/lists/(\\d+)/?$\"\n    example = \"https://x.com/i/lists/12345\"\n\n    def tweets(self):\n        return self.api.list_latest_tweets_timeline(self.user)\n\n\nclass TwitterListMembersExtractor(TwitterExtractor):\n    \"\"\"Extractor for members of a Twitter list\"\"\"\n    subcategory = \"list-members\"\n    pattern = BASE_PATTERN + r\"/i/lists/(\\d+)/members\"\n    example = \"https://x.com/i/lists/12345/members\"\n\n    def items(self):\n        self.login()\n        return self._users_result(TwitterAPI(self).list_members(self.user))\n\n\nclass TwitterFollowingExtractor(TwitterExtractor):\n    \"\"\"Extractor for followed users\"\"\"\n    subcategory = \"following\"\n    pattern = USER_PATTERN + r\"/following(?!\\w)\"\n    example = \"https://x.com/USER/following\"\n\n    def items(self):\n        self.api = TwitterAPI(self)\n        self.login()\n        return self._users_result(self.api.user_following(self.user))\n\n\nclass TwitterFollowersExtractor(TwitterExtractor):\n    \"\"\"Extractor for a user's followers\"\"\"\n    subcategory = \"followers\"\n    pattern = USER_PATTERN + r\"/followers(?!\\w)\"\n    example = \"https://x.com/USER/followers\"\n\n    def items(self):\n        self.api = TwitterAPI(self)\n        self.login()\n        return self._users_result(self.api.user_followers(self.user))\n\n\nclass TwitterCommunityExtractor(TwitterExtractor):\n    \"\"\"Extractor for a Twitter community\"\"\"\n    subcategory = \"community\"\n    directory_fmt = (\"{category}\", \"Communities\",\n                     \"{community[name]} ({community[id]})\")\n    archive_fmt = \"C_{community[id]}_{tweet_id}_{num}\"\n    pattern = BASE_PATTERN + r\"/i/communities/(\\d+)\"\n    example = \"https://x.com/i/communities/12345\"\n\n    def tweets(self):\n        if self.textonly:\n            return self.api.community_tweets_timeline(self.user)\n        return self.api.community_media_timeline(self.user)\n\n\nclass TwitterCommunitiesExtractor(TwitterExtractor):\n    \"\"\"Extractor for followed Twitter communities\"\"\"\n    subcategory = \"communities\"\n    directory_fmt = TwitterCommunityExtractor.directory_fmt\n    archive_fmt = TwitterCommunityExtractor.archive_fmt\n    pattern = BASE_PATTERN + r\"/([^/?#]+)/communities/?$\"\n    example = \"https://x.com/i/communities\"\n\n    def tweets(self):\n        return self.api.communities_main_page_timeline(self.user)\n\n\nclass TwitterEventExtractor(TwitterExtractor):\n    \"\"\"Extractor for Tweets from a Twitter Event\"\"\"\n    subcategory = \"event\"\n    directory_fmt = (\"{category}\", \"Events\",\n                     \"{event[id]} {event[short_title]}\")\n    pattern = BASE_PATTERN + r\"/i/events/(\\d+)\"\n    example = \"https://x.com/i/events/12345\"\n\n    def metadata(self):\n        return {\"event\": self.api.live_event(self.user)}\n\n    def tweets(self):\n        return self.api.live_event_timeline(self.user)\n\n\nclass TwitterTweetExtractor(TwitterExtractor):\n    \"\"\"Extractor for individual tweets\"\"\"\n    subcategory = \"tweet\"\n    pattern = (BASE_PATTERN + r\"/([^/?#]+|i/web)/status/(\\d+)\"\n               r\"/?(?:$|\\?|#|photo/|video/)\")\n    example = \"https://x.com/USER/status/12345\"\n\n    def __init__(self, match):\n        TwitterExtractor.__init__(self, match)\n        self.tweet_id = match[2]\n\n    def tweets(self):\n        if conversations := self.config(\"conversations\"):\n            self._accessible = (conversations == \"accessible\")\n            return self._tweets_conversation(self.tweet_id)\n\n        endpoint = self.config(\"tweet-endpoint\")\n        if endpoint == \"detail\" or endpoint in {None, \"auto\"} and \\\n                self.api.headers[\"x-twitter-auth-type\"]:\n            return self._tweets_detail(self.tweet_id)\n\n        return self._tweets_single(self.tweet_id)\n\n    def _tweets_single(self, tweet_id):\n        tweet = self.api.tweet_result_by_rest_id(tweet_id)\n\n        try:\n            self._assign_user(tweet[\"core\"][\"user_results\"][\"result\"])\n        except KeyError:\n            raise self.exc.AbortExtraction(\n                f\"'{tweet.get('reason') or 'Unavailable'}'\")\n\n        yield tweet\n\n        if not self.quoted:\n            return\n\n        while True:\n            parent_id = tweet[\"rest_id\"]\n            tweet_id = tweet[\"legacy\"].get(\"quoted_status_id_str\")\n            if not tweet_id:\n                break\n            tweet = self.api.tweet_result_by_rest_id(tweet_id)\n            tweet[\"legacy\"][\"quoted_by_id_str\"] = parent_id\n            yield tweet\n\n    def _tweets_detail(self, tweet_id):\n        tweets = []\n\n        for tweet in self.api.tweet_detail(tweet_id):\n            if tweet[\"rest_id\"] == tweet_id or \\\n                    tweet.get(\"_retweet_id_str\") == tweet_id:\n                if self._user_obj is None:\n                    self._assign_user(tweet[\"core\"][\"user_results\"][\"result\"])\n                tweets.append(tweet)\n\n                tweet_id = tweet[\"legacy\"].get(\"quoted_status_id_str\")\n                if not tweet_id:\n                    break\n\n        return tweets\n\n    def _tweets_conversation(self, tweet_id):\n        tweets = self.api.tweet_detail(tweet_id)\n        buffer = []\n\n        for tweet in tweets:\n            buffer.append(tweet)\n            if tweet[\"rest_id\"] == tweet_id or \\\n                    tweet.get(\"_retweet_id_str\") == tweet_id:\n                self._assign_user(tweet[\"core\"][\"user_results\"][\"result\"])\n                break\n        else:\n            # initial Tweet not accessible\n            if self._accessible:\n                return ()\n            return buffer\n\n        return itertools.chain(buffer, tweets)\n\n\nclass TwitterQuotesExtractor(TwitterExtractor):\n    \"\"\"Extractor for quotes of a Tweet\"\"\"\n    subcategory = \"quotes\"\n    pattern = BASE_PATTERN + r\"/(?:[^/?#]+|i/web)/status/(\\d+)/quotes\"\n    example = \"https://x.com/USER/status/12345/quotes\"\n\n    def items(self):\n        url = f\"{self.root}/search?q=quoted_tweet_id:{self.user}\"\n        data = {\"_extractor\": TwitterSearchExtractor}\n        yield Message.Queue, url, data\n\n\nclass TwitterInfoExtractor(TwitterExtractor):\n    \"\"\"Extractor for a user's profile data\"\"\"\n    subcategory = \"info\"\n    directory_fmt = (\"{category}\", \"{name}\")\n    pattern = USER_PATTERN + r\"/info\"\n    example = \"https://x.com/USER/info\"\n\n    def items(self):\n        api = TwitterAPI(self)\n\n        screen_name = self.user\n        if screen_name.startswith(\"id:\"):\n            user = self.cache(api.user_by_rest_id, screen_name[3:])\n        else:\n            user = self.cache(api.user_by_screen_name, screen_name)\n\n        return iter(((Message.Directory, \"\", self._transform_user(user)),))\n\n\nclass TwitterAvatarExtractor(TwitterExtractor):\n    subcategory = \"avatar\"\n    filename_fmt = \"avatar {date}.{extension}\"\n    archive_fmt = \"AV_{user[id]}_{date}\"\n    pattern = USER_PATTERN + r\"/photo\"\n    example = \"https://x.com/USER/photo\"\n\n    def tweets(self):\n        self.api._user_id_by_screen_name(self.user)\n        user = self._user_obj\n        url = user[\"avatar\"][\"image_url\"]\n\n        if url == (\"https://abs.twimg.com/sticky\"\n                   \"/default_profile_images/default_profile_normal.png\"):\n            return ()\n\n        url = url.replace(\"_normal.\", \".\")\n        id_str = url.rsplit(\"/\", 2)[1]\n\n        return (self._make_tweet(user, url, id_str),)\n\n\nclass TwitterBackgroundExtractor(TwitterExtractor):\n    subcategory = \"background\"\n    filename_fmt = \"background {date}.{extension}\"\n    archive_fmt = \"BG_{user[id]}_{date}\"\n    pattern = USER_PATTERN + r\"/header_photo\"\n    example = \"https://x.com/USER/header_photo\"\n\n    def tweets(self):\n        self.api._user_id_by_screen_name(self.user)\n        user = self._user_obj\n\n        try:\n            url = user[\"legacy\"][\"profile_banner_url\"]\n            _, timestamp = url.rsplit(\"/\", 1)\n        except (KeyError, ValueError):\n            return ()\n\n        id_str = str((int(timestamp) * 1000 - 1288834974657) << 22)\n        return (self._make_tweet(user, url, id_str),)\n\n\nclass TwitterImageExtractor(Extractor):\n    category = \"twitter\"\n    subcategory = \"image\"\n    pattern = r\"https?://pbs\\.twimg\\.com/media/([\\w-]+)(?:\\?format=|\\.)(\\w+)\"\n    example = \"https://pbs.twimg.com/media/ABCDE?format=jpg&name=orig\"\n\n    def __init__(self, match):\n        Extractor.__init__(self, match)\n        self.id, self.fmt = match.groups()\n        TwitterExtractor._init_sizes(self)\n\n    def items(self):\n        base = f\"https://pbs.twimg.com/media/{self.id}?format={self.fmt}&name=\"\n\n        data = {\n            \"filename\": self.id,\n            \"extension\": self.fmt,\n            \"_fallback\": TwitterExtractor._image_fallback(self, base),\n        }\n\n        yield Message.Directory, \"\", data\n        yield Message.Url, base + self._size_image, data\n\n\nclass TwitterAPI():\n    client_transaction = None\n\n    def __init__(self, extractor):\n        self.extractor = extractor\n        self.log = extractor.log\n        self.exc = extractor.exc\n\n        self.root = \"https://x.com/i/api\"\n        self._nsfw_warning = True\n        self._json_dumps = util.json_dumps\n\n        cookies = extractor.cookies\n        cookies_domain = extractor.cookies_domain\n\n        csrf = extractor.config(\"csrf\")\n        if csrf is None or csrf == \"cookies\":\n            csrf_token = cookies.get(\"ct0\", domain=cookies_domain)\n        else:\n            csrf_token = None\n        if not csrf_token:\n            csrf_token = util.generate_token()\n            cookies.set(\"ct0\", csrf_token, domain=cookies_domain)\n\n        auth_token = cookies.get(\"auth_token\", domain=cookies_domain)\n\n        self.headers = {\n            \"Accept\": \"*/*\",\n            \"Referer\": extractor.root + \"/\",\n            \"content-type\": \"application/json\",\n            \"x-guest-token\": None,\n            \"x-twitter-auth-type\": \"OAuth2Session\" if auth_token else None,\n            \"x-csrf-token\": csrf_token,\n            \"x-twitter-client-language\": \"en\",\n            \"x-twitter-active-user\": \"yes\",\n            \"x-client-transaction-id\": None,\n            \"Sec-Fetch-Dest\": \"empty\",\n            \"Sec-Fetch-Mode\": \"cors\",\n            \"Sec-Fetch-Site\": \"same-origin\",\n            \"authorization\": \"Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejR\"\n                             \"COuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu\"\n                             \"4FA33AGWWjCpTnA\",\n        }\n        self.params = {\n            \"include_profile_interstitial_type\": \"1\",\n            \"include_blocking\": \"1\",\n            \"include_blocked_by\": \"1\",\n            \"include_followed_by\": \"1\",\n            \"include_want_retweets\": \"1\",\n            \"include_mute_edge\": \"1\",\n            \"include_can_dm\": \"1\",\n            \"include_can_media_tag\": \"1\",\n            \"include_ext_is_blue_verified\": \"1\",\n            \"include_ext_verified_type\": \"1\",\n            \"include_ext_profile_image_shape\": \"1\",\n            \"skip_status\": \"1\",\n            \"cards_platform\": \"Web-12\",\n            \"include_cards\": \"1\",\n            \"include_ext_alt_text\": \"true\",\n            \"include_ext_limited_action_results\": \"true\",\n            \"include_quote_count\": \"true\",\n            \"include_reply_count\": \"1\",\n            \"tweet_mode\": \"extended\",\n            \"include_ext_views\": \"true\",\n            \"include_entities\": \"true\",\n            \"include_user_entities\": \"true\",\n            \"include_ext_media_color\": \"true\",\n            \"include_ext_media_availability\": \"true\",\n            \"include_ext_sensitive_media_warning\": \"true\",\n            \"include_ext_trusted_friends_metadata\": \"true\",\n            \"send_error_codes\": \"true\",\n            \"simple_quoted_tweet\": \"true\",\n            \"cursor\": None,\n            \"count\": \"20\",\n            \"ext\": \"mediaStats,highlightedLabel,parodyCommentaryFanLabel,\"\n                   \"voiceInfo,birdwatchPivot,superFollowMetadata,\"\n                   \"unmentionInfo,editControl,article\",\n        }\n        self.features = {\n            \"hidden_profile_subscriptions_enabled\": True,\n            \"payments_enabled\": False,\n            \"rweb_xchat_enabled\": False,\n            \"profile_label_improvements_pcf_label_in_post_enabled\": True,\n            \"rweb_tipjar_consumption_enabled\": True,\n            \"verified_phone_label_enabled\": False,\n            \"highlights_tweets_tab_ui_enabled\": True,\n            \"responsive_web_twitter_article_notes_tab_enabled\": True,\n            \"subscriptions_feature_can_gift_premium\": True,\n            \"creator_subscriptions_tweet_preview_api_enabled\": True,\n            \"responsive_web_graphql_\"\n            \"skip_user_profile_image_extensions_enabled\": False,\n            \"responsive_web_graphql_timeline_navigation_enabled\": True,\n        }\n        self.features_pagination = {\n            \"rweb_video_screen_enabled\": False,\n            \"payments_enabled\": False,\n            \"rweb_xchat_enabled\": False,\n            \"profile_label_improvements_pcf_label_in_post_enabled\": True,\n            \"rweb_tipjar_consumption_enabled\": True,\n            \"verified_phone_label_enabled\": False,\n            \"creator_subscriptions_tweet_preview_api_enabled\": True,\n            \"responsive_web_graphql\"\n            \"_timeline_navigation_enabled\": True,\n            \"responsive_web_graphql\"\n            \"_skip_user_profile_image_extensions_enabled\": False,\n            \"premium_content_api_read_enabled\": False,\n            \"communities_web_enable_tweet_community_results_fetch\": True,\n            \"c9s_tweet_anatomy_moderator_badge_enabled\": True,\n            \"responsive_web_grok_analyze_button_fetch_trends_enabled\": False,\n            \"responsive_web_grok_analyze_post_followups_enabled\": True,\n            \"responsive_web_jetfuel_frame\": True,\n            \"responsive_web_grok_share_attachment_enabled\": True,\n            \"articles_preview_enabled\": True,\n            \"responsive_web_edit_tweet_api_enabled\": True,\n            \"graphql_is_translatable_rweb_tweet_is_translatable_enabled\": True,\n            \"view_counts_everywhere_api_enabled\": True,\n            \"longform_notetweets_consumption_enabled\": True,\n            \"responsive_web_twitter_article_tweet_consumption_enabled\": True,\n            \"tweet_awards_web_tipping_enabled\": False,\n            \"responsive_web_grok_show_grok_translated_post\": False,\n            \"responsive_web_grok_analysis_button_from_backend\": True,\n            \"creator_subscriptions_quote_tweet_preview_enabled\": False,\n            \"freedom_of_speech_not_reach_fetch_enabled\": True,\n            \"standardized_nudges_misinfo\": True,\n            \"tweet_with_visibility_results\"\n            \"_prefer_gql_limited_actions_policy_enabled\": True,\n            \"longform_notetweets_rich_text_read_enabled\": True,\n            \"longform_notetweets_inline_media_enabled\": True,\n            \"responsive_web_grok_image_annotation_enabled\": True,\n            \"responsive_web_grok_imagine_annotation_enabled\": True,\n            \"responsive_web_grok\"\n            \"_community_note_auto_translation_is_enabled\": False,\n            \"responsive_web_enhance_cards_enabled\": False,\n        }\n\n    def tweet_result_by_rest_id(self, tweet_id):\n        endpoint = \"/graphql/qxWQxcMLiTPcavz9Qy5hwQ/TweetResultByRestId\"\n        variables = {\n            \"tweetId\": tweet_id,\n            \"withCommunity\": False,\n            \"includePromotedContent\": False,\n            \"withVoice\": False,\n        }\n        features = self.features_pagination.copy()\n        del features[\"rweb_video_screen_enabled\"]\n        field_toggles = {\n            \"withArticleRichContentState\": True,\n            \"withArticlePlainText\": False,\n            \"withGrokAnalyze\": False,\n            \"withDisallowedReplyControls\": False,\n        }\n        params = {\n            \"variables\"   : self._json_dumps(variables),\n            \"features\"    : self._json_dumps(features),\n            \"fieldToggles\": self._json_dumps(field_toggles),\n        }\n        tweet = self._call(endpoint, params)[\"data\"][\"tweetResult\"][\"result\"]\n        if \"tweet\" in tweet:\n            tweet = tweet[\"tweet\"]\n\n        if tweet.get(\"__typename\") == \"TweetUnavailable\":\n            reason = tweet.get(\"reason\")\n            if reason in {\"NsfwViewerHasNoStatedAge\", \"NsfwLoggedOut\"}:\n                raise self.exc.AuthRequired(message=\"NSFW Tweet\")\n            if reason == \"Protected\":\n                raise self.exc.AuthRequired(message=\"Protected Tweet\")\n            raise self.exc.AbortExtraction(f\"Tweet unavailable ('{reason}')\")\n\n        return tweet\n\n    def tweet_detail(self, tweet_id):\n        endpoint = \"/graphql/iFEr5AcP121Og4wx9Yqo3w/TweetDetail\"\n        variables = {\n            \"focalTweetId\": tweet_id,\n            \"referrer\": \"profile\",\n            \"with_rux_injections\": False,\n            #  \"rankingMode\": \"Relevance\",\n            \"includePromotedContent\": False,\n            \"withCommunity\": True,\n            \"withQuickPromoteEligibilityTweetFields\": False,\n            \"withBirdwatchNotes\": True,\n            \"withVoice\": True,\n        }\n        field_toggles = {\n            \"withArticleRichContentState\": True,\n            \"withArticlePlainText\": False,\n            \"withGrokAnalyze\": False,\n            \"withDisallowedReplyControls\": False,\n        }\n        return self._pagination_tweets(\n            endpoint, variables,\n            (\"threaded_conversation_with_injections_v2\",),\n            field_toggles=field_toggles)\n\n    def user_tweets(self, screen_name):\n        endpoint = \"/graphql/E8Wq-_jFSaU7hxVcuOPR9g/UserTweets\"\n        variables = {\n            \"userId\": self._user_id_by_screen_name(screen_name),\n            \"count\": self.extractor.config(\"limit\", 50),\n            \"includePromotedContent\": False,\n            \"withQuickPromoteEligibilityTweetFields\": False,\n            \"withVoice\": True,\n        }\n        field_toggles = {\n            \"withArticlePlainText\": False,\n        }\n        return self._pagination_tweets(\n            endpoint, variables, field_toggles=field_toggles)\n\n    def user_tweets_and_replies(self, screen_name):\n        endpoint = \"/graphql/-O3QOHrVn1aOm_cF5wyTCQ/UserTweetsAndReplies\"\n        variables = {\n            \"userId\": self._user_id_by_screen_name(screen_name),\n            \"count\": self.extractor.config(\"limit\", 50),\n            \"includePromotedContent\": False,\n            \"withCommunity\": True,\n            \"withVoice\": True,\n        }\n        field_toggles = {\n            \"withArticlePlainText\": False,\n        }\n        return self._pagination_tweets(\n            endpoint, variables, field_toggles=field_toggles)\n\n    def user_highlights(self, screen_name):\n        endpoint = \"/graphql/gmHw9geMTncZ7jeLLUUNOw/UserHighlightsTweets\"\n        variables = {\n            \"userId\": self._user_id_by_screen_name(screen_name),\n            \"count\": self.extractor.config(\"limit\", 50),\n            \"includePromotedContent\": False,\n            \"withVoice\": True,\n        }\n        field_toggles = {\n            \"withArticlePlainText\": False,\n        }\n        return self._pagination_tweets(\n            endpoint, variables, field_toggles=field_toggles)\n\n    def user_media(self, screen_name):\n        endpoint = \"/graphql/jCRhbOzdgOHp6u9H4g2tEg/UserMedia\"\n        variables = {\n            \"userId\": self._user_id_by_screen_name(screen_name),\n            \"count\": self.extractor.config(\"limit\", 50),\n            \"includePromotedContent\": False,\n            \"withClientEventToken\": False,\n            \"withBirdwatchNotes\": False,\n            \"withVoice\": True,\n        }\n        field_toggles = {\n            \"withArticlePlainText\": False,\n        }\n        return self._pagination_tweets(\n            endpoint, variables, field_toggles=field_toggles)\n\n    def user_likes(self, screen_name):\n        endpoint = \"/graphql/TGEKkJG_meudeaFcqaxM-Q/Likes\"\n        variables = {\n            \"userId\": self._user_id_by_screen_name(screen_name),\n            \"count\": self.extractor.config(\"limit\", 50),\n            \"includePromotedContent\": False,\n            \"withClientEventToken\": False,\n            \"withBirdwatchNotes\": False,\n            \"withVoice\": True,\n        }\n        field_toggles = {\n            \"withArticlePlainText\": False,\n        }\n        return self._pagination_tweets(\n            endpoint, variables, field_toggles=field_toggles)\n\n    def user_bookmarks(self):\n        endpoint = \"/graphql/pLtjrO4ubNh996M_Cubwsg/Bookmarks\"\n        variables = {\n            \"count\": self.extractor.config(\"limit\", 50),\n            \"includePromotedContent\": False,\n        }\n        return self._pagination_tweets(\n            endpoint, variables, (\"bookmark_timeline_v2\", \"timeline\"),\n            stop_tweets=128)\n\n    def search_timeline(self, query, product=None):\n        cfg = self.extractor.config\n\n        if product is None:\n            if product := cfg(\"search-results\"):\n                product = {\n                    \"top\"  : \"Top\",\n                    \"live\" : \"Latest\",\n                    \"user\" : \"People\",\n                    \"media\": \"Media\",\n                    \"list\" : \"Lists\",\n                }.get(product.lower(), product).capitalize()\n            else:\n                product = \"Latest\"\n\n        endpoint = \"/graphql/4fpceYZ6-YQCx_JSl_Cn_A/SearchTimeline\"\n        variables = {\n            \"rawQuery\": query,\n            \"count\": cfg(\"search-limit\", 20),\n            \"querySource\": \"typed_query\",\n            \"product\": product,\n            \"withGrokTranslatedBio\": False,\n        }\n\n        pgn = cfg(\"search-pagination\", \"max_id\")\n        if pgn in {\"max_id\", \"maxid\", \"id\"}:\n            update_variables = self._update_variables_search_maxid\n        elif pgn in {\"until\", \"date\", \"datetime\", \"dt\"}:\n            update_variables = self._update_variables_search_date\n            self._var_date_prev = None\n        else:\n            update_variables = None\n\n        stop_tweets = cfg(\"search-stop\")\n        if stop_tweets is None or stop_tweets == \"auto\":\n            stop_tweets = 3\n\n        return self._pagination_tweets(\n            endpoint, variables,\n            (\"search_by_raw_query\", \"search_timeline\", \"timeline\"),\n            stop_tweets=stop_tweets, update_variables=update_variables)\n\n    def community_query(self, community_id):\n        endpoint = \"/graphql/2W09l7nD7ZbxGQHXvfB22w/CommunityQuery\"\n        params = {\n            \"variables\": self._json_dumps({\n                \"communityId\": community_id,\n            }),\n            \"features\": self._json_dumps({\n                \"c9s_list_members_action_api_enabled\": False,\n                \"c9s_superc9s_indication_enabled\": False,\n            }),\n        }\n        return (self._call(endpoint, params)\n                [\"data\"][\"communityResults\"][\"result\"])\n\n    def community_tweets_timeline(self, community_id):\n        endpoint = \"/graphql/Nyt-88UX4-pPCImZNUl9RQ/CommunityTweetsTimeline\"\n        variables = {\n            \"communityId\": community_id,\n            \"count\": self.extractor.config(\"limit\", 50),\n            \"displayLocation\": \"Community\",\n            \"rankingMode\": \"Recency\",\n            \"withCommunity\": True,\n        }\n        return self._pagination_tweets(\n            endpoint, variables,\n            (\"communityResults\", \"result\", \"ranked_community_timeline\",\n             \"timeline\"))\n\n    def community_media_timeline(self, community_id):\n        endpoint = \"/graphql/ZniZ7AAK_VVu1xtSx1V-gQ/CommunityMediaTimeline\"\n        variables = {\n            \"communityId\": community_id,\n            \"count\": self.extractor.config(\"limit\", 50),\n            \"withCommunity\": True,\n        }\n        return self._pagination_tweets(\n            endpoint, variables,\n            (\"communityResults\", \"result\", \"community_media_timeline\",\n             \"timeline\"))\n\n    def communities_main_page_timeline(self, screen_name):\n        endpoint = (\"/graphql/p048a9n3hTPppQyK7FQTFw\"\n                    \"/CommunitiesMainPageTimeline\")\n        variables = {\n            \"count\": self.extractor.config(\"limit\", 50),\n            \"withCommunity\": True,\n        }\n        return self._pagination_tweets(\n            endpoint, variables,\n            (\"viewer\", \"communities_timeline\", \"timeline\"))\n\n    def home_timeline(self):\n        endpoint = \"/graphql/DXmgQYmIft1oLP6vMkJixw/HomeTimeline\"\n        variables = {\n            \"count\": self.extractor.config(\"limit\", 50),\n            \"includePromotedContent\": False,\n            \"latestControlAvailable\": True,\n            \"withCommunity\": True,\n        }\n        return self._pagination_tweets(\n            endpoint, variables, (\"home\", \"home_timeline_urt\"))\n\n    def home_latest_timeline(self):\n        endpoint = \"/graphql/SFxmNKWfN9ySJcXG_tjX8g/HomeLatestTimeline\"\n        variables = {\n            \"count\": self.extractor.config(\"limit\", 50),\n            \"includePromotedContent\": False,\n            \"latestControlAvailable\": True,\n        }\n        return self._pagination_tweets(\n            endpoint, variables, (\"home\", \"home_timeline_urt\"))\n\n    def live_event_timeline(self, event_id):\n        endpoint = f\"/2/live_event/timeline/{event_id}.json\"\n        params = self.params.copy()\n        params[\"timeline_id\"] = \"recap\"\n        params[\"urt\"] = \"true\"\n        params[\"get_annotations\"] = \"true\"\n        return self._pagination_rest(endpoint, params)\n\n    def live_event(self, event_id):\n        endpoint = f\"/1.1/live_event/1/{event_id}/timeline.json\"\n        params = self.params.copy()\n        params[\"count\"] = \"0\"\n        params[\"urt\"] = \"true\"\n        return (self._call(endpoint, params)\n                [\"twitter_objects\"][\"live_events\"][event_id])\n\n    def list_latest_tweets_timeline(self, list_id):\n        endpoint = \"/graphql/06JtmwM8k_1cthpFZITVVA/ListLatestTweetsTimeline\"\n        variables = {\n            \"listId\": list_id,\n            \"count\": self.extractor.config(\"limit\", 50),\n        }\n        return self._pagination_tweets(\n            endpoint, variables, (\"list\", \"tweets_timeline\", \"timeline\"))\n\n    def list_members(self, list_id):\n        endpoint = \"/graphql/naea_MSad4pOb-D6_oVv_g/ListMembers\"\n        variables = {\n            \"listId\": list_id,\n            \"count\": 100,\n        }\n        return self._pagination_users(\n            endpoint, variables, (\"list\", \"members_timeline\", \"timeline\"))\n\n    def notifications_devicefollow(self):\n        endpoint = \"/2/notifications/device_follow.json\"\n        params = self.params.copy()\n        params[\"count\"] = self.extractor.config(\"limit\", 50)\n        return self._pagination_rest(endpoint, params)\n\n    def user_followers(self, screen_name):\n        endpoint = \"/graphql/i6PPdIMm1MO7CpAqjau7sw/Followers\"\n        variables = {\n            \"userId\": self._user_id_by_screen_name(screen_name),\n            \"count\": 100,\n            \"includePromotedContent\": False,\n            \"withGrokTranslatedBio\": False,\n        }\n        return self._pagination_users(endpoint, variables)\n\n    def user_followers_verified(self, screen_name):\n        endpoint = \"/graphql/fxEl9kp1Tgolqkq8_Lo3sg/BlueVerifiedFollowers\"\n        variables = {\n            \"userId\": self._user_id_by_screen_name(screen_name),\n            \"count\": 100,\n            \"includePromotedContent\": False,\n            \"withGrokTranslatedBio\": False,\n        }\n        return self._pagination_users(endpoint, variables)\n\n    def user_following(self, screen_name):\n        endpoint = \"/graphql/SaWqzw0TFAWMx1nXWjXoaQ/Following\"\n        variables = {\n            \"userId\": self._user_id_by_screen_name(screen_name),\n            \"count\": 100,\n            \"includePromotedContent\": False,\n            \"withGrokTranslatedBio\": False,\n        }\n        return self._pagination_users(endpoint, variables)\n\n    def user_by_rest_id(self, rest_id):\n        endpoint = \"/graphql/8r5oa_2vD0WkhIAOkY4TTA/UserByRestId\"\n        features = self.features\n        params = {\n            \"variables\": self._json_dumps({\n                \"userId\": rest_id,\n            }),\n            \"features\": self._json_dumps(features),\n        }\n        return self._call(endpoint, params)[\"data\"][\"user\"][\"result\"]\n\n    def user_by_screen_name(self, screen_name):\n        endpoint = \"/graphql/ck5KkZ8t5cOmoLssopN99Q/UserByScreenName\"\n        features = self.features.copy()\n        features[\"subscriptions_verification_info_\"\n                 \"is_identity_verified_enabled\"] = True\n        features[\"subscriptions_verification_info_\"\n                 \"verified_since_enabled\"] = True\n        params = {\n            \"variables\": self._json_dumps({\n                \"screen_name\": screen_name,\n                \"withGrokTranslatedBio\": False,\n            }),\n            \"features\": self._json_dumps(features),\n            \"fieldToggles\": self._json_dumps({\n                \"withAuxiliaryUserLabels\": True,\n            }),\n        }\n        return self._call(endpoint, params)[\"data\"][\"user\"][\"result\"]\n\n    def user_about_account(self, screen_name):\n        endpoint = \"/graphql/zs_jFPFT78rBpXv9Z3U2YQ/AboutAccountQuery\"\n        params = {\"variables\": self._json_dumps({\"screenName\": screen_name})}\n        return (self._call(endpoint, params)\n                [\"data\"][\"user_result_by_screen_name\"][\"result\"])\n\n    def _user_id_by_screen_name(self, screen_name):\n        user = ()\n        extr = self.extractor\n        try:\n            if screen_name.startswith(\"id:\"):\n                user = extr.cache(self.user_by_rest_id, screen_name[3:])\n            else:\n                user = extr.cache(self.user_by_screen_name, screen_name)\n            extr._assign_user(user)\n            return user[\"rest_id\"]\n        except KeyError:\n            if user and user.get(\"__typename\") == \"UserUnavailable\":\n                raise extr.exc.NotFoundError(user[\"message\"], False)\n            else:\n                raise extr.exc.NotFoundError(\"user\")\n\n    def _guest_token(self):\n        endpoint = \"/1.1/guest/activate.json\"\n        self.log.info(\"Requesting guest token\")\n        return str(self._call(\n            endpoint, None, \"POST\", False, \"https://api.x.com\",\n        )[\"guest_token\"])\n\n    def _authenticate_guest(self):\n        guest_token = self.extractor.cache(\n            self._guest_token, _key=None, _exp=3600, _mem=False)\n        if guest_token != self.headers[\"x-guest-token\"]:\n            self.headers[\"x-guest-token\"] = guest_token\n            self.extractor.cookies.set(\n                \"gt\", guest_token, domain=self.extractor.cookies_domain)\n\n    def _client_transaction(self):\n        self.log.info(\"Initializing client transaction keys\")\n\n        ct = self.extractor.utils(\"transaction_id\").ClientTransaction()\n        ct.initialize(self.extractor)\n\n        # update 'x-csrf-token' header (#7467)\n        csrf_token = self.extractor.cookies.get(\n            \"ct0\", domain=self.extractor.cookies_domain)\n        if csrf_token:\n            self.headers[\"x-csrf-token\"] = csrf_token\n\n        return ct\n\n    def _transaction_id(self, url, method=\"GET\"):\n        if self.client_transaction is None:\n            TwitterAPI.client_transaction = self.extractor.cache(\n                self._client_transaction, _key=None, _exp=10_800, _mem=False)\n        path = url[url.find(\"/\", 8):]\n        self.headers[\"x-client-transaction-id\"] = \\\n            self.client_transaction.generate_transaction_id(method, path)\n\n    def _call(self, endpoint, params, method=\"GET\", auth=True, root=None):\n        url = (self.root if root is None else root) + endpoint\n\n        while True:\n            if auth:\n                if self.headers[\"x-twitter-auth-type\"]:\n                    self._transaction_id(url, method)\n                else:\n                    self._authenticate_guest()\n\n            response = self.extractor.request(\n                url, method=method, params=params,\n                headers=self.headers, fatal=None)\n\n            # update 'x-csrf-token' header (#1170)\n            if csrf_token := response.cookies.get(\"ct0\"):\n                self.headers[\"x-csrf-token\"] = csrf_token\n\n            remaining = int(response.headers.get(\"x-rate-limit-remaining\", 6))\n            if remaining < 6 and remaining <= random.randrange(1, 6):\n                self._handle_ratelimit(response)\n                continue\n\n            try:\n                data = response.json()\n            except ValueError:\n                data = {\"errors\": ({\"message\": response.text},)}\n\n            errors = data.get(\"errors\")\n            if not errors:\n                return data\n\n            retry = False\n            for error in errors:\n                msg = error.get(\"message\") or \"Unspecified\"\n                self.log.debug(\"API error: '%s'\", msg)\n\n                if \"this account is temporarily locked\" in msg:\n                    msg = \"Account temporarily locked\"\n                    if self.extractor.config(\"locked\") != \"wait\":\n                        raise self.exc.AuthorizationError(msg)\n                    self.log.warning(msg)\n                    self.extractor.input(\"Press ENTER to retry.\")\n                    retry = True\n\n                elif \"Could not authenticate you\" in msg:\n                    raise self.exc.AbortExtraction(f\"'{msg}'\")\n\n                elif msg.lower().startswith(\"timeout\"):\n                    retry = True\n\n            if retry:\n                if self.headers[\"x-twitter-auth-type\"]:\n                    self.log.debug(\"Retrying API request\")\n                    continue\n                else:\n                    # fall through to \"Login Required\"\n                    response.status_code = 404\n\n            if response.status_code < 400:\n                return data\n            elif response.status_code in {403, 404} and \\\n                    not self.headers[\"x-twitter-auth-type\"]:\n                raise self.exc.AuthRequired(\n                    \"authenticated cookies\", \"timeline\")\n            elif response.status_code == 429:\n                self._handle_ratelimit(response)\n                continue\n\n            # error\n            try:\n                errors = \", \".join(e[\"message\"] for e in errors)\n            except Exception:\n                pass\n\n            raise self.exc.AbortExtraction(\n                f\"{response.status_code} {response.reason} ({errors})\")\n\n    def _pagination_rest(self, endpoint, params):\n        extr = self.extractor\n        if cursor := extr._init_cursor():\n            params[\"cursor\"] = cursor\n        original_retweets = (extr.retweets == \"original\")\n        bottom = (\"cursor-bottom-\", \"sq-cursor-bottom\")\n\n        while True:\n            data = self._call(endpoint, params)\n\n            instructions = data[\"timeline\"][\"instructions\"]\n            if not instructions:\n                return extr._update_cursor(None)\n\n            tweets = data[\"globalObjects\"][\"tweets\"]\n            users = data[\"globalObjects\"][\"users\"]\n            tweet_id = cursor = None\n            tweet_ids = []\n            entries = ()\n\n            # process instructions\n            for instr in instructions:\n                if \"addEntries\" in instr:\n                    entries = instr[\"addEntries\"][\"entries\"]\n                elif \"replaceEntry\" in instr:\n                    entry = instr[\"replaceEntry\"][\"entry\"]\n                    if entry[\"entryId\"].startswith(bottom):\n                        cursor = (entry[\"content\"][\"operation\"]\n                                  [\"cursor\"][\"value\"])\n\n            # collect tweet IDs and cursor value\n            for entry in entries:\n                entry_startswith = entry[\"entryId\"].startswith\n\n                if entry_startswith((\"tweet-\", \"sq-I-t-\")):\n                    tweet_ids.append(\n                        entry[\"content\"][\"item\"][\"content\"][\"tweet\"][\"id\"])\n\n                elif entry_startswith(\"homeConversation-\"):\n                    tweet_ids.extend(\n                        entry[\"content\"][\"timelineModule\"][\"metadata\"]\n                        [\"conversationMetadata\"][\"allTweetIds\"][::-1])\n\n                elif entry_startswith(bottom):\n                    cursor = entry[\"content\"][\"operation\"][\"cursor\"]\n                    if not cursor.get(\"stopOnEmptyResponse\", True):\n                        # keep going even if there are no tweets\n                        tweet_id = True\n                    cursor = cursor[\"value\"]\n\n                elif entry_startswith(\"conversationThread-\"):\n                    tweet_ids.extend(\n                        item[\"entryId\"][6:]\n                        for item in entry[\"content\"][\"timelineModule\"][\"items\"]\n                        if item[\"entryId\"].startswith(\"tweet-\")\n                    )\n\n            # process tweets\n            for tweet_id in tweet_ids:\n                try:\n                    tweet = tweets[tweet_id]\n                except KeyError:\n                    self.log.debug(\"Skipping %s (deleted)\", tweet_id)\n                    continue\n\n                if \"retweeted_status_id_str\" in tweet:\n                    retweet = tweets.get(tweet[\"retweeted_status_id_str\"])\n                    if original_retweets:\n                        if not retweet:\n                            continue\n                        retweet[\"retweeted_status_id_str\"] = retweet[\"id_str\"]\n                        retweet[\"_retweet_id_str\"] = tweet[\"id_str\"]\n                        tweet = retweet\n                    elif retweet:\n                        tweet[\"author\"] = users[retweet[\"user_id_str\"]]\n                        if \"extended_entities\" in retweet and \\\n                                \"extended_entities\" not in tweet:\n                            tweet[\"extended_entities\"] = \\\n                                retweet[\"extended_entities\"]\n                tweet[\"user\"] = users[tweet[\"user_id_str\"]]\n                yield tweet\n\n                if \"quoted_status_id_str\" in tweet:\n                    if quoted := tweets.get(tweet[\"quoted_status_id_str\"]):\n                        quoted = quoted.copy()\n                        quoted[\"author\"] = users[quoted[\"user_id_str\"]]\n                        quoted[\"quoted_by\"] = tweet[\"user\"][\"screen_name\"]\n                        quoted[\"quoted_by_id_str\"] = tweet[\"id_str\"]\n                        yield quoted\n\n            # stop on empty response\n            if not cursor or (not tweets and not tweet_id):\n                return extr._update_cursor(None)\n            params[\"cursor\"] = extr._update_cursor(cursor)\n\n    def _pagination_tweets(self, endpoint, variables,\n                           path=None, stop_tweets=0, update_variables=None,\n                           features=None, field_toggles=None):\n        extr = self.extractor\n        original_retweets = (extr.retweets == \"original\")\n        pinned_tweet = True if extr.pinned else None\n        stop_tweets_max = stop_tweets\n        api_retries = None\n\n        if isinstance(count := variables.get(\"count\"), list):\n            count = count.copy()\n            count.reverse()\n            self.log.debug(\"Using 'count: %s'\", count[-1])\n            variables[\"count\"] = count.pop()\n        else:\n            count = False\n\n        params = {\"variables\": None}\n        if cursor := extr._init_cursor():\n            variables[\"cursor\"] = cursor\n        if features is None:\n            features = self.features_pagination\n        if features:\n            params[\"features\"] = self._json_dumps(features)\n        if field_toggles:\n            params[\"fieldToggles\"] = self._json_dumps(field_toggles)\n\n        while True:\n            params[\"variables\"] = self._json_dumps(variables)\n            data = self._call(endpoint, params)\n\n            try:\n                if path is None:\n                    instructions = (data[\"data\"][\"user\"][\"result\"][\"timeline\"]\n                                    [\"timeline\"][\"instructions\"])\n                else:\n                    instructions = data[\"data\"]\n                    for key in path:\n                        instructions = instructions[key]\n                    instructions = instructions[\"instructions\"]\n\n                cursor = None\n                entries = None\n                for instr in instructions:\n                    instr_type = instr.get(\"type\")\n                    if instr_type == \"TimelineAddEntries\":\n                        if entries:\n                            entries.extend(instr[\"entries\"])\n                        else:\n                            entries = instr[\"entries\"]\n                    elif instr_type == \"TimelineAddToModule\":\n                        entries = instr[\"moduleItems\"]\n                    elif instr_type == \"TimelinePinEntry\":\n                        if pinned_tweet is not None:\n                            pinned_tweet = instr[\"entry\"]\n                    elif instr_type == \"TimelineReplaceEntry\":\n                        entry = instr[\"entry\"]\n                        if entry[\"entryId\"].startswith(\"cursor-bottom-\"):\n                            cursor = entry[\"content\"][\"value\"]\n                if entries is None:\n                    if not cursor:\n                        return extr._update_cursor(None)\n                    entries = ()\n\n            except LookupError:\n                extr.log.debug(data)\n\n                if errors := data.get(\"errors\"):\n                    if api_retries is None:\n                        api_tries = 1\n                        api_retries = extr.config(\"retries-api\", 9)\n                        if api_retries < 0:\n                            api_retries = float(\"inf\")\n\n                    err = []\n                    srv = False\n                    for e in errors:\n                        err.append(f\"- '{e.get('message') or e.get('name')}'\")\n                        if e.get(\"source\") == \"Server\":\n                            srv = True\n\n                    self.log.warning(\"API errors (%s/%s):\\n%s\",\n                                     api_tries, api_retries+1, \"\\n\".join(err))\n                    if srv and api_tries <= api_retries:\n                        api_tries += 1\n                        continue\n\n                if user := extr._user_obj:\n                    user = user[\"legacy\"]\n                    if user.get(\"blocked_by\"):\n                        if self.headers[\"x-twitter-auth-type\"] and \\\n                                extr.config(\"logout\"):\n                            extr.cookies_file = None\n                            del extr.cookies[\"auth_token\"]\n                            self.headers[\"x-twitter-auth-type\"] = None\n                            extr.log.info(\"Retrying API request as guest\")\n                            continue\n                        raise self.exc.AuthorizationError(\n                            user[\"screen_name\"] + \" blocked your account\")\n                    elif user.get(\"protected\"):\n                        raise self.exc.AuthorizationError(\n                            user[\"screen_name\"] + \"'s Tweets are protected\")\n\n                raise self.exc.AbortExtraction(\n                    \"Unable to retrieve Tweets from this timeline\")\n\n            tweets = []\n            tweet = last_tweet = retry = None\n            api_tries = 1\n\n            if pinned_tweet is not None and isinstance(pinned_tweet, dict):\n                pinned_tweet[\"pinned\"] = True\n                tweets.append(pinned_tweet)\n                pinned_tweet = None\n\n            for entry in entries:\n                esw = entry[\"entryId\"].startswith\n\n                if esw(\"tweet-\"):\n                    tweets.append(entry)\n                elif esw((\"profile-grid-\",\n                          \"search-grid-\",\n                          \"communities-grid-\")):\n                    if \"content\" in entry:\n                        tweets.extend(entry[\"content\"][\"items\"])\n                    else:\n                        tweets.append(entry)\n                elif esw((\"homeConversation-\",\n                          \"profile-conversation-\",\n                          \"conversationthread-\")):\n                    tweets.extend(entry[\"content\"][\"items\"])\n                elif esw(\"tombstone-\"):\n                    item = entry[\"content\"][\"itemContent\"]\n                    item[\"tweet_results\"] = \\\n                        {\"result\": {\"tombstone\": item[\"tombstoneInfo\"]}}\n                    tweets.append(entry)\n                elif esw(\"cursor-bottom-\"):\n                    cursor = entry[\"content\"]\n                    if \"itemContent\" in cursor:\n                        cursor = cursor[\"itemContent\"]\n                    if not cursor.get(\"stopOnEmptyResponse\", True):\n                        # keep going even if there are no tweets\n                        tweet = True\n                    cursor = cursor.get(\"value\")\n\n            if pinned_tweet is not None:\n                if extr._user_obj is None:\n                    pinned = None\n                elif pinned := extr._user_obj[\"legacy\"].get(\n                        \"pinned_tweet_ids_str\"):\n                    pinned = \"-tweet-\" + pinned[0]\n                    for idx, entry in enumerate(tweets):\n                        if entry[\"entryId\"].endswith(pinned):\n                            # mark as pinned / set 'pinned = True'\n                            try:\n                                pinned_tweet = (\n                                    (entry.get(\"content\") or entry[\"item\"])\n                                    [\"itemContent\"][\"tweet_results\"][\"result\"])\n                                if \"tweet\" in pinned_tweet:\n                                    pinned_tweet = pinned_tweet[\"tweet\"]\n                                pinned_tweet[\"pinned\"] = True\n                                # move to front of 'tweets'\n                                del tweets[idx]\n                                tweets.insert(0, entry)\n                            except Exception:\n                                break\n                del pinned\n                pinned_tweet = None\n\n            for entry in tweets:\n                try:\n                    item = ((entry.get(\"content\") or entry[\"item\"])\n                            [\"itemContent\"])\n                    if \"promotedMetadata\" in item and not extr.ads:\n                        extr.log.debug(\n                            \"Skipping %s (ad)\",\n                            (entry.get(\"entryId\") or \"\").rpartition(\"-\")[2])\n                        continue\n\n                    tweet = item[\"tweet_results\"][\"result\"]\n                    if \"tombstone\" in tweet:\n                        tweet = self._process_tombstone(\n                            entry, tweet[\"tombstone\"])\n                        if not tweet:\n                            continue\n\n                    if \"tweet\" in tweet:\n                        tweet = tweet[\"tweet\"]\n                    legacy = tweet[\"legacy\"]\n                    tweet[\"sortIndex\"] = entry.get(\"sortIndex\")\n                except KeyError:\n                    extr.log.debug(\n                        \"Skipping %s (deleted)\",\n                        (entry.get(\"entryId\") or \"\").rpartition(\"-\")[2])\n                    continue\n\n                if retry is None:\n                    try:\n                        tweet[\"core\"][\"user_results\"][\"result\"]\n                        retry = False\n                    except KeyError:\n                        self.log.warning(\"Received Tweet results without \"\n                                         \"'core' data ... Retrying\")\n                        retry = True\n                        break\n\n                if \"retweeted_status_result\" in legacy:\n                    try:\n                        retweet = legacy[\"retweeted_status_result\"][\"result\"]\n                        if \"tweet\" in retweet:\n                            retweet = retweet[\"tweet\"]\n                        if original_retweets:\n                            retweet[\"legacy\"][\"retweeted_status_id_str\"] = \\\n                                retweet[\"rest_id\"]\n                            retweet[\"_retweet_id_str\"] = tweet[\"rest_id\"]\n                            tweet = retweet\n                        else:\n                            legacy[\"retweeted_status_id_str\"] = \\\n                                retweet[\"rest_id\"]\n                            tweet[\"author\"] = \\\n                                retweet[\"core\"][\"user_results\"][\"result\"]\n\n                            rtlegacy = retweet[\"legacy\"]\n\n                            if \"note_tweet\" in retweet:\n                                tweet[\"note_tweet\"] = retweet[\"note_tweet\"]\n\n                            if \"extended_entities\" in rtlegacy and \\\n                                    \"extended_entities\" not in legacy:\n                                legacy[\"extended_entities\"] = \\\n                                    rtlegacy[\"extended_entities\"]\n\n                            if \"withheld_scope\" in rtlegacy and \\\n                                    \"withheld_scope\" not in legacy:\n                                legacy[\"withheld_scope\"] = \\\n                                    rtlegacy[\"withheld_scope\"]\n\n                            legacy[\"full_text\"] = rtlegacy[\"full_text\"]\n                    except Exception as exc:\n                        extr.log.debug(\n                            \"%s:  %s: %s\",\n                            tweet.get(\"rest_id\"), exc.__class__.__name__, exc)\n                        continue\n\n                yield tweet\n\n                if \"quoted_status_result\" in tweet:\n                    try:\n                        quoted = tweet[\"quoted_status_result\"][\"result\"]\n                        quoted[\"legacy\"][\"quoted_by\"] = (\n                            tweet[\"core\"][\"user_results\"][\"result\"]\n                            [\"core\"][\"screen_name\"])\n                        quoted[\"legacy\"][\"quoted_by_id_str\"] = tweet[\"rest_id\"]\n                        quoted[\"sortIndex\"] = entry.get(\"sortIndex\")\n\n                        yield quoted\n                    except KeyError:\n                        extr.log.debug(\n                            \"Skipping quote of %s (deleted)\",\n                            tweet.get(\"rest_id\"))\n                        continue\n\n            if retry:\n                continue\n            elif tweet:\n                stop_tweets = stop_tweets_max\n                last_tweet = tweet\n            elif stop_tweets <= 0:\n                if not count:\n                    return extr._update_cursor(None)\n                self.log.debug(\"Switching to 'count: %s'\", count[-1])\n                variables[\"count\"] = count.pop()\n                continue\n            else:\n                self.log.debug(\n                    \"No Tweet results (%s/%s)\",\n                    stop_tweets_max - stop_tweets + 1, stop_tweets_max)\n                stop_tweets -= 1\n\n            if not cursor or cursor == variables.get(\"cursor\"):\n                self.log.debug(\"No continuation cursor\")\n                return extr._update_cursor(None)\n\n            if update_variables is None:\n                variables[\"cursor\"] = extr._update_cursor(cursor)\n            else:\n                variables = update_variables(variables, cursor, last_tweet)\n\n    def _pagination_users(self, endpoint, variables, path=None):\n        extr = self.extractor\n        if cursor := extr._init_cursor():\n            variables[\"cursor\"] = cursor\n        params = {\n            \"variables\": None,\n            \"features\" : self._json_dumps(self.features_pagination),\n        }\n\n        while True:\n            cursor = entry = None\n            params[\"variables\"] = self._json_dumps(variables)\n            data = self._call(endpoint, params)[\"data\"]\n\n            try:\n                if path is None:\n                    instructions = (data[\"user\"][\"result\"][\"timeline\"]\n                                    [\"timeline\"][\"instructions\"])\n                else:\n                    for key in path:\n                        data = data[key]\n                    instructions = data[\"instructions\"]\n            except KeyError:\n                return extr._update_cursor(None)\n\n            for instr in instructions:\n                if instr[\"type\"] == \"TimelineAddEntries\":\n                    for entry in instr[\"entries\"]:\n                        if entry[\"entryId\"].startswith(\"user-\"):\n                            try:\n                                user = (entry[\"content\"][\"itemContent\"]\n                                        [\"user_results\"][\"result\"])\n                            except KeyError:\n                                pass\n                            else:\n                                if \"rest_id\" in user:\n                                    yield user\n                        elif entry[\"entryId\"].startswith(\"cursor-bottom-\"):\n                            cursor = entry[\"content\"][\"value\"]\n\n            if not cursor or cursor.startswith((\"-1|\", \"0|\")) or not entry:\n                return extr._update_cursor(None)\n            variables[\"cursor\"] = extr._update_cursor(cursor)\n\n    def _handle_ratelimit(self, response):\n        rl = self.extractor.config(\"ratelimit\")\n        if rl == \"abort\":\n            raise self.exc.AbortExtraction(\"Rate limit exceeded\")\n\n        until = response.headers.get(\"x-rate-limit-reset\")\n        seconds = None if until else 60.0\n\n        if rl and isinstance(rl, str) and \":\" in rl:\n            rl, _, num = rl.partition(\":\")\n            if rl == \"abort\":\n                amt = getattr(self, \"_ratelimit_amt\", 1)\n                num = text.parse_int(num)\n                msg = f\"Rate limit exceeded ({amt}/{num})\"\n                if amt >= num:\n                    raise self.exc.AbortExtraction(msg)\n                self.log.warning(msg)\n                self._ratelimit_amt = amt + 1\n            elif rl == \"wait\":\n                until = None\n                seconds = text.parse_float(num) or 60.0\n            else:\n                self.log.warning(\"Unsupported 'ratelimit' setting '%s'\", rl)\n\n        self.extractor.wait(until=until, seconds=seconds)\n\n    def _process_tombstone(self, entry, tombstone):\n        text = (tombstone.get(\"richText\") or tombstone[\"text\"])[\"text\"]\n        tweet_id = entry[\"entryId\"].rpartition(\"-\")[2]\n\n        if text.startswith(\"Age-restricted\"):\n            if self._nsfw_warning:\n                self._nsfw_warning = False\n                self.log.warning('\"%s\"', text)\n\n        self.log.debug(\"Skipping %s ('%s')\", tweet_id, text)\n\n    def _update_variables_search_maxid(self, variables, cursor, tweet):\n        try:\n            tweet_id = tweet.get(\"id_str\") or tweet[\"legacy\"][\"id_str\"]\n            max_id = \"max_id:\" + str(int(tweet_id)-1)\n\n            query, n = text.re(r\"\\bmax_id:\\d+\").subn(\n                max_id, variables[\"rawQuery\"])\n            if n:\n                variables[\"rawQuery\"] = query\n            else:\n                variables[\"rawQuery\"] = f\"{query} {max_id}\"\n\n            if prefix := getattr(self.extractor, \"_cursor_prefix\", None):\n                self.extractor._cursor_prefix = \\\n                    f\"{prefix.partition('_')[0]}_{tweet_id}/\"\n            variables[\"cursor\"] = None\n        except Exception as exc:\n            self.extractor.log.debug(\n                \"Failed to update 'max_id' search query (%s: %s). Falling \"\n                \"back to 'cursor' pagination\", exc.__class__.__name__, exc)\n            variables[\"cursor\"] = self.extractor._update_cursor(cursor)\n\n        return variables\n\n    def _update_variables_search_date(self, variables, cursor, tweet):\n        try:\n            tweet_id = tweet.get(\"id_str\") or tweet[\"legacy\"][\"id_str\"]\n            date = self.extractor._tweetid_to_datetime(int(tweet_id))\n\n            if date == self._var_date_prev:\n                variables[\"cursor\"] = self.extractor._update_cursor(cursor)\n                return variables\n\n            dstr = f\"until:{date.year:>04}-{date.month:>02}-{date.day:>02}\"\n            query, n = text.re(r\"\\buntil:\\d{4}-\\d{2}-\\d{2}\").subn(\n                dstr, variables[\"rawQuery\"])\n            if n:\n                variables[\"rawQuery\"] = query\n            else:\n                variables[\"rawQuery\"] = f\"{query} {dstr}\"\n\n            if prefix := getattr(self.extractor, \"_cursor_prefix\", None):\n                self.extractor._cursor_prefix = \\\n                    f\"{prefix.partition('_')[0]}_{tweet_id}/\"\n            variables[\"cursor\"] = None\n            self._var_date_prev = date\n        except Exception as exc:\n            self.extractor.log.debug(\n                \"Failed to update 'until' search query (%s: %s). Falling \"\n                \"back to 'cursor' pagination\", exc.__class__.__name__, exc)\n            variables[\"cursor\"] = self.extractor._update_cursor(cursor)\n\n        return variables\n"
  },
  {
    "path": "gallery_dl/extractor/unsplash.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2021-2025 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://unsplash.com/\"\"\"\n\nfrom .common import Extractor, Message\nfrom .. import text, util\n\nBASE_PATTERN = r\"(?:https?://)?unsplash\\.com\"\n\n\nclass UnsplashExtractor(Extractor):\n    \"\"\"Base class for unsplash extractors\"\"\"\n    category = \"unsplash\"\n    directory_fmt = (\"{category}\", \"{user[username]}\")\n    filename_fmt = \"{id}.{extension}\"\n    archive_fmt = \"{id}\"\n    root = \"https://unsplash.com\"\n    page_start = 1\n    per_page = 20\n\n    def __init__(self, match):\n        Extractor.__init__(self, match)\n        self.item = match[1]\n\n    def items(self):\n        fmt = self.config(\"format\") or \"raw\"\n        metadata = self.metadata()\n\n        for photo in self.photos():\n            util.delete_items(\n                photo, (\"current_user_collections\", \"related_collections\"))\n            url = photo[\"urls\"][fmt]\n            text.nameext_from_url(url, photo)\n\n            if metadata:\n                photo.update(metadata)\n            photo[\"extension\"] = \"jpg\"\n            photo[\"date\"] = self.parse_datetime_iso(photo[\"created_at\"])\n            if \"tags\" in photo:\n                photo[\"tags\"] = [t[\"title\"] for t in photo[\"tags\"]]\n\n            yield Message.Directory, \"\", photo\n            yield Message.Url, url, photo\n\n    def metadata(self):\n        return None\n\n    def skip_files(self, num):\n        pages = num // self.per_page\n        self.page_start += pages\n        return pages * self.per_page\n\n    def _pagination(self, url, params, results=False):\n        params[\"per_page\"] = self.per_page\n        params[\"page\"] = self.page_start\n\n        while True:\n            photos = self.request_json(url, params=params)\n            if results:\n                photos = photos[\"results\"]\n            yield from photos\n\n            if len(photos) < self.per_page:\n                return\n            params[\"page\"] += 1\n\n\nclass UnsplashImageExtractor(UnsplashExtractor):\n    \"\"\"Extractor for a single unsplash photo\"\"\"\n    subcategory = \"image\"\n    pattern = BASE_PATTERN + r\"/photos/([^/?#]+)\"\n    example = \"https://unsplash.com/photos/ID\"\n\n    def photos(self):\n        url = f\"{self.root}/napi/photos/{self.item}\"\n        return (self.request_json(url),)\n\n\nclass UnsplashUserExtractor(UnsplashExtractor):\n    \"\"\"Extractor for all photos of an unsplash user\"\"\"\n    subcategory = \"user\"\n    pattern = BASE_PATTERN + r\"/@(\\w+)/?$\"\n    example = \"https://unsplash.com/@USER\"\n\n    def photos(self):\n        url = f\"{self.root}/napi/users/{self.item}/photos\"\n        params = {\"order_by\": \"latest\"}\n        return self._pagination(url, params)\n\n\nclass UnsplashFavoriteExtractor(UnsplashExtractor):\n    \"\"\"Extractor for all likes of an unsplash user\"\"\"\n    subcategory = \"favorite\"\n    pattern = BASE_PATTERN + r\"/@(\\w+)/likes\"\n    example = \"https://unsplash.com/@USER/likes\"\n\n    def photos(self):\n        url = f\"{self.root}/napi/users/{self.item}/likes\"\n        params = {\"order_by\": \"latest\"}\n        return self._pagination(url, params)\n\n\nclass UnsplashCollectionExtractor(UnsplashExtractor):\n    \"\"\"Extractor for an unsplash collection\"\"\"\n    subcategory = \"collection\"\n    pattern = BASE_PATTERN + r\"/collections/([^/?#]+)(?:/([^/?#]+))?\"\n    example = \"https://unsplash.com/collections/12345/TITLE\"\n\n    def __init__(self, match):\n        UnsplashExtractor.__init__(self, match)\n        self.title = match[2] or \"\"\n\n    def metadata(self):\n        return {\"collection_id\": self.item, \"collection_title\": self.title}\n\n    def photos(self):\n        url = f\"{self.root}/napi/collections/{self.item}/photos\"\n        params = {\"order_by\": \"latest\"}\n        return self._pagination(url, params)\n\n\nclass UnsplashSearchExtractor(UnsplashExtractor):\n    \"\"\"Extractor for unsplash search results\"\"\"\n    subcategory = \"search\"\n    pattern = BASE_PATTERN + r\"/s/photos/([^/?#]+)(?:\\?([^#]+))?\"\n    example = \"https://unsplash.com/s/photos/QUERY\"\n\n    def __init__(self, match):\n        UnsplashExtractor.__init__(self, match)\n        self.query = match[2]\n\n    def photos(self):\n        url = self.root + \"/napi/search/photos\"\n        params = {\"query\": text.unquote(self.item.replace('-', ' '))}\n        if self.query:\n            params.update(text.parse_query(self.query))\n        return self._pagination(url, params, True)\n"
  },
  {
    "path": "gallery_dl/extractor/uploadir.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2022-2025 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://uploadir.com/\"\"\"\n\nfrom .common import Extractor, Message\nfrom .. import text\n\n\nclass UploadirFileExtractor(Extractor):\n    \"\"\"Extractor for uploadir files\"\"\"\n    category = \"uploadir\"\n    subcategory = \"file\"\n    root = \"https://uploadir.com\"\n    filename_fmt = \"{filename} ({id}).{extension}\"\n    archive_fmt = \"{id}\"\n    pattern = r\"(?:https?://)?uploadir\\.com/(?:user/)?u(?:ploads)?/([^/?#]+)\"\n    example = \"https://uploadir.com/u/ID\"\n\n    def __init__(self, match):\n        Extractor.__init__(self, match)\n        self.file_id = match[1]\n\n    def items(self):\n        url = f\"{self.root}/u/{self.file_id}\"\n        response = self.request(url, method=\"HEAD\", allow_redirects=False)\n\n        if 300 <= response.status_code < 400:\n            url = response.headers[\"Location\"]\n            extr = text.extract_from(self.request(url).text)\n\n            name = text.unescape(extr(\"<h2>\", \"</h2>\").strip())\n            url = self.root + extr('class=\"form\" action=\"', '\"')\n            token = extr('name=\"authenticity_token\" value=\"', '\"')\n\n            data = text.nameext_from_url(name, {\n                \"_http_method\": \"POST\",\n                \"_http_data\"  : {\n                    \"authenticity_token\": token,\n                    \"upload_id\": self.file_id,\n                },\n            })\n\n        else:\n            hcd = response.headers.get(\"Content-Disposition\")\n            name = (hcd.partition(\"filename*=UTF-8''\")[2] or\n                    text.extr(hcd, 'filename=\"', '\"'))\n            data = text.nameext_from_url(name)\n\n        data[\"id\"] = self.file_id\n        yield Message.Directory, \"\", data\n        yield Message.Url, url, data\n"
  },
  {
    "path": "gallery_dl/extractor/urlgalleries.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://urlgalleries.com/\"\"\"\n\nfrom .common import GalleryExtractor, Message\nfrom .. import text\n\n\nclass UrlgalleriesGalleryExtractor(GalleryExtractor):\n    \"\"\"Extractor for urlgalleries.com galleries\"\"\"\n    category = \"urlgalleries\"\n    root = \"https://urlgalleries.com\"\n    parent = True\n    request_interval = (0.5, 1.5)\n    pattern = (r\"(?:https?://)?(?:\\w+\\.)?urlgalleries\\.com\"\n               r\"/([^/?#]+)/(\\d+)(/[^/?#]+)?\")\n    example = \"https://urlgalleries.com/BLOG/12345/TITLE\"\n\n    def items(self):\n        blog, self.gallery_id, slug = self.groups\n        url = f\"{self.root}/{blog}/{self.gallery_id}{slug or ''}/?a=10000\"\n        page = self.request(url).text\n\n        imgs = self.images(page)\n        data = self.metadata(page)\n        data[\"count\"] = len(imgs)\n\n        if \". Published by \" in (desc := data.pop(\"_desc\")):\n            data[\"blog\"] = text.extr(desc, \". Published by \", \".\")\n\n        root = self.root\n        yield Message.Directory, \"\", data\n        for data[\"num\"], img in enumerate(imgs, 1):\n            page = self.request(root + img).text\n            url = text.extr(page, ' IMAGE_URL = \"', '\"')\n            yield Message.Queue, url, data\n\n    def metadata(self, page):\n        extr = text.extract_from(page)\n        return {\n            \"gallery_id\": self.gallery_id,\n            \"_desc\": extr(\n                'property=\"og:description\" content=\"', '\"'),\n            \"date\" : self.parse_datetime_iso(\n                extr('\"datePublished\":\"', '\"')),\n            \"blog\" : text.unescape(extr(\n                '\"@type\":\"ListItem\",\"position\":2,\"name\":\"', '\"')),\n            \"title\": text.unescape(extr(\n                '\"@type\":\"ListItem\",\"position\":3,\"name\":\"', '\"').strip()),\n            \"tags\" : text.split_html(\n                extr('<div class=\"badgeCatsInner\"', '</div>'))[1:],\n        }\n\n    def images(self, page):\n        imgs = text.extr(page, '<section ', \"</section>\")\n        return list(text.extract_iter(imgs, ' href=\"', '\"'))\n"
  },
  {
    "path": "gallery_dl/extractor/urlshortener.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for general-purpose URL shorteners\"\"\"\n\nfrom .common import BaseExtractor, Message\n\n\nclass UrlshortenerExtractor(BaseExtractor):\n    \"\"\"Base class for URL shortener extractors\"\"\"\n    basecategory = \"urlshortener\"\n\n\nBASE_PATTERN = UrlshortenerExtractor.update({\n    \"bitly\": {\n        \"root\": \"https://bit.ly\",\n        \"pattern\": r\"bit\\.ly\",\n    },\n    \"tco\": {\n        # t.co sends 'http-equiv=\"refresh\"' (200) when using browser UA\n        \"headers\": {\"User-Agent\": None},\n        \"root\": \"https://t.co\",\n        \"pattern\": r\"t\\.co\",\n    },\n})\n\n\nclass UrlshortenerLinkExtractor(UrlshortenerExtractor):\n    \"\"\"Extractor for general-purpose URL shorteners\"\"\"\n    subcategory = \"link\"\n    pattern = BASE_PATTERN + r\"(/[^/?#]+)\"\n    example = \"https://bit.ly/abcde\"\n\n    def items(self):\n        url = self.root + self.groups[-1]\n        location = self.request_location(\n            url, headers=self.config_instance(\"headers\"), notfound=\"URL\")\n        if not location:\n            raise self.exc.AbortExtraction(\"Unable to resolve short URL\")\n        yield Message.Queue, location, {}\n"
  },
  {
    "path": "gallery_dl/extractor/utils/500px_graphql.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2026 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\nOtherPhotosQuery = \"\"\"\\\nquery OtherPhotosQuery($username: String!, $pageSize: Int) {\n  user: userByUsername(username: $username) {\n    ...OtherPhotosPaginationContainer_user_RlXb8\n    id\n  }\n}\n\nfragment OtherPhotosPaginationContainer_user_RlXb8 on User {\n  photos(first: $pageSize, privacy: PROFILE, sort: ID_DESC) {\n    edges {\n      node {\n        id\n        legacyId\n        canonicalPath\n        width\n        height\n        name\n        isLikedByMe\n        notSafeForWork\n        photographer: uploader {\n          id\n          legacyId\n          username\n          displayName\n          canonicalPath\n          followedByUsers {\n            isFollowedByMe\n          }\n        }\n        images(sizes: [33, 35]) {\n          size\n          url\n          jpegUrl\n          webpUrl\n          id\n        }\n        __typename\n      }\n      cursor\n    }\n    totalCount\n    pageInfo {\n      endCursor\n      hasNextPage\n    }\n  }\n}\n\"\"\"\n\nOtherPhotosPaginationContainerQuery = \"\"\"\\\nquery OtherPhotosPaginationContainerQuery($username: String!, $pageSize: Int, $cursor: String) {\n  userByUsername(username: $username) {\n    ...OtherPhotosPaginationContainer_user_3e6UuE\n    id\n  }\n}\n\nfragment OtherPhotosPaginationContainer_user_3e6UuE on User {\n  photos(first: $pageSize, after: $cursor, privacy: PROFILE, sort: ID_DESC) {\n    edges {\n      node {\n        id\n        legacyId\n        canonicalPath\n        width\n        height\n        name\n        isLikedByMe\n        notSafeForWork\n        photographer: uploader {\n          id\n          legacyId\n          username\n          displayName\n          canonicalPath\n          followedByUsers {\n            isFollowedByMe\n          }\n        }\n        images(sizes: [33, 35]) {\n          size\n          url\n          jpegUrl\n          webpUrl\n          id\n        }\n        __typename\n      }\n      cursor\n    }\n    totalCount\n    pageInfo {\n      endCursor\n      hasNextPage\n    }\n  }\n}\n\"\"\"\n\nProfileRendererQuery = \"\"\"\\\nquery ProfileRendererQuery($username: String!) {\n  profile: userByUsername(username: $username) {\n    id\n    legacyId\n    userType: type\n    username\n    firstName\n    displayName\n    registeredAt\n    canonicalPath\n    avatar {\n      ...ProfileAvatar_avatar\n      id\n    }\n    userProfile {\n      firstname\n      lastname\n      state\n      country\n      city\n      about\n      id\n    }\n    socialMedia {\n      website\n      twitter\n      instagram\n      facebook\n      id\n    }\n    coverPhotoUrl\n    followedByUsers {\n      totalCount\n      isFollowedByMe\n    }\n    followingUsers {\n      totalCount\n    }\n    membership {\n      expiryDate\n      membershipTier: tier\n      photoUploadQuota\n      refreshPhotoUploadQuotaAt\n      paymentStatus\n      id\n    }\n    profileTabs {\n      tabs {\n        name\n        visible\n      }\n    }\n    ...EditCover_cover\n    photoStats {\n      likeCount\n      viewCount\n    }\n    photos(privacy: PROFILE) {\n      totalCount\n    }\n    licensingPhotos(status: ACCEPTED) {\n      totalCount\n    }\n    portfolio {\n      id\n      status\n      userDisabled\n    }\n  }\n}\n\nfragment EditCover_cover on User {\n  coverPhotoUrl\n}\n\nfragment ProfileAvatar_avatar on UserAvatar {\n  images(sizes: [MEDIUM, LARGE]) {\n    size\n    url\n    id\n  }\n}\n\"\"\"\n\nGalleriesDetailQueryRendererQuery = \"\"\"\\\nquery GalleriesDetailQueryRendererQuery($galleryOwnerLegacyId: ID!, $ownerLegacyId: String, $slug: String, $token: String, $pageSize: Int, $gallerySize: Int) {\n  galleries(galleryOwnerLegacyId: $galleryOwnerLegacyId, first: $gallerySize) {\n    edges {\n      node {\n        legacyId\n        description\n        name\n        privacy\n        canonicalPath\n        notSafeForWork\n        buttonName\n        externalUrl\n        cover {\n          images(sizes: [35, 33]) {\n            size\n            webpUrl\n            jpegUrl\n            id\n          }\n          id\n        }\n        photos {\n          totalCount\n        }\n        id\n      }\n    }\n  }\n  gallery: galleryByOwnerIdAndSlugOrToken(ownerLegacyId: $ownerLegacyId, slug: $slug, token: $token) {\n    ...GalleriesDetailPaginationContainer_gallery_RlXb8\n    id\n  }\n}\n\nfragment GalleriesDetailPaginationContainer_gallery_RlXb8 on Gallery {\n  id\n  legacyId\n  name\n  privacy\n  notSafeForWork\n  ownPhotosOnly\n  canonicalPath\n  publicSlug\n  lastPublishedAt\n  photosAddedSinceLastPublished\n  reportStatus\n  creator {\n    legacyId\n    id\n  }\n  cover {\n    images(sizes: [33, 32, 36, 2048]) {\n      url\n      size\n      webpUrl\n      id\n    }\n    id\n  }\n  description\n  externalUrl\n  buttonName\n  photos(first: $pageSize) {\n    totalCount\n    edges {\n      cursor\n      node {\n        id\n        legacyId\n        canonicalPath\n        name\n        description\n        category\n        uploadedAt\n        location\n        width\n        height\n        isLikedByMe\n        photographer: uploader {\n          id\n          legacyId\n          username\n          displayName\n          canonicalPath\n          avatar {\n            images(sizes: SMALL) {\n              url\n              id\n            }\n            id\n          }\n          followedByUsers {\n            totalCount\n            isFollowedByMe\n          }\n        }\n        images(sizes: [33, 32]) {\n          size\n          url\n          webpUrl\n          id\n        }\n        __typename\n      }\n    }\n    pageInfo {\n      endCursor\n      hasNextPage\n    }\n  }\n}\n\"\"\"\n\nGalleriesDetailPaginationContainerQuery = \"\"\"\\\nquery GalleriesDetailPaginationContainerQuery($ownerLegacyId: String, $slug: String, $token: String, $pageSize: Int, $cursor: String) {\n  galleryByOwnerIdAndSlugOrToken(ownerLegacyId: $ownerLegacyId, slug: $slug, token: $token) {\n    ...GalleriesDetailPaginationContainer_gallery_3e6UuE\n    id\n  }\n}\n\nfragment GalleriesDetailPaginationContainer_gallery_3e6UuE on Gallery {\n  id\n  legacyId\n  name\n  privacy\n  notSafeForWork\n  ownPhotosOnly\n  canonicalPath\n  publicSlug\n  lastPublishedAt\n  photosAddedSinceLastPublished\n  reportStatus\n  creator {\n    legacyId\n    id\n  }\n  cover {\n    images(sizes: [33, 32, 36, 2048]) {\n      url\n      size\n      webpUrl\n      id\n    }\n    id\n  }\n  description\n  externalUrl\n  buttonName\n  photos(first: $pageSize, after: $cursor) {\n    totalCount\n    edges {\n      cursor\n      node {\n        id\n        legacyId\n        canonicalPath\n        name\n        description\n        category\n        uploadedAt\n        location\n        width\n        height\n        isLikedByMe\n        photographer: uploader {\n          id\n          legacyId\n          username\n          displayName\n          canonicalPath\n          avatar {\n            images(sizes: SMALL) {\n              url\n              id\n            }\n            id\n          }\n          followedByUsers {\n            totalCount\n            isFollowedByMe\n          }\n        }\n        images(sizes: [33, 32]) {\n          size\n          url\n          webpUrl\n          id\n        }\n        __typename\n      }\n    }\n    pageInfo {\n      endCursor\n      hasNextPage\n    }\n  }\n}\n\"\"\"\n\nLikedPhotosQueryRendererQuery = \"\"\"\\\nquery LikedPhotosQueryRendererQuery($pageSize: Int) {\n  ...LikedPhotosPaginationContainer_query_RlXb8\n}\n\nfragment LikedPhotosPaginationContainer_query_RlXb8 on Query {\n  likedPhotos(first: $pageSize) {\n    edges {\n      node {\n        id\n        legacyId\n        canonicalPath\n        name\n        description\n        category\n        uploadedAt\n        location\n        width\n        height\n        isLikedByMe\n        notSafeForWork\n        tags\n        photographer: uploader {\n          id\n          legacyId\n          username\n          displayName\n          canonicalPath\n          avatar {\n            images {\n              url\n              id\n            }\n            id\n          }\n          followedByUsers {\n            totalCount\n            isFollowedByMe\n          }\n        }\n        images(sizes: [33, 35]) {\n          size\n          url\n          jpegUrl\n          webpUrl\n          id\n        }\n        __typename\n      }\n      cursor\n    }\n    pageInfo {\n      endCursor\n      hasNextPage\n    }\n  }\n}\n\"\"\"\n\nLikedPhotosPaginationContainerQuery = \"\"\"\\\nquery LikedPhotosPaginationContainerQuery($cursor: String, $pageSize: Int) {\n  ...LikedPhotosPaginationContainer_query_3e6UuE\n}\n\nfragment LikedPhotosPaginationContainer_query_3e6UuE on Query {\n  likedPhotos(first: $pageSize, after: $cursor) {\n    edges {\n      node {\n        id\n        legacyId\n        canonicalPath\n        name\n        description\n        category\n        uploadedAt\n        location\n        width\n        height\n        isLikedByMe\n        notSafeForWork\n        tags\n        photographer: uploader {\n          id\n          legacyId\n          username\n          displayName\n          canonicalPath\n          avatar {\n            images {\n              url\n              id\n            }\n            id\n          }\n          followedByUsers {\n            totalCount\n            isFollowedByMe\n          }\n        }\n        images(sizes: [33, 35]) {\n          size\n          url\n          jpegUrl\n          webpUrl\n          id\n        }\n        __typename\n      }\n      cursor\n    }\n    pageInfo {\n      endCursor\n      hasNextPage\n    }\n  }\n}\n\"\"\"\n"
  },
  {
    "path": "gallery_dl/extractor/utils/__init__.py",
    "content": ""
  },
  {
    "path": "gallery_dl/extractor/utils/behance_graphql.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2026 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\nGetProfileProjects = \"\"\"\\\nquery GetProfileProjects($username: String, $after: String) {\n  user(username: $username) {\n    profileProjects(first: 12, after: $after) {\n      pageInfo {\n        endCursor\n        hasNextPage\n      }\n      nodes {\n        __typename\n        adminFlags {\n          mature_lock\n          privacy_lock\n          dmca_lock\n          flagged_lock\n          privacy_violation_lock\n          trademark_lock\n          spam_lock\n          eu_ip_lock\n        }\n        colors {\n          r\n          g\n          b\n        }\n        covers {\n          size_202 {\n            url\n          }\n          size_404 {\n            url\n          }\n          size_808 {\n            url\n          }\n        }\n        features {\n          url\n          name\n          featuredOn\n          ribbon {\n            image\n            image2x\n            image3x\n          }\n        }\n        fields {\n          id\n          label\n          slug\n          url\n        }\n        hasMatureContent\n        id\n        isFeatured\n        isHiddenFromWorkTab\n        isMatureReviewSubmitted\n        isOwner\n        isFounder\n        isPinnedToSubscriptionOverview\n        isPrivate\n        linkedAssets {\n          ...sourceLinkFields\n        }\n        linkedAssetsCount\n        sourceFiles {\n          ...sourceFileFields\n        }\n        matureAccess\n        modifiedOn\n        name\n        owners {\n          ...OwnerFields\n          images {\n            size_50 {\n              url\n            }\n          }\n        }\n        premium\n        publishedOn\n        stats {\n          appreciations {\n            all\n          }\n          views {\n            all\n          }\n          comments {\n            all\n          }\n        }\n        slug\n        tools {\n          id\n          title\n          category\n          categoryLabel\n          categoryId\n          approved\n          url\n          backgroundColor\n        }\n        url\n      }\n    }\n  }\n}\n\nfragment sourceFileFields on SourceFile {\n  __typename\n  sourceFileId\n  projectId\n  userId\n  title\n  assetId\n  renditionUrl\n  mimeType\n  size\n  category\n  licenseType\n  unitAmount\n  currency\n  tier\n  hidden\n  extension\n  hasUserPurchased\n}\n\nfragment sourceLinkFields on LinkedAsset {\n  __typename\n  name\n  premium\n  url\n  category\n  licenseType\n}\n\nfragment OwnerFields on User {\n  displayName\n  hasPremiumAccess\n  id\n  isFollowing\n  isProfileOwner\n  location\n  locationUrl\n  url\n  username\n  availabilityInfo {\n    availabilityTimeline\n    isAvailableFullTime\n    isAvailableFreelance\n  }\n}\n\"\"\"\n\nGetMoodboardItemsAndRecommendations = \"\"\"\\\nquery GetMoodboardItemsAndRecommendations(\n  $id: Int!\n  $firstItem: Int!\n  $afterItem: String\n  $shouldGetRecommendations: Boolean!\n  $shouldGetItems: Boolean!\n  $shouldGetMoodboardFields: Boolean!\n) {\n  viewer @include(if: $shouldGetMoodboardFields) {\n    isOptedOutOfRecommendations\n    isAdmin\n  }\n  moodboard(id: $id) {\n    ...moodboardFields @include(if: $shouldGetMoodboardFields)\n\n    items(first: $firstItem, after: $afterItem) @include(if: $shouldGetItems) {\n      pageInfo {\n        endCursor\n        hasNextPage\n      }\n      nodes {\n        ...nodesFields\n      }\n    }\n\n    recommendedItems(first: 80) @include(if: $shouldGetRecommendations) {\n      nodes {\n        ...nodesFields\n        fetchSource\n      }\n    }\n  }\n}\n\nfragment moodboardFields on Moodboard {\n  id\n  label\n  privacy\n  followerCount\n  isFollowing\n  projectCount\n  url\n  isOwner\n  owners {\n    ...OwnerFields\n    images {\n      size_50 {\n        url\n      }\n      size_100 {\n        url\n      }\n      size_115 {\n        url\n      }\n      size_230 {\n        url\n      }\n      size_138 {\n        url\n      }\n      size_276 {\n        url\n      }\n    }\n  }\n}\n\nfragment projectFields on Project {\n  __typename\n  id\n  isOwner\n  publishedOn\n  matureAccess\n  hasMatureContent\n  modifiedOn\n  name\n  url\n  isPrivate\n  slug\n  license {\n    license\n    description\n    id\n    label\n    url\n    text\n    images\n  }\n  fields {\n    label\n  }\n  colors {\n    r\n    g\n    b\n  }\n  owners {\n    ...OwnerFields\n    images {\n      size_50 {\n        url\n      }\n      size_100 {\n        url\n      }\n      size_115 {\n        url\n      }\n      size_230 {\n        url\n      }\n      size_138 {\n        url\n      }\n      size_276 {\n        url\n      }\n    }\n  }\n  covers {\n    size_original {\n      url\n    }\n    size_max_808 {\n      url\n    }\n    size_808 {\n      url\n    }\n    size_404 {\n      url\n    }\n    size_202 {\n      url\n    }\n    size_230 {\n      url\n    }\n    size_115 {\n      url\n    }\n  }\n  stats {\n    views {\n      all\n    }\n    appreciations {\n      all\n    }\n    comments {\n      all\n    }\n  }\n}\n\nfragment exifDataValueFields on exifDataValue {\n  id\n  label\n  value\n  searchValue\n}\n\nfragment nodesFields on MoodboardItem {\n  id\n  entityType\n  width\n  height\n  flexWidth\n  flexHeight\n  images {\n    size\n    url\n  }\n\n  entity {\n    ... on Project {\n      ...projectFields\n    }\n\n    ... on ImageModule {\n      project {\n        ...projectFields\n      }\n\n      colors {\n        r\n        g\n        b\n      }\n\n      exifData {\n        lens {\n          ...exifDataValueFields\n        }\n        software {\n          ...exifDataValueFields\n        }\n        makeAndModel {\n          ...exifDataValueFields\n        }\n        focalLength {\n          ...exifDataValueFields\n        }\n        iso {\n          ...exifDataValueFields\n        }\n        location {\n          ...exifDataValueFields\n        }\n        flash {\n          ...exifDataValueFields\n        }\n        exposureMode {\n          ...exifDataValueFields\n        }\n        shutterSpeed {\n          ...exifDataValueFields\n        }\n        aperture {\n          ...exifDataValueFields\n        }\n      }\n    }\n\n    ... on MediaCollectionComponent {\n      project {\n        ...projectFields\n      }\n    }\n  }\n}\n\nfragment OwnerFields on User {\n  displayName\n  hasPremiumAccess\n  id\n  isFollowing\n  isProfileOwner\n  location\n  locationUrl\n  url\n  username\n  availabilityInfo {\n    availabilityTimeline\n    isAvailableFullTime\n    isAvailableFreelance\n  }\n}\n\"\"\"\n"
  },
  {
    "path": "gallery_dl/extractor/utils/deviantart_journal.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2026 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\nSHADOW = \"\"\"\n<span class=\"shadow\">\n    <img src=\"{src}\" class=\"smshadow\" width=\"{width}\" height=\"{height}\">\n</span>\n<br><br>\n\"\"\"\n\nHEADER = \"\"\"<div usr class=\"gr\">\n<div class=\"metadata\">\n    <h2><a href=\"{url}\">{title}</a></h2>\n    <ul>\n        <li class=\"author\">\n            by <span class=\"name\"><span class=\"username-with-symbol u\">\n            <a class=\"u regular username\" href=\"{userurl}\">{username}</a>\\\n<span class=\"user-symbol regular\"></span></span></span>,\n            <span>{date}</span>\n        </li>\n    </ul>\n</div>\n\"\"\"\n\nHEADER_CUSTOM = \"\"\"<div class='boxtop journaltop'>\n<h2>\n    <img src=\"https://st.deviantart.net/minish/gruzecontrol/icons/journal.gif\\\n?2\" style=\"vertical-align:middle\" alt=\"\"/>\n    <a href=\"{url}\">{title}</a>\n</h2>\nJournal Entry: <span>{date}</span>\n\"\"\"\n\nHTML = \"\"\"text:<!DOCTYPE html>\n<html>\n<head>\n    <meta charset=\"utf-8\">\n    <title>{title}</title>\n    <link rel=\"stylesheet\" href=\"https://st.deviantart.net\\\n/css/deviantart-network_lc.css?3843780832\"/>\n    <link rel=\"stylesheet\" href=\"https://st.deviantart.net\\\n/css/group_secrets_lc.css?3250492874\"/>\n    <link rel=\"stylesheet\" href=\"https://st.deviantart.net\\\n/css/v6core_lc.css?4246581581\"/>\n    <link rel=\"stylesheet\" href=\"https://st.deviantart.net\\\n/css/sidebar_lc.css?1490570941\"/>\n    <link rel=\"stylesheet\" href=\"https://st.deviantart.net\\\n/css/writer_lc.css?3090682151\"/>\n    <link rel=\"stylesheet\" href=\"https://st.deviantart.net\\\n/css/v6loggedin_lc.css?3001430805\"/>\n    <style>{css}</style>\n    <link rel=\"stylesheet\" href=\"https://st.deviantart.net\\\n/roses/cssmin/core.css?1488405371919\"/>\n    <link rel=\"stylesheet\" href=\"https://st.deviantart.net\\\n/roses/cssmin/peeky.css?1487067424177\"/>\n    <link rel=\"stylesheet\" href=\"https://st.deviantart.net\\\n/roses/cssmin/desktop.css?1491362542749\"/>\n    <link rel=\"stylesheet\" href=\"https://static.parastorage.com/services\\\n/da-deviation/2bfd1ff7a9d6bf10d27b98dd8504c0399c3f9974a015785114b7dc6b\\\n/app.min.css\"/>\n</head>\n<body id=\"deviantART-v7\" class=\"bubble no-apps loggedout w960 deviantart\">\n    <div id=\"output\">\n    <div class=\"dev-page-container bubbleview\">\n    <div class=\"dev-page-view view-mode-normal\">\n    <div class=\"dev-view-main-content\">\n    <div class=\"dev-view-deviation\">\n    {shadow}\n    <div class=\"journal-wrapper tt-a\">\n    <div class=\"journal-wrapper2\">\n    <div class=\"journal {cls} journalcontrol\">\n    {html}\n    </div>\n    </div>\n    </div>\n    </div>\n    </div>\n    </div>\n    </div>\n    </div>\n</body>\n</html>\n\"\"\"\n\nHTML_EXTRA = \"\"\"\\\n<div id=\"devskin0\"><div class=\"negate-box-margin\" style=\"\">\\\n<div usr class=\"gr-box gr-genericbox\"\n        ><i usr class=\"gr1\"><i></i></i\n        ><i usr class=\"gr2\"><i></i></i\n        ><i usr class=\"gr3\"><i></i></i\n        ><div usr class=\"gr-top\">\n            <i usr class=\"tri\"></i>\n            {}\n            </div>\n    </div><div usr class=\"gr-body\"><div usr class=\"gr\">\n            <div class=\"grf-indent\">\n            <div class=\"text\">\n                {}            </div>\n        </div>\n                </div></div>\n        <i usr class=\"gr3 gb\"></i>\n        <i usr class=\"gr2 gb\"></i>\n        <i usr class=\"gr1 gb gb1\"></i>    </div>\n    </div></div>\"\"\"\n\nTEXT = \"\"\"text:{title}\nby {username}, {date}\n\n{content}\n\"\"\"\n"
  },
  {
    "path": "gallery_dl/extractor/utils/deviantart_tiptap.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2026 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom ... import text, util\nfrom .. deviantart import eclipse_media\n\n\ndef to_html(markup):\n    html = []\n\n    html.append('<div data-editor-viewer=\"1\" '\n                'class=\"_83r8m _2CKTq _3NjDa mDnFl\">')\n    data = util.json_loads(markup)\n    for block in data[\"document\"][\"content\"]:\n        process_content(html, block)\n    html.append(\"</div>\")\n\n    return \"\".join(html)\n\n\ndef process_content(html, content):\n    type = content[\"type\"]\n\n    if type == \"paragraph\":\n        if children := content.get(\"content\"):\n            html.append('<p style=\"')\n\n            if attrs := content.get(\"attrs\"):\n                if align := attrs.get(\"textAlign\"):\n                    html.append(\"text-align:\")\n                    html.append(align)\n                    html.append(\";\")\n                process_indentation(html, attrs)\n                html.append('\">')\n            else:\n                html.append('margin-inline-start:0px\">')\n\n            for block in children:\n                process_content(html, block)\n            html.append(\"</p>\")\n        else:\n            html.append('<p class=\"empty-p\"><br/></p>')\n\n    elif type == \"text\":\n        process_text(html, content)\n\n    elif type == \"heading\":\n        attrs = content[\"attrs\"]\n        level = str(attrs.get(\"level\") or \"3\")\n\n        html.append(\"<h\")\n        html.append(level)\n        html.append(' style=\"text-align:')\n        html.append(attrs.get(\"textAlign\") or \"left\")\n        html.append('\">')\n        html.append('<span style=\"')\n        process_indentation(html, attrs)\n        html.append('\">')\n        process_children(html, content)\n        html.append(\"</span></h\")\n        html.append(level)\n        html.append(\">\")\n\n    elif type in {\"listItem\", \"bulletList\", \"orderedList\", \"blockquote\"}:\n        c = type[1]\n        tag = (\n            \"li\" if c == \"i\" else\n            \"ul\" if c == \"u\" else\n            \"ol\" if c == \"r\" else\n            \"blockquote\"\n        )\n        html.append(\"<\" + tag + \">\")\n        process_children(html, content)\n        html.append(\"</\" + tag + \">\")\n\n    elif type == \"anchor\":\n        attrs = content[\"attrs\"]\n        html.append('<a id=\"')\n        html.append(attrs.get(\"id\") or \"\")\n        html.append('\" data-testid=\"anchor\"></a>')\n\n    elif type == \"hardBreak\":\n        html.append(\"<br/><br/>\")\n\n    elif type == \"horizontalRule\":\n        html.append(\"<hr/>\")\n\n    elif type == \"da-deviation\":\n        process_deviation(html, content)\n\n    elif type == \"da-mention\":\n        user = content[\"attrs\"][\"user\"][\"username\"]\n        html.append('<a href=\"https://www.deviantart.com/')\n        html.append(user.lower())\n        html.append('\" data-da-type=\"da-mention\" data-user=\"\">@<!-- -->')\n        html.append(user)\n        html.append('</a>')\n\n    elif type == \"da-gif\":\n        attrs = content[\"attrs\"]\n        width = str(attrs.get(\"width\") or \"\")\n        height = str(attrs.get(\"height\") or \"\")\n        url = text.escape(attrs.get(\"url\") or \"\")\n\n        html.append('<div data-da-type=\"da-gif\" data-width=\"')\n        html.append(width)\n        html.append('\" data-height=\"')\n        html.append(height)\n        html.append('\" data-alignment=\"')\n        html.append(attrs.get(\"alignment\") or \"\")\n        html.append('\" data-url=\"')\n        html.append(url)\n        html.append('\" class=\"t61qu\"><video role=\"img\" autoPlay=\"\" '\n                    'muted=\"\" loop=\"\" style=\"pointer-events:none\" '\n                    'controlsList=\"nofullscreen\" playsInline=\"\" '\n                    'aria-label=\"gif\" data-da-type=\"da-gif\" width=\"')\n        html.append(width)\n        html.append('\" height=\"')\n        html.append(height)\n        html.append('\" src=\"')\n        html.append(url)\n        html.append('\" class=\"_1Fkk6\"></video></div>')\n\n    elif type == \"da-video\":\n        src = text.escape(content[\"attrs\"].get(\"src\") or \"\")\n        html.append('<div data-testid=\"video\" data-da-type=\"da-video\" '\n                    'data-src=\"')\n        html.append(src)\n        html.append('\" class=\"_1Uxvs\"><div data-canfs=\"yes\" data-testid=\"v'\n                    'ideo-inner\" class=\"main-video\" style=\"width:780px;hei'\n                    'ght:438px\"><div style=\"width:780px;height:438px\">'\n                    '<video src=\"')\n        html.append(src)\n        html.append('\" style=\"width:100%;height:100%;\" preload=\"auto\" cont'\n                    'rols=\"\"></video></div></div></div>')\n\n    else:\n        import logging\n        logging.getLogger(\"tiptap\").warning(\n            \"Unsupported content type '%s'\", type)\n\n\ndef process_text(html, content):\n    if marks := content.get(\"marks\"):\n        close = []\n        for mark in marks:\n            type = mark[\"type\"]\n            if type == \"link\":\n                attrs = mark.get(\"attrs\") or {}\n                html.append('<a href=\"')\n                html.append(text.escape(attrs.get(\"href\") or \"\"))\n                if \"target\" in attrs:\n                    html.append('\" target=\"')\n                    html.append(attrs[\"target\"])\n                html.append('\" rel=\"')\n                html.append(attrs.get(\"rel\") or\n                            \"noopener noreferrer nofollow ugc\")\n                html.append('\">')\n                close.append(\"</a>\")\n            elif type == \"bold\":\n                html.append(\"<strong>\")\n                close.append(\"</strong>\")\n            elif type == \"italic\":\n                html.append(\"<em>\")\n                close.append(\"</em>\")\n            elif type == \"underline\":\n                html.append(\"<u>\")\n                close.append(\"</u>\")\n            elif type == \"strike\":\n                html.append(\"<s>\")\n                close.append(\"</s>\")\n            elif type == \"textStyle\" and len(mark) <= 1:\n                pass\n            else:\n                import logging\n                logging.getLogger(\"tiptap\").warning(\n                    \"Unsupported text marker '%s'\", type)\n        close.reverse()\n        html.append(text.escape(content[\"text\"]))\n        html.extend(close)\n    else:\n        html.append(text.escape(content[\"text\"]))\n\n\ndef process_children(html, content):\n    if children := content.get(\"content\"):\n        for block in children:\n            process_content(html, block)\n\n\ndef process_indentation(html, attrs):\n    itype = (\"text-indent\" if attrs.get(\"indentType\") == \"line\" else\n             \"margin-inline-start\")\n    isize = str((attrs.get(\"indentation\") or 0) * 24)\n    html.append(itype + \":\" + isize + \"px\")\n\n\ndef process_deviation(html, content):\n    dev = content[\"attrs\"][\"deviation\"]\n    media = dev.get(\"media\") or ()\n\n    html.append('<div class=\"jjNX2\">')\n    html.append('<figure class=\"Qf-HY\" data-da-type=\"da-deviation\" '\n                'data-deviation=\"\" '\n                'data-width=\"\" data-link=\"\" data-alignment=\"center\">')\n\n    if \"baseUri\" in media:\n        url, formats = eclipse_media(media)\n        full = formats[\"fullview\"]\n\n        html.append('<a href=\"')\n        html.append(text.escape(dev[\"url\"]))\n        html.append('\" class=\"_3ouD5\" style=\"margin:0 auto;display:flex;'\n                    'align-items:center;justify-content:center;'\n                    'overflow:hidden;width:780px;height:')\n        html.append(str(780 * full[\"h\"] / full[\"w\"]))\n        html.append('px\">')\n\n        html.append('<img src=\"')\n        html.append(text.escape(url))\n        html.append('\" alt=\"')\n        html.append(text.escape(dev[\"title\"]))\n        html.append('\" style=\"width:100%;max-width:100%;display:block\"/>')\n        html.append(\"</a>\")\n\n    elif \"textContent\" in dev:\n        html.append('<div class=\"_32Hs4\" style=\"width:350px\">')\n\n        html.append('<a href=\"')\n        html.append(text.escape(dev[\"url\"]))\n        html.append('\" class=\"_3ouD5\">')\n\n        html.append('''\\\n<section class=\"Q91qI aG7Yi\" style=\"width:350px;height:313px\">\\\n<div class=\"_16ECM _1xMkk\" aria-hidden=\"true\">\\\n<svg height=\"100%\" viewBox=\"0 0 15 12\" preserveAspectRatio=\"xMidYMin slice\" \\\nfill-rule=\"evenodd\">\\\n<linearGradient x1=\"87.8481761%\" y1=\"16.3690766%\" \\\nx2=\"45.4107524%\" y2=\"71.4898596%\" id=\"app-root-3\">\\\n<stop stop-color=\"#00FF62\" offset=\"0%\"></stop>\\\n<stop stop-color=\"#3197EF\" stop-opacity=\"0\" offset=\"100%\"></stop>\\\n</linearGradient>\\\n<text class=\"_2uqbc\" fill=\"url(#app-root-3)\" text-anchor=\"end\" x=\"15\" y=\"11\">J\\\n</text></svg></div><div class=\"_1xz9u\">Literature</div><h3 class=\"_2WvKD\">\\\n''')\n        html.append(text.escape(dev[\"title\"]))\n        html.append('</h3><div class=\"_2CPLm\">')\n        html.append(text.escape(dev[\"textContent\"][\"excerpt\"]))\n        html.append('</div></section></a></div>')\n\n    html.append('</figure></div>')\n"
  },
  {
    "path": "gallery_dl/extractor/utils/geo.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2026 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n# Adapted from yt-dlp.\n# https://github.com/yt-dlp/yt-dlp/blob/master/yt_dlp/utils/_utils.py\n\nimport random\nimport socket\nimport struct\n\n\nCOUNTRY_IP_MAP = {\n    \"AD\": \"46.172.224.0/19\",\n    \"AE\": \"94.200.0.0/13\",\n    \"AF\": \"149.54.0.0/17\",\n    \"AG\": \"209.59.64.0/18\",\n    \"AI\": \"204.14.248.0/21\",\n    \"AL\": \"46.99.0.0/16\",\n    \"AM\": \"46.70.0.0/15\",\n    \"AO\": \"105.168.0.0/13\",\n    \"AP\": \"182.50.184.0/21\",\n    \"AQ\": \"23.154.160.0/24\",\n    \"AR\": \"181.0.0.0/12\",\n    \"AS\": \"202.70.112.0/20\",\n    \"AT\": \"77.116.0.0/14\",\n    \"AU\": \"1.128.0.0/11\",\n    \"AW\": \"181.41.0.0/18\",\n    \"AX\": \"185.217.4.0/22\",\n    \"AZ\": \"5.197.0.0/16\",\n    \"BA\": \"31.176.128.0/17\",\n    \"BB\": \"65.48.128.0/17\",\n    \"BD\": \"114.130.0.0/16\",\n    \"BE\": \"57.0.0.0/8\",\n    \"BF\": \"102.178.0.0/15\",\n    \"BG\": \"95.42.0.0/15\",\n    \"BH\": \"37.131.0.0/17\",\n    \"BI\": \"154.117.192.0/18\",\n    \"BJ\": \"137.255.0.0/16\",\n    \"BL\": \"185.212.72.0/23\",\n    \"BM\": \"196.12.64.0/18\",\n    \"BN\": \"156.31.0.0/16\",\n    \"BO\": \"161.56.0.0/16\",\n    \"BQ\": \"161.0.80.0/20\",\n    \"BR\": \"191.128.0.0/12\",\n    \"BS\": \"24.51.64.0/18\",\n    \"BT\": \"119.2.96.0/19\",\n    \"BW\": \"168.167.0.0/16\",\n    \"BY\": \"178.120.0.0/13\",\n    \"BZ\": \"179.42.192.0/18\",\n    \"CA\": \"99.224.0.0/11\",\n    \"CD\": \"41.243.0.0/16\",\n    \"CF\": \"197.242.176.0/21\",\n    \"CG\": \"160.113.0.0/16\",\n    \"CH\": \"85.0.0.0/13\",\n    \"CI\": \"102.136.0.0/14\",\n    \"CK\": \"202.65.32.0/19\",\n    \"CL\": \"152.172.0.0/14\",\n    \"CM\": \"102.244.0.0/14\",\n    \"CN\": \"36.128.0.0/10\",\n    \"CO\": \"181.240.0.0/12\",\n    \"CR\": \"201.192.0.0/12\",\n    \"CU\": \"152.206.0.0/15\",\n    \"CV\": \"165.90.96.0/19\",\n    \"CW\": \"190.88.128.0/17\",\n    \"CY\": \"31.153.0.0/16\",\n    \"CZ\": \"88.100.0.0/14\",\n    \"DE\": \"53.0.0.0/8\",\n    \"DJ\": \"197.241.0.0/17\",\n    \"DK\": \"87.48.0.0/12\",\n    \"DM\": \"192.243.48.0/20\",\n    \"DO\": \"152.166.0.0/15\",\n    \"DZ\": \"41.96.0.0/12\",\n    \"EC\": \"186.68.0.0/15\",\n    \"EE\": \"90.190.0.0/15\",\n    \"EG\": \"156.160.0.0/11\",\n    \"ER\": \"196.200.96.0/20\",\n    \"ES\": \"88.0.0.0/11\",\n    \"ET\": \"196.188.0.0/14\",\n    \"EU\": \"2.16.0.0/13\",\n    \"FI\": \"91.152.0.0/13\",\n    \"FJ\": \"144.120.0.0/16\",\n    \"FK\": \"80.73.208.0/21\",\n    \"FM\": \"119.252.112.0/20\",\n    \"FO\": \"88.85.32.0/19\",\n    \"FR\": \"90.0.0.0/9\",\n    \"GA\": \"41.158.0.0/15\",\n    \"GB\": \"25.0.0.0/8\",\n    \"GD\": \"74.122.88.0/21\",\n    \"GE\": \"31.146.0.0/16\",\n    \"GF\": \"161.22.64.0/18\",\n    \"GG\": \"62.68.160.0/19\",\n    \"GH\": \"154.160.0.0/12\",\n    \"GI\": \"95.164.0.0/16\",\n    \"GL\": \"88.83.0.0/19\",\n    \"GM\": \"160.182.0.0/15\",\n    \"GN\": \"197.149.192.0/18\",\n    \"GP\": \"104.250.0.0/19\",\n    \"GQ\": \"105.235.224.0/20\",\n    \"GR\": \"94.64.0.0/13\",\n    \"GT\": \"168.234.0.0/16\",\n    \"GU\": \"168.123.0.0/16\",\n    \"GW\": \"197.214.80.0/20\",\n    \"GY\": \"181.41.64.0/18\",\n    \"HK\": \"113.252.0.0/14\",\n    \"HN\": \"181.210.0.0/16\",\n    \"HR\": \"93.136.0.0/13\",\n    \"HT\": \"148.102.128.0/17\",\n    \"HU\": \"84.0.0.0/14\",\n    \"ID\": \"39.192.0.0/10\",\n    \"IE\": \"87.32.0.0/12\",\n    \"IL\": \"79.176.0.0/13\",\n    \"IM\": \"5.62.80.0/20\",\n    \"IN\": \"117.192.0.0/10\",\n    \"IO\": \"203.83.48.0/21\",\n    \"IQ\": \"37.236.0.0/14\",\n    \"IR\": \"2.176.0.0/12\",\n    \"IS\": \"82.221.0.0/16\",\n    \"IT\": \"79.0.0.0/10\",\n    \"JE\": \"87.244.64.0/18\",\n    \"JM\": \"72.27.0.0/17\",\n    \"JO\": \"176.29.0.0/16\",\n    \"JP\": \"133.0.0.0/8\",\n    \"KE\": \"105.48.0.0/12\",\n    \"KG\": \"158.181.128.0/17\",\n    \"KH\": \"36.37.128.0/17\",\n    \"KI\": \"103.25.140.0/22\",\n    \"KM\": \"197.255.224.0/20\",\n    \"KN\": \"198.167.192.0/19\",\n    \"KP\": \"175.45.176.0/22\",\n    \"KR\": \"175.192.0.0/10\",\n    \"KW\": \"37.36.0.0/14\",\n    \"KY\": \"64.96.0.0/15\",\n    \"KZ\": \"2.72.0.0/13\",\n    \"LA\": \"115.84.64.0/18\",\n    \"LB\": \"178.135.0.0/16\",\n    \"LC\": \"24.92.144.0/20\",\n    \"LI\": \"82.117.0.0/19\",\n    \"LK\": \"112.134.0.0/15\",\n    \"LR\": \"102.183.0.0/16\",\n    \"LS\": \"129.232.0.0/17\",\n    \"LT\": \"78.56.0.0/13\",\n    \"LU\": \"188.42.0.0/16\",\n    \"LV\": \"46.109.0.0/16\",\n    \"LY\": \"41.252.0.0/14\",\n    \"MA\": \"105.128.0.0/11\",\n    \"MC\": \"88.209.64.0/18\",\n    \"MD\": \"37.246.0.0/16\",\n    \"ME\": \"178.175.0.0/17\",\n    \"MF\": \"74.112.232.0/21\",\n    \"MG\": \"154.126.0.0/17\",\n    \"MH\": \"117.103.88.0/21\",\n    \"MK\": \"77.28.0.0/15\",\n    \"ML\": \"154.118.128.0/18\",\n    \"MM\": \"37.111.0.0/17\",\n    \"MN\": \"49.0.128.0/17\",\n    \"MO\": \"60.246.0.0/16\",\n    \"MP\": \"202.88.64.0/20\",\n    \"MQ\": \"109.203.224.0/19\",\n    \"MR\": \"41.188.64.0/18\",\n    \"MS\": \"208.90.112.0/22\",\n    \"MT\": \"46.11.0.0/16\",\n    \"MU\": \"105.16.0.0/12\",\n    \"MV\": \"27.114.128.0/18\",\n    \"MW\": \"102.70.0.0/15\",\n    \"MX\": \"187.192.0.0/11\",\n    \"MY\": \"175.136.0.0/13\",\n    \"MZ\": \"197.218.0.0/15\",\n    \"NA\": \"41.182.0.0/16\",\n    \"NC\": \"101.101.0.0/18\",\n    \"NE\": \"197.214.0.0/18\",\n    \"NF\": \"203.17.240.0/22\",\n    \"NG\": \"105.112.0.0/12\",\n    \"NI\": \"186.76.0.0/15\",\n    \"NL\": \"145.96.0.0/11\",\n    \"NO\": \"84.208.0.0/13\",\n    \"NP\": \"36.252.0.0/15\",\n    \"NR\": \"203.98.224.0/19\",\n    \"NU\": \"49.156.48.0/22\",\n    \"NZ\": \"49.224.0.0/14\",\n    \"OM\": \"5.36.0.0/15\",\n    \"PA\": \"186.72.0.0/15\",\n    \"PE\": \"186.160.0.0/14\",\n    \"PF\": \"123.50.64.0/18\",\n    \"PG\": \"124.240.192.0/19\",\n    \"PH\": \"49.144.0.0/13\",\n    \"PK\": \"39.32.0.0/11\",\n    \"PL\": \"83.0.0.0/11\",\n    \"PM\": \"70.36.0.0/20\",\n    \"PR\": \"66.50.0.0/16\",\n    \"PS\": \"188.161.0.0/16\",\n    \"PT\": \"85.240.0.0/13\",\n    \"PW\": \"202.124.224.0/20\",\n    \"PY\": \"181.120.0.0/14\",\n    \"QA\": \"37.210.0.0/15\",\n    \"RE\": \"102.35.0.0/16\",\n    \"RO\": \"79.112.0.0/13\",\n    \"RS\": \"93.86.0.0/15\",\n    \"RU\": \"5.136.0.0/13\",\n    \"RW\": \"41.186.0.0/16\",\n    \"SA\": \"188.48.0.0/13\",\n    \"SB\": \"202.1.160.0/19\",\n    \"SC\": \"154.192.0.0/11\",\n    \"SD\": \"102.120.0.0/13\",\n    \"SE\": \"78.64.0.0/12\",\n    \"SG\": \"8.128.0.0/10\",\n    \"SI\": \"188.196.0.0/14\",\n    \"SK\": \"78.98.0.0/15\",\n    \"SL\": \"102.143.0.0/17\",\n    \"SM\": \"89.186.32.0/19\",\n    \"SN\": \"41.82.0.0/15\",\n    \"SO\": \"154.115.192.0/18\",\n    \"SR\": \"186.179.128.0/17\",\n    \"SS\": \"105.235.208.0/21\",\n    \"ST\": \"197.159.160.0/19\",\n    \"SV\": \"168.243.0.0/16\",\n    \"SX\": \"190.102.0.0/20\",\n    \"SY\": \"5.0.0.0/16\",\n    \"SZ\": \"41.84.224.0/19\",\n    \"TC\": \"65.255.48.0/20\",\n    \"TD\": \"154.68.128.0/19\",\n    \"TG\": \"196.168.0.0/14\",\n    \"TH\": \"171.96.0.0/13\",\n    \"TJ\": \"85.9.128.0/18\",\n    \"TK\": \"27.96.24.0/21\",\n    \"TL\": \"180.189.160.0/20\",\n    \"TM\": \"95.85.96.0/19\",\n    \"TN\": \"197.0.0.0/11\",\n    \"TO\": \"175.176.144.0/21\",\n    \"TR\": \"78.160.0.0/11\",\n    \"TT\": \"186.44.0.0/15\",\n    \"TV\": \"202.2.96.0/19\",\n    \"TW\": \"120.96.0.0/11\",\n    \"TZ\": \"156.156.0.0/14\",\n    \"UA\": \"37.52.0.0/14\",\n    \"UG\": \"102.80.0.0/13\",\n    \"US\": \"6.0.0.0/8\",\n    \"UY\": \"167.56.0.0/13\",\n    \"UZ\": \"84.54.64.0/18\",\n    \"VA\": \"212.77.0.0/19\",\n    \"VC\": \"207.191.240.0/21\",\n    \"VE\": \"186.88.0.0/13\",\n    \"VG\": \"66.81.192.0/20\",\n    \"VI\": \"146.226.0.0/16\",\n    \"VN\": \"14.160.0.0/11\",\n    \"VU\": \"202.80.32.0/20\",\n    \"WF\": \"117.20.32.0/21\",\n    \"WS\": \"202.4.32.0/19\",\n    \"YE\": \"134.35.0.0/16\",\n    \"YT\": \"41.242.116.0/22\",\n    \"ZA\": \"41.0.0.0/11\",\n    \"ZM\": \"102.144.0.0/13\",\n    \"ZW\": \"102.177.192.0/18\",\n}\n\n\ndef random_ipv4(blocks):\n    if isinstance(blocks, str):\n        blocks = blocks.split(\",\")\n\n    cidr = []\n    for block in blocks:\n        if len(block) == 2:\n            block = COUNTRY_IP_MAP.get(block.upper())\n            if not block:\n                continue\n        cidr.append(block)\n    if not cidr:\n        return None\n\n    block = random.choice(cidr)\n    addr, _, preflen = block.partition(\"/\")\n    if not preflen:\n        return addr\n\n    addr_min = struct.unpack(\"!L\", socket.inet_aton(addr))[0]\n    addr_max = addr_min | (0xffffffff >> int(preflen))\n    return str(socket.inet_ntoa(struct.pack(\n        \"!L\", random.randint(addr_min, addr_max))))\n"
  },
  {
    "path": "gallery_dl/extractor/utils/joyreactor_graphql.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2026 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\nIdPostPageQuery = \"\"\"\\\nquery IdPostPageQuery(\n  $id: ID!\n  $isAuthorised: Boolean!\n) {\n  node(id: $id) {\n    __typename\n    ... on Post {\n      id\n      user {\n        id\n      }\n      text\n      nsfw\n      unsafe\n      seoAttributes {\n        title\n        description\n        ocr\n        similarPosts\n      }\n      ...Post_post_2lIf9C\n      comments {\n        ...CommentTree_comments_48QSE5\n        id\n      }\n      tags {\n        ...TagList_blogs\n        id\n      }\n    }\n    id\n  }\n}\n\nfragment AttributeEmbed_attribute on AttributeEmbed {\n  __isAttributeEmbed: __typename\n  type\n  value\n  image {\n    comment\n    id\n  }\n}\n\nfragment AttributePicture_attribute on AttributePicture {\n  __isAttributePicture: __typename\n  id\n  type\n  insertId\n  image {\n    width\n    height\n    type\n    comment\n    hasVideo\n    id\n  }\n}\n\nfragment AttributePicture_post on Post {\n  nsfw\n  tags {\n    name\n    seoName\n    synonyms\n    id\n  }\n}\n\nfragment Attribute_attribute on Attribute {\n  __isAttribute: __typename\n  type\n  ...AttributePicture_attribute\n  ...AttributeEmbed_attribute\n}\n\nfragment Attribute_post on Post {\n  ...AttributePicture_post\n}\n\nfragment CommentTree_comments_2EWd0p on Comment {\n  id\n  locale\n  level\n  parent {\n    __typename\n    id\n  }\n  createdAt\n  user {\n    id\n  }\n  ...Comment_comment_2EWd0p\n}\n\nfragment CommentTree_comments_48QSE5 on Comment {\n  id\n  locale\n  level\n  parent {\n    __typename\n    id\n  }\n  createdAt\n  user {\n    id\n  }\n  ...Comment_comment_48QSE5\n}\n\nfragment CommentTree_post on Post {\n  id\n  ...Comment_post\n}\n\nfragment CommentVote_comment on Comment {\n  id\n  rating\n  voted\n}\n\nfragment Comment_comment_2EWd0p on Comment {\n  id\n  user {\n    id\n    username\n  }\n  createdAt\n  rating\n  level\n  contentVersion\n  banned\n  contentEditedAt\n  locale\n  ...EditableCommentContent_content\n  ...Content_content\n}\n\nfragment Comment_comment_48QSE5 on Comment {\n  id\n  user {\n    id\n    username\n  }\n  createdAt\n  rating\n  level\n  contentVersion\n  banned\n  contentEditedAt\n  locale\n  ...EditableCommentContent_content\n  ...Content_content\n  ...CommentVote_comment @include(if: $isAuthorised)\n}\n\nfragment Comment_post on Post {\n  id\n  ...Content_post\n}\n\nfragment Content_content on Content {\n  __isContent: __typename\n  text\n  attributes {\n    __typename\n    id\n    insertId\n    ...Attribute_attribute\n  }\n}\n\nfragment Content_post on Post {\n  ...Attribute_post\n}\n\nfragment EditableCommentContent_content on Content {\n  __isContent: __typename\n  text\n  attributes {\n    __typename\n    id\n    insertId\n    type\n    image {\n      id\n      hasVideo\n      type\n      width\n      height\n    }\n    ... on CommentAttributeEmbed {\n      value\n    }\n  }\n}\n\nfragment Poll_post_2lIf9C on Post {\n  id\n  poll {\n    question\n    answers {\n      id\n      answer\n      count\n    }\n    voted @include(if: $isAuthorised)\n  }\n}\n\nfragment PostComments_post_2lIf9C on Post {\n  id\n  viewedCommentsAt @include(if: $isAuthorised)\n  viewedCommentsCount @include(if: $isAuthorised)\n  commentsCount\n  user {\n    id\n  }\n  unsafe\n  ...CommentTree_post\n}\n\nfragment PostFooter_post_2lIf9C on Post {\n  id\n  commentsCount\n  rating\n  ratingGeneral\n  createdAt\n  viewedCommentsCount @include(if: $isAuthorised)\n  favorite @include(if: $isAuthorised)\n  ...PostVote_post @include(if: $isAuthorised)\n}\n\nfragment PostTags_post on Post {\n  id\n  user {\n    id\n  }\n  postTags {\n    deletable\n    tag {\n      id\n      name\n      seoName\n      showAsCategory\n      mainTag {\n        id\n        category {\n          id\n        }\n        userTag {\n          state\n        }\n      }\n    }\n  }\n}\n\nfragment PostVote_post on Post {\n  id\n  rating\n  ratingGeneral\n  minusThreshold\n  vote {\n    createdAt\n    power\n  }\n}\n\nfragment Post_post_2lIf9C on Post {\n  id\n  user {\n    id\n    username\n  }\n  bestComments {\n    ...CommentTree_comments_2EWd0p\n    id\n  }\n  tags {\n    mainTag {\n      id\n      name\n      category {\n        id\n      }\n      userTag {\n        state\n      }\n    }\n    id\n  }\n  nsfw\n  unsafe\n  createdAt\n  editableUntil\n  text\n  favorite @include(if: $isAuthorised)\n  ...PostVote_post @include(if: $isAuthorised)\n  banned\n  poll {\n    question\n  }\n  commentsCount\n  ...CommentTree_post\n  ...PostTags_post\n  ...Content_post\n  ...Content_content\n  ...PostFooter_post_2lIf9C\n  ...PostComments_post_2lIf9C\n  ...Poll_post_2lIf9C\n}\n\nfragment TagList_blogs on Tag {\n  id\n  name\n  seoName\n  count\n  subscribers\n  showAsCategory\n  mainTag {\n    nsfw\n    unsafe\n    category {\n      name\n      id\n    }\n    id\n  }\n}\n\"\"\"\n\nTagPageQuery = \"\"\"\\\nquery TagPageQuery(\n  $name: String\n  $lineType: PostLineType!\n  $favoriteType: PostLineType\n  $page: Int\n  $isAuthorised: Boolean!\n  $isHomepage: Boolean!\n) {\n  tag(name: $name) {\n    id\n    name\n    mainTag {\n      id\n      hierarchy {\n        mainTag {\n          name\n          id\n        }\n        id\n      }\n      nsfw\n      unsafe\n      synonyms\n      count\n      seoName\n      category {\n        name\n        unsafe\n        nsfw\n        id\n      }\n    }\n    count\n    postPager(type: $lineType, favoriteType: $favoriteType) {\n      count\n      id\n    }\n    ...TagHeader_blog @skip(if: $isHomepage)\n    ...TagSidebar_blog @skip(if: $isHomepage)\n    ...TagPostPager_blog_qTg8U\n  }\n}\n\nfragment AttributeEmbed_attribute on AttributeEmbed {\n  __isAttributeEmbed: __typename\n  type\n  value\n  image {\n    comment\n    id\n  }\n}\n\nfragment AttributePicture_attribute on AttributePicture {\n  __isAttributePicture: __typename\n  id\n  type\n  insertId\n  image {\n    width\n    height\n    type\n    comment\n    hasVideo\n    id\n  }\n}\n\nfragment AttributePicture_post on Post {\n  nsfw\n  tags {\n    name\n    seoName\n    synonyms\n    id\n  }\n}\n\nfragment Attribute_attribute on Attribute {\n  __isAttribute: __typename\n  type\n  ...AttributePicture_attribute\n  ...AttributeEmbed_attribute\n}\n\nfragment Attribute_post on Post {\n  ...AttributePicture_post\n}\n\nfragment BlogDescription_blog on Tag {\n  id\n  articlePost {\n    ...Content_post\n    ...Content_content\n    id\n  }\n}\n\nfragment CommentTree_comments_2EWd0p on Comment {\n  id\n  locale\n  level\n  parent {\n    __typename\n    id\n  }\n  createdAt\n  user {\n    id\n  }\n  ...Comment_comment_2EWd0p\n}\n\nfragment CommentTree_post on Post {\n  id\n  ...Comment_post\n}\n\nfragment Comment_comment_2EWd0p on Comment {\n  id\n  user {\n    id\n    username\n  }\n  createdAt\n  rating\n  level\n  contentVersion\n  banned\n  contentEditedAt\n  locale\n  ...EditableCommentContent_content\n  ...Content_content\n}\n\nfragment Comment_post on Post {\n  id\n  ...Content_post\n}\n\nfragment Content_content on Content {\n  __isContent: __typename\n  text\n  attributes {\n    __typename\n    id\n    insertId\n    ...Attribute_attribute\n  }\n}\n\nfragment Content_post on Post {\n  ...Attribute_post\n}\n\nfragment EditableCommentContent_content on Content {\n  __isContent: __typename\n  text\n  attributes {\n    __typename\n    id\n    insertId\n    type\n    image {\n      id\n      hasVideo\n      type\n      width\n      height\n    }\n    ... on CommentAttributeEmbed {\n      value\n    }\n  }\n}\n\nfragment Poll_post_2lIf9C on Post {\n  id\n  poll {\n    question\n    answers {\n      id\n      answer\n      count\n    }\n    voted @include(if: $isAuthorised)\n  }\n}\n\nfragment PostComments_post_2lIf9C on Post {\n  id\n  viewedCommentsAt @include(if: $isAuthorised)\n  viewedCommentsCount @include(if: $isAuthorised)\n  commentsCount\n  user {\n    id\n  }\n  unsafe\n  ...CommentTree_post\n}\n\nfragment PostFooter_post_2lIf9C on Post {\n  id\n  commentsCount\n  rating\n  ratingGeneral\n  createdAt\n  viewedCommentsCount @include(if: $isAuthorised)\n  favorite @include(if: $isAuthorised)\n  ...PostVote_post @include(if: $isAuthorised)\n}\n\nfragment PostPager_posts_3OSKdM on PostPager {\n  posts(page: $page) {\n    id\n    nsfw\n    unsafe\n    tags {\n      mainTag {\n        id\n        nsfw\n        unsafe\n        category {\n          id\n        }\n        userTag {\n          state\n        }\n      }\n      id\n    }\n    user {\n      username\n      id\n    }\n    commentsCount\n    ...Post_post_2lIf9C\n  }\n  count\n  id\n}\n\nfragment PostTags_post on Post {\n  id\n  user {\n    id\n  }\n  postTags {\n    deletable\n    tag {\n      id\n      name\n      seoName\n      showAsCategory\n      mainTag {\n        id\n        category {\n          id\n        }\n        userTag {\n          state\n        }\n      }\n    }\n  }\n}\n\nfragment PostVote_post on Post {\n  id\n  rating\n  ratingGeneral\n  minusThreshold\n  vote {\n    createdAt\n    power\n  }\n}\n\nfragment Post_post_2lIf9C on Post {\n  id\n  user {\n    id\n    username\n  }\n  bestComments {\n    ...CommentTree_comments_2EWd0p\n    id\n  }\n  tags {\n    mainTag {\n      id\n      name\n      category {\n        id\n      }\n      userTag {\n        state\n      }\n    }\n    id\n  }\n  nsfw\n  unsafe\n  createdAt\n  editableUntil\n  text\n  favorite @include(if: $isAuthorised)\n  ...PostVote_post @include(if: $isAuthorised)\n  banned\n  poll {\n    question\n  }\n  commentsCount\n  ...CommentTree_post\n  ...PostTags_post\n  ...Content_post\n  ...Content_content\n  ...PostFooter_post_2lIf9C\n  ...PostComments_post_2lIf9C\n  ...Poll_post_2lIf9C\n}\n\nfragment TagHeader_blog on Tag {\n  id\n  seoName\n  name\n  mainTag {\n    id\n    unsafe\n    nsfw\n    articlePost {\n      id\n    }\n    ...BlogDescription_blog\n    subTagsMenu {\n      ...TagList_blogs\n      id\n    }\n    subTags {\n      ...TagList_blogs\n      id\n    }\n    ...TagSuperBlogs_blog\n    hierarchy {\n      mainTag {\n        id\n        name\n        showAsCategory\n      }\n      id\n    }\n    synonyms\n    subscribers\n    count\n    image {\n      id\n    }\n    userTag {\n      state\n    }\n    articleImage {\n      id\n      type\n    }\n    category {\n      id\n      name\n      category {\n        id\n      }\n      showAsCategory\n      nsfw\n      unsafe\n    }\n    moderators {\n      ...UserList_users\n      id\n    }\n  }\n  ...TagSidebar_blog\n}\n\nfragment TagList_blogs on Tag {\n  id\n  name\n  seoName\n  count\n  subscribers\n  showAsCategory\n  mainTag {\n    nsfw\n    unsafe\n    category {\n      name\n      id\n    }\n    id\n  }\n}\n\nfragment TagPostPager_blog_qTg8U on Tag {\n  unsafe\n  postPager(type: $lineType, favoriteType: $favoriteType) {\n    ...PostPager_posts_3OSKdM\n    count\n    id\n  }\n}\n\nfragment TagSidebar_blog on Tag {\n  name\n  mainTag {\n    subTagsMenu {\n      id\n    }\n    subTags {\n      ...TagList_blogs\n      id\n    }\n    ...TagSuperBlogs_blog\n    nsfw\n    unsafe\n    category {\n      id\n      name\n      category {\n        id\n      }\n      nsfw\n      unsafe\n    }\n    moderators {\n      ...UserList_users\n      id\n    }\n    id\n  }\n}\n\nfragment TagSuperBlogs_blog on Tag {\n  subTagsMenu {\n    id\n    name\n    nsfw\n    unsafe\n    showAsCategory\n  }\n}\n\nfragment UserList_users on User {\n  id\n  username\n}\n\"\"\"\n\nUserProfilePageQuery = \"\"\"\\\nquery UserProfilePageQuery(\n  $username: String!\n  $page: Int\n  $isAuthorised: Boolean!\n  $selfOrAdmin: Boolean!\n) {\n  user(username: $username) {\n    id\n    postPager {\n      ...PostPager_posts_3OSKdM\n      count\n      id\n    }\n    ...UserSidebar_user_2lIf9C\n    active @include(if: $selfOrAdmin)\n    postsBannedUntil @include(if: $selfOrAdmin)\n    commentsBannedUntil @include(if: $selfOrAdmin)\n    tagBans @include(if: $selfOrAdmin) {\n      tag {\n        id\n        name\n      }\n      bannedUntil\n    }\n    donatedLeft @include(if: $selfOrAdmin)\n    goldStatusExpire @include(if: $selfOrAdmin)\n    platinumStatusExpire @include(if: $selfOrAdmin)\n    settings @include(if: $selfOrAdmin) {\n      goldStatusExtend\n    }\n    userState @include(if: $isAuthorised)\n  }\n  me {\n    goldStatus\n    platinumStatus\n    id\n  }\n}\n\nfragment AttributeEmbed_attribute on AttributeEmbed {\n  __isAttributeEmbed: __typename\n  type\n  value\n  image {\n    comment\n    id\n  }\n}\n\nfragment AttributePicture_attribute on AttributePicture {\n  __isAttributePicture: __typename\n  id\n  type\n  insertId\n  image {\n    width\n    height\n    type\n    comment\n    hasVideo\n    id\n  }\n}\n\nfragment AttributePicture_post on Post {\n  nsfw\n  tags {\n    name\n    seoName\n    synonyms\n    id\n  }\n}\n\nfragment Attribute_attribute on Attribute {\n  __isAttribute: __typename\n  type\n  ...AttributePicture_attribute\n  ...AttributeEmbed_attribute\n}\n\nfragment Attribute_post on Post {\n  ...AttributePicture_post\n}\n\nfragment CommentTree_comments_2EWd0p on Comment {\n  id\n  locale\n  level\n  parent {\n    __typename\n    id\n  }\n  createdAt\n  user {\n    id\n  }\n  ...Comment_comment_2EWd0p\n}\n\nfragment CommentTree_post on Post {\n  id\n  ...Comment_post\n}\n\nfragment Comment_comment_2EWd0p on Comment {\n  id\n  user {\n    id\n    username\n  }\n  createdAt\n  rating\n  level\n  contentVersion\n  banned\n  contentEditedAt\n  locale\n  ...EditableCommentContent_content\n  ...Content_content\n}\n\nfragment Comment_post on Post {\n  id\n  ...Content_post\n}\n\nfragment Content_content on Content {\n  __isContent: __typename\n  text\n  attributes {\n    __typename\n    id\n    insertId\n    ...Attribute_attribute\n  }\n}\n\nfragment Content_post on Post {\n  ...Attribute_post\n}\n\nfragment EditableCommentContent_content on Content {\n  __isContent: __typename\n  text\n  attributes {\n    __typename\n    id\n    insertId\n    type\n    image {\n      id\n      hasVideo\n      type\n      width\n      height\n    }\n    ... on CommentAttributeEmbed {\n      value\n    }\n  }\n}\n\nfragment Poll_post_2lIf9C on Post {\n  id\n  poll {\n    question\n    answers {\n      id\n      answer\n      count\n    }\n    voted @include(if: $isAuthorised)\n  }\n}\n\nfragment PostComments_post_2lIf9C on Post {\n  id\n  viewedCommentsAt @include(if: $isAuthorised)\n  viewedCommentsCount @include(if: $isAuthorised)\n  commentsCount\n  user {\n    id\n  }\n  unsafe\n  ...CommentTree_post\n}\n\nfragment PostFooter_post_2lIf9C on Post {\n  id\n  commentsCount\n  rating\n  ratingGeneral\n  createdAt\n  viewedCommentsCount @include(if: $isAuthorised)\n  favorite @include(if: $isAuthorised)\n  ...PostVote_post @include(if: $isAuthorised)\n}\n\nfragment PostPager_posts_3OSKdM on PostPager {\n  posts(page: $page) {\n    id\n    nsfw\n    unsafe\n    tags {\n      mainTag {\n        id\n        nsfw\n        unsafe\n        category {\n          id\n        }\n        userTag {\n          state\n        }\n      }\n      id\n    }\n    user {\n      username\n      id\n    }\n    commentsCount\n    ...Post_post_2lIf9C\n  }\n  count\n  id\n}\n\nfragment PostTags_post on Post {\n  id\n  user {\n    id\n  }\n  postTags {\n    deletable\n    tag {\n      id\n      name\n      seoName\n      showAsCategory\n      mainTag {\n        id\n        category {\n          id\n        }\n        userTag {\n          state\n        }\n      }\n    }\n  }\n}\n\nfragment PostVote_post on Post {\n  id\n  rating\n  ratingGeneral\n  minusThreshold\n  vote {\n    createdAt\n    power\n  }\n}\n\nfragment Post_post_2lIf9C on Post {\n  id\n  user {\n    id\n    username\n  }\n  bestComments {\n    ...CommentTree_comments_2EWd0p\n    id\n  }\n  tags {\n    mainTag {\n      id\n      name\n      category {\n        id\n      }\n      userTag {\n        state\n      }\n    }\n    id\n  }\n  nsfw\n  unsafe\n  createdAt\n  editableUntil\n  text\n  favorite @include(if: $isAuthorised)\n  ...PostVote_post @include(if: $isAuthorised)\n  banned\n  poll {\n    question\n  }\n  commentsCount\n  ...CommentTree_post\n  ...PostTags_post\n  ...Content_post\n  ...Content_content\n  ...PostFooter_post_2lIf9C\n  ...PostComments_post_2lIf9C\n  ...Poll_post_2lIf9C\n}\n\nfragment TagList_blogs on Tag {\n  id\n  name\n  seoName\n  count\n  subscribers\n  showAsCategory\n  mainTag {\n    nsfw\n    unsafe\n    category {\n      name\n      id\n    }\n    id\n  }\n}\n\nfragment UserAwards_awardUser on AwardUser {\n  award {\n    id\n    name\n    description\n    picUrl\n    nextAward {\n      id\n    }\n  }\n  hidden\n}\n\nfragment UserList_users on User {\n  id\n  username\n}\n\nfragment UserSidebar_user_2lIf9C on User {\n  id\n  username\n  rating\n  ratingWeek\n  hideSubscriptionsRatings\n  commentNum\n  postNum\n  goodPostNum\n  bestPostNum\n  about\n  createdAt\n  sequentialVisits\n  totalVisits\n  lastVisit\n  canSendPrivateMessage @include(if: $isAuthorised)\n  awards {\n    ...UserAwards_awardUser\n  }\n  subscribedTags {\n    ...TagList_blogs\n    id\n  }\n  moderatedTags {\n    ...TagList_blogs\n    id\n  }\n  blockedTags {\n    ...TagList_blogs\n    id\n  }\n  friends {\n    ...UserList_users\n    id\n  }\n  blockedUsers {\n    ...UserList_users\n    id\n  }\n  topTagRatings {\n    ...UserTagRatings_favoriteTag\n  }\n}\n\nfragment UserTagRatings_favoriteTag on UserTag {\n  tag {\n    id\n    name\n    nsfw\n    unsafe\n    showAsCategory\n  }\n  rating\n}\n\"\"\"\n\nSearchPageQuery = \"\"\"\\\nquery SearchPageQuery(\n  $query: String!\n  $showNsfw: Boolean\n  $showUnsafe: Boolean\n  $showOnlyNsfw: Boolean\n  $tagNames: [String!]\n  $username: String\n  $page: Int\n  $isAuthorised: Boolean!\n  $minRating: Int\n  $maxRating: Int\n  $sortByRating: Boolean\n  $sortByDate: Boolean\n) {\n  search(query: $query, showNsfw: $showNsfw, showUnsafe: $showUnsafe, \\\n  showOnlyNsfw: $showOnlyNsfw, username: $username, tagNames: $tagNames, \\\n  sortByDate: $sortByDate, sortByRating: $sortByRating, minRating: \\\n  $minRating, maxRating: $maxRating) {\n    tags {\n      ...TagList_blogs\n      mainTag {\n        id\n        unsafe\n      }\n      id\n    }\n    postPager {\n      ...PostPager_posts_3OSKdM\n      count\n      id\n    }\n    similarQueries @skip(if: $isAuthorised)\n  }\n}\n\nfragment AttributeEmbed_attribute on AttributeEmbed {\n  __isAttributeEmbed: __typename\n  type\n  value\n  image {\n    comment\n    id\n  }\n}\n\nfragment AttributePicture_attribute on AttributePicture {\n  __isAttributePicture: __typename\n  id\n  type\n  insertId\n  image {\n    width\n    height\n    type\n    comment\n    hasVideo\n    id\n  }\n}\n\nfragment AttributePicture_post on Post {\n  nsfw\n  tags {\n    name\n    seoName\n    synonyms\n    id\n  }\n}\n\nfragment Attribute_attribute on Attribute {\n  __isAttribute: __typename\n  type\n  ...AttributePicture_attribute\n  ...AttributeEmbed_attribute\n}\n\nfragment Attribute_post on Post {\n  ...AttributePicture_post\n}\n\nfragment CommentTree_comments_2EWd0p on Comment {\n  id\n  locale\n  level\n  parent {\n    __typename\n    id\n  }\n  createdAt\n  user {\n    id\n  }\n  ...Comment_comment_2EWd0p\n}\n\nfragment CommentTree_post on Post {\n  id\n  ...Comment_post\n}\n\nfragment Comment_comment_2EWd0p on Comment {\n  id\n  user {\n    id\n    username\n  }\n  createdAt\n  rating\n  level\n  contentVersion\n  banned\n  contentEditedAt\n  locale\n  ...EditableCommentContent_content\n  ...Content_content\n}\n\nfragment Comment_post on Post {\n  id\n  ...Content_post\n}\n\nfragment Content_content on Content {\n  __isContent: __typename\n  text\n  attributes {\n    __typename\n    id\n    insertId\n    ...Attribute_attribute\n  }\n}\n\nfragment Content_post on Post {\n  ...Attribute_post\n}\n\nfragment EditableCommentContent_content on Content {\n  __isContent: __typename\n  text\n  attributes {\n    __typename\n    id\n    insertId\n    type\n    image {\n      id\n      hasVideo\n      type\n      width\n      height\n    }\n    ... on CommentAttributeEmbed {\n      value\n    }\n  }\n}\n\nfragment Poll_post_2lIf9C on Post {\n  id\n  poll {\n    question\n    answers {\n      id\n      answer\n      count\n    }\n    voted @include(if: $isAuthorised)\n  }\n}\n\nfragment PostComments_post_2lIf9C on Post {\n  id\n  viewedCommentsAt @include(if: $isAuthorised)\n  viewedCommentsCount @include(if: $isAuthorised)\n  commentsCount\n  user {\n    id\n  }\n  unsafe\n  ...CommentTree_post\n}\n\nfragment PostFooter_post_2lIf9C on Post {\n  id\n  commentsCount\n  rating\n  ratingGeneral\n  createdAt\n  viewedCommentsCount @include(if: $isAuthorised)\n  favorite @include(if: $isAuthorised)\n  ...PostVote_post @include(if: $isAuthorised)\n}\n\nfragment PostPager_posts_3OSKdM on PostPager {\n  posts(page: $page) {\n    id\n    nsfw\n    unsafe\n    tags {\n      mainTag {\n        id\n        nsfw\n        unsafe\n        category {\n          id\n        }\n        userTag {\n          state\n        }\n      }\n      id\n    }\n    user {\n      username\n      id\n    }\n    commentsCount\n    ...Post_post_2lIf9C\n  }\n  count\n  id\n}\n\nfragment PostTags_post on Post {\n  id\n  user {\n    id\n  }\n  postTags {\n    deletable\n    tag {\n      id\n      name\n      seoName\n      showAsCategory\n      mainTag {\n        id\n        category {\n          id\n        }\n        userTag {\n          state\n        }\n      }\n    }\n  }\n}\n\nfragment PostVote_post on Post {\n  id\n  rating\n  ratingGeneral\n  minusThreshold\n  vote {\n    createdAt\n    power\n  }\n}\n\nfragment Post_post_2lIf9C on Post {\n  id\n  user {\n    id\n    username\n  }\n  bestComments {\n    ...CommentTree_comments_2EWd0p\n    id\n  }\n  tags {\n    mainTag {\n      id\n      name\n      category {\n        id\n      }\n      userTag {\n        state\n      }\n    }\n    id\n  }\n  nsfw\n  unsafe\n  createdAt\n  editableUntil\n  text\n  favorite @include(if: $isAuthorised)\n  ...PostVote_post @include(if: $isAuthorised)\n  banned\n  poll {\n    question\n  }\n  commentsCount\n  ...CommentTree_post\n  ...PostTags_post\n  ...Content_post\n  ...Content_content\n  ...PostFooter_post_2lIf9C\n  ...PostComments_post_2lIf9C\n  ...Poll_post_2lIf9C\n}\n\nfragment TagList_blogs on Tag {\n  id\n  name\n  seoName\n  count\n  subscribers\n  showAsCategory\n  mainTag {\n    nsfw\n    unsafe\n    category {\n      name\n      id\n    }\n    id\n  }\n}\n\"\"\"\n"
  },
  {
    "path": "gallery_dl/extractor/utils/jsurl.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2026 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\ndef parse(data):\n    \"\"\"Parse JSURL data\n\n    Nested lists and dicts are handled in a special way to deal\n    with the way Tsumino expects its parameters -> expand(...)\n\n    Example: ~(name~'John*20Doe~age~42~children~(~'Mary~'Bill))\n    Ref: https://github.com/Sage/jsurl\n    \"\"\"\n    i = 0\n    imax = len(data)\n\n    def eat(expected):\n        nonlocal i\n\n        if data[i] != expected:\n            raise ValueError(\n                f\"bad JSURL syntax: expected '{expected}', got {data[i]}\")\n        i += 1\n\n    def decode():\n        nonlocal i\n\n        beg = i\n        result = \"\"\n\n        while i < imax:\n            ch = data[i]\n\n            if ch not in \"~)*!\":\n                i += 1\n\n            elif ch == \"*\":\n                if beg < i:\n                    result += data[beg:i]\n                if data[i + 1] == \"*\":\n                    result += chr(int(data[i+2:i+6], 16))\n                    i += 6\n                else:\n                    result += chr(int(data[i+1:i+3], 16))\n                    i += 3\n                beg = i\n\n            elif ch == \"!\":\n                if beg < i:\n                    result += data[beg:i]\n                result += \"$\"\n                i += 1\n                beg = i\n\n            else:\n                break\n\n        return result + data[beg:i]\n\n    def parse_one():\n        nonlocal i\n\n        eat('~')\n        result = \"\"\n        ch = data[i]\n\n        if ch == \"(\":\n            i += 1\n\n            if data[i] == \"~\":\n                result = []\n                if data[i+1] == \")\":\n                    i += 1\n                else:\n                    result.append(parse_one())\n                    while data[i] == \"~\":\n                        result.append(parse_one())\n\n            else:\n                result = {}\n\n                if data[i] != \")\":\n                    while True:\n                        key = decode()\n                        value = parse_one()\n                        for ekey, evalue in expand(key, value):\n                            result[ekey] = evalue\n                        if data[i] != \"~\":\n                            break\n                        i += 1\n            eat(\")\")\n\n        elif ch == \"'\":\n            i += 1\n            result = decode()\n\n        else:\n            beg = i\n            i += 1\n\n            while i < imax and data[i] not in \"~)\":\n                i += 1\n\n            sub = data[beg:i]\n            if ch in \"0123456789-\":\n                fval = float(sub)\n                ival = int(fval)\n                result = ival if ival == fval else fval\n            else:\n                if sub not in (\"true\", \"false\", \"null\"):\n                    raise ValueError(\"bad value keyword: \" + sub)\n                result = sub\n\n        return result\n\n    def expand(key, value):\n        if isinstance(value, list):\n            for index, cvalue in enumerate(value):\n                ckey = f\"{key}[{index}]\"\n                yield from expand(ckey, cvalue)\n        elif isinstance(value, dict):\n            for ckey, cvalue in value.items():\n                ckey = f\"{key}[{ckey}]\"\n                yield from expand(ckey, cvalue)\n        else:\n            yield key, value\n\n    return parse_one()\n"
  },
  {
    "path": "gallery_dl/extractor/utils/luscious_graphql.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2026 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nAlbumGet = \"\"\"\nquery AlbumGet($id: ID!) {\n    album {\n        get(id: $id) {\n            ... on Album {\n                ...AlbumStandard\n            }\n            ... on MutationError {\n                errors {\n                    code\n                    message\n                }\n            }\n        }\n    }\n}\n\nfragment AlbumStandard on Album {\n    __typename\n    id\n    title\n    labels\n    description\n    created\n    modified\n    like_status\n    number_of_favorites\n    rating\n    status\n    marked_for_deletion\n    marked_for_processing\n    number_of_pictures\n    number_of_animated_pictures\n    slug\n    is_manga\n    url\n    download_url\n    permissions\n    cover {\n        width\n        height\n        size\n        url\n    }\n    created_by {\n        id\n        name\n        display_name\n        user_title\n        avatar {\n            url\n            size\n        }\n        url\n    }\n    content {\n        id\n        title\n        url\n    }\n    language {\n        id\n        title\n        url\n    }\n    tags {\n        id\n        category\n        text\n        url\n        count\n    }\n    genres {\n        id\n        title\n        slug\n        url\n    }\n    audiences {\n        id\n        title\n        url\n        url\n    }\n    last_viewed_picture {\n        id\n        position\n        url\n    }\n}\n\"\"\"\n\nAlbumListOwnPictures = \"\"\"\nquery AlbumListOwnPictures($input: PictureListInput!) {\n    picture {\n        list(input: $input) {\n            info {\n                ...FacetCollectionInfo\n            }\n            items {\n                ...PictureStandardWithoutAlbum\n            }\n        }\n    }\n}\n\nfragment FacetCollectionInfo on FacetCollectionInfo {\n    page\n    has_next_page\n    has_previous_page\n    total_items\n    total_pages\n    items_per_page\n    url_complete\n    url_filters_only\n}\n\nfragment PictureStandardWithoutAlbum on Picture {\n    __typename\n    id\n    title\n    created\n    like_status\n    number_of_comments\n    number_of_favorites\n    status\n    width\n    height\n    resolution\n    aspect_ratio\n    url_to_original\n    url_to_video\n    is_animated\n    position\n    tags {\n        id\n        category\n        text\n        url\n    }\n    permissions\n    url\n    thumbnails {\n        width\n        height\n        size\n        url\n    }\n}\n\"\"\"\n\nAlbumListWithPeek = \"\"\"\nquery AlbumListWithPeek($input: AlbumListInput!) {\n    album {\n        list(input: $input) {\n            info {\n                ...FacetCollectionInfo\n            }\n            items {\n                ...AlbumMinimal\n                peek_thumbnails {\n                    width\n                    height\n                    size\n                    url\n                }\n            }\n        }\n    }\n}\n\nfragment FacetCollectionInfo on FacetCollectionInfo {\n    page\n    has_next_page\n    has_previous_page\n    total_items\n    total_pages\n    items_per_page\n    url_complete\n    url_filters_only\n}\n\nfragment AlbumMinimal on Album {\n    __typename\n    id\n    title\n    labels\n    description\n    created\n    modified\n    number_of_favorites\n    number_of_pictures\n    slug\n    is_manga\n    url\n    download_url\n    cover {\n        width\n        height\n        size\n        url\n    }\n    content {\n        id\n        title\n        url\n    }\n    language {\n        id\n        title\n        url\n    }\n    tags {\n        id\n        category\n        text\n        url\n        count\n    }\n    genres {\n        id\n        title\n        slug\n        url\n    }\n    audiences {\n        id\n        title\n        url\n    }\n}\n\"\"\"\n"
  },
  {
    "path": "gallery_dl/extractor/utils/mangafire_vrf.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2026 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"VRF generation utils\n\nadapted from dazedcat19/FMD2\nhttps://github.com/dazedcat19/FMD2/blob/master/lua/modules/MangaFire.lua\n\"\"\"\n\nfrom ... import text\nimport binascii\n\n\ndef generate(input):\n    input = text.quote(input).encode()\n\n    for key_b64, seed_b64, prefix_b64, schedule in (\n        (key_l, seed_A, prefix_O, schedule_c),\n        (key_g, seed_V, prefix_v, schedule_y),\n        (key_B, seed_N, prefix_L, schedule_b),\n        (key_m, seed_P, prefix_p, schedule_j),\n        (key_F, seed_k, prefix_W, schedule_e),\n    ):\n        input = transform(\n            rc4(binascii.a2b_base64(key_b64), input),\n            binascii.a2b_base64(seed_b64),\n            binascii.a2b_base64(prefix_b64),\n            schedule,\n        )\n\n    return binascii.b2a_base64(bytes(input), newline=False).rstrip(\n        b\"=\").replace(b\"+\", b\"-\").replace(b\"/\", b\"_\")\n\n\ndef transform(input, seed, prefix, schedule):\n    prefix_len = len(prefix)\n\n    out = []\n    for idx, c in enumerate(input):\n        if idx < prefix_len:\n            out.append(prefix[idx] or 0)\n        out.append(schedule[idx % 10]((c ^ seed[idx % 32]) & 255) & 255)\n    return out\n\n\ndef rc4(key, input):\n    lkey = len(key)\n\n    j = 0\n    s = list(range(256))\n    for i in range(256):\n        j = (j + s[i] + key[i % lkey]) & 255\n        s[i], s[j] = s[j], s[i]\n\n    out = []\n    i = j = 0\n    for c in input:\n        i = (i + 1) & 255\n        j = (j + s[i]) & 255\n        s[i], s[j] = s[j], s[i]\n        k = s[(s[i] + s[j]) & 255]\n        out.append(c ^ k)\n    return out\n\n\ndef add8(n):\n    return lambda c: (c + n) & 255\n\n\ndef sub8(n):\n    return lambda c: (c - n + 256) & 255\n\n\ndef xor8(n):\n    return lambda c: (c ^ n) & 255\n\n\ndef rotl8(n):\n    return lambda c: ((c << n) | (c >> (8 - n))) & 255\n\n\ndef rotr8(n):\n    return lambda c: ((c >> n) | (c << (8 - n))) & 255\n\n\nschedule_c = (\n    sub8(223), rotr8(4), rotr8(4), add8(234), rotr8(7),\n    rotr8(2), rotr8(7), sub8(223), rotr8(7), rotr8(6),\n)\nschedule_y = (\n    add8(19), rotr8(7), add8(19), rotr8(6), add8(19),\n    rotr8(1), add8(19), rotr8(6), rotr8(7), rotr8(4),\n)\nschedule_b = (\n    sub8(223), rotr8(1), add8(19), sub8(223), rotl8(2),\n    sub8(223), add8(19), rotl8(1), rotl8(2), rotl8(1),\n)\nschedule_j = (\n    add8(19), rotl8(1), rotl8(1), rotr8(1), add8(234),\n    rotl8(1), sub8(223), rotl8(6), rotl8(4), rotl8(1),\n)\nschedule_e = (\n    rotr8(1), rotl8(1), rotl8(6), rotr8(1), rotl8(2),\n    rotr8(4), rotl8(1), rotl8(1), sub8(223), rotl8(2),\n)\n\n\nkey_l = \"FgxyJUQDPUGSzwbAq/ToWn4/e8jYzvabE+dLMb1XU1o=\"\nkey_g = \"CQx3CLwswJAnM1VxOqX+y+f3eUns03ulxv8Z+0gUyik=\"\nkey_B = \"fAS+otFLkKsKAJzu3yU+rGOlbbFVq+u+LaS6+s1eCJs=\"\nkey_m = \"Oy45fQVK9kq9019+VysXVlz1F9S1YwYKgXyzGlZrijo=\"\nkey_F = \"aoDIdXezm2l3HrcnQdkPJTDT8+W6mcl2/02ewBHfPzg=\"\n\nseed_A = \"yH6MXnMEcDVWO/9a6P9W92BAh1eRLVFxFlWTHUqQ474=\"\nseed_V = \"RK7y4dZ0azs9Uqz+bbFB46Bx2K9EHg74ndxknY9uknA=\"\nseed_N = \"rqr9HeTQOg8TlFiIGZpJaxcvAaKHwMwrkqojJCpcvoc=\"\nseed_P = \"/4GPpmZXYpn5RpkP7FC/dt8SXz7W30nUZTe8wb+3xmU=\"\nseed_k = \"wsSGSBXKWA9q1oDJpjtJddVxH+evCfL5SO9HZnUDFU8=\"\n\nprefix_O = \"l9PavRg=\"\nprefix_v = \"Ml2v7ag1Jg==\"\nprefix_L = \"i/Va0UxrbMo=\"\nprefix_p = \"WFjKAHGEkQM=\"\nprefix_W = \"5Rr27rWd\"\n"
  },
  {
    "path": "gallery_dl/extractor/utils/mangapark_graphql.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2026 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\nGet_comicChapterList = \"\"\"\nquery Get_comicChapterList($comicId: ID!) {\n    get_comicChapterList(comicId: $comicId) {\n        data {\n            id\n            dname\n            title\n            lang\n            urlPath\n            srcTitle\n            sourceId\n            dateCreate\n        }\n    }\n}\n\"\"\"\n\nGet_chapterNode = \"\"\"\nquery Get_chapterNode($getChapterNodeId: ID!) {\n    get_chapterNode(id: $getChapterNodeId) {\n        data {\n            id\n            dname\n            lang\n            sourceId\n            srcTitle\n            dateCreate\n            comicNode{\n                id\n            }\n            imageFile {\n                urlList\n            }\n        }\n    }\n}\n\"\"\"\n\nGet_comicNode = \"\"\"\nquery Get_comicNode($getComicNodeId: ID!) {\n    get_comicNode(id: $getComicNodeId) {\n        data {\n            id\n            name\n            artists\n            authors\n            genres\n        }\n    }\n}\n\"\"\"\n\nget_content_source_chapterList = \"\"\"\n  query get_content_source_chapterList($sourceId: Int!) {\n    get_content_source_chapterList(\n      sourceId: $sourceId\n    ) {\n\n  id\n  data {\n\n\n  id\n  sourceId\n\n  dbStatus\n  isNormal\n  isHidden\n  isDeleted\n  isFinal\n\n  dateCreate\n  datePublic\n  dateModify\n  lang\n  volume\n  serial\n  dname\n  title\n  urlPath\n\n  srcTitle srcColor\n\n  count_images\n\n  stat_count_post_child\n  stat_count_post_reply\n  stat_count_views_login\n  stat_count_views_guest\n\n  userId\n  userNode {\n\n  id\n  data {\n\nid\nname\nuniq\navatarUrl\nurlPath\n\nverified\ndeleted\nbanned\n\ndateCreate\ndateOnline\n\nstat_count_chapters_normal\nstat_count_chapters_others\n\nis_adm is_mod is_vip is_upr\n\n  }\n\n  }\n\n  disqusId\n\n\n  }\n\n    }\n  }\n\"\"\"\n\nget_content_comic_sources = \"\"\"\n  query get_content_comic_sources($comicId: Int!, $dbStatuss: [String] = [], $userId: Int, $haveChapter: Boolean, $sortFor: String) {\n    get_content_comic_sources(\n      comicId: $comicId\n      dbStatuss: $dbStatuss\n      userId: $userId\n      haveChapter: $haveChapter\n      sortFor: $sortFor\n    ) {\n\nid\ndata{\n\n  id\n\n  dbStatus\n  isNormal\n  isHidden\n  isDeleted\n\n  lang name altNames authors artists\n\n  release\n  genres summary{code} extraInfo{code}\n\n  urlCover600\n  urlCover300\n  urlCoverOri\n\n  srcTitle srcColor\n\n  chapterCount\n  chapterNode_last {\n    id\n    data {\n      dateCreate datePublic dateModify\n      volume serial\n      dname title\n      urlPath\n      userNode {\n        id data {uniq name}\n      }\n    }\n  }\n}\n\n    }\n  }\n\"\"\"\n"
  },
  {
    "path": "gallery_dl/extractor/utils/patreon_tiptap.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2026 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom ... import text, util\n\n\ndef to_html(markup):\n    html = []\n\n    data = util.json_loads(markup)\n    for block in data[\"content\"]:\n        process_content(html, block)\n\n    return \"\".join(html)\n\n\ndef process_content(html, content):\n    type = content[\"type\"]\n\n    if type == \"paragraph\":\n        if children := content.get(\"content\"):\n            html.append('<p>')\n            for block in children:\n                process_content(html, block)\n            html.append(\"</p>\")\n        else:\n            html.append(\"<p></p>\")\n\n    elif type == \"text\":\n        process_text(html, content)\n\n    elif type == \"heading\":\n        if attrs := content.get(\"attrs\"):\n            level = str(attrs.get(\"level\") or \"3\")\n        else:\n            level = \"3\"\n        html.append(f\"<h{level}>\")\n        process_children(html, content)\n        html.append(f\"</h{level}>\")\n\n    elif type in {\"listItem\", \"bulletList\", \"orderedList\", \"blockquote\"}:\n        c = type[1]\n        tag = (\n            \"li\" if c == \"i\" else\n            \"ul\" if c == \"u\" else\n            \"ol\" if c == \"r\" else\n            \"blockquote\"\n        )\n        html.append(f\"<{tag}>\")\n        process_children(html, content)\n        html.append(f\"</{tag}>\")\n\n    elif type == \"image\":\n        if (attrs := content.get(\"attrs\")) and (src := attrs.get(\"src\")):\n            html.append(f'<img src=\"{text.escape(src)}\">')\n\n    elif type == \"link\":\n        if (attrs := content.get(\"attrs\")) and (href := attrs.get(\"href\")):\n            html.append(f'<a href=\"{text.escape(href)}\">')\n            process_children(html, content)\n            html.append(\"</a>\")\n\n    elif type == \"hardBreak\":\n        html.append(\"<br/>\")\n\n    elif type == \"horizontalRule\":\n        html.append(\"<hr/>\")\n\n    else:\n        import logging\n        logging.getLogger(\"tiptap\").warning(\n            \"Unsupported content type '%s'\", type)\n\n\ndef process_text(html, content):\n    if marks := content.get(\"marks\"):\n        close = []\n        for mark in marks:\n            type = mark[\"type\"]\n            if type == \"link\":\n                attrs = mark.get(\"attrs\") or {}\n                html.append('<a href=\"')\n                html.append(text.escape(attrs.get(\"href\") or \"\"))\n                html.append('\"')\n                if \"target\" in attrs:\n                    html.append(f' target=\"{attrs[\"target\"]}\"')\n                if \"rel\" in attrs:\n                    html.append(f' rel=\"{attrs[\"rel\"]}\"')\n                html.append(\">\")\n                close.append(\"</a>\")\n            elif type == \"bold\":\n                html.append(\"<strong>\")\n                close.append(\"</strong>\")\n            elif type == \"italic\":\n                html.append(\"<em>\")\n                close.append(\"</em>\")\n            elif type == \"underline\":\n                html.append(\"<u>\")\n                close.append(\"</u>\")\n            elif type == \"strike\":\n                html.append(\"<s>\")\n                close.append(\"</s>\")\n            elif type == \"textStyle\" and len(mark) <= 1:\n                pass\n            else:\n                import logging\n                logging.getLogger(\"tiptap\").warning(\n                    \"Unsupported text marker '%s'\", type)\n        close.reverse()\n        html.append(text.escape(content[\"text\"]))\n        html.extend(close)\n    else:\n        html.append(text.escape(content[\"text\"]))\n\n\ndef process_children(html, content):\n    if children := content.get(\"content\"):\n        for block in children:\n            process_content(html, block)\n\n\ndef process_indentation(html, attrs):\n    itype = (\"text-indent\" if attrs.get(\"indentType\") == \"line\" else\n             \"margin-inline-start\")\n    isize = str((attrs.get(\"indentation\") or 0) * 24)\n    html.append(f\"{itype}:{isize}px\")\n"
  },
  {
    "path": "gallery_dl/extractor/utils/scrolller_graphql.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2026 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\nSubredditPostQuery = \"\"\"\\\nquery SubredditPostQuery(\n    $url: String!\n) {\n    getPost(\n        data: { url: $url }\n    ) {\n        __typename id url title subredditId subredditTitle subredditUrl\n        redditPath isNsfw hasAudio fullLengthSource gfycatSource redgifsSource\n        ownerAvatar username displayName favoriteCount isPaid tags\n        commentsCount commentsRepliesCount isFavorite\n        albumContent { mediaSources { url width height isOptimized } }\n        mediaSources { url width height isOptimized }\n        blurredMediaSources { url width height isOptimized }\n    }\n}\n\"\"\"\n\nUserPostsQuery = \"\"\"\\\nquery UserPostsQuery(\n    $username: String!\n    $iterator: String\n    $limit: Int!\n    $filter: GalleryFilter\n    $sortBy: GallerySortBy\n    $isNsfw: Boolean\n) {\n    getUserPosts(\n        data: {\n            username: $username\n            iterator: $iterator\n            limit: $limit\n            filter: $filter\n            sortBy: $sortBy\n            isNsfw: $isNsfw\n        }\n    ) {\n        iterator items {\n            __typename id url title posted_by reddit_posted_by subredditId\n            subredditTitle subredditUrl subredditIsFollowing redditPath isNsfw\n            hasAudio fullLengthSource gfycatSource redgifsSource ownerAvatar\n            username displayName favoriteCount isPaid tags commentsCount\n            commentsRepliesCount duration createdAt isFavorite\n            albumContent { mediaSources { url width height isOptimized } }\n            mediaSources { url width height isOptimized }\n            blurredMediaSources { url width height isOptimized type }\n        }\n    }\n}\n\"\"\"\n\nSubredditQuery = \"\"\"\\\nquery SubredditQuery(\n    $url: String!\n    $iterator: String\n    $sortBy: GallerySortBy\n    $filter: GalleryFilter\n    $limit: Int!\n) {\n    getSubreddit(\n        data: {\n            url: $url,\n            iterator: $iterator,\n            filter: $filter,\n            limit: $limit,\n            sortBy: $sortBy\n        }\n    ) {\n        __typename id url title secondaryTitle description createdAt isNsfw\n        subscribers isComplete itemCount videoCount pictureCount albumCount\n        isPaid username tags isFollowing\n        banner { url width height isOptimized }\n        children {\n            iterator items {\n                __typename id url title subredditId subredditTitle subredditUrl\n                redditPath isNsfw hasAudio fullLengthSource gfycatSource\n                redgifsSource ownerAvatar username displayName favoriteCount\n                isPaid tags commentsCount commentsRepliesCount isFavorite\n                albumContent { mediaSources { url width height isOptimized } }\n                mediaSources { url width height isOptimized }\n                blurredMediaSources { url width height isOptimized }\n            }\n        }\n    }\n}\n\"\"\"\n\nSubredditChildrenQuery = \"\"\"\\\nquery SubredditChildrenQuery(\n    $subredditId: Int!\n    $iterator: String\n    $filter: GalleryFilter\n    $sortBy: GallerySortBy\n    $limit: Int!\n    $isNsfw: Boolean\n) {\n    getSubredditChildren(\n        data: {\n            subredditId: $subredditId,\n            iterator: $iterator,\n            filter: $filter,\n            sortBy: $sortBy,\n            limit: $limit,\n            isNsfw: $isNsfw\n        },\n    ) {\n        iterator items {\n            __typename id url title subredditId subredditTitle subredditUrl\n            redditPath isNsfw hasAudio fullLengthSource gfycatSource\n            redgifsSource ownerAvatar username displayName favoriteCount isPaid\n            tags commentsCount commentsRepliesCount isFavorite\n            albumContent { mediaSources { url width height isOptimized } }\n            mediaSources { url width height isOptimized }\n            blurredMediaSources { url width height isOptimized }\n        }\n    }\n}\n\"\"\"\n\nGetFollowingSubreddits = \"\"\"\\\nquery GetFollowingSubreddits(\n    $iterator: String,\n    $limit: Int!,\n    $filter: GalleryFilter,\n    $isNsfw: Boolean,\n    $sortBy: GallerySortBy\n) {\n    getFollowingSubreddits(\n        data: {\n            isNsfw: $isNsfw\n            limit: $limit\n            filter: $filter\n            iterator: $iterator\n            sortBy: $sortBy\n        }\n    ) {\n        iterator items {\n            __typename id url title secondaryTitle description createdAt isNsfw\n            subscribers isComplete itemCount videoCount pictureCount albumCount\n            isFollowing\n        }\n    }\n}\n\"\"\"\n\nLoginQuery = \"\"\"\\\nquery LoginQuery(\n    $username: String!,\n    $password: String!\n) {\n    login(\n        username: $username,\n        password: $password\n    ) {\n        username token expiresAt isAdmin status isPremium\n    }\n}\n\"\"\"\n\nItemTypeQuery = \"\"\"\\\nquery ItemTypeQuery(\n    $url: String!\n) {\n    getItemType(\n        url: $url\n    )\n}\n\"\"\"\n"
  },
  {
    "path": "gallery_dl/extractor/utils/twitter_transaction_id.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2025-2026 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n# Adapted from iSarabjitDhiman/XClientTransaction\n# https://github.com/iSarabjitDhiman/XClientTransaction\n\n# References:\n# https://antibot.blog/posts/1741552025433\n# https://antibot.blog/posts/1741552092462\n# https://antibot.blog/posts/1741552163416\n\n\"\"\"Twitter 'x-client-transaction-id' header generation\"\"\"\n\nimport math\nimport time\nimport random\nimport hashlib\nimport binascii\nimport itertools\nfrom ... import text, util\n\n\nclass ClientTransaction():\n    __slots__ = (\"key_bytes\", \"animation_key\")\n\n    def __getstate__(self):\n        return (self.key_bytes, self.animation_key)\n\n    def __setstate__(self, state):\n        self.key_bytes, self.animation_key = state\n\n    def initialize(self, extractor, homepage=None):\n        if homepage is None:\n            homepage = extractor.request(\"https://x.com/\").text\n\n        key = self._extract_verification_key(homepage)\n        if not key:\n            extractor.log.error(\n                \"Failed to extract 'twitter-site-verification' key\")\n\n        ondemand_pos = homepage.find('\"ondemand.s\"')\n        ondemand_key = text.rextr(homepage, \",\", ':', ondemand_pos)\n        ondemand_s = text.extract(\n            homepage, ondemand_key + ':\"', '\"', ondemand_pos)[0]\n\n        indices = extractor.cache(\n            self._extract_indices, ondemand_s, extractor, _mem=False)\n        if not indices:\n            extractor.log.error(\"Failed to extract KEY_BYTE indices\")\n\n        frames = self._extract_frames(homepage)\n        if not frames:\n            extractor.log.error(\"Failed to extract animation frame data\")\n\n        self.key_bytes = key_bytes = binascii.a2b_base64(key)\n        self.animation_key = self._calculate_animation_key(\n            frames, indices[0], key_bytes, indices[1:])\n\n    def _extract_verification_key(self, homepage):\n        pos = homepage.find('name=\"twitter-site-verification\"')\n        beg = homepage.rfind(\"<\", 0, pos)\n        end = homepage.find(\">\", pos)\n        return text.extr(homepage[beg:end], 'content=\"', '\"')\n\n    def _extract_indices(self, ondemand_s, extractor):\n        url = (f\"https://abs.twimg.com/responsive-web/client-web\"\n               f\"/ondemand.s.{ondemand_s}a.js\")\n        page = extractor.request(url).text\n        pattern = util.re_compile(r\"\\(\\w\\[(\\d\\d?)\\],\\s*16\\)\")\n        return [int(i) for i in pattern.findall(page)]\n\n    def _extract_frames(self, homepage):\n        return list(text.extract_iter(\n            homepage, 'id=\"loading-x-anim-', \"</svg>\"))\n\n    def _calculate_animation_key(self, frames, row_index, key_bytes,\n                                 key_bytes_indices, total_time=4096):\n        frame = frames[key_bytes[5] % 4]\n        array = self._generate_2d_array(frame)\n        frame_row = array[key_bytes[row_index] % 16]\n\n        frame_time = 1\n        for index in key_bytes_indices:\n            frame_time *= key_bytes[index] % 16\n        frame_time = round_js(frame_time / 10) * 10\n        target_time = frame_time / total_time\n\n        return self.animate(frame_row, target_time)\n\n    def _generate_2d_array(self, frame):\n        split = util.re_compile(r\"[^\\d]+\").split\n        return [\n            [int(x) for x in split(path) if x]\n            for path in text.extr(\n                frame, '</path><path d=\"', '\"')[9:].split(\"C\")\n        ]\n\n    def animate(self, frames, target_time):\n        curve = [scale(float(frame), is_odd(index), 1.0, False)\n                 for index, frame in enumerate(frames[7:])]\n        cubic = cubic_value(curve, target_time)\n\n        color_a = (float(frames[0]), float(frames[1]), float(frames[2]))\n        color_b = (float(frames[3]), float(frames[4]), float(frames[5]))\n        color = interpolate_list(cubic, color_a, color_b)\n        color = [0.0 if c <= 0.0 else 255.0 if c >= 255.0 else c\n                 for c in color]\n\n        rotation_a = 0.0\n        rotation_b = scale(float(frames[6]), 60.0, 360.0, True)\n        rotation = interpolate_value(cubic, rotation_a, rotation_b)\n        matrix = rotation_matrix_2d(rotation)\n\n        result = (\n            hex(round(color[0]))[2:],\n            hex(round(color[1]))[2:],\n            hex(round(color[2]))[2:],\n            float_to_hex(abs(round(matrix[0], 2))),\n            float_to_hex(abs(round(matrix[1], 2))),\n            float_to_hex(abs(round(matrix[2], 2))),\n            float_to_hex(abs(round(matrix[3], 2))),\n            \"00\",\n        )\n        return \"\".join(result).replace(\".\", \"\").replace(\"-\", \"\")\n\n    def generate_transaction_id(self, method, path,\n                                keyword=\"obfiowerehiring\", rndnum=3):\n        bytes_key = self.key_bytes\n\n        nowf = time.time()\n        nowi = int(nowf)\n        now = nowi - 1682924400\n        bytes_time = (\n            (now      ) & 0xFF,  # noqa: E202\n            (now >>  8) & 0xFF,  # noqa: E222\n            (now >> 16) & 0xFF,\n            (now >> 24) & 0xFF,\n        )\n\n        payload = f\"{method}!{path}!{now}{keyword}{self.animation_key}\"\n        bytes_hash = hashlib.sha256(payload.encode()).digest()[:16]\n\n        num = (random.randrange(16) << 4) + int((nowf - nowi) * 16.0)\n        result = bytes(\n            byte ^ num\n            for byte in itertools.chain(\n                (0,), bytes_key, bytes_time, bytes_hash, (rndnum,))\n        )\n        return binascii.b2a_base64(result).rstrip(b\"=\\n\")\n\n\n# Cubic Curve\n\ndef cubic_value(curve, t):\n    if t <= 0.0:\n        if curve[0] > 0.0:\n            value = curve[1] / curve[0]\n        elif curve[1] == 0.0 and curve[2] > 0.0:\n            value = curve[3] / curve[2]\n        else:\n            value = 0.0\n        return value * t\n\n    if t >= 1.0:\n        if curve[2] < 1.0:\n            value = (curve[3] - 1.0) / (curve[2] - 1.0)\n        elif curve[2] == 1.0 and curve[0] < 1.0:\n            value = (curve[1] - 1.0) / (curve[0] - 1.0)\n        else:\n            value = 0.0\n        return 1.0 + value * (t - 1.0)\n\n    start = 0.0\n    end = 1.0\n    while start < end:\n        mid = (start + end) / 2.0\n        est = cubic_calculate(curve[0], curve[2], mid)\n        if abs(t - est) < 0.00001:\n            return cubic_calculate(curve[1], curve[3], mid)\n        if est < t:\n            start = mid\n        else:\n            end = mid\n    return cubic_calculate(curve[1], curve[3], mid)\n\n\ndef cubic_calculate(a, b, m):\n    m1 = 1.0 - m\n    return 3.0*a*m1*m1*m + 3.0*b*m1*m*m + m*m*m\n\n\n# Interpolation\n\ndef interpolate_list(x, a, b):\n    return [\n        interpolate_value(x, a[i], b[i])\n        for i in range(len(a))\n    ]\n\n\ndef interpolate_value(x, a, b):\n    if isinstance(a, bool):\n        return a if x <= 0.5 else b\n    return a * (1.0 - x) + b * x\n\n\n# Rotation\n\ndef rotation_matrix_2d(deg):\n    rad = math.radians(deg)\n    cos = math.cos(rad)\n    sin = math.sin(rad)\n    return [cos, -sin, sin, cos]\n\n\n# Utilities\n\ndef float_to_hex(numf):\n    numi = int(numf)\n\n    fraction = numf - numi\n    if not fraction:\n        return hex(numi)[2:]\n\n    result = [\".\"]\n    while fraction > 0.0:\n        fraction *= 16.0\n        integer = int(fraction)\n        fraction -= integer\n        result.append(chr(integer + 87) if integer > 9 else str(integer))\n    return hex(numi)[2:] + \"\".join(result)\n\n\ndef is_odd(num):\n    return -1.0 if num % 2 else 0.0\n\n\ndef round_js(num):\n    floor = math.floor(num)\n    return floor if (num - floor) < 0.5 else math.ceil(num)\n\n\ndef scale(value, value_min, value_max, rounding):\n    result = value * (value_max-value_min) / 255.0 + value_min\n    return math.floor(result) if rounding else round(result, 2)\n"
  },
  {
    "path": "gallery_dl/extractor/vanillarock.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2019-2025 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://vanilla-rock.com/\"\"\"\n\nfrom .common import Extractor, Message\nfrom .. import text\n\n\nclass VanillarockExtractor(Extractor):\n    \"\"\"Base class for vanillarock extractors\"\"\"\n    category = \"vanillarock\"\n    root = \"https://vanilla-rock.com\"\n\n    def __init__(self, match):\n        Extractor.__init__(self, match)\n        self.path = match[1]\n\n\nclass VanillarockPostExtractor(VanillarockExtractor):\n    \"\"\"Extractor for blogposts on vanilla-rock.com\"\"\"\n    subcategory = \"post\"\n    directory_fmt = (\"{category}\", \"{path}\")\n    filename_fmt = \"{num:>02}.{extension}\"\n    archive_fmt = \"{filename}\"\n    pattern = (r\"(?:https?://)?(?:www\\.)?vanilla-rock\\.com\"\n               r\"(/(?!category/|tag/)[^/?#]+)/?$\")\n    example = \"https://vanilla-rock.com/TITLE\"\n\n    def items(self):\n        extr = text.extract_from(self.request(self.root + self.path).text)\n        name = extr('<h1 class=\"entry-title\">', \"<\")\n\n        imgs = []\n        while True:\n            img = extr('<div class=\"main-img\">', '</div>')\n            if not img:\n                break\n            imgs.append(text.extr(img, 'href=\"', '\"'))\n\n        data = {\n            \"count\": len(imgs),\n            \"title\": text.unescape(name),\n            \"path\" : self.path.strip(\"/\"),\n            \"date\" : self.parse_datetime_iso(extr(\n                '<div class=\"date\">', '</div>')),\n            \"tags\" : text.split_html(extr(\n                '<div class=\"cat-tag\">', '</div>'))[::2],\n        }\n\n        yield Message.Directory, \"\", data\n        for data[\"num\"], url in enumerate(imgs, 1):\n            yield Message.Url, url, text.nameext_from_url(url, data)\n\n\nclass VanillarockTagExtractor(VanillarockExtractor):\n    \"\"\"Extractor for vanillarock blog posts by tag or category\"\"\"\n    subcategory = \"tag\"\n    pattern = (r\"(?:https?://)?(?:www\\.)?vanilla-rock\\.com\"\n               r\"(/(?:tag|category)/[^?#]+)\")\n    example = \"https://vanilla-rock.com/tag/TAG\"\n\n    def items(self):\n        url = self.root + self.path\n        data = {\"_extractor\": VanillarockPostExtractor}\n\n        while url:\n            extr = text.extract_from(self.request(url).text)\n            while True:\n                post = extr('<h2 class=\"entry-title\">', '</h2>')\n                if not post:\n                    break\n                yield Message.Queue, text.extr(post, 'href=\"', '\"'), data\n            url = text.unescape(extr('class=\"next page-numbers\" href=\"', '\"'))\n"
  },
  {
    "path": "gallery_dl/extractor/vichan.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2022-2025 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for vichan imageboards\"\"\"\n\nfrom .common import BaseExtractor, Message\nfrom .. import text\n\n\nclass VichanExtractor(BaseExtractor):\n    \"\"\"Base class for vichan extractors\"\"\"\n    basecategory = \"vichan\"\n\n\nBASE_PATTERN = VichanExtractor.update({\n    \"8kun\": {\n        \"root\": \"https://8kun.top\",\n        \"pattern\": r\"8kun\\.top\",\n    },\n    \"smugloli\": {\n        \"root\": None,\n        \"pattern\": r\"smuglo(?:\\.li|li\\.net)\",\n    },\n    \"gurochan\": {\n        \"root\": \"https://boards.guro.cx\",\n        \"pattern\": r\"boards\\.guro\\.cx\",\n    },\n})\n\n\nclass VichanThreadExtractor(VichanExtractor):\n    \"\"\"Extractor for vichan threads\"\"\"\n    subcategory = \"thread\"\n    directory_fmt = (\"{category}\", \"{board}\", \"{thread} {title}\")\n    filename_fmt = \"{time}{num:?-//} {filename}.{extension}\"\n    archive_fmt = \"{board}_{thread}_{tim}\"\n    pattern = BASE_PATTERN + r\"/([^/?#]+)/res/(\\d+)\"\n    example = \"https://8kun.top/a/res/12345.html\"\n\n    def items(self):\n        board = self.groups[-2]\n        thread = self.groups[-1]\n        url = f\"{self.root}/{board}/res/{thread}.json\"\n        posts = self.request_json(url)[\"posts\"]\n\n        title = posts[0].get(\"sub\") or text.remove_html(posts[0][\"com\"])\n        process = (self._process_8kun if self.category == \"8kun\" else\n                   self._process)\n        data = {\n            \"board\" : board,\n            \"thread\": thread,\n            \"title\" : text.unescape(title)[:50],\n            \"num\"   : 0,\n        }\n\n        yield Message.Directory, \"\", data\n        for post in posts:\n            if \"filename\" in post:\n                yield process(post, data)\n                if \"extra_files\" in post:\n                    for post[\"num\"], filedata in enumerate(\n                            post[\"extra_files\"], 1):\n                        yield process(post, filedata)\n\n    def _process(self, post, data):\n        post.update(data)\n        ext = post[\"ext\"]\n        post[\"extension\"] = ext[1:]\n        post[\"url\"] = url = \\\n            f\"{self.root}/{post['board']}/src/{post['tim']}{ext}\"\n        return Message.Url, url, post\n\n    def _process_8kun(self, post, data):\n        post.update(data)\n        ext = post[\"ext\"]\n        tim = post[\"tim\"]\n\n        if len(tim) > 16:\n            url = f\"https://media.128ducks.com/file_store/{tim}{ext}\"\n        else:\n            url = f\"https://media.128ducks.com/{post['board']}/src/{tim}{ext}\"\n\n        post[\"url\"] = url\n        post[\"extension\"] = ext[1:]\n        return Message.Url, url, post\n\n\nclass VichanBoardExtractor(VichanExtractor):\n    \"\"\"Extractor for vichan boards\"\"\"\n    subcategory = \"board\"\n    pattern = BASE_PATTERN + r\"/([^/?#]+)(?:/index|/catalog|/\\d+|/?$)\"\n    example = \"https://8kun.top/a/\"\n\n    def items(self):\n        board = self.groups[-1]\n        url = f\"{self.root}/{board}/threads.json\"\n        threads = self.request_json(url)\n\n        for page in threads:\n            for thread in page[\"threads\"]:\n                url = f\"{self.root}/{board}/res/{thread['no']}.html\"\n                thread[\"page\"] = page[\"page\"]\n                thread[\"_extractor\"] = VichanThreadExtractor\n                yield Message.Queue, url, thread\n"
  },
  {
    "path": "gallery_dl/extractor/vipergirls.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2023-2026 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://vipergirls.to/\"\"\"\n\nfrom .common import Extractor, Message\nfrom .. import text, util\n\nBASE_PATTERN = r\"(?:https?://)?(?:www\\.)?vipergirls\\.to\"\n\n\nclass VipergirlsExtractor(Extractor):\n    \"\"\"Base class for vipergirls extractors\"\"\"\n    category = \"vipergirls\"\n    root = \"https://vipergirls.to\"\n    request_interval = 0.5\n    request_interval_min = 0.2\n    cookies_domain = \".vipergirls.to\"\n    cookies_names = (\"vg_userid\", \"vg_password\")\n\n    def _init(self):\n        if domain := self.config(\"domain\"):\n            pos = domain.find(\"://\")\n            if pos >= 0:\n                self.root = domain.rstrip(\"/\")\n                self.cookies_domain = \".\" + domain[pos+1:].strip(\"/\")\n            else:\n                domain = domain.strip(\"/\")\n                self.root = \"https://\" + domain\n                self.cookies_domain = \".\" + domain\n        else:\n            self.root = \"https://viper.click\"\n            self.cookies_domain = \".viper.click\"\n\n    def items(self):\n        self.login()\n        root = self.posts()\n        forum_title = root[1].attrib[\"title\"]\n        thread_title = root[2].attrib[\"title\"]\n\n        if like := self.config(\"like\"):\n            user_hash = root[0].get(\"hash\")\n            if len(user_hash) < 16:\n                self.log.warning(\"Login required to like posts\")\n                like = False\n\n        posts = root.iter(\"post\")\n        if (order := self.config(\"order-posts\")) and \\\n                order[0] not in (\"d\", \"r\"):\n            if self.page:\n                util.advance(posts, (text.parse_int(self.page[5:]) - 1) * 15)\n        else:\n            posts = list(posts)\n            if self.page:\n                offset = text.parse_int(self.page[5:]) * 15\n                posts = posts[:offset]\n            posts.reverse()\n\n        for post in posts:\n            images = list(post)\n\n            data = post.attrib\n            data[\"forum_title\"] = forum_title\n            data[\"thread_id\"] = self.thread_id\n            data[\"thread_title\"] = thread_title\n            data[\"post_id\"] = data.pop(\"id\")\n            data[\"post_num\"] = data.pop(\"number\")\n            data[\"post_title\"] = data.pop(\"title\")\n            data[\"count\"] = len(images)\n            del data[\"imagecount\"]\n\n            yield Message.Directory, \"\", data\n            if images:\n                for data[\"num\"], image in enumerate(images, 1):\n                    yield Message.Queue, image.attrib[\"main_url\"], data\n                if like:\n                    self.like(post, user_hash)\n\n    def login(self):\n        if self.cookies_check(self.cookies_names):\n            return\n\n        username, password = self._get_auth_info()\n        if username:\n            return self.cookies_update(self.cache(\n                self._login_impl, username, password,\n                _exp=90*86400, _mem=False))\n\n    def _login_impl(self, username, password):\n        self.log.info(\"Logging in as %s\", username)\n\n        url = self.root + \"/login.php?do=login\"\n        data = {\n            \"vb_login_username\": username,\n            \"vb_login_password\": password,\n            \"do\"               : \"login\",\n            \"cookieuser\"       : \"1\",\n        }\n\n        response = self.request(url, method=\"POST\", data=data)\n        if not response.cookies.get(\"vg_password\"):\n            raise self.exc.AuthenticationError()\n\n        return {cookie.name: cookie.value\n                for cookie in response.cookies}\n\n    def like(self, post, user_hash):\n        url = self.root + \"/post_thanks.php\"\n        params = {\n            \"do\"           : \"post_thanks_add\",\n            \"p\"            : post.get(\"id\"),\n            \"securitytoken\": user_hash,\n        }\n\n        with self.request(url, params=params, allow_redirects=False):\n            pass\n\n\nclass VipergirlsThreadExtractor(VipergirlsExtractor):\n    \"\"\"Extractor for vipergirls threads\"\"\"\n    subcategory = \"thread\"\n    pattern = (BASE_PATTERN +\n               r\"/threads/(\\d+)(?:-[^/?#]+)?(/page\\d+)?(?:$|#|\\?(?!p=))\")\n    example = \"https://vipergirls.to/threads/12345-TITLE\"\n\n    def __init__(self, match):\n        VipergirlsExtractor.__init__(self, match)\n        self.thread_id, self.page = match.groups()\n\n    def posts(self):\n        url = f\"{self.root}/vr.php?t={self.thread_id}\"\n        return self.request_xml(url)\n\n\nclass VipergirlsPostExtractor(VipergirlsExtractor):\n    \"\"\"Extractor for vipergirls posts\"\"\"\n    subcategory = \"post\"\n    pattern = (BASE_PATTERN +\n               r\"/threads/(\\d+)(?:-[^/?#]+)?\\?p=\\d+[^#]*#post(\\d+)\")\n    example = \"https://vipergirls.to/threads/12345-TITLE?p=23456#post23456\"\n\n    def __init__(self, match):\n        VipergirlsExtractor.__init__(self, match)\n        self.thread_id, self.post_id = match.groups()\n        self.page = 0\n\n    def posts(self):\n        url = f\"{self.root}/vr.php?p={self.post_id}\"\n        return self.request_xml(url)\n"
  },
  {
    "path": "gallery_dl/extractor/vk.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2021-2025 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://vk.com/\"\"\"\n\nfrom .common import Extractor, Message\nfrom .. import text\n\nBASE_PATTERN = r\"(?:https://)?(?:www\\.|m\\.)?vk\\.com\"\n\n\nclass VkExtractor(Extractor):\n    \"\"\"Base class for vk extractors\"\"\"\n    category = \"vk\"\n    directory_fmt = (\"{category}\", \"{user[name]|user[id]}\")\n    filename_fmt = \"{id}.{extension}\"\n    archive_fmt = \"{user[id]}_{id}\"\n    root = \"https://vk.com\"\n    request_interval = (0.5, 1.5)\n\n    def _init(self):\n        self.offset = text.parse_int(self.config(\"offset\"))\n\n    def finalize(self, status):\n        if status and self.offset:\n            self.log.info(\"Use '-o offset=%s' to continue downloading \"\n                          \"from the current position\", self.offset)\n\n    def skip_files(self, num):\n        self.offset += num\n        return num\n\n    def items(self):\n        subn = text.re(r\"/imp[fg]/\").subn\n        sizes = \"wzyxrqpo\"\n\n        data = self.metadata()\n        yield Message.Directory, \"\", data\n\n        for photo in self.photos():\n\n            for size in sizes:\n                size += \"_\"\n                if size in photo:\n                    break\n            else:\n                self.log.warning(\"no photo URL found (%s)\", photo.get(\"id\"))\n                continue\n\n            try:\n                url = photo[size + \"src\"]\n            except KeyError:\n                self.log.warning(\"no photo URL found (%s)\", photo.get(\"id\"))\n                continue\n\n            url_sub, count = subn(\"/\", url.partition(\"?\")[0])\n            if count:\n                photo[\"_fallback\"] = (url,)\n                photo[\"url\"] = url = url_sub\n            else:\n                photo[\"url\"] = url\n\n            try:\n                _, photo[\"width\"], photo[\"height\"] = photo[size]\n            except ValueError:\n                # photo without width/height entries (#2535)\n                photo[\"width\"] = photo[\"height\"] = 0\n\n            photo[\"id\"] = photo[\"id\"].rpartition(\"_\")[2]\n            photo[\"date\"] = self.parse_timestamp(text.extr(\n                photo[\"date\"], 'data-date=\"', '\"'))\n            photo[\"description\"] = text.unescape(text.extr(\n                photo.get(\"desc\", \"\"), \">\", \"<\"))\n            photo.update(data)\n\n            text.nameext_from_url(url, photo)\n            yield Message.Url, url, photo\n\n    def _pagination(self, photos_id):\n        url = self.root + \"/al_photos.php\"\n        headers = {\n            \"X-Requested-With\": \"XMLHttpRequest\",\n            \"Origin\"          : self.root,\n            \"Referer\"         : self.root + \"/\" + photos_id,\n        }\n        data = {\n            \"act\"      : \"show\",\n            \"al\"       : \"1\",\n            \"direction\": \"1\",\n            \"list\"     : photos_id,\n            \"offset\"   : self.offset,\n        }\n\n        while True:\n            response = self.request(\n                url, method=\"POST\", headers=headers, data=data)\n            if response.history and \"/challenge.html\" in response.url:\n                raise self.exc.AbortExtraction(\n                    \"HTTP redirect to 'challenge' page:\\n\" + response.url)\n\n            payload = response.json()[\"payload\"][1]\n            if len(payload) < 4:\n                self.log.debug(payload)\n                raise self.exc.AuthorizationError(\n                    text.unescape(payload[0]) if payload[0] else None)\n\n            total = payload[1]\n            photos = payload[3]\n\n            for i in range(len(photos)):\n                photos[i][\"num\"] = self.offset + i + 1\n                photos[i][\"count\"] = total\n\n            offset_next = self.offset + len(photos)\n            if offset_next >= total:\n                # the last chunk of photos also contains the first few photos\n                # again if 'total' is not a multiple of 10\n                if extra := total - offset_next:\n                    del photos[extra:]\n\n                yield from photos\n                self.offset = 0\n                return\n\n            yield from photos\n            data[\"offset\"] = self.offset = offset_next\n\n\nclass VkPhotosExtractor(VkExtractor):\n    \"\"\"Extractor for photos from a vk user\"\"\"\n    subcategory = \"photos\"\n    pattern = (BASE_PATTERN + r\"/(?:\"\n               r\"(?:albums|photos|id)(-?\\d+)\"\n               r\"|(?!(?:album|tag|wall)-?\\d+_?)([^/?#]+))\")\n    example = \"https://vk.com/id12345\"\n\n    def __init__(self, match):\n        VkExtractor.__init__(self, match)\n        self.user_id, self.user_name = match.groups()\n\n    def photos(self):\n        return self._pagination(\"photos\" + self.user_id)\n\n    def metadata(self):\n        if self.user_id:\n            user_id = self.user_id\n            prefix = \"public\" if user_id[0] == \"-\" else \"id\"\n            url = f\"{self.root}/{prefix}{user_id.lstrip('-')}\"\n            data = self._extract_profile(url)\n        else:\n            url = f\"{self.root}/{self.user_name}\"\n            data = self._extract_profile(url)\n            self.user_id = data[\"user\"][\"id\"]\n        return data\n\n    def _extract_profile(self, url):\n        page = self.request(url).text\n        extr = text.extract_from(page)\n\n        user = {\n            \"id\"  : extr('property=\"og:url\" content=\"https://vk.com/id', '\"'),\n            \"nick\": text.unescape(extr(\n                \"<title>\", \" | VK</title>\")),\n            \"info\": text.unescape(extr(\n                ',\"activity\":\"', '\",\"')).replace(\"\\\\/\", \"/\"),\n            \"name\": extr('href=\"https://m.vk.com/', '\"'),\n        }\n\n        if user[\"id\"]:\n            user[\"group\"] = False\n        else:\n            user[\"group\"] = True\n            user[\"id\"] = extr('data-from-id=\"', '\"')\n\n        return {\"user\": user}\n\n\nclass VkAlbumExtractor(VkExtractor):\n    \"\"\"Extractor for a vk album\"\"\"\n    subcategory = \"album\"\n    directory_fmt = (\"{category}\", \"{user[id]}\", \"{album[id]}\")\n    pattern = BASE_PATTERN + r\"/album(-?\\d+)_(\\d+)$\"\n    example = \"https://vk.com/album12345_00\"\n\n    def photos(self):\n        user_id, album_id = self.groups\n        return self._pagination(f\"album{user_id}_{album_id}\")\n\n    def metadata(self):\n        user_id, album_id = self.groups\n\n        url = f\"{self.root}/album{user_id}_{album_id}\"\n        page = self.request(url).text\n        desc = text.extr(page, 'name=\"og:description\" value=\"', '\"')\n        try:\n            album_name, user_name, photos = desc.rsplit(\" - \", 2)\n        except ValueError:\n            if msg := text.extr(\n                    page, '<div class=\"message_page_title\">Error</div>',\n                    \"</div>\"):\n                msg = f\" ('{text.remove_html(msg)[:-5]}')\"\n            self.log.warning(\"%s_%s: Failed to extract metadata%s\",\n                             user_id, album_id, msg)\n            return {\"user\": {\"id\": user_id}, \"album\": {\"id\": album_id}}\n\n        return {\n            \"user\": {\n                \"id\"   : user_id,\n                \"nick\" : text.unescape(user_name),\n                \"name\" : text.unescape(text.extr(\n                    page, 'class=\"ui_crumb\" href=\"/', '\"')),\n                \"group\": user_id[0] == \"-\",\n            },\n            \"album\": {\n                \"id\"   : album_id,\n                \"name\" : text.unescape(album_name),\n                \"count\": text.parse_int(photos[:-7])\n            },\n        }\n\n\nclass VkTaggedExtractor(VkExtractor):\n    \"\"\"Extractor for a vk tagged photos\"\"\"\n    subcategory = \"tagged\"\n    directory_fmt = (\"{category}\", \"{user[id]}\", \"tags\")\n    pattern = BASE_PATTERN + r\"/tag(-?\\d+)$\"\n    example = \"https://vk.com/tag12345\"\n\n    def __init__(self, match):\n        VkExtractor.__init__(self, match)\n        self.user_id = match[1]\n\n    def photos(self):\n        return self._pagination(\"tag\" + self.user_id)\n\n    def metadata(self):\n        return {\"user\": {\"id\": self.user_id}}\n\n\nclass VkWallPostExtractor(VkExtractor):\n    \"\"\"Extractor for a vk wall post\"\"\"\n    subcategory = \"wall-post\"\n    directory_fmt = (\"{category}\", \"{user[id]}\", \"wall\")\n    filename_fmt = \"{wall[id]}_{num}.{extension}\"\n    pattern = BASE_PATTERN + r\"/wall(-?\\d+)_(\\d+)\"\n    example = \"https://vk.com/wall12345_123\"\n\n    def photos(self):\n        user_id, wall_id = self.groups\n        return self._pagination(f\"wall{user_id}_{wall_id}\")\n\n    def metadata(self):\n        user_id, wall_id = self.groups\n\n        url = f\"{self.root}/wall{user_id}_{wall_id}\"\n        page = self.request(url).text\n        desc = text.unescape(\n            text.extr(page, 'data-testid=\"post_description\">', \"</div>\") or\n            text.extr(page, 'name=\"description\" content=\"', '\"'))\n\n        return {\n            \"user\": {\n                \"id\": user_id,\n            },\n            \"wall\": {\n                \"id\": wall_id,\n                \"description\": desc,\n            },\n        }\n"
  },
  {
    "path": "gallery_dl/extractor/vsco.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2019-2025 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://vsco.co/\"\"\"\n\nfrom .common import Extractor, Message, Dispatch\nfrom .. import text, util\n\nBASE_PATTERN = r\"(?:https?://)?(?:www\\.)?vsco\\.co\"\nUSER_PATTERN = BASE_PATTERN + r\"/([^/?#]+)\"\n\n\nclass VscoExtractor(Extractor):\n    \"\"\"Base class for vsco extractors\"\"\"\n    category = \"vsco\"\n    root = \"https://vsco.co\"\n    directory_fmt = (\"{category}\", \"{user}\")\n    filename_fmt = \"{id}.{extension}\"\n    archive_fmt = \"{id}\"\n    browser = \"firefox\"\n\n    def __init__(self, match):\n        Extractor.__init__(self, match)\n        self.user = match[1].lower()\n\n    def items(self):\n        videos = self.config(\"videos\", True)\n        yield Message.Directory, \"\", {\"user\": self.user}\n        for img in self.images():\n\n            if not img:\n                continue\n            elif \"playback_url\" in img:\n                img = self._transform_video(img)\n            elif \"responsive_url\" not in img:\n                continue\n\n            if img[\"is_video\"]:\n                if not videos:\n                    continue\n                url = text.ensure_http_scheme(img[\"video_url\"])\n            else:\n                base = img[\"responsive_url\"].partition(\"/\")[2]\n                cdn, _, path = base.partition(\"/\")\n                if cdn.startswith(\"aws\"):\n                    url = f\"https://image-{cdn}.vsco.co/{path}\"\n                elif cdn.isdecimal():\n                    url = \"https://image.vsco.co/\" + base\n                elif img[\"responsive_url\"].startswith(\"http\"):\n                    url = img[\"responsive_url\"]\n                else:\n                    url = \"https://\" + img[\"responsive_url\"]\n\n            data = text.nameext_from_url(url, {\n                \"id\"    : img[\"_id\"],\n                \"user\"  : self.user,\n                \"grid\"  : img[\"grid_name\"],\n                \"meta\"  : img.get(\"image_meta\") or {},\n                \"tags\"  : [tag[\"text\"] for tag in img.get(\"tags\") or ()],\n                \"date\"  : self.parse_timestamp(img[\"upload_date\"] / 1000),\n                \"video\" : img[\"is_video\"],\n                \"width\" : img[\"width\"],\n                \"height\": img[\"height\"],\n                \"description\": img.get(\"description\") or \"\",\n            })\n            if data[\"extension\"] == \"m3u8\":\n                url = \"ytdl:\" + url\n                data[\"_ytdl_manifest\"] = \"hls\"\n                data[\"extension\"] = \"mp4\"\n            yield Message.Url, url, data\n\n    def images(self):\n        \"\"\"Return an iterable with all relevant image objects\"\"\"\n\n    def _extract_preload_state(self, url):\n        page = self.request(url, notfound=self.subcategory).text\n        return util.json_loads(text.extr(page, \"__PRELOADED_STATE__ = \", \"<\")\n                               .replace('\":undefined', '\":null'))\n\n    def _pagination(self, url, params, token, key, extra=None):\n        headers = {\n            \"Referer\"          : f\"{self.root}/{self.user}\",\n            \"Authorization\"    : \"Bearer \" + token,\n            \"X-Client-Platform\": \"web\",\n            \"X-Client-Build\"   : \"1\",\n        }\n\n        if extra:\n            yield from map(self._transform_media, extra)\n\n        while True:\n            data = self.request_json(url, params=params, headers=headers)\n            medias = data.get(key)\n            if not medias:\n                return\n\n            if \"cursor\" in params:\n                for media in medias:\n                    yield media[media[\"type\"]]\n                cursor = data.get(\"next_cursor\")\n                if not cursor:\n                    return\n                params[\"cursor\"] = cursor\n            else:\n                yield from medias\n                params[\"page\"] += 1\n\n    def _transform_media(self, media):\n        if \"responsiveUrl\" not in media:\n            return None\n        media[\"_id\"] = media[\"id\"]\n        media[\"is_video\"] = media[\"isVideo\"]\n        media[\"grid_name\"] = media[\"gridName\"]\n        media[\"upload_date\"] = media[\"uploadDate\"]\n        media[\"responsive_url\"] = media[\"responsiveUrl\"]\n        media[\"video_url\"] = media.get(\"videoUrl\")\n        media[\"image_meta\"] = media.get(\"imageMeta\")\n        return media\n\n    def _transform_video(self, media):\n        media[\"is_video\"] = True\n        media[\"grid_name\"] = \"\"\n        media[\"video_url\"] = media[\"playback_url\"]\n        media[\"responsive_url\"] = media[\"poster_url\"]\n        media[\"upload_date\"] = media[\"created_date\"]\n        return media\n\n\nclass VscoUserExtractor(Dispatch, VscoExtractor):\n    \"\"\"Extractor for a vsco user profile\"\"\"\n    pattern = USER_PATTERN + r\"/?$\"\n    example = \"https://vsco.co/USER\"\n\n    def items(self):\n        base = f\"{self.root}/{self.user}/\"\n        return self._dispatch_extractors((\n            (VscoAvatarExtractor    , base + \"avatar\"),\n            (VscoGalleryExtractor   , base + \"gallery\"),\n            (VscoSpacesExtractor    , base + \"spaces\"),\n            (VscoCollectionExtractor, base + \"collection\"),\n        ), (\"gallery\",))\n\n\nclass VscoGalleryExtractor(VscoExtractor):\n    \"\"\"Extractor for a vsco user's gallery\"\"\"\n    subcategory = \"gallery\"\n    pattern = USER_PATTERN + r\"/(?:gallery|images)\"\n    example = \"https://vsco.co/USER/gallery\"\n\n    def images(self):\n        url = f\"{self.root}/{self.user}/gallery\"\n        data = self._extract_preload_state(url)\n        tkn = data[\"users\"][\"currentUser\"][\"tkn\"]\n        sid = str(data[\"sites\"][\"siteByUsername\"][self.user][\"site\"][\"id\"])\n\n        url = self.root + \"/api/3.0/medias/profile\"\n        params = {\n            \"site_id\"  : sid,\n            \"limit\"    : \"14\",\n            \"cursor\"   : None,\n        }\n\n        return self._pagination(url, params, tkn, \"media\")\n\n\nclass VscoCollectionExtractor(VscoExtractor):\n    \"\"\"Extractor for images from a collection on vsco.co\"\"\"\n    subcategory = \"collection\"\n    directory_fmt = (\"{category}\", \"{user}\", \"collection\")\n    archive_fmt = \"c_{user}_{id}\"\n    pattern = USER_PATTERN + r\"/collection\"\n    example = \"https://vsco.co/USER/collection/1\"\n\n    def images(self):\n        url = f\"{self.root}/{self.user}/collection/1\"\n        data = self._extract_preload_state(url)\n\n        tkn = data[\"users\"][\"currentUser\"][\"tkn\"]\n        cid = (data[\"sites\"][\"siteByUsername\"][self.user]\n               [\"site\"][\"siteCollectionId\"])\n\n        url = f\"{self.root}/api/2.0/collections/{cid}/medias\"\n        params = {\"page\": 2, \"size\": \"20\"}\n        return self._pagination(url, params, tkn, \"medias\", (\n            data[\"medias\"][\"byId\"][mid[\"id\"]][\"media\"]\n            for mid in data\n            [\"collections\"][\"byId\"][cid][\"1\"][\"collection\"]\n        ))\n\n\nclass VscoSpaceExtractor(VscoExtractor):\n    \"\"\"Extractor for a vsco.co space\"\"\"\n    subcategory = \"space\"\n    directory_fmt = (\"{category}\", \"space\", \"{user}\")\n    archive_fmt = \"s_{user}_{id}\"\n    pattern = BASE_PATTERN + r\"/spaces/([^/?#]+)\"\n    example = \"https://vsco.co/spaces/a1b2c3d4e5f\"\n\n    def images(self):\n        url = f\"{self.root}/spaces/{self.user}\"\n        data = self._extract_preload_state(url)\n\n        tkn = data[\"users\"][\"currentUser\"][\"tkn\"]\n        sid = self.user\n\n        posts = data[\"entities\"][\"posts\"]\n        images = data[\"entities\"][\"postImages\"]\n        for post in posts.values():\n            post[\"image\"] = images[post[\"image\"]]\n\n        space = data[\"spaces\"][\"byId\"][sid]\n        space[\"postsList\"] = [posts[pid] for pid in space[\"postsList\"]]\n\n        url = f\"{self.root}/grpc/spaces/{sid}/posts\"\n        params = {}\n        return self._pagination(url, params, tkn, space)\n\n    def _pagination(self, url, params, token, data):\n        headers = {\n            \"Accept\"       : \"application/json\",\n            \"Referer\"      : f\"{self.root}/spaces/{self.user}\",\n            \"Content-Type\" : \"application/json\",\n            \"Authorization\": \"Bearer \" + token,\n        }\n\n        while True:\n            for post in data[\"postsList\"]:\n                post = self._transform_media(post[\"image\"])\n                post[\"upload_date\"] = post[\"upload_date\"][\"sec\"] * 1000\n                yield post\n\n            cursor = data[\"cursor\"]\n            if cursor.get(\"atEnd\"):\n                return\n            params[\"cursor\"] = cursor[\"postcursorcontext\"][\"postId\"]\n\n            data = self.request_json(url, params=params, headers=headers)\n\n\nclass VscoSpacesExtractor(VscoExtractor):\n    \"\"\"Extractor for a vsco.co user's spaces\"\"\"\n    subcategory = \"spaces\"\n    pattern = USER_PATTERN + r\"/spaces\"\n    example = \"https://vsco.co/USER/spaces\"\n\n    def items(self):\n        url = f\"{self.root}/{self.user}/spaces\"\n        data = self._extract_preload_state(url)\n\n        tkn = data[\"users\"][\"currentUser\"][\"tkn\"]\n        uid = data[\"sites\"][\"siteByUsername\"][self.user][\"site\"][\"userId\"]\n\n        headers = {\n            \"Accept\"       : \"application/json\",\n            \"Referer\"      : url,\n            \"Content-Type\" : \"application/json\",\n            \"Authorization\": \"Bearer \" + tkn,\n        }\n        # this would theoretically need to be paginated\n        url = f\"{self.root}/grpc/spaces/user/{uid}\"\n        data = self.request_json(url, headers=headers)\n\n        for space in data[\"spacesWithRoleList\"]:\n            space = space[\"space\"]\n            url = f\"{self.root}/spaces/{space['id']}\"\n            space[\"_extractor\"] = VscoSpaceExtractor\n            yield Message.Queue, url, space\n\n\nclass VscoAvatarExtractor(VscoExtractor):\n    \"\"\"Extractor for vsco.co user avatars\"\"\"\n    subcategory = \"avatar\"\n    pattern = USER_PATTERN + r\"/avatar\"\n    example = \"https://vsco.co/USER/avatar\"\n\n    def images(self):\n        url = f\"{self.root}/{self.user}/gallery\"\n        page = self.request(url).text\n        piid = text.extr(page, '\"profileImageId\":\"', '\"')\n\n        url = \"https://im.vsco.co/\" + piid\n        # needs GET request, since HEAD does not redirect to full URL\n        response = self.request(url, allow_redirects=False)\n\n        return ({\n            \"_id\"           : piid,\n            \"is_video\"      : False,\n            \"grid_name\"     : \"\",\n            \"upload_date\"   : 0,\n            \"responsive_url\": response.headers[\"Location\"],\n            \"video_url\"     : \"\",\n            \"image_meta\"    : None,\n            \"width\"         : 0,\n            \"height\"        : 0,\n        },)\n\n\nclass VscoImageExtractor(VscoExtractor):\n    \"\"\"Extractor for individual images on vsco.co\"\"\"\n    subcategory = \"image\"\n    pattern = USER_PATTERN + r\"/media/([0-9a-fA-F]+)\"\n    example = \"https://vsco.co/USER/media/0123456789abcdef\"\n\n    def images(self):\n        url = f\"{self.root}/{self.user}/media/{self.groups[1]}\"\n        data = self._extract_preload_state(url)\n        media = data[\"medias\"][\"byId\"].popitem()[1][\"media\"]\n        return (self._transform_media(media),)\n\n\nclass VscoVideoExtractor(VscoExtractor):\n    \"\"\"Extractor for vsco.co videos links\"\"\"\n    subcategory = \"video\"\n    pattern = USER_PATTERN + r\"/video/([^/?#]+)\"\n    example = \"https://vsco.co/USER/video/012345678-9abc-def0\"\n\n    def images(self):\n        url = f\"{self.root}/{self.user}/video/{self.groups[1]}\"\n        data = self._extract_preload_state(url)\n        media = data[\"medias\"][\"byId\"].popitem()[1][\"media\"]\n\n        return ({\n            \"_id\"           : media[\"id\"],\n            \"is_video\"      : True,\n            \"grid_name\"     : \"\",\n            \"upload_date\"   : media[\"createdDate\"],\n            \"responsive_url\": media[\"posterUrl\"],\n            \"video_url\"     : media.get(\"playbackUrl\"),\n            \"image_meta\"    : None,\n            \"width\"         : media[\"width\"],\n            \"height\"        : media[\"height\"],\n            \"description\"   : media[\"description\"],\n        },)\n"
  },
  {
    "path": "gallery_dl/extractor/wallhaven.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2018-2026 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://wallhaven.cc/\"\"\"\n\nfrom .common import Extractor, Message, Dispatch\nfrom .. import text\n\n\nclass WallhavenExtractor(Extractor):\n    \"\"\"Base class for wallhaven extractors\"\"\"\n    category = \"wallhaven\"\n    root = \"https://wallhaven.cc\"\n    filename_fmt = \"{category}_{id}_{resolution}.{extension}\"\n    archive_fmt = \"{id}\"\n    request_interval = 1.4\n\n    def _init(self):\n        self.api = WallhavenAPI(self)\n\n    def items(self):\n        metadata = self.metadata()\n        for wp in self.wallpapers():\n            self._transform(wp)\n            wp.update(metadata)\n            url = wp[\"url\"]\n            yield Message.Directory, \"\", wp\n            yield Message.Url, url, text.nameext_from_url(url, wp)\n\n    def wallpapers(self):\n        \"\"\"Return relevant 'wallpaper' objects\"\"\"\n\n    def metadata(self):\n        \"\"\"Return general metadata\"\"\"\n        return ()\n\n    def _transform(self, wp):\n        wp[\"url\"] = wp.pop(\"path\")\n        if \"tags\" in wp:\n            wp[\"tags\"] = [t[\"name\"] for t in wp[\"tags\"]]\n        wp[\"date\"] = self.parse_datetime_iso(wp.pop(\"created_at\"))\n        wp[\"width\"] = wp.pop(\"dimension_x\")\n        wp[\"height\"] = wp.pop(\"dimension_y\")\n        wp[\"wh_category\"] = wp[\"category\"]\n\n\nclass WallhavenSearchExtractor(WallhavenExtractor):\n    \"\"\"Extractor for search results on wallhaven.cc\"\"\"\n    subcategory = \"search\"\n    directory_fmt = (\"{category}\", \"{search[tags]}\")\n    archive_fmt = \"s_{search[q]}_{id}\"\n    pattern = r\"(?:https?://)?wallhaven\\.cc/search(?:/?\\?([^#]+))?\"\n    example = \"https://wallhaven.cc/search?q=QUERY\"\n\n    def __init__(self, match):\n        WallhavenExtractor.__init__(self, match)\n        self.params = text.parse_query(match[1])\n\n    def wallpapers(self):\n        return self.api.search(self.params)\n\n    def metadata(self):\n        return {\"search\": self.params}\n\n\nclass WallhavenCollectionExtractor(WallhavenExtractor):\n    \"\"\"Extractor for a collection on wallhaven.cc\"\"\"\n    subcategory = \"collection\"\n    directory_fmt = (\"{category}\", \"{username}\", \"{collection_id}\")\n    pattern = r\"(?:https?://)?wallhaven\\.cc/user/([^/?#]+)/favorites/(\\d+)\"\n    example = \"https://wallhaven.cc/user/USER/favorites/12345\"\n\n    def __init__(self, match):\n        WallhavenExtractor.__init__(self, match)\n        self.username, self.collection_id = match.groups()\n\n    def wallpapers(self):\n        return self.api.collection(self.username, self.collection_id)\n\n    def metadata(self):\n        return {\"username\": self.username, \"collection_id\": self.collection_id}\n\n\nclass WallhavenUserExtractor(Dispatch, WallhavenExtractor):\n    \"\"\"Extractor for a wallhaven user\"\"\"\n    pattern = r\"(?:https?://)?wallhaven\\.cc/user/([^/?#]+)/?$\"\n    example = \"https://wallhaven.cc/user/USER\"\n\n    def items(self):\n        base = f\"{self.root}/user/{self.groups[0]}/\"\n        return self._dispatch_extractors((\n            (WallhavenUploadsExtractor    , base + \"uploads\"),\n            (WallhavenCollectionsExtractor, base + \"favorites\"),\n        ), (\"uploads\",))\n\n\nclass WallhavenCollectionsExtractor(WallhavenExtractor):\n    \"\"\"Extractor for all collections of a wallhaven user\"\"\"\n    subcategory = \"collections\"\n    pattern = r\"(?:https?://)?wallhaven\\.cc/user/([^/?#]+)/favorites/?$\"\n    example = \"https://wallhaven.cc/user/USER/favorites\"\n\n    def __init__(self, match):\n        WallhavenExtractor.__init__(self, match)\n        self.username = match[1]\n\n    def items(self):\n        base = f\"{self.root}/user/{self.username}/favorites/\"\n        for collection in self.api.collections(self.username):\n            collection[\"_extractor\"] = WallhavenCollectionExtractor\n            url = base + str(collection[\"id\"])\n            yield Message.Queue, url, collection\n\n\nclass WallhavenUploadsExtractor(WallhavenExtractor):\n    \"\"\"Extractor for all uploads of a wallhaven user\"\"\"\n    subcategory = \"uploads\"\n    directory_fmt = (\"{category}\", \"{username}\")\n    archive_fmt = \"u_{username}_{id}\"\n    pattern = r\"(?:https?://)?wallhaven\\.cc/user/([^/?#]+)/uploads\"\n    example = \"https://wallhaven.cc/user/USER/uploads\"\n\n    def __init__(self, match):\n        WallhavenExtractor.__init__(self, match)\n        self.username = match[1]\n\n    def wallpapers(self):\n        params = {\"q\": \"@\" + self.username}\n        return self.api.search(params)\n\n    def metadata(self):\n        return {\"username\": self.username}\n\n\nclass WallhavenImageExtractor(WallhavenExtractor):\n    \"\"\"Extractor for individual wallpaper on wallhaven.cc\"\"\"\n    subcategory = \"image\"\n    pattern = (r\"(?:https?://)?(?:wallhaven\\.cc/w/|whvn\\.cc/\"\n               r\"|w\\.wallhaven\\.cc/[a-z]+/\\w\\w/wallhaven-)(\\w+)\")\n    example = \"https://wallhaven.cc/w/ID\"\n\n    def __init__(self, match):\n        WallhavenExtractor.__init__(self, match)\n        self.wallpaper_id = match[1]\n\n    def wallpapers(self):\n        return (self.api.info(self.wallpaper_id),)\n\n\nclass WallhavenAPI():\n    \"\"\"Interface for wallhaven's API\n\n    Ref: https://wallhaven.cc/help/api\n    \"\"\"\n\n    def __init__(self, extractor):\n        self.extractor = extractor\n\n        key = extractor.config(\"api-key\")\n        if key is None:\n            key = \"25HYZenXTICjzBZXzFSg98uJtcQVrDs2\"\n            extractor.log.debug(\"Using default API Key\")\n        else:\n            extractor.log.debug(\"Using custom API Key\")\n        self.headers = {\"X-API-Key\": key}\n\n    def info(self, wallpaper_id):\n        endpoint = \"/v1/w/\" + wallpaper_id\n        return self._call(endpoint)[\"data\"]\n\n    def collection(self, username, collection_id):\n        endpoint = f\"/v1/collections/{username}/{collection_id}\"\n        return self._pagination(endpoint)\n\n    def collections(self, username):\n        endpoint = \"/v1/collections/\" + username\n        return self._pagination(endpoint, metadata=False)\n\n    def search(self, params):\n        endpoint = \"/v1/search\"\n        return self._pagination(endpoint, params)\n\n    def _call(self, endpoint, params=None):\n        url = \"https://wallhaven.cc/api\" + endpoint\n\n        while True:\n            response = self.extractor.request(\n                url, params=params, headers=self.headers, fatal=None)\n\n            if response.status_code < 400:\n                return response.json()\n            if response.status_code == 429:\n                self.extractor.wait(seconds=60)\n                continue\n\n            self.extractor.log.debug(\"Server response: %s\", response.text)\n            raise self.extractor.exc.AbortExtraction(\n                f\"API request failed \"\n                f\"({response.status_code} {response.reason})\")\n\n    def _pagination(self, endpoint, params=None, metadata=None):\n        if params is None:\n            params_ptr = None\n            params = {}\n        else:\n            params_ptr = params\n            params = params.copy()\n        if metadata is None:\n            metadata = self.extractor.config(\"metadata\")\n\n        while True:\n            data = self._call(endpoint, params)\n\n            meta = data.get(\"meta\")\n            if params_ptr is not None:\n                if meta and \"query\" in meta:\n                    query = meta[\"query\"]\n                    if isinstance(query, dict):\n                        params_ptr[\"tags\"] = query.get(\"tag\")\n                        params_ptr[\"tag_id\"] = query.get(\"id\")\n                    else:\n                        params_ptr[\"tags\"] = query\n                        params_ptr[\"tag_id\"] = 0\n                params_ptr = None\n\n            if metadata:\n                for wp in data[\"data\"]:\n                    yield self.info(str(wp[\"id\"]))\n            else:\n                yield from data[\"data\"]\n\n            if not meta or meta[\"current_page\"] >= meta[\"last_page\"]:\n                return\n            params[\"page\"] = meta[\"current_page\"] + 1\n"
  },
  {
    "path": "gallery_dl/extractor/wallpapercave.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2021 David Hoppenbrouwers\n# Copyright 2023-2025 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://wallpapercave.com/\"\"\"\n\nfrom .common import Extractor, Message\nfrom .. import text\n\n\nclass WallpapercaveImageExtractor(Extractor):\n    \"\"\"Extractor for images on wallpapercave.com\"\"\"\n    category = \"wallpapercave\"\n    subcategory = \"image\"\n    root = \"https://wallpapercave.com\"\n    pattern = r\"(?:https?://)?(?:www\\.)?wallpapercave\\.com/\"\n    example = \"https://wallpapercave.com/w/wp12345\"\n\n    def items(self):\n        page = self.request(text.ensure_http_scheme(self.url)).text\n\n        path = None\n        for path in text.extract_iter(page, 'class=\"download\" href=\"', '\"'):\n            image = text.nameext_from_url(path)\n            yield Message.Directory, \"\", image\n            yield Message.Url, self.root + path, image\n\n        if path is None:\n            try:\n                path = text.rextr(\n                    page, 'href=\"', '\"', page.index('id=\"tdownload\"'), None)\n            except Exception:\n                pass\n            else:\n                image = text.nameext_from_url(path)\n                yield Message.Directory, \"\", image\n                yield Message.Url, self.root + path, image\n\n        if path is None:\n            for wp in text.extract_iter(\n                    page, 'class=\"wallpaper\" id=\"wp', '</picture>'):\n                if path := text.rextr(wp, ' src=\"', '\"'):\n                    image = text.nameext_from_url(path)\n                    yield Message.Directory, \"\", image\n                    yield Message.Url, self.root + path, image\n"
  },
  {
    "path": "gallery_dl/extractor/warosu.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2017-2023 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://warosu.org/\"\"\"\n\nfrom .common import Extractor, Message\nfrom .. import text\n\n\nclass WarosuThreadExtractor(Extractor):\n    \"\"\"Extractor for threads on warosu.org\"\"\"\n    category = \"warosu\"\n    subcategory = \"thread\"\n    root = \"https://warosu.org\"\n    directory_fmt = (\"{category}\", \"{board}\", \"{thread} - {title}\")\n    filename_fmt = \"{tim} {filename}.{extension}\"\n    archive_fmt = \"{board}_{thread}_{tim}\"\n    pattern = r\"(?:https?://)?(?:www\\.)?warosu\\.org/([^/]+)/thread/(\\d+)\"\n    example = \"https://warosu.org/a/thread/12345\"\n\n    def __init__(self, match):\n        Extractor.__init__(self, match)\n        self.board, self.thread = match.groups()\n\n    def items(self):\n        url = f\"{self.root}/{self.board}/thread/{self.thread}\"\n        page = self.request(url).text\n        data = self.metadata(page)\n        posts = self.posts(page)\n\n        if not data[\"title\"]:\n            data[\"title\"] = text.unescape(text.remove_html(\n                posts[0][\"com\"]))[:50]\n\n        yield Message.Directory, \"\", data\n        for post in posts:\n            if \"image\" in post:\n                for key in (\"w\", \"h\", \"no\", \"time\", \"tim\"):\n                    post[key] = text.parse_int(post[key])\n                dt = self.parse_timestamp(post[\"time\"])\n                # avoid zero-padding 'day' with %d\n                post[\"now\"] = dt.strftime(f\"%a, %b {dt.day}, %Y %H:%M:%S\")\n                post.update(data)\n                yield Message.Url, post[\"image\"], post\n\n    def metadata(self, page):\n        boardname = text.extr(page, \"<title>\", \"</title>\")\n        title = text.unescape(text.extr(page, \"class=filetitle>\", \"<\"))\n        return {\n            \"board\"     : self.board,\n            \"board_name\": boardname.split(\" - \")[1],\n            \"thread\"    : self.thread,\n            \"title\"     : title,\n        }\n\n    def posts(self, page):\n        \"\"\"Build a list of all post objects\"\"\"\n        page = text.extr(page, \"<div class=content\", \"</form>\")\n        needle = \"<table>\"\n        return [self.parse(post) for post in page.split(needle)]\n\n    def parse(self, post):\n        \"\"\"Build post object by extracting data from an HTML post\"\"\"\n        data = self._extract_post(post)\n        if '<span class=\"fileinfo' in post and \\\n                self._extract_image(post, data):\n            part = data[\"image\"].rpartition(\"/\")[2]\n            data[\"tim\"], _, data[\"extension\"] = part.partition(\".\")\n            data[\"ext\"] = \".\" + data[\"extension\"]\n        return data\n\n    def _extract_post(self, post):\n        extr = text.extract_from(post)\n        return {\n            \"no\"  : extr(\"id=p\", \">\"),\n            \"name\": extr(\"class=postername>\", \"<\").strip(),\n            \"time\": extr(\"class=posttime title=\", \"000>\"),\n            \"com\" : text.unescape(text.remove_html(extr(\n                \"<blockquote>\", \"</blockquote>\").strip())),\n        }\n\n    def _extract_image(self, post, data):\n        extr = text.extract_from(post)\n        extr('<span class=\"fileinfo\">', \"\")\n        data[\"fsize\"] = extr(\"File: \", \", \")\n        data[\"w\"] = extr(\"\", \"x\")\n        data[\"h\"] = extr(\"\", \", \")\n        data[\"filename\"] = text.unquote(extr(\n            \"\", \"<\").rstrip().rpartition(\".\")[0])\n        extr(\"<br>\", \"\")\n\n        if url := extr(\"<a href=\", \">\"):\n            if url[0] == \"/\":\n                data[\"image\"] = self.root + url\n            elif \"warosu.\" not in url:\n                return False\n            else:\n                data[\"image\"] = url\n            return True\n        return False\n"
  },
  {
    "path": "gallery_dl/extractor/weasyl.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://www.weasyl.com/\"\"\"\n\nfrom .common import Extractor, Message\nfrom .. import text, util\n\nBASE_PATTERN = r\"(?:https://)?(?:www\\.)?weasyl.com/\"\n\n\nclass WeasylExtractor(Extractor):\n    category = \"weasyl\"\n    directory_fmt = (\"{category}\", \"{owner_login}\")\n    filename_fmt = \"{submitid} {title}.{extension}\"\n    archive_fmt = \"{submitid}\"\n    root = \"https://www.weasyl.com\"\n    useragent = util.USERAGENT_GALLERYDL\n\n    def populate_submission(self, data):\n        # Some submissions don't have content and can be skipped\n        if \"submission\" in data[\"media\"]:\n            data[\"url\"] = data[\"media\"][\"submission\"][0][\"url\"]\n            data[\"date\"] = self.parse_datetime_iso(data[\"posted_at\"][:19])\n            text.nameext_from_url(data[\"url\"], data)\n            return True\n        return False\n\n    def _init(self):\n        self.session.headers['X-Weasyl-API-Key'] = self.config(\"api-key\")\n\n    def request_submission(self, submitid):\n        return self.request_json(\n            f\"{self.root}/api/submissions/{submitid}/view\")\n\n    def retrieve_journal(self, journalid):\n        data = self.request_json(\n            f\"{self.root}/api/journals/{journalid}/view\")\n        data[\"extension\"] = \"html\"\n        data[\"html\"] = \"text:\" + data[\"content\"]\n        data[\"date\"] = self.parse_datetime_iso(data[\"posted_at\"])\n        return data\n\n    def submissions(self, owner_login, folderid=None):\n        metadata = self.config(\"metadata\")\n        url = f\"{self.root}/api/users/{owner_login}/gallery\"\n        params = {\n            \"nextid\"  : None,\n            \"folderid\": folderid,\n        }\n\n        while True:\n            data = self.request_json(url, params=params)\n            for submission in data[\"submissions\"]:\n                if metadata:\n                    submission = self.request_submission(\n                        submission[\"submitid\"])\n                if self.populate_submission(submission):\n                    submission[\"folderid\"] = folderid\n                    # Do any submissions have more than one url? If so\n                    # a urllist of the submission array urls would work.\n                    yield Message.Url, submission[\"url\"], submission\n            if not data[\"nextid\"]:\n                return\n            params[\"nextid\"] = data[\"nextid\"]\n\n\nclass WeasylSubmissionExtractor(WeasylExtractor):\n    subcategory = \"submission\"\n    pattern = BASE_PATTERN + r\"(?:~[\\w~-]+/submissions|submission|view)/(\\d+)\"\n    example = \"https://www.weasyl.com/~USER/submissions/12345/TITLE\"\n\n    def __init__(self, match):\n        WeasylExtractor.__init__(self, match)\n        self.submitid = match[1]\n\n    def items(self):\n        data = self.request_submission(self.submitid)\n        if self.populate_submission(data):\n            yield Message.Directory, \"\", data\n            yield Message.Url, data[\"url\"], data\n\n\nclass WeasylSubmissionsExtractor(WeasylExtractor):\n    subcategory = \"submissions\"\n    pattern = BASE_PATTERN + r\"(?:~|submissions/)([\\w~-]+)/?$\"\n    example = \"https://www.weasyl.com/submissions/USER\"\n\n    def __init__(self, match):\n        WeasylExtractor.__init__(self, match)\n        self.owner_login = match[1]\n\n    def items(self):\n        yield Message.Directory, \"\", {\"owner_login\": self.owner_login}\n        yield from self.submissions(self.owner_login)\n\n\nclass WeasylFolderExtractor(WeasylExtractor):\n    subcategory = \"folder\"\n    directory_fmt = (\"{category}\", \"{owner_login}\", \"{folder_name}\")\n    pattern = BASE_PATTERN + r\"submissions/([\\w~-]+)\\?folderid=(\\d+)\"\n    example = \"https://www.weasyl.com/submissions/USER?folderid=12345\"\n\n    def __init__(self, match):\n        WeasylExtractor.__init__(self, match)\n        self.owner_login, self.folderid = match.groups()\n\n    def items(self):\n        iter = self.submissions(self.owner_login, self.folderid)\n        # Folder names are only on single submission api calls\n        msg, url, data = next(iter)\n        details = self.request_submission(data[\"submitid\"])\n        yield Message.Directory, \"\", details\n        yield msg, url, data\n        yield from iter\n\n\nclass WeasylJournalExtractor(WeasylExtractor):\n    subcategory = \"journal\"\n    filename_fmt = \"{journalid} {title}.{extension}\"\n    archive_fmt = \"{journalid}\"\n    pattern = BASE_PATTERN + r\"journal/(\\d+)\"\n    example = \"https://www.weasyl.com/journal/12345\"\n\n    def __init__(self, match):\n        WeasylExtractor.__init__(self, match)\n        self.journalid = match[1]\n\n    def items(self):\n        data = self.retrieve_journal(self.journalid)\n        yield Message.Directory, \"\", data\n        yield Message.Url, data[\"html\"], data\n\n\nclass WeasylJournalsExtractor(WeasylExtractor):\n    subcategory = \"journals\"\n    filename_fmt = \"{journalid} {title}.{extension}\"\n    archive_fmt = \"{journalid}\"\n    pattern = BASE_PATTERN + r\"journals/([\\w~-]+)\"\n    example = \"https://www.weasyl.com/journals/USER\"\n\n    def __init__(self, match):\n        WeasylExtractor.__init__(self, match)\n        self.owner_login = match[1]\n\n    def items(self):\n        yield Message.Directory, \"\", {\"owner_login\": self.owner_login}\n\n        url = f\"{self.root}/journals/{self.owner_login}\"\n        page = self.request(url).text\n        for journalid in text.extract_iter(page, 'href=\"/journal/', '/'):\n            data = self.retrieve_journal(journalid)\n            yield Message.Url, data[\"html\"], data\n\n\nclass WeasylFavoriteExtractor(WeasylExtractor):\n    subcategory = \"favorite\"\n    directory_fmt = (\"{category}\", \"{user}\", \"Favorites\")\n    pattern = BASE_PATTERN + r\"favorites(?:\\?userid=(\\d+)|/([^/?#]+))\"\n    example = \"https://www.weasyl.com/favorites?userid=12345\"\n\n    def items(self):\n        userid, username = self.groups\n        owner_login = lastid = None\n\n        if username:\n            owner_login = username\n            path = \"/favorites/\" + username\n        else:\n            path = \"/favorites\"\n        params = {\n            \"userid\" : userid,\n            \"feature\": \"submit\",\n        }\n\n        while True:\n            page = self.request(self.root + path, params=params).text\n            pos = page.index('id=\"favorites-content\"')\n\n            if not owner_login:\n                owner_login = text.extr(page, '<a href=\"/~', '\"')\n\n            for submitid in text.extract_iter(page, \"/submissions/\", \"/\", pos):\n                if submitid == lastid:\n                    continue\n                lastid = submitid\n\n                submission = self.request_submission(submitid)\n                if self.populate_submission(submission):\n                    submission[\"user\"] = owner_login\n                    yield Message.Directory, \"\", submission\n                    yield Message.Url, submission[\"url\"], submission\n\n            try:\n                pos = page.index('\">Next (', pos)\n            except ValueError:\n                return\n            path = text.unescape(text.rextr(page, 'href=\"', '\"', pos))\n            params = None\n"
  },
  {
    "path": "gallery_dl/extractor/webmshare.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2022-2025 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://webmshare.com/\"\"\"\n\nfrom .common import Extractor, Message\nfrom .. import text\n\n\nclass WebmshareVideoExtractor(Extractor):\n    \"\"\"Extractor for webmshare videos\"\"\"\n    category = \"webmshare\"\n    subcategory = \"video\"\n    root = \"https://webmshare.com\"\n    filename_fmt = \"{id}{title:? //}.{extension}\"\n    archive_fmt = \"{id}\"\n    pattern = (r\"(?:https?://)?(?:s\\d+\\.)?webmshare\\.com\"\n               r\"/(?:play/|download-webm/)?(\\w{3,})\")\n    example = \"https://webmshare.com/_ID_\"\n\n    def __init__(self, match):\n        Extractor.__init__(self, match)\n        self.video_id = match[1]\n\n    def items(self):\n        url = f\"{self.root}/{self.video_id}\"\n        extr = text.extract_from(self.request(url).text)\n\n        data = {\n            \"title\": text.unescape(extr(\n                'property=\"og:title\" content=\"', '\"').rpartition(\" — \")[0]),\n            \"thumb\": \"https:\" + extr('property=\"og:image\" content=\"', '\"'),\n            \"url\"  : \"https:\" + extr('property=\"og:video\" content=\"', '\"'),\n            \"width\": text.parse_int(extr(\n                'property=\"og:video:width\" content=\"', '\"')),\n            \"height\": text.parse_int(extr(\n                'property=\"og:video:height\" content=\"', '\"')),\n            \"date\" : self.parse_datetime(extr(\n                \"<small>Added \", \"<\"), \"%B %d, %Y\"),\n            \"views\": text.parse_int(extr('glyphicon-eye-open\"></span>', '<')),\n            \"id\"       : self.video_id,\n            \"filename\" : self.video_id,\n            \"extension\": \"webm\",\n        }\n\n        if data[\"title\"] == \"webmshare\":\n            data[\"title\"] = \"\"\n\n        yield Message.Directory, \"\", data\n        yield Message.Url, data[\"url\"], data\n"
  },
  {
    "path": "gallery_dl/extractor/webtoons.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2020 Leonardo Taccari\n# Copyright 2021-2025 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://www.webtoons.com/\"\"\"\n\nfrom .common import GalleryExtractor, Extractor, Message\nfrom .. import text, util\n\nBASE_PATTERN = r\"(?:https?://)?(?:www\\.)?webtoons\\.com\"\nLANG_PATTERN = BASE_PATTERN + r\"/(([^/?#]+)\"\n\n\nclass WebtoonsBase():\n    category = \"webtoons\"\n    root = \"https://www.webtoons.com\"\n    directory_fmt = (\"{category}\", \"{comic}\")\n    filename_fmt = \"{episode_no}-{num:>02}{type:?-//}.{extension}\"\n    archive_fmt = \"{title_no}_{episode_no}_{num}\"\n    cookies_domain = \".webtoons.com\"\n    request_interval = (0.5, 1.5)\n\n    def setup_agegate_cookies(self):\n        self.cookies_update({\n            \"atGDPR\"     : \"AD_CONSENT\",\n            \"needCCPA\"   : \"false\",\n            \"needCOPPA\"  : \"false\",\n            \"needGDPR\"   : \"false\",\n            \"pagGDPR\"    : \"true\",\n            \"ageGatePass\": \"true\",\n        })\n\n    _init = setup_agegate_cookies\n\n    def request(self, url, **kwargs):\n        response = Extractor.request(self, url, **kwargs)\n        if response.history and \"/ageGate\" in response.url:\n            raise self.exc.AbortExtraction(\n                f\"HTTP redirect to age gate check ('{response.url}')\")\n        return response\n\n\nclass WebtoonsEpisodeExtractor(WebtoonsBase, GalleryExtractor):\n    \"\"\"Extractor for an episode on webtoons.com\"\"\"\n    subcategory = \"episode\"\n    pattern = (LANG_PATTERN + r\"/([^/?#]+)/([^/?#]+)/[^/?#]+)\"\n               r\"/viewer\\?([^#'\\\"]+)\")\n    example = (\"https://www.webtoons.com/en/GENRE/TITLE/NAME/viewer\"\n               \"?title_no=123&episode_no=12345\")\n    images_urls = []\n\n    def _init(self):\n        self.setup_agegate_cookies()\n\n        base, self.lang, self.genre, self.comic, query = self.groups\n        params = text.parse_query(query)\n        self.title_no = params.get(\"title_no\")\n        self.episode_no = params.get(\"episode_no\")\n        self.page_url = f\"{self.root}/{base}/viewer?{query}\"\n        self.bgm = self.config(\"bgm\", True)\n\n    def metadata(self, page):\n        extr = text.extract_from(page)\n        title = extr('<meta property=\"og:title\" content=\"', '\"')\n        descr = extr('<meta property=\"og:description\" content=\"', '\"')\n\n        if extr('<div class=\"subj_info\"', '\\n'):\n            comic_name = extr(\">\", \"<\")\n            episode_name = extr('<h1 class=\"subj_episode\" title=\"', '\"')\n        else:\n            comic_name = episode_name = \"\"\n\n        if extr('<span class=\"tx _btnOpenEpisodeLis', '\"'):\n            episode = extr(\">#\", \"<\")\n        else:\n            episode = \"\"\n\n        if extr('<span class=\"author\"', \"\\n\"):\n            username = extr(\"/u/\", '\"')\n            author_name = extr(\"<span>\", \"</span>\")\n        else:\n            username = author_name = \"\"\n\n        return {\n            \"genre\"       : self.genre,\n            \"comic\"       : self.comic,\n            \"title_no\"    : self.title_no,\n            \"episode_no\"  : self.episode_no,\n            \"title\"       : text.unescape(title),\n            \"episode\"     : episode,\n            \"comic_name\"  : text.unescape(comic_name),\n            \"episode_name\": text.unescape(episode_name),\n            \"username\"    : username,\n            \"author_name\" : text.unescape(author_name),\n            \"description\" : text.unescape(descr),\n            \"lang\"        : self.lang,\n            \"language\"    : util.code_to_language(self.lang),\n        }\n\n    def images(self, page):\n        quality = self.config(\"quality\")\n        if quality is None or quality == \"original\":\n            quality = {\"jpg\": False, \"jpeg\": False, \"webp\": False}\n        elif not quality:\n            quality = None\n        elif isinstance(quality, str):\n            quality = {\"jpg\": quality, \"jpeg\": quality}\n        elif isinstance(quality, int):\n            quality = \"q\" + str(quality)\n            quality = {\"jpg\": quality, \"jpeg\": quality}\n        elif not isinstance(quality, dict):\n            quality = None\n\n        if self.bgm:\n            num = 0\n            self.paths = paths = {}\n        else:\n            num = None\n\n        results = []\n        for url in text.extract_iter(\n                page, 'class=\"_images\" data-url=\"', '\"'):\n\n            path, _, query = url.rpartition(\"?\")\n            if num is not None:\n                num += 1\n                paths[path[path.find(\"/\", 8):]] = num\n            if quality is not None:\n                type = quality.get(path.rpartition(\".\")[2].lower())\n                if type is False:\n                    url = path\n                elif type:\n                    url = f\"{path}?type={type}\"\n\n            results.append((_url(url), None))\n        return results\n\n    def assets(self, page):\n        assets = []\n\n        if self.config(\"thumbnails\", False):\n            active = text.extr(page, 'class=\"on', '</a>')\n            url = _url(text.extr(active, 'data-url=\"', '\"'))\n            assets.append({\"url\": url, \"type\": \"thumbnail\"})\n\n        if self.bgm:\n            if bgm := text.extr(page, \"episodeBgmList:\", \",\\n\"):\n                self._asset_bgm(assets, util.json_loads(bgm))\n\n        return assets\n\n    def _asset_bgm(self, assets, bgm_list):\n        import binascii\n        params = {\n            #  \"quality\"     : \"MIDDLE\",\n            \"quality\"     : \"HIGH\",  # no difference to 'MIDDLE'\n            \"acceptCodecs\": \"AAC,MP3\",\n        }\n        headers = {\n            \"Accept\"        : \"application/json\",\n            \"Content-Type\"  : \"application/json\",\n            \"Origin\"        : self.root,\n            \"Referer\"       : self.root + \"/\",\n            \"Sec-Fetch-Dest\": \"empty\",\n            \"Sec-Fetch-Mode\": \"cors\",\n            \"Sec-Fetch-Site\": \"cross-site\",\n        }\n        paths = self.paths\n\n        if isinstance(self.bgm, str):\n            remux = ext = self.bgm.lower()\n        else:\n            ext = \"mp4\"\n            remux = False\n\n        for bgm in bgm_list:\n            url = (f\"https://apis.naver.com/audiocweb/audiocplayogwweb/play\"\n                   f\"/audio/{bgm['audioId']}/hls/token\")\n            data = self.request_json(\n                url, params=params, headers=headers, interval=False)\n            token = data[\"result\"][\"playToken\"]\n            data = util.json_loads(binascii.a2b_base64(token).decode())\n            audio = data[\"audioInfo\"]\n            play = bgm.get(\"playImageUrl\", \"\")\n            stop = bgm.get(\"stopImageUrl\", \"\")\n\n            assets.append({\n                **bgm,\n                **audio,\n                \"num_play\": paths.get(play) or 0,\n                \"num_stop\": paths.get(stop) or 0,\n                \"filename_play\": play[play.rfind(\"/\")+1:play.rfind(\".\")],\n                \"filename_stop\": stop[stop.rfind(\"/\")+1:stop.rfind(\".\")],\n                \"extension\": ext,\n                \"type\": \"bgm\",\n                \"url\" : \"ytdl:\" + audio[\"url\"],\n                \"_ytdl_manifest\": audio[\"type\"].lower(),\n                \"_ytdl_manifest_remux\": remux,\n            })\n\n\nclass WebtoonsComicExtractor(WebtoonsBase, Extractor):\n    \"\"\"Extractor for an entire comic on webtoons.com\"\"\"\n    subcategory = \"comic\"\n    categorytransfer = True\n    filename_fmt = \"{type}.{extension}\"\n    archive_fmt = \"{title_no}_{type}\"\n    pattern = LANG_PATTERN + r\"/([^/?#]+)/([^/?#]+))/list\\?([^#]+)\"\n    example = \"https://www.webtoons.com/en/GENRE/TITLE/list?title_no=123\"\n\n    def items(self):\n        kw = self.kwdict\n        base, kw[\"lang\"], kw[\"genre\"], kw[\"comic\"], query = self.groups\n        params = text.parse_query(query)\n        kw[\"title_no\"] = title_no = text.parse_int(params.get(\"title_no\"))\n        kw[\"page\"] = page_no = text.parse_int(params.get(\"page\"), 1)\n\n        path = f\"/{base}/list?title_no={title_no}&page={page_no}\"\n        response = self.request(self.root + path)\n        if response.history:\n            parts = response.url.split(\"/\")\n            base = \"/\".join(parts[3:-1])\n        page = response.text\n\n        if self.config(\"banners\") and (asset := self._asset_banner(page)):\n            yield Message.Directory, \"\", asset\n            yield Message.Url, asset[\"url\"], asset\n\n        data = {\"_extractor\": WebtoonsEpisodeExtractor}\n        while True:\n            for url in self.get_episode_urls(page):\n                params = text.parse_query(url.rpartition(\"?\")[2])\n                data[\"episode_no\"] = text.parse_int(params.get(\"episode_no\"))\n                yield Message.Queue, url, data\n\n            kw[\"page\"] = page_no = page_no + 1\n            path = f\"/{base}/list?title_no={title_no}&page={page_no}\"\n            if path not in page:\n                return\n            page = self.request(self.root + path).text\n\n    def get_episode_urls(self, page):\n        \"\"\"Extract and return all episode urls in 'page'\"\"\"\n        page = text.extr(page, 'id=\"_listUl\"', \"</ul>\")\n        return [\n            match[0]\n            for match in WebtoonsEpisodeExtractor.pattern.finditer(page)\n        ]\n\n    def _asset_banner(self, page):\n        try:\n            pos = page.index('<span class=\"thmb')\n        except Exception:\n            return\n\n        url = _url(text.extract(page, 'src=\"', '\"', pos)[0])\n        return text.nameext_from_url(url, {\"url\": url, \"type\": \"banner\"})\n\n\nclass WebtoonsArtistExtractor(WebtoonsBase, Extractor):\n    \"\"\"Extractor for webtoons.com artists\"\"\"\n    subcategory = \"artist\"\n    pattern = BASE_PATTERN + r\"/p/community/([^/?#]+)/u/([^/?#]+)\"\n    example = \"https://www.webtoons.com/p/community/LANG/u/ARTIST\"\n\n    def items(self):\n        for comic in self.comics():\n            comic[\"_extractor\"] = WebtoonsComicExtractor\n            comic_url = self.root + comic[\"extra\"][\"episodeListPath\"]\n            yield Message.Queue, comic_url, comic\n\n    def comics(self):\n        lang, artist = self.groups\n        language = util.code_to_language(lang).upper()\n\n        url = f\"{self.root}/p/community/{lang}/u/{artist}\"\n        page = self.request(url).text\n        creator_id = text.extr(page, '\\\\\"creatorId\\\\\":\\\\\"', '\\\\')\n\n        url = f\"{self.root}/p/community/api/v1/creator/{creator_id}/titles\"\n        params = {\n            \"language\": language,\n            \"nextSize\": \"50\",\n        }\n        headers = {\n            \"language\": language,\n        }\n        data = self.request_json(url, params=params, headers=headers)\n\n        return data[\"result\"][\"titles\"]\n\n\ndef _url(url):\n    return url.replace(\"://webtoon-phinf.\", \"://swebtoon-phinf.\")\n"
  },
  {
    "path": "gallery_dl/extractor/weebcentral.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2025-2026 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://weebcentral.com/\"\"\"\n\nfrom .common import ChapterExtractor, MangaExtractor\nfrom .. import text\n\nBASE_PATTERN = r\"(?:https?://)?(?:www\\.)?weebcentral\\.com\"\n\n\nclass WeebcentralBase():\n    category = \"weebcentral\"\n    root = \"https://weebcentral.com\"\n    request_interval = (0.5, 1.5)\n\n    def _manga_info(self, manga_id):\n        url = f\"{self.root}/series/{manga_id}\"\n        page = self.request(url).text\n        extr = text.extract_from(page)\n\n        return {\n            \"manga_id\": manga_id,\n            \"lang\"    : \"en\",\n            \"language\": \"English\",\n            \"manga\"   : text.unescape(extr(\"<title>\", \" | Weeb Central\")),\n            \"author\"  : text.split_html(extr(\"<strong>Author\", \"</li>\"))[1::2],\n            \"tags\"    : text.split_html(extr(\"<strong>Tag\", \"</li>\"))[1::2],\n            \"type\"    : text.remove_html(extr(\"<strong>Type: \", \"</li>\")),\n            \"status\"  : text.remove_html(extr(\"<strong>Status: \", \"</li>\")),\n            \"release\" : text.remove_html(extr(\"<strong>Released: \", \"</li>\")),\n            \"official\": \">Yes\" in extr(\"<strong>Official Translatio\", \"</li>\"),\n            \"description\": text.unescape(text.remove_html(extr(\n                \"<strong>Description\", \"</li>\"))),\n        }\n\n\nclass WeebcentralChapterExtractor(WeebcentralBase, ChapterExtractor):\n    \"\"\"Extractor for manga chapters from weebcentral.com\"\"\"\n    pattern = BASE_PATTERN + r\"(/chapters/(\\w+))\"\n    example = \"https://weebcentral.com/chapters/01JHABCDEFGHIJKLMNOPQRSTUV\"\n\n    def metadata(self, page):\n        extr = text.extract_from(page)\n        manga_id = extr(\"'series_id': '\", \"'\")\n        chapter_type = extr(\"'chapter_type': '\", \"'\")\n        chapter, sep, minor = extr(\"'number': '\", \"'\").partition(\".\")\n\n        return {\n            **self.cache(self._manga_info, manga_id),\n            \"chapter\": text.parse_int(chapter),\n            \"chapter_id\": self.groups[1],\n            \"chapter_type\": chapter_type,\n            \"chapter_minor\": sep + minor,\n        }\n\n    def images(self, page):\n        referer = self.page_url\n        url = referer + \"/images\"\n        params = {\n            \"is_prev\"      : \"False\",\n            \"current_page\" : \"1\",\n            \"reading_style\": \"long_strip\",\n        }\n        headers = {\n            \"Accept\"        : \"*/*\",\n            \"Referer\"       : referer,\n            \"HX-Request\"    : \"true\",\n            \"HX-Current-URL\": referer,\n        }\n        page = self.request(url, params=params, headers=headers).text\n        extr = text.extract_from(page)\n\n        results = []\n        while True:\n            src = extr('src=\"', '\"')\n            if not src:\n                break\n            results.append((src, {\n                \"width\" : text.parse_int(extr('width=\"' , '\"')),\n                \"height\": text.parse_int(extr('height=\"', '\"')),\n            }))\n        return results\n\n\nclass WeebcentralMangaExtractor(WeebcentralBase, MangaExtractor):\n    \"\"\"Extractor for manga from weebcentral.com\"\"\"\n    chapterclass = WeebcentralChapterExtractor\n    pattern = BASE_PATTERN + r\"/series/(\\w+)\"\n    example = \"https://weebcentral.com/series/01J7ABCDEFGHIJKLMNOPQRSTUV/TITLE\"\n\n    def chapters(self, _):\n        manga_id = self.groups[0]\n        referer = f\"{self.root}/series/{manga_id}\"\n        url = referer + \"/full-chapter-list\"\n        headers = {\n            \"Accept\"        : \"*/*\",\n            \"Referer\"       : referer,\n            \"HX-Request\"    : \"true\",\n            \"HX-Target\"     : \"chapter-list\",\n            \"HX-Current-URL\": referer,\n        }\n        page = self.request(url, headers=headers).text\n        extr = text.extract_from(page)\n        data = self.cache(self._manga_info, manga_id)\n        base = self.root + \"/chapters/\"\n\n        results = []\n        while True:\n            chapter_id = extr(\"/chapters/\", '\"')\n            if not chapter_id:\n                break\n            type, _, chapter = extr('<span class=\"\">', \"<\").partition(\" \")\n            chapter, sep, minor = chapter.partition(\".\")\n\n            chapter = {\n                \"chapter_id\"   : chapter_id,\n                \"chapter\"      : text.parse_int(chapter),\n                \"chapter_minor\": sep + minor,\n                \"chapter_type\" : type,\n                \"date\"         : self.parse_datetime_iso(extr(\n                    ' datetime=\"', '\"')[:-5]),\n            }\n            chapter.update(data)\n            results.append((base + chapter_id, chapter))\n        return results\n"
  },
  {
    "path": "gallery_dl/extractor/weebdex.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2025-2026 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://weebdex.org/\"\"\"\n\nfrom .common import ChapterExtractor, MangaExtractor\nfrom .. import text\n\nBASE_PATTERN = r\"(?:https?://)?weebdex\\.org\"\n\n\nclass WeebdexBase():\n    \"\"\"Base class for weebdex extractors\"\"\"\n    category = \"weebdex\"\n    root = \"https://weebdex.org\"\n    root_api = \"https://api.weebdex.org\"\n    request_interval = 0.2  # 5 requests per second\n\n    def _init(self):\n        self.headers_api = {\n            \"Referer\": self.root + \"/\",\n            \"Origin\" : self.root,\n            \"Sec-Fetch-Dest\": \"empty\",\n            \"Sec-Fetch-Mode\": \"cors\",\n            \"Sec-Fetch-Site\": \"same-site\",\n        }\n\n    def _manga_info(self, mid):\n        url = f\"{self.root_api}/manga/{mid}\"\n        manga = self.request_json(url, headers=self.headers_api)\n        rel = manga[\"relationships\"]\n\n        return {\n            \"manga\"   : manga.get(\"title\"),\n            \"manga_id\": manga.get(\"id\"),\n            \"manga_date\": self.parse_datetime_iso(manga.get(\"created_at\")),\n            \"year\"    : manga.get(\"year\"),\n            \"status\"  : manga.get(\"status\"),\n            \"origin\"  : manga.get(\"language\"),\n            \"description\": manga.get(\"description\"),\n            \"demographic\": manga.get(\"demographic\"),\n            \"tags\"    : [f\"{t['group']}:{t['name']}\"\n                         for t in rel.get(\"tags\") or ()],\n            \"author\"  : [a[\"name\"] for a in rel.get(\"authors\") or ()],\n            \"artist\"  : [a[\"name\"] for a in rel.get(\"artists\") or ()],\n        }\n\n\nclass WeebdexChapterExtractor(WeebdexBase, ChapterExtractor):\n    \"\"\"Extractor for weebdex manga chapters\"\"\"\n    archive_fmt = \"{chapter_id}_{version}_{page}\"\n    pattern = BASE_PATTERN + r\"/chapter/(\\w+)\"\n    example = \"https://weebdex.org/chapter/ID/PAGE\"\n\n    def metadata(self, _):\n        cid = self.groups[0]\n        url = f\"{self.root_api}/chapter/{cid}\"\n        self.data = data = self.request_json(url, headers=self.headers_api)\n\n        rel = data.pop(\"relationships\")\n        try:\n            chapter, sep, minor = data[\"chapter\"].partition(\".\")\n        except Exception:\n            chapter = 0\n            sep = minor = \"\"\n\n        return {\n            **self.cache(self._manga_info, rel[\"manga\"][\"id\"]),\n            \"title\"   : data.get(\"title\", \"\"),\n            \"version\" : data.get(\"version\", 0),\n            \"volume\"  : text.parse_int(data.get(\"volume\")),\n            \"chapter\" : text.parse_int(chapter),\n            \"chapter_minor\": sep + minor,\n            \"chapter_id\"   : cid,\n            \"date\"         : self.parse_datetime_iso(data.get(\"created_at\")),\n            \"date_updated\" : self.parse_datetime_iso(data.get(\"updated_at\")),\n            \"lang\"    : data.get(\"language\"),\n            \"uploader\": rel[\"uploader\"][\"name\"] if \"uploader\" in rel else \"\",\n            \"group\"   : [g[\"name\"] for g in rel.get(\"groups\") or ()],\n        }\n\n    def images(self, _):\n        data = self.data\n        base = f\"{data['node']}/data/{data['id']}/\"\n\n        if self.config(\"data-saver\", False):\n            pages = data[\"data_optimized\"]\n            original = False\n        else:\n            pages = data[\"data\"]\n            original = True\n\n        return [\n            (base + page[\"name\"], {\n                \"width\" : page[\"dimensions\"][0],\n                \"height\": page[\"dimensions\"][1],\n                \"original\": original,\n            })\n            for page in pages\n        ]\n\n\nclass WeebdexMangaExtractor(WeebdexBase, MangaExtractor):\n    \"\"\"Extractor for weebdex manga\"\"\"\n    chapterclass = WeebdexChapterExtractor\n    reverse = False\n    pattern = BASE_PATTERN + r\"/title/(\\w+)(?:/[^/?#]+/?\\?([^#]+))?\"\n    example = \"https://weebdex.org/title/ID/SLUG\"\n\n    def chapters(self, page):\n        mid, qs = self.groups\n\n        params = text.parse_query(qs)\n        params.setdefault(\"limit\", 100)\n        params.setdefault(\"order\", \"asc\")\n        if \"tlang\" not in params:\n            params[\"tlang\"] = self.config(\"lang\", \"en\")\n\n        url = f\"{self.root_api}/manga/{mid}/chapters\"\n        base = self.root + \"/chapter/\"\n        manga = self.cache(self._manga_info, mid)\n        results = []\n\n        while True:\n            data = self.request_json(\n                url, params=params, headers=self.headers_api)\n\n            for ch in data[\"data\"]:\n                try:\n                    chapter, sep, minor = ch[\"chapter\"].partition(\".\")\n                except Exception:\n                    chapter = 0\n                    sep = minor = \"\"\n                ch[\"volume\"] = text.parse_int(ch.get(\"volume\"))\n                ch[\"chapter\"] = text.parse_int(chapter)\n                ch[\"chapter_minor\"] = sep + minor\n                ch.update(manga)\n                results.append((base + ch[\"id\"], ch))\n\n            if data[\"total\"] <= data[\"page\"] * params[\"limit\"]:\n                break\n            params[\"page\"] = data[\"page\"] + 1\n\n        return results\n"
  },
  {
    "path": "gallery_dl/extractor/weibo.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2019-2026 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://www.weibo.com/\"\"\"\n\nfrom .common import Extractor, Message, Dispatch\nfrom .. import text, util\nimport random\n\nBASE_PATTERN = r\"(?:https?://)?(?:www\\.|m\\.)?weibo\\.c(?:om|n)\"\nUSER_PATTERN = BASE_PATTERN + r\"/(?:(u|n|p(?:rofile)?)/)?([^/?#]+)(?:/home)?\"\n\n\nclass WeiboExtractor(Extractor):\n    category = \"weibo\"\n    directory_fmt = (\"{category}\", \"{user[screen_name]}\")\n    filename_fmt = \"{status[id]}_{num:>02}.{extension}\"\n    archive_fmt = \"{status[id]}_{num}\"\n    cookies_domain = \".weibo.com\"\n    cookies_names = (\"SUB\", \"SUBP\")\n    root = \"https://weibo.com\"\n    request_interval = (1.0, 2.0)\n\n    def __init__(self, match):\n        Extractor.__init__(self, match)\n        self._prefix = match[1]\n        self.user = match[2]\n\n    def _init(self):\n        self.livephoto = self.config(\"livephoto\", True)\n        self.retweets = self.config(\"retweets\", False)\n        self.longtext = self.config(\"text\", False)\n        self.videos = self.config(\"videos\", True)\n        self.movies = self.config(\"movies\", False)\n        self.gifs = self.config(\"gifs\", True)\n        self.gifs_video = (self.gifs == \"video\")\n\n        cookies = self.cache(\n            _cookie_cache, _key=None, _exp=365*86400, _mem=False)\n        if cookies is None:\n            self.logged_in = self.cookies_check(\n                self.cookies_names, self.cookies_domain)\n            return\n\n        domain = self.cookies_domain\n        cookies = {c.name: c for c in cookies if c.domain == domain}\n        for cookie in self.cookies:\n            if cookie.domain == domain and cookie.name in cookies:\n                del cookies[cookie.name]\n                if not cookies:\n                    self.logged_in = True\n                    return\n\n        self.logged_in = False\n        for cookie in cookies.values():\n            self.cookies.set_cookie(cookie)\n\n    def request(self, url, **kwargs):\n        response = Extractor.request(self, url, **kwargs)\n\n        if response.history:\n            if \"login.sina.com\" in response.url:\n                raise self.exc.AbortExtraction(\n                    f\"HTTP redirect to login page \"\n                    f\"({response.url.partition('?')[0]})\")\n            if \"passport.weibo.com\" in response.url:\n                self._sina_visitor_system(response)\n                response = Extractor.request(self, url, **kwargs)\n\n        return response\n\n    def items(self):\n        original_retweets = (self.retweets == \"original\")\n\n        for status in self.statuses():\n\n            if \"ori_mid\" in status and not self.retweets:\n                self.log.debug(\"Skipping %s (快转 retweet)\", status[\"id\"])\n                continue\n\n            if \"retweeted_status\" in status:\n                if not self.retweets:\n                    self.log.debug(\"Skipping %s (retweet)\", status[\"id\"])\n                    continue\n\n                # videos of the original post are in status\n                # images of the original post are in status[\"retweeted_status\"]\n                files = []\n                self._extract_status(status, files)\n                self._extract_status(status[\"retweeted_status\"], files)\n\n                if original_retweets:\n                    status = status[\"retweeted_status\"]\n            else:\n                files = []\n                self._extract_status(status, files)\n\n            if self.longtext and status.get(\"isLongText\") and \\\n                    status[\"text\"].endswith('class=\"expand\">展开</span>'):\n                status = self._status_by_id(status[\"id\"])\n\n            status[\"date\"] = self.parse_datetime(\n                status[\"created_at\"], \"%a %b %d %H:%M:%S %z %Y\")\n            status[\"count\"] = len(files)\n            yield Message.Directory, \"\", status\n\n            num = 0\n            for file in files:\n                url = file[\"url\"]\n                if not url:\n                    continue\n                if url.startswith(\"http:\"):\n                    url = \"https:\" + url[5:]\n                if \"filename\" not in file:\n                    text.nameext_from_url(url, file)\n                    if file[\"extension\"] == \"json\":\n                        file[\"extension\"] = \"mp4\"\n                if file[\"extension\"] == \"m3u8\":\n                    url = \"ytdl:\" + url\n                    file[\"_ytdl_manifest\"] = \"hls\"\n                    file[\"extension\"] = \"mp4\"\n                num += 1\n                file[\"status\"] = status\n                file[\"num\"] = num\n                yield Message.Url, url, file\n\n    def _extract_status(self, status, files):\n        if \"mix_media_info\" in status:\n            for item in status[\"mix_media_info\"][\"items\"]:\n                type = item.get(\"type\")\n                if type == \"video\":\n                    if self.videos:\n                        files.append(self._extract_video(\n                            item[\"data\"][\"media_info\"]))\n                elif type == \"pic\":\n                    files.append(item[\"data\"][\"largest\"].copy())\n                else:\n                    self.log.warning(\"Unknown media type '%s'\", type)\n            return\n\n        if pic_ids := status.get(\"pic_ids\"):\n            pics = status[\"pic_infos\"]\n            for pic_id in pic_ids:\n                pic = pics[pic_id]\n                pic_type = pic.get(\"type\")\n\n                if pic_type == \"gif\" and self.gifs:\n                    if self.gifs_video:\n                        files.append({\"url\": pic[\"video\"]})\n                    else:\n                        files.append(pic[\"largest\"].copy())\n\n                elif pic_type == \"livephoto\" and self.livephoto:\n                    files.append(pic[\"largest\"].copy())\n                    files.append({\"url\": pic[\"video\"]})\n\n                else:\n                    files.append(pic[\"largest\"].copy())\n\n        if \"page_info\" in status:\n            info = status[\"page_info\"]\n            if \"media_info\" in info and self.videos:\n                if info.get(\"type\") != \"5\" or self.movies:\n                    files.append(self._extract_video(info[\"media_info\"]))\n                else:\n                    self.log.debug(\"%s: Ignoring 'movie' video\", status[\"id\"])\n\n    def _extract_video(self, info):\n        if info.get(\"live_status\") == 1:\n            self.log.debug(\"Skipping ongoing live stream\")\n            return {\"url\": \"\"}\n\n        try:\n            media = max(info[\"playback_list\"],\n                        key=lambda m: m[\"meta\"][\"quality_index\"])\n        except Exception:\n            video = {\"url\": (info.get(\"replay_hd\") or\n                             info.get(\"stream_url_hd\") or\n                             info.get(\"stream_url\") or \"\")}\n        else:\n            video = media[\"play_info\"].copy()\n\n        if \"//wblive-out.\" in video[\"url\"] and \\\n                not text.ext_from_url(video[\"url\"]):\n            try:\n                video[\"url\"] = self.request_location(video[\"url\"])\n            except self.exc.HttpError as exc:\n                self.log.warning(\"%s: %s\", exc.__class__.__name__, exc)\n                video[\"url\"] = \"\"\n\n        return video\n\n    def _status_by_id(self, status_id):\n        url = (f\"{self.root}/ajax/statuses/show\"\n               f\"?id={status_id}&isGetLongText=true\")\n        return self.request_json(url)\n\n    def _user(self, user):\n        url = (f\"{self.root}/ajax/profile/info?\"\n               f\"{'screen_name' if self._prefix == 'n' else 'custom'}={user}\")\n        return self.request_json(url, interval=False)[\"data\"][\"user\"]\n\n    def _user_id(self):\n        user = self.user\n        if len(user) >= 10 and user.isdecimal():\n            return user[-10:]\n        else:\n            return self._user(user)[\"idstr\"]\n\n    def _pagination(self, endpoint, params,\n                    since_key=\"sinceid\", subalbums=None):\n        url = f\"{self.root}/ajax{endpoint}\"\n        headers = {\n            \"Accept\": \"application/json, text/plain, */*\",\n            \"X-Requested-With\": \"XMLHttpRequest\",\n            \"X-XSRF-TOKEN\": None,\n            \"Referer\": f\"{self.root}/u/{params['uid']}\",\n        }\n\n        while True:\n            response = self.request(url, params=params, headers=headers)\n            headers[\"X-XSRF-TOKEN\"] = response.cookies.get(\"XSRF-TOKEN\")\n\n            data = response.json()\n            if not data.get(\"ok\"):\n                self.log.debug(response.content)\n                if \"since_id\" not in params:  # first iteration\n                    raise self.exc.AbortExtraction(\n                        f'\"{data.get(\"msg\") or \"unknown error\"}\"')\n\n            try:\n                data = data[\"data\"]\n                statuses = data[\"list\"]\n            except KeyError:\n                return\n\n            if subalbums is not None:\n                subalbums = None\n                yield data.get(\"album_list\") or ()\n\n            yield from statuses\n\n            # videos, newvideo\n            if cursor := data.get(\"next_cursor\"):\n                if cursor == -1:\n                    return\n                params[\"cursor\"] = cursor\n                continue\n\n            # album\n            if \"since_id\" in data:\n                params[since_key] = since_id = data[\"since_id\"]\n                if not since_id:\n                    return\n                if \"page\" in params:\n                    params[\"page\"] += 1\n                continue\n\n            # home, article\n            if \"page\" in params:\n                if not statuses:\n                    return\n                params[\"page\"] += 1\n                continue\n\n            # feed, last album page\n            try:\n                params[\"since_id\"] = statuses[-1][\"id\"] - 1\n            except LookupError:\n                return\n\n    def _sina_visitor_system(self, response):\n        self.log.info(\"Sina Visitor System\")\n\n        passport_url = \"https://passport.weibo.com/visitor/genvisitor\"\n        headers = {\"Referer\": response.url}\n        data = {\n            \"cb\": \"gen_callback\",\n            \"fp\": '{\"os\":\"1\",\"browser\":\"Gecko109,0,0,0\",\"fonts\":\"undefined\",'\n                  '\"screenInfo\":\"1920*1080*24\",\"plugins\":\"\"}',\n        }\n\n        page = Extractor.request(\n            self, passport_url, method=\"POST\", headers=headers, data=data).text\n        data = util.json_loads(text.extr(page, \"(\", \");\"))[\"data\"]\n\n        passport_url = \"https://passport.weibo.com/visitor/visitor\"\n        params = {\n            \"a\"    : \"incarnate\",\n            \"t\"    : data[\"tid\"],\n            \"w\"    : \"3\" if data.get(\"new_tid\") else \"2\",\n            \"c\"    : f\"{data.get('confidence') or 100:>03}\",\n            \"gc\"   : \"\",\n            \"cb\"   : \"cross_domain\",\n            \"from\" : \"weibo\",\n            \"_rand\": random.random(),\n        }\n        response = Extractor.request(self, passport_url, params=params)\n        self.cache_update(\n            _cookie_cache, None, response.cookies, _exp=365*86400)\n\n\nclass WeiboUserExtractor(WeiboExtractor):\n    \"\"\"Extractor for weibo user profiles\"\"\"\n    subcategory = \"user\"\n    pattern = USER_PATTERN + r\"(?:$|#)\"\n    example = \"https://weibo.com/USER\"\n\n    # do NOT override 'initialize()'\n    # it is needed for 'self._user_id()'\n    # def initialize(self):\n    #     pass\n\n    def items(self):\n        base = f\"{self.root}/u/{self._user_id()}?tabtype=\"\n        return Dispatch._dispatch_extractors(self, (\n            (WeiboHomeExtractor    , base + \"home\"),\n            (WeiboFeedExtractor    , base + \"feed\"),\n            (WeiboVideosExtractor  , base + \"video\"),\n            (WeiboNewvideoExtractor, base + \"newVideo\"),\n            (WeiboArticleExtractor , base + \"article\"),\n            (WeiboAlbumExtractor   , base + \"album\"),\n        ), (\"feed\",), {\n            (\"album\", \"subalbums\", base + \"album-only\"),\n        })\n\n\nclass WeiboHomeExtractor(WeiboExtractor):\n    \"\"\"Extractor for weibo 'home' listings\"\"\"\n    subcategory = \"home\"\n    pattern = USER_PATTERN + r\"\\?tabtype=home\"\n    example = \"https://weibo.com/USER?tabtype=home\"\n\n    def statuses(self):\n        endpoint = \"/profile/myhot\"\n        params = {\"uid\": self._user_id(), \"page\": 1, \"feature\": \"2\"}\n        return self._pagination(endpoint, params)\n\n\nclass WeiboFeedExtractor(WeiboExtractor):\n    \"\"\"Extractor for weibo user feeds\"\"\"\n    subcategory = \"feed\"\n    pattern = USER_PATTERN + r\"\\?tabtype=feed\"\n    example = \"https://weibo.com/USER?tabtype=feed\"\n\n    def statuses(self):\n        endpoint = \"/statuses/mymblog\"\n        params = {\"uid\": self._user_id(), \"feature\": \"0\"}\n        if self.logged_in:\n            params[\"page\"] = 1\n        return self._pagination(endpoint, params)\n\n\nclass WeiboVideosExtractor(WeiboExtractor):\n    \"\"\"Extractor for weibo 'video' listings\"\"\"\n    subcategory = \"videos\"\n    pattern = USER_PATTERN + r\"\\?tabtype=video\"\n    example = \"https://weibo.com/USER?tabtype=video\"\n\n    def statuses(self):\n        endpoint = \"/profile/getprofilevideolist\"\n        params = {\"uid\": self._user_id()}\n\n        for status in self._pagination(endpoint, params):\n            yield status[\"video_detail_vo\"]\n\n\nclass WeiboNewvideoExtractor(WeiboExtractor):\n    \"\"\"Extractor for weibo 'newVideo' listings\"\"\"\n    subcategory = \"newvideo\"\n    pattern = USER_PATTERN + r\"\\?tabtype=newVideo\"\n    example = \"https://weibo.com/USER?tabtype=newVideo\"\n\n    def statuses(self):\n        endpoint = \"/profile/getWaterFallContent\"\n        params = {\"uid\": self._user_id()}\n        return self._pagination(endpoint, params)\n\n\nclass WeiboArticleExtractor(WeiboExtractor):\n    \"\"\"Extractor for weibo 'article' listings\"\"\"\n    subcategory = \"article\"\n    pattern = USER_PATTERN + r\"\\?tabtype=article\"\n    example = \"https://weibo.com/USER?tabtype=article\"\n\n    def statuses(self):\n        endpoint = \"/statuses/mymblog\"\n        params = {\"uid\": self._user_id(), \"page\": 1, \"feature\": \"10\"}\n        return self._pagination(endpoint, params)\n\n\nclass WeiboAlbumExtractor(WeiboExtractor):\n    \"\"\"Extractor for weibo 'album' listings\"\"\"\n    subcategory = \"album\"\n    pattern = USER_PATTERN + r\"\\?tabtype=album(?:[:_-]([^&#]+))?\"\n    example = \"https://weibo.com/USER?tabtype=album\"\n\n    def items(self):\n        subalbum = self.groups[2]\n\n        if not subalbum and not self.config(\"subalbums\", False):\n            return WeiboExtractor.items(self)\n\n        self.directory_fmt = (\"{category}\", \"{user[screen_name]}\",\n                              \"Album\", \"{subalbum[pic_title]|''}\")\n        self.filename_fmt = \"{filename}.{extension}\"\n        self.archive_fmt = \"{subalbum[pic_title]}_{pid}\"\n        return self.items_subalbum(subalbum)\n\n    def items_subalbum(self, subalbum):\n        user = self.kwdict[\"user\"] = self._user(self.user)\n        base = self.root + \"/ajax/common/download?pid=\"\n\n        for data, files in self.albums(user[\"idstr\"], subalbum):\n            self.kwdict[\"subalbum\"] = data\n            yield Message.Directory, \"\", {}\n            for file in files:\n                if \"pid\" in file:\n                    file[\"filename\"] = file[\"pid\"]\n                    file[\"extension\"] = \"jpg\"\n                    yield Message.Url, base + file[\"pid\"], file\n                elif \"mid\" in file:\n                    mid = file[\"mid\"]\n                    status = self._status_by_id(mid)\n                    if status.get(\"ok\") != 1:\n                        self.log.debug(\"Skipping status %s (%s)\", mid, status)\n                    else:\n                        self.statuses = lambda: (status,)\n                        yield from WeiboExtractor.items(self)\n                        yield Message.Directory, \"\", {}\n\n    def statuses(self):\n        endpoint = \"/profile/getImageWall\"\n        params = {\"uid\": self._user_id()}\n\n        seen = set()\n        for image in self._pagination(endpoint, params):\n            mid = image[\"mid\"]\n            if mid not in seen:\n                seen.add(mid)\n                status = self._status_by_id(mid)\n                if status.get(\"ok\") != 1:\n                    self.log.debug(\"Skipping status %s (%s)\", mid, status)\n                else:\n                    yield status\n\n    def albums(self, uid, subalbum):\n        endpoint = \"/profile/getImageWall\"\n        params = {\n            \"uid\"      : uid,\n            \"sinceid\"  : \"0\",\n            \"has_album\": \"true\",\n        }\n        album = self._pagination(endpoint, params, subalbums=True)\n        subalbums = next(album, ())\n\n        if not subalbum or subalbum == \"0\":\n            return (({}, album),)\n\n        if subalbum == \"all\":\n            results = [\n                (sub, self._pagination_subalbum(uid, sub))\n                for sub in subalbums\n            ]\n            results.append(({}, album))\n            return results\n\n        if subalbum == \"only\":\n            return [\n                (sub, self._pagination_subalbum(uid, sub))\n                for sub in subalbums\n            ]\n\n        if subalbum.isdecimal():\n            try:\n                sub = subalbums[int(subalbum)-1]\n            except Exception:\n                raise self.exc.NotFoundError(\"subalbum\")\n        else:\n            subalbum = text.unquote(subalbum)\n            for sub in subalbums:\n                if sub[\"pic_title\"] == subalbum:\n                    break\n            else:\n                raise self.exc.NotFoundError(\"subalbum\")\n        return ((sub, self._pagination_subalbum(uid, sub)),)\n\n    def _pagination_subalbum(self, uid, sub):\n        params = {\"uid\": uid, \"containerid\": text.unquote(sub[\"containerid\"])}\n        return self._pagination(\"/profile/getAlbumDetail\", params, \"since_id\")\n\n\nclass WeiboStatusExtractor(WeiboExtractor):\n    \"\"\"Extractor for a weibo status\"\"\"\n    subcategory = \"status\"\n    pattern = BASE_PATTERN + r\"/(detail|status|\\d+)/(\\w+)\"\n    example = \"https://weibo.com/detail/12345\"\n\n    def statuses(self):\n        status = self._status_by_id(self.user)\n        if status.get(\"ok\") != 1:\n            self.log.debug(status)\n            raise self.exc.NotFoundError(\"status\")\n        return (status,)\n\n\ndef _cookie_cache():\n    return None\n"
  },
  {
    "path": "gallery_dl/extractor/whyp.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2025-2026 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://whyp.it/\"\"\"\n\nfrom .common import Extractor, Message\nfrom .. import text\n\nBASE_PATTERN = r\"(?:https?://)?(?:www\\.)?whyp\\.it\"\n\n\nclass WhypExtractor(Extractor):\n    \"\"\"Base class for whyp extractors\"\"\"\n    category = \"whyp\"\n    root = \"https://whyp.it\"\n    root_api = \"https://api.whyp.it\"\n    directory_fmt = (\"{category}\", \"{user[username]} ({user[id]})\")\n    filename_fmt = \"{id} {title}.{extension}\"\n    archive_fmt = \"{id}\"\n\n    def _init(self):\n        self.headers_api = {\n            \"Accept\" : \"application/json\",\n            \"Origin\" : self.root,\n            \"Referer\": self.root + \"/\",\n            \"Sec-Fetch-Dest\": \"empty\",\n            \"Sec-Fetch-Mode\": \"cors\",\n            \"Sec-Fetch-Site\": \"same-site\",\n        }\n\n    def items(self):\n        for track in self.tracks():\n            if url := track.get(\"lossless_url\"):\n                track[\"original\"] = True\n            else:\n                url = track[\"lossy_url\"]\n                track[\"original\"] = False\n\n            if \"created_at\" in track:\n                track[\"date\"] = self.parse_datetime_iso(track[\"created_at\"])\n\n            yield Message.Directory, \"\", track\n            yield Message.Url, url, text.nameext_from_url(url, track)\n\n\nclass WhypAudioExtractor(WhypExtractor):\n    subcategory = \"audio\"\n    pattern = BASE_PATTERN + r\"/tracks/(\\d+)(?:/[^/?#]+)?/?(?:\\?([^#]+))?\"\n    example = \"https://whyp.it/tracks/12345/SLUG\"\n\n    def tracks(self):\n        tid, qs = self.groups\n        url = f\"{self.root_api}/api/tracks/{tid}\"\n        params = None if qs is None else text.parse_query(qs)\n        data = self.request_json(url, params=params, headers=self.headers_api)\n        return (data[\"track\"],)\n\n\nclass WhypUserExtractor(WhypExtractor):\n    subcategory = \"user\"\n    pattern = BASE_PATTERN + r\"/users/(\\d+)(?:/[^/?#]+)?/?(?:\\?([^#]+))?\"\n    example = \"https://whyp.it/users/123/NAME\"\n\n    def tracks(self):\n        uid, qs = self.groups\n\n        url = f\"{self.root_api}/api/users/{uid}/tracks\"\n        params = text.parse_query(qs)\n        headers = self.headers_api\n\n        while True:\n            data = self.request_json(url, params=params, headers=headers)\n\n            yield from data[\"tracks\"]\n\n            if not (cursor := data.get(\"next_cursor\")):\n                break\n            params[\"cursor\"] = cursor\n\n\nclass WhypCollectionExtractor(WhypExtractor):\n    subcategory = \"collection\"\n    pattern = BASE_PATTERN + r\"/collections/(\\d+)(?:/[^/?#]+)?/?(?:\\?([^#]+))?\"\n    example = \"https://whyp.it/collections/123/NAME\"\n\n    def tracks(self):\n        cid, qs = self.groups\n\n        url = f\"{self.root_api}/api/collections/{cid}\"\n        params = None if qs is None else text.parse_query(qs)\n        headers = self.headers_api\n        self.kwdict[\"collection\"] = collection = self.request_json(\n            url, params=params, headers=headers)[\"collection\"]\n\n        url = f\"{self.root_api}/api/collections/{cid}/tracks\"\n        params = {\"token\": collection[\"token\"]}\n        data = self.request_json(url, params=params, headers=headers)\n        return data[\"tracks\"]\n"
  },
  {
    "path": "gallery_dl/extractor/wikiart.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2019-2025 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://www.wikiart.org/\"\"\"\n\nfrom .common import Extractor, Message\nfrom .. import text\n\nBASE_PATTERN = r\"(?:https?://)?(?:www\\.)?wikiart\\.org/([a-z]+)\"\n\n\nclass WikiartExtractor(Extractor):\n    \"\"\"Base class for wikiart extractors\"\"\"\n    category = \"wikiart\"\n    filename_fmt = \"{id}_{title}.{extension}\"\n    archive_fmt = \"{id}\"\n    root = \"https://www.wikiart.org\"\n\n    def __init__(self, match):\n        Extractor.__init__(self, match)\n        self.lang = match[1]\n\n    def items(self):\n        data = self.metadata()\n        yield Message.Directory, \"\", data\n        for painting in self.paintings():\n            url = painting[\"image\"]\n            painting.update(data)\n            yield Message.Url, url, text.nameext_from_url(url, painting)\n\n    def metadata(self):\n        \"\"\"Return a dict with general metadata\"\"\"\n\n    def paintings(self):\n        \"\"\"Return an iterable containing all relevant 'painting' objects\"\"\"\n\n    def _pagination(self, url, extra_params=None, key=\"Paintings\", stop=False):\n        headers = {\n            \"X-Requested-With\": \"XMLHttpRequest\",\n            \"Referer\": url,\n        }\n        params = {\n            \"json\": \"2\",\n            \"layout\": \"new\",\n            \"page\": 1,\n            \"resultType\": \"masonry\",\n        }\n        if extra_params:\n            params.update(extra_params)\n\n        while True:\n            data = self.request_json(url, headers=headers, params=params)\n            items = data.get(key)\n            if not items:\n                return\n            yield from items\n            if stop:\n                return\n            params[\"page\"] += 1\n\n\nclass WikiartArtistExtractor(WikiartExtractor):\n    \"\"\"Extractor for an artist's paintings on wikiart.org\"\"\"\n    subcategory = \"artist\"\n    directory_fmt = (\"{category}\", \"{artist[artistName]}\")\n    pattern = BASE_PATTERN + r\"/(?!\\w+-by-)([\\w-]+)/?$\"\n    example = \"https://www.wikiart.org/en/ARTIST\"\n\n    def __init__(self, match):\n        WikiartExtractor.__init__(self, match)\n        self.artist_name = match[2]\n        self.artist = None\n\n    def metadata(self):\n        url = f\"{self.root}/{self.lang}/{self.artist_name}?json=2\"\n        self.artist = self.request_json(url)\n        return {\"artist\": self.artist}\n\n    def paintings(self):\n        url = f\"{self.root}/{self.lang}/{self.artist_name}/mode/all-paintings\"\n        return self._pagination(url)\n\n\nclass WikiartImageExtractor(WikiartArtistExtractor):\n    \"\"\"Extractor for individual paintings on wikiart.org\"\"\"\n    subcategory = \"image\"\n    pattern = BASE_PATTERN + r\"/(?!(?:paintings|artists)-by-)([\\w-]+)/([\\w-]+)\"\n    example = \"https://www.wikiart.org/en/ARTIST/TITLE\"\n\n    def __init__(self, match):\n        WikiartArtistExtractor.__init__(self, match)\n        self.title = match[3]\n\n    def paintings(self):\n        title, sep, year = self.title.rpartition(\"-\")\n        if not sep or not year.isdecimal():\n            title = self.title\n        url = (f\"{self.root}/{self.lang}/Search/\"\n               f\"{self.artist.get('artistName') or self.artist_name} {title}\")\n        return self._pagination(url, stop=True)\n\n\nclass WikiartArtworksExtractor(WikiartExtractor):\n    \"\"\"Extractor for artwork collections on wikiart.org\"\"\"\n    subcategory = \"artworks\"\n    directory_fmt = (\"{category}\", \"Artworks by {group!c}\", \"{type}\")\n    pattern = BASE_PATTERN + r\"/paintings-by-([\\w-]+)/([\\w-]+)\"\n    example = \"https://www.wikiart.org/en/paintings-by-GROUP/TYPE\"\n\n    def __init__(self, match):\n        WikiartExtractor.__init__(self, match)\n        self.group = match[2]\n        self.type = match[3]\n\n    def metadata(self):\n        return {\"group\": self.group, \"type\": self.type}\n\n    def paintings(self):\n        url = f\"{self.root}/{self.lang}/paintings-by-{self.group}/{self.type}\"\n        return self._pagination(url)\n\n\nclass WikiartArtistsExtractor(WikiartExtractor):\n    \"\"\"Extractor for artist collections on wikiart.org\"\"\"\n    subcategory = \"artists\"\n    pattern = (BASE_PATTERN + r\"/artists-by-([\\w-]+)/([\\w-]+)\")\n    example = \"https://www.wikiart.org/en/artists-by-GROUP/TYPE\"\n\n    def __init__(self, match):\n        WikiartExtractor.__init__(self, match)\n        self.group = match[2]\n        self.type = match[3]\n\n    def items(self):\n        url = f\"{self.root}/{self.lang}/App/Search/Artists-by-{self.group}\"\n        params = {\"json\": \"3\", \"searchterm\": self.type}\n\n        for artist in self._pagination(url, params, \"Artists\"):\n            artist[\"_extractor\"] = WikiartArtistExtractor\n            yield Message.Queue, self.root + artist[\"artistUrl\"], artist\n"
  },
  {
    "path": "gallery_dl/extractor/wikifeet.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://www.wikifeet.com/\"\"\"\n\nfrom .common import GalleryExtractor\nfrom .. import text, util\n\n\nclass WikifeetGalleryExtractor(GalleryExtractor):\n    \"\"\"Extractor for image galleries from wikifeet.com\"\"\"\n    category = \"wikifeet\"\n    directory_fmt = (\"{category}\", \"{celebrity}\")\n    filename_fmt = \"{category}_{celeb}_{pid}.{extension}\"\n    archive_fmt = \"{type}_{celeb}_{pid}\"\n    pattern = (r\"(?:https?://)(?:(?:www\\.)?wikifeetx?|\"\n               r\"men\\.wikifeet)\\.com/([^/?#]+)\")\n    example = \"https://www.wikifeet.com/CELEB\"\n\n    def __init__(self, match):\n        self.root = text.root_from_url(match[0])\n        if \"wikifeetx.com\" in self.root:\n            self.category = \"wikifeetx\"\n        self.type = \"men\" if \"://men.\" in self.root else \"women\"\n        self.celeb = match[1]\n        GalleryExtractor.__init__(self, match, self.root + \"/\" + self.celeb)\n\n    def metadata(self, page):\n        extr = text.extract_from(page)\n        return {\n            \"celeb\"     : self.celeb,\n            \"type\"      : self.type,\n            \"birthplace\": text.unescape(extr('\"bplace\":\"', '\"')),\n            \"birthday\"  : self.parse_datetime_iso(text.unescape(extr(\n                '\"bdate\":\"', '\"'))[:10]),\n            \"shoesize\"  : text.unescape(extr('\"ssize\":', ',')),\n            \"rating\"    : text.parse_float(extr('\"score\":', ',')),\n            \"celebrity\" : text.unescape(extr('\"cname\":\"', '\"')),\n        }\n\n    def images(self, page):\n        tagmap = {\n            \"C\": \"Close-up\",\n            \"T\": \"Toenails\",\n            \"N\": \"Nylons\",\n            \"A\": \"Arches\",\n            \"S\": \"Soles\",\n            \"B\": \"Barefoot\",\n        }\n\n        gallery = text.extr(page, '\"gallery\":[', '],')\n        base = f\"https://pics.wikifeet.com/{self.celeb}-Feet-\"\n        return [\n            (f\"{base}{data['pid']}.jpg\", {\n                \"pid\"   : data[\"pid\"],\n                \"width\" : data[\"pw\"],\n                \"height\": data[\"ph\"],\n                \"tags\"  : [\n                    tagmap[tag]\n                    for tag in data[\"tags\"] if tag in tagmap\n                ],\n            })\n            for data in util.json_loads(f\"[{gallery}]\")\n        ]\n"
  },
  {
    "path": "gallery_dl/extractor/wikimedia.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2022 Ailothaen\n# Copyright 2024-2026 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for Wikimedia sites\"\"\"\n\nfrom .common import BaseExtractor, Message\nfrom .. import text, util\n\n\nclass WikimediaExtractor(BaseExtractor):\n    \"\"\"Base class for wikimedia extractors\"\"\"\n    basecategory = \"wikimedia\"\n    filename_fmt = \"{filename} ({sha1[:8]}).{extension}\"\n    archive_fmt = \"{sha1}\"\n    useragent = util.USERAGENT_GALLERYDL\n    request_interval = (1.0, 2.0)\n\n    def __init__(self, match):\n        self._init_category(match)\n\n        self.format = False\n        if self.category == \"wikimedia\":\n            labels = self.root.split(\".\")\n            self.lang = labels[-3][-2:]\n            self.category = labels[-2]\n        elif self.category in {\"fandom\", \"wikigg\"}:\n            self.lang = \"en\"\n            self.format = \"original\"\n            self.basesubcategory = self.category\n            self.category = (\n                f\"{self.category}-\"\n                f\"{self.root.partition('.')[0].rpartition('/')[2]}\")\n        else:\n            self.lang = \"\"\n\n        if useragent := self.config_instance(\"useragent\"):\n            self.useragent = useragent\n\n        BaseExtractor.__init__(self, match)\n\n    def _init(self):\n        if api_path := self.config_instance(\"api-path\"):\n            if api_path[0] == \"/\":\n                self.api_url = self.root + api_path\n            else:\n                self.api_url = api_path\n        else:\n            self.api_url = None\n\n        # note: image revisions are different from page revisions\n        # ref:\n        # https://www.mediawiki.org/wiki/API:Revisions\n        # https://www.mediawiki.org/wiki/API:Imageinfo\n        self.image_revisions = self.config(\"image-revisions\", 1)\n        self.format = self.config(\"format\", self.format)\n        self.per_page = self.config(\"limit\", 50)\n        self.subcategories = False\n\n    def _search_api_path(self, root):\n        self.log.debug(\"Probing possible API endpoints\")\n        for path in (\"/api.php\", \"/w/api.php\", \"/wiki/api.php\"):\n            url = root + path\n            response = self.request(url, method=\"HEAD\", fatal=None)\n            if response.status_code < 400:\n                return url\n        raise self.exc.AbortExtraction(\"Unable to find API endpoint\")\n\n    def prepare_info(self, info):\n        \"\"\"Adjust the content of an image info object\"\"\"\n\n    def prepare_image(self, image):\n        \"\"\"Adjust the content of an image object\"\"\"\n        image[\"metadata\"] = {\n            m[\"name\"]: m[\"value\"]\n            for m in image[\"metadata\"] or ()}\n        image[\"commonmetadata\"] = {\n            m[\"name\"]: m[\"value\"]\n            for m in image[\"commonmetadata\"] or ()}\n\n        text.nameext_from_name(\n            image[\"canonicaltitle\"].partition(\":\")[2], image)\n        image[\"date\"] = self.parse_datetime_iso(image[\"timestamp\"])\n\n        if self.format:\n            url = image[\"url\"]\n            image[\"url\"] = (f\"{url}{'&' if '?' in url else '?'}\"\n                            f\"format={self.format}\")\n\n    def items(self):\n        params = self.params()\n\n        for info in self._pagination(params):\n            try:\n                images = info.pop(\"imageinfo\")\n            except KeyError:\n                self.log.debug(\"Missing 'imageinfo' for %s\", info)\n                images = ()\n\n            info[\"count\"] = len(images)\n            self.prepare_info(info)\n            yield Message.Directory, \"\", info\n\n            num = 0\n            for image in images:\n                # https://www.mediawiki.org/wiki/Release_notes/1.34\n                if \"filemissing\" in image:\n                    self.log.warning(\n                        \"File %s (or its revision) is missing\",\n                        image[\"canonicaltitle\"].partition(\":\")[2])\n                    continue\n                num += 1\n                image[\"num\"] = num\n                self.prepare_image(image)\n                image.update(info)\n                yield Message.Url, image[\"url\"], image\n\n        if self.subcategories:\n            base = self.root + \"/wiki/\"\n            params[\"gcmtype\"] = \"subcat\"\n            for subcat in self._pagination(params):\n                url = base + subcat[\"title\"].replace(\" \", \"_\")\n                subcat[\"_extractor\"] = WikimediaArticleExtractor\n                yield Message.Queue, url, subcat\n\n    def _pagination(self, params):\n        \"\"\"\n        https://www.mediawiki.org/wiki/API:Query\n        https://opendata.stackexchange.com/questions/13381\n        \"\"\"\n\n        url = self.api_url or \\\n            self.cache(self._search_api_path, self.root, _mem=False)\n\n        params[\"action\"] = \"query\"\n        params[\"format\"] = \"json\"\n        params[\"prop\"] = \"imageinfo\"\n        params[\"iiprop\"] = (\n            \"timestamp|user|userid|comment|canonicaltitle|url|size|\"\n            \"sha1|mime|metadata|commonmetadata|extmetadata|bitdepth\"\n        )\n        params[\"iilimit\"] = self.image_revisions\n\n        while True:\n            data = self.request_json(url, params=params)\n\n            # ref: https://www.mediawiki.org/wiki/API:Errors_and_warnings\n            if error := data.get(\"error\"):\n                self.log.error(\"%s: %s\", error[\"code\"], error[\"info\"])\n                return\n            # MediaWiki will emit warnings for non-fatal mistakes such as\n            # invalid parameter instead of raising an error\n            if warnings := data.get(\"warnings\"):\n                self.log.debug(\"MediaWiki returned warnings: %s\", warnings)\n\n            try:\n                pages = data[\"query\"][\"pages\"]\n            except KeyError:\n                pass\n            else:\n                yield from pages.values()\n\n            try:\n                continuation = data[\"continue\"]\n            except KeyError:\n                break\n            params.update(continuation)\n\n\nBASE_PATTERN = WikimediaExtractor.update({\n    \"wikimedia\": {\n        \"root\": None,\n        \"pattern\": r\"[a-z]{2,}\\.\"\n                   r\"wik(?:i(?:pedia|quote|books|source|news|versity|data\"\n                   r\"|voyage)|tionary)\"\n                   r\"\\.org\",\n        \"api-path\": \"/w/api.php\",\n    },\n    \"wikispecies\": {\n        \"root\": \"https://species.wikimedia.org\",\n        \"pattern\": r\"species\\.wikimedia\\.org\",\n        \"api-path\": \"/w/api.php\",\n    },\n    \"wikimediacommons\": {\n        \"root\": \"https://commons.wikimedia.org\",\n        \"pattern\": r\"commons\\.wikimedia\\.org\",\n        \"api-path\": \"/w/api.php\",\n    },\n    \"mediawiki\": {\n        \"root\": \"https://www.mediawiki.org\",\n        \"pattern\": r\"(?:www\\.)?mediawiki\\.org\",\n        \"api-path\": \"/w/api.php\",\n    },\n    \"fandom\": {\n        \"root\": None,\n        \"pattern\": r\"[\\w-]+\\.fandom\\.com\",\n        \"api-path\": \"/api.php\",\n    },\n    \"wikigg\": {\n        \"root\": None,\n        \"pattern\": r\"\\w+\\.wiki\\.gg\",\n        \"api-path\": \"/api.php\",\n    },\n    \"mariowiki\": {\n        \"root\": \"https://www.mariowiki.com\",\n        \"pattern\": r\"(?:www\\.)?mariowiki\\.com\",\n        \"api-path\": \"/api.php\",\n    },\n    \"bulbapedia\": {\n        \"root\": \"https://bulbapedia.bulbagarden.net\",\n        \"pattern\": r\"(?:bulbapedia|archives)\\.bulbagarden\\.net\",\n        \"api-path\": \"/w/api.php\",\n    },\n    \"pidgiwiki\": {\n        \"root\": \"https://www.pidgi.net\",\n        \"pattern\": r\"(?:www\\.)?pidgi\\.net\",\n        \"api-path\": \"/wiki/api.php\",\n    },\n    \"azurlanewiki\": {\n        \"root\": \"https://azurlane.koumakan.jp\",\n        \"pattern\": r\"azurlane\\.koumakan\\.jp\",\n        \"api-path\": \"/w/api.php\",\n        \"useragent\": \"Googlebot-Image/1.0\",\n    },\n    \"mgewiki\": {\n        \"root\": \"https://mgewiki.moe\",\n        \"pattern\": r\"(?:www\\.)?mgewiki\\.moe\",\n        \"api-path\": \"/api.php\",\n    },\n})\n\n\nclass WikimediaArticleExtractor(WikimediaExtractor):\n    \"\"\"Extractor for wikimedia articles\"\"\"\n    subcategory = \"article\"\n    directory_fmt = (\"{category}\", \"{page}\")\n    pattern = BASE_PATTERN + r\"/(?!static/)([^?#]+)\"\n    example = \"https://en.wikipedia.org/wiki/TITLE\"\n\n    def __init__(self, match):\n        WikimediaExtractor.__init__(self, match)\n\n        path = self.groups[-1]\n        if path[2] == \"/\":\n            self.lang = lang = path[:2]\n            self.root = f\"{self.root}/{lang}\"\n            path = path[3:]\n        if path.startswith(\"wiki/\"):\n            path = path[5:]\n        elif path.startswith(\"index.php/\"):\n            path = path[10:]\n        self.path = text.unquote(path)\n\n        pre, sep, _ = path.partition(\":\")\n        self.prefix = prefix = pre.lower() if sep else None\n        if prefix is not None:\n            self.subcategory = prefix\n\n    def params(self):\n        if self.prefix == \"category\":\n            if self.config(\"subcategories\", True):\n                self.subcategories = True\n            return {\n                \"generator\": \"categorymembers\",\n                \"gcmtitle\" : self.path,\n                \"gcmtype\"  : \"file\",\n                \"gcmlimit\" : self.per_page,\n            }\n\n        if self.prefix == \"file\":\n            return {\n                \"titles\": self.path,\n            }\n\n        return {\n            \"generator\": \"images\",\n            \"gimlimit\" : self.per_page,\n            \"titles\"   : self.path,\n        }\n\n    def prepare_info(self, info):\n        info[\"page\"] = self.path\n        info[\"lang\"] = self.lang\n\n\nclass WikimediaWikiExtractor(WikimediaExtractor):\n    \"\"\"Extractor for all files on a MediaWiki instance\"\"\"\n    subcategory = \"wiki\"\n    pattern = BASE_PATTERN + r\"/?$\"\n    example = \"https://en.wikipedia.org/\"\n\n    def params(self):\n        # ref: https://www.mediawiki.org/wiki/API:Allpages\n        return {\n            \"generator\"   : \"allpages\",\n            \"gapnamespace\": 6,  # \"File\" namespace\n            \"gaplimit\"    : self.per_page,\n        }\n"
  },
  {
    "path": "gallery_dl/extractor/xasiat.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://www.xasiat.com\"\"\"\n\nfrom .common import Extractor, Message\nfrom .. import text\nimport time\n\nBASE_PATTERN = r\"(?:https?://)?(?:www\\.)?xasiat\\.com((?:/fr|/ja)?/albums\"\n\n\nclass XasiatExtractor(Extractor):\n    category = \"xasiat\"\n    directory_fmt = (\"{category}\", \"{title}\")\n    archive_fmt = \"{album_url}_{num}\"\n    root = \"https://www.xasiat.com\"\n\n    def items(self):\n        data = {\"_extractor\": XasiatAlbumExtractor}\n        for url in self.posts():\n            yield Message.Queue, url, data\n\n    def posts(self):\n        return self._pagination(self.groups[0])\n\n    def _pagination(self, path, pnum=1):\n        url = f\"{self.root}{path}/\"\n        find_posts = text.re(r'class=\"item  \">\\s*<a href=\"([^\"]+)').findall\n\n        while True:\n            params = {\n                \"mode\": \"async\",\n                \"function\": \"get_block\",\n                \"block_id\": \"list_albums_common_albums_list\",\n                \"sort_by\": \"post_date\",\n                \"from\": pnum,\n                \"_\": int(time.time() * 1000),\n            }\n\n            page = self.request(url, params=params).text\n            yield from find_posts(page)\n\n            if \"<span>Next</span>\" in page:\n                return\n\n            pnum += 1\n\n\nclass XasiatAlbumExtractor(XasiatExtractor):\n    subcategory = \"album\"\n    pattern = BASE_PATTERN + r\"/(\\d+)/[^/?#]+)\"\n    example = \"https://www.xasiat.com/albums/12345/TITLE/\"\n\n    def items(self):\n        path, album_id = self.groups\n        url = f\"{self.root}{path}/\"\n        response = self.request(url)\n        extr = text.extract_from(response.text)\n\n        title = extr(\"<h1>\", \"<\")\n        info = extr('class=\"info-content\"', \"</div>\")\n        images = extr('class=\"images\"', \"</div>\")\n\n        urls = list(text.extract_iter(images, 'href=\"', '\"'))\n        categories = text.re(r'categories/[^\"]+\\\">\\s*(.+)\\s*</a').findall(info)\n        data = {\n            \"title\": text.unescape(title),\n            \"model\": text.re(\n                r'top_models1\"></i>\\s*(.+)\\s*</span').findall(info),\n            \"tags\": text.re(\n                r'tags/[^\"]+\\\">\\s*(.+)\\s*</a').findall(info),\n            \"album_category\": categories[0] if categories else \"\",\n            \"album_url\": response.url,\n            \"album_id\": text.parse_int(album_id),\n            \"count\": len(urls),\n        }\n\n        yield Message.Directory, \"\", data\n        for data[\"num\"], url in enumerate(urls, 1):\n            yield Message.Url, url, text.nameext_from_url(url[:-1], data)\n\n\nclass XasiatTagExtractor(XasiatExtractor):\n    subcategory = \"tag\"\n    pattern = BASE_PATTERN + r\"/tags/[^/?#]+)\"\n    example = \"https://www.xasiat.com/albums/tags/TAG/\"\n\n\nclass XasiatCategoryExtractor(XasiatExtractor):\n    subcategory = \"category\"\n    pattern = BASE_PATTERN + r\"/categories/[^/?#]+)\"\n    example = \"https://www.xasiat.com/albums/categories/CATEGORY/\"\n\n\nclass XasiatModelExtractor(XasiatExtractor):\n    subcategory = \"model\"\n    pattern = BASE_PATTERN + r\"/models/[^/?#]+)\"\n    example = \"https://www.xasiat.com/albums/models/MODEL/\"\n"
  },
  {
    "path": "gallery_dl/extractor/xenforo.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2025-2026 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for XenForo forums\"\"\"\n\nfrom .common import BaseExtractor, Message\nfrom .. import text, util\nimport binascii\n\n\nclass XenforoExtractor(BaseExtractor):\n    \"\"\"Base class for xenforo extractors\"\"\"\n    basecategory = \"xenforo\"\n    directory_fmt = (\"{category}\", \"{thread[section]}\",\n                     \"{thread[title]} ({thread[id]})\")\n    filename_fmt = \"{post[id]}_{num:>02}{id:?_//}_{filename}.{extension}\"\n    archive_fmt = \"{post[id]}/{type[0]}{id}_{filename}\"\n\n    def __init__(self, match):\n        BaseExtractor.__init__(self, match)\n        self.cookies_domain = self.root.split(\"/\")[2]\n        self.cookies_names = self.config_instance(\"cookies\") or (\"xf_user\",)\n\n    def items(self):\n        self.login()\n\n        extract_urls = text.re(\n            r'(?s)(?:'\n            r'<video (.*?\\ssrc=\"[^\"]+\".*?)</video>'\n            r'|<a [^>]*?'\n            r'href=\"([^\"]*?/(?:index\\.php\\?)?attachments/[^\"]+\".*?)</a>'\n            r'|<div class=\"bb(?:Image|Media)Wrapper[^>]*?'\n            r'data-src=\"([^\"]+\".*?) />'\n            r'|(?:<a [^>]*?href=\"|<iframe [^>]*?src=\"|'\n            r'''onclick=\"loadMedia\\(this, ')([^\"']+[^<]*?<)'''\n            r')'\n        ).findall\n\n        embeds = self.config(\"embeds\", True)\n        quotes = self.config(\"quoted\", False)\n        attachments = self.config(\"attachments\", True)\n\n        root = self.root\n        base = root if (pos := root.find(\"/\", 8)) < 0 else root[:pos]\n        for post in self.posts():\n            content = post[\"content\"]\n            if not quotes:\n                content = self._remove_quotes(content)\n\n            urls = extract_urls(content)\n            if embeds and \"data-s9e-mediaembed-iframe=\" in content:\n                self._extract_embeds(urls, post, content)\n            if attachments and post[\"attachments\"]:\n                self._extract_attachments(urls, post)\n\n            data = {\"post\": post}\n            post[\"count\"] = data[\"count\"] = len(urls)\n            yield Message.Directory, \"\", data\n\n            id_last = None\n            data[\"_http_expected_status\"] = (403,)\n            data[\"_http_validate\"] = self._validate\n            data[\"num\"] = data[\"num_internal\"] = data[\"num_external\"] = 0\n            for video, inl, bb, ext in urls:\n                if ext:\n                    if ext[0] == \"#\":\n                        continue\n                    if ext[0] == \"/\":\n                        if ext[1] == \"/\":\n                            if \"'\" in ext:\n                                ext = ext[:ext.find(\"'\")]\n                            ext = \"https:\" + ext\n                        elif ext.startswith(\"/goto/link-confirmation?\"):\n                            params = text.parse_query(text.unescape(ext[24:]))\n                            ext = binascii.a2b_base64(params[\"url\"]).decode()\n                        elif ext.startswith(\"/redirect/\"):\n                            ext = text.unescape(text.extr(\n                                ext, \">\", \"<\").strip())\n                        else:\n                            continue\n                    elif '\"' in ext:\n                        ext = ext[:ext.find('\"')]\n                    data[\"num\"] += 1\n                    data[\"num_external\"] += 1\n                    data[\"type\"] = \"external\"\n                    yield Message.Queue, ext, data\n\n                elif video:\n                    data[\"num\"] += 1\n                    data[\"num_internal\"] += 1\n                    data[\"type\"] = \"video\"\n                    url = text.extr(video, 'src=\"', '\"')\n                    text.nameext_from_url(url, data)\n                    data[\"id\"] = text.parse_int(\n                        data[\"filename\"].partition(\"-\")[0])\n                    if url[0] == \"/\":\n                        url = base + url\n                    yield Message.Url, url, data\n\n                elif (inline := bb or inl):\n                    url = inline[:inline.find('\"')]\n                    name, _, id = url[url.rfind(\"/\", 0, -1):].strip(\n                        \"/\").rpartition(\".\")\n                    data[\"id\"] = id = text.parse_int(id)\n                    if id:\n                        if id == id_last:\n                            id_last = None\n                            continue\n                        else:\n                            id_last = id\n                    if alt := (text.extr(inline, 'alt=\"', '\"') or\n                               text.extr(inline, 'title=\"', '\"')):\n                        text.nameext_from_name(alt, data)\n                        if not data[\"extension\"]:\n                            data[\"extension\"] = name.rpartition(\"-\")[2]\n                    else:\n                        data[\"filename\"], _, data[\"extension\"] = \\\n                            name.rpartition(\"-\")\n                    data[\"num\"] += 1\n                    data[\"num_internal\"] += 1\n                    data[\"type\"] = \"inline\"\n                    if url[0] == \"/\":\n                        url = base + url\n                    yield Message.Url, url, data\n\n    def items_media(self, path, pnum, callback=None):\n        if (order := self.config(\"order-posts\")) and \\\n                order[0] in {\"d\", \"r\"}:\n            pages = self._pagination_reverse(path, pnum, callback)\n            reverse = True\n        else:\n            pages = self._pagination(path, pnum, callback)\n            reverse = False\n\n        if self.config(\"metadata\"):\n            extr_media = self._extract_media_ex\n            meta = True\n        else:\n            extr_media = self._extract_media\n            meta = False\n\n        root = self.root\n        base = root if (pos := root.find(\"/\", 8)) < 0 else root[:pos]\n        for page in pages:\n            posts = page.split(\n                '<div class=\"itemList-item js-inlineModContainer')\n            del posts[0]\n\n            if reverse:\n                posts.reverse()\n\n            for html in posts:\n                href, pos = text.extract(html, 'href=\"', '\"')\n                name, pos = text.extract(html, \"alt='\", \"'\", pos)\n\n                url, media = extr_media(\n                    base + href, href[href.rfind(\"/\", 0, -1)+1:-1])\n                if url.endswith(\"/register/full\"):\n                    self._warn_auth()\n                    continue\n                if not meta and name:\n                    text.nameext_from_name(text.unescape(name), media)\n\n                yield Message.Directory, \"\", media\n                yield Message.Url, url, media\n\n    def request_page(self, url):\n        try:\n            response = self.request(url)\n            if response.history and response.url.endswith(\"/register\"):\n                self._require_auth(response)\n            return response\n        except self.exc.HttpError as exc:\n            if exc.status == 403 and b\">Log in<\" in exc.response.content:\n                self._require_auth(exc.response)\n            raise\n\n    def login(self):\n        if self.cookies_names and self.cookies_check(\n                self.cookies_names, subdomains=True):\n            return\n\n        username, password = self._get_auth_info()\n        if username:\n            return self.cookies_update(self.cache(\n                self._login_impl, username, password,\n                _exp=365*86400, _mem=False))\n\n    def _login_impl(self, username, password):\n        self.log.info(\"Logging in as %s\", username)\n\n        url = self.root + \"/login/login\"\n        page = self.request(url).text\n        data = {\n            \"_xfToken\": text.extr(page, 'name=\"_xfToken\" value=\"', '\"'),\n            \"login\"   : username,\n            \"password\": password,\n            \"remember\": \"1\",\n            \"_xfRedirect\": \"\",\n        }\n        response = self.request(url, method=\"POST\", data=data)\n\n        if not response.history:\n            err = self._extract_error(response.text)\n            err = f'\"{err}\"' if err else None\n            raise self.exc.AuthenticationError(err)\n\n        return {\n            cookie.name: cookie.value\n            for cookie in self.cookies\n            if cookie.domain.endswith(self.cookies_domain)\n        }\n\n    def _pagination(self, base, pnum=None, callback=None, params=\"\"):\n        base = self.root + base\n\n        if pnum is None:\n            url = f\"{base}/{params}\"\n            pnum = 1\n        else:\n            url = f\"{base}/page-{pnum}{params}\"\n            pnum = None\n\n        page = self.request_page(url).text\n        if callback is not None:\n            callback(page)\n        while True:\n            yield page\n\n            if pnum is None or \"pageNav-jump--next\" not in page:\n                return\n            pnum += 1\n            page = self.request_page(f\"{base}/page-{pnum}{params}\").text\n\n    def _pagination_reverse(self, base, pnum=None, callback=None):\n        base = self.root + base\n\n        url = f\"{base}/page-{'9999' if pnum is None else pnum}\"\n        with self.request_page(url) as response:\n            if pnum is None and not response.history:\n                self._require_auth()\n            url = response.url\n            if url[-1] == \"/\":\n                pnum = 1\n            else:\n                pnum = text.parse_int(url[url.rfind(\"-\")+1:], 1)\n            page = response.text\n\n        if callback is not None:\n            callback(page)\n        while True:\n            yield page\n\n            pnum -= 1\n            if pnum > 1:\n                url = f\"{base}/page-{pnum}\"\n            elif pnum == 1:\n                url = base + \"/\"\n            else:\n                return\n\n            page = self.request_page(url).text\n\n    def _remove_quotes(self, content):\n        while \"<blockquote\" in content:\n            beg = content.index(\"<blockquote\")\n            end = content.index(\"</blockquote\", beg)\n            for _ in range(content.count(\"<blockquote\", beg+11, end)):\n                end = content.index(\"</blockquote\", end+13)\n            content = content[:beg] + content[end+13:]\n        return content\n\n    def _extract_error(self, html):\n        if msg := (text.extr(html, \"blockMessage--error\", \"</\") or\n                   text.extr(html, '\"blockMessage\"', \"</div>\")):\n            return text.unescape(msg[msg.find(\">\")+1:].strip())\n\n    def _parse_post(self, html):\n        extr = text.extract_from(html)\n\n        post = {\n            \"author\": extr('data-author=\"', '\"'),\n            \"id\": (extr('data-content=\"post-', '\"') or\n                   extr('data-content=\"profile-post-', '\"')),\n            \"author_url\": (extr('itemprop=\"url\" content=\"', '\"') or\n                           extr('<a href=\"', '\"')),\n            \"date\": self.parse_datetime_iso(extr('datetime=\"', '\"')),\n            \"content\": (extr('class=\"message-body',\n                             '<div class=\"js-selectToQuote') or\n                        extr('class=\"message-body',\n                             '</article>')),\n            \"attachments\": extr('<section class=\"message-attachments\">',\n                                '</section>'),\n        }\n\n        url_a = post[\"author_url\"]\n        post[\"author_slug\"], _, post[\"author_id\"] = \\\n            url_a[url_a.rfind(\"/\", 0, -1)+1:-1].rpartition(\".\")\n\n        con = post[\"content\"]\n        if (pos := con.find('<div class=\"bbWrapper')) >= 0:\n            con = con[pos:]\n        post[\"content\"] = con.strip()\n\n        return post\n\n    def _parse_thread(self, page):\n        try:\n            data = self._extract_jsonld(page)\n        except ValueError:\n            return {}\n\n        main = data.get(\"mainEntity\", data)\n        url = main.get(\"url\") or main.get(\"@id\") or \"\"\n\n        self.kwdict[\"thread\"] = thread = self._parse_author(main[\"author\"], {\n            \"id\"   : url[url.rfind(\".\")+1:-1],\n            \"url\"  : url,\n            \"title\": main[\"headline\"],\n            \"date\" : self.parse_datetime_iso(main[\"datePublished\"]),\n            \"tags\" : (main[\"keywords\"].split(\", \")\n                      if \"keywords\" in main else ()),\n            \"section\": main[\"articleSection\"],\n        })\n\n        stats = main[\"interactionStatistic\"]\n        if isinstance(stats, list):\n            thread[\"views\"] = stats[0][\"userInteractionCount\"]\n            thread[\"posts\"] = stats[1][\"userInteractionCount\"]\n        else:\n            thread[\"views\"] = -1\n            thread[\"posts\"] = stats[\"userInteractionCount\"]\n\n        return thread\n\n    def _parse_album(self, page):\n        main = self._extract_jsonld(page)[\"mainEntity\"]\n        url = main.get(\"url\") or main.get(\"@id\") or \"\"\n        slug, _, id = url[url.rfind(\"/\", 0, -1)+1:-1].rpartition(\".\")\n\n        self.kwdict[\"album\"] = album = self._parse_author(main[\"author\"], {\n            \"id\"   : id,\n            \"url\"  : url,\n            \"slug\" : text.unquote(slug),\n            \"title\": main[\"headline\"],\n            \"description\": main.get(\"description\"),\n            \"date\": self.parse_datetime_iso(main[\"dateCreated\"]),\n        })\n\n        stats = main[\"interactionStatistic\"]\n        if isinstance(stats, list):\n            album[\"count\"] = stats[0][\"userInteractionCount\"]\n            album[\"likes\"] = stats[1][\"userInteractionCount\"]\n            album[\"views\"] = stats[2][\"userInteractionCount\"]\n            album[\"comments\"] = stats[3][\"userInteractionCount\"]\n\n        return album\n\n    def _parse_profile(self, page):\n        user = self._extract_jsonld(page)\n        main = user[\"mainEntity\"]\n        url = user.get(\"url\") or main.get(\"@id\") or \"\"\n        slug, _, id = url[url.rfind(\"/\", 0, -1)+1:-1].rpartition(\".\")\n\n        self.kwdict[\"profile\"] = profile = {\n            \"id\"    : main.get(\"identifier\") or id,\n            \"url\"   : url,\n            \"slug\"  : text.unquote(slug),\n            \"name\"  : main.get(\"name\"),\n            \"avatar\": main.get(\"image\"),\n            \"description\": main.get(\"description\"),\n            \"date\"  : self.parse_datetime_iso(user.get(\"dateCreated\")),\n        }\n\n        stats = main.get(\"interactionStatistic\")\n        if isinstance(stats, list):\n            profile[\"follows\"] = stats[0][\"userInteractionCount\"]\n            profile[\"likes\"] = stats[1][\"userInteractionCount\"]\n\n        return profile\n\n    def _parse_author(self, author, data):\n        data[\"author\"] = author.get(\"name\") or \"\"\n        if url := author.get(\"url\"):\n            data[\"author_url\"] = url\n            data[\"author_slug\"], _, data[\"author_id\"] = \\\n                url[url.rfind(\"/\", 0, -1)+1:-1].rpartition(\".\")\n        else:\n            data[\"author_url\"] = \"\"\n            data[\"author_slug\"] = text.slugify(data[\"author\"][:15])\n            data[\"author_id\"] = data[\"author\"][15:]\n        return data\n\n    def _extract_attachments(self, urls, post):\n        find = text.re(r\"(?s)\\shref=[\\\"'](.+)\").search\n        for att in text.extract_iter(post[\"attachments\"], \"<li\", \"</li>\"):\n            urls.append((None, find(att)[1], None, None))\n\n    def _extract_embeds(self, urls, post, content):\n        for embed in text.extract_iter(\n                content, \"data-s9e-mediaembed-iframe='\", \"'\"):\n            data = {}\n            key = None\n            for value in util.json_loads(embed):\n                if key is None:\n                    key = value\n                else:\n                    data[key] = value\n                    key = None\n\n            src = data.get(\"src\")\n            if not src:\n                self.log.debug(data)\n                continue\n\n            type = data.get(\"data-s9e-mediaembed\")\n            frag = src[src.find(\"#\")+1:]\n            if type == \"tiktok\":\n                url = \"https://www.tiktok.com/@/video/\" + frag\n            elif type == \"reddit\":\n                url = \"https://embed.reddit.com/r/\" + frag\n            elif type == \"imgur\":\n                url = \"https://imgur.com/\" + frag\n            else:\n                self.log.warning(\"%s: Unsupported media embed type '%s'\",\n                                 post[\"id\"], type)\n                continue\n            urls.append((None, None, None, url))\n\n    def _extract_media(self, url, file):\n        media = {}\n        name, _, media[\"id\"] = file.rpartition(\".\")\n        media[\"filename\"], _, media[\"extension\"] = name.rpartition(\"-\")\n        return url + \"full\", media\n\n    def _extract_media_ex(self, url, file):\n        page = self.request_page(url).text\n\n        schema = self._extract_jsonld(page)\n        main = schema[\"mainEntity\"]\n        stats = main[\"interactionStatistic\"]\n\n        media = text.nameext_from_name(main[\"name\"], {\n            \"schema\": schema,\n            \"id\"    : file.rpartition(\".\")[2],\n            \"size\"  : main.get(\"contentSize\"),\n            \"description\": main.get(\"description\"),\n            \"date\"  : self.parse_datetime_iso(main.get(\"dateCreated\")),\n            \"width\" : (w := main.get(\"width\")) and text.parse_int(\n                w[\"name\"].partition(\" \")[0]) or 0,\n            \"height\": (h := main.get(\"height\")) and text.parse_int(\n                h[\"name\"].partition(\" \")[0]) or 0,\n        })\n\n        self._parse_author(main[\"author\"], media)\n        if ext := main.get(\"encodingFormat\"):\n            media[\"extension\"] = ext\n\n        if isinstance(stats, list):\n            media[\"views\"] = stats[0][\"userInteractionCount\"]\n            media[\"likes\"] = stats[1][\"userInteractionCount\"]\n            media[\"comments\"] = stats[2][\"userInteractionCount\"]\n\n        return main[\"contentUrl\"], media\n\n    def _require_auth(self, response=None):\n        raise self.exc.AuthRequired(\n            (\"username & password\", \"authenticated cookies\"), None,\n            None if response is None else self._extract_error(response.text))\n\n    def _warn_auth(self):\n        try:\n            self._require_auth()\n        except Exception as exc:\n            self.log.warning(exc)\n\n    def _validate(self, response):\n        if response.status_code == 403 and b\">Log in<\" in response.content:\n            self._require_auth(response)\n        return True\n\n\nBASE_PATTERN = XenforoExtractor.update({\n    \"simpcity\": {\n        \"root\": \"https://simpcity.cr\",\n        \"pattern\": r\"(?:www\\.)?simpcity\\.(?:cr|su)\",\n        \"cookies\": (\"ogaddgmetaprof_user\",),\n    },\n    \"nudostarforum\": {\n        \"root\": \"https://nudostar.com/forum\",\n        \"pattern\": r\"(?:www\\.)?nudostar\\.com/forum\",\n    },\n    \"atfforum\": {\n        \"root\": \"https://allthefallen.moe/forum\",\n        \"pattern\": r\"(?:www\\.)?allthefallen\\.moe/forum\",\n    },\n    \"celebforum\": {\n        \"root\": \"https://celebforum.to\",\n        \"pattern\": r\"(?:www\\.)?celebforum\\.to\",\n    },\n    \"titsintops\": {\n        \"root\": \"https://titsintops.com/phpBB2\",\n        \"pattern\": r\"(?:www\\.)?titsintops\\.com/phpBB2\",\n    },\n    \"socialmediagirlsforum\": {\n        \"root\": \"https://forums.socialmediagirls.com\",\n        \"pattern\": r\"forums\\.socialmediagirls\\.com\",\n    },\n    \"blacktowhite\": {\n        \"root\": \"https://www.blacktowhite.net\",\n        \"pattern\": r\"(?:www\\.)?blacktowhite\\.net\",\n    },\n})\n\n\nclass XenforoPostExtractor(XenforoExtractor):\n    subcategory = \"post\"\n    pattern = (BASE_PATTERN + r\"(/(?:index\\.php\\?)?threads\"\n               r\"/[^/?#]+/(?:page-\\d+)?#?post-|/posts/)(\\d+)\")\n    example = \"https://simpcity.cr/threads/TITLE.12345/post-54321\"\n\n    def posts(self):\n        path = self.groups[-2]\n        post_id = self.groups[-1]\n        url = f\"{self.root}{path}{post_id}/\"\n        page = self.request_page(url).text\n\n        pos = page.find(f'data-content=\"post-{post_id}\"')\n        if pos < 0:\n            raise self.exc.NotFoundError(\"post\")\n        html = text.extract(page, \"<article \", \"<footer\", pos-200)[0]\n\n        self._parse_thread(page)\n        return (self._parse_post(html),)\n\n\nclass XenforoThreadExtractor(XenforoExtractor):\n    subcategory = \"thread\"\n    pattern = (BASE_PATTERN + r\"(/(?:index\\.php\\?)?threads\"\n               r\"/(?:[^/?#]+\\.)?\\d+)(?:/page-(\\d+))?\")\n    example = \"https://simpcity.cr/threads/TITLE.12345/\"\n\n    def posts(self):\n        path = self.groups[-2]\n        pnum = self.groups[-1]\n\n        if (order := self.config(\"order-posts\")) and \\\n                order[0] not in {\"d\", \"r\"}:\n            params = \"?order=reaction_score\" if order[0] == \"s\" else \"\"\n            pages = self._pagination(path, pnum, params=params)\n            reverse = False\n        elif order == \"reaction\":\n            pages = self._pagination(\n                path, pnum, params=\"?order=reaction_score\")\n            reverse = False\n        else:\n            pages = self._pagination_reverse(path, pnum)\n            reverse = True\n\n        for page in pages:\n            if \"thread\" not in self.kwdict:\n                self._parse_thread(page)\n            posts = text.extract_iter(page, \"<article \", \"<footer\")\n            if reverse:\n                posts = list(posts)\n                posts.reverse()\n            for html in posts:\n                yield self._parse_post(html)\n\n\nclass XenforoForumExtractor(XenforoExtractor):\n    subcategory = \"forum\"\n    pattern = (BASE_PATTERN + r\"(/(?:index\\.php\\?)?forums\"\n               r\"/(?:[^/?#]+\\.)?[^/?#]+)(?:/page-(\\d+))?\")\n    example = \"https://simpcity.cr/forums/TITLE.123/\"\n\n    def items(self):\n        extract_threads = text.re(\n            r'(/(?:index\\.php\\?)?threads/[^\"]+)\"[^>]+data-xf-init=').findall\n\n        data = {\"_extractor\": XenforoThreadExtractor}\n        path = self.groups[-2]\n        pnum = self.groups[-1]\n        for page in self._pagination(path, pnum):\n            for path in extract_threads(page):\n                yield Message.Queue, self.root + text.unquote(path), data\n\n\nclass XenforoMediaUserExtractor(XenforoExtractor):\n    subcategory = \"media-user\"\n    directory_fmt = (\"{category}\", \"Media\", \"{author_slug}\")\n    filename_fmt = \"{filename}.{extension}\"\n    archive_fmt = \"{id}\"\n    pattern = (BASE_PATTERN + r\"(/(?:index\\.php\\?)?)me(?:\"\n               r\"dia/users/([^/?#]+)(?:/page-(\\d+))?|\"\n               r\"mbers/([^/?#]+)/#xfmgMedia)\")\n    example = \"https://simpcity.cr/media/users/USER.123/\"\n\n    def items(self):\n        groups = self.groups\n\n        user = groups[-3]\n        if user is None:\n            user = groups[-1]\n            pnum = None\n        else:\n            pnum = groups[-2]\n\n        if not self.config(\"metadata\"):\n            self.kwdict[\"author_slug\"], _, self.kwdict[\"author_id\"] = \\\n                user.rpartition(\".\")\n\n        return self.items_media(f\"{groups[-4]}media/users/{user}\", pnum)\n\n\nclass XenforoMediaAlbumExtractor(XenforoExtractor):\n    subcategory = \"media-album\"\n    directory_fmt = (\"{category}\", \"Media\", \"Albums\",\n                     \"{album[slug]} ({album[id]})\")\n    filename_fmt = \"{filename}.{extension}\"\n    archive_fmt = \"{id}\"\n    pattern = (BASE_PATTERN + r\"(/(?:index\\.php\\?)?\"\n               r\"media/albums/([^/?#]+))(?:/page-(\\d+))?\")\n    example = \"https://simpcity.cr/media/albums/ALBUM.123/\"\n\n    def items(self):\n        return self.items_media(\n            self.groups[-3], self.groups[-1], self._parse_album)\n\n\nclass XenforoMediaCategoryExtractor(XenforoExtractor):\n    subcategory = \"media-category\"\n    directory_fmt = (\"{category}\", \"Media\", \"Category\", \"{mcategory}\")\n    filename_fmt = \"{filename}.{extension}\"\n    archive_fmt = \"{id}\"\n    pattern = (BASE_PATTERN + r\"(/(?:index\\.php\\?)?\"\n               r\"media/categories/([^/?#]+))(?:/page-(\\d+))?\")\n    example = \"https://simpcity.cr/media/categories/CATEGORY.123/\"\n\n    def items(self):\n        self.kwdict[\"mcategory\"], _, self.kwdict[\"mcategory_id\"] = \\\n            self.groups[-2].rpartition(\".\")\n        return self.items_media(self.groups[-3], self.groups[-1])\n\n\nclass XenforoMediaItemExtractor(XenforoExtractor):\n    subcategory = \"media-item\"\n    directory_fmt = (\"{category}\", \"Media\", \"{author_slug|''}\")\n    filename_fmt = \"{filename}.{extension}\"\n    archive_fmt = \"{id}\"\n    pattern = BASE_PATTERN + r\"(/(?:index\\.php\\?)?media/((?:[^/?#]+\\.)\\d+))\"\n    example = \"https://simpcity.cr/media/NAME.123/\"\n\n    def items(self):\n        url = f\"{self.root}{self.groups[-2]}/\"\n        url, media = (self._extract_media_ex if self.config(\"metadata\") else\n                      self._extract_media)(url, self.groups[-1])\n        yield Message.Directory, \"\", media\n        yield Message.Url, url, media\n\n\nclass XenforoProfileExtractor(XenforoExtractor):\n    subcategory = \"profile\"\n    directory_fmt = (\"{category}\", \"Profiles\", \"{profile[name]}\")\n    archive_fmt = \"{id}\"\n    pattern = (BASE_PATTERN + r\"(/(?:index\\.php\\?)?\"\n               r\"members/[^/?#]+)(?:/page-(\\d+))?\")\n    example = \"https://simpcity.cr/members/USER.123/\"\n\n    def posts(self):\n        path = self.groups[-2]\n        pnum = self.groups[-1]\n\n        if (order := self.config(\"order-posts\")) and \\\n                order[0] in {\"d\", \"r\"}:\n            pages = self._pagination_reverse(path, pnum)\n            reverse = True\n        else:\n            pages = self._pagination(path, pnum)\n            reverse = False\n\n        for page in pages:\n            if \"profile\" not in self.kwdict:\n                self._parse_profile(page)\n            posts = text.extract_iter(page, \"<article \", \"<footer\")\n            if reverse:\n                posts = list(posts)\n                posts.reverse()\n            for html in posts:\n                yield self._parse_post(html)\n"
  },
  {
    "path": "gallery_dl/extractor/xfolio.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2025 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://xfolio.jp/\"\"\"\n\nfrom .common import Extractor, Message\nfrom .. import text\n\nBASE_PATTERN = r\"(?:https?://)?xfolio\\.jp(?:/[^/?#]+)?\"\n\n\nclass XfolioExtractor(Extractor):\n    \"\"\"Base class for xfolio extractors\"\"\"\n    category = \"xfolio\"\n    root = \"https://xfolio.jp\"\n    cookies_domain = \".xfolio.jp\"\n    directory_fmt = (\"{category}\", \"{creator_slug}\", \"{work_id}\")\n    filename_fmt = \"{work_id}_{image_id}.{extension}\"\n    archive_fmt = \"{work_id}_{image_id}\"\n    request_interval = (0.5, 1.5)\n\n    def _init(self):\n        XfolioExtractor._init = Extractor._init\n        if not self.cookies_check((\"xfolio_session\",)):\n            self.log.error(\"'xfolio_session' cookie required\")\n\n    def items(self):\n        data = {\"_extractor\": XfolioWorkExtractor}\n        for work in self.works():\n            yield Message.Queue, work, data\n\n    def request(self, url, **kwargs):\n        response = Extractor.request(self, url, **kwargs)\n\n        if \"/system/recaptcha\" in response.url:\n            raise self.exc.AbortExtraction(\"Bot check / CAPTCHA page\")\n\n        return response\n\n\nclass XfolioWorkExtractor(XfolioExtractor):\n    subcategory = \"work\"\n    pattern = BASE_PATTERN + r\"/portfolio/([^/?#]+)/works/(\\d+)\"\n    example = \"https://xfolio.jp/portfolio/USER/works/12345\"\n\n    def items(self):\n        creator, work_id = self.groups\n        url = f\"{self.root}/portfolio/{creator}/works/{work_id}\"\n        html = self.request(url).text\n\n        work = self._extract_data(html)\n        files = self._extract_files(html, work)\n        work[\"count\"] = len(files)\n\n        yield Message.Directory, \"\", work\n        for work[\"num\"], file in enumerate(files, 1):\n            file.update(work)\n            yield Message.Url, file[\"url\"], file\n\n    def _extract_data(self, html):\n        creator, work_id = self.groups\n        extr = text.extract_from(html)\n        return {\n            \"title\"          : text.unescape(extr(\n                'property=\"og:title\" content=\"', '\"').rpartition(\" - \")[0]),\n            \"description\"    : text.unescape(extr(\n                'property=\"og:description\" content=\"', '\"')),\n            \"creator_id\"     : extr(' data-creator-id=\"', '\"'),\n            \"creator_userid\" : extr(' data-creator-user-id=\"', '\"'),\n            \"creator_name\"   : extr(' data-creator-name=\"', '\"'),\n            \"creator_profile\": text.unescape(extr(\n                ' data-creator-profile=\"', '\"')),\n            \"series_id\"      : extr(\"/series/\", '\"'),\n            \"creator_slug\"   : creator,\n            \"work_id\"        : work_id,\n        }\n\n    def _extract_files(self, html, work):\n        files = []\n\n        work_id = work[\"work_id\"]\n        for img in text.extract_iter(\n                html, 'class=\"article__wrap_img', \"</div>\"):\n            image_id = text.extr(img, \"/fullscale_image?image_id=\", \"&\")\n            if not image_id:\n                self.log.warning(\n                    \"%s: 'fullscale_image' not available\", work_id)\n                continue\n\n            files.append({\n                \"image_id\" : image_id,\n                \"extension\": \"jpg\",\n                \"url\": (f\"{self.root}/user_asset.php?id={image_id}&work_id=\"\n                        f\"{work_id}&work_image_id={image_id}&type=work_image\"),\n                \"_http_headers\": {\"Referer\": (\n                    f\"{self.root}/fullscale_image\"\n                    f\"?image_id={image_id}&work_id={work_id}\")},\n            })\n\n        return files\n\n\nclass XfolioUserExtractor(XfolioExtractor):\n    subcategory = \"user\"\n    pattern = BASE_PATTERN + r\"/portfolio/([^/?#]+)(?:/works)?/?(?:$|\\?|#)\"\n    example = \"https://xfolio.jp/portfolio/USER\"\n\n    def works(self):\n        url = f\"{self.root}/portfolio/{self.groups[0]}/works\"\n\n        while True:\n            html = self.request(url).text\n\n            for item in text.extract_iter(\n                    html, '<div class=\"postItem', \"</div>\"):\n                yield text.extr(item, ' href=\"', '\"')\n\n            pager = text.extr(html, ' class=\"pager__list_next', \"</li>\")\n            url = text.extr(pager, ' href=\"', '\"')\n            if not url:\n                return\n            url = text.unescape(url)\n\n\nclass XfolioSeriesExtractor(XfolioExtractor):\n    subcategory = \"series\"\n    pattern = BASE_PATTERN + r\"/portfolio/([^/?#]+)/series/(\\d+)\"\n    example = \"https://xfolio.jp/portfolio/USER/series/12345\"\n\n    def works(self):\n        creator, series_id = self.groups\n        url = f\"{self.root}/portfolio/{creator}/series/{series_id}\"\n        html = self.request(url).text\n\n        return [\n            text.extr(item, ' href=\"', '\"')\n            for item in text.extract_iter(\n                html, 'class=\"listWrap--title\">', \"</a>\")\n        ]\n"
  },
  {
    "path": "gallery_dl/extractor/xhamster.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2019-2026 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://xhamster.com/\"\"\"\n\nfrom .common import Extractor, Message\nfrom .. import text, util\n\nBASE_PATTERN = (r\"(?:https?://)?((?:[\\w-]+\\.)?xhamster\"\n                r\"(?:\\d?\\.(?:com|one|desi)|\\.porncache\\.net))\")\n\n\nclass XhamsterExtractor(Extractor):\n    \"\"\"Base class for xhamster extractors\"\"\"\n    category = \"xhamster\"\n\n    def __init__(self, match):\n        self.root = \"https://\" + match[1]\n        Extractor.__init__(self, match)\n\n\nclass XhamsterGalleryExtractor(XhamsterExtractor):\n    \"\"\"Extractor for image galleries on xhamster.com\"\"\"\n    subcategory = \"gallery\"\n    directory_fmt = (\"{category}\", \"{user[name]}\",\n                     \"{gallery[id]} {gallery[title]}\")\n    filename_fmt = \"{num:>03}_{id}.{extension}\"\n    archive_fmt = \"{id}\"\n    pattern = BASE_PATTERN + r\"(/photos/gallery/[^/?#]+)\"\n    example = \"https://xhamster.com/photos/gallery/12345\"\n\n    def items(self):\n        data = self.metadata()\n        yield Message.Directory, \"\", data\n        for num, image in enumerate(self.images(), 1):\n            url = image[\"imageURL\"]\n            image.update(data)\n            text.nameext_from_url(url, image)\n            image[\"num\"] = num\n            image[\"extension\"] = \"webp\"\n            del image[\"modelName\"]\n            yield Message.Url, url, image\n\n    def metadata(self):\n        data = self.data = self._extract_data(self.root + self.groups[1])\n\n        gallery = data[\"galleryPage\"]\n        info = gallery[\"infoProps\"]\n        model = gallery[\"galleryModel\"]\n        author = info[\"authorInfoProps\"]\n\n        return {\n            \"user\":\n            {\n                \"id\"         : text.parse_int(model[\"userId\"]),\n                \"url\"        : author[\"authorLink\"],\n                \"name\"       : author[\"authorName\"],\n                \"verified\"   : True if author.get(\"verified\") else False,\n                \"subscribers\": info[\"subscribeButtonProps\"][\"subscribers\"],\n            },\n            \"gallery\":\n            {\n                \"id\"         : text.parse_int(gallery[\"id\"]),\n                \"tags\"       : [t[\"label\"] for t in info[\"categoriesTags\"]],\n                \"date\"       : self.parse_timestamp(model[\"created\"]),\n                \"views\"      : text.parse_int(model[\"views\"]),\n                \"likes\"      : text.parse_int(model[\"rating\"][\"likes\"]),\n                \"dislikes\"   : text.parse_int(model[\"rating\"][\"dislikes\"]),\n                \"title\"      : model[\"title\"],\n                \"description\": model[\"description\"],\n                \"thumbnail\"  : model[\"thumbURL\"],\n            },\n            \"count\": text.parse_int(gallery[\"photosCount\"]),\n        }\n\n    def images(self):\n        data = self.data\n        self.data = None\n\n        while True:\n            yield from data[\"photosGalleryModel\"][\"photos\"]\n\n            pagination = data[\"galleryPage\"][\"paginationProps\"]\n            if pagination[\"currentPageNumber\"] >= pagination[\"lastPageNumber\"]:\n                return\n            url = (pagination[\"pageLinkTemplate\"][:-3] +\n                   str(pagination[\"currentPageNumber\"] + 1))\n\n            data = self._extract_data(url)\n\n    def _extract_data(self, url):\n        page = self.request(url).text\n        return util.json_loads(text.extr(\n            page, \"window.initials=\", \"</script>\").rstrip(\"\\n\\r;\"))\n\n\nclass XhamsterUserExtractor(XhamsterExtractor):\n    \"\"\"Extractor for all galleries of an xhamster user\"\"\"\n    subcategory = \"user\"\n    pattern = BASE_PATTERN + r\"/users/([^/?#]+)(?:/photos)?/?(?:$|[?#])\"\n    example = \"https://xhamster.com/users/USER/photos\"\n\n    def items(self):\n        url = f\"{self.root}/users/{self.groups[1]}/photos\"\n        data = {\"_extractor\": XhamsterGalleryExtractor}\n\n        while url:\n            extr = text.extract_from(self.request(url).text)\n            while True:\n                url = extr(' role-pop\" href=\"', '\"')\n                if not url:\n                    break\n                yield Message.Queue, url, data\n            url = extr('data-page=\"next\" href=\"', '\"')\n"
  },
  {
    "path": "gallery_dl/extractor/xvideos.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2017-2025 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://www.xvideos.com/\"\"\"\n\nfrom .common import GalleryExtractor, Extractor, Message\nfrom .. import text, util\n\nBASE_PATTERN = (r\"(?:https?://)?(?:www\\.)?xvideos\\.com\"\n                r\"/(?:profiles|(?:amateur-|model-)?channels)\")\n\n\nclass XvideosBase():\n    \"\"\"Base class for xvideos extractors\"\"\"\n    category = \"xvideos\"\n    root = \"https://www.xvideos.com\"\n\n\nclass XvideosGalleryExtractor(XvideosBase, GalleryExtractor):\n    \"\"\"Extractor for user profile galleries on xvideos.com\"\"\"\n    subcategory = \"gallery\"\n    directory_fmt = (\"{category}\", \"{user[name]}\",\n                     \"{gallery[id]} {gallery[title]}\")\n    filename_fmt = \"{category}_{gallery[id]}_{num:>03}.{extension}\"\n    archive_fmt = \"{gallery[id]}_{num}\"\n    pattern = BASE_PATTERN + r\"/([^/?#]+)/photos/(\\d+)\"\n    example = \"https://www.xvideos.com/profiles/USER/photos/12345\"\n\n    def __init__(self, match):\n        self.user, self.gallery_id = match.groups()\n        url = f\"{self.root}/profiles/{self.user}/photos/{self.gallery_id}\"\n        GalleryExtractor.__init__(self, match, url)\n\n    def metadata(self, page):\n        extr = text.extract_from(page)\n        user = {\n            \"id\"     : text.parse_int(extr('\"id_user\":', ',')),\n            \"display\": extr('\"display\":\"', '\"'),\n            \"sex\"    : extr('\"sex\":\"', '\"'),\n            \"name\"   : self.user,\n        }\n        title = extr('\"title\":\"', '\"')\n        user[\"description\"] = extr(\n            '<small class=\"mobile-hide\">', '</small>').strip()\n        tags = extr('<em>Tagged:</em>', '<').strip()\n\n        return {\n            \"user\": user,\n            \"gallery\": {\n                \"id\"   : text.parse_int(self.gallery_id),\n                \"title\": text.unescape(title),\n                \"tags\" : text.unescape(tags).split(\", \") if tags else [],\n            },\n        }\n\n    def images(self, page):\n        results = [\n            (url, None)\n            for url in text.extract_iter(\n                page, '<a class=\"embed-responsive-item\" href=\"', '\"')\n        ]\n\n        if not results:\n            return\n\n        while len(results) % 500 == 0:\n            path = text.rextr(page, ' href=\"', '\"', page.find(\">Next</\"))\n            if not path:\n                break\n            page = self.request(self.root + path).text\n            results.extend(\n                (url, None)\n                for url in text.extract_iter(\n                    page, '<a class=\"embed-responsive-item\" href=\"', '\"')\n            )\n\n        return results\n\n\nclass XvideosUserExtractor(XvideosBase, Extractor):\n    \"\"\"Extractor for user profiles on xvideos.com\"\"\"\n    subcategory = \"user\"\n    categorytransfer = True\n    pattern = BASE_PATTERN + r\"/([^/?#]+)/?(?:#.*)?$\"\n    example = \"https://www.xvideos.com/profiles/USER\"\n\n    def __init__(self, match):\n        Extractor.__init__(self, match)\n        self.user = match[1]\n\n    def items(self):\n        url = f\"{self.root}/profiles/{self.user}\"\n        page = self.request(url, notfound=self.subcategory).text\n        data = util.json_loads(text.extr(\n            page, \"xv.conf=\", \";</script>\"))[\"data\"]\n\n        if not isinstance(data[\"galleries\"], dict):\n            return\n        if \"0\" in data[\"galleries\"]:\n            del data[\"galleries\"][\"0\"]\n\n        galleries = [\n            {\n                \"id\"   : text.parse_int(gid),\n                \"title\": text.unescape(gdata[\"title\"]),\n                \"count\": gdata[\"nb_pics\"],\n                \"_extractor\": XvideosGalleryExtractor,\n            }\n            for gid, gdata in data[\"galleries\"].items()\n        ]\n        galleries.sort(key=lambda x: x[\"id\"])\n\n        base = f\"{self.root}/profiles/{self.user}/photos/\"\n        for gallery in galleries:\n            url = base + str(gallery[\"id\"])\n            yield Message.Queue, url, gallery\n"
  },
  {
    "path": "gallery_dl/extractor/yiffverse.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2024-2026n Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://yiffverse.com/\"\"\"\n\nfrom .booru import BooruExtractor\nfrom .. import text\nimport collections\n\nBASE_PATTERN = r\"(?:https?://)?(?:www\\.)?yiffverse\\.com\"\n\n\nclass YiffverseExtractor(BooruExtractor):\n    category = \"yiffverse\"\n    root = \"https://yiffverse.com\"\n    root_cdn = \"https://furry34com.b-cdn.net\"\n    filename_fmt = \"{category}_{id}.{extension}\"\n    per_page = 30\n\n    TAG_TYPES = {\n        None: \"general\",\n        1   : \"general\",\n        2   : \"copyright\",\n        4   : \"character\",\n        8   : \"artist\",\n        16  : \"system\",\n        32  : \"meta\",\n    }\n    FORMATS = (\n        (\"100\", \"mov.mp4\"),\n        (\"101\", \"mov720.mp4\"),\n        (\"102\", \"mov480.mp4\"),\n        (\"10\" , \"pic.jpg\"),\n    )\n\n    def _file_url(self, post):\n        files = post[\"files\"]\n        for fmt, extension in self.FORMATS:\n            if fmt in files:\n                break\n        else:\n            fmt = next(iter(files))\n\n        post_id = post[\"id\"]\n        root = self.root_cdn if files[fmt][0] else self.root\n        post[\"file_url\"] = url = \\\n            f\"{root}/posts/{post_id // 1000}/{post_id}/{post_id}.{extension}\"\n        post[\"format_id\"] = fmt\n        post[\"format\"] = extension.partition(\".\")[0]\n\n        return url\n\n    def _prepare(self, post):\n        post.pop(\"files\", None)\n        post[\"date\"] = self.parse_datetime_iso(post[\"created\"])\n        post[\"filename\"], _, post[\"format\"] = post[\"filename\"].rpartition(\".\")\n        if \"tags\" in post:\n            post[\"tags\"] = [t[\"value\"] for t in post[\"tags\"]]\n\n    def _tags(self, post, _):\n        if \"tags\" not in post:\n            post.update(self._fetch_post(post[\"id\"]))\n\n        tags = collections.defaultdict(list)\n        for tag in post[\"tags\"]:\n            tags[tag[\"type\"]].append(tag[\"value\"])\n        types = self.TAG_TYPES\n        for type, values in tags.items():\n            post[\"tags_\" + types[type]] = values\n\n    def _fetch_post(self, post_id):\n        url = f\"{self.root}/api/v2/post/{post_id}\"\n        return self.request_json(url)\n\n    def _pagination(self, endpoint, params=None):\n        url = f\"{self.root}/api{endpoint}\"\n\n        if params is None:\n            params = {}\n        params[\"sortOrder\"] = 1\n        params[\"status\"] = 2\n        params[\"take\"] = self.per_page\n        threshold = self.per_page\n\n        while True:\n            data = self.request_json(url, method=\"POST\", json=params)\n\n            yield from data[\"items\"]\n\n            if len(data[\"items\"]) < threshold:\n                return\n            params[\"cursor\"] = data.get(\"cursor\")\n\n\nclass YiffversePostExtractor(YiffverseExtractor):\n    subcategory = \"post\"\n    archive_fmt = \"{id}\"\n    pattern = BASE_PATTERN + r\"/post/(\\d+)\"\n    example = \"https://yiffverse.com/post/12345\"\n\n    def posts(self):\n        return (self._fetch_post(self.groups[0]),)\n\n\nclass YiffversePlaylistExtractor(YiffverseExtractor):\n    subcategory = \"playlist\"\n    directory_fmt = (\"{category}\", \"{playlist_id}\")\n    archive_fmt = \"p_{playlist_id}_{id}\"\n    pattern = BASE_PATTERN + r\"/playlist/(\\d+)\"\n    example = \"https://yiffverse.com/playlist/12345\"\n\n    def metadata(self):\n        return {\"playlist_id\": self.groups[0]}\n\n    def posts(self):\n        endpoint = \"/v2/post/search/playlist/\" + self.groups[0]\n        return self._pagination(endpoint)\n\n\nclass YiffverseTagExtractor(YiffverseExtractor):\n    subcategory = \"tag\"\n    directory_fmt = (\"{category}\", \"{search_tags}\")\n    archive_fmt = \"t_{search_tags}_{id}\"\n    pattern = BASE_PATTERN + r\"/(?:tag/([^/?#]+))?(?:/?\\?([^#]+))?(?:$|#)\"\n    example = \"https://yiffverse.com/tag/TAG\"\n\n    def _init(self):\n        tag, query = self.groups\n        params = text.parse_query(query)\n\n        self.tags = tags = []\n        if tag:\n            tags.append(text.unquote(tag))\n        if \"tags\" in params:\n            tags.extend(params[\"tags\"].split(\"|\"))\n\n        type = params.get(\"type\")\n        if type == \"video\":\n            self.type = 1\n        elif type == \"image\":\n            self.type = 0\n        else:\n            self.type = None\n\n    def metadata(self):\n        return {\"search_tags\": \" \".join(self.tags)}\n\n    def posts(self):\n        endpoint = \"/v2/post/search/root\"\n        params = {\"includeTags\": [t.replace(\"_\", \" \") for t in self.tags]}\n        if self.type is not None:\n            params[\"type\"] = self.type\n        return self._pagination(endpoint, params)\n"
  },
  {
    "path": "gallery_dl/extractor/yourlesbians.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2025 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://yourlesbians.com/\"\"\"\n\nfrom .common import GalleryExtractor\nfrom .. import text\n\nBASE_PATTERN = r\"(?:https?://)?(?:www\\.)?yourlesbians\\.com\"\n\n\nclass YourlesbiansAlbumExtractor(GalleryExtractor):\n    category = \"yourlesbians\"\n    subcategory = \"album\"\n    root = \"https://yourlesbians.com\"\n    directory_fmt = (\"{category}\", \"{title}\")\n    filename_fmt = \"{num:>03} {filename}.{extension}\"\n    archive_fmt = \"{title}/{num}\"\n    pattern = BASE_PATTERN + r\"(/album/([^/?#]+)/?)\"\n    example = \"https://yourlesbians.com/album/SLUG/\"\n\n    def metadata(self, page):\n        extr = text.extract_from(page)\n        data = {\n            \"album_url\": extr('property=\"og:url\" content=\"', '\"'),\n            \"title\": text.unescape(extr(\n                'property=\"og:title\" content=\"', '\"')[:-8].rstrip()),\n            \"album_thumbnail\": extr('property=\"og:image\" content=\"', '\"'),\n            \"description\": extr('property=\"og:description\" content=\"', '\"'),\n            \"tags\": text.split_html(extr('tags-row', '</div>'))[1:],\n        }\n        if data[\"description\"].endswith(\", right after.\"):\n            data[\"description\"] = \"\"\n        self.album = extr('class=\"album-inner', \"</div>\")\n        return data\n\n    def images(self, _):\n        results = []\n        for url in text.extract_iter(self.album, '<a href=\"', '\"'):\n            fn, _, ext = url.rsplit(\"/\", 2)[1].rpartition(\".\")\n            results.append((url, {\n                \"filename\" : fn,\n                \"extension\": ext,\n            }))\n        return results\n"
  },
  {
    "path": "gallery_dl/extractor/ytdl.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2021-2026 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for sites supported by youtube-dl\"\"\"\n\nfrom .common import Extractor, Message\nfrom .. import ytdl, config\n\n\nclass YoutubeDLExtractor(Extractor):\n    \"\"\"Generic extractor for youtube-dl supported URLs\"\"\"\n    category = \"ytdl\"\n    directory_fmt = (\"{category}\", \"{subcategory}\")\n    filename_fmt = \"{title}-{id}.{extension}\"\n    archive_fmt = \"{extractor_key} {id}\"\n    pattern = r\"ytdl:(.*)\"\n    example = \"ytdl:https://www.youtube.com/watch?v=abcdefghijk\"\n\n    def __init__(self, match):\n        # import main youtube_dl module\n        ytdl_module = ytdl.import_module(config.get(\n            (\"extractor\", \"ytdl\"), \"module\"))\n        self.ytdl_module_name = ytdl_module.__name__\n\n        # find suitable youtube_dl extractor\n        self.ytdl_url = url = match[1]\n        generic = config.interpolate((\"extractor\", \"ytdl\"), \"generic\", True)\n        if generic == \"force\":\n            self.ytdl_ie_key = \"Generic\"\n            self.force_generic_extractor = True\n        else:\n            for ie in ytdl_module.extractor.gen_extractor_classes():\n                if ie.suitable(url):\n                    self.ytdl_ie_key = ie.ie_key()\n                    break\n            if not generic and self.ytdl_ie_key == \"Generic\":\n                raise self.exc.NoExtractorError()\n            self.force_generic_extractor = False\n\n        if self.ytdl_ie_key == \"Generic\" and config.interpolate(\n                (\"extractor\", \"ytdl\"), \"generic-category\", True):\n            # set subcategory to URL domain\n            self.category = \"ytdl-generic\"\n            self.subcategory = url[url.rfind(\"/\", None, 8)+1:url.find(\"/\", 8)]\n        else:\n            # set subcategory to youtube_dl extractor's key\n            self.subcategory = self.ytdl_ie_key\n        Extractor.__init__(self, match)\n\n    def items(self):\n        # import subcategory module\n        ytdl_module = ytdl.import_module(\n            config.get((\"extractor\", \"ytdl\", self.subcategory), \"module\") or\n            self.ytdl_module_name)\n        self.log.debug(\"Using %s\", ytdl_module)\n\n        # construct YoutubeDL object\n        extr_opts = {\n            \"extract_flat\"           : \"in_playlist\",\n            \"force_generic_extractor\": self.force_generic_extractor,\n        }\n        user_opts = {\n            \"retries\"                : self._retries,\n            \"socket_timeout\"         : self._timeout,\n            \"nocheckcertificate\"     : not self._verify,\n        }\n\n        if self._proxies:\n            user_opts[\"proxy\"] = self._proxies.get(\"http\")\n\n        username, password = self._get_auth_info()\n        if username:\n            user_opts[\"username\"], user_opts[\"password\"] = username, password\n        del username, password\n\n        ytdl_instance = ytdl.construct_YoutubeDL(\n            ytdl_module, self, user_opts, extr_opts)\n\n        # transfer cookies to ytdl\n        if cookies := self.cookies:\n            set_cookie = ytdl_instance.cookiejar.set_cookie\n            for cookie in cookies:\n                set_cookie(cookie)\n\n        # extract youtube_dl info_dict\n        try:\n            info_dict = ytdl_instance._YoutubeDL__extract_info(\n                self.ytdl_url,\n                ytdl_instance.get_info_extractor(self.ytdl_ie_key),\n                False, {}, True)\n        #  except ytdl_module.utils.YoutubeDLError:\n        #     raise self.exc.AbortExtraction(\"Failed to extract video data\")\n        except self.exc.ControlException:\n            raise\n        except Exception as exc:\n            raise self.exc.AbortExtraction(\n                f\"Failed to extract video data \"\n                f\"({exc.__class__.__name__}: {exc})\")\n\n        if not info_dict:\n            return\n        elif \"entries\" in info_dict:\n            results = self._process_entries(\n                ytdl_module, ytdl_instance, info_dict[\"entries\"])\n        else:\n            results = (info_dict,)\n\n        # yield results\n        for info_dict in results:\n            info_dict[\"extension\"] = None\n            info_dict[\"_ytdl_info_dict\"] = info_dict\n            info_dict[\"_ytdl_instance\"] = ytdl_instance\n\n            url = \"ytdl:\" + (info_dict.get(\"url\") or\n                             info_dict.get(\"webpage_url\") or\n                             self.ytdl_url)\n\n            yield Message.Directory, \"\", info_dict\n            yield Message.Url, url, info_dict\n\n    def _process_entries(self, ytdl_module, ytdl_instance, entries):\n        for entry in entries:\n            if not entry:\n                continue\n\n            if entry.get(\"_type\") in {\"url\", \"url_transparent\"}:\n                try:\n                    entry = ytdl_instance.extract_info(\n                        entry[\"url\"], False,\n                        ie_key=entry.get(\"ie_key\"))\n                except self.exc.ControlException:\n                    raise\n                except Exception as exc:\n                    if ytdl_instance.params.get(\"ignoreerrors\"):\n                        continue\n                    raise self.exc.AbortExtraction(\n                        f\"Failed to extract playlist entry \"\n                        f\"({exc.__class__.__name__}: {exc})\")\n                if not entry:\n                    continue\n\n            if \"entries\" in entry:\n                yield from self._process_entries(\n                    ytdl_module, ytdl_instance, entry[\"entries\"])\n            else:\n                yield entry\n\n\nif config.get((\"extractor\", \"ytdl\"), \"enabled\"):\n    # make 'ytdl:' prefix optional\n    YoutubeDLExtractor.pattern = r\"(?:ytdl:)?(.*)\"\n"
  },
  {
    "path": "gallery_dl/extractor/zerochan.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2022-2026 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Extractors for https://www.zerochan.net/\"\"\"\n\nfrom .booru import BooruExtractor\nfrom .. import text, util\nimport collections\n\nBASE_PATTERN = r\"(?:https?://)?(?:www\\.)?zerochan\\.net\"\n\n\nclass ZerochanExtractor(BooruExtractor):\n    \"\"\"Base class for zerochan extractors\"\"\"\n    category = \"zerochan\"\n    root = \"https://www.zerochan.net\"\n    filename_fmt = \"{id}.{extension}\"\n    archive_fmt = \"{id}\"\n    page_start = 1\n    per_page = 200\n    cookies_domain = \".zerochan.net\"\n    cookies_names = (\"z_id\", \"z_hash\")\n    useragent = util.USERAGENT_GALLERYDL\n    request_interval = (0.5, 1.5)\n\n    def login(self):\n        self._logged_in = True\n        if self.cookies_check(self.cookies_names):\n            return\n\n        username, password = self._get_auth_info()\n        if username:\n            return self.cookies_update(self.cache(\n                self._login_impl, username, password,\n                _exp=90*86400, _mem=False))\n\n        self._logged_in = False\n\n    def _login_impl(self, username, password):\n        self.log.info(\"Logging in as %s\", username)\n\n        url = self.root + \"/login\"\n        headers = {\n            \"Origin\"  : self.root,\n            \"Referer\" : url,\n        }\n        data = {\n            \"ref\"     : \"/\",\n            \"name\"    : username,\n            \"password\": password,\n            \"login\"   : \"Login\",\n        }\n\n        response = self.request(\n            url, method=\"POST\", headers=headers, data=data, expected=(500,))\n        if not response.history:\n            raise self.exc.AuthenticationError()\n\n        return response.cookies\n\n    def _parse_entry_html(self, entry_id):\n        url = f\"{self.root}/{entry_id}\"\n        page = self.request(url, expected=(500,)).text\n\n        try:\n            jsonld = self._extract_jsonld(page)\n        except Exception:\n            return {\"id\": entry_id}\n\n        extr = text.extract_from(page)\n        data = {\n            \"id\"      : text.parse_int(entry_id),\n            \"file_url\": jsonld[\"contentUrl\"],\n            \"date\"    : self.parse_datetime_iso(jsonld[\"datePublished\"]),\n            \"width\"   : text.parse_int(jsonld[\"width\"][:-3]),\n            \"height\"  : text.parse_int(jsonld[\"height\"][:-3]),\n            \"size\"    : text.parse_bytes(jsonld[\"contentSize\"][:-1]),\n            \"path\"    : text.split_html(extr(\n                'class=\"breadcrumbs', '</nav>'))[2:],\n            \"uploader\": extr('href=\"/user/', '\"'),\n            \"tags\"    : extr('<ul id=\"tags\"', '</ul>'),\n            \"source\"  : text.unescape(text.remove_html(extr(\n                'id=\"source-url\"', '</p>').rpartition(\"</s>\")[2])),\n        }\n\n        try:\n            data[\"author\"] = jsonld[\"author\"][\"name\"]\n        except Exception:\n            data[\"author\"] = \"\"\n\n        html = data[\"tags\"]\n        tags = data[\"tags\"] = []\n        for tag in html.split(\"<li class=\")[1:]:\n            category = text.extr(tag, '\"', '\"')\n            name = text.unescape(text.extr(tag, 'data-tag=\"', '\"'))\n            tags.append(category.partition(\" \")[0].capitalize() + \":\" + name)\n\n        return data\n\n    def _parse_entry_api(self, entry_id):\n        url = f\"{self.root}/{entry_id}?json\"\n        txt = self.request(url).text\n        try:\n            item = util.json_loads(txt)\n        except ValueError:\n            item = self._parse_json(txt)\n            item[\"id\"] = text.parse_int(entry_id)\n\n        data = {\n            \"id\"      : item[\"id\"],\n            \"file_url\": item[\"full\"],\n            \"width\"   : item[\"width\"],\n            \"height\"  : item[\"height\"],\n            \"size\"    : item[\"size\"],\n            \"name\"    : item[\"primary\"],\n            \"md5\"     : item[\"hash\"],\n            \"source\"  : item.get(\"source\"),\n        }\n\n        if not self._logged_in:\n            data[\"tags\"] = item[\"tags\"]\n\n        return data\n\n    def _parse_json(self, txt):\n        txt = text.re(r\"[\\x00-\\x1f\\x7f]\").sub(\"\", txt)\n        main, _, tags = txt.partition('tags\": [')\n\n        item = {}\n        for line in main.split(',  \"')[1:]:\n            key, _, value = line.partition('\": ')\n            if value:\n                if value[0] == '\"':\n                    value = value[1:-1]\n                else:\n                    value = text.parse_int(value)\n            if key:\n                item[key] = value\n\n        item[\"tags\"] = tags = tags[5:].split('\",    \"')\n        if tags:\n            tags[-1] = tags[-1][:-5]\n\n        return item\n\n    def _tags(self, post, page):\n        tags = collections.defaultdict(list)\n        for tag in post[\"tags\"]:\n            category, _, name = tag.partition(\":\")\n            tags[category].append(name)\n        for key, value in tags.items():\n            post[\"tags_\" + key.lower()] = value\n\n\nclass ZerochanTagExtractor(ZerochanExtractor):\n    subcategory = \"tag\"\n    directory_fmt = (\"{category}\", \"{search_tags}\")\n    pattern = BASE_PATTERN + r\"/(?!\\d+$)([^/?#]+)/?(?:\\?([^#]+))?\"\n    example = \"https://www.zerochan.net/TAG\"\n\n    def __init__(self, match):\n        ZerochanExtractor.__init__(self, match)\n        self.search_tag, self.query = match.groups()\n\n    def _init(self):\n        if self.config(\"pagination\") == \"html\":\n            self.posts = self.posts_html\n            self.per_page = 24\n        else:\n            self.posts = self.posts_api\n\n        if exts := self.config(\"extensions\"):\n            if isinstance(exts, str):\n                exts = exts.split(\",\")\n            self.exts = exts\n        else:\n            self.exts = (\"jpg\", \"png\", \"webp\", \"gif\")\n\n    def metadata(self):\n        return {\"search_tags\": text.unquote(\n            self.search_tag.replace(\"+\", \" \"))}\n\n    def posts_html(self):\n        url = self.root + \"/\" + self.search_tag\n        metadata = self.config(\"metadata\")\n\n        params = text.parse_query(self.query, empty=True)\n        params[\"p\"] = text.parse_int(params.get(\"p\"), self.page_start)\n\n        while True:\n            try:\n                page = self.request(\n                    url, params=params, expected=(500,)).text\n            except self.exc.HttpError as exc:\n                if exc.status == 404:\n                    return\n                raise\n            thumbs = text.extr(page, '<ul id=\"thumbs', '</ul>')\n            extr = text.extract_from(thumbs)\n\n            while True:\n                post = extr('<li class=\"', '>')\n                if not post:\n                    break\n\n                if metadata:\n                    entry_id = extr('href=\"/', '\"')\n                    post = self._parse_entry_html(entry_id)\n                    post.update(self._parse_entry_api(entry_id))\n                    yield post\n                else:\n                    yield {\n                        \"id\"    : extr('href=\"/', '\"'),\n                        \"name\"  : extr('alt=\"', '\"'),\n                        \"width\" : extr('title=\"', '&#10005;'),\n                        \"height\": extr('', ' '),\n                        \"size\"  : extr('', 'b'),\n                        \"file_url\": \"https://static.\" + extr(\n                            '<a href=\"https://static.', '\"'),\n                    }\n\n            if 'rel=\"next\"' not in page:\n                break\n            params[\"p\"] += 1\n\n    def posts_api(self):\n        url = self.root + \"/\" + self.search_tag\n        metadata = self.config(\"metadata\")\n\n        params = text.parse_query(self.query, empty=True)\n        params[\"p\"] = text.parse_int(params.get(\"p\"), self.page_start)\n        params.setdefault(\"l\", self.per_page)\n        params[\"json\"] = \"1\"\n\n        while True:\n            try:\n                response = self.request(\n                    url, params=params, allow_redirects=False)\n            except self.exc.HttpError as exc:\n                if exc.status == 404:\n                    return\n                raise\n\n            if response.status_code >= 300:\n                url = text.urljoin(self.root, response.headers[\"location\"])\n                self.log.warning(\"HTTP redirect to %s\", url)\n                if self.config(\"redirects\"):\n                    continue\n                raise self.exc.AbortExtraction()\n\n            data = response.json()\n            try:\n                posts = data[\"items\"]\n            except Exception:\n                self.log.debug(\"Server response: %s\", data)\n                return\n\n            if metadata:\n                for post in posts:\n                    post_id = post[\"id\"]\n                    post.update(self._parse_entry_html(post_id))\n                    post.update(self._parse_entry_api(post_id))\n                    yield post\n            else:\n                for post in posts:\n                    urls = self._urls(post)\n                    post[\"file_url\"] = next(urls)\n                    post[\"_fallback\"] = urls\n                    yield post\n\n            if not data.get(\"next\"):\n                return\n            params[\"p\"] += 1\n\n    def _urls(self, post, static=\"https://static.zerochan.net/.full.\"):\n        base = static + str(post[\"id\"]) + \".\"\n        for ext in self.exts:\n            yield base + ext\n\n\nclass ZerochanImageExtractor(ZerochanExtractor):\n    subcategory = \"image\"\n    pattern = BASE_PATTERN + r\"/(\\d+)\"\n    example = \"https://www.zerochan.net/12345\"\n\n    def posts(self):\n        image_id = self.groups[0]\n\n        try:\n            post = self._parse_entry_html(image_id)\n        except self.exc.HttpError as exc:\n            if exc.status in {404, 410}:\n                if msg := text.extr(exc.response.text, \"<h2>\", \"<\"):\n                    self.log.warning(f\"'{msg}'\")\n                return ()\n            raise\n\n        if self.config(\"metadata\"):\n            post.update(self._parse_entry_api(image_id))\n        return (post,)\n"
  },
  {
    "path": "gallery_dl/formatter.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2021-2026 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"String formatters\"\"\"\n\nimport os\nimport sys\nimport time\nimport string\nimport _string\nimport operator\nfrom . import text, util, dt\n\nNONE = util.NONE\n\n\ndef parse(format_string, default=NONE, fmt=format):\n    key = format_string, default, fmt\n\n    try:\n        return _CACHE[key]\n    except KeyError:\n        pass\n\n    if format_string and format_string[0] == \"\\f\":\n        kind, _, format_string = format_string.partition(\" \")\n        try:\n            cls = _FORMATTERS[kind[1:]]\n        except KeyError:\n            import logging\n            logging.getLogger(\"formatter\").error(\n                \"Invalid formatter type '%s'\", kind[1:])\n            cls = StringFormatter\n    else:\n        cls = StringFormatter\n\n    try:\n        formatter = _CACHE[key] = cls(format_string, default, fmt)\n    except Exception as exc:\n        import logging\n        logging.getLogger(\"formatter\").error(\n            \"Invalid format string '%s' (%s: %s)\",\n            format_string, exc.__class__.__name__, exc)\n        raise\n\n    return formatter\n\n\nclass StringFormatter():\n    \"\"\"Custom, extended version of string.Formatter\n\n    This string formatter implementation is a mostly performance-optimized\n    variant of the original string.Formatter class. Unnecessary features have\n    been removed (positional arguments, unused argument check) and new\n    formatting options have been added.\n\n    Extra Conversions:\n    - \"l\": calls str.lower on the target value\n    - \"u\": calls str.upper\n    - \"c\": calls str.capitalize\n    - \"C\": calls string.capwords\n    - \"g\": calls text.slugify()\n    - \"j\": calls json.dumps\n    - \"t\": calls str.strip\n    - \"T\": calls dt.to_ts_string()\n    - \"d\": calls dt.parse_ts()\n    - \"s\": calls str()\n    - \"S\": calls util.to_string()\n    - \"U\": calls urllib.parse.unescape\n    - \"r\": calls repr()\n    - \"a\": calls ascii()\n    - Example: {f!l} -> \"example\"; {f!u} -> \"EXAMPLE\"\n\n    # Go to _CONVERSIONS and _SPECIFIERS below to se all of them, read:\n    # https://github.com/mikf/gallery-dl/blob/master/docs/formatting.md\n\n    Extra Format Specifiers:\n    - \"?<before>/<after>/\":\n        Adds <before> and <after> to the actual value if it evaluates to True.\n        Otherwise the whole replacement field becomes an empty string.\n        Example: {f:?-+/+-/} -> \"-+Example+-\" (if \"f\" contains \"Example\")\n                             -> \"\"            (if \"f\" is None, 0, \"\")\n\n    - \"L<maxlen>/<replacement>/\":\n        Replaces the output with <replacement> if its length (in characters)\n        exceeds <maxlen>. Otherwise everything is left as is.\n        Example: {f:L5/too long/} -> \"foo\"      (if \"f\" is \"foo\")\n                                  -> \"too long\" (if \"f\" is \"foobar\")\n\n    - \"J<separator>/\":\n        Joins elements of a list (or string) using <separator>\n        Example: {f:J - /} -> \"a - b - c\" (if \"f\" is [\"a\", \"b\", \"c\"])\n\n    - \"R<old>/<new>/\":\n        Replaces all occurrences of <old> with <new>\n        Example: {f:R /_/} -> \"f_o_o_b_a_r\" (if \"f\" is \"f o o b a r\")\n    \"\"\"\n\n    def __init__(self, format_string, default=NONE, fmt=format):\n        self.default = default\n        self.format = fmt\n        self.result = []\n        self.fields = []\n\n        for literal_text, field_name, format_spec, conv in \\\n                _string.formatter_parser(format_string):\n            if literal_text:\n                self.result.append(literal_text)\n            if field_name:\n                self.fields.append((\n                    len(self.result),\n                    self._field_access(field_name, format_spec, conv),\n                ))\n                self.result.append(\"\")\n\n        if len(self.result) == 1:\n            if self.fields:\n                self.format_map = self.fields[0][1]\n            else:\n                self.format_map = lambda _: format_string\n            del self.result, self.fields\n        elif fmt is not format:\n            self.format_map = self.format_map_safe\n\n    def format_map(self, kwdict):\n        \"\"\"Apply 'kwdict' to the initial format_string and return its result\"\"\"\n        result = self.result\n        for index, func in self.fields:\n            result[index] = func(kwdict)\n        return \"\".join(result)\n\n    def format_map_safe(self, kwdict):\n        \"\"\"Like 'format_map', but ensures all items are 'str'\"\"\"\n        result = self.result\n        for index, func in self.fields:\n            result[index] = str(func(kwdict))\n        return \"\".join(result)\n\n    def _field_access(self, field_name, format_spec, conversion):\n        fmt = self._parse_format_spec(format_spec, conversion)\n\n        if \"|\" in field_name:\n            return self._apply_list([\n                parse_field_name(fn)\n                for fn in field_name.split(\"|\")\n            ], fmt)\n        else:\n            key, funcs = parse_field_name(field_name)\n            return self._apply(key, funcs, fmt)\n\n    def _apply(self, key, funcs, fmt):\n        if key in _GLOBALS:\n            def wrap(_):\n                try:\n                    obj = gobj()\n                    for func in funcs:\n                        obj = func(obj)\n                except Exception:\n                    obj = self.default\n                return fmt(obj)\n            gobj = _GLOBALS[key]\n        elif funcs:\n            def wrap(kwdict):\n                try:\n                    obj = kwdict[key]\n                    for func in funcs:\n                        obj = func(obj)\n                except Exception:\n                    obj = self.default\n                return fmt(obj)\n        else:\n            def wrap(kwdict):\n                return fmt(kwdict[key] if key in kwdict else self.default)\n            del funcs\n        return wrap\n\n    def _apply_list(self, lst, fmt):\n        def wrap(kwdict):\n            for key, funcs in lst:\n                try:\n                    obj = _GLOBALS[key]() if key in _GLOBALS else kwdict[key]\n                    for func in funcs:\n                        obj = func(obj)\n                    if obj:\n                        break\n                except Exception:\n                    obj = None\n            else:\n                if obj is None:\n                    obj = self.default\n            return fmt(obj)\n        return wrap\n\n    def _parse_format_spec(self, format_spec, conversion):\n        fmt = _build_format_func(format_spec, self.format)\n        if not conversion:\n            return fmt\n\n        conversion = _CONVERSIONS[conversion]\n        if fmt is self.format:\n            return conversion\n        else:\n            return lambda obj: fmt(conversion(obj))\n\n\nclass ExpressionFormatter():\n    \"\"\"Generate text by evaluating a Python expression\"\"\"\n\n    def __init__(self, expression, default=NONE, fmt=None):\n        self.format_map = util.compile_expression(expression)\n\n\nclass FStringFormatter():\n    \"\"\"Generate text by evaluating an f-string literal\"\"\"\n\n    def __init__(self, fstring, default=NONE, fmt=None):\n        self.format_map = util.compile_expression(f'f\"\"\"{fstring}\"\"\"')\n\n\ndef _init_jinja():\n    import jinja2\n    from . import config\n\n    if opts := config.get((), \"jinja\"):\n        JinjaFormatter.env = env = jinja2.Environment(\n            **opts.get(\"environment\") or {})\n    else:\n        JinjaFormatter.env = jinja2.Environment()\n        return\n\n    if policies := opts.get(\"policies\"):\n        env.policies.update(policies)\n\n    if path := opts.get(\"filters\"):\n        module = util.import_file(path).__dict__\n        env.filters.update(\n            module[\"__filters__\"] if \"__filters__\" in module else module)\n\n    if path := opts.get(\"tests\"):\n        module = util.import_file(path).__dict__\n        env.tests.update(\n            module[\"__tests__\"] if \"__tests__\" in module else module)\n\n\nclass JinjaFormatter():\n    \"\"\"Generate text by evaluating a Jinja template string\"\"\"\n    env = None\n\n    def __init__(self, source, default=NONE, fmt=None):\n        if self.env is None:\n            _init_jinja()\n        self.format_map = self.env.from_string(source).render\n\n\nclass ModuleFormatter():\n    \"\"\"Generate text by calling an external function\"\"\"\n\n    def __init__(self, function_spec, default=NONE, fmt=None):\n        module_name, _, function_name = function_spec.rpartition(\":\")\n        module = util.import_file(module_name)\n        self.format_map = getattr(module, function_name)\n\n\nclass TemplateFormatter(StringFormatter):\n    \"\"\"Read format_string from file\"\"\"\n\n    def __init__(self, path, default=NONE, fmt=format):\n        with open(util.expand_path(path), encoding=\"utf-8\") as fp:\n            format_string = fp.read()\n        StringFormatter.__init__(self, format_string, default, fmt)\n\n\nclass TemplateFStringFormatter(FStringFormatter):\n    \"\"\"Read f-string from file\"\"\"\n\n    def __init__(self, path, default=NONE, fmt=None):\n        with open(util.expand_path(path), encoding=\"utf-8\") as fp:\n            fstring = fp.read()\n        FStringFormatter.__init__(self, fstring, default, fmt)\n\n\nclass TemplateJinjaFormatter(JinjaFormatter):\n    \"\"\"Generate text by evaluating a Jinja template\"\"\"\n\n    def __init__(self, path, default=NONE, fmt=None):\n        with open(util.expand_path(path), encoding=\"utf-8\") as fp:\n            source = fp.read()\n        JinjaFormatter.__init__(self, source, default, fmt)\n\n\ndef parse_field_name(field_name):\n    if field_name[0] == \"'\":\n        return \"_lit\", (operator.itemgetter(field_name[1:-1]),)\n\n    first, rest = _string.formatter_field_name_split(field_name)\n    funcs = []\n\n    for is_attr, key in rest:\n        if is_attr:\n            func = _attrgetter\n        else:\n            func = operator.itemgetter\n            try:\n                if \":\" in key:\n                    if key[0] == \"b\":\n                        func = _bytesgetter\n                        key = _slice(key[1:])\n                    else:\n                        key = _slice(key)\n                elif key[0] == \"-\":\n                    key = int(key)\n                else:\n                    key = key.strip(\"\\\"'\")\n            except TypeError:\n                pass  # key is an integer\n\n        funcs.append(func(key))\n\n    return first, funcs\n\n\ndef _slice(indices):\n    start, _, stop = indices.partition(\":\")\n    stop, _, step = stop.partition(\":\")\n    return slice(\n        int(start) if start else None,\n        int(stop) if stop else None,\n        int(step) if step else None,\n    )\n\n\ndef _attrgetter(key):\n\n    if key.isdecimal() or key[0] == \"-\":\n        try:\n            return operator.itemgetter(int(key))\n        except ValueError:\n            pass\n\n    def apply_key(obj):\n        try:\n            return obj[key]\n        except (TypeError, KeyError):\n            return getattr(obj, key)\n    return apply_key\n\n\ndef _bytesgetter(slice):\n\n    def apply_slice_bytes(obj):\n        return obj.encode(_ENCODING)[slice].decode(_ENCODING, \"ignore\")\n\n    return apply_slice_bytes\n\n\ndef _build_format_func(format_spec, default):\n    if format_spec:\n        return _FORMAT_SPECIFIERS.get(\n            format_spec[0], _default_format)(format_spec, default)\n    return default\n\n\ndef _parse_optional(format_spec, default):\n    before, after, format_spec = format_spec.split(_SEPARATOR, 2)\n    before = before[1:]\n    fmt = _build_format_func(format_spec, default)\n\n    def optional(obj):\n        return f\"{before}{fmt(obj)}{after}\" if obj else \"\"\n    return optional\n\n\ndef _parse_slice(format_spec, default):\n    indices, _, format_spec = format_spec.partition(\"]\")\n    fmt = _build_format_func(format_spec, default)\n\n    if indices[1] == \"b\":\n        slice_bytes = _bytesgetter(_slice(indices[2:]))\n\n        def apply_slice(obj):\n            return fmt(slice_bytes(obj))\n\n    else:\n        slice = _slice(indices[1:])\n\n        def apply_slice(obj):\n            return fmt(obj[slice])\n\n    return apply_slice\n\n\ndef _parse_arithmetic(format_spec, default):\n    op, _, format_spec = format_spec.partition(_SEPARATOR)\n    fmt = _build_format_func(format_spec, default)\n\n    value = int(op[2:])\n    op = op[1]\n\n    if op == \"+\":\n        return lambda obj: fmt(obj + value)\n    if op == \"-\":\n        return lambda obj: fmt(obj - value)\n    if op == \"*\":\n        return lambda obj: fmt(obj * value)\n\n    return fmt\n\n\ndef _parse_conversion(format_spec, default):\n    conversions, _, format_spec = format_spec.partition(_SEPARATOR)\n    convs = [_CONVERSIONS[c] for c in conversions[1:]]\n    fmt = _build_format_func(format_spec, default)\n\n    if len(conversions) <= 2:\n\n        def convert_one(obj):\n            return fmt(conv(obj))\n        conv = _CONVERSIONS[conversions[1]]\n        return convert_one\n\n    def convert_many(obj):\n        for conv in convs:\n            obj = conv(obj)\n        return fmt(obj)\n    convs = [_CONVERSIONS[c] for c in conversions[1:]]\n    return convert_many\n\n\ndef _parse_maxlen(format_spec, default):\n    maxlen, replacement, format_spec = format_spec.split(_SEPARATOR, 2)\n    fmt = _build_format_func(format_spec, default)\n\n    if maxlen[1] == \"b\":\n        maxlen = text.parse_int(maxlen[2:])\n\n        def mlen(obj):\n            obj = fmt(obj)\n            return obj if len(obj.encode(_ENCODING)) <= maxlen else replacement\n    else:\n        maxlen = text.parse_int(maxlen[1:])\n\n        def mlen(obj):\n            obj = fmt(obj)\n            return obj if len(obj) <= maxlen else replacement\n    return mlen\n\n\ndef _parse_identity(format_spec, default):\n    return util.identity\n\n\ndef _parse_join(format_spec, default):\n    separator, _, format_spec = format_spec.partition(_SEPARATOR)\n    join = separator[1:].join\n    fmt = _build_format_func(format_spec, default)\n\n    def apply_join(obj):\n        if isinstance(obj, str):\n            return fmt(obj)\n        return fmt(join(obj))\n    return apply_join\n\n\ndef _parse_map(format_spec, default):\n    key, _, format_spec = format_spec.partition(_SEPARATOR)\n    key = key[1:]\n    fmt = _build_format_func(format_spec, default)\n\n    def map_(obj):\n        if not obj or isinstance(obj, str):\n            return fmt(obj)\n\n        results = []\n        for item in obj:\n            if isinstance(item, dict):\n                value = item.get(key, ...)\n                results.append(default if value is ... else value)\n            else:\n                results.append(item)\n        return fmt(results)\n\n    return map_\n\n\ndef _parse_replace(format_spec, default):\n    old, new, format_spec = format_spec.split(_SEPARATOR, 2)\n    old = old[1:]\n    fmt = _build_format_func(format_spec, default)\n\n    def replace(obj):\n        return fmt(obj.replace(old, new))\n    return replace\n\n\ndef _parse_datetime(format_spec, default):\n    dt_format, _, format_spec = format_spec.partition(_SEPARATOR)\n    dt_format = dt_format[1:]\n    fmt = _build_format_func(format_spec, default)\n\n    def dt_parse(obj):\n        return fmt(dt.parse(obj, dt_format))\n    return dt_parse\n\n\ndef _parse_offset(format_spec, default):\n    offset, _, format_spec = format_spec.partition(_SEPARATOR)\n    offset = offset[1:]\n    fmt = _build_format_func(format_spec, default)\n\n    if not offset or offset == \"local\":\n        def off(dt_utc):\n            local = time.localtime(dt.to_ts(dt_utc))\n            return fmt(dt_utc + dt.timedelta(0, local.tm_gmtoff))\n    else:\n        hours, _, minutes = offset.partition(\":\")\n        offset = 3600 * int(hours)\n        if minutes:\n            offset += 60 * (int(minutes) if offset > 0 else -int(minutes))\n        offset = dt.timedelta(0, offset)\n\n        def off(obj):\n            return fmt(obj + offset)\n    return off\n\n\ndef _parse_sort(format_spec, default):\n    args, _, format_spec = format_spec.partition(_SEPARATOR)\n    fmt = _build_format_func(format_spec, default)\n\n    if \"d\" in args or \"r\" in args:\n        def sort(obj):\n            return fmt(sorted(obj, reverse=True))\n    else:\n        def sort(obj):\n            return fmt(sorted(obj))\n    return sort\n\n\ndef _parse_limit(format_spec, default):\n    limit, hint, format_spec = format_spec.split(_SEPARATOR, 2)\n    fmt = _build_format_func(format_spec, default)\n\n    if limit[1] == \"b\":\n        hint = hint.encode(_ENCODING)\n        limit = int(limit[2:])\n        limit_hint = limit - len(hint)\n\n        def apply_limit(obj):\n            objb = obj.encode(_ENCODING)\n            if len(objb) > limit:\n                obj = (objb[:limit_hint] + hint).decode(_ENCODING, \"ignore\")\n            return fmt(obj)\n    else:\n        limit = int(limit[1:])\n        limit_hint = limit - len(hint)\n\n        def apply_limit(obj):\n            if len(obj) > limit:\n                obj = obj[:limit_hint] + hint\n            return fmt(obj)\n    return apply_limit\n\n\ndef _default_format(format_spec, default):\n    def wrap(obj):\n        return format(obj, format_spec)\n    return wrap\n\n\nclass Literal():\n    # __getattr__, __getattribute__, and __class_getitem__\n    # are all slower than regular __getitem__\n\n    def __getitem__(self, key):\n        return key\n\n\n_literal = Literal()\n\n_CACHE = {}\n_ENCODING = sys.getfilesystemencoding()\n_SEPARATOR = \"/\"\n_FORMATTERS = {\n    \"E\" : ExpressionFormatter,\n    \"F\" : FStringFormatter,\n    \"J\" : JinjaFormatter,\n    \"M\" : ModuleFormatter,\n    \"S\" : StringFormatter,\n    \"T\" : TemplateFormatter,\n    \"TF\": TemplateFStringFormatter,\n    \"FT\": TemplateFStringFormatter,\n    \"TJ\": TemplateJinjaFormatter,\n    \"JT\": TemplateJinjaFormatter,\n}\n_GLOBALS = {\n    \"_env\": lambda: os.environ,\n    \"_lit\": lambda: _literal,\n    \"_now\": dt.datetime.now,\n    \"_nul\": lambda: util.NONE,\n}\n_CONVERSIONS = {\n    \"l\": str.lower,\n    \"u\": str.upper,\n    \"c\": str.capitalize,\n    \"C\": string.capwords,\n    \"j\": util.json_dumps,\n    \"t\": str.strip,\n    \"n\": len,\n    \"L\": util.code_to_language,\n    \"T\": dt.to_ts_string,\n    \"d\": dt.parse_ts,\n    \"D\": dt.convert,\n    \"q\": text.quote,\n    \"Q\": text.unquote,\n    \"U\": text.unescape,\n    \"H\": lambda s: text.unescape(text.remove_html(s)),\n    \"g\": text.slugify,\n    \"R\": text.extract_urls,\n    \"W\": text.sanitize_whitespace,\n    \"S\": util.to_string,\n    \"s\": str,\n    \"r\": repr,\n    \"a\": ascii,\n    \"i\": int,\n    \"f\": float,\n}\n_FORMAT_SPECIFIERS = {\n    \"?\": _parse_optional,\n    \"[\": _parse_slice,\n    \"A\": _parse_arithmetic,\n    \"C\": _parse_conversion,\n    \"D\": _parse_datetime,\n    \"I\": _parse_identity,\n    \"J\": _parse_join,\n    \"L\": _parse_maxlen,\n    \"M\": _parse_map,\n    \"O\": _parse_offset,\n    \"R\": _parse_replace,\n    \"S\": _parse_sort,\n    \"X\": _parse_limit,\n}\n"
  },
  {
    "path": "gallery_dl/job.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2015-2026 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nimport sys\nimport errno\nimport logging\nimport functools\nimport collections\n\nfrom . import (\n    extractor,\n    downloader,\n    postprocessor,\n    archive,\n    config,\n    exception,\n    formatter,\n    output,\n    path,\n    text,\n    util,\n    dt,\n    version,\n)\nfrom .extractor.message import Message\nstdout_write = output.stdout_write\nFLAGS = util.FLAGS\n\n\nclass Job():\n    \"\"\"Base class for Job types\"\"\"\n    ulog = None\n    _logger_adapter = output.LoggerAdapter\n\n    def __init__(self, extr, parent=None):\n        if isinstance(extr, str):\n            extr = extractor.find(extr)\n        if not extr:\n            raise exception.NoExtractorError()\n\n        self.extractor = extr\n        self.pathfmt = None\n        self.status = 0\n        self.kwdict = {}\n        self.kwdict_eval = False\n\n        if cfgpath := self._build_config_path(parent):\n            if isinstance(cfgpath, list):\n                extr.config = extr._config_shared\n                extr.config_accumulate = extr._config_shared_accumulate\n            extr._cfgpath = cfgpath\n\n        if actions := extr.config(\"actions\"):\n            from .actions import LoggerAdapter, parse_logging\n            self._logger_adapter = LoggerAdapter\n            self._logger_actions = parse_logging(actions)\n\n        path_proxy = output.PathfmtProxy(self)\n        self._logger_extra = {\n            \"job\"      : self,\n            \"extractor\": extr,\n            \"path\"     : path_proxy,\n            \"keywords\" : output.KwdictProxy(self),\n        }\n        extr.log = self._wrap_logger(extr.log)\n        extr.log.debug(\"Using %s for '%s'\", extr.__class__.__name__, extr.url)\n\n        self.metadata_url = extr.config2(\n            \"metadata-url\", \"url-metadata\", \"_url\")\n        self.metadata_http = extr.config2(\n            \"metadata-http\", \"http-metadata\")\n        metadata_path = extr.config2(\n            \"metadata-path\", \"path-metadata\", \"_path\")\n        metadata_version = extr.config2(\n            \"metadata-version\", \"version-metadata\")\n        metadata_extractor = extr.config2(\n            \"metadata-extractor\", \"extractor-metadata\", \"_extr\")\n\n        if metadata_path:\n            self.kwdict[metadata_path] = path_proxy\n        if metadata_extractor:\n            self.kwdict[metadata_extractor] = extr\n        if metadata_version:\n            self.kwdict[metadata_version] = {\n                \"version\"         : version.__version__,\n                \"is_executable\"   : util.EXECUTABLE,\n                \"current_git_head\": util.git_head()\n            }\n        # user-supplied metadata\n        kwdict = extr.config(\"keywords\")\n        if kwdict_global := extr.config(\"keywords-global\"):\n            kwdict = {**kwdict_global, **kwdict} if kwdict else kwdict_global\n        elif not kwdict:\n            return\n\n        if extr.config(\"keywords-eval\"):\n            self.kwdict_eval = []\n            for key, value in kwdict.items():\n                if isinstance(value, str):\n                    fmt = formatter.parse(value, None, util.identity)\n                    self.kwdict_eval.append((key, fmt.format_map))\n                else:\n                    self.kwdict[key] = value\n        else:\n            self.kwdict.update(kwdict)\n\n    def _build_config_path(self, parent):\n        extr = self.extractor\n        cfgpath = []\n\n        if parent is None:\n            self.parents = ()\n        else:\n            pextr = parent.extractor\n            if extr.category == pextr.category or \\\n                    extr.category in parent.parents:\n                parents = parent.parents\n            else:\n                parents = parent.parents + (pextr.category,)\n            self.parents = parents\n\n            if pextr.config(\"category-transfer\", pextr.categorytransfer):\n                extr.category = pextr.category\n                extr.subcategory = pextr.subcategory\n                return pextr._cfgpath\n\n            if parents:\n                sub = extr.subcategory\n                for category in parents:\n                    cat = f\"{category}>{extr.category}\"\n                    cfgpath.append((cat, sub))\n                    cfgpath.append((category + \">*\", sub))\n                cfgpath.append((extr.category, sub))\n\n        if extr.basecategory:\n            if not cfgpath:\n                cfgpath.append((extr.category, extr.subcategory))\n            if extr.basesubcategory:\n                cfgpath.append((extr.basesubcategory, extr.subcategory))\n            cfgpath.append((extr.basecategory, extr.subcategory))\n\n        return cfgpath\n\n    def run(self):\n        \"\"\"Execute or run the job\"\"\"\n        extractor = self.extractor\n        log = extractor.log\n\n        self._init()\n\n        # sleep before extractor start\n        sleep = util.build_duration_func(\n            extractor.config(\"sleep-extractor\"))\n        if sleep is not None:\n            extractor.sleep(sleep(), \"extractor\")\n\n        try:\n            msg = self.dispatch(extractor)\n        except exception.StopExtraction as exc:\n            if exc.depth > 1 and exc.target != extractor.__class__.subcategory:\n                exc.depth -= 1\n                raise\n            pass\n        except exception.AbortExtraction as exc:\n            log.traceback(exc)\n            log.error(exc.message)\n            self.status |= exc.code\n        except (exception.TerminateExtraction, exception.RestartExtraction):\n            raise\n        except exception.GalleryDLException as exc:\n            log.error(\"%s: %s\", exc.__class__.__name__, exc)\n            log.traceback(exc)\n            self.status |= exc.code\n        except OSError as exc:\n            log.traceback(exc)\n            if (name := exc.__class__.__name__) == \"JSONDecodeError\":\n                log.error(\"Failed to parse JSON data:  %s: %s\", name, exc)\n                self.status |= 1\n            else:  # regular OSError\n                log.error(\"Unable to download data:  %s: %s\", name, exc)\n                self.status |= 128\n        except Exception as exc:\n            log.error((\"An unexpected error occurred: %s - %s. \"\n                       \"Please run gallery-dl again with the --verbose flag, \"\n                       \"copy its output and report this issue on \"\n                       \"https://github.com/mikf/gallery-dl/issues .\"),\n                      exc.__class__.__name__, exc)\n            log.traceback(exc)\n            self.status |= 1\n        except BaseException:\n            self.status |= 1\n            raise\n        else:\n            if msg is None:\n                log.info(\"No results for %s\", extractor.url)\n        finally:\n            if extractor.status:\n                self.status |= extractor.status\n            self.handle_finalize()\n            if extractor.finalize is not None:\n                extractor.finalize(self.status)\n\n        return self.status\n\n    def dispatch(self, messages):\n        \"\"\"Call the appropriate message handler\"\"\"\n        msg = None\n        process = True\n        metadata_url = self.metadata_url\n\n        if follow := self.extractor.config(\"follow\"):\n            follow = formatter.parse(follow, None, util.identity).format_map\n            follow_urls = follow_kwdict = None\n        else:\n            follow = follow_urls = None\n\n        for msg, url, kwdict in messages:\n\n            if msg == Message.Directory:\n                if follow_urls is not None:\n                    for furl in follow_urls:\n                        if metadata_url is not None:\n                            follow_kwdict[metadata_url] = furl\n                        if self.pred_queue(furl, follow_kwdict):\n                            self.handle_queue(furl, follow_kwdict)\n                    follow_urls = None\n\n                self.update_kwdict(kwdict)\n                if self.pred_post(url, kwdict):\n                    process = True\n                    self.handle_directory(kwdict)\n                    if follow is not None:\n                        follow_urls = self._collect_urls(follow(kwdict))\n                        if follow_urls is not None:\n                            follow_kwdict = kwdict.copy()\n                else:\n                    process = None\n                if FLAGS.POST is not None:\n                    FLAGS.process(\"POST\")\n\n            elif process is None:\n                continue\n            elif FLAGS.POST is False:\n                FLAGS.POST = process = None\n                continue\n\n            elif msg == Message.Url:\n                if metadata_url is not None:\n                    kwdict[metadata_url] = url\n                self.update_kwdict(kwdict)\n                if self.pred_url(url, kwdict):\n                    if FLAGS.FILE is False:\n                        FLAGS.FILE = None\n                        continue\n                    self.handle_url(url, kwdict)\n                if FLAGS.FILE is not None:\n                    FLAGS.process(\"FILE\")\n\n            elif msg == Message.Queue:\n                self.update_kwdict(kwdict)\n                if metadata_url is not None:\n                    kwdict[metadata_url] = url\n                if self.pred_queue(url, kwdict):\n                    if FLAGS.CHILD is False:\n                        FLAGS.CHILD = None\n                        continue\n                    self.handle_queue(url, kwdict)\n                if FLAGS.CHILD is not None:\n                    FLAGS.process(\"CHILD\")\n\n        if follow_urls is not None:\n            for furl in follow_urls:\n                if metadata_url is not None:\n                    follow_kwdict[metadata_url] = furl\n                if self.pred_queue(furl, follow_kwdict):\n                    self.handle_queue(furl, follow_kwdict)\n\n        return msg\n\n    def handle_url(self, url, kwdict):\n        \"\"\"Handle Message.Url\"\"\"\n\n    def handle_directory(self, kwdict):\n        \"\"\"Handle Message.Directory\"\"\"\n\n    def handle_queue(self, url, kwdict):\n        \"\"\"Handle Message.Queue\"\"\"\n\n    def handle_finalize(self):\n        \"\"\"Handle job finalization\"\"\"\n\n    def update_kwdict(self, kwdict):\n        \"\"\"Update 'kwdict' with additional metadata\"\"\"\n        extr = self.extractor\n        kwdict[\"category\"] = extr.category\n        kwdict[\"subcategory\"] = extr.subcategory\n        if self.metadata_http:\n            kwdict.pop(self.metadata_http, None)\n        if extr.kwdict:\n            kwdict.update(extr.kwdict)\n        if self.kwdict:\n            kwdict.update(self.kwdict)\n        if self.kwdict_eval:\n            for key, valuegen in self.kwdict_eval:\n                kwdict[key] = valuegen(kwdict)\n\n    def initialize(self):\n        pass\n\n    def _init(self):\n        extr = self.extractor\n\n        extr.initialize()\n        self.pred_url = self._prepare_predicates(\n            \"file\", \"image\", extr.skip_files)\n        self.pred_post = self._prepare_predicates(\n            \"post\", None, extr.skip_posts)\n        self.pred_queue = self._prepare_predicates(\n            \"child\", \"chapter\", extr.skip_children)\n\n        init = extr.config(\"init\", False)\n        if init and init != \"lazy\":\n            self.initialize()\n\n    def _collect_urls(self, source):\n        if not source:\n            return None\n        if isinstance(source, list):\n            return source\n        if isinstance(source, str):\n            if urls := text.extract_urls(source):\n                return urls\n\n    def _prepare_predicates(self, target, alt=None, skip=None):\n        predicates = []\n        extr = self.extractor\n\n        if extr.config(target + \"-unique\") or \\\n                alt is not None and extr.config(alt + \"-unique\"):\n            predicates.append(util.predicate_unique())\n\n        if target == \"post\":\n            if dta := extr.config(\"date-after\"):\n                dta = dt.convert(dta)\n            if dtb := extr.config(\"date-before\"):\n                dtb = dt.convert(dtb)\n            if dta or dtb:\n                predicates.append(util.predicate_date(\n                    dtb or None, dta or None, extr.skip_date))\n\n            if tl := extr.config2(\"whitelist-tags\", \"tags-whitelist\"):\n                predicates.append(util.predicate_tags(tl, True))\n            elif tl := extr.config2(\"blacklist-tags\", \"tags-blacklist\"):\n                predicates.append(util.predicate_tags(tl, False))\n\n        if (pfilter := extr.config(target + \"-filter\")) or \\\n                alt is not None and (pfilter := extr.config(alt + \"-filter\")):\n            try:\n                predicates.append(util.predicate_filter(pfilter, target))\n            except (SyntaxError, ValueError, TypeError) as exc:\n                extr.log.warning(exc)\n\n        if (prange := extr.config(target + \"-range\")) or \\\n                alt is not None and (prange := extr.config(alt + \"-range\")):\n            try:\n                if pfilter:\n                    skip = None\n                flag = target if alt is not None else None\n                predicates.append(util.predicate_range(prange, skip, flag))\n            except ValueError as exc:\n                extr.log.warning(\"invalid %s range: %s\", target, exc)\n\n        return util.predicate_build(predicates)\n\n    def get_logger(self, name):\n        return self._wrap_logger(logging.getLogger(name))\n\n    def _wrap_logger(self, logger):\n        return self._logger_adapter(logger, self)\n\n    def _write_unsupported(self, url):\n        if self.ulog is not None:\n            self.ulog.info(url)\n\n\nclass DownloadJob(Job):\n    \"\"\"Download images into appropriate directory/filename locations\"\"\"\n\n    def __init__(self, url, parent=None):\n        Job.__init__(self, url, parent)\n        self.log = self.get_logger(\"download\")\n        self.fallback = None\n        self.archive = None\n        self.sleep = None\n        self.hooks = ()\n        self.downloaders = {}\n        self.out = output.select()\n        self.visited = set() if parent is None else parent.visited\n        self._extractor_filter = None\n        self._skipcnt = 0\n\n    def handle_url(self, url, kwdict):\n        \"\"\"Download the resource specified in 'url'\"\"\"\n        hooks = self.hooks\n        pathfmt = self.pathfmt\n        archive = self.archive\n\n        # prepare download\n        pathfmt.set_filename(kwdict)\n\n        if \"prepare\" in hooks:\n            for callback in hooks[\"prepare\"]:\n                callback(pathfmt)\n\n        if archive is not None and archive.check(kwdict):\n            pathfmt.fix_extension()\n            self.handle_skip()\n            return\n\n        if pathfmt.extension and not self.metadata_http:\n            pathfmt.build_path()\n\n            if pathfmt.exists():\n                if archive is not None and self._archive_write_skip:\n                    archive.add(kwdict)\n                self.handle_skip()\n                return\n\n        if \"prepare-after\" in hooks:\n            for callback in hooks[\"prepare-after\"]:\n                callback(pathfmt)\n\n            if kwdict.pop(\"_file_recheck\", False) and pathfmt.exists():\n                if archive is not None and self._archive_write_skip:\n                    archive.add(kwdict)\n                self.handle_skip()\n                return\n\n        if self.sleep is not None:\n            self.extractor.sleep(self.sleep(), \"download\")\n\n        # download from URL\n        failed = False\n        try:\n            if not self.download(url):\n                # use fallback URLs if available/enabled\n                fallback = kwdict.get(\"_fallback\", ()) if self.fallback else ()\n                for num, url in enumerate(fallback, 1):\n                    util.remove_file(pathfmt.temppath)\n                    self.log.info(\"Trying fallback URL #%d\", num)\n                    if self.download(url):\n                        break\n                else:\n                    failed = True\n        except exception.StopDownload:\n            failed = True\n\n        if failed:\n            self.status |= 4\n            self.log.error(\"Failed to download %s\", pathfmt.filename or url)\n            if \"error\" in hooks:\n                for callback in hooks[\"error\"]:\n                    callback(pathfmt)\n            return\n\n        if not pathfmt.temppath:\n            if archive is not None and self._archive_write_skip:\n                archive.add(kwdict)\n            self.handle_skip()\n            return\n\n        # run postprocessors\n        if \"file\" in hooks:\n            for callback in hooks[\"file\"]:\n                callback(pathfmt)\n\n        # process download flag\n        if FLAGS.DOWNLOAD is not None:\n            FLAGS.DOWNLOAD = None\n            self.status |= 4\n            self.log.error(\"Failed to download %s\", pathfmt.filename or url)\n            if \"error\" in hooks:\n                for callback in hooks[\"error\"]:\n                    callback(pathfmt)\n            return\n\n        # download succeeded\n        pathfmt.finalize()\n        self.out.success(pathfmt.path)\n        self._skipcnt = 0\n        if archive is not None and self._archive_write_file:\n            archive.add(kwdict)\n        if \"after\" in hooks:\n            for callback in hooks[\"after\"]:\n                callback(pathfmt)\n        if archive is not None and self._archive_write_after:\n            archive.add(kwdict)\n\n    def handle_directory(self, kwdict):\n        \"\"\"Set and create the target directory for downloads\"\"\"\n        if self.pathfmt is None:\n            self.initialize(kwdict)\n        else:\n            if \"post-after\" in self.hooks:\n                for callback in self.hooks[\"post-after\"]:\n                    callback(self.pathfmt)\n            if FLAGS.POST is not None:\n                FLAGS.process(\"POST\")\n            self.pathfmt.set_directory(kwdict)\n        if \"post\" in self.hooks:\n            for callback in self.hooks[\"post\"]:\n                callback(self.pathfmt)\n\n    def handle_queue(self, url, kwdict):\n        if url in self.visited:\n            return\n        self.visited.add(url)\n\n        if \"child\" in self.hooks:\n            pathfmt = self.pathfmt\n            pathfmt.kwdict = kwdict\n            for callback in self.hooks[\"child\"]:\n                callback(pathfmt)\n\n        if cls := kwdict.get(\"_extractor\"):\n            extr = cls.from_url(url)\n        else:\n            if extr := extractor.find(url):\n                if self._extractor_filter is None:\n                    self._extractor_filter = self._build_extractor_filter()\n                if not self._extractor_filter(extr):\n                    extr = None\n\n        if extr:\n            job = self.__class__(extr, self)\n            pfmt = self.pathfmt\n            pextr = self.extractor\n            parent = pextr.config(\"parent\", pextr.parent)\n\n            if pfmt and pextr.config(\"parent-directory\", parent):\n                extr._parentdir = pfmt.directory\n            else:\n                extr._parentdir = pextr._parentdir\n\n            if pmeta := pextr.config2(\n                    \"parent-metadata\", \"metadata-parent\", parent or \"_parent\"):\n                if isinstance(pmeta, str):\n                    data = self.kwdict.copy()\n                    if kwdict:\n                        data.update(kwdict)\n                    job.kwdict[pmeta] = data\n                else:\n                    if self.kwdict:\n                        job.kwdict.update(self.kwdict)\n                    if kwdict:\n                        job.kwdict.update(kwdict)\n                        if \"_extractor\" in kwdict:\n                            del job.kwdict[\"_extractor\"]\n\n            if pextr.config(\"parent-session\", parent):\n                extr.session = pextr.session\n\n            while True:\n                try:\n                    if pextr.config(\"parent-skip\", parent):\n                        job._skipcnt = self._skipcnt\n                        status = job.run()\n                        self._skipcnt = job._skipcnt\n                    else:\n                        status = job.run()\n\n                    if status:\n                        self.status |= status\n                        if (status & 95 and   # not FormatError or OSError\n                                \"_fallback\" in kwdict and self.fallback):\n                            fallback = kwdict[\"_fallback\"] = \\\n                                iter(kwdict[\"_fallback\"])\n                            try:\n                                url = next(fallback)\n                            except StopIteration:\n                                pass\n                            else:\n                                pextr.log.info(\"Downloading fallback URL\")\n                                text.nameext_from_url(url, kwdict)\n                                if kwdict[\"filename\"].startswith((\n                                        \"HLS\", \"DASH\")):\n                                    kwdict[\"filename\"] = url.rsplit(\"/\", 2)[-2]\n                                if url.startswith(\"ytdl:\"):\n                                    kwdict[\"extension\"] = \"mp4\"\n                                self.handle_url(url, kwdict)\n                    break\n                except exception.RestartExtraction:\n                    pass\n\n        else:\n            self._write_unsupported(url)\n\n        if \"child-after\" in self.hooks:\n            pathfmt = self.pathfmt\n            pathfmt.kwdict = kwdict\n            for callback in self.hooks[\"child-after\"]:\n                callback(pathfmt)\n\n    def handle_finalize(self):\n        if self.archive is not None:\n            if not self.status:\n                self.archive.finalize()\n            self.archive.close()\n\n        if pathfmt := self.pathfmt:\n            hooks = self.hooks\n            if \"post-after\" in hooks:\n                for callback in hooks[\"post-after\"]:\n                    callback(pathfmt)\n\n            self.extractor.cookies_store()\n\n            if self.status:\n                if \"finalize-error\" in hooks:\n                    for callback in hooks[\"finalize-error\"]:\n                        callback(pathfmt)\n            else:\n                if \"finalize-success\" in hooks:\n                    for callback in hooks[\"finalize-success\"]:\n                        callback(pathfmt)\n            if \"finalize\" in hooks:\n                for callback in hooks[\"finalize\"]:\n                    callback(pathfmt)\n\n    def handle_skip(self):\n        pathfmt = self.pathfmt\n        if \"skip\" in self.hooks:\n            for callback in self.hooks[\"skip\"]:\n                callback(pathfmt)\n        self.out.skip(pathfmt.path)\n\n        if self._skipexc is not None:\n            if self._skipftr is None or self._skipftr(pathfmt.kwdict):\n                self._skipcnt += 1\n                if self._skipcnt >= self._skipmax:\n                    raise self._skipexc\n\n        if self.sleep_skip is not None:\n            self.extractor.sleep(self.sleep_skip(), \"skip\")\n\n    def download(self, url):\n        \"\"\"Download 'url'\"\"\"\n        if downloader := self.get_downloader(url[:url.find(\":\")]):\n            try:\n                return downloader.download(url, self.pathfmt)\n            except OSError as exc:\n                if exc.errno == errno.ENOSPC:\n                    raise\n                self.log.warning(\"%s: %s\", exc.__class__.__name__, exc)\n                return False\n        self._write_unsupported(url)\n        return False\n\n    def get_downloader(self, scheme):\n        \"\"\"Return a downloader suitable for 'scheme'\"\"\"\n        try:\n            return self.downloaders[scheme]\n        except KeyError:\n            pass\n\n        cls = downloader.find(scheme)\n        if cls and config.get((\"downloader\", cls.scheme), \"enabled\", True):\n            instance = cls(self)\n        else:\n            instance = None\n            self.log.error(\"'%s:' URLs are not supported/enabled\", scheme)\n\n        if cls and cls.scheme == \"http\":\n            self.downloaders[\"http\"] = self.downloaders[\"https\"] = instance\n        else:\n            self.downloaders[scheme] = instance\n        return instance\n\n    def initialize(self, kwdict=None):\n        \"\"\"initialize PathFormat, postprocessors, archive, options, etc\"\"\"\n        extr = self.extractor\n        cfg = extr.config\n\n        pathfmt = self.pathfmt = path.PathFormat(extr)\n        if kwdict is not None:\n            pathfmt.set_directory(kwdict)\n\n        self.sleep = util.build_duration_func(cfg(\"sleep\"))\n        self.sleep_skip = util.build_duration_func(cfg(\"sleep-skip\"))\n        self.fallback = cfg(\"fallback\", True)\n        if not cfg(\"download\", True):\n            # monkey-patch method to do nothing and always return True\n            self.download = pathfmt.fix_extension\n\n        if archive_path := cfg(\"archive\"):\n            archive_table = cfg(\"archive-table\")\n            archive_prefix = cfg(\"archive-prefix\")\n            if archive_prefix is None:\n                archive_prefix = extr.category if archive_table is None else \"\"\n\n            archive_format = cfg(\"archive-format\")\n            if archive_format is None:\n                archive_format = extr.archive_fmt\n\n            try:\n                self.archive = archive.connect(\n                    archive_path,\n                    archive_prefix,\n                    archive_format,\n                    archive_table,\n                    cfg(\"archive-mode\"),\n                    cfg(\"archive-pragma\"),\n                    pathfmt,\n                )\n            except Exception as exc:\n                extr.log.warning(\n                    \"Failed to open download archive at '%s' (%s: %s)\",\n                    archive_path, exc.__class__.__name__, exc)\n            else:\n                extr.log.debug(\"Using download archive '%s'\", archive_path)\n\n                events = cfg(\"archive-event\")\n                if events is None:\n                    self._archive_write_file = True\n                    self._archive_write_skip = False\n                    self._archive_write_after = False\n                else:\n                    if isinstance(events, str):\n                        events = events.split(\",\")\n                    self._archive_write_file = (\"file\" in events)\n                    self._archive_write_skip = (\"skip\" in events)\n                    self._archive_write_after = (\"after\" in events)\n\n        if skip := cfg(\"skip\", True):\n            self._skipexc = None\n            if skip == \"enumerate\":\n                pathfmt.check_file = pathfmt._enum_file\n            elif isinstance(skip, str):\n                skip, _, smax = skip.partition(\":\")\n                if skip == \"abort\":\n                    smax, _, sarg = smax.partition(\":\")\n                    self._skipexc = exception.StopExtraction(sarg or None)\n                elif skip == \"terminate\":\n                    self._skipexc = exception.TerminateExtraction\n                elif skip == \"exit\":\n                    self._skipexc = SystemExit\n                self._skipmax = text.parse_int(smax)\n\n                if skip_filter := cfg(\"skip-filter\"):\n                    self._skipftr = util.compile_filter(skip_filter)\n                else:\n                    self._skipftr = None\n        else:\n            # monkey-patch methods to always return False\n            pathfmt.exists = lambda x=None: False\n            if self.archive is not None:\n                self.archive.check = pathfmt.exists\n\n        if not cfg(\"postprocess\", True):\n            return\n\n        if postprocessors := extr.config_accumulate(\"postprocessors\"):\n            self.hooks = collections.defaultdict(list)\n\n            pp_log = self.get_logger(\"postprocessor\")\n            pp_conf = config.get((), \"postprocessor\") or {}\n            pp_opts = cfg(\"postprocessor-options\")\n            pp_list = []\n\n            for pp_dict in postprocessors:\n                if isinstance(pp_dict, str):\n                    pp_dict = pp_conf.get(pp_dict) or {\"name\": pp_dict}\n                elif \"type\" in pp_dict:\n                    pp_type = pp_dict[\"type\"]\n                    if pp_type in pp_conf:\n                        pp = pp_conf[pp_type].copy()\n                        pp.update(pp_dict)\n                        pp_dict = pp\n                    if \"name\" not in pp_dict:\n                        pp_dict[\"name\"] = pp_type\n                if pp_opts:\n                    pp_dict = pp_dict.copy()\n                    pp_dict.update(pp_opts)\n\n                clist = pp_dict.get(\"whitelist\")\n                if clist is not None:\n                    negate = False\n                else:\n                    clist = pp_dict.get(\"blacklist\")\n                    negate = True\n                if clist and not util.build_extractor_filter(\n                        clist, negate)(extr):\n                    continue\n\n                name = pp_dict.get(\"name\", \"\")\n                if \"__init__\" not in pp_dict:\n                    name, sep, event = name.rpartition(\"@\")\n                    if sep:\n                        pp_dict[\"name\"] = name\n                        if \"event\" not in pp_dict:\n                            pp_dict[\"event\"] = event\n                    else:\n                        name = event\n\n                    name, sep, mode = name.rpartition(\"/\")\n                    if sep:\n                        pp_dict[\"name\"] = name\n                        if \"mode\" not in pp_dict:\n                            pp_dict[\"mode\"] = mode\n                    else:\n                        name = mode\n\n                    pp_dict[\"__init__\"] = None\n\n                pp_cls = postprocessor.find(name)\n                if pp_cls is None:\n                    pp_log.warning(\"module '%s' not found\", name)\n                    continue\n                try:\n                    pp_obj = pp_cls(self, pp_dict)\n                except Exception as exc:\n                    pp_log.traceback(exc)\n                    pp_log.error(\"'%s' initialization failed:  %s: %s\",\n                                 name, exc.__class__.__name__, exc)\n                else:\n                    pp_list.append(pp_obj)\n\n            if pp_list:\n                extr.log.debug(\"Active postprocessor modules: %s\", pp_list)\n                if \"init\" in self.hooks:\n                    for callback in self.hooks[\"init\"]:\n                        callback(pathfmt)\n\n    def register_hooks(self, hooks, options=None):\n        expr = options.get(\"filter\") if options else None\n\n        if expr:\n            condition = util.compile_filter(expr)\n            for hook, callback in hooks.items():\n                self.hooks[hook].append(functools.partial(\n                    _call_hook_condition, callback, condition))\n        else:\n            for hook, callback in hooks.items():\n                self.hooks[hook].append(callback)\n\n    def _build_extractor_filter(self):\n        clist = self.extractor.config(\"whitelist\")\n        if clist is not None:\n            negate = False\n            special = None\n        else:\n            clist = self.extractor.config(\"blacklist\")\n            negate = True\n            special = util.SPECIAL_EXTRACTORS\n            if clist is None:\n                clist = (self.extractor.category,)\n\n        return util.build_extractor_filter(clist, negate, special)\n\n\ndef _call_hook_condition(callback, condition, pathfmt):\n    if condition(pathfmt.kwdict):\n        callback(pathfmt)\n\n\nclass SimulationJob(DownloadJob):\n    \"\"\"Simulate the extraction process without downloading anything\"\"\"\n\n    def handle_url(self, url, kwdict):\n        ext = kwdict[\"extension\"] or \"jpg\"\n        kwdict[\"extension\"] = self.pathfmt.extension_map(ext, ext)\n        if self.sleep is not None:\n            self.extractor.sleep(self.sleep(), \"download\")\n        if self.archive is not None and self._archive_write_skip:\n            self.archive.add(kwdict)\n        self.out.skip(self.pathfmt.build_filename(kwdict))\n\n    def handle_directory(self, kwdict):\n        if self.pathfmt is None:\n            self.initialize()\n\n\nclass KeywordJob(Job):\n    \"\"\"Print available keywords\"\"\"\n\n    def __init__(self, url, parent=None):\n        Job.__init__(self, url, parent)\n        self.private = config.get((\"output\",), \"private\")\n\n    def handle_url(self, url, kwdict):\n        stdout_write(\"\\nKeywords for filenames and --filter:\\n\"\n                     \"------------------------------------\\n\")\n\n        if self.metadata_http and url.startswith(\"http\"):\n            kwdict[self.metadata_http] = util.extract_headers(\n                self.extractor.request(url, method=\"HEAD\"))\n\n        self.print_kwdict(kwdict)\n        raise exception.StopExtraction()\n\n    def handle_directory(self, kwdict):\n        stdout_write(\"Keywords for directory names:\\n\"\n                     \"-----------------------------\\n\")\n        self.print_kwdict(kwdict)\n\n    def handle_queue(self, url, kwdict):\n        extr = None\n        if \"_extractor\" in kwdict:\n            extr = kwdict[\"_extractor\"].from_url(url)\n\n        if not util.filter_dict(kwdict):\n            self.extractor.log.info(\n                \"This extractor only spawns other extractors \"\n                \"and does not provide any metadata on its own.\")\n\n            if extr:\n                self.extractor.log.info(\n                    \"Showing results for '%s' instead:\\n\", url)\n                KeywordJob(extr, self).run()\n            else:\n                self.extractor.log.info(\n                    \"Try 'gallery-dl -K \\\"%s\\\"' instead.\", url)\n        else:\n            stdout_write(\"Keywords for --child-filter:\\n\"\n                         \"----------------------------\\n\")\n            self.print_kwdict(kwdict)\n            if extr or self.extractor.categorytransfer:\n                stdout_write(\"\\n\")\n                KeywordJob(extr or url, self).run()\n        raise exception.StopExtraction()\n\n    def print_kwdict(self, kwdict, prefix=\"\", markers=None):\n        \"\"\"Print key-value pairs in 'kwdict' with formatting\"\"\"\n        write = sys.stdout.write\n        suffix = \"']\" if prefix else \"\"\n\n        markerid = id(kwdict)\n        if markers is None:\n            markers = {markerid}\n        elif markerid in markers:\n            write(f\"{prefix[:-2]}\\n  <circular reference>\\n\")\n            return  # ignore circular reference\n        else:\n            markers.add(markerid)\n\n        for key, value in sorted(kwdict.items()):\n            if key[0] == \"_\" and not self.private:\n                continue\n            key = prefix + key + suffix\n\n            if isinstance(value, dict):\n                self.print_kwdict(value, key + \"['\", markers)\n\n            elif isinstance(value, list):\n                if not value:\n                    pass\n                elif isinstance(value[0], dict):\n                    self.print_kwdict(value[0], key + \"[N]['\", markers)\n                else:\n                    fmt = (\"  {:>%s} {}\\n\" % len(str(len(value)))).format\n                    write(key + \"[N]\\n\")\n                    for idx, val in enumerate(value, 0):\n                        write(fmt(idx, val))\n\n            else:\n                # string or number\n                write(f\"{key}\\n  {value}\\n\")\n\n        markers.remove(markerid)\n\n\nclass UrlJob(Job):\n    \"\"\"Print download urls\"\"\"\n    maxdepth = 1\n\n    def __init__(self, url, parent=None, depth=1):\n        Job.__init__(self, url, parent)\n        self.depth = depth\n        if depth >= self.maxdepth:\n            self.handle_queue = self.handle_url\n\n    def handle_url(self, url, _):\n        stdout_write(url + \"\\n\")\n\n    def handle_url_fallback(self, url, kwdict):\n        stdout_write(url + \"\\n\")\n        if \"_fallback\" in kwdict:\n            for url in kwdict[\"_fallback\"]:\n                stdout_write(f\"| {url}\\n\")\n\n    def handle_queue(self, url, kwdict):\n        if cls := kwdict.get(\"_extractor\"):\n            extr = cls.from_url(url)\n        else:\n            extr = extractor.find(url)\n\n        if extr:\n            self.status |= self.__class__(extr, self, self.depth + 1).run()\n        else:\n            self._write_unsupported(url)\n\n\nclass InfoJob(Job):\n    \"\"\"Print extractor defaults and settings\"\"\"\n\n    def run(self):\n        ex = self.extractor\n        pm = self._print_multi\n        pc = self._print_config\n\n        if ex.basecategory:\n            pm(\"Category / Subcategory / Basecategory\",\n               ex.category, ex.subcategory, ex.basecategory)\n        else:\n            pm(\"Category / Subcategory\", ex.category, ex.subcategory)\n\n        pc(\"Filename format\", \"filename\", ex.filename_fmt)\n        pc(\"Directory format\", \"directory\", ex.directory_fmt)\n        pc(\"Archive format\", \"archive-format\", ex.archive_fmt)\n        pc(\"Request interval\", \"sleep-request\", ex.request_interval)\n\n        return 0\n\n    def _print_multi(self, title, *values):\n        stdout_write(\n            f\"{title}\\n  {' / '.join(map(util.json_dumps, values))}\\n\\n\")\n\n    def _print_config(self, title, optname, value):\n        optval = self.extractor.config(optname, util.SENTINEL)\n        if optval is not util.SENTINEL:\n            stdout_write(\n                f\"{title} (custom):\\n  {util.json_dumps(optval)}\\n\"\n                f\"{title} (default):\\n  {util.json_dumps(value)}\\n\\n\")\n        elif value:\n            stdout_write(\n                f\"{title} (default):\\n  {util.json_dumps(value)}\\n\\n\")\n\n\nclass DataJob(Job):\n    \"\"\"Collect extractor results and dump them\"\"\"\n    resolve = False\n\n    def __init__(self, url, parent=None, file=sys.stdout, ensure_ascii=True,\n                 resolve=False):\n        Job.__init__(self, url, parent)\n        self.file = file\n        self.data = []\n        self.data_urls = []\n        self.data_post = []\n        self.data_meta = []\n        self.exception = None\n        self.ascii = config.get((\"output\",), \"ascii\", ensure_ascii)\n        self.jsonl = config.get((\"output\",), \"jsonl\", False)\n        self.resolve = 128 if resolve is True else (resolve or self.resolve)\n\n        private = config.get((\"output\",), \"private\")\n        self.filter = dict.copy if private else util.filter_dict\n\n        if self.resolve > 0:\n            self.handle_queue = self.handle_queue_resolve\n        if not self.jsonl:\n            self.out = util.noop\n\n    def run(self):\n        self._init()\n\n        extractor = self.extractor\n        sleep = util.build_duration_func(\n            extractor.config(\"sleep-extractor\"))\n        if sleep is not None:\n            extractor.sleep(sleep(), \"extractor\")\n\n        # collect data\n        try:\n            self.dispatch(extractor)\n        except exception.StopExtraction:\n            pass\n        except Exception as exc:\n            self.exception = exc\n            self.data.append((-1, {\n                \"error\"  : exc.__class__.__name__,\n                \"message\": str(exc),\n            }))\n        except BaseException:\n            pass\n\n        # convert numbers to string\n        if config.get((\"output\",), \"num-to-str\", False):\n            for msg in self.data:\n                util.transform_dict(msg[-1], util.number_to_string)\n\n        if self.file and not self.jsonl:\n            # dump to 'file'\n            try:\n                util.dump_json(self.data, self.file, self.ascii, 2)\n                self.file.flush()\n            except Exception:\n                pass\n\n        return 0\n\n    def out(self, msg):\n        self.file.write(util.json_dumps(msg))\n        self.file.write(\"\\n\")\n        self.file.flush()\n\n    def handle_url(self, url, kwdict):\n        kwdict = self.filter(kwdict)\n        self.out(msg := (Message.Url, url, kwdict))\n        self.data_urls.append(url)\n        self.data_meta.append(kwdict)\n        self.data.append(msg)\n\n    def handle_directory(self, kwdict):\n        kwdict = self.filter(kwdict)\n        self.out(msg := (Message.Directory, kwdict))\n        self.data_post.append(kwdict)\n        self.data.append(msg)\n\n    def handle_queue(self, url, kwdict):\n        kwdict = self.filter(kwdict)\n        self.out(msg := (Message.Queue, url, kwdict))\n        self.data_urls.append(url)\n        self.data_meta.append(kwdict)\n        self.data.append(msg)\n\n    def handle_queue_resolve(self, url, kwdict):\n        if cls := kwdict.get(\"_extractor\"):\n            extr = cls.from_url(url)\n        else:\n            extr = extractor.find(url)\n\n        if not extr:\n            kwdict = self.filter(kwdict)\n            self.out(msg := (Message.Queue, url, kwdict))\n            self.data_urls.append(url)\n            self.data_meta.append(kwdict)\n            return self.data.append(msg)\n\n        job = self.__class__(extr, self, None, self.ascii, self.resolve-1)\n        job.data = self.data\n        job.data_urls = self.data_urls\n        job.data_post = self.data_post\n        job.data_meta = self.data_meta\n        job.run()\n"
  },
  {
    "path": "gallery_dl/oauth.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2018-2026 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"OAuth helper functions and classes\"\"\"\n\nimport hmac\nimport time\nimport random\nimport string\nimport hashlib\nimport binascii\nimport urllib.parse\n\nimport requests\nimport requests.auth\n\nfrom . import text\n\n\ndef nonce(size, alphabet=string.ascii_letters):\n    \"\"\"Generate a nonce value with 'size' characters\"\"\"\n    return \"\".join(random.choice(alphabet) for _ in range(size))\n\n\ndef quote(value, quote=urllib.parse.quote):\n    \"\"\"Quote 'value' according to the OAuth1.0 standard\"\"\"\n    return quote(value, \"~\")\n\n\ndef concat(*args):\n    \"\"\"Concatenate 'args' as expected by OAuth1.0\"\"\"\n    return \"&\".join(quote(item) for item in args)\n\n\nclass OAuth1Session(requests.Session):\n    \"\"\"Extension to requests.Session to support OAuth 1.0\"\"\"\n\n    def __init__(self, consumer_key, consumer_secret,\n                 token=None, token_secret=None):\n\n        requests.Session.__init__(self)\n        self.auth = OAuth1Client(\n            consumer_key, consumer_secret,\n            token, token_secret,\n        )\n\n    def rebuild_auth(self, prepared_request, response):\n        if \"Authorization\" in prepared_request.headers:\n            del prepared_request.headers[\"Authorization\"]\n            prepared_request.prepare_auth(self.auth)\n\n\nclass OAuth1Client(requests.auth.AuthBase):\n    \"\"\"OAuth1.0a authentication\"\"\"\n\n    def __init__(self, consumer_key, consumer_secret,\n                 token=None, token_secret=None):\n\n        self.consumer_key = consumer_key\n        self.consumer_secret = consumer_secret\n        self.token = token\n        self.token_secret = token_secret\n\n    def __call__(self, request):\n        oauth_params = [\n            (\"oauth_consumer_key\", self.consumer_key),\n            (\"oauth_nonce\", nonce(16)),\n            (\"oauth_signature_method\", \"HMAC-SHA1\"),\n            (\"oauth_timestamp\", str(int(time.time()))),\n            (\"oauth_version\", \"1.0\"),\n        ]\n        if self.token:\n            oauth_params.append((\"oauth_token\", self.token))\n\n        signature = self.generate_signature(request, oauth_params)\n        oauth_params.append((\"oauth_signature\", signature))\n\n        request.headers[\"Authorization\"] = \"OAuth \" + \",\".join(\n            key + '=\"' + value + '\"' for key, value in oauth_params)\n\n        return request\n\n    def generate_signature(self, request, params):\n        \"\"\"Generate 'oauth_signature' value\"\"\"\n        url, _, query = request.url.partition(\"?\")\n\n        params = params.copy()\n        for key, value in text.parse_query(query).items():\n            params.append((quote(key), quote(value)))\n        params.sort()\n        query = \"&\".join(\"=\".join(item) for item in params)\n\n        message = concat(request.method, url, query).encode()\n        key = concat(self.consumer_secret, self.token_secret or \"\").encode()\n        signature = hmac.new(key, message, hashlib.sha1).digest()\n\n        return quote(binascii.b2a_base64(signature)[:-1].decode())\n\n\nclass OAuth1API():\n    \"\"\"Base class for OAuth1.0 based API interfaces\"\"\"\n    API_KEY = None\n    API_SECRET = None\n\n    def __init__(self, extractor):\n        self.exc = extractor.exc\n        self.log = extractor.log\n        self.extractor = extractor\n\n        api_key = extractor.config(\"api-key\", self.API_KEY)\n        api_secret = extractor.config(\"api-secret\", self.API_SECRET)\n        token = extractor.config(\"access-token\")\n        token_secret = extractor.config(\"access-token-secret\")\n        key_type = \"default\" if api_key == self.API_KEY else \"custom\"\n\n        if token is None or token == \"cache\":\n            token, token_secret = extractor.cache(\n                _token_cache, (extractor.category, api_key), _mem=False)\n\n        if api_key and api_secret and token and token_secret:\n            self.log.debug(\"Using %s OAuth1.0 authentication\", key_type)\n            self.session = OAuth1Session(\n                api_key, api_secret, token, token_secret)\n            self.api_key = None\n        else:\n            self.log.debug(\"Using %s api_key authentication\", key_type)\n            self.session = extractor.session\n            self.api_key = api_key\n\n    def request(self, url, **kwargs):\n        kwargs[\"fatal\"] = None\n        kwargs[\"session\"] = self.session\n        return self.extractor.request(url, **kwargs)\n\n\ndef _token_cache(key):\n    return None, None\n"
  },
  {
    "path": "gallery_dl/option.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2017-2026 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Command line option parsing\"\"\"\n\nimport argparse\nimport logging\nimport os.path\nimport sys\nfrom . import job, util, version\n\n\nclass ConfigAction(argparse.Action):\n    \"\"\"Set argparse results as config values\"\"\"\n    def __call__(self, parser, namespace, values, option_string=None):\n        namespace.options.append(((), self.dest, values))\n\n\nclass ConfigConstAction(argparse.Action):\n    \"\"\"Set argparse const values as config values\"\"\"\n    def __call__(self, parser, namespace, values, option_string=None):\n        namespace.options.append(((), self.dest, self.const))\n\n\nclass AppendCommandAction(argparse.Action):\n    def __call__(self, parser, namespace, values, option_string=None):\n        items = getattr(namespace, self.dest, None) or []\n        val = self.const.copy()\n        val[\"command\"] = values\n        items.append(val)\n        setattr(namespace, self.dest, items)\n\n\nclass DeprecatedConfigConstAction(argparse.Action):\n    \"\"\"Set argparse const values as config values + deprecation warning\"\"\"\n    def __call__(self, parser, namespace, values, option_string=None):\n        sys.stderr.write(\n            f\"Warning: {'/'.join(self.option_strings)} is deprecated. \"\n            f\"Use {self.choices} instead.\\n\")\n        namespace.options.append(((), self.dest, self.const))\n\n\nclass ConfigParseAction(argparse.Action):\n    \"\"\"Parse KEY=VALUE config options\"\"\"\n    def __call__(self, parser, namespace, values, option_string=None):\n        key, value = _parse_option(values)\n        key = key.split(\".\")  # splitting an empty string becomes [\"\"]\n        namespace.options.append((key[:-1], key[-1], value))\n\n\nclass PPParseAction(argparse.Action):\n    \"\"\"Parse KEY=VALUE post processor options\"\"\"\n    def __call__(self, parser, namespace, values, option_string=None):\n        key, value = _parse_option(values)\n        namespace.options_pp[key] = value\n\n\nclass InputfileAction(argparse.Action):\n    \"\"\"Collect input files\"\"\"\n    def __call__(self, parser, namespace, value, option_string=None):\n        namespace.input_files.append((value, self.const))\n\n\nclass MtimeAction(argparse.Action):\n    \"\"\"Configure mtime post processors\"\"\"\n    def __call__(self, parser, namespace, value, option_string=None):\n        namespace.postprocessors.append({\n            \"name\": \"mtime\",\n            \"value\": f\"{{{self.const or value}}}\",\n        })\n\n\nclass RenameAction(argparse.Action):\n    \"\"\"Configure rename post processors\"\"\"\n    def __call__(self, parser, namespace, value, option_string=None):\n        if self.const:\n            namespace.postprocessors.append({\n                \"name\": \"rename\",\n                \"to\"  : value,\n            })\n        else:\n            namespace.postprocessors.append({\n                \"name\": \"rename\",\n                \"from\": value,\n            })\n\n\nclass UgoiraAction(argparse.Action):\n    \"\"\"Configure ugoira post processors\"\"\"\n    def __call__(self, parser, namespace, value, option_string=None):\n        value = self.const or value.strip().lower()\n\n        if value in {\"webm\", \"vp9\"}:\n            pp = {\n                \"extension\"        : \"webm\",\n                \"ffmpeg-args\"      : (\"-c:v\", \"libvpx-vp9\",\n                                      \"-crf\", \"12\",\n                                      \"-b:v\", \"0\",\n                                      \"-pix_fmt\", \"yuv420p\", \"-an\"),\n            }\n        elif value == \"vp9-lossless\":\n            pp = {\n                \"extension\"        : \"webm\",\n                \"ffmpeg-args\"      : (\"-c:v\", \"libvpx-vp9\",\n                                      \"-lossless\", \"1\",\n                                      \"-pix_fmt\", \"yuv420p\", \"-an\"),\n            }\n        elif value == \"vp8\":\n            pp = {\n                \"extension\"        : \"webm\",\n                \"ffmpeg-args\"      : (\"-c:v\", \"libvpx\",\n                                      \"-crf\", \"4\",\n                                      \"-b:v\", \"5M\",\n                                      \"-pix_fmt\", \"yuv420p\", \"-an\"),\n            }\n        elif value == \"mp4\":\n            pp = {\n                \"extension\"        : \"mp4\",\n                \"ffmpeg-args\"      : (\"-c:v\", \"libx264\",\n                                      \"-b:v\", \"5M\",\n                                      \"-pix_fmt\", \"yuv420p\", \"-an\"),\n                \"libx264-prevent-odd\": True,\n            }\n        elif value == \"gif\":\n            pp = {\n                \"extension\"        : \"gif\",\n                \"ffmpeg-args\"      : (\"-filter_complex\", \"[0:v] split [a][b];\"\n                                      \"[a] palettegen [p];[b][p] paletteuse\"),\n                \"repeat-last-frame\": False,\n            }\n        elif value in {\"mkv\", \"copy\"}:\n            pp = {\n                \"extension\"        : \"mkv\",\n                \"ffmpeg-args\"      : (\"-c:v\", \"copy\"),\n                \"repeat-last-frame\": False,\n            }\n        elif value in {\"zip\", \"archive\"}:\n            pp = {\n                \"mode\"             : \"archive\",\n            }\n        else:\n            parser.error(f\"Unsupported Ugoira format '{value}'\")\n\n        pp[\"name\"] = \"ugoira\"\n        pp[\"whitelist\"] = (\"pixiv\", \"danbooru\")\n\n        namespace.options.append(((\"extractor\",), \"ugoira\", \"original\"))\n        namespace.postprocessors.append(pp)\n\n\nclass PrintAction(argparse.Action):\n    def __call__(self, parser, namespace, value, option_string=None):\n        if self.const:\n            if self.const == \"-\":\n                namespace.options.append(((), \"skip\", False))\n                namespace.options.append(((), \"download\", False))\n                namespace.options.append(((\"output\",), \"mode\", False))\n            filename = \"-\"\n            base = None\n            mode = \"w\"\n        else:\n            if self.const is None:\n                namespace.options.append(((), \"skip\", False))\n                namespace.options.append(((), \"download\", False))\n            value, path = value\n            base, filename = os.path.split(path)\n            mode = \"a\"\n\n        event, sep, format_string = value.partition(\":\")\n        if not sep:\n            format_string = event\n            event = (\"prepare\",)\n        else:\n            event = event.strip().lower()\n            if event not in {\"init\", \"file\", \"after\", \"skip\", \"error\",\n                             \"prepare\", \"prepare-after\", \"post\", \"post-after\",\n                             \"finalize\", \"finalize-success\", \"finalize-error\",\n                             \"child\", \"child-after\"}:\n                format_string = value\n                event = (\"prepare\",)\n\n        if not format_string:\n            return\n\n        if format_string.startswith(\"\\\\f\"):\n            format_string = \"\\f\" + format_string[2:]\n\n        if format_string[0] == \"\\f\":\n            if format_string[1] == \"F\" and format_string[-1] != \"\\n\":\n                format_string += \"\\n\"\n        elif \"{\" not in format_string and \" \" not in format_string:\n            format_string = f\"{{{format_string}}}\\n\"\n        elif format_string[-1] != \"\\n\":\n            format_string += \"\\n\"\n\n        namespace.postprocessors.append({\n            \"name\"          : \"metadata\",\n            \"event\"         : event,\n            \"filename\"      : filename,\n            \"base-directory\": base or \".\",\n            \"content-format\": format_string,\n            \"open\"          : mode,\n        })\n\n\nclass Formatter(argparse.HelpFormatter):\n    \"\"\"Custom HelpFormatter class to customize help output\"\"\"\n    def __init__(self, prog):\n        argparse.HelpFormatter.__init__(self, prog, max_help_position=30)\n\n    def _format_action_invocation(self, action):\n        opts = action.option_strings\n        if action.metavar:\n            opts = opts.copy()\n            opts[-1] = f\"{opts[-1]} {action.metavar}\"\n        return \", \".join(opts)\n\n    def _format_usage(self, usage, actions, groups, prefix):\n        return f\"Usage: {self._prog} [OPTIONS] URL [URL...]\\n\"\n\n\ndef _parse_option(opt):\n    key, _, value = opt.partition(\"=\")\n    try:\n        value = util.json_loads(value)\n    except ValueError:\n        pass\n    return key, value\n\n\ndef build_parser():\n    \"\"\"Build and configure an ArgumentParser object\"\"\"\n    SUPPRESS = argparse.SUPPRESS\n    parser = argparse.ArgumentParser(\n        formatter_class=Formatter,\n        add_help=False,\n    )\n\n    general = parser.add_argument_group(\"General Options\")\n    general.add_argument(\n        \"-h\", \"--help\",\n        action=\"help\",\n        help=\"Print this help message and exit\",\n    )\n    general.add_argument(\n        \"--version\",\n        action=\"version\", version=version.__version__,\n        help=\"Print program version and exit\",\n    )\n    general.add_argument(\n        \"-f\", \"--filename\",\n        dest=\"filename\", metavar=\"FORMAT\",\n        help=(\"Filename format string for downloaded files \"\n              \"('/O' for \\\"original\\\" filenames)\"),\n    )\n    general.add_argument(\n        \"-d\", \"--destination\",\n        dest=\"base-directory\", metavar=\"PATH\", action=ConfigAction,\n        help=\"Target location for file downloads\",\n    )\n    general.add_argument(\n        \"-D\", \"--directory\",\n        dest=\"directory\", metavar=\"PATH\",\n        help=\"Exact location for file downloads\",\n    )\n    general.add_argument(\n        \"--restrict-filenames\",\n        dest=\"path-restrict\", metavar=\"VALUE\", action=ConfigAction,\n        help=(\"Replace restricted filename characters with underscores. \"\n              \"One of 'windows', 'windows+', 'unix', 'ascii', 'ascii+', \"\n              \"or a custom set of characters\"),\n    )\n    general.add_argument(\n        \"--windows-filenames\",\n        dest=\"path-restrict\", nargs=0, action=ConfigConstAction,\n        const=\"windows\",\n        help=\"Force filenames to be Windows-compatible\",\n    )\n    general.add_argument(\n        \"-X\", \"--extractors\",\n        dest=\"extractor_sources\", metavar=\"PATH\", action=\"append\",\n        help=\"Load external extractors from PATH\",\n    )\n    general.add_argument(\n        \"--compat\",\n        dest=\"category-map\", nargs=0, action=ConfigConstAction, const=\"compat\",\n        help=\"Restore legacy 'category' names\",\n    )\n\n    update = parser.add_argument_group(\"Update Options\")\n    if util.EXECUTABLE:\n        update.add_argument(\n            \"-U\", \"--update\",\n            dest=\"update\", action=\"store_const\", const=\"latest\",\n            help=\"Update to the latest version\",\n        )\n        update.add_argument(\n            \"--update-to\",\n            dest=\"update\", metavar=\"CHANNEL[@TAG]\",\n            help=(\"Switch to a dfferent release channel (stable or dev) \"\n                  \"or upgrade/downgrade to a specific version\"),\n        )\n        update.add_argument(\n            \"--update-check\",\n            dest=\"update\", action=\"store_const\", const=\"check\",\n            help=\"Check if a newer version is available\",\n        )\n    else:\n        update.add_argument(\n            \"-U\", \"--update-check\",\n            dest=\"update\", action=\"store_const\", const=\"check\",\n            help=\"Check if a newer version is available\",\n        )\n\n    input = parser.add_argument_group(\"Input Options\")\n    input.add_argument(\n        \"urls\",\n        metavar=\"URL\", nargs=\"*\",\n        help=SUPPRESS,\n    )\n    input.add_argument(\n        \"-i\", \"--input-file\",\n        dest=\"input_files\", metavar=\"FILE\", action=InputfileAction, const=None,\n        default=[],\n        help=(\"Download URLs found in FILE ('-' for stdin). \"\n              \"More than one --input-file can be specified\"),\n    )\n    input.add_argument(\n        \"-I\", \"--input-file-comment\",\n        dest=\"input_files\", metavar=\"FILE\", action=InputfileAction, const=\"c\",\n        help=(\"Download URLs found in FILE. \"\n              \"Comment them out after they were downloaded successfully.\"),\n    )\n    input.add_argument(\n        \"-x\", \"--input-file-delete\",\n        dest=\"input_files\", metavar=\"FILE\", action=InputfileAction, const=\"d\",\n        help=(\"Download URLs found in FILE. \"\n              \"Delete them after they were downloaded successfully.\"),\n    )\n    input.add_argument(\n        \"--no-input\",\n        dest=\"input\", nargs=0, action=ConfigConstAction, const=False,\n        help=\"Do not prompt for passwords/tokens\",\n    )\n\n    output = parser.add_argument_group(\"Output Options\")\n    output.add_argument(\n        \"-q\", \"--quiet\",\n        dest=\"loglevel\", default=logging.INFO,\n        action=\"store_const\", const=logging.ERROR,\n        help=\"Activate quiet mode\",\n    )\n    output.add_argument(\n        \"-w\", \"--warning\",\n        dest=\"loglevel\",\n        action=\"store_const\", const=logging.WARNING,\n        help=\"Print only warnings and errors\",\n    )\n    output.add_argument(\n        \"-v\", \"--verbose\",\n        dest=\"loglevel\",\n        action=\"store_const\", const=logging.DEBUG,\n        help=\"Print various debugging information\",\n    )\n    output.add_argument(\n        \"-g\", \"--get-urls\",\n        dest=\"list_urls\", action=\"count\",\n        help=\"Print URLs instead of downloading\",\n    )\n    output.add_argument(\n        \"-G\", \"--resolve-urls\",\n        dest=\"list_urls\", action=\"store_const\", const=128,\n        help=\"Print URLs instead of downloading; resolve intermediary URLs\",\n    )\n    output.add_argument(\n        \"-j\", \"--dump-json\",\n        dest=\"dump_json\", action=\"count\",\n        help=\"Print JSON information\",\n    )\n    output.add_argument(\n        \"-J\", \"--resolve-json\",\n        dest=\"dump_json\", action=\"store_const\", const=128,\n        help=\"Print JSON information; resolve intermediary URLs\",\n    )\n    output.add_argument(\n        \"-s\", \"--simulate\",\n        dest=\"jobtype\", action=\"store_const\", const=job.SimulationJob,\n        help=\"Simulate data extraction; do not download anything\",\n    )\n    output.add_argument(\n        \"-E\", \"--extractor-info\",\n        dest=\"jobtype\", action=\"store_const\", const=job.InfoJob,\n        help=\"Print extractor defaults and settings\",\n    )\n    output.add_argument(\n        \"-K\", \"--list-keywords\",\n        dest=\"jobtype\", action=\"store_const\", const=job.KeywordJob,\n        help=(\"Print a list of available keywords and example values \"\n              \"for the given URLs\"),\n    )\n    output.add_argument(\n        \"-e\", \"--error-file\",\n        dest=\"errorfile\", metavar=\"FILE\", action=ConfigAction,\n        help=\"Add input URLs which returned an error to FILE\",\n    )\n    output.add_argument(\n        \"-N\", \"--print\",\n        dest=\"postprocessors\", metavar=\"[EVENT:]FORMAT\",\n        action=PrintAction, const=\"-\", default=[],\n        help=(\"Write FORMAT during EVENT (default 'prepare') to standard \"\n              \"output instead of downloading files. \"\n              \"Can be used multiple times. \"\n              \"Examples: 'id' or 'post:{md5[:8]}'\"),\n    )\n    output.add_argument(\n        \"--Print\",\n        dest=\"postprocessors\", metavar=\"[EVENT:]FORMAT\",\n        action=PrintAction, const=\"+\",\n        help=\"Like --print, but downloads files as well\",\n    )\n    output.add_argument(\n        \"--print-to-file\",\n        dest=\"postprocessors\", metavar=\"[EVENT:]FORMAT FILE\",\n        action=PrintAction, const=None, nargs=2,\n        help=(\"Append FORMAT during EVENT to FILE instead of downloading \"\n              \"files. Can be used multiple times\"),\n    )\n    output.add_argument(\n        \"--Print-to-file\",\n        dest=\"postprocessors\", metavar=\"[EVENT:]FORMAT FILE\",\n        action=PrintAction, const=False, nargs=2,\n        help=\"Like --print-to-file, but downloads files as well\",\n    )\n    output.add_argument(\n        \"--list-modules\",\n        dest=\"list_modules\", action=\"store_true\",\n        help=\"Print a list of available extractor modules\",\n    )\n    output.add_argument(\n        \"--list-extractors\",\n        dest=\"list_extractors\", metavar=\"[CATEGORIES]\", nargs=\"*\",\n        help=(\"Print a list of extractor classes \"\n              \"with description, (sub)category and example URL\"),\n    )\n    output.add_argument(\n        \"--write-log\",\n        dest=\"logfile\", metavar=\"FILE\", action=ConfigAction,\n        help=\"Write logging output to FILE\",\n    )\n    output.add_argument(\n        \"--write-unsupported\",\n        dest=\"unsupportedfile\", metavar=\"FILE\", action=ConfigAction,\n        help=(\"Write URLs, which get emitted by other extractors but cannot \"\n              \"be handled, to FILE\"),\n    )\n    output.add_argument(\n        \"--write-pages\",\n        dest=\"write-pages\", nargs=0, action=ConfigConstAction, const=True,\n        help=(\"Write downloaded intermediary pages to files \"\n              \"in the current directory to debug problems\"),\n    )\n    output.add_argument(\n        \"--print-traffic\",\n        dest=\"print_traffic\", action=\"store_true\",\n        help=\"Display sent and read HTTP traffic\",\n    )\n    output.add_argument(\n        \"--no-colors\",\n        dest=\"colors\", action=\"store_false\",\n        help=\"Do not emit ANSI color codes in output\",\n    )\n\n    networking = parser.add_argument_group(\"Networking Options\")\n    networking.add_argument(\n        \"-R\", \"--retries\",\n        dest=\"retries\", metavar=\"N\", type=int, action=ConfigAction,\n        help=(\"Maximum number of retries for failed HTTP requests \"\n              \"or -1 for infinite retries (default: 4)\"),\n    )\n    networking.add_argument(\n        \"-a\", \"--user-agent\",\n        dest=\"user-agent\", metavar=\"UA\", action=ConfigAction,\n        help=\"User-Agent request header\",\n    )\n    networking.add_argument(\n        \"--http-timeout\",\n        dest=\"timeout\", metavar=\"SECONDS\", type=float, action=ConfigAction,\n        help=\"Timeout for HTTP connections (default: 30.0)\",\n    )\n    networking.add_argument(\n        \"--proxy\",\n        dest=\"proxy\", metavar=\"URL\", action=ConfigAction,\n        help=\"Use the specified proxy\",\n    )\n    networking.add_argument(\n        \"--xff\",\n        dest=\"geo-bypass\", metavar=\"VALUE\", action=ConfigAction,\n        help=(\"Use a fake 'X-Forwarded-For' HTTP header to try bypassing \"\n              \"geographic restrictions. Can be IP blocks in CIDR notation \"\n              \"or two-letter ISO 3166-2 country codes (12.0.0.0/8,FR,CN)\")\n    )\n    networking.add_argument(\n        \"--source-address\",\n        dest=\"source-address\", metavar=\"IP\", action=ConfigAction,\n        help=\"Client-side IP address to bind to\",\n    )\n    networking.add_argument(\n        \"-4\", \"--force-ipv4\",\n        dest=\"source-address\", nargs=0, action=ConfigConstAction,\n        const=\"0.0.0.0\",\n        help=\"Make all connections via IPv4\",\n    )\n    networking.add_argument(\n        \"-6\", \"--force-ipv6\",\n        dest=\"source-address\", nargs=0, action=ConfigConstAction, const=\"::\",\n        help=\"Make all connections via IPv6\",\n    )\n    networking.add_argument(\n        \"--no-check-certificate\",\n        dest=\"verify\", nargs=0, action=ConfigConstAction, const=False,\n        help=\"Disable HTTPS certificate validation\",\n    )\n\n    downloader = parser.add_argument_group(\"Downloader Options\")\n    downloader.add_argument(\n        \"-r\", \"--limit-rate\",\n        dest=\"rate\", metavar=\"RATE\", action=ConfigAction,\n        help=\"Maximum download rate (e.g. 500k, 2.5M, or 800k-2M)\",\n    )\n    downloader.add_argument(\n        \"--chunk-size\",\n        dest=\"chunk-size\", metavar=\"SIZE\", action=ConfigAction,\n        help=\"Size of in-memory data chunks (default: 32k)\",\n    )\n    downloader.add_argument(\n        \"--no-part\",\n        dest=\"part\", nargs=0, action=ConfigConstAction, const=False,\n        help=\"Do not use .part files\",\n    )\n    downloader.add_argument(\n        \"--no-skip\",\n        dest=\"skip\", nargs=0, action=ConfigConstAction, const=False,\n        help=\"Do not skip downloads; overwrite existing files\",\n    )\n    downloader.add_argument(\n        \"--no-mtime\",\n        dest=\"mtime\", nargs=0, action=ConfigConstAction, const=False,\n        help=(\"Do not set file modification times according to \"\n              \"Last-Modified HTTP response headers\")\n    )\n    downloader.add_argument(\n        \"--no-download\",\n        dest=\"download\", nargs=0, action=ConfigConstAction, const=False,\n        help=(\"Do not download any files\")\n    )\n\n    sleep = parser.add_argument_group(\"Sleep Options\")\n    sleep.add_argument(\n        \"--sleep\",\n        dest=\"sleep\", metavar=\"SECONDS\", action=ConfigAction,\n        help=(\"Number of seconds to wait before each download. \"\n              \"This can be either a constant value or a range \"\n              \"(e.g. 2.7 or 2.0-3.5)\"),\n    )\n    sleep.add_argument(\n        \"--sleep-skip\",\n        dest=\"sleep-skip\", metavar=\"SECONDS\", action=ConfigAction,\n        help=(\"Number of seconds to wait after skipping a file download\"),\n    )\n    sleep.add_argument(\n        \"--sleep-extractor\",\n        dest=\"sleep-extractor\", metavar=\"SECONDS\", action=ConfigAction,\n        help=(\"Number of seconds to wait before starting data extraction \"\n              \"for an input URL\"),\n    )\n    sleep.add_argument(\n        \"--sleep-request\",\n        dest=\"sleep-request\", metavar=\"SECONDS\", action=ConfigAction,\n        help=(\"Number of seconds to wait between HTTP requests \"\n              \"during data extraction\"),\n    )\n    sleep.add_argument(\n        \"--sleep-retries\",\n        dest=\"sleep-retries\", metavar=\"[TYPE=]SECONDS\", action=ConfigAction,\n        help=(\"Number of seconds to wait before retrying an HTTP request. \"\n              \"Can be prefixed with \"\n              \"'lin[:START[:MAX]]' or 'exp[:BASE[:START[:MAX]]]' \"\n              \"for linear or exponential growth between consecutive retries \"\n              \"(e.g. '30', 'exp=40', 'lin:20=30-60'\"),\n    )\n    sleep.add_argument(\n        \"--sleep-429\",\n        dest=\"sleep-429\", metavar=\"[TYPE=]SECONDS\", action=ConfigAction,\n        help=(\"Number of seconds to wait when receiving a \"\n              \"'429 Too Many Requests' response\"),\n    )\n\n    configuration = parser.add_argument_group(\"Configuration Options\")\n    configuration.add_argument(\n        \"-o\", \"--option\",\n        dest=\"options\", metavar=\"KEY=VALUE\",\n        action=ConfigParseAction, default=[],\n        help=(\"Additional options. \"\n              \"Example: -o browser=firefox\")   ,\n    )\n    configuration.add_argument(\n        \"-c\", \"--config\",\n        dest=\"configs_extra\", metavar=\"FILE\", action=\"append\",\n        help=\"Additional configuration files in default format\",\n    )\n    configuration.add_argument(\n        \"--config-json\",\n        dest=\"configs_json\", metavar=\"FILE\", action=\"append\",\n        help=\"Additional configuration files in JSON format\",\n    )\n    configuration.add_argument(\n        \"--config-yaml\",\n        dest=\"configs_yaml\", metavar=\"FILE\", action=\"append\",\n        help=\"Additional configuration files in YAML format\",\n    )\n    configuration.add_argument(\n        \"--config-toml\",\n        dest=\"configs_toml\", metavar=\"FILE\", action=\"append\",\n        help=\"Additional configuration files in TOML format\",\n    )\n    configuration.add_argument(\n        \"--config-type\",\n        dest=\"config_type\", metavar=\"TYPE\",\n        help=(\"Set filetype of default configuration files \"\n              \"(json, yaml, toml)\"),\n    )\n    configuration.add_argument(\n        \"--config-ignore\",\n        dest=\"config_load\", action=\"store_false\",\n        help=\"Do not load default configuration files\",\n    )\n    configuration.add_argument(\n        \"--ignore-config\",\n        dest=\"config_load\", action=\"store_false\",\n        help=SUPPRESS,\n    )\n    configuration.add_argument(\n        \"--config-create\",\n        dest=\"config\", action=\"store_const\", const=\"init\",\n        help=\"Create a basic configuration file\",\n    )\n    configuration.add_argument(\n        \"--config-status\",\n        dest=\"config\", action=\"store_const\", const=\"status\",\n        help=\"Show configuration file status\",\n    )\n    configuration.add_argument(\n        \"--config-open\",\n        dest=\"config\", action=\"store_const\", const=\"open\",\n        help=\"Open configuration file in external application\",\n    )\n\n    cache = parser.add_argument_group(\"Cache Options\")\n    cache.add_argument(\n        \"--cache-file\",\n        dest=\"cache_file\", metavar=\"PATH\",\n        help=\"Use PATH as cache file\",\n    )\n    cache.add_argument(\n        \"--cache-status\",\n        dest=\"cache_status\", action=\"store_true\",\n        help=\"Show cache file information\",\n    )\n    cache.add_argument(\n        \"--cache-show\",\n        dest=\"cache_show\", metavar=\"MODULE\",\n        help=\"Show cached values for MODULE (ALL to show all entries, EXP to \"\n             \"show only expired entries, VAL to show only valid entries)\",\n    )\n    cache.add_argument(\n        \"--cache-clear\",\n        dest=\"cache_clear\", metavar=\"MODULE\",\n        help=\"Delete cached login sessions, cookies, etc. for MODULE \"\n             \"(ALL to delete everything, EXP to delete only expired values)\",\n    )\n    cache.add_argument(\n        \"--cache-vacuum\",\n        dest=\"cache_vacuum\", action=\"store_const\", const=\"vacuum\",\n        help=\"Clean up the cache database by removing unused space and \"\n             \"reorganizing the data to improve performance\",\n    )\n    cache.add_argument(\n        \"--clear-cache\", dest=\"cache_clear\", help=SUPPRESS)\n\n    authentication = parser.add_argument_group(\"Authentication Options\")\n    authentication.add_argument(\n        \"-u\", \"--username\",\n        dest=\"username\", metavar=\"USER\", action=ConfigAction,\n        help=\"Username to login with\",\n    )\n    authentication.add_argument(\n        \"-p\", \"--password\",\n        dest=\"password\", metavar=\"PASS\", action=ConfigAction,\n        help=\"Password belonging to the given username\",\n    )\n    authentication.add_argument(\n        \"--netrc\",\n        dest=\"netrc\", nargs=0, action=ConfigConstAction, const=True,\n        help=\"Enable .netrc authentication data\",\n    )\n\n    cookies = parser.add_argument_group(\"Cookie Options\")\n    cookies.add_argument(\n        \"-C\", \"--cookies\",\n        dest=\"cookies\", metavar=\"FILE\", action=ConfigAction,\n        help=\"File to load additional cookies from\",\n    )\n    cookies.add_argument(\n        \"--cookies-export\",\n        dest=\"cookies-update\", metavar=\"FILE\", action=ConfigAction,\n        help=\"Export session cookies to FILE\",\n    )\n    cookies.add_argument(\n        \"--cookies-from-browser\",\n        dest=\"cookies_from_browser\",\n        metavar=\"BROWSER[/DOMAIN][+KEYRING][:PROFILE][::CONTAINER]\",\n        help=(\"Name of the browser to load cookies from, with optional \"\n              \"domain prefixed with '/', \"\n              \"keyring name prefixed with '+', \"\n              \"profile prefixed with ':', and \"\n              \"container prefixed with '::' \"\n              \"('none' for no container (default), 'all' for all containers)\"),\n    )\n\n    selection = parser.add_argument_group(\"Selection Options\")\n    selection.add_argument(\n        \"-A\", \"--abort\",\n        dest=\"abort\", metavar=\"N[:TARGET]\",\n        help=(\"Stop current extractor(s) \"\n              \"after N consecutive file downloads were skipped. \"\n              \"Specify a TARGET to set how many levels to ascend or \"\n              \"to which subcategory to jump to. \"\n              \"Examples: '-A 3', '-A 3:2', '-A 3:manga'\"),\n    )\n    selection.add_argument(\n        \"-T\", \"--terminate\",\n        dest=\"terminate\", metavar=\"N\",\n        help=(\"Stop current & parent extractors \"\n              \"and proceed with the next input URL \"\n              \"after N consecutive file downloads were skipped\"),\n    )\n    selection.add_argument(\n        \"--filesize-min\",\n        dest=\"filesize-min\", metavar=\"SIZE\", action=ConfigAction,\n        help=\"Do not download files smaller than SIZE (e.g. 500k or 2.5M)\",\n    )\n    selection.add_argument(\n        \"--filesize-max\",\n        dest=\"filesize-max\", metavar=\"SIZE\", action=ConfigAction,\n        help=\"Do not download files larger than SIZE (e.g. 500k or 2.5M)\",\n    )\n    selection.add_argument(\n        \"--download-archive\",\n        dest=\"archive\", metavar=\"FILE\", action=ConfigAction,\n        help=(\"Record successfully downloaded files in FILE and \"\n              \"skip downloading any file already in it\"),\n    )\n    selection.add_argument(\n        \"--date-before\",\n        dest=\"date-before\", metavar=\"DATE\", action=ConfigAction,\n        help=(\"Process only posts created before this date given in \"\n              \"ISO 8601 format or as Unix timestamp (e.g. '2025-10-31', \"\n              \"'2026-01-09T15:30:00', '1767972600')\")\n    )\n    selection.add_argument(\n        \"--date-after\",\n        dest=\"date-after\", metavar=\"DATE\", action=ConfigAction,\n        help=(\"Process only posts created after this date. \"\n              \"Stop extraction when an older post is encountered\")\n    )\n    selection.add_argument(\n        \"--blacklist\",\n        dest=\"blacklist\", metavar=\"CATEGORIES\", action=ConfigAction,\n        help=(\"Ignore the given comma-separated category names or \"\n              \"category:subcategory pairs when spawning child extractors for \"\n              \"external URLs (e.g. 'pixiv', 'pixiv:user,*:artist')\"),\n    )\n    selection.add_argument(\n        \"--whitelist\",\n        dest=\"whitelist\", metavar=\"CATEGORIES\", action=ConfigAction,\n        help=(\"Allow only the given comma-separated category names or \"\n              \"category:subcategory pairs to allow when spawning child \"\n              \"extractors for external URLs\"),\n    )\n    selection.add_argument(\n        \"--tags-blacklist\",\n        dest=\"tags-blacklist\", metavar=\"TAGS\", action=ConfigAction,\n        help=(\"Ignore posts tagged with any of the tags given as comma-\"\n              \"separated list or path to a file containing them (e.g. \"\n              r\"'1girl', 'shirt,highres,smile', 'C:\\path\\to\\list.txt')\"),\n    )\n    selection.add_argument(\n        \"--tags-whitelist\",\n        dest=\"tags-whitelist\", metavar=\"TAGS\", action=ConfigAction,\n        help=(\"Allow only posts tagged with at least one of the tags given as \"\n              \"comma-separated list or path to a file containing them\"),\n    )\n    selection.add_argument(\n        \"--range\",\n        dest=\"file-range\", metavar=\"RANGE\", action=ConfigAction,\n        help=(\"Index range(s) specifying which files to download. \"\n              \"These can be either a constant value, range, or slice \"\n              \"(e.g. '5', '8-20', or '1:24:3')\"),\n    )\n    selection.add_argument(\n        \"--post-range\",\n        dest=\"post-range\", metavar=\"RANGE\", action=ConfigAction,\n        help=(\"Like '--range', but for posts\"),\n    )\n    selection.add_argument(\n        \"--child-range\",\n        dest=\"child-range\", metavar=\"RANGE\", action=ConfigAction,\n        help=(\"Like '--range', but for child extractors handling \"\n              \"manga chapters, external URLs, etc.\"),\n    )\n    selection.add_argument(\n        \"--filter\",\n        dest=\"file-filter\", metavar=\"EXPR\", action=ConfigAction,\n        help=(\"Python expression controlling which files to download. \"\n              \"Files for which the expression evaluates to False are ignored. \"\n              \"Available keys are the filename-specific ones listed by '-K'. \"\n              \"Example: --filter \\\"image_width >= 1000 and \"\n              \"rating in ('s', 'q')\\\"\"),\n    )\n    selection.add_argument(\n        \"--post-filter\",\n        dest=\"post-filter\", metavar=\"EXPR\", action=ConfigAction,\n        help=(\"Like '--filter', but for posts\"),\n    )\n    selection.add_argument(\n        \"--child-filter\",\n        dest=\"child-filter\", metavar=\"EXPR\", action=ConfigAction,\n        help=(\"Like '--filter', but for child extractors handling \"\n              \"manga chapters, external URLs, etc.\"),\n    )\n    selection.add_argument(\n        \"--file-range\", \"--image-range\",\n        dest=\"file-range\", action=ConfigAction, help=SUPPRESS)\n    selection.add_argument(\n        \"--chapter-range\",\n        dest=\"child-range\", action=ConfigAction, help=SUPPRESS)\n    selection.add_argument(\n        \"--file-filter\", \"--image-filter\",\n        dest=\"file-filter\", action=ConfigAction, help=SUPPRESS)\n    selection.add_argument(\n        \"--chapter-filter\",\n        dest=\"child-filter\", action=ConfigAction, help=SUPPRESS)\n\n    infojson = {\n        \"name\"    : \"metadata\",\n        \"event\"   : \"init\",\n        \"filename\": \"info.json\",\n    }\n    postprocessor = parser.add_argument_group(\"Post-processing Options\")\n    postprocessor.add_argument(\n        \"-P\", \"--postprocessor\",\n        dest=\"postprocessors\", metavar=\"NAME\", action=\"append\",\n        help=\"Activate the specified post processor\",\n    )\n    postprocessor.add_argument(\n        \"--no-postprocessors\",\n        dest=\"postprocess\", nargs=0, action=ConfigConstAction, const=False,\n        help=(\"Do not run any post processors\")\n    )\n    postprocessor.add_argument(\n        \"-O\", \"--postprocessor-option\",\n        dest=\"options_pp\", metavar=\"KEY=VALUE\",\n        action=PPParseAction, default={},\n        help=\"Additional post processor options\",\n    )\n    postprocessor.add_argument(\n        \"--write-metadata\",\n        dest=\"postprocessors\",\n        action=\"append_const\", const=\"metadata\",\n        help=\"Write metadata to separate JSON files\",\n    )\n    postprocessor.add_argument(\n        \"--write-info-json\",\n        dest=\"postprocessors\",\n        action=\"append_const\", const=infojson,\n        help=\"Write gallery metadata to a info.json file\",\n    )\n    postprocessor.add_argument(\n        \"--write-infojson\",\n        dest=\"postprocessors\",\n        action=\"append_const\", const=infojson,\n        help=SUPPRESS,\n    )\n    postprocessor.add_argument(\n        \"--write-tags\",\n        dest=\"postprocessors\",\n        action=\"append_const\", const={\"name\": \"metadata\", \"mode\": \"tags\"},\n        help=\"Write image tags to separate text files\",\n    )\n    postprocessor.add_argument(\n        \"--zip\",\n        dest=\"postprocessors\",\n        action=\"append_const\", const=\"zip\",\n        help=\"Store downloaded files in a ZIP archive\",\n    )\n    postprocessor.add_argument(\n        \"--cbz\",\n        dest=\"postprocessors\",\n        action=\"append_const\", const={\n            \"name\"     : \"zip\",\n            \"extension\": \"cbz\",\n        },\n        help=\"Store downloaded files in a CBZ archive\",\n    )\n    postprocessor.add_argument(\n        \"--mtime\",\n        dest=\"postprocessors\", metavar=\"NAME\", action=MtimeAction,\n        help=(\"Set file modification times according to metadata \"\n              \"selected by NAME. Examples: 'date' or 'status[date]'\"),\n    )\n    postprocessor.add_argument(\n        \"--mtime-from-date\",\n        dest=\"postprocessors\", nargs=0, action=MtimeAction,\n        const=\"date|status[date]\",\n        help=SUPPRESS,\n    )\n    postprocessor.add_argument(\n        \"--rename\",\n        dest=\"postprocessors\", metavar=\"FORMAT\", action=RenameAction, const=0,\n        help=(\"Rename previously downloaded files from FORMAT \"\n              \"to the current filename format\"),\n    )\n    postprocessor.add_argument(\n        \"--rename-to\",\n        dest=\"postprocessors\", metavar=\"FORMAT\", action=RenameAction, const=1,\n        help=(\"Rename previously downloaded files from the current filename \"\n              \"format to FORMAT\"),\n    )\n    postprocessor.add_argument(\n        \"--ugoira\",\n        dest=\"postprocessors\", metavar=\"FMT\", action=UgoiraAction,\n        help=(\"Convert Pixiv Ugoira to FMT using FFmpeg. \"\n              \"Supported formats are 'webm', 'mp4', 'gif', \"\n              \"'vp8', 'vp9', 'vp9-lossless', 'copy', 'zip'.\"),\n    )\n    postprocessor.add_argument(\n        \"--ugoira-conv\",\n        dest=\"postprocessors\", nargs=0, action=UgoiraAction, const=\"vp8\",\n        help=SUPPRESS,\n    )\n    postprocessor.add_argument(\n        \"--ugoira-conv-lossless\",\n        dest=\"postprocessors\", nargs=0, action=UgoiraAction,\n        const=\"vp9-lossless\",\n        help=SUPPRESS,\n    )\n    postprocessor.add_argument(\n        \"--ugoira-conv-copy\",\n        dest=\"postprocessors\", nargs=0, action=UgoiraAction, const=\"copy\",\n        help=SUPPRESS,\n    )\n    postprocessor.add_argument(\n        \"--exec\",\n        dest=\"postprocessors\", metavar=\"CMD\",\n        action=AppendCommandAction, const={\"name\": \"exec\"},\n        help=(\"Execute CMD for each downloaded file. \"\n              \"Supported replacement fields are \"\n              \"{} or {_path}, {_temppath}, {_directory}, {_filename}. \"\n              \"On Windows, use {_path_unc} or {_directory_unc} for UNC paths. \"\n              \"Example: --exec \\\"convert {} {}.png && rm {}\\\"\"),\n    )\n    postprocessor.add_argument(\n        \"--exec-after\",\n        dest=\"postprocessors\", metavar=\"CMD\",\n        action=AppendCommandAction, const={\n            \"name\": \"exec\", \"event\": \"finalize\"},\n        help=(\"Execute CMD after all files were downloaded. \"\n              \"Example: --exec-after \\\"cd {_directory} \"\n              \"&& convert * ../doc.pdf\\\"\"),\n    )\n\n    try:\n        # restore normal behavior when adding '-4' or '-6' as arguments\n        parser._has_negative_number_optionals.clear()\n    except Exception:\n        pass\n\n    return parser\n"
  },
  {
    "path": "gallery_dl/output.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2015-2025 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nimport os\nimport sys\nimport shutil\nimport logging\nimport unicodedata\nfrom . import config, util, formatter\n\n\n# --------------------------------------------------------------------\n# Globals\n\ntry:\n    TTY_STDOUT = sys.stdout.isatty()\nexcept Exception:\n    TTY_STDOUT = False\n\ntry:\n    TTY_STDERR = sys.stderr.isatty()\nexcept Exception:\n    TTY_STDERR = False\n\ntry:\n    TTY_STDIN = sys.stdin.isatty()\nexcept Exception:\n    TTY_STDIN = False\n\n\nCOLORS_DEFAULT = {}\nCOLORS = not os.environ.get(\"NO_COLOR\")\nif COLORS:\n    if TTY_STDOUT:\n        COLORS_DEFAULT[\"success\"] = \"1;32\"\n        COLORS_DEFAULT[\"skip\"] = \"2\"\n    if TTY_STDERR:\n        COLORS_DEFAULT[\"debug\"] = \"0;37\"\n        COLORS_DEFAULT[\"info\"] = \"1;37\"\n        COLORS_DEFAULT[\"warning\"] = \"1;33\"\n        COLORS_DEFAULT[\"error\"] = \"1;31\"\n\n\nif util.WINDOWS:\n    ANSI = COLORS and os.environ.get(\"TERM\") == \"ANSI\"\n    OFFSET = 1\n    CHAR_SKIP = \"# \"\n    CHAR_SUCCESS = \"* \"\n    CHAR_ELLIPSIES = \"...\"\nelse:\n    ANSI = COLORS\n    OFFSET = 0\n    CHAR_SKIP = \"# \"\n    CHAR_SUCCESS = \"✔ \"\n    CHAR_ELLIPSIES = \"…\"\n\n\n# --------------------------------------------------------------------\n# Logging\n\nLOG_FORMAT = \"[{name}][{levelname}] {message}\"\nLOG_FORMAT_DATE = \"%Y-%m-%d %H:%M:%S\"\nLOG_LEVEL = logging.INFO\nLOG_LEVELS = (\"debug\", \"info\", \"warning\", \"error\")\n\n\nclass Logger(logging.Logger):\n    \"\"\"Custom Logger that includes extra info in log records\"\"\"\n\n    def makeRecord(self, name, level, fn, lno, msg, args, exc_info,\n                   func=None, extra=None, sinfo=None,\n                   factory=logging._logRecordFactory):\n        rv = factory(name, level, fn, lno, msg, args, exc_info, func, sinfo)\n        if extra:\n            rv.__dict__.update(extra)\n        return rv\n\n\nclass LoggerAdapter():\n    \"\"\"Trimmed-down version of logging.LoggingAdapter\"\"\"\n    __slots__ = (\"logger\", \"extra\")\n\n    def __init__(self, logger, job):\n        self.logger = logger\n        self.extra = job._logger_extra\n\n    def traceback(self, exc):\n        if self.logger.isEnabledFor(logging.DEBUG):\n            self.logger._log(\n                logging.DEBUG, \"\", None, exc_info=exc, extra=self.extra)\n\n    def debug(self, msg, *args, **kwargs):\n        if self.logger.isEnabledFor(logging.DEBUG):\n            kwargs[\"extra\"] = self.extra\n            self.logger._log(logging.DEBUG, msg, args, **kwargs)\n\n    def info(self, msg, *args, **kwargs):\n        if self.logger.isEnabledFor(logging.INFO):\n            kwargs[\"extra\"] = self.extra\n            self.logger._log(logging.INFO, msg, args, **kwargs)\n\n    def warning(self, msg, *args, **kwargs):\n        if self.logger.isEnabledFor(logging.WARNING):\n            kwargs[\"extra\"] = self.extra\n            self.logger._log(logging.WARNING, msg, args, **kwargs)\n\n    def error(self, msg, *args, **kwargs):\n        if self.logger.isEnabledFor(logging.ERROR):\n            kwargs[\"extra\"] = self.extra\n            self.logger._log(logging.ERROR, msg, args, **kwargs)\n\n\nclass PathfmtProxy():\n    __slots__ = (\"job\",)\n\n    def __init__(self, job):\n        self.job = job\n\n    def __getattribute__(self, name):\n        pathfmt = object.__getattribute__(self, \"job\").pathfmt\n        return getattr(pathfmt, name, None) if pathfmt else None\n\n    def __str__(self):\n        if pathfmt := object.__getattribute__(self, \"job\").pathfmt:\n            return pathfmt.path or pathfmt.directory\n        return \"\"\n\n\nclass KwdictProxy():\n    __slots__ = (\"job\",)\n\n    def __init__(self, job):\n        self.job = job\n\n    def __getattribute__(self, name):\n        pathfmt = object.__getattribute__(self, \"job\").pathfmt\n        return pathfmt.kwdict.get(name) if pathfmt else None\n\n\nclass Formatter(logging.Formatter):\n    \"\"\"Custom formatter that supports different formats per loglevel\"\"\"\n\n    def __init__(self, fmt, datefmt):\n        if isinstance(fmt, dict):\n            for key in LOG_LEVELS:\n                value = fmt[key] if key in fmt else LOG_FORMAT\n                fmt[key] = (formatter.parse(value).format_map,\n                            \"{asctime\" in value)\n        else:\n            if fmt == LOG_FORMAT:\n                fmt = (fmt.format_map, False)\n            else:\n                fmt = (formatter.parse(fmt).format_map, \"{asctime\" in fmt)\n            fmt = {\"debug\": fmt, \"info\": fmt, \"warning\": fmt, \"error\": fmt}\n\n        self.formats = fmt\n        self.datefmt = datefmt\n\n    def format(self, record):\n        record.message = record.getMessage()\n        fmt, asctime = self.formats[record.levelname]\n        if asctime:\n            record.asctime = self.formatTime(record, self.datefmt)\n        msg = fmt(record.__dict__)\n        if record.exc_info and not record.exc_text:\n            record.exc_text = self.formatException(record.exc_info)\n        if record.exc_text:\n            msg = f\"{msg}\\n{record.exc_text}\"\n        if record.stack_info:\n            msg = f\"{msg}\\n{record.stack_info}\"\n        return msg\n\n\nclass FileHandler(logging.StreamHandler):\n    def __init__(self, path, mode, encoding, delay=True):\n        self.path = path\n        self.mode = mode\n        self.errors = None\n        self.encoding = encoding\n\n        if delay:\n            logging.Handler.__init__(self)\n            self.stream = None\n            self.emit = self.emit_delayed\n        else:\n            logging.StreamHandler.__init__(self, self._open())\n\n    def close(self):\n        with self.lock:\n            try:\n                if self.stream:\n                    try:\n                        self.flush()\n                        self.stream.close()\n                    finally:\n                        self.stream = None\n            finally:\n                logging.StreamHandler.close(self)\n\n    def _open(self):\n        try:\n            return open(self.path, self.mode,\n                        encoding=self.encoding, errors=self.errors)\n        except FileNotFoundError:\n            os.makedirs(os.path.dirname(self.path))\n            return open(self.path, self.mode,\n                        encoding=self.encoding, errors=self.errors)\n\n    def emit_delayed(self, record):\n        if self.mode != \"w\" or not self._closed:\n            self.stream = self._open()\n        self.emit = logging.StreamHandler.emit.__get__(self)\n        self.emit(record)\n\n\ndef initialize_logging(loglevel):\n    \"\"\"Setup basic logging functionality before configfiles have been loaded\"\"\"\n    # convert levelnames to lowercase\n    for level in (10, 20, 30, 40, 50):\n        name = logging.getLevelName(level)\n        logging.addLevelName(level, name.lower())\n\n    # register custom Logging class\n    logging.Logger.manager.setLoggerClass(Logger)\n\n    # setup basic logging to stderr\n    formatter = Formatter(LOG_FORMAT, LOG_FORMAT_DATE)\n    handler = logging.StreamHandler()\n    handler.setFormatter(formatter)\n    handler.setLevel(loglevel)\n    root = logging.getLogger()\n    root.setLevel(logging.NOTSET)\n    root.addHandler(handler)\n\n    return logging.getLogger(\"gallery-dl\")\n\n\ndef configure_logging(loglevel):\n    root = logging.getLogger()\n    minlevel = loglevel\n\n    # stream logging handler\n    handler = root.handlers[0]\n    opts = config.interpolate((\"output\",), \"log\")\n\n    colors = config.interpolate((\"output\",), \"colors\")\n    if colors is None:\n        colors = COLORS_DEFAULT\n    if colors and not opts:\n        opts = LOG_FORMAT\n\n    if opts:\n        if isinstance(opts, str):\n            logfmt = opts\n            opts = {}\n        elif \"format\" in opts:\n            logfmt = opts[\"format\"]\n        else:\n            logfmt = LOG_FORMAT\n\n        if not isinstance(logfmt, dict) and colors:\n            ansifmt = \"\\033[{}m{}\\033[0m\".format\n            lf = {}\n            for level in LOG_LEVELS:\n                c = colors.get(level)\n                lf[level] = ansifmt(c, logfmt) if c else logfmt\n            logfmt = lf\n\n        handler.setFormatter(Formatter(\n            logfmt, opts.get(\"format-date\", LOG_FORMAT_DATE)))\n\n        if \"level\" in opts and handler.level == LOG_LEVEL:\n            handler.setLevel(opts[\"level\"])\n\n        if minlevel > handler.level:\n            minlevel = handler.level\n\n    # file logging handler\n    if handler := setup_logging_handler(\"logfile\", lvl=loglevel):\n        root.addHandler(handler)\n        if minlevel > handler.level:\n            minlevel = handler.level\n\n    root.setLevel(minlevel)\n\n\ndef setup_logging_handler(key, fmt=LOG_FORMAT, lvl=LOG_LEVEL, mode=\"w\",\n                          defer=False):\n    \"\"\"Setup a new logging handler\"\"\"\n    opts = config.interpolate((\"output\",), key)\n    if not opts:\n        return None\n    if not isinstance(opts, dict):\n        opts = {\"path\": opts}\n\n    path = opts.get(\"path\")\n    mode = opts.get(\"mode\", mode)\n    encoding = opts.get(\"encoding\", \"utf-8\")\n    delay = opts.get(\"defer\", defer)\n    try:\n        path = util.expand_path(path)\n        handler = FileHandler(path, mode, encoding, delay)\n    except (OSError, ValueError) as exc:\n        logging.getLogger(\"gallery-dl\").warning(\n            \"%s: %s\", key, exc)\n        return None\n    except TypeError as exc:\n        logging.getLogger(\"gallery-dl\").warning(\n            \"%s: missing or invalid path (%s)\", key, exc)\n        return None\n\n    handler.setLevel(opts.get(\"level\", lvl))\n    handler.setFormatter(Formatter(\n        opts.get(\"format\", fmt),\n        opts.get(\"format-date\", LOG_FORMAT_DATE),\n    ))\n    return handler\n\n\n# --------------------------------------------------------------------\n# Utility functions\n\ndef stdout_write_flush(s):\n    sys.stdout.write(s)\n    sys.stdout.flush()\n\n\ndef stderr_write_flush(s):\n    sys.stderr.write(s)\n    sys.stderr.flush()\n\n\nif getattr(sys.stdout, \"line_buffering\", None):\n    def stdout_write(s):\n        sys.stdout.write(s)\nelse:\n    stdout_write = stdout_write_flush\n\n\nif getattr(sys.stderr, \"line_buffering\", None):\n    def stderr_write(s):\n        sys.stderr.write(s)\nelse:\n    stderr_write = stderr_write_flush\n\n\ndef configure_standard_streams():\n    for name in (\"stdout\", \"stderr\", \"stdin\"):\n        stream = getattr(sys, name, None)\n        if not stream:\n            continue\n\n        options = config.get((\"output\",), name)\n        if not options:\n            options = {\"errors\": \"replace\"}\n        elif isinstance(options, str):\n            options = {\"errors\": \"replace\", \"encoding\": options}\n        elif not options.get(\"errors\"):\n            options[\"errors\"] = \"replace\"\n\n        stream.reconfigure(**options)\n\n\n# --------------------------------------------------------------------\n# Downloader output\n\ndef select():\n    \"\"\"Select a suitable output class\"\"\"\n    mode = config.get((\"output\",), \"mode\")\n\n    if mode is None or mode == \"auto\":\n        try:\n            if TTY_STDOUT:\n                output = ColorOutput() if ANSI else TerminalOutput()\n            else:\n                output = PipeOutput()\n        except Exception:\n            output = PipeOutput()\n    elif isinstance(mode, dict):\n        output = CustomOutput(mode)\n    elif not mode:\n        output = NullOutput()\n    else:\n        output = {\n            \"default\" : PipeOutput,\n            \"pipe\"    : PipeOutput,\n            \"term\"    : TerminalOutput,\n            \"terminal\": TerminalOutput,\n            \"color\"   : ColorOutput,\n            \"null\"    : NullOutput,\n        }[mode.lower()]()\n\n    if not config.get((\"output\",), \"skip\", True):\n        output.skip = util.identity\n    return output\n\n\nclass NullOutput():\n\n    def start(self, path):\n        \"\"\"Print a message indicating the start of a download\"\"\"\n\n    def skip(self, path):\n        \"\"\"Print a message indicating that a download has been skipped\"\"\"\n\n    def success(self, path):\n        \"\"\"Print a message indicating the completion of a download\"\"\"\n\n    def progress(self, bytes_total, bytes_downloaded, bytes_per_second):\n        \"\"\"Display download progress\"\"\"\n\n\nclass PipeOutput(NullOutput):\n\n    def skip(self, path):\n        stdout_write(f\"{CHAR_SKIP}{path}\\n\")\n\n    def success(self, path):\n        stdout_write(f\"{path}\\n\")\n\n\nclass TerminalOutput():\n\n    def __init__(self):\n        if shorten := config.get((\"output\",), \"shorten\", True):\n            func = shorten_string_eaw if shorten == \"eaw\" else shorten_string\n            limit = shutil.get_terminal_size().columns - OFFSET\n            sep = CHAR_ELLIPSIES\n            self.shorten = lambda txt: func(txt, limit, sep)\n        else:\n            self.shorten = util.identity\n\n    def start(self, path):\n        stdout_write_flush(self.shorten(f\"  {path}\"))\n\n    def skip(self, path):\n        stdout_write(f\"{self.shorten(CHAR_SKIP + path)}\\n\")\n\n    def success(self, path):\n        stdout_write(f\"\\r{self.shorten(CHAR_SUCCESS + path)}\\n\")\n\n    def progress(self, bytes_total, bytes_downloaded, bytes_per_second):\n        bdl = util.format_value(bytes_downloaded)\n        bps = util.format_value(bytes_per_second)\n        if bytes_total is None:\n            stderr_write(f\"\\r{bdl:>7}B {bps:>7}B/s \")\n        else:\n            stderr_write(f\"\\r{bytes_downloaded * 100 // bytes_total:>3}% \"\n                         f\"{bdl:>7}B {bps:>7}B/s \")\n\n\nclass ColorOutput(TerminalOutput):\n\n    def __init__(self):\n        TerminalOutput.__init__(self)\n\n        colors = config.interpolate((\"output\",), \"colors\")\n        if colors is None:\n            colors = COLORS_DEFAULT\n\n        self.color_skip = f\"\\x1b[{colors.get('skip', '2')}m\"\n        self.color_success = f\"\\r\\x1b[{colors.get('success', '1;32')}m\"\n\n    def start(self, path):\n        stdout_write_flush(self.shorten(path))\n\n    def skip(self, path):\n        stdout_write(f\"{self.color_skip}{self.shorten(path)}\\x1b[0m\\n\")\n\n    def success(self, path):\n        stdout_write(f\"{self.color_success}{self.shorten(path)}\\x1b[0m\\n\")\n\n\nclass CustomOutput():\n\n    def __init__(self, options):\n\n        fmt_skip = options.get(\"skip\")\n        fmt_start = options.get(\"start\")\n        fmt_success = options.get(\"success\")\n        off_skip = off_start = off_success = 0\n\n        if isinstance(fmt_skip, list):\n            off_skip, fmt_skip = fmt_skip\n        if isinstance(fmt_start, list):\n            off_start, fmt_start = fmt_start\n        if isinstance(fmt_success, list):\n            off_success, fmt_success = fmt_success\n\n        if shorten := config.get((\"output\",), \"shorten\", True):\n            func = shorten_string_eaw if shorten == \"eaw\" else shorten_string\n            width = shutil.get_terminal_size().columns\n\n            self._fmt_skip = self._make_func(\n                func, fmt_skip, width - off_skip)\n            self._fmt_start = self._make_func(\n                func, fmt_start, width - off_start)\n            self._fmt_success = self._make_func(\n                func, fmt_success, width - off_success)\n        else:\n            self._fmt_skip = fmt_skip.format\n            self._fmt_start = fmt_start.format\n            self._fmt_success = fmt_success.format\n\n        self._fmt_progress = (options.get(\"progress\") or\n                              \"\\r{0:>7}B {1:>7}B/s \").format\n        self._fmt_progress_total = (options.get(\"progress-total\") or\n                                    \"\\r{3:>3}% {0:>7}B {1:>7}B/s \").format\n\n    def _make_func(self, shorten, format_string, limit):\n        fmt = format_string.format\n        return lambda txt: fmt(shorten(txt, limit, CHAR_ELLIPSIES))\n\n    def start(self, path):\n        stdout_write_flush(self._fmt_start(path))\n\n    def skip(self, path):\n        stdout_write(self._fmt_skip(path))\n\n    def success(self, path):\n        stdout_write(self._fmt_success(path))\n\n    def progress(self, bytes_total, bytes_downloaded, bytes_per_second):\n        bdl = util.format_value(bytes_downloaded)\n        bps = util.format_value(bytes_per_second)\n        if bytes_total is None:\n            stderr_write(self._fmt_progress(bdl, bps))\n        else:\n            stderr_write(self._fmt_progress_total(\n                bdl, bps, util.format_value(bytes_total),\n                bytes_downloaded * 100 // bytes_total))\n\n\nclass EAWCache(dict):\n\n    def __missing__(self, key):\n        width = self[key] = \\\n            2 if unicodedata.east_asian_width(key) in \"WF\" else 1\n        return width\n\n\ndef shorten_string(txt, limit, sep=\"…\"):\n    \"\"\"Limit width of 'txt'; assume all characters have a width of 1\"\"\"\n    if len(txt) <= limit:\n        return txt\n    limit -= len(sep)\n    return f\"{txt[:limit // 2]}{sep}{txt[-((limit+1) // 2):]}\"\n\n\ndef shorten_string_eaw(txt, limit, sep=\"…\", cache=EAWCache()):\n    \"\"\"Limit width of 'txt'; check for east-asian characters with width > 1\"\"\"\n    char_widths = [cache[c] for c in txt]\n    text_width = sum(char_widths)\n\n    if text_width <= limit:\n        # no shortening required\n        return txt\n\n    limit -= len(sep)\n    if text_width == len(txt):\n        # all characters have a width of 1\n        return f\"{txt[:limit // 2]}{sep}{txt[-((limit+1) // 2):]}\"\n\n    # wide characters\n    left = 0\n    lwidth = limit // 2\n    while True:\n        lwidth -= char_widths[left]\n        if lwidth < 0:\n            break\n        left += 1\n\n    right = -1\n    rwidth = (limit+1) // 2 + (lwidth + char_widths[left])\n    while True:\n        rwidth -= char_widths[right]\n        if rwidth < 0:\n            break\n        right -= 1\n\n    return f\"{txt[:left]}{sep}{txt[right+1:]}\"\n"
  },
  {
    "path": "gallery_dl/path.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2021-2026 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Filesystem path handling\"\"\"\n\nimport os\nimport shutil\nimport functools\nfrom . import util, formatter, exception\n\nWINDOWS = util.WINDOWS\nEXTENSION_MAP = {\n    \"jpeg\": \"jpg\",\n    \"jpe\" : \"jpg\",\n    \"jfif\": \"jpg\",\n    \"jif\" : \"jpg\",\n    \"jfi\" : \"jpg\",\n}\n\n\nclass PathFormat():\n\n    def __init__(self, extractor):\n        config = extractor.config\n        kwdefault = config(\"keywords-default\")\n        if kwdefault is None:\n            kwdefault = util.NONE\n\n        self.filename_conditions = self.directory_conditions = None\n\n        filename_fmt = config(\"filename\")\n        try:\n            if filename_fmt is None:\n                filename_fmt = extractor.filename_fmt\n            elif isinstance(filename_fmt, dict):\n                self.filename_conditions = [\n                    (util.compile_filter(expr),\n                     formatter.parse(fmt, kwdefault).format_map)\n                    for expr, fmt in filename_fmt.items() if expr\n                ]\n                filename_fmt = filename_fmt.get(\"\", extractor.filename_fmt)\n\n            self.filename_formatter = formatter.parse(\n                filename_fmt, kwdefault).format_map\n        except Exception as exc:\n            raise exception.FilenameFormatError(exc)\n\n        directory_fmt = config(\"directory\")\n        try:\n            if directory_fmt is None:\n                directory_fmt = extractor.directory_fmt\n            elif isinstance(directory_fmt, dict):\n                self.directory_conditions = [\n                    (util.compile_filter(expr), [\n                        formatter.parse(fmt, kwdefault).format_map\n                        for fmt in fmts\n                    ])\n                    for expr, fmts in directory_fmt.items() if expr\n                ]\n                directory_fmt = directory_fmt.get(\"\", extractor.directory_fmt)\n\n            self.directory_formatters = [\n                formatter.parse(dirfmt, kwdefault).format_map\n                for dirfmt in directory_fmt\n            ]\n        except Exception as exc:\n            raise exception.DirectoryFormatError(exc)\n\n        self.kwdict = {}\n        self.delete = False\n        self.prefix = \"\"\n        self.filename = \"\"\n        self.extension = \"\"\n        self.directory = \"\"\n        self.realdirectory = \"\"\n        self.path = \"\"\n        self.realpath = \"\"\n        self.temppath = \"\"\n\n        extension_map = config(\"extension-map\")\n        if extension_map is None:\n            extension_map = EXTENSION_MAP\n        self.extension_map = extension_map.get\n\n        restrict = config(\"path-restrict\", \"auto\")\n        replace = config(\"path-replace\", \"_\")\n        conv = config(\"path-convert\")\n        if restrict == \"auto\":\n            restrict = \"\\\\\\\\|/<>:\\\"?*\" if WINDOWS else \"/\"\n        elif restrict == \"unix\":\n            restrict = \"/\"\n        elif restrict == \"windows\":\n            restrict = \"\\\\\\\\|/<>:\\\"?*\"\n        elif restrict == \"windows+\":\n            restrict = {\"\\\\\": \"⧹\", \"|\" : \"\", \"/\" : \"⧸\", \"<\" : \"＜\", \">\" : \"＞\",\n                        \":\" : \"：\", '\"' : \"＂\", \"?\" : \"？\", \"*\" : \"＊\"}\n        elif restrict == \"ascii\":\n            restrict = \"^0-9A-Za-z_.\"\n        elif restrict == \"ascii+\":\n            restrict = \"^0-9@-[\\\\]-{ #-)+-.;=!}~\"\n        self.clean_segment = _build_cleanfunc(restrict, replace, conv)\n\n        remove = config(\"path-remove\", \"\\x00-\\x1f\\x7f\")\n        self.clean_path = _build_cleanfunc(remove, \"\")\n\n        strip = config(\"path-strip\", \"auto\")\n        if strip == \"auto\":\n            strip = \". \" if WINDOWS else \"\"\n        elif strip == \"unix\":\n            strip = \"\"\n        elif strip == \"windows\":\n            strip = \". \"\n        self.strip = strip\n\n        if WINDOWS:\n            self.extended = config(\"path-extended\", True)\n\n        self.basedirectory_conditions = None\n        basedir = extractor._parentdir\n        if not basedir:\n            basedir = config(\"base-directory\")\n            if basedir is None:\n                basedir = self.clean_path(f\".{os.sep}gallery-dl{os.sep}\")\n            elif basedir:\n                if isinstance(basedir, dict):\n                    self.basedirectory_conditions = conds = []\n                    for expr, bdir in basedir.items():\n                        if not expr:\n                            basedir = bdir\n                            continue\n                        conds.append((util.compile_filter(expr),\n                                      self._prepare_basedirectory(bdir)))\n                basedir = self._prepare_basedirectory(basedir)\n        self.basedirectory = basedir\n\n    def _prepare_basedirectory(self, basedir):\n        basedir = util.expand_path(basedir)\n        if os.altsep and os.altsep in basedir:\n            basedir = basedir.replace(os.altsep, os.sep)\n        if basedir[-1] != os.sep:\n            basedir += os.sep\n        return self.clean_path(basedir)\n\n    def __str__(self):\n        return self.realpath\n\n    def open(self, mode=\"wb\"):\n        \"\"\"Open file and return a corresponding file object\"\"\"\n        try:\n            return open(self.temppath, mode)\n        except FileNotFoundError:\n            if \"r\" in mode:\n                # '.part' file no longer exists\n                return util.NullContext()\n            os.makedirs(self.realdirectory)\n            return open(self.temppath, mode)\n\n    def exists(self):\n        \"\"\"Return True if the file exists on disk\"\"\"\n        if self.extension:\n            try:\n                os.lstat(self.realpath)  # raises OSError if file doesn't exist\n                return self.check_file()\n            except OSError:\n                pass\n        return False\n\n    def check_file(self):\n        return True\n\n    def _enum_file(self):\n        num = 1\n        try:\n            while True:\n                prefix = format(num) + \".\"\n                self.kwdict[\"extension\"] = prefix + self.extension\n                self.build_path()\n                os.lstat(self.realpath)  # raises OSError if file doesn't exist\n                num += 1\n        except OSError:\n            pass\n        self.prefix = prefix\n        return False\n\n    def set_directory(self, kwdict):\n        \"\"\"Build directory path and create it if necessary\"\"\"\n        self.kwdict = kwdict\n\n        if self.basedirectory_conditions is None:\n            basedir = self.basedirectory\n        else:\n            for condition, basedir in self.basedirectory_conditions:\n                if condition(kwdict):\n                    break\n            else:\n                basedir = self.basedirectory\n\n        if segments := self.build_directory(kwdict):\n            self.directory = directory = \\\n                f\"{basedir}{self.clean_path(os.sep.join(segments))}{os.sep}\"\n        else:\n            self.directory = directory = basedir\n\n        if WINDOWS and self.extended:\n            directory = self._extended_path(directory)\n        self.realdirectory = directory\n\n    def _extended_path(self, path):\n        # Enable longer-than-260-character paths\n        path = os.path.abspath(path)\n        if not path.startswith(\"\\\\\\\\\"):\n            path = \"\\\\\\\\?\\\\\" + path\n        elif not path.startswith(\"\\\\\\\\?\\\\\"):\n            path = \"\\\\\\\\?\\\\UNC\\\\\" + path[2:]\n\n        # abspath() in Python 3.7+ removes trailing path separators (#402)\n        if path[-1] != os.sep:\n            return path + os.sep\n        return path\n\n    def set_filename(self, kwdict):\n        \"\"\"Set general filename data\"\"\"\n        self.kwdict = kwdict\n        self.filename = self.temppath = self.prefix = \"\"\n\n        ext = kwdict[\"extension\"]\n        kwdict[\"extension\"] = self.extension = self.extension_map(ext, ext)\n\n    def set_extension(self, extension, real=True):\n        \"\"\"Set filename extension\"\"\"\n        self.extension = extension = self.extension_map(extension, extension)\n        self.kwdict[\"extension\"] = self.prefix + extension\n\n    def fix_extension(self, _=None):\n        \"\"\"Fix filenames without a given filename extension\"\"\"\n        try:\n            if not self.extension:\n                self.kwdict[\"extension\"] = \\\n                    self.prefix + self.extension_map(\"\", \"\")\n                self.build_path()\n                if self.path[-1] == \".\":\n                    self.path = self.path[:-1]\n                    self.temppath = self.realpath = self.realpath[:-1]\n            elif not self.temppath:\n                self.build_path()\n        except exception.GalleryDLException:\n            raise\n        except Exception:\n            self.path = self.directory + \"?\"\n            self.realpath = self.temppath = self.realdirectory + \"?\"\n        return True\n\n    def build_filename(self, kwdict):\n        \"\"\"Apply 'kwdict' to filename format string\"\"\"\n        try:\n            if self.filename_conditions is None:\n                fmt = self.filename_formatter\n            else:\n                for condition, fmt in self.filename_conditions:\n                    if condition(kwdict):\n                        break\n                else:\n                    fmt = self.filename_formatter\n            return self.clean_path(self.clean_segment(fmt(kwdict)))\n        except Exception as exc:\n            raise exception.FilenameFormatError(exc)\n\n    def build_directory(self, kwdict, segments=None):\n        \"\"\"Apply 'kwdict' to directory format strings\"\"\"\n        try:\n            if segments is None:\n                if self.directory_conditions is None:\n                    formatters = self.directory_formatters\n                else:\n                    for condition, formatters in self.directory_conditions:\n                        if condition(kwdict):\n                            break\n                    else:\n                        formatters = self.directory_formatters\n            else:\n                formatters = [formatter.parse(fmt).format_map\n                              for fmt in segments]\n\n            segments = []\n            strip = self.strip\n            for fmt in formatters:\n                segment = fmt(kwdict)\n                if segment.__class__ is str:\n                    segment = segment.strip()\n                    if strip and segment not in {\".\", \"..\"}:\n                        segment = segment.rstrip(strip)\n                    if segment:\n                        segments.append(self.clean_segment(segment))\n                else:  # assume list\n                    for segment in segment:\n                        segment = segment.strip()\n                        if strip and segment not in {\".\", \"..\"}:\n                            segment = segment.rstrip(strip)\n                        if segment:\n                            segments.append(self.clean_segment(segment))\n            return segments\n        except Exception as exc:\n            raise exception.DirectoryFormatError(exc)\n\n    def build_path(self):\n        \"\"\"Combine directory and filename to full paths\"\"\"\n        self.filename = filename = self.build_filename(self.kwdict)\n        self.path = self.directory + filename\n        self.realpath = self.realdirectory + filename\n        if not self.temppath:\n            self.temppath = self.realpath\n\n    def generate_path(self, segments):\n        if not segments:\n            return \"\"\n\n        root = segments[0]\n        if root[0] == \":\":\n            if \":basedirectory\".startswith(root):\n                root = self.basedirectory\n            elif \":directory\".startswith(root):\n                root = self.realdirectory\n            elif root.startswith(\":~\"):\n                root = os.path.expanduser(root[1:])\n            elif root.startswith(\":$\"):\n                root = os.environ.get(root[2:])\n        elif WINDOWS:\n            s = root[:3].replace(\"/\", \"\\\\\")\n            if not s.startswith(\":\", 1) and not s.startswith(\"\\\\\\\\\"):\n                root = None\n        elif not root.startswith(\"/\"):\n            root = None\n\n        if root is None:\n            path = self.clean_path(os.sep.join(self.build_directory(\n                self.kwdict, segments)))\n        else:\n            if root[-1] != os.sep:\n                root += os.sep\n            path = root + self.clean_path(os.sep.join(self.build_directory(\n                self.kwdict, segments[1:])))\n\n        return path\n\n    def part_enable(self, part_directory=None):\n        \"\"\"Enable .part file usage\"\"\"\n        if self.extension:\n            self.temppath += \".part\"\n        else:\n            self.kwdict[\"extension\"] = self.prefix + self.extension_map(\n                \"part\", \"part\")\n            self.build_path()\n\n        if part_directory is not None:\n            if isinstance(part_directory, list):\n                for condition, part_directory in part_directory:\n                    if condition(self.kwdict):\n                        break\n                else:\n                    return\n\n            self.temppath = os.path.join(\n                part_directory,\n                os.path.basename(self.temppath),\n            )\n\n    def part_size(self):\n        \"\"\"Return size of .part file\"\"\"\n        try:\n            return os.stat(self.temppath).st_size\n        except OSError:\n            pass\n        return 0\n\n    def set_mtime(self, path=None):\n        if (mtime := (self.kwdict.get(\"_mtime_meta\") or\n                      self.kwdict.get(\"_mtime_http\"))):\n            util.set_mtime(self.realpath if path is None else path, mtime)\n\n    def finalize(self):\n        \"\"\"Move tempfile to its target location\"\"\"\n        if self.delete:\n            self.delete = False\n            os.unlink(self.temppath)\n            return\n\n        if self.temppath != self.realpath:\n            # Move temp file to its actual location\n            while True:\n                try:\n                    os.replace(self.temppath, self.realpath)\n                except FileNotFoundError:\n                    try:\n                        # delayed directory creation\n                        os.makedirs(self.realdirectory)\n                    except FileExistsError:\n                        # file at self.temppath does not exist\n                        return False\n                    continue\n                except OSError:\n                    # move across different filesystems\n                    try:\n                        shutil.copyfile(self.temppath, self.realpath)\n                    except FileNotFoundError:\n                        try:\n                            os.makedirs(self.realdirectory)\n                        except FileExistsError:\n                            return False\n                        shutil.copyfile(self.temppath, self.realpath)\n                    os.unlink(self.temppath)\n                break\n\n        self.set_mtime()\n\n\ndef _build_convertfunc(func, conv):\n    if len(conv) <= 1:\n        conv = formatter._CONVERSIONS[conv]\n        return lambda x: conv(func(x))\n\n    def convert_many(x):\n        x = func(x)\n        for conv in convs:\n            x = conv(x)\n        return x\n    convs = [formatter._CONVERSIONS[c] for c in conv]\n    return convert_many\n\n\ndef _build_cleanfunc(chars, repl, conv=None):\n    if not chars:\n        func = util.identity\n    elif isinstance(chars, dict):\n        if 0 not in chars:\n            chars = _process_repl_dict(chars)\n            chars[0] = None\n\n        def func(x):\n            return x.translate(table)\n        table = str.maketrans(chars)\n    elif len(chars) == 1:\n        def func(x):\n            return x.replace(chars, repl)\n    else:\n        func = functools.partial(util.re(f\"[{chars}]\").sub, repl)\n    return _build_convertfunc(func, conv) if conv else func\n\n\ndef _process_repl_dict(chars):\n    # can't modify 'chars' while *directly* iterating over its keys\n    for char in [c for c in chars if len(c) > 1]:\n        if len(char) == 3 and char[1] == \"-\":\n            citer = range(ord(char[0]), ord(char[2])+1)\n        else:\n            citer = char\n\n        repl = chars.pop(char)\n        for c in citer:\n            chars[c] = repl\n\n    return chars\n"
  },
  {
    "path": "gallery_dl/postprocessor/__init__.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2018-2026 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Post-processing modules\"\"\"\n\nmodules = {\n    \"actions\",\n    \"classify\",\n    \"compare\",\n    \"directory\",\n    \"exec\",\n    \"hash\",\n    \"metadata\",\n    \"mtime\",\n    \"python\",\n    \"rename\",\n    \"ugoira\",\n    \"zip\",\n}\n\n\ndef find(name):\n    \"\"\"Return a postprocessor class with the given name\"\"\"\n    try:\n        return _cache[name]\n    except KeyError:\n        pass\n\n    cls = None\n    if name in modules:  # prevent unwanted imports\n        try:\n            module = __import__(name, globals(), None, None, 1)\n        except ImportError:\n            pass\n        else:\n            cls = module.__postprocessor__\n    _cache[name] = cls\n    return cls\n\n\n# --------------------------------------------------------------------\n# internals\n\n_cache = {}\n"
  },
  {
    "path": "gallery_dl/postprocessor/actions.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2026 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Trigger actions\"\"\"\n\nfrom .common import PostProcessor\nfrom .. import actions\n\n\nclass ActionsPP(PostProcessor):\n\n    def __init__(self, job, options):\n        PostProcessor.__init__(self, job)\n        self.action = actions.parse(options.get(\"mode\") or\n                                    options.get(\"action\"))\n        self.args = {\n            \"job\"  : job,\n            \"log\"  : job.extractor.log,\n            \"level\": 0,\n        }\n        events = options.get(\"event\")\n        if events is None:\n            events = (\"prepare\",)\n        elif isinstance(events, str):\n            events = events.split(\",\")\n        job.register_hooks({event: self.run for event in events}, options)\n\n    def run(self, pathfmt):\n        self.action({**pathfmt.kwdict, **self.args})\n\n\n__postprocessor__ = ActionsPP\n"
  },
  {
    "path": "gallery_dl/postprocessor/classify.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2018-2024 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Categorize files by file extension\"\"\"\n\nfrom .common import PostProcessor\nimport os\n\n\nclass ClassifyPP(PostProcessor):\n\n    DEFAULT_MAPPING = {\n        \"Pictures\" : (\"jpg\", \"jpeg\", \"png\", \"gif\", \"bmp\", \"svg\", \"webp\",\n                      \"avif\", \"heic\", \"heif\", \"ico\", \"psd\"),\n        \"Video\"    : (\"flv\", \"ogv\", \"avi\", \"mp4\", \"mpg\", \"mpeg\", \"3gp\", \"mkv\",\n                      \"webm\", \"vob\", \"wmv\", \"m4v\", \"mov\"),\n        \"Music\"    : (\"mp3\", \"aac\", \"flac\", \"ogg\", \"wma\", \"m4a\", \"wav\"),\n        \"Archives\" : (\"zip\", \"rar\", \"7z\", \"tar\", \"gz\", \"bz2\"),\n        \"Documents\": (\"txt\", \"pdf\"),\n    }\n\n    def __init__(self, job, options):\n        PostProcessor.__init__(self, job)\n        self.directory = self.realdirectory = \"\"\n\n        mapping = options.get(\"mapping\", self.DEFAULT_MAPPING)\n        self.mapping = {\n            ext: directory\n            for directory, exts in mapping.items()\n            for ext in exts\n        }\n\n        job.register_hooks({\n            \"post\"   : self.initialize,\n            \"prepare\": self.prepare,\n        }, options)\n\n    def initialize(self, pathfmt):\n        # store base directory paths\n        self.directory = pathfmt.directory\n        self.realdirectory = pathfmt.realdirectory\n\n    def prepare(self, pathfmt):\n        # extend directory paths depending on file extension\n        ext = pathfmt.extension\n        if ext in self.mapping:\n            extra = self.mapping[ext] + os.sep\n            pathfmt.directory = self.directory + extra\n            pathfmt.realdirectory = self.realdirectory + extra\n\n\n__postprocessor__ = ClassifyPP\n"
  },
  {
    "path": "gallery_dl/postprocessor/common.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2018-2026 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Common classes and constants used by postprocessor modules.\"\"\"\n\nfrom .. import archive\n\n\nclass PostProcessor():\n    \"\"\"Base class for postprocessors\"\"\"\n\n    def __init__(self, job):\n        self.name = self.__class__.__name__[:-2].lower()\n        self.log = job.get_logger(\"postprocessor.\" + self.name)\n\n    def __repr__(self):\n        return self.__class__.__name__\n\n    def _archive_init(self, job, options, prefix=None):\n        if archive_path := options.get(\"archive\"):\n            extr = job.extractor\n\n            archive_table = options.get(\"archive-table\")\n            archive_prefix = options.get(\"archive-prefix\")\n            if archive_prefix is None:\n                archive_prefix = extr.category if archive_table is None else \"\"\n\n            archive_format = options.get(\"archive-format\")\n            if archive_format is None:\n                if prefix is None:\n                    prefix = \"_\" + self.name.upper() + \"_\"\n                archive_format = prefix + extr.archive_fmt\n\n            try:\n                self.archive = archive.connect(\n                    archive_path,\n                    archive_prefix,\n                    archive_format,\n                    archive_table,\n                    \"file\",\n                    options.get(\"archive-pragma\"),\n                    job.pathfmt,\n                    \"_archive_\" + self.name,\n                )\n            except Exception as exc:\n                self.log.warning(\n                    \"Failed to open %s archive at '%s' (%s: %s)\",\n                    self.name, archive_path, exc.__class__.__name__, exc)\n            else:\n                self.log.debug(\n                    \"Using %s archive '%s'\", self.name, archive_path)\n                return True\n\n        self.archive = None\n        return False\n\n    def _archive_register(self, job):\n        job.register_hooks({\"finalize\": self._archive_close})\n\n    def _archive_close(self, _):\n        self.archive.close()\n"
  },
  {
    "path": "gallery_dl/postprocessor/compare.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2020-2025 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Compare versions of the same file and replace/enumerate them on mismatch\"\"\"\n\nfrom .common import PostProcessor\nfrom .. import text, util, output, exception\nimport os\n\n\nclass ComparePP(PostProcessor):\n\n    def __init__(self, job, options):\n        PostProcessor.__init__(self, job)\n        if options.get(\"shallow\"):\n            self._compare = self._compare_size\n        self._equal_exc = self._equal_cnt = 0\n\n        if equal := options.get(\"equal\"):\n            equal, _, emax = equal.partition(\":\")\n            self._equal_max = text.parse_int(emax)\n            if equal == \"abort\":\n                self._equal_exc = exception.StopExtraction\n            elif equal == \"terminate\":\n                self._equal_exc = exception.TerminateExtraction\n            elif equal == \"exit\":\n                self._equal_exc = SystemExit\n\n        job.register_hooks({\"file\": (\n            self.enumerate\n            if options.get(\"action\") == \"enumerate\" else\n            self.replace\n        )}, options)\n\n    def replace(self, pathfmt):\n        try:\n            if self._compare(pathfmt.realpath, pathfmt.temppath):\n                return self._equal(pathfmt)\n        except OSError:\n            pass\n        self._equal_cnt = 0\n\n    def enumerate(self, pathfmt):\n        num = 1\n        try:\n            while not self._compare(pathfmt.realpath, pathfmt.temppath):\n                pathfmt.prefix = prefix = format(num) + \".\"\n                pathfmt.kwdict[\"extension\"] = prefix + pathfmt.extension\n                pathfmt.build_path()\n                num += 1\n            return self._equal(pathfmt)\n        except OSError:\n            pass\n        self._equal_cnt = 0\n\n    def _compare(self, f1, f2):\n        return self._compare_size(f1, f2) and self._compare_content(f1, f2)\n\n    def _compare_size(self, f1, f2):\n        return os.stat(f1).st_size == os.stat(f2).st_size\n\n    def _compare_content(self, f1, f2):\n        size = 16384\n        with open(f1, \"rb\") as fp1, open(f2, \"rb\") as fp2:\n            while True:\n                buf1 = fp1.read(size)\n                buf2 = fp2.read(size)\n                if buf1 != buf2:\n                    return False\n                if not buf1:\n                    return True\n\n    def _equal(self, pathfmt):\n        if self._equal_exc:\n            self._equal_cnt += 1\n            if self._equal_cnt >= self._equal_max:\n                util.remove_file(pathfmt.temppath)\n                output.stderr_write(\"\\n\")\n                raise self._equal_exc()\n        pathfmt.delete = True\n\n\n__postprocessor__ = ComparePP\n"
  },
  {
    "path": "gallery_dl/postprocessor/directory.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2025 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Trigger directory format string evaluation\"\"\"\n\nfrom .common import PostProcessor\n\n\nclass DirectoryPP(PostProcessor):\n\n    def __init__(self, job, options):\n        PostProcessor.__init__(self, job)\n\n        events = options.get(\"event\")\n        if events is None:\n            events = (\"prepare\",)\n        elif isinstance(events, str):\n            events = events.split(\",\")\n        job.register_hooks({event: self.run for event in events}, options)\n\n    def run(self, pathfmt):\n        pathfmt.set_directory(pathfmt.kwdict)\n\n\n__postprocessor__ = DirectoryPP\n"
  },
  {
    "path": "gallery_dl/postprocessor/exec.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2018-2026 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Run external processes\"\"\"\n\nfrom .common import PostProcessor\nfrom .. import util, formatter\nimport subprocess\nimport os\n\n\nif util.WINDOWS:\n    def quote(s):\n        s = s.replace('\"', '\\\\\"')\n        return f'\"{s}\"'\nelse:\n    from shlex import quote\n\n\ndef trim(args):\n    return (args.partition(\" \") if isinstance(args, str) else args)[0]\n\n\nclass ExecPP(PostProcessor):\n\n    def __init__(self, job, options):\n        PostProcessor.__init__(self, job)\n\n        if cmds := options.get(\"commands\"):\n            self.cmds = [self._prepare_cmd(c) for c in cmds]\n            execute = self.exec_many\n        else:\n            execute, self.args = self._prepare_cmd(options[\"command\"])\n            if options.get(\"async\", False):\n                self._exec = self._popen\n\n        self.output = options.get(\"output\", True)\n        self.verbose = options.get(\"verbose\", True)\n        self.session = False\n        self.creationflags = 0\n        if options.get(\"session\"):\n            if util.WINDOWS:\n                self.creationflags = subprocess.CREATE_NEW_PROCESS_GROUP\n            else:\n                self.session = True\n\n        s = options.get(\"success\")\n        e = options.get(\"error\")\n        if s or e:\n            from .. import actions\n            self.action_success = None if s is None else actions.parse(s)\n            self.action_error = None if e is None else actions.parse(e)\n            self.action_args = {\"job\": job, \"level\": 0}\n        else:\n            self.action_success = self.action_error = None\n\n        events = options.get(\"event\")\n        if events is None:\n            events = (\"after\",)\n        elif isinstance(events, str):\n            events = events.split(\",\")\n        job.register_hooks({event: execute for event in events}, options)\n\n        if self._archive_init(job, options):\n            self._archive_register(job)\n\n    def _prepare_cmd(self, cmd):\n        if isinstance(cmd, str):\n            self._sub = util.re(\n                r\"(?i)\\{(_directory(?:_unc)?|_filename\"\n                r\"|_(?:temp)?path(?:_unc)?|)\\}\").sub\n            return self.exec_string, cmd\n        else:\n            return self.exec_list, [formatter.parse(arg) for arg in cmd]\n\n    def exec_list(self, pathfmt):\n        archive = self.archive\n        kwdict = pathfmt.kwdict\n\n        if archive and archive.check(kwdict):\n            return\n\n        kwdict[\"_directory\"] = pathfmt.directory\n        kwdict[\"_filename\"] = pathfmt.filename\n        kwdict[\"_temppath\"] = pathfmt.temppath\n        kwdict[\"_path\"] = pathfmt.path\n        if util.WINDOWS:\n            kwdict[\"_directory_unc\"] = pathfmt.realdirectory\n            kwdict[\"_path_unc\"] = pathfmt.realpath\n\n        args = [arg.format_map(kwdict) for arg in self.args]\n        args[0] = os.path.expanduser(args[0])\n        retcode = self._exec(args, False)\n\n        if archive:\n            archive.add(kwdict)\n        return retcode\n\n    def exec_string(self, pathfmt):\n        archive = self.archive\n        if archive and archive.check(pathfmt.kwdict):\n            return\n\n        self.pathfmt = pathfmt\n        args = self._sub(self._replace, self.args)\n        retcode = self._exec(args, True)\n\n        if archive:\n            archive.add(pathfmt.kwdict)\n        return retcode\n\n    def exec_many(self, pathfmt):\n        if archive := self.archive:\n            if archive.check(pathfmt.kwdict):\n                return\n            self.archive = False\n\n        retcode = 0\n        for execute, args in self.cmds:\n            self.args = args\n            if retcode := execute(pathfmt):\n                # non-zero exit status\n                break\n\n        if archive:\n            self.archive = archive\n            archive.add(pathfmt.kwdict)\n        return retcode\n\n    def _exec(self, args, shell):\n        if retcode := self._popen(args, shell).wait():\n            self.log.warning(\"'%s' returned with non-zero exit status (%d)\",\n                             args if self.verbose else trim(args), retcode)\n            if self.action_error is not None:\n                self.action_error(self.action_args)\n        elif self.action_success is not None:\n            self.action_success(self.action_args)\n        return retcode\n\n    def _popen(self, args, shell):\n        self.log.debug(\"Running '%s'\", args if self.verbose else trim(args))\n        out = None if self.output else subprocess.DEVNULL\n        return util.Popen(\n            args,\n            shell=shell,\n            stdout=out, stderr=out,\n            creationflags=self.creationflags,\n            start_new_session=self.session,\n        )\n\n    def _replace(self, match):\n        attr = {\n            \"\"              : \"path\",\n            \"_path\"         : \"path\",\n            \"_path_unc\"     : \"realpath\",\n            \"_temppath\"     : \"temppath\",\n            \"_temppath_unc\" : \"temppath\",\n            \"_directory\"    : \"directory\",\n            \"_directory_unc\": \"realdirectory\",\n            \"_filename\"     : \"filename\",\n        }[match[1].lower()]\n        return quote(getattr(self.pathfmt, attr))\n\n\n__postprocessor__ = ExecPP\n"
  },
  {
    "path": "gallery_dl/postprocessor/hash.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2024-2026 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Compute file hash digests\"\"\"\n\nfrom .common import PostProcessor\nimport hashlib\n\n\nclass HashPP(PostProcessor):\n\n    def __init__(self, job, options):\n        PostProcessor.__init__(self, job)\n\n        self.chunk_size = options.get(\"chunk-size\", 32768)\n        self.filename = options.get(\"filename\")\n\n        hashes = options.get(\"mode\") or options.get(\"hashes\")\n        if isinstance(hashes, dict):\n            self.hashes = list(hashes.items())\n        elif isinstance(hashes, str):\n            self.hashes = []\n            for h in hashes.split(\",\"):\n                name, sep, key = h.partition(\":\")\n                self.hashes.append((key if sep else name, name))\n        elif hashes:\n            self.hashes = hashes\n        else:\n            self.hashes = ((\"md5\", \"md5\"), (\"sha1\", \"sha1\"))\n\n        events = options.get(\"event\")\n        if events is None:\n            events = (\"file\",)\n        elif isinstance(events, str):\n            events = events.split(\",\")\n        job.register_hooks({event: self.run for event in events}, options)\n\n    def run(self, pathfmt):\n        hashes = [\n            (key, hashlib.new(name))\n            for key, name in self.hashes\n        ]\n\n        size = self.chunk_size\n        with self._open(pathfmt) as fp:\n            while True:\n                data = fp.read(size)\n                if not data:\n                    break\n                for _, h in hashes:\n                    h.update(data)\n\n        for key, h in hashes:\n            pathfmt.kwdict[key] = h.hexdigest()\n\n        if self.filename:\n            pathfmt.build_path()\n\n    def _open(self, pathfmt):\n        try:\n            return open(pathfmt.temppath, \"rb\")\n        except OSError:\n            return open(pathfmt.realpath, \"rb\")\n\n\n__postprocessor__ = HashPP\n"
  },
  {
    "path": "gallery_dl/postprocessor/metadata.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2019-2026 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Write metadata to external files\"\"\"\n\nfrom .common import PostProcessor\nfrom .. import util, formatter\nimport json\nimport sys\nimport os\n\n\nclass MetadataPP(PostProcessor):\n\n    def __init__(self, job, options):\n        PostProcessor.__init__(self, job)\n\n        mode = options.get(\"mode\")\n        cfmt = options.get(\"content-format\") or options.get(\"format\")\n        omode = \"w\"\n        filename = None\n\n        if mode == \"tags\":\n            self.write = self._write_tags\n            ext = \"txt\"\n        elif mode == \"modify\":\n            self.run = self._run_modify\n            self.fields = {\n                name: formatter.parse(value, None, util.identity).format_map\n                for name, value in options.get(\"fields\").items()\n            }\n            ext = None\n        elif mode == \"delete\":\n            self.run = self._run_delete\n            self.fields = options.get(\"fields\")\n            ext = None\n        elif mode == \"custom\" or not mode and cfmt:\n            self.write = self._write_custom\n            if isinstance(cfmt, list):\n                cfmt = \"\\n\".join(cfmt) + \"\\n\"\n            self._content_fmt = formatter.parse(cfmt).format_map\n            ext = \"txt\"\n        elif mode == \"print\":\n            nl = \"\\n\"\n            if isinstance(cfmt, list):\n                cfmt = f\"{nl.join(cfmt)}{nl}\"\n            if cfmt[-1] != nl and (cfmt[0] != \"\\f\" or cfmt[1] == \"F\"):\n                cfmt = f\"{cfmt}{nl}\"\n            self.write = self._write_custom\n            self._content_fmt = formatter.parse(cfmt).format_map\n            filename = \"-\"\n        elif mode == \"jsonl\":\n            self.write = self._write_json\n            self._json_encode = self._make_encoder(options).encode\n            omode = \"a\"\n            filename = \"data.jsonl\"\n        else:\n            self.write = self._write_json\n            self._json_encode = self._make_encoder(options, 4).encode\n            ext = \"json\"\n\n        if base_directory := options.get(\"base-directory\"):\n            if base_directory is True:\n                self._base = lambda p: p.basedirectory\n            else:\n                sep = os.sep\n                altsep = os.altsep\n                base_directory = util.expand_path(base_directory)\n                if altsep and altsep in base_directory:\n                    base_directory = base_directory.replace(altsep, sep)\n                if base_directory[-1] != sep:\n                    base_directory += sep\n                self._base = lambda p: base_directory\n\n        directory = options.get(\"directory\")\n        if isinstance(directory, list):\n            self._directory = self._directory_format\n            self._directory_formatters = [\n                formatter.parse(dirfmt, util.NONE).format_map\n                for dirfmt in directory\n            ]\n        elif directory:\n            self._directory = self._directory_custom\n            sep = os.sep + (os.altsep or \"\")\n            self._metadir = util.expand_path(directory).rstrip(sep) + os.sep\n\n        filename = options.get(\"filename\", filename)\n        extfmt = options.get(\"extension-format\")\n        if filename:\n            if filename == \"-\":\n                self.run = self._run_stdout\n            else:\n                self._filename = self._filename_custom\n                self._filename_fmt = formatter.parse(filename).format_map\n        elif extfmt:\n            self._filename = self._filename_extfmt\n            self._extension_fmt = formatter.parse(extfmt).format_map\n        else:\n            self.extension = options.get(\"extension\", ext)\n\n        events = options.get(\"event\")\n        if events is None:\n            events = (\"file\",)\n        elif isinstance(events, str):\n            events = events.split(\",\")\n        job.register_hooks({event: self.run for event in events}, options)\n\n        if self._archive_init(job, options, \"_MD_\"):\n            self._archive_register(job)\n\n        self.filter = self._make_filter(options)\n        self.mtime = options.get(\"mtime\")\n        self.omode = options.get(\"open\", omode)\n        self.encoding = options.get(\"encoding\", \"utf-8\")\n        self.newline = options.get(\"newline\")\n        self.skip = options.get(\"skip\", False)\n        self.meta_path = options.get(\"metadata-path\")\n\n    def open(self, path):\n        return open(path, self.omode,\n                    encoding=self.encoding,\n                    newline=self.newline)\n\n    def run(self, pathfmt):\n        archive = self.archive\n        if archive and archive.check(pathfmt.kwdict):\n            return\n\n        if util.WINDOWS and pathfmt.extended:\n            directory = pathfmt._extended_path(self._directory(pathfmt))\n        else:\n            directory = self._directory(pathfmt)\n        path = directory + self._filename(pathfmt)\n\n        if self.meta_path is not None:\n            pathfmt.kwdict[self.meta_path] = path\n\n        if self.skip and os.path.exists(path):\n            return\n\n        try:\n            with self.open(path) as fp:\n                self.write(fp, pathfmt.kwdict)\n        except FileNotFoundError:\n            os.makedirs(directory, exist_ok=True)\n            with self.open(path) as fp:\n                self.write(fp, pathfmt.kwdict)\n\n        if archive:\n            archive.add(pathfmt.kwdict)\n\n        if self.mtime:\n            pathfmt.set_mtime(path)\n\n    def _run_stdout(self, pathfmt):\n        self.write(sys.stdout, pathfmt.kwdict)\n\n    def _run_modify(self, pathfmt):\n        kwdict = pathfmt.kwdict\n        for key, func in self.fields.items():\n            obj = kwdict\n            try:\n                if \"[\" in key:\n                    obj, key = _traverse(obj, key)\n                obj[key] = func(kwdict)\n            except Exception:\n                pass\n\n    def _run_delete(self, pathfmt):\n        kwdict = pathfmt.kwdict\n        for key in self.fields:\n            obj = kwdict\n            try:\n                if \"[\" in key:\n                    obj, key = _traverse(obj, key)\n                del obj[key]\n            except Exception:\n                pass\n\n    def _base(self, pathfmt):\n        return pathfmt.realdirectory\n\n    def _directory(self, pathfmt):\n        return self._base(pathfmt)\n\n    def _directory_custom(self, pathfmt):\n        return os.path.join(self._base(pathfmt), self._metadir)\n\n    def _directory_format(self, pathfmt):\n        formatters = pathfmt.directory_formatters\n        conditions = pathfmt.directory_conditions\n        try:\n            pathfmt.directory_formatters = self._directory_formatters\n            pathfmt.directory_conditions = ()\n            if segments := pathfmt.build_directory(pathfmt.kwdict):\n                directory = pathfmt.clean_path(os.sep.join(segments) + os.sep)\n            else:\n                directory = \".\" + os.sep\n            return os.path.join(self._base(pathfmt), directory)\n        finally:\n            pathfmt.directory_conditions = conditions\n            pathfmt.directory_formatters = formatters\n\n    def _filename(self, pathfmt):\n        return (pathfmt.filename or \"metadata\") + \".\" + self.extension\n\n    def _filename_custom(self, pathfmt):\n        return pathfmt.clean_path(pathfmt.clean_segment(\n            self._filename_fmt(pathfmt.kwdict)))\n\n    def _filename_extfmt(self, pathfmt):\n        kwdict = pathfmt.kwdict\n        ext = kwdict.get(\"extension\")\n        kwdict[\"extension\"] = pathfmt.extension\n        kwdict[\"extension\"] = pathfmt.prefix + self._extension_fmt(kwdict)\n        filename = pathfmt.build_filename(kwdict)\n        kwdict[\"extension\"] = ext\n        return filename\n\n    def _write_custom(self, fp, kwdict):\n        fp.write(self._content_fmt(kwdict))\n\n    def _write_tags(self, fp, kwdict):\n        if not (tags := kwdict.get(\"tags\") or kwdict.get(\"tag_string\")):\n            return\n\n        if isinstance(tags, str):\n            taglist = tags.split(\", \")\n            if len(taglist) < len(tags) / 16:\n                taglist = tags.split(\" \")\n            tags = taglist\n        elif isinstance(tags[0], dict):\n            # pixiv \"tags\": \"original\"\n            tags = [\n                tag\n                for tagdict in tags\n                for tag in tagdict.values()\n                if isinstance(tag, str)\n            ]\n            tags.sort()\n\n        fp.write(\"\\n\".join(tags) + \"\\n\")\n\n    def _write_json(self, fp, kwdict):\n        if self.filter:\n            kwdict = self.filter(kwdict)\n        fp.write(self._json_encode(kwdict) + \"\\n\")\n\n    def _make_filter(self, options):\n        if include := options.get(\"include\"):\n            if isinstance(include, str):\n                include = include.split(\",\")\n            return lambda d: {k: d[k] for k in include if k in d}\n\n        private = options.get(\"private\")\n        if exclude := options.get(\"exclude\"):\n            if isinstance(exclude, str):\n                exclude = exclude.split(\",\")\n            exclude = set(exclude)\n\n            if private:\n                return lambda d: {k: v for k, v in d.items()\n                                  if k not in exclude}\n            return lambda d: {k: v for k, v in util.filter_dict(d).items()\n                              if k not in exclude}\n\n        if not private:\n            return util.filter_dict\n\n    def _make_encoder(self, options, indent=None):\n        return json.JSONEncoder(\n            ensure_ascii=options.get(\"ascii\", False),\n            sort_keys=options.get(\"sort\", False),\n            separators=options.get(\"separators\"),\n            indent=options.get(\"indent\", indent),\n            check_circular=False,\n            default=util.json_default,\n        )\n\n\ndef _traverse(obj, key):\n    name, _, key = key.partition(\"[\")\n    obj = obj[name]\n\n    while \"[\" in key:\n        name, _, key = key.partition(\"[\")\n        obj = obj[name.strip(\"\\\"']\")]\n\n    return obj, key.strip(\"\\\"']\")\n\n\n__postprocessor__ = MetadataPP\n"
  },
  {
    "path": "gallery_dl/postprocessor/mtime.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2019-2026 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Use metadata as file modification time\"\"\"\n\nfrom .common import PostProcessor\nfrom .. import text, util, dt, formatter\n\n\nclass MtimePP(PostProcessor):\n\n    def __init__(self, job, options):\n        PostProcessor.__init__(self, job)\n        if value := options.get(\"value\"):\n            self._get = formatter.parse(value, None, util.identity).format_map\n        else:\n            key = options.get(\"mode\") or options.get(\"key\", \"date\")\n            self._get = lambda kwdict: kwdict.get(key)\n\n        events = options.get(\"event\")\n        if events is None:\n            events = (\"file\",)\n        elif isinstance(events, str):\n            events = events.split(\",\")\n        job.register_hooks({event: self.run for event in events}, options)\n\n    def run(self, pathfmt):\n        if mtime := self._get(pathfmt.kwdict):\n            if isinstance(mtime, dt.datetime):\n                mtime = dt.to_ts(mtime)\n            else:\n                mtime = text.parse_int(mtime)\n        else:\n            mtime = None\n        pathfmt.kwdict[\"_mtime_meta\"] = mtime\n\n\n__postprocessor__ = MtimePP\n"
  },
  {
    "path": "gallery_dl/postprocessor/python.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2023-2025 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Run Python functions\"\"\"\n\nfrom .common import PostProcessor\nfrom .. import util\n\n\nclass PythonPP(PostProcessor):\n\n    def __init__(self, job, options):\n        PostProcessor.__init__(self, job)\n\n        mode = options.get(\"mode\")\n        if mode == \"eval\" or not mode and options.get(\"expression\"):\n            self.function = util.compile_expression(options[\"expression\"])\n        else:\n            spec = options[\"function\"]\n            module_name, _, function_name = spec.rpartition(\":\")\n            module = util.import_file(module_name)\n            self.function = getattr(module, function_name)\n\n        if archive := self._archive_init(job, options):\n            self.run = self.run_archive\n\n        events = options.get(\"event\")\n        if events is None:\n            events = (\"file\",)\n        elif isinstance(events, str):\n            events = events.split(\",\")\n        job.register_hooks({event: self.run for event in events}, options)\n\n        if archive:\n            self._archive_register(job)\n\n    def run(self, pathfmt):\n        self.function(pathfmt.kwdict)\n\n    def run_archive(self, pathfmt):\n        kwdict = pathfmt.kwdict\n        if self.archive.check(kwdict):\n            return\n        self.function(kwdict)\n        self.archive.add(kwdict)\n\n\n__postprocessor__ = PythonPP\n"
  },
  {
    "path": "gallery_dl/postprocessor/rename.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2024 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Rename files\"\"\"\n\nfrom .common import PostProcessor\nfrom .. import formatter\nimport os\n\n\nclass RenamePP(PostProcessor):\n\n    def __init__(self, job, options):\n        PostProcessor.__init__(self, job)\n\n        self.skip = options.get(\"skip\", True)\n        old = options.get(\"from\")\n        new = options.get(\"to\")\n\n        if old:\n            self._old = self._apply_format(old)\n            self._new = (self._apply_format(new) if new else\n                         self._apply_pathfmt)\n            job.register_hooks({\n                \"prepare\": self.rename_from,\n            }, options)\n\n        elif new:\n            self._old = self._apply_pathfmt\n            self._new = self._apply_format(new)\n            job.register_hooks({\n                \"skip\"         : self.rename_to_skip,\n                \"prepare-after\": self.rename_to_pafter,\n            }, options)\n\n        else:\n            raise ValueError(\"Option 'from' or 'to' is required\")\n\n    def rename_from(self, pathfmt):\n        name_old = self._old(pathfmt)\n        path_old = pathfmt.realdirectory + name_old\n\n        if os.path.exists(path_old):\n            name_new = self._new(pathfmt)\n            path_new = pathfmt.realdirectory + name_new\n            self._rename(path_old, name_old, path_new, name_new)\n\n    def rename_to_skip(self, pathfmt):\n        name_old = self._old(pathfmt)\n        path_old = pathfmt.realdirectory + name_old\n\n        if os.path.exists(path_old):\n            pathfmt.filename = name_new = self._new(pathfmt)\n            pathfmt.path = pathfmt.directory + name_new\n            pathfmt.realpath = path_new = pathfmt.realdirectory + name_new\n            self._rename(path_old, name_old, path_new, name_new)\n\n    def rename_to_pafter(self, pathfmt):\n        pathfmt.filename = name_new = self._new(pathfmt)\n        pathfmt.path = pathfmt.directory + name_new\n        pathfmt.realpath = pathfmt.realdirectory + name_new\n        pathfmt.kwdict[\"_file_recheck\"] = True\n\n    def _rename(self, path_old, name_old, path_new, name_new):\n        if self.skip and os.path.exists(path_new):\n            return self.log.warning(\n                \"Not renaming '%s' to '%s' since another file with the \"\n                \"same name exists\", name_old, name_new)\n\n        self.log.info(\"'%s' -> '%s'\", name_old, name_new)\n        os.replace(path_old, path_new)\n\n    def _apply_pathfmt(self, pathfmt):\n        return pathfmt.build_filename(pathfmt.kwdict)\n\n    def _apply_format(self, format_string):\n        fmt = formatter.parse(format_string).format_map\n\n        def apply(pathfmt):\n            return pathfmt.clean_path(pathfmt.clean_segment(fmt(\n                pathfmt.kwdict)))\n\n        return apply\n\n\n__postprocessor__ = RenamePP\n"
  },
  {
    "path": "gallery_dl/postprocessor/ugoira.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2018-2026 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Convert Pixiv Ugoira to WebM\"\"\"\n\nfrom .common import PostProcessor\nfrom .. import util, output\nimport subprocess\nimport tempfile\nimport zipfile\nimport shutil\nimport os\n\ntry:\n    from math import gcd\nexcept ImportError:\n    def gcd(a, b):\n        while b:\n            a, b = b, a % b\n        return a\n\n\nclass UgoiraPP(PostProcessor):\n\n    def __init__(self, job, options):\n        PostProcessor.__init__(self, job)\n        self.args = options.get(\"ffmpeg-args\") or ()\n        self.twopass = options.get(\"ffmpeg-twopass\", False)\n        self.output = options.get(\"ffmpeg-output\", \"error\")\n        self.delete = not options.get(\"keep-files\", False)\n        self.repeat = options.get(\"repeat-last-frame\", True)\n        self.metadata = options.get(\"metadata\", True)\n        self.mtime = options.get(\"mtime\", True)\n        self.mkvm_args = options.get(\"mkvmerge-args\") or ()\n        self.mkvm_output = options.get(\"mkvmerge-output\", False)\n        self.mkvm_metadata = options.get(\"mkvmerge-metadata\", True)\n        self.mkvm_mtime = options.get(\"mkvmerge-mtime\", True)\n        self.skip = options.get(\"skip\", True)\n        self.uniform = self._convert_zip = self._convert_files = False\n\n        ffmpeg = options.get(\"ffmpeg-location\")\n        self.ffmpeg = util.expand_path(ffmpeg) if ffmpeg else \"ffmpeg\"\n\n        mkvmerge = options.get(\"mkvmerge-location\")\n        self.mkvmerge = util.expand_path(mkvmerge) if mkvmerge else \"mkvmerge\"\n\n        ext = options.get(\"extension\")\n        mode = options.get(\"mode\") or options.get(\"ffmpeg-demuxer\")\n        if mode is None or mode == \"auto\":\n            if ext in (None, \"webm\", \"mkv\") and (\n                    mkvmerge or shutil.which(\"mkvmerge\")):\n                mode = \"mkvmerge\"\n            else:\n                mode = \"concat\"\n\n        if mode == \"mkvmerge\":\n            self._process = self._process_mkvmerge\n            self._finalize = self._finalize_mkvmerge\n        elif mode == \"image2\":\n            self._process = self._process_image2\n            self._finalize = None\n        elif mode == \"archive\":\n            if ext is None:\n                ext = \"zip\"\n            self._convert_impl = self.convert_to_archive\n            self._tempdir = util.NullContext\n        else:\n            self._process = self._process_concat\n            self._finalize = None\n        self.extension = \"webm\" if ext is None else ext\n        self.log.debug(\"using %s demuxer\", mode)\n\n        rate = options.get(\"framerate\", \"auto\")\n        if rate == \"uniform\":\n            self.uniform = True\n        elif rate != \"auto\":\n            self.calculate_framerate = lambda _: (None, rate)\n\n        if options.get(\"libx264-prevent-odd\", True):\n            # get last video-codec argument\n            vcodec = None\n            for index, arg in enumerate(self.args):\n                arg, _, stream = arg.partition(\":\")\n                if arg == \"-vcodec\" or arg in (\"-c\", \"-codec\") and (\n                        not stream or stream.partition(\":\")[0] in (\"v\", \"V\")):\n                    vcodec = self.args[index + 1]\n            # use filter when using libx264/5\n            self.prevent_odd = (\n                vcodec in (\"libx264\", \"libx265\") or\n                not vcodec and self.extension.lower() in (\"mp4\", \"mkv\"))\n        else:\n            self.prevent_odd = False\n\n        self.args_pp = args = []\n        if isinstance(self.output, str):\n            args += (\"-hide_banner\", \"-loglevel\", self.output)\n        if self.prevent_odd:\n            args += (\"-vf\", \"crop=iw-mod(iw\\\\,2):ih-mod(ih\\\\,2)\")\n\n        job.register_hooks({\n            \"prepare\": self.prepare,\n            \"file\"   : self.convert_from_zip,\n            \"after\"  : self.convert_from_files,\n        }, options)\n\n    def prepare(self, pathfmt):\n        self._convert_zip = self._convert_files = False\n        if \"_ugoira_frame_data\" not in pathfmt.kwdict:\n            self._frames = None\n            return\n\n        self._frames = pathfmt.kwdict[\"_ugoira_frame_data\"]\n        index = pathfmt.kwdict.get(\"_ugoira_frame_index\")\n        if index is None:\n            self._convert_zip = True\n            if self.delete:\n                pathfmt.set_extension(self.extension)\n                pathfmt.build_path()\n        else:\n            pathfmt.build_path()\n            frame = self._frames[index].copy()\n            frame[\"index\"] = index\n            frame[\"path\"] = pathfmt.realpath\n            frame[\"ext\"] = pathfmt.extension\n\n            if not index:\n                self._files = [frame]\n            else:\n                self._files.append(frame)\n                if len(self._files) >= len(self._frames):\n                    self._convert_files = True\n\n    def convert_from_zip(self, pathfmt):\n        if not self._convert_zip:\n            return\n        self._zip_source = True\n        self._zip_ext = ext = pathfmt.extension\n\n        with self._tempdir() as tempdir:\n            if tempdir:\n                try:\n                    with zipfile.ZipFile(pathfmt.temppath) as zfile:\n                        zfile.extractall(tempdir)\n                except FileNotFoundError:\n                    pathfmt.realpath = pathfmt.temppath\n                    return\n                except Exception as exc:\n                    pathfmt.realpath = pathfmt.temppath\n                    self.log.error(\n                        \"%s: Unable to extract frames from %s (%s: %s)\",\n                        pathfmt.kwdict.get(\"id\"), pathfmt.filename,\n                        exc.__class__.__name__, exc)\n                    return self.log.traceback(exc)\n\n            if self.convert(pathfmt, tempdir):\n                if self.delete:\n                    pathfmt.delete = True\n                elif pathfmt.extension != ext:\n                    self.log.info(pathfmt.filename)\n                    pathfmt.set_extension(ext)\n                    pathfmt.build_path()\n\n    def convert_from_files(self, pathfmt):\n        if not self._convert_files:\n            return\n        self._zip_source = False\n\n        with tempfile.TemporaryDirectory() as tempdir:\n            for frame in self._files:\n\n                # update frame filename extension\n                frame[\"file\"] = name = \\\n                    f\"{frame['file'].partition('.')[0]}.{frame['ext']}\"\n\n                if tempdir:\n                    # move frame into tempdir\n                    try:\n                        self._copy_file(frame[\"path\"], tempdir + \"/\" + name)\n                    except OSError as exc:\n                        self.log.debug(\"Unable to copy frame %s (%s: %s)\",\n                                       name, exc.__class__.__name__, exc)\n                        return\n\n            pathfmt.kwdict[\"num\"] = 0\n            self._frames = self._files\n            if self.convert(pathfmt, tempdir):\n                self.log.info(pathfmt.filename)\n                if self.delete:\n                    self.log.debug(\"Deleting frames\")\n                    for frame in self._files:\n                        util.remove_file(frame[\"path\"])\n\n    def convert(self, pathfmt, tempdir):\n        pathfmt.set_extension(self.extension)\n        pathfmt.build_path()\n        if self.skip and pathfmt.exists():\n            return True\n\n        return self._convert_impl(pathfmt, tempdir)\n\n    def convert_to_animation(self, pathfmt, tempdir):\n        # process frames and collect command-line arguments\n        args = self._process(pathfmt, tempdir)\n        if self.args_pp:\n            args += self.args_pp\n        if self.args:\n            args += self.args\n\n        # ensure target directory exists\n        os.makedirs(pathfmt.realdirectory, exist_ok=True)\n\n        # invoke ffmpeg\n        try:\n            if self.twopass:\n                if \"-f\" not in self.args:\n                    args += (\"-f\", self.extension)\n                args += (\"-passlogfile\", tempdir + \"/ffmpeg2pass\", \"-pass\")\n                self._exec(args + [\"1\", \"-y\", os.devnull])\n                self._exec(args + [\"2\", pathfmt.realpath])\n            else:\n                args.append(pathfmt.realpath)\n                self._exec(args)\n            if self._finalize:\n                self._finalize(pathfmt, tempdir)\n        except OSError as exc:\n            output.stderr_write(\"\\n\")\n            self.log.error(\"Unable to invoke FFmpeg (%s: %s)\",\n                           exc.__class__.__name__, exc)\n            self.log.traceback(exc)\n            pathfmt.realpath = pathfmt.temppath\n        except Exception as exc:\n            output.stderr_write(\"\\n\")\n            self.log.error(\"%s: %s\", exc.__class__.__name__, exc)\n            self.log.traceback(exc)\n            pathfmt.realpath = pathfmt.temppath\n        else:\n            if self.mtime:\n                pathfmt.set_mtime()\n            return True\n\n    def convert_to_archive(self, pathfmt, tempdir):\n        frames = self._frames\n\n        if self.metadata:\n            if isinstance(self.metadata, str):\n                metaname = self.metadata\n            else:\n                metaname = \"animation.json\"\n            framedata = util.json_dumps([\n                {\"file\": frame[\"file\"], \"delay\": frame[\"delay\"]}\n                for frame in frames\n            ]).encode()\n\n        if self._zip_source:\n            zpath = pathfmt.temppath\n            if self.delete:\n                self.delete = False\n            elif self._zip_ext != self.extension:\n                self._copy_file(zpath, pathfmt.realpath)\n                zpath = pathfmt.realpath\n\n            if self.metadata:\n                with zipfile.ZipFile(zpath, \"a\") as zfile:\n                    zinfo = zipfile.ZipInfo(metaname)\n                    if self.mtime:\n                        zinfo.date_time = zfile.infolist()[0].date_time\n                    with zfile.open(zinfo, \"w\") as fp:\n                        fp.write(framedata)\n        else:\n            if self.mtime:\n                dt = pathfmt.kwdict[\"date_url\"] or pathfmt.kwdict[\"date\"]\n                mtime = (dt.year, dt.month, dt.day,\n                         dt.hour, dt.minute, dt.second)\n            with zipfile.ZipFile(pathfmt.realpath, \"w\") as zfile:\n                for frame in frames:\n                    zinfo = zipfile.ZipInfo.from_file(\n                        frame[\"path\"], frame[\"file\"])\n                    if self.mtime:\n                        zinfo.date_time = mtime\n                    with open(frame[\"path\"], \"rb\") as src, \\\n                            zfile.open(zinfo, \"w\") as dst:\n                        shutil.copyfileobj(src, dst, 1024*8)\n                if self.metadata:\n                    zinfo = zipfile.ZipInfo(metaname)\n                    if self.mtime:\n                        zinfo.date_time = mtime\n                    with zfile.open(zinfo, \"w\") as fp:\n                        fp.write(framedata)\n\n        return True\n\n    _convert_impl = convert_to_animation\n    _tempdir = tempfile.TemporaryDirectory\n\n    def _exec(self, args):\n        self.log.debug(args)\n        out = None if self.output else subprocess.DEVNULL\n        if retcode := util.Popen(args, stdout=out, stderr=out).wait():\n            output.stderr_write(\"\\n\")\n            self.log.error(\"Non-zero exit status when running %s (%s)\",\n                           args, retcode)\n            raise ValueError()\n        return retcode\n\n    def _copy_file(self, src, dst):\n        shutil.copyfile(src, dst)\n\n    def _process_concat(self, pathfmt, tempdir):\n        rate_in, rate_out = self.calculate_framerate(self._frames)\n        args = [self.ffmpeg, \"-f\", \"concat\"]\n        if rate_in:\n            args += (\"-r\", str(rate_in))\n        args += (\"-i\", self._write_ffmpeg_concat(tempdir))\n        if rate_out:\n            args += (\"-r\", str(rate_out))\n        return args\n\n    def _process_image2(self, pathfmt, tempdir):\n        tempdir += \"/\"\n        frames = self._frames\n\n        # add extra frame if necessary\n        if self.repeat and not self._delay_is_uniform(frames):\n            last = frames[-1]\n            delay_gcd = self._delay_gcd(frames)\n            if last[\"delay\"] - delay_gcd > 0:\n                last[\"delay\"] -= delay_gcd\n\n                self.log.debug(\"non-uniform delays; inserting extra frame\")\n                last_copy = last.copy()\n                frames.append(last_copy)\n                name, _, ext = last_copy[\"file\"].rpartition(\".\")\n                last_copy[\"file\"] = f\"{int(name) + 1:>06}.{ext}\"\n                shutil.copyfile(tempdir + last[\"file\"],\n                                tempdir + last_copy[\"file\"])\n\n        # adjust frame mtime values\n        ts = 0\n        for frame in frames:\n            os.utime(tempdir + frame[\"file\"], ns=(ts, ts))\n            ts += frame[\"delay\"] * 1000000\n\n        return [\n            self.ffmpeg,\n            \"-f\", \"image2\",\n            \"-ts_from_file\", \"2\",\n            \"-pattern_type\", \"sequence\",\n            \"-i\", (f\"{tempdir.replace('%', '%%')}%06d.\"\n                   f\"{frame['file'].rpartition('.')[2]}\"),\n        ]\n\n    def _process_mkvmerge(self, pathfmt, tempdir):\n        self._realpath = pathfmt.realpath\n        pathfmt.realpath = f\"{tempdir}/temp.{self.extension}\"\n\n        return [\n            self.ffmpeg,\n            \"-r\", \"25\",\n            \"-f\", \"concat\",\n            \"-i\", self._write_ffmpeg_concat(tempdir, False),\n        ]\n\n    def _finalize_mkvmerge(self, pathfmt, tempdir):\n        args = [\n            self.mkvmerge,\n            \"-o\", pathfmt.path,  # mkvmerge does not support \"raw\" paths\n            \"--timecodes\", \"0:\" + self._write_mkvmerge_timecodes(tempdir),\n        ]\n        if self.mkvm_mtime and (dt := pathfmt.kwdict.get(\"date_url\") or\n                                pathfmt.kwdict.get(\"date\")):\n            args += (\"--date\", str(dt))\n        if not self.mkvm_metadata:\n            args.append(\"--disable-track-statistics-tags\")\n        if not self.mkvm_output:\n            args.append(\"--quiet\")\n        if self.extension == \"webm\":\n            args.append(\"--webm\")\n        if self.mkvm_args:\n            args += self.mkvm_args\n        args += (\"=\", pathfmt.realpath)\n\n        pathfmt.realpath = self._realpath\n        self._exec(args)\n\n    def _write_ffmpeg_concat(self, tempdir, duration=True):\n        content = [\"ffconcat version 1.0\"]\n\n        if duration:\n            for frame in self._frames:\n                content.append(f\"file '{frame['file']}'\\n\"\n                               f\"duration {frame['delay'] / 1000}\")\n        else:\n            for frame in self._frames:\n                content.append(f\"file '{frame['file']}'\")\n        if self.repeat:\n            content.append(f\"file '{frame['file']}'\")\n        content.append(\"\")\n\n        ffconcat = tempdir + \"/ffconcat.txt\"\n        with open(ffconcat, \"w\", encoding=\"utf-8\") as fp:\n            fp.write(\"\\n\".join(content))\n        return ffconcat\n\n    def _write_mkvmerge_timecodes(self, tempdir):\n        content = [\"# timecode format v2\"]\n\n        delay_sum = 0\n        for frame in self._frames:\n            content.append(str(delay_sum))\n            delay_sum += frame[\"delay\"]\n        content.append(str(delay_sum))\n        content.append(\"\")\n\n        timecodes = tempdir + \"/timecodes.tc\"\n        with open(timecodes, \"w\", encoding=\"utf-8\") as fp:\n            fp.write(\"\\n\".join(content))\n        return timecodes\n\n    def calculate_framerate(self, frames):\n        if self._delay_is_uniform(frames):\n            return (f\"1000/{frames[0]['delay']}\", None)\n\n        if not self.uniform:\n            gcd = self._delay_gcd(frames)\n            if gcd >= 10:\n                return (None, f\"1000/{gcd}\")\n\n        return (None, None)\n\n    def _delay_gcd(self, frames):\n        result = frames[0][\"delay\"]\n        for f in frames:\n            result = gcd(result, f[\"delay\"])\n        return result\n\n    def _delay_is_uniform(self, frames):\n        delay = frames[0][\"delay\"]\n        for f in frames:\n            if f[\"delay\"] != delay:\n                return False\n        return True\n\n\n__postprocessor__ = UgoiraPP\n"
  },
  {
    "path": "gallery_dl/postprocessor/zip.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2018-2022 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Store files in ZIP archives\"\"\"\n\nfrom .common import PostProcessor\nfrom .. import util\nimport zipfile\nimport os\n\n\nclass ZipPP(PostProcessor):\n\n    COMPRESSION_ALGORITHMS = {\n        \"store\": zipfile.ZIP_STORED,\n        \"zip\"  : zipfile.ZIP_DEFLATED,\n        \"bzip2\": zipfile.ZIP_BZIP2,\n        \"lzma\" : zipfile.ZIP_LZMA,\n    }\n\n    def __init__(self, job, options):\n        PostProcessor.__init__(self, job)\n        self.delete = not options.get(\"keep-files\", False)\n        self.files = options.get(\"files\")\n        ext = \".\" + options.get(\"extension\", \"zip\")\n        algorithm = options.get(\"compression\", \"store\")\n        if algorithm not in self.COMPRESSION_ALGORITHMS:\n            self.log.warning(\n                \"unknown compression algorithm '%s'; falling back to 'store'\",\n                algorithm)\n            algorithm = \"store\"\n\n        self.zfile = None\n        self.path = job.pathfmt.realdirectory[:-1]\n        self.args = (self.path + ext, \"a\",\n                     self.COMPRESSION_ALGORITHMS[algorithm], True)\n\n        job.register_hooks({\n            \"file\": (self.write_safe if options.get(\"mode\") == \"safe\" else\n                     self.write_fast),\n        }, options)\n        job.hooks[\"finalize\"].append(self.finalize)\n\n    def open(self):\n        try:\n            return zipfile.ZipFile(*self.args)\n        except FileNotFoundError:\n            os.makedirs(os.path.dirname(self.path))\n            return zipfile.ZipFile(*self.args)\n\n    def write(self, pathfmt, zfile):\n        # 'NameToInfo' is not officially documented, but it's available\n        # for all supported Python versions and using it directly is a lot\n        # faster than calling getinfo()\n        if self.files:\n            self.write_extra(pathfmt, zfile, self.files)\n            self.files = None\n        if pathfmt.filename not in zfile.NameToInfo:\n            zfile.write(pathfmt.temppath, pathfmt.filename)\n            pathfmt.delete = self.delete\n\n    def write_fast(self, pathfmt):\n        if self.zfile is None:\n            self.zfile = self.open()\n        self.write(pathfmt, self.zfile)\n\n    def write_safe(self, pathfmt):\n        with self.open() as zfile:\n            self.write(pathfmt, zfile)\n\n    def write_extra(self, pathfmt, zfile, files):\n        for path in map(util.expand_path, files):\n            if not os.path.isabs(path):\n                path = os.path.join(pathfmt.realdirectory, path)\n            try:\n                zfile.write(path, os.path.basename(path))\n            except OSError as exc:\n                self.log.warning(\n                    \"Unable to write %s to %s\", path, zfile.filename)\n                self.log.debug(\"%s: %s\", exc, exc.__class__.__name__)\n                pass\n            else:\n                if self.delete:\n                    util.remove_file(path)\n\n    def finalize(self, pathfmt):\n        if self.zfile:\n            self.zfile.close()\n\n        if self.delete:\n            util.remove_directory(self.path)\n\n            if self.zfile and not self.zfile.NameToInfo:\n                # remove empty zip archive\n                util.remove_file(self.zfile.filename)\n\n\n__postprocessor__ = ZipPP\n"
  },
  {
    "path": "gallery_dl/text.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2015-2026 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Collection of functions that work on strings/text\"\"\"\n\nimport html\nimport urllib.parse\nimport re as re_module\n\ntry:\n    re_compile = re_module._compiler.compile\nexcept AttributeError:\n    re_compile = re_module.sre_compile.compile\n\nHTML_RE = re_compile(r\"<[^>]+>\")\nPATTERN_CACHE = {}\n\n\ndef re(pattern):\n    \"\"\"Compile a regular expression pattern\"\"\"\n    try:\n        return PATTERN_CACHE[pattern]\n    except KeyError:\n        p = PATTERN_CACHE[pattern] = re_compile(pattern)\n        return p\n\n\ndef remove_html(txt, repl=\" \", sep=\" \"):\n    \"\"\"Remove html-tags from a string\"\"\"\n    try:\n        txt = HTML_RE.sub(repl, txt)\n    except TypeError:\n        return \"\"\n    if sep:\n        return sep.join(txt.split())\n    return txt.strip()\n\n\ndef split_html(txt):\n    \"\"\"Split input string by HTML tags\"\"\"\n    try:\n        return [\n            unescape(x).strip()\n            for x in HTML_RE.split(txt)\n            if x and not x.isspace()\n        ]\n    except TypeError:\n        return []\n\n\ndef slugify(value):\n    \"\"\"Convert a string to a URL slug\n\n    Adapted from:\n    https://github.com/django/django/blob/master/django/utils/text.py\n    \"\"\"\n    value = re(r\"[^\\w\\s-]\").sub(\"\", str(value).lower())\n    return re(r\"[-\\s]+\").sub(\"-\", value).strip(\"-_\")\n\n\ndef sanitize_whitespace(value):\n    \"\"\"Replace all whitespace characters with a single space\"\"\"\n    return re(r\"\\s+\").sub(\" \", value.strip())\n\n\ndef ensure_http_scheme(url, scheme=\"https://\"):\n    \"\"\"Prepend 'scheme' to 'url' if it doesn't have one\"\"\"\n    if url and not url.startswith((\"https://\", \"http://\")):\n        return scheme + url.lstrip(\"/:\")\n    return url\n\n\ndef root_from_url(url, scheme=\"https://\"):\n    \"\"\"Extract scheme and domain from a URL\"\"\"\n    if not url.startswith((\"https://\", \"http://\")):\n        try:\n            return scheme + url[:url.index(\"/\")]\n        except ValueError:\n            return scheme + url\n    try:\n        return url[:url.index(\"/\", 8)]\n    except ValueError:\n        return url\n\n\ndef filename_from_url(url):\n    \"\"\"Extract the last part of an URL to use as a filename\"\"\"\n    try:\n        return url.partition(\"?\")[0].rpartition(\"/\")[2]\n    except Exception:\n        return \"\"\n\n\ndef ext_from_url(url):\n    \"\"\"Extract the filename extension of an URL\"\"\"\n    name, _, ext = filename_from_url(url).rpartition(\".\")\n    return ext.lower() if name else \"\"\n\n\ndef nameext_from_url(url, data=None):\n    \"\"\"Extract the last part of an URL and fill 'data' accordingly\"\"\"\n    if data is None:\n        data = {}\n\n    filename = unquote(filename_from_url(url))\n    name, _, ext = filename.rpartition(\".\")\n    if name and len(ext) <= 16:\n        data[\"filename\"] = name\n        data[\"extension\"] = ext.lower()\n    else:\n        data[\"filename\"] = filename\n        data[\"extension\"] = \"\"\n\n    return data\n\n\ndef nameext_from_name(filename, data=None):\n    \"\"\"Extract the last part of a file name and fill 'data' accordingly\"\"\"\n    if data is None:\n        data = {}\n\n    name, _, ext = filename.rpartition(\".\")\n    if name and len(ext) <= 16:\n        data[\"filename\"] = name\n        data[\"extension\"] = ext.lower()\n    else:\n        data[\"filename\"] = filename\n        data[\"extension\"] = \"\"\n\n    return data\n\n\ndef extract(txt, begin, end, pos=None):\n    \"\"\"Extract the text between 'begin' and 'end' from 'txt'\n\n    Args:\n        txt: String to search in\n        begin: First string to be searched for\n        end: Second string to be searched for after 'begin'\n        pos: Starting position for searches in 'txt'\n\n    Returns:\n        The string between the two search-strings 'begin' and 'end' beginning\n        with position 'pos' in 'txt' as well as the position after 'end'.\n\n        If at least one of 'begin' or 'end' is not found, None and the original\n        value of 'pos' is returned\n\n    Examples:\n        extract(\"abcde\", \"b\", \"d\")    -> \"c\" , 4\n        extract(\"abcde\", \"b\", \"d\", 3) -> None, 3\n    \"\"\"\n    try:\n        first = txt.index(begin, pos) + len(begin)\n        last = txt.index(end, first)\n        return txt[first:last], last+len(end)\n    except Exception:\n        return None, 0 if pos is None else pos\n\n\ndef extr(txt, begin, end, default=\"\"):\n    \"\"\"Stripped-down version of 'extract()'\"\"\"\n    try:\n        first = txt.index(begin) + len(begin)\n        return txt[first:txt.index(end, first)]\n    except Exception:\n        return default\n\n\ndef rextract(txt, begin, end, pos=None):\n    try:\n        lbeg = len(begin)\n        first = txt.rindex(begin, None, pos)\n        last = txt.index(end, first + lbeg)\n        return txt[first + lbeg:last], first\n    except Exception:\n        return None, -1 if pos is None else pos\n\n\ndef rextr(txt, begin, end, pos=None, default=\"\"):\n    \"\"\"Stripped-down version of 'rextract()'\"\"\"\n    try:\n        first = txt.rindex(begin, None, pos) + len(begin)\n        return txt[first:txt.index(end, first)]\n    except Exception:\n        return default\n\n\ndef extract_all(txt, rules, pos=None, values=None):\n    \"\"\"Calls extract for each rule and returns the result in a dict\"\"\"\n    if values is None:\n        values = {}\n    for key, begin, end in rules:\n        result, pos = extract(txt, begin, end, pos)\n        if key:\n            values[key] = result\n    return values, 0 if pos is None else pos\n\n\ndef extract_iter(txt, begin, end, pos=None):\n    \"\"\"Yield values that would be returned by repeated calls of extract()\"\"\"\n    try:\n        index = txt.index\n        lbeg = len(begin)\n        lend = len(end)\n        while True:\n            first = index(begin, pos) + lbeg\n            last = index(end, first)\n            pos = last + lend\n            yield txt[first:last]\n    except Exception:\n        return\n\n\ndef extract_from(txt, pos=None, default=\"\"):\n    \"\"\"Returns a function object that extracts from 'txt'\"\"\"\n    def extr(begin, end, index=txt.index, txt=txt):\n        nonlocal pos\n        try:\n            first = index(begin, pos) + len(begin)\n            last = index(end, first)\n            pos = last + len(end)\n            return txt[first:last]\n        except Exception:\n            return default\n    return extr\n\n\nextract_urls = re(r\"https?://[^\\s\\\"'<>\\\\]+\").findall\n\n\ndef parse_hex_escapes(txt):\n    \"\"\"Convert hex escapes in 'txt' into actual characters\"\"\"\n    return re(r\"\\\\x([0-9a-fA-F]{2})\").sub(_hex_to_char, txt)\n\n\ndef parse_unicode_escapes(txt):\n    \"\"\"Convert JSON Unicode escapes in 'txt' into actual characters\"\"\"\n    if \"\\\\u\" in txt:\n        return re(r\"\\\\u([0-9a-fA-F]{4})\").sub(_hex_to_char, txt)\n    return txt\n\n\ndef _hex_to_char(match):\n    return chr(int(match[1], 16))\n\n\ndef parse_bytes(value, default=0, suffixes=\"bkmgtp\"):\n    \"\"\"Convert a bytes-amount (\"500k\", \"2.5M\", ...) to int\"\"\"\n    if not value:\n        return default\n\n    value = str(value).strip()\n    last = value[-1].lower()\n\n    if last in suffixes:\n        mul = 1024 ** suffixes.index(last)\n        value = value[:-1]\n    else:\n        mul = 1\n\n    try:\n        return round(float(value) * mul)\n    except ValueError:\n        return default\n\n\ndef parse_int(value, default=0):\n    \"\"\"Convert 'value' to int\"\"\"\n    if not value:\n        return default\n    try:\n        return int(value)\n    except Exception:\n        return default\n\n\ndef parse_float(value, default=0.0):\n    \"\"\"Convert 'value' to float\"\"\"\n    if not value:\n        return default\n    try:\n        return float(value)\n    except Exception:\n        return default\n\n\ndef parse_query(qs, empty=False):\n    \"\"\"Parse a query string into name-value pairs\n\n    Ignore values whose name has been seen before\n    \"\"\"\n    if not qs:\n        return {}\n\n    result = {}\n    try:\n        for name_value in qs.split(\"&\"):\n            name, eq, value = name_value.partition(\"=\")\n            if eq or empty:\n                name = unquote(name.replace(\"+\", \" \"))\n                if name not in result:\n                    result[name] = unquote(value.replace(\"+\", \" \"))\n    except Exception:\n        pass\n    return result\n\n\ndef parse_query_list(qs, as_list=()):\n    \"\"\"Parse a query string into name-value pairs\n\n    Combine values of names in 'as_list' into lists\n    \"\"\"\n    if not qs:\n        return {}\n\n    result = {}\n    try:\n        for name_value in qs.split(\"&\"):\n            name, eq, value = name_value.partition(\"=\")\n            if eq:\n                name = unquote(name.replace(\"+\", \" \"))\n                value = unquote(value.replace(\"+\", \" \"))\n                if name in as_list:\n                    if name in result:\n                        result[name].append(value)\n                    else:\n                        result[name] = [value]\n                elif name not in result:\n                    result[name] = value\n    except Exception:\n        pass\n    return result\n\n\ndef build_query(params):\n    return \"&\".join([\n        f\"{quote(name)}={quote(value)}\"\n        for name, value in params.items()\n    ])\n\n\nurljoin = urllib.parse.urljoin\n\nquote = urllib.parse.quote\nunquote = urllib.parse.unquote\n\nescape = html.escape\nunescape = html.unescape\n"
  },
  {
    "path": "gallery_dl/update.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2024-2025 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nimport os\nimport sys\n\nfrom .extractor.common import Extractor, Message\nfrom .job import DownloadJob\nfrom . import util, version, output, exception\n\nREPOS = {\n    \"stable\" : \"mikf/gallery-dl\",\n    \"dev\"    : \"gdl-org/builds\",\n    \"nightly\": \"gdl-org/builds\",\n    \"master\" : \"gdl-org/builds\",\n}\n\nBINARIES_STABLE = {\n    \"windows\"    : \"gallery-dl.exe\",\n    \"windows_x64\": \"gallery-dl.exe\",\n    \"windows_x86\": \"gallery-dl_x86.exe\",\n    \"linux\"      : \"gallery-dl.bin\",\n}\nBINARIES_DEV = {\n    \"windows\"    : \"gallery-dl_windows.exe\",\n    \"windows_x64\": \"gallery-dl_windows.exe\",\n    \"windows_x86\": \"gallery-dl_windows_x86.exe\",\n    \"linux\"      : \"gallery-dl_linux\",\n    \"macos\"      : \"gallery-dl_macos\",\n}\nBINARIES = {\n    \"stable\" : BINARIES_STABLE,\n    \"dev\"    : BINARIES_DEV,\n    \"nightly\": BINARIES_DEV,\n    \"master\" : BINARIES_DEV,\n}\n\n\nclass UpdateJob(DownloadJob):\n\n    def handle_url(self, url, kwdict):\n        if not self._check_update(kwdict):\n            if kwdict[\"_check\"]:\n                self.status |= 1\n            return self.extractor.log.info(\n                \"gallery-dl is up to date (%s)\", version.__version__)\n\n        if kwdict[\"_check\"]:\n            return self.extractor.log.info(\n                \"A new release is available: %s -> %s\",\n                version.__version__, kwdict[\"tag_name\"])\n\n        self.extractor.log.info(\n            \"Updating from %s to %s\",\n            version.__version__, kwdict[\"tag_name\"])\n\n        path_old = sys.executable + \".old\"\n        path_new = sys.executable + \".new\"\n        directory, filename = os.path.split(sys.executable)\n\n        pathfmt = self.pathfmt\n        pathfmt.extension = \"new\"\n        pathfmt.filename = filename\n        pathfmt.temppath = path_new\n        pathfmt.realpath = pathfmt.path = sys.executable\n        pathfmt.realdirectory = pathfmt.directory = directory\n\n        self._newline = True\n        if not self.download(url):\n            self.status |= 4\n            return self._error(\"Failed to download %s\", url.rpartition(\"/\")[2])\n\n        if not util.WINDOWS:\n            try:\n                mask = os.stat(sys.executable).st_mode\n            except OSError:\n                mask = 0o755\n                self._warning(\"Unable to get file permission bits\")\n\n        try:\n            os.replace(sys.executable, path_old)\n        except OSError:\n            return self._error(\"Unable to move current executable\")\n\n        try:\n            pathfmt.finalize()\n        except OSError:\n            self._error(\"Unable to overwrite current executable\")\n            return os.replace(path_old, sys.executable)\n\n        if util.WINDOWS:\n            import atexit\n            import subprocess\n\n            cmd = f'ping 127.0.0.1 -n 5 -w 1000 & del /F \"{path_old}\"'\n            atexit.register(\n                util.Popen, cmd, shell=True,\n                stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,\n            )\n\n        else:\n            try:\n                os.unlink(path_old)\n            except OSError:\n                self._warning(\"Unable to delete old executable\")\n\n            try:\n                os.chmod(sys.executable, mask)\n            except OSError:\n                self._warning(\"Unable to restore file permission bits\")\n\n        self.out.success(pathfmt.path)\n\n    def _check_update(self, kwdict):\n        if kwdict[\"_exact\"]:\n            return True\n\n        tag = kwdict[\"tag_name\"]\n\n        if tag[0] == \"v\":\n            kwdict[\"tag_name\"] = tag = tag[1:]\n            ver, _, dev = version.__version__.partition(\"-\")\n\n            version_local = [int(v) for v in ver.split(\".\")]\n            version_remote = [int(v) for v in tag.split(\".\")]\n\n            if dev:\n                version_local[-1] -= 0.5\n            if version_local >= version_remote:\n                return False\n\n        elif version.__version__.endswith(\":\" + tag):\n            return False\n\n        return True\n\n    def _warning(self, msg, *args):\n        if self._newline:\n            self._newline = False\n            output.stderr_write(\"\\n\")\n        self.extractor.log.warning(msg, *args)\n\n    def _error(self, msg, *args):\n        if self._newline:\n            self._newline = False\n            output.stderr_write(\"\\n\")\n        self.status |= 1\n        self.extractor.log.error(msg, *args)\n\n\nclass UpdateExtractor(Extractor):\n    category = \"update\"\n    root = \"https://github.com\"\n    root_api = \"https://api.github.com\"\n    pattern = r\"update(?::(.+))?\"\n\n    def items(self):\n        tag = \"latest\"\n        check = exact = False\n\n        variant = version.__variant__ or \"stable/windows\"\n        repo, _, binary = variant.partition(\"/\")\n\n        target = self.groups[0]\n        if target == \"latest\":\n            pass\n        elif target == \"check\":\n            check = True\n        else:\n            channel, sep, target = target.partition(\"@\")\n            if sep:\n                repo = channel\n                tag = target\n                exact = True\n            elif channel in REPOS:\n                repo = channel\n            else:\n                tag = channel\n                exact = True\n\n            if util.re_compile(r\"\\d\\.\\d+\\.\\d+\").match(tag):\n                tag = \"v\" + tag\n\n        try:\n            path_repo = REPOS[repo or \"stable\"]\n        except KeyError:\n            raise exception.AbortExtraction(f\"Invalid channel '{repo}'\")\n\n        path_tag = tag if tag == \"latest\" else \"tags/\" + tag\n        url = f\"{self.root_api}/repos/{path_repo}/releases/{path_tag}\"\n        headers = {\n            \"Accept\": \"application/vnd.github+json\",\n            \"User-Agent\": util.USERAGENT_GALLERYDL,\n            \"X-GitHub-Api-Version\": \"2022-11-28\",\n        }\n        data = self.request(url, headers=headers, notfound=\"tag\").json()\n        data[\"_check\"] = check\n        data[\"_exact\"] = exact\n\n        if binary == \"linux\" and \\\n                repo != \"stable\" and \\\n                data[\"tag_name\"] <= \"2024.05.28\":\n            binary_name = \"gallery-dl_ubuntu\"\n        else:\n            binary_name = BINARIES[repo][binary]\n\n        url = (f\"{self.root}/{path_repo}/releases/download\"\n               f\"/{data['tag_name']}/{binary_name}\")\n\n        yield Message.Directory, \"\", data\n        yield Message.Url, url, data\n"
  },
  {
    "path": "gallery_dl/util.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2017-2026 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Utility functions and classes\"\"\"\n\nimport os\nimport sys\nimport json\nimport time\nimport random\nimport getpass\nimport hashlib\nimport binascii\nimport functools\nimport itertools\nimport subprocess\nimport collections\nimport urllib.parse\nfrom http.cookiejar import Cookie\nfrom email.utils import mktime_tz, parsedate_tz\nfrom . import text, dt, version, exception\n\n\ndef bencode(num, alphabet=\"0123456789\"):\n    \"\"\"Encode an integer into a base-N encoded string\"\"\"\n    data = \"\"\n    base = len(alphabet)\n    while num:\n        num, remainder = divmod(num, base)\n        data = alphabet[remainder] + data\n    return data\n\n\ndef bdecode(data, alphabet=\"0123456789\"):\n    \"\"\"Decode a base-N encoded string ( N = len(alphabet) )\"\"\"\n    num = 0\n    base = len(alphabet)\n    for c in data:\n        num = num * base + alphabet.find(c)\n    return num\n\n\ndef b36encode(num):\n    return bencode(num, \"0123456789abcdefghijklmnopqrstuvwxyz\")\n\n\ndef b36decode(data):\n    return int(data, 36) if data else 0\n\n\ndef decrypt_xor(encrypted, key, base64=True, fromhex=False):\n    if base64:\n        encrypted = binascii.a2b_base64(encrypted)\n    if fromhex:\n        encrypted = bytes.fromhex(encrypted.decode())\n\n    div = len(key)\n    return bytes([\n        encrypted[i] ^ key[i % div]\n        for i in range(len(encrypted))\n    ]).decode()\n\n\ndef advance(iterable, num):\n    \"\"\"\"Advance 'iterable' by 'num' steps\"\"\"\n    iterator = iter(iterable)\n    next(itertools.islice(iterator, num, num), None)\n    return iterator\n\n\ndef repeat(times):\n    \"\"\"Return an iterator that returns None\"\"\"\n    if times < 0:\n        return itertools.repeat(None)\n    return itertools.repeat(None, times)\n\n\ndef unique(iterable):\n    \"\"\"Yield unique elements from 'iterable' while preserving order\"\"\"\n    seen = set()\n    add = seen.add\n    for element in iterable:\n        if element not in seen:\n            add(element)\n            yield element\n\n\ndef unique_sequence(iterable):\n    \"\"\"Yield sequentially unique elements from 'iterable'\"\"\"\n    last = None\n    for element in iterable:\n        if element != last:\n            last = element\n            yield element\n\n\ndef contains(values, elements, separator=\" \"):\n    \"\"\"Returns True if at least one of 'elements' is contained in 'values'\"\"\"\n    if isinstance(values, str) and (separator or separator is None):\n        values = values.split(separator)\n\n    if not isinstance(elements, (tuple, list)):\n        return elements in values\n\n    for e in elements:\n        if e in values:\n            return True\n    return False\n\n\ndef raises(cls):\n    \"\"\"Returns a function that raises 'cls' as exception\"\"\"\n    def wrap(*args):\n        raise cls(*args)\n    return wrap\n\n\ndef identity(x, _=None):\n    \"\"\"Returns its argument\"\"\"\n    return x\n\n\ndef true(_, __=None):\n    \"\"\"Always returns True\"\"\"\n    return True\n\n\ndef false(_, __=None):\n    \"\"\"Always returns False\"\"\"\n    return False\n\n\ndef noop(_=None):\n    \"\"\"Does nothing\"\"\"\n\n\ndef md5(s):\n    \"\"\"Generate MD5 hexdigest of 's'\"\"\"\n    if not s:\n        s = b\"\"\n    elif isinstance(s, str):\n        s = s.encode()\n    return hashlib.md5(s).hexdigest()\n\n\ndef sha1(s):\n    \"\"\"Generate SHA1 hexdigest of 's'\"\"\"\n    if not s:\n        s = b\"\"\n    elif isinstance(s, str):\n        s = s.encode()\n    return hashlib.sha1(s).hexdigest()\n\n\ndef generate_token(size=16):\n    \"\"\"Generate a random token with hexadecimal digits\"\"\"\n    return random.getrandbits(size * 8).to_bytes(size, \"big\").hex()\n\n\ndef format_value(value, suffixes=\"kMGTPEZY\"):\n    value = str(value)\n    value_len = len(value)\n    index = value_len - 4\n    if index >= 0:\n        offset = (value_len - 1) % 3 + 1\n        return (f\"{value[:offset]}.{value[offset:offset+2]}\"\n                f\"{suffixes[index // 3]}\")\n    return value\n\n\ndef combine_dict(a, b):\n    \"\"\"Recursively combine the contents of 'b' into 'a'\"\"\"\n    for key, value in b.items():\n        if key in a and isinstance(value, dict) and isinstance(a[key], dict):\n            combine_dict(a[key], value)\n        else:\n            a[key] = value\n    return a\n\n\ndef transform_dict(a, func):\n    \"\"\"Recursively apply 'func' to all values in 'a'\"\"\"\n    for key, value in a.items():\n        if isinstance(value, dict):\n            transform_dict(value, func)\n        else:\n            a[key] = func(value)\n\n\ndef filter_dict(a):\n    \"\"\"Return a copy of 'a' without \"private\" entries\"\"\"\n    return {k: v for k, v in a.items() if k[0] != \"_\"}\n\n\ndef delete_items(obj, keys):\n    \"\"\"Remove all 'keys' from 'obj'\"\"\"\n    for key in keys:\n        if key in obj:\n            del obj[key]\n\n\ndef enumerate_reversed(iterable, start=0, length=None):\n    \"\"\"Enumerate 'iterable' and return its elements in reverse order\"\"\"\n    if length is None:\n        length = len(iterable)\n\n    try:\n        iterable = zip(range(start-1+length, start-1, -1), reversed(iterable))\n    except TypeError:\n        iterable = list(zip(range(start, start+length), iterable))\n        iterable.reverse()\n\n    return iterable\n\n\ndef number_to_string(value, numbers=(int, float)):\n    \"\"\"Convert numbers (int, float) to string; Return everything else as is.\"\"\"\n    return str(value) if value.__class__ in numbers else value\n\n\ndef to_string(value):\n    \"\"\"str() with \"better\" defaults\"\"\"\n    if not value:\n        return \"\"\n    if value.__class__ is list:\n        try:\n            return \", \".join(value)\n        except Exception:\n            return \", \".join(map(str, value))\n    return str(value)\n\n\ndef json_default(obj):\n    if isinstance(obj, CustomNone):\n        return None\n    return str(obj)\n\n\njson_loads = json._default_decoder.decode\njson_dumps = json.JSONEncoder(\n    check_circular=False,\n    separators=(\",\", \":\"),\n    default=json_default,\n).encode\n\n\ndef dump_json(obj, fp=sys.stdout, ensure_ascii=True, indent=4):\n    \"\"\"Serialize 'obj' as JSON and write it to 'fp'\"\"\"\n    json.dump(\n        obj, fp,\n        ensure_ascii=ensure_ascii,\n        indent=indent,\n        default=json_default,\n        sort_keys=True,\n    )\n    fp.write(\"\\n\")\n\n\ndef dump_response(response, fp, headers=False, content=True, hide_auth=True):\n    \"\"\"Write the contents of 'response' into a file-like object\"\"\"\n\n    if headers:\n        request = response.request\n        req_headers = request.headers.copy()\n        res_headers = response.headers.copy()\n\n        if hide_auth:\n            if authorization := req_headers.get(\"Authorization\"):\n                atype, sep, _ = str(authorization).partition(\" \")\n                req_headers[\"Authorization\"] = f\"{atype} ***\" if sep else \"***\"\n\n            if cookie := req_headers.get(\"Cookie\"):\n                req_headers[\"Cookie\"] = \";\".join(\n                    c.partition(\"=\")[0] + \"=***\"\n                    for c in cookie.split(\";\")\n                )\n\n            if set_cookie := res_headers.get(\"Set-Cookie\"):\n                res_headers[\"Set-Cookie\"] = re(r\"(^|, )([^ =]+)=[^,;]*\").sub(\n                    r\"\\1\\2=***\", set_cookie)\n\n        request_headers = \"\\n\".join(\n            f\"{name}: {value}\"\n            for name, value in req_headers.items()\n        )\n        response_headers = \"\\n\".join(\n            f\"{name}: {value}\"\n            for name, value in res_headers.items()\n        )\n\n        output = f\"\"\"\\\n{request.method} {request.url}\nStatus: {response.status_code} {response.reason}\n\nRequest Headers\n---------------\n{request_headers}\n\"\"\"\n        if request.body:\n            output = f\"\"\"{output}\nRequest Body\n------------\n{request.body}\n\"\"\"\n        output = f\"\"\"{output}\nResponse Headers\n----------------\n{response_headers}\n\"\"\"\n        fp.write(output.encode())\n\n    if content:\n        if headers:\n            fp.write(b\"\\nContent\\n-------\\n\")\n        fp.write(response.content)\n\n\ndef extract_headers(response):\n    headers = response.headers\n    data = dict(headers)\n\n    if hcd := headers.get(\"content-disposition\"):\n        if name := text.extr(hcd, 'filename=\"', '\"'):\n            text.nameext_from_url(name, data)\n\n    if hlm := headers.get(\"last-modified\"):\n        data[\"date\"] = dt.datetime(*parsedate_tz(hlm)[:6])\n\n    return data\n\n\ndef detect_challenge(response):\n    server = response.headers.get(\"server\")\n    if not server:\n        return\n\n    elif server.startswith(\"cloudflare\"):\n        if response.status_code not in (403, 503):\n            return\n\n        mitigated = response.headers.get(\"cf-mitigated\")\n        if mitigated and mitigated.lower() == \"challenge\":\n            return \"Cloudflare challenge\"\n\n        content = response.content\n        if b\"_cf_chl_opt\" in content or b\"jschl-answer\" in content:\n            return \"Cloudflare challenge\"\n        elif b'name=\"captcha-bypass\"' in content:\n            return \"Cloudflare CAPTCHA\"\n\n    elif server.startswith(\"ddos-guard\"):\n        if response.status_code == 403 and \\\n                b\"/ddos-guard/js-challenge/\" in response.content:\n            return \"DDoS-Guard challenge\"\n\n\n@functools.lru_cache(maxsize=None)\ndef git_head():\n    try:\n        out, err = Popen(\n            (\"git\", \"rev-parse\", \"--short\", \"HEAD\"),\n            stdout=subprocess.PIPE,\n            stderr=subprocess.PIPE,\n            cwd=os.path.dirname(os.path.abspath(__file__)),\n        ).communicate()\n        if out and not err:\n            return out.decode().rstrip()\n    except (OSError, subprocess.SubprocessError):\n        pass\n    return None\n\n\ndef expand_path(path):\n    \"\"\"Expand environment variables and tildes (~)\"\"\"\n    if not path:\n        return path\n    if not isinstance(path, str):\n        import logging\n        logging.getLogger(\"gallery-dl\").error(\n            \"Non-string paths are no longer supported.\")\n    return os.path.expandvars(os.path.expanduser(path))\n\n\ndef remove_file(path):\n    try:\n        os.unlink(path)\n    except OSError:\n        pass\n\n\ndef remove_directory(path):\n    try:\n        os.rmdir(path)\n    except OSError:\n        pass\n\n\ndef set_mtime(path, mtime):\n    try:\n        if isinstance(mtime, str):\n            mtime = mktime_tz(parsedate_tz(mtime))\n        os.utime(path, (time.time(), mtime))\n    except Exception:\n        pass\n\n\ndef cookiestxt_load(fp):\n    \"\"\"Parse a Netscape cookies.txt file and add return its Cookies\"\"\"\n    cookies = []\n\n    for line in fp:\n\n        line = line.lstrip(\" \")\n        # strip '#HttpOnly_'\n        if line.startswith(\"#HttpOnly_\"):\n            line = line[10:]\n        # ignore empty lines and comments\n        if not line or line[0] in (\"#\", \"$\", \"\\n\"):\n            continue\n        # strip trailing '\\n'\n        if line[-1] == \"\\n\":\n            line = line[:-1]\n\n        domain, domain_specified, path, secure, expires, name, value = \\\n            line.split(\"\\t\")\n\n        if not name:\n            name = value\n            value = None\n\n        cookies.append(Cookie(\n            0, name, value,\n            None, False,\n            domain,\n            domain_specified == \"TRUE\",\n            domain[0] == \".\" if domain else False,\n            path, False,\n            secure == \"TRUE\",\n            None if expires == \"0\" or not expires else expires,\n            False, None, None, {},\n        ))\n\n    return cookies\n\n\ndef cookiestxt_store(fp, cookies):\n    \"\"\"Write 'cookies' in Netscape cookies.txt format to 'fp'\"\"\"\n    fp.write(\"# Netscape HTTP Cookie File\\n\\n\")\n\n    for cookie in cookies:\n        if not cookie.domain:\n            continue\n\n        if cookie.value is None:\n            name = \"\"\n            value = cookie.name\n        else:\n            name = cookie.name\n            value = cookie.value\n\n        domain = cookie.domain\n        fp.write(\n            f\"{domain}\\t\"\n            f\"{'TRUE' if domain and domain[0] == '.' else 'FALSE'}\\t\"\n            f\"{cookie.path}\\t\"\n            f\"{'TRUE' if cookie.secure else 'FALSE'}\\t\"\n            f\"{'0' if cookie.expires is None else str(cookie.expires)}\\t\"\n            f\"{name}\\t\"\n            f\"{value}\\n\"\n        )\n\n\ndef code_to_language(code, default=None):\n    \"\"\"Map an ISO 639-1 language code to its actual name\"\"\"\n    return CODES.get((code or \"\").lower(), default)\n\n\ndef language_to_code(lang, default=None):\n    \"\"\"Map a language name to its ISO 639-1 code\"\"\"\n    if lang is None:\n        return default\n    lang = lang.capitalize()\n    for code, language in CODES.items():\n        if language == lang:\n            return code\n    return default\n\n\nCODES = {\n    \"ar\": \"Arabic\",\n    \"bg\": \"Bulgarian\",\n    \"bn\": \"Bengali\",\n    \"ca\": \"Catalan\",\n    \"cs\": \"Czech\",\n    \"da\": \"Danish\",\n    \"de\": \"German\",\n    \"el\": \"Greek\",\n    \"en\": \"English\",\n    \"es\": \"Spanish\",\n    \"fa\": \"Persian\",\n    \"fi\": \"Finnish\",\n    \"fr\": \"French\",\n    \"he\": \"Hebrew\",\n    \"hi\": \"Hindi\",\n    \"hu\": \"Hungarian\",\n    \"id\": \"Indonesian\",\n    \"it\": \"Italian\",\n    \"ja\": \"Japanese\",\n    \"ko\": \"Korean\",\n    \"ms\": \"Malay\",\n    \"nl\": \"Dutch\",\n    \"no\": \"Norwegian\",\n    \"pl\": \"Polish\",\n    \"pt\": \"Portuguese\",\n    \"ro\": \"Romanian\",\n    \"ru\": \"Russian\",\n    \"sk\": \"Slovak\",\n    \"sl\": \"Slovenian\",\n    \"sr\": \"Serbian\",\n    \"sv\": \"Swedish\",\n    \"th\": \"Thai\",\n    \"tr\": \"Turkish\",\n    \"uk\": \"Ukrainian\",\n    \"vi\": \"Vietnamese\",\n    \"zh\": \"Chinese\",\n}\n\n\ndef HTTPBasicAuth(username, password):\n    authorization = b\"Basic \" + binascii.b2a_base64(\n        f\"{username}:{password}\".encode(\"latin1\"), newline=False)\n    del username, password\n\n    def _apply(request):\n        request.headers[\"Authorization\"] = authorization\n        return request\n    return _apply\n\n\nclass ModuleProxy():\n    __slots__ = ()\n\n    def __getitem__(self, key, modules=sys.modules):\n        try:\n            return modules[key]\n        except KeyError:\n            pass\n        try:\n            __import__(key)\n        except ImportError:\n            modules[key] = NONE\n            return NONE\n        return modules[key]\n\n    __getattr__ = __getitem__\n\n\nclass LazyPrompt():\n    __slots__ = ()\n\n    def __str__(self):\n        return getpass.getpass()\n\n\nclass NullContext():\n    __slots__ = ()\n\n    def __enter__(self):\n        return None\n\n    def __exit__(self, exc_type, exc_value, traceback):\n        pass\n\n\nclass NullResponse():\n    __slots__ = (\"url\", \"reason\")\n\n    ok = is_redirect = is_permanent_redirect = False\n    cookies = headers = history = links = {}\n    encoding = apparent_encoding = \"utf-8\"\n    content = b\"\"\n    text = \"\"\n    status_code = 900\n    close = noop\n\n    def __init__(self, url, reason=\"\"):\n        self.url = url\n        self.reason = str(reason)\n\n    def __enter__(self):\n        return self\n\n    def __exit__(self, exc_type, exc_value, traceback):\n        pass\n\n    def __str__(self):\n        return \"900 \" + self.reason\n\n    def json(self):\n        return {}\n\n\nclass CustomNone():\n    \"\"\"None-style type that supports more operations than regular None\"\"\"\n    __slots__ = ()\n\n    __getattribute__ = identity\n    __getitem__ = identity\n    __iter__ = identity\n\n    def __call__(self, *args, **kwargs):\n        return self\n\n    def __next__(self):\n        raise StopIteration\n\n    def __eq__(self, other):\n        return other is self or other is None\n\n    def __ne__(self, other):\n        return other is not self and other is not None\n\n    __lt__ = true\n    __le__ = true\n    __gt__ = false\n    __ge__ = false\n    __bool__ = false\n\n    __add__ = identity\n    __sub__ = identity\n    __mul__ = identity\n    __matmul__ = identity\n    __truediv__ = identity\n    __floordiv__ = identity\n    __mod__ = identity\n\n    __radd__ = identity\n    __rsub__ = identity\n    __rmul__ = identity\n    __rmatmul__ = identity\n    __rtruediv__ = identity\n    __rfloordiv__ = identity\n    __rmod__ = identity\n\n    __lshift__ = identity\n    __rshift__ = identity\n    __and__ = identity\n    __xor__ = identity\n    __or__ = identity\n\n    __rlshift__ = identity\n    __rrshift__ = identity\n    __rand__ = identity\n    __rxor__ = identity\n    __ror__ = identity\n\n    __neg__ = identity\n    __pos__ = identity\n    __abs__ = identity\n    __invert__ = identity\n\n    def __len__(self):\n        return 0\n\n    __int__ = __len__\n    __hash__ = __len__\n    __index__ = __len__\n\n    def __format__(self, _):\n        return \"None\"\n\n    def __str__(self):\n        return \"None\"\n\n    __repr__ = __str__\n\n\nclass Flags():\n\n    def __init__(self):\n        self.FILE = self.POST = self.CHILD = self.DOWNLOAD = None\n\n    def process(self, flag):\n        value = self.__dict__[flag]\n        if value is False:  # flag was set to \"skip\"\n            if flag == \"DOWNLOAD\":\n                self.DOWNLOAD = None\n                raise exception.StopDownload()\n            return \"skip\"\n        self.__dict__[flag] = None\n\n        if value == \"abort\":\n            raise exception.AbortExtraction()\n        if value == \"terminate\":\n            raise exception.TerminateExtraction()\n        if value == \"restart\":\n            raise exception.RestartExtraction()\n        raise exception.StopExtraction()\n\n\n# v137.0 release of Firefox on 2025-04-01 has ordinal 739342\n# 735506 == 739342 - 137 * 28\n# v135.0 release of Chrome  on 2025-04-01 has ordinal 739342\n# 735562 == 739342 - 135 * 28\n#  _ord_today = dt.date.today().toordinal()\n#  _ff_ver = (_ord_today - 735506) // 28\n#  _ch_ver = (_ord_today - 735562) // 28\n\n_ord_today = dt.date.today().toordinal()\n_ff_ver = (_ord_today - 735_513) // 28  # 147 on 2026-01-13\n_ch_ver = (_ord_today - 735_599) // 28  # 143 on 2025-12-18\n\nre = text.re\nre_compile = text.re_compile\n\nNONE = CustomNone()\nFLAGS = Flags()\nWINDOWS = (os.name == \"nt\")\nSENTINEL = object()\nEXECUTABLE = getattr(sys, \"frozen\", False)\nSPECIAL_EXTRACTORS = {\"oauth\", \"recursive\", \"generic\"}\n\nEXTS_IMAGE = {\"jpg\", \"jpeg\", \"png\", \"gif\", \"bmp\", \"svg\", \"psd\", \"ico\",\n              \"webp\", \"avif\", \"heic\", \"heif\"}\nEXTS_VIDEO = {\"mp4\", \"m4v\", \"mov\", \"webm\", \"mkv\", \"ogv\", \"flv\", \"avi\", \"wmv\"}\nEXTS_ARCHIVE = {\"zip\", \"rar\", \"7z\", \"tar\", \"gz\", \"bz2\", \"lzma\", \"xz\"}\n\nUSERAGENT_GALLERYDL = \"gallery-dl/\" + version.__version__\nUSERAGENT_FIREFOX = (f\"Mozilla/5.0 (Windows NT 10.0; Win64; x64; \"\n                     f\"rv:{_ff_ver}.0) Gecko/20100101 Firefox/{_ff_ver}.0\")\nUSERAGENT_CHROME = (\"Mozilla/5.0 (Windows NT 10.0; Win64; x64) \"\n                    \"AppleWebKit/537.36 (KHTML, like Gecko) \"\n                    f\"Chrome/{_ch_ver}.0.0.0 Safari/537.36\")\n\nGLOBALS = {\n    \"contains\" : contains,\n    \"parse_int\": text.parse_int,\n    \"urlsplit\" : urllib.parse.urlsplit,\n    \"datetime\" : dt.datetime,\n    \"timedelta\": dt.timedelta,\n    \"abort\"    : raises(exception.StopExtraction),\n    \"error\"    : raises(exception.AbortExtraction),\n    \"terminate\": raises(exception.TerminateExtraction),\n    \"restart\"  : raises(exception.RestartExtraction),\n    \"hash_sha1\": sha1,\n    \"hash_md5\" : md5,\n    \"std\"      : ModuleProxy(),\n    \"re\"       : text.re_module,\n    \"exts_image\"  : EXTS_IMAGE,\n    \"exts_video\"  : EXTS_VIDEO,\n    \"exts_archive\": EXTS_ARCHIVE,\n}\n\n\nif EXECUTABLE and hasattr(sys, \"_MEIPASS\"):\n    # https://github.com/pyinstaller/pyinstaller/blob/develop/doc\n    # /runtime-information.rst#ld_library_path--libpath-considerations\n    _popen_env = os.environ.copy()\n\n    orig = _popen_env.get(\"LD_LIBRARY_PATH_ORIG\")\n    if orig is None:\n        _popen_env.pop(\"LD_LIBRARY_PATH\", None)\n    else:\n        _popen_env[\"LD_LIBRARY_PATH\"] = orig\n\n    orig = _popen_env.get(\"DYLD_LIBRARY_PATH_ORIG\")\n    if orig is None:\n        _popen_env.pop(\"DYLD_LIBRARY_PATH\", None)\n    else:\n        _popen_env[\"DYLD_LIBRARY_PATH\"] = orig\n\n    del orig\n\n    def Popen(args, **kwargs):\n        kwargs[\"env\"] = _popen_env\n        return subprocess.Popen(args, **kwargs)\nelse:\n    Popen = subprocess.Popen\n\n\ndef compile_expression_raw(expr, name=\"<expr>\", globals=None):\n    code_object = compile(expr, name, \"eval\")\n    return functools.partial(eval, code_object, globals or GLOBALS)\n\n\ndef compile_expression_defaultdict(expr, name=\"<expr>\", globals=None):\n    global GLOBALS_DEFAULT\n\n    if isinstance(__builtins__, dict):\n        # cpython\n        GLOBALS_DEFAULT = collections.defaultdict(lambda n=NONE: n, GLOBALS)\n    else:\n        # pypy3 - insert __builtins__ symbols into globals dict\n        GLOBALS_DEFAULT = collections.defaultdict(\n            lambda n=NONE: n, __builtins__.__dict__)\n        GLOBALS_DEFAULT.update(GLOBALS)\n\n    global compile_expression_defaultdict\n    compile_expression_defaultdict = compile_expression_defaultdict_impl\n    return compile_expression_defaultdict_impl(expr, name, globals)\n\n\ndef compile_expression_defaultdict_impl(expr, name=\"<expr>\", globals=None):\n    code_object = compile(expr, name, \"eval\")\n    return functools.partial(eval, code_object, globals or GLOBALS_DEFAULT)\n\n\ndef compile_expression_tryexcept(expr, name=\"<expr>\", globals=None):\n    code_object = compile(expr, name, \"eval\")\n    if globals is None:\n        globals = GLOBALS\n\n    def _eval(locals=None):\n        try:\n            return eval(code_object, globals, locals)\n        except exception.GalleryDLException:\n            raise\n        except Exception:\n            return NONE\n\n    return _eval\n\n\ncompile_expression = compile_expression_tryexcept\n\n\ndef compile_filter(expr, name=\"<filter>\", globals=None):\n    if not isinstance(expr, str):\n        expr = f\"({') and ('.join(expr)})\"\n    return compile_expression(expr, name, globals)\n\n\ndef import_file(path):\n    \"\"\"Import a Python module from a filesystem path\"\"\"\n    path, name = os.path.split(path)\n\n    name, sep, ext = name.rpartition(\".\")\n    if not sep:\n        name = ext\n\n    if path:\n        path = expand_path(path)\n        sys.path.insert(0, path)\n        try:\n            return __import__(name)\n        finally:\n            del sys.path[0]\n    else:\n        return __import__(name.replace(\"-\", \"_\"))\n\n\ndef build_selection_func(value, min=0.0, conv=float):\n    if not value:\n        return (lambda: min) if min else None\n\n    if isinstance(value, str):\n        lower, _, upper = value.partition(\"-\")\n    else:\n        try:\n            lower, upper = value\n        except TypeError:\n            lower = value\n            upper = None\n    lower = conv(lower)\n\n    if upper:\n        upper = conv(upper)\n        return functools.partial(\n            random.uniform if lower.__class__ is float else random.randint,\n            lower if lower > min else min,\n            upper if upper > min else min,\n        )\n    else:\n        if lower < min:\n            lower = min\n        return lambda: lower\n\n\nbuild_duration_func = build_selection_func\n\n\ndef build_duration_func_ex(value):\n    if not value:\n        return None\n    if not isinstance(value, str) or \"=\" not in value:\n        value = build_duration_func(value)\n        return lambda _: value()\n\n    args, _, value = value.partition(\"=\")\n    type, _, args = args.partition(\":\")\n    value = build_duration_func(value)\n\n    if \"exponential\".startswith(type):\n        if not args:\n            return lambda n: value() * (2 ** (n-1))\n        base, _, start = args.partition(\":\")\n        start, _, max = start.partition(\":\")\n        start = float(start) if start else 0\n        base = float(base) if base else 2\n        max = int(max) if max else 3600\n        return lambda n: min(start + value() * (base ** (n-1)), max)\n\n    if \"linear\".startswith(type):\n        if not args:\n            return lambda n: value() * n\n        start, _, max = args.partition(\":\")\n        start = float(start) if start else 0\n        max = int(max) if max else 3600\n        return lambda n: min(start + value() * n, max)\n\n    raise ValueError(\"Invalid duration type \" + repr(type))\n\n\ndef build_extractor_filter(categories, negate=True, special=None):\n    \"\"\"Build a function that takes an Extractor class as argument\n    and returns True if that class is allowed by 'categories'\n    \"\"\"\n    if isinstance(categories, str):\n        categories = categories.split(\",\")\n\n    catset = set()  # set of categories / basecategories\n    subset = set()  # set of subcategories\n    catsub = []     # list of category-subcategory pairs\n\n    for item in categories:\n        category, _, subcategory = item.partition(\":\")\n        if category and category != \"*\":\n            if subcategory and subcategory != \"*\":\n                catsub.append((category, subcategory))\n            else:\n                catset.add(category)\n        elif subcategory and subcategory != \"*\":\n            subset.add(subcategory)\n\n    if special:\n        catset |= special\n    elif not catset and not subset and not catsub:\n        return true if negate else false\n\n    tests = []\n\n    if negate:\n        if catset:\n            tests.append(lambda extr:\n                         extr.category not in catset and\n                         extr.basecategory not in catset)\n        if subset:\n            tests.append(lambda extr: extr.subcategory not in subset)\n    else:\n        if catset:\n            tests.append(lambda extr:\n                         extr.category in catset or\n                         extr.basecategory in catset)\n        if subset:\n            tests.append(lambda extr: extr.subcategory in subset)\n\n    if catsub:\n        def test(extr):\n            for category, subcategory in catsub:\n                if subcategory == extr.subcategory and (\n                        category == extr.category or\n                        category == extr.basecategory):\n                    return not negate\n            return negate\n        tests.append(test)\n\n    if len(tests) == 1:\n        return tests[0]\n    if negate:\n        return lambda extr: all(t(extr) for t in tests)\n    else:\n        return lambda extr: any(t(extr) for t in tests)\n\n\ndef build_proxy_map(proxies, log=None):\n    \"\"\"Generate a proxy map\"\"\"\n    if not proxies:\n        return None\n\n    if isinstance(proxies, str):\n        if \"://\" not in proxies:\n            proxies = \"http://\" + proxies.lstrip(\"/\")\n        proxies = {\"http\": proxies, \"https\": proxies}\n    elif isinstance(proxies, dict):\n        for scheme, proxy in proxies.items():\n            if \"://\" not in proxy:\n                proxies[scheme] = \"http://\" + proxy.lstrip(\"/\")\n    else:\n        proxies = None\n\n    if log is not None:\n        if proxies is None:\n            log.warning(\"Invalid proxy specifier: %r\", proxies)\n        else:\n            log.debug(\"Proxy Map: %s\", proxies)\n\n    return proxies\n\n\ndef predicate_build(predicates):\n    if not predicates:\n        return true\n\n    if len(predicates) == 1:\n        return predicates[0]\n\n    def chain(url, kwdict):\n        for pred in predicates:\n            if not pred(url, kwdict):\n                return False\n        return True\n    return chain\n\n\ndef predicate_unique():\n    \"\"\"Predicate; True if given URL has not been encountered before\"\"\"\n    def _pred(url, _):\n        if url.startswith(\"text:\"):\n            return True\n        if url not in urls:\n            urls.add(url)\n            return True\n        return False\n    urls = set()\n    return _pred\n\n\ndef predicate_filter(expr, target=\"image\"):\n    \"\"\"Predicate; True if evaluating the given expression returns True\"\"\"\n    def _pred(_, kwdict):\n        try:\n            return expr(kwdict)\n        except exception.GalleryDLException:\n            raise\n        except Exception as exc:\n            raise exception.FilterError(exc)\n    expr = compile_filter(expr, f\"<{target} filter>\")\n    return _pred\n\n\ndef predicate_tags(blacklist, negate=False):\n    def _pred(_, kwdict):\n        if not (tags := kwdict.get(\"tags\") or kwdict.get(\"tag_string\")):\n            return True\n\n        if isinstance(tags, str):\n            taglist = tags.split(\", \")\n            if len(taglist) < len(tags) / 16:\n                taglist = tags.split(\" \")\n            tags = taglist\n        elif isinstance(tags[0], dict):\n            # pixiv \"tags\": \"original\"\n            tags = [\n                tag\n                for tagdict in tags\n                for tag in tagdict.values()\n                if isinstance(tag, str)\n            ]\n            tags.sort()\n\n        for tag in tags:\n            if tag in blacklist:\n                return negate\n        return not negate\n\n    if isinstance(blacklist, str):\n        try:\n            with open(expand_path(blacklist)) as fp:\n                blacklist = {t.lower() for tag in fp if (t := tag.strip())}\n        except OSError:\n            blacklist = {tag for tag in\n                         blacklist.replace(\" \", \"\").lower().split(\",\")}\n    else:\n        blacklist = set(blacklist)\n    return _pred\n\n\ndef predicate_date(before, after=None, skip=None):\n    if after is None:\n        if skip is not None and skip(before):\n            return true\n\n        def _pred(_, kwdict):\n            if (date := kwdict.get(\"date\")) and date >= before:\n                return False\n            return True\n\n    elif before is None or after > before or skip is not None and skip(before):\n        def _pred(_, kwdict):\n            if (date := kwdict.get(\"date\")) and date <= after:\n                raise exception.StopExtraction()\n            return True\n\n    else:\n        def _pred(_, kwdict):\n            if not (date := kwdict.get(\"date\")):\n                return True\n            if date <= after:\n                raise exception.StopExtraction()\n            return date < before\n    return _pred\n\n\ndef predicate_range(ranges, skip=None, flag=None):\n    \"\"\"Predicate; True if the current index is in the given range(s)\"\"\"\n    if ranges := predicate_range_parse(ranges):\n        # technically wrong for 'step > 2', but good enough for now\n        # and evaluating min/max for a large range is slow\n        upper = max(r.stop for r in ranges) - 1\n        lower = min(r.start for r in ranges)\n        index = 0 if skip is None or lower <= 1 else skip(lower)\n        del lower\n    else:\n        index = upper = 0\n\n    if flag is None:\n        def _pred(_url, _kwdict):\n            nonlocal index\n\n            if index >= upper:\n                raise exception.StopExtraction()\n            index += 1\n\n            for range in ranges:\n                if index in range:\n                    return True\n            return False\n    else:\n        def _pred(_url, _kwdict):\n            nonlocal index\n\n            index += 1\n            if index >= upper:\n                if index > upper:\n                    raise exception.StopExtraction()\n                FLAGS.__dict__[flag.upper()] = \"stop\"\n\n            for range in ranges:\n                if index in range:\n                    return True\n            return False\n    return _pred\n\n\ndef predicate_range_parse(rangespec):\n    \"\"\"Parse an integer range string and return the resulting ranges\n\n    Examples:\n        _parse(\"-2,4,6-8,10-\")      -> [(1,3), (4,5), (6,9), (10,INTMAX)]\n        _parse(\" - 3 , 4-  4, 2-6\") -> [(1,4), (4,5), (2,7)]\n        _parse(\"1:2,4:8:2\")         -> [(1,1), (4,7,2)]\n    \"\"\"\n    ranges = []\n\n    if isinstance(rangespec, str):\n        rangespec = rangespec.split(\",\")\n    elif isinstance(rangespec, int):\n        rangespec = (str(rangespec),)\n\n    for group in rangespec:\n        if not group:\n            continue\n\n        elif \":\" in group:\n            start, _, stop = group.partition(\":\")\n            stop, _, step = stop.partition(\":\")\n            ranges.append(range(\n                int(start) if start.strip() else 1,\n                int(stop) if stop.strip() else sys.maxsize,\n                int(step) if step.strip() else 1,\n            ))\n\n        elif \"-\" in group:\n            start, _, stop = group.partition(\"-\")\n            ranges.append(range(\n                int(start) if start.strip() else 1,\n                int(stop) + 1 if stop.strip() else sys.maxsize,\n            ))\n\n        else:\n            start = int(group)\n            ranges.append(range(start, start+1))\n\n    return ranges\n"
  },
  {
    "path": "gallery_dl/version.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2016-2026 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n__version__ = \"1.32.0-dev\"\n__variant__ = None\n"
  },
  {
    "path": "gallery_dl/ytdl.py",
    "content": "# -*- coding: utf-8 -*-\n\n# Copyright 2021-2025 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Helpers for interacting with youtube-dl\"\"\"\n\nimport shlex\nimport itertools\nfrom . import text, util, exception\n\n\ndef import_module(module_name):\n    if module_name is None:\n        try:\n            return __import__(\"yt_dlp\")\n        except (ImportError, SyntaxError):\n            return __import__(\"youtube_dl\")\n    return util.import_file(module_name)\n\n\ndef construct_YoutubeDL(module, obj, user_opts, system_opts=None):\n    opts = argv = None\n    config = obj.config\n\n    if not config(\"deprecations\"):\n        module.YoutubeDL.deprecated_feature = util.false\n        module.YoutubeDL.deprecation_warning = util.false\n\n    if cfg := config(\"config-file\"):\n        with open(util.expand_path(cfg), encoding=\"utf-8\") as fp:\n            contents = fp.read()\n        argv = shlex.split(contents, comments=True)\n\n    if cmd := config(\"cmdline-args\"):\n        if isinstance(cmd, str):\n            cmd = shlex.split(cmd)\n        argv = (argv + cmd) if argv else cmd\n\n    try:\n        opts = parse_command_line(module, argv) if argv else user_opts\n    except SystemExit:\n        raise exception.AbortExtraction(\"Invalid command-line option\")\n\n    if opts.get(\"format\") is None:\n        opts[\"format\"] = config(\"format\")\n    if opts.get(\"nopart\") is None:\n        opts[\"nopart\"] = not config(\"part\", True)\n    if opts.get(\"updatetime\") is None:\n        opts[\"updatetime\"] = config(\"mtime\", True)\n    if opts.get(\"min_filesize\") is None:\n        opts[\"min_filesize\"] = text.parse_bytes(config(\"filesize-min\"), None)\n    if opts.get(\"max_filesize\") is None:\n        opts[\"max_filesize\"] = text.parse_bytes(config(\"filesize-max\"), None)\n    if opts.get(\"overwrites\") is None and not config(\"skip\", True):\n        opts[\"overwrites\"] = True\n    if opts.get(\"ratelimit\") is None:\n        if rate := config(\"rate\"):\n            func = util.build_selection_func(rate, 0, text.parse_bytes)\n            if hasattr(func, \"args\"):\n                opts[\"__gdl_ratelimit_func\"] = func\n            else:\n                opts[\"ratelimit\"] = func() or None\n        else:\n            opts[\"ratelimit\"] = None\n\n    if raw_opts := config(\"raw-options\"):\n        opts.update(raw_opts)\n    if config(\"logging\", True):\n        opts[\"logger\"] = obj.log\n    if system_opts:\n        opts.update(system_opts)\n\n    opts[\"__gdl_initialize\"] = True\n    return module.YoutubeDL(opts)\n\n\ndef parse_command_line(module, argv):\n    parser, opts, args = module.parseOpts(argv)\n\n    ytdlp = hasattr(module, \"cookies\")\n    try:\n        std_headers = module.utils.networking.std_headers\n    except AttributeError:\n        std_headers = module.std_headers\n\n    try:\n        parse_bytes = module.parse_bytes\n    except AttributeError:\n        parse_bytes = module.FileDownloader.parse_bytes\n\n    # HTTP headers\n    if opts.user_agent is not None:\n        std_headers[\"User-Agent\"] = opts.user_agent\n    if opts.referer is not None:\n        std_headers[\"Referer\"] = opts.referer\n    if opts.headers:\n        if isinstance(opts.headers, dict):\n            std_headers.update(opts.headers)\n        else:\n            for h in opts.headers:\n                key, _, value = h.partition(\":\")\n                std_headers[key] = value\n\n    if opts.ratelimit is not None:\n        opts.ratelimit = parse_bytes(opts.ratelimit)\n    if getattr(opts, \"throttledratelimit\", None) is not None:\n        opts.throttledratelimit = parse_bytes(opts.throttledratelimit)\n    if opts.min_filesize is not None:\n        opts.min_filesize = parse_bytes(opts.min_filesize)\n    if opts.max_filesize is not None:\n        opts.max_filesize = parse_bytes(opts.max_filesize)\n    if opts.max_sleep_interval is None:\n        opts.max_sleep_interval = opts.sleep_interval\n    if getattr(opts, \"overwrites\", None):\n        opts.continue_dl = False\n    if opts.retries is not None:\n        opts.retries = parse_retries(opts.retries)\n    if getattr(opts, \"file_access_retries\", None) is not None:\n        opts.file_access_retries = parse_retries(opts.file_access_retries)\n    if opts.fragment_retries is not None:\n        opts.fragment_retries = parse_retries(opts.fragment_retries)\n    if getattr(opts, \"extractor_retries\", None) is not None:\n        opts.extractor_retries = parse_retries(opts.extractor_retries)\n    if opts.buffersize is not None:\n        opts.buffersize = parse_bytes(opts.buffersize)\n    if opts.http_chunk_size is not None:\n        opts.http_chunk_size = parse_bytes(opts.http_chunk_size)\n    if opts.extractaudio:\n        opts.audioformat = opts.audioformat.lower()\n    if opts.audioquality:\n        opts.audioquality = opts.audioquality.strip(\"kK\")\n    if opts.recodevideo is not None:\n        opts.recodevideo = opts.recodevideo.replace(\" \", \"\")\n    if getattr(opts, \"remuxvideo\", None) is not None:\n        opts.remuxvideo = opts.remuxvideo.replace(\" \", \"\")\n    if getattr(opts, \"wait_for_video\", None) is not None:\n        min_wait, _, max_wait = opts.wait_for_video.partition(\"-\")\n        opts.wait_for_video = (module.parse_duration(min_wait),\n                               module.parse_duration(max_wait))\n\n    if opts.date is not None:\n        date = module.DateRange.day(opts.date)\n    else:\n        date = module.DateRange(opts.dateafter, opts.datebefore)\n\n    decodeOption = getattr(module, \"decodeOption\", util.identity)\n    compat_opts = getattr(opts, \"compat_opts\", ())\n\n    def _unused_compat_opt(name):\n        if name not in compat_opts:\n            return False\n        compat_opts.discard(name)\n        compat_opts.update([f\"*{name}\"])\n        return True\n\n    def set_default_compat(\n            compat_name, opt_name, default=True, remove_compat=True):\n        attr = getattr(opts, opt_name, None)\n        if compat_name in compat_opts:\n            if attr is None:\n                setattr(opts, opt_name, not default)\n                return True\n            else:\n                if remove_compat:\n                    _unused_compat_opt(compat_name)\n                return False\n        elif attr is None:\n            setattr(opts, opt_name, default)\n        return None\n\n    set_default_compat(\"abort-on-error\", \"ignoreerrors\", \"only_download\")\n    set_default_compat(\"no-playlist-metafiles\", \"allow_playlist_files\")\n    set_default_compat(\"no-clean-infojson\", \"clean_infojson\")\n    if \"format-sort\" in compat_opts:\n        opts.format_sort.extend(module.InfoExtractor.FormatSort.ytdl_default)\n    _video_multistreams_set = set_default_compat(\n        \"multistreams\", \"allow_multiple_video_streams\",\n        False, remove_compat=False)\n    _audio_multistreams_set = set_default_compat(\n        \"multistreams\", \"allow_multiple_audio_streams\",\n        False, remove_compat=False)\n    if _video_multistreams_set is False and _audio_multistreams_set is False:\n        _unused_compat_opt(\"multistreams\")\n\n    if isinstance(opts.outtmpl, dict):\n        outtmpl = opts.outtmpl\n        outtmpl_default = outtmpl.get(\"default\")\n    else:\n        opts.outtmpl = outtmpl = outtmpl_default = \"\"\n\n    if \"filename\" in compat_opts:\n        if outtmpl_default is None:\n            outtmpl_default = outtmpl[\"default\"] = \"%(title)s-%(id)s.%(ext)s\"\n        else:\n            _unused_compat_opt(\"filename\")\n\n    if opts.extractaudio and not opts.keepvideo and opts.format is None:\n        opts.format = \"bestaudio/best\"\n\n    if ytdlp:\n        def metadataparser_actions(f):\n            if isinstance(f, str):\n                yield module.MetadataFromFieldPP.to_action(f)\n            else:\n                REPLACE = module.MetadataParserPP.Actions.REPLACE\n                args = f[1:]\n                for x in f[0].split(\",\"):\n                    action = [REPLACE, x]\n                    action += args\n                    yield action\n\n        parse_metadata = getattr(opts, \"parse_metadata\", None)\n        if isinstance(parse_metadata, dict):\n            if opts.metafromtitle is not None:\n                if \"pre_process\" not in parse_metadata:\n                    parse_metadata[\"pre_process\"] = []\n                parse_metadata[\"pre_process\"].append(\n                    f\"title:{opts.metafromtitle}\")\n            opts.parse_metadata = {\n                k: list(itertools.chain.from_iterable(map(\n                        metadataparser_actions, v)))\n                for k, v in parse_metadata.items()\n            }\n        else:\n            if parse_metadata is None:\n                parse_metadata = []\n            if opts.metafromtitle is not None:\n                parse_metadata.append(f\"title:{opts.metafromtitle}\")\n            opts.parse_metadata = list(itertools.chain.from_iterable(map(\n                metadataparser_actions, parse_metadata)))\n\n        opts.metafromtitle = None\n    else:\n        opts.parse_metadata = ()\n\n    download_archive_fn = module.expand_path(opts.download_archive) \\\n        if opts.download_archive is not None else opts.download_archive\n\n    if getattr(opts, \"getcomments\", None):\n        opts.writeinfojson = True\n\n    if getattr(opts, \"no_sponsorblock\", None):\n        opts.sponsorblock_mark = set()\n        opts.sponsorblock_remove = set()\n    else:\n        opts.sponsorblock_mark = \\\n            getattr(opts, \"sponsorblock_mark\", None) or set()\n        opts.sponsorblock_remove = \\\n            getattr(opts, \"sponsorblock_remove\", None) or set()\n    opts.remove_chapters = getattr(opts, \"remove_chapters\", None) or ()\n\n    try:\n        postprocessors = list(module.get_postprocessors(opts))\n    except AttributeError:\n        postprocessors = legacy_postprocessors(\n            opts, module, ytdlp, compat_opts)\n\n    match_filter = (\n        None if opts.match_filter is None\n        else module.match_filter_func(opts.match_filter))\n\n    if cookiesfrombrowser := getattr(opts, \"cookiesfrombrowser\", None):\n        pattern = text.re(r\"\"\"(?x)\n            (?P<name>[^+:]+)\n            (?:\\s*\\+\\s*(?P<keyring>[^:]+))?\n            (?:\\s*:\\s*(?!:)(?P<profile>.+?))?\n            (?:\\s*::\\s*(?P<container>.+))?\"\"\")\n        if match := pattern.fullmatch(cookiesfrombrowser):\n            browser, keyring, profile, container = match.groups()\n            if keyring is not None:\n                keyring = keyring.upper()\n            cookiesfrombrowser = (browser.lower(), profile, keyring, container)\n        else:\n            cookiesfrombrowser = None\n\n    return {\n        \"usenetrc\": opts.usenetrc,\n        \"netrc_location\": getattr(opts, \"netrc_location\", None),\n        \"username\": opts.username,\n        \"password\": opts.password,\n        \"twofactor\": opts.twofactor,\n        \"videopassword\": opts.videopassword,\n        \"ap_mso\": opts.ap_mso,\n        \"ap_username\": opts.ap_username,\n        \"ap_password\": opts.ap_password,\n        \"quiet\": opts.quiet,\n        \"no_warnings\": opts.no_warnings,\n        \"forceurl\": opts.geturl,\n        \"forcetitle\": opts.gettitle,\n        \"forceid\": opts.getid,\n        \"forcethumbnail\": opts.getthumbnail,\n        \"forcedescription\": opts.getdescription,\n        \"forceduration\": opts.getduration,\n        \"forcefilename\": opts.getfilename,\n        \"forceformat\": opts.getformat,\n        \"forceprint\": getattr(opts, \"forceprint\", None) or (),\n        \"force_write_download_archive\": getattr(\n            opts, \"force_write_download_archive\", None),\n        \"simulate\": opts.simulate,\n        \"skip_download\": opts.skip_download,\n        \"format\": opts.format,\n        \"allow_unplayable_formats\": getattr(\n            opts, \"allow_unplayable_formats\", None),\n        \"ignore_no_formats_error\": getattr(\n            opts, \"ignore_no_formats_error\", None),\n        \"format_sort\": getattr(\n            opts, \"format_sort\", None),\n        \"format_sort_force\": getattr(\n            opts, \"format_sort_force\", None),\n        \"allow_multiple_video_streams\": opts.allow_multiple_video_streams,\n        \"allow_multiple_audio_streams\": opts.allow_multiple_audio_streams,\n        \"check_formats\": getattr(\n            opts, \"check_formats\", None),\n        \"outtmpl\": opts.outtmpl,\n        \"outtmpl_na_placeholder\": opts.outtmpl_na_placeholder,\n        \"paths\": getattr(opts, \"paths\", None),\n        \"autonumber_size\": opts.autonumber_size,\n        \"autonumber_start\": opts.autonumber_start,\n        \"restrictfilenames\": opts.restrictfilenames,\n        \"windowsfilenames\": getattr(opts, \"windowsfilenames\", None),\n        \"ignoreerrors\": opts.ignoreerrors,\n        \"force_generic_extractor\": opts.force_generic_extractor,\n        \"ratelimit\": opts.ratelimit,\n        \"throttledratelimit\": getattr(opts, \"throttledratelimit\", None),\n        \"overwrites\": getattr(opts, \"overwrites\", None),\n        \"retries\": opts.retries,\n        \"file_access_retries\": getattr(opts, \"file_access_retries\", None),\n        \"fragment_retries\": opts.fragment_retries,\n        \"extractor_retries\": getattr(opts, \"extractor_retries\", None),\n        \"skip_unavailable_fragments\": opts.skip_unavailable_fragments,\n        \"keep_fragments\": opts.keep_fragments,\n        \"concurrent_fragment_downloads\": getattr(\n            opts, \"concurrent_fragment_downloads\", None),\n        \"buffersize\": opts.buffersize,\n        \"noresizebuffer\": opts.noresizebuffer,\n        \"http_chunk_size\": opts.http_chunk_size,\n        \"continuedl\": opts.continue_dl,\n        \"noprogress\": True if opts.noprogress is None else opts.noprogress,\n        \"playliststart\": opts.playliststart,\n        \"playlistend\": opts.playlistend,\n        \"playlistreverse\": opts.playlist_reverse,\n        \"playlistrandom\": opts.playlist_random,\n        \"noplaylist\": opts.noplaylist,\n        \"logtostderr\": outtmpl_default == \"-\",\n        \"consoletitle\": opts.consoletitle,\n        \"nopart\": opts.nopart,\n        \"updatetime\": opts.updatetime,\n        \"writedescription\": opts.writedescription,\n        \"writeannotations\": getattr(opts, \"writeannotations\", None),\n        \"writeinfojson\": opts.writeinfojson,\n        \"allow_playlist_files\": opts.allow_playlist_files,\n        \"clean_infojson\": opts.clean_infojson,\n        \"getcomments\": getattr(opts, \"getcomments\", None),\n        \"writethumbnail\": opts.writethumbnail is True,\n        \"write_all_thumbnails\": getattr(opts, \"write_all_thumbnails\", None) or\n        opts.writethumbnail == \"all\",\n        \"writelink\": getattr(opts, \"writelink\", None),\n        \"writeurllink\": getattr(opts, \"writeurllink\", None),\n        \"writewebloclink\": getattr(opts, \"writewebloclink\", None),\n        \"writedesktoplink\": getattr(opts, \"writedesktoplink\", None),\n        \"writesubtitles\": opts.writesubtitles,\n        \"writeautomaticsub\": opts.writeautomaticsub,\n        \"allsubtitles\": opts.allsubtitles,\n        \"subtitlesformat\": opts.subtitlesformat,\n        \"subtitleslangs\": opts.subtitleslangs,\n        \"matchtitle\": decodeOption(opts.matchtitle),\n        \"rejecttitle\": decodeOption(opts.rejecttitle),\n        \"max_downloads\": opts.max_downloads,\n        \"prefer_free_formats\": opts.prefer_free_formats,\n        \"trim_file_name\": getattr(opts, \"trim_file_name\", None),\n        \"verbose\": opts.verbose,\n        \"dump_intermediate_pages\": opts.dump_intermediate_pages,\n        \"write_pages\": opts.write_pages,\n        \"test\": opts.test,\n        \"keepvideo\": opts.keepvideo,\n        \"min_filesize\": opts.min_filesize,\n        \"max_filesize\": opts.max_filesize,\n        \"min_views\": opts.min_views,\n        \"max_views\": opts.max_views,\n        \"daterange\": date,\n        \"cachedir\": opts.cachedir,\n        \"youtube_print_sig_code\": getattr(\n            opts, \"youtube_print_sig_code\", None),\n        \"age_limit\": opts.age_limit,\n        \"download_archive\": download_archive_fn,\n        \"break_on_existing\": getattr(opts, \"break_on_existing\", None),\n        \"break_on_reject\": getattr(opts, \"break_on_reject\", None),\n        \"break_per_url\": getattr(opts, \"break_per_url\", None),\n        \"skip_playlist_after_errors\": getattr(\n            opts, \"skip_playlist_after_errors\", None),\n        \"cookiefile\": opts.cookiefile,\n        \"cookiesfrombrowser\": cookiesfrombrowser,\n        \"nocheckcertificate\": opts.no_check_certificate,\n        \"prefer_insecure\": opts.prefer_insecure,\n        \"proxy\": opts.proxy,\n        \"socket_timeout\": opts.socket_timeout,\n        \"bidi_workaround\": opts.bidi_workaround,\n        \"debug_printtraffic\": opts.debug_printtraffic,\n        \"prefer_ffmpeg\": getattr(opts, \"prefer_ffmpeg\", None),\n        \"include_ads\": getattr(opts, \"include_ads\", None),\n        \"default_search\": opts.default_search,\n        \"dynamic_mpd\": getattr(opts, \"dynamic_mpd\", None),\n        \"extractor_args\": getattr(opts, \"extractor_args\", None),\n        \"youtube_include_dash_manifest\": getattr(\n            opts, \"youtube_include_dash_manifest\", None),\n        \"youtube_include_hls_manifest\": getattr(\n            opts, \"youtube_include_hls_manifest\", None),\n        \"encoding\": opts.encoding,\n        \"extract_flat\": opts.extract_flat,\n        \"live_from_start\": getattr(opts, \"live_from_start\", None),\n        \"wait_for_video\": getattr(opts, \"wait_for_video\", None),\n        \"mark_watched\": opts.mark_watched,\n        \"merge_output_format\": opts.merge_output_format,\n        \"postprocessors\": postprocessors,\n        \"fixup\": opts.fixup,\n        \"source_address\": opts.source_address,\n        \"sleep_interval_requests\": getattr(\n            opts, \"sleep_interval_requests\", None),\n        \"sleep_interval\": opts.sleep_interval,\n        \"max_sleep_interval\": opts.max_sleep_interval,\n        \"sleep_interval_subtitles\": getattr(\n            opts, \"sleep_interval_subtitles\", None),\n        \"external_downloader\": opts.external_downloader,\n        \"playlist_items\": opts.playlist_items,\n        \"xattr_set_filesize\": getattr(opts, \"xattr_set_filesize\", None),\n        \"match_filter\": match_filter,\n        \"no_color\": getattr(opts, \"no_color\", None),\n        \"ffmpeg_location\": opts.ffmpeg_location,\n        \"hls_prefer_native\": opts.hls_prefer_native,\n        \"hls_use_mpegts\": opts.hls_use_mpegts,\n        \"hls_split_discontinuity\": getattr(\n            opts, \"hls_split_discontinuity\", None),\n        \"external_downloader_args\": opts.external_downloader_args,\n        \"postprocessor_args\": opts.postprocessor_args,\n        \"cn_verification_proxy\": getattr(opts, \"cn_verification_proxy\", None),\n        \"geo_verification_proxy\": opts.geo_verification_proxy,\n        \"geo_bypass\": getattr(\n            opts, \"geo_bypass\", \"default\"),\n        \"geo_bypass_country\": getattr(\n            opts, \"geo_bypass_country\", None),\n        \"geo_bypass_ip_block\": getattr(\n            opts, \"geo_bypass_ip_block\", None),\n        \"compat_opts\": compat_opts,\n    }\n\n\ndef parse_retries(retries, name=\"\"):\n    if retries in (\"inf\", \"infinite\"):\n        return float(\"inf\")\n    return int(retries)\n\n\ndef legacy_postprocessors(opts, module, ytdlp, compat_opts):\n    postprocessors = []\n\n    sponsorblock_query = opts.sponsorblock_mark | opts.sponsorblock_remove\n    if opts.metafromtitle:\n        postprocessors.append({\n            \"key\": \"MetadataFromTitle\",\n            \"titleformat\": opts.metafromtitle,\n        })\n    if getattr(opts, \"add_postprocessors\", None):\n        postprocessors += list(opts.add_postprocessors)\n    if sponsorblock_query:\n        postprocessors.append({\n            \"key\": \"SponsorBlock\",\n            \"categories\": sponsorblock_query,\n            \"api\": opts.sponsorblock_api,\n            \"when\": \"pre_process\",\n        })\n    if opts.parse_metadata:\n        postprocessors.append({\n            \"key\": \"MetadataParser\",\n            \"actions\": opts.parse_metadata,\n            \"when\": \"pre_process\",\n        })\n    if opts.convertsubtitles:\n        pp = {\"key\": \"FFmpegSubtitlesConvertor\",\n              \"format\": opts.convertsubtitles}\n        if ytdlp:\n            pp[\"when\"] = \"before_dl\"\n        postprocessors.append(pp)\n    if getattr(opts, \"convertthumbnails\", None):\n        postprocessors.append({\n            \"key\": \"FFmpegThumbnailsConvertor\",\n            \"format\": opts.convertthumbnails,\n            \"when\": \"before_dl\",\n        })\n    if getattr(opts, \"exec_before_dl_cmd\", None):\n        postprocessors.append({\n            \"key\": \"Exec\",\n            \"exec_cmd\": opts.exec_before_dl_cmd,\n            \"when\": \"before_dl\",\n        })\n    if opts.extractaudio:\n        postprocessors.append({\n            \"key\": \"FFmpegExtractAudio\",\n            \"preferredcodec\": opts.audioformat,\n            \"preferredquality\": opts.audioquality,\n            \"nopostoverwrites\": opts.nopostoverwrites,\n        })\n    if getattr(opts, \"remuxvideo\", None):\n        postprocessors.append({\n            \"key\": \"FFmpegVideoRemuxer\",\n            \"preferedformat\": opts.remuxvideo,\n        })\n    if opts.recodevideo:\n        postprocessors.append({\n            \"key\": \"FFmpegVideoConvertor\",\n            \"preferedformat\": opts.recodevideo,\n        })\n    if opts.embedsubtitles:\n        pp = {\"key\": \"FFmpegEmbedSubtitle\"}\n        if ytdlp:\n            pp[\"already_have_subtitle\"] = (\n                opts.writesubtitles and \"no-keep-subs\" not in compat_opts)\n        postprocessors.append(pp)\n        if not opts.writeautomaticsub and \"no-keep-subs\" not in compat_opts:\n            opts.writesubtitles = True\n    if opts.allsubtitles and not opts.writeautomaticsub:\n        opts.writesubtitles = True\n    remove_chapters_patterns, remove_ranges = [], []\n    for regex in opts.remove_chapters:\n        if regex.startswith(\"*\"):\n            dur = list(map(module.parse_duration, regex[1:].split(\"-\")))\n            if len(dur) == 2 and all(t is not None for t in dur):\n                remove_ranges.append(tuple(dur))\n                continue\n        remove_chapters_patterns.append(text.re(regex))\n    if opts.remove_chapters or sponsorblock_query:\n        postprocessors.append({\n            \"key\": \"ModifyChapters\",\n            \"remove_chapters_patterns\": remove_chapters_patterns,\n            \"remove_sponsor_segments\": opts.sponsorblock_remove,\n            \"remove_ranges\": remove_ranges,\n            \"sponsorblock_chapter_title\": opts.sponsorblock_chapter_title,\n            \"force_keyframes\": opts.force_keyframes_at_cuts,\n        })\n    addchapters = getattr(opts, \"addchapters\", None)\n    embed_infojson = getattr(opts, \"embed_infojson\", None)\n    if opts.addmetadata or addchapters or embed_infojson:\n        pp = {\"key\": \"FFmpegMetadata\"}\n        if ytdlp:\n            if embed_infojson is None:\n                embed_infojson = \"if_exists\"\n            pp[\"add_metadata\"] = opts.addmetadata\n            pp[\"add_chapters\"] = addchapters\n            pp[\"add_infojson\"] = embed_infojson\n\n        postprocessors.append(pp)\n    if getattr(opts, \"sponskrub\", False) is not False:\n        postprocessors.append({\n            \"key\": \"SponSkrub\",\n            \"path\": opts.sponskrub_path,\n            \"args\": opts.sponskrub_args,\n            \"cut\": opts.sponskrub_cut,\n            \"force\": opts.sponskrub_force,\n            \"ignoreerror\": opts.sponskrub is None,\n            \"_from_cli\": True,\n        })\n    if opts.embedthumbnail:\n        already_have_thumbnail = (opts.writethumbnail or\n                                  getattr(opts, \"write_all_thumbnails\", False))\n        postprocessors.append({\n            \"key\": \"EmbedThumbnail\",\n            \"already_have_thumbnail\": already_have_thumbnail,\n        })\n        if not already_have_thumbnail:\n            opts.writethumbnail = True\n            if isinstance(opts.outtmpl, dict):\n                opts.outtmpl[\"pl_thumbnail\"] = \"\"\n    if getattr(opts, \"split_chapters\", None):\n        postprocessors.append({\n            \"key\": \"FFmpegSplitChapters\",\n            \"force_keyframes\": opts.force_keyframes_at_cuts,\n        })\n    if opts.xattrs:\n        postprocessors.append({\"key\": \"XAttrMetadata\"})\n    if opts.exec_cmd:\n        postprocessors.append({\n            \"key\": \"Exec\",\n            \"exec_cmd\": opts.exec_cmd,\n            \"when\": \"after_move\",\n        })\n\n    return postprocessors\n"
  },
  {
    "path": "pyproject.toml",
    "content": "[build-system]\nrequires = [\"setuptools\"]\nbuild-backend = \"setuptools.build_meta\"\n"
  },
  {
    "path": "requirements.txt",
    "content": "requests>=2.11.0\n"
  },
  {
    "path": "scripts/build_testresult_db.py",
    "content": "#!/usr/bin/env python3\n# -*- coding: utf-8 -*-\n\n\"\"\"Collect results of extractor unit tests\"\"\"\n\nimport sys\nimport os.path\nimport datetime\n\nimport util\nfrom gallery_dl import extractor, job, config\nfrom test.test_results import setup_test_config\n\n\n# filter test cases\n\ntests = [\n    (idx, extr, url, result)\n\n    for extr in extractor.extractors()\n    if hasattr(extr, \"test\") and extr.test\n    if len(sys.argv) <= 1 or extr.category in sys.argv\n\n    for idx, (url, result) in enumerate(extr._get_tests())\n    if result\n]\n\n\n# setup target directory\n\npath = util.path(\"archive\", \"testdb\", str(datetime.date.today()))\nos.makedirs(path, exist_ok=True)\n\n\nfor idx, extr, url, result in tests:\n\n    # filename\n    name = \"{}-{}-{}.json\".format(extr.category, extr.subcategory, idx)\n    print(name)\n\n    # config values\n    setup_test_config()\n\n    if \"options\" in result:\n        for key, value in result[\"options\"]:\n            key = key.split(\".\")\n            config.set(key[:-1], key[-1], value)\n    if \"range\" in result:\n        config.set((), \"image-range\"  , result[\"range\"])\n        config.set((), \"chapter-range\", result[\"range\"])\n\n    # write test data\n    try:\n        with util.open(os.path.join(path, name), \"w\") as outfile:\n            job.DataJob(url, file=outfile, ensure_ascii=False).run()\n    except KeyboardInterrupt:\n        sys.exit()\n"
  },
  {
    "path": "scripts/completion_bash.py",
    "content": "#!/usr/bin/env python3\n# -*- coding: utf-8 -*-\n\n# Copyright 2019 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Generate bash completion script from gallery-dl's argument parser\"\"\"\n\nimport util\nfrom gallery_dl import option\n\n\nTEMPLATE = \"\"\"_gallery_dl()\n{\n    local cur prev\n    COMPREPLY=()\n    cur=\"${COMP_WORDS[COMP_CWORD]}\"\n    prev=\"${COMP_WORDS[COMP_CWORD-1]}\"\n\n    if [[ \"${prev}\" =~ ^(%(fileopts)s)$ ]]; then\n        COMPREPLY=( $(compgen -f -- \"${cur}\") )\n    elif [[ \"${prev}\" =~ ^(%(diropts)s)$ ]]; then\n        COMPREPLY=( $(compgen -d -- \"${cur}\") )\n    else\n        COMPREPLY=( $(compgen -W \"%(opts)s\" -- \"${cur}\") )\n    fi\n}\n\ncomplete -F _gallery_dl gallery-dl\n\"\"\"\n\nopts = []\ndiropts = []\nfileopts = []\nfor action in option.build_parser()._actions:\n\n    if action.metavar in (\"DEST\",):\n        diropts.extend(action.option_strings)\n\n    elif action.metavar in (\"FILE\", \"CFG\"):\n        fileopts.extend(action.option_strings)\n\n    for opt in action.option_strings:\n        if opt.startswith(\"--\"):\n            opts.append(opt)\n\nPATH = util.path(\"data/completion/gallery-dl\")\nwith util.lazy(PATH) as fp:\n    fp.write(TEMPLATE % {\n        \"opts\"    : \" \".join(opts),\n        \"diropts\" : \"|\".join(diropts),\n        \"fileopts\": \"|\".join(fileopts),\n    })\n"
  },
  {
    "path": "scripts/completion_fish.py",
    "content": "#!/usr/bin/env python3\n# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Generate fish completion script from gallery-dl's argument parser\"\"\"\n\nimport util\nfrom gallery_dl import option\n\n\nTEMPLATE = \"\"\"complete -c gallery-dl -x\n%(opts)s\n\"\"\"\n\nopts = []\nfor action in option.build_parser()._actions:\n    if not action.option_strings:\n        continue\n\n    opt = \"complete -c gallery-dl\"\n\n    if action.metavar:\n        if action.metavar == \"FILE\":\n            opt += \" -r -F\"\n        elif action.metavar == \"PATH\":\n            opt += \" -x -a '(__fish_complete_directories)'\"\n        else:\n            opt += \" -x\"\n\n    for optstr in action.option_strings:\n        if optstr.startswith(\"--\"):\n            opt += \" -l '\" + optstr[2:] + \"'\"\n        else:\n            opt += \" -s '\" + optstr[1:] + \"'\"\n\n    opt += \" -d '\" + action.help.replace(\"'\", '\"') + \"'\"\n\n    opts.append(opt)\n\nPATH = util.path(\"data/completion/gallery-dl.fish\")\nwith util.lazy(PATH) as fp:\n    fp.write(TEMPLATE % {\"opts\": \"\\n\".join(opts)})\n"
  },
  {
    "path": "scripts/completion_zsh.py",
    "content": "#!/usr/bin/env python3\n# -*- coding: utf-8 -*-\n\n# Copyright 2020 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Generate zsh completion script from gallery-dl's argument parser\"\"\"\n\nimport util\nimport argparse\nfrom gallery_dl import option\n\nTEMPLATE = \"\"\"#compdef gallery-dl\n\nlocal curcontext=\"$curcontext\"\ntypeset -A opt_args\n\nlocal rc=1\n_arguments -s -S \\\\\n%(opts)s && rc=0\n\nreturn rc\n\"\"\"\n\nTR = str.maketrans({\n    \"'\": \"'\\\\''\",\n    \"[\": \"\\\\[\",\n    \"]\": \"\\\\]\",\n})\n\n\nopts = []\nfor action in option.build_parser()._actions:\n\n    if not action.option_strings or action.help == argparse.SUPPRESS:\n        continue\n    elif len(action.option_strings) == 1:\n        opt = action.option_strings[0]\n    else:\n        opt = \"{\" + \",\".join(action.option_strings) + \"}\"\n\n    opt += \"'[\" + action.help.translate(TR) + \"]'\"\n\n    if action.metavar:\n        opt += \":'<\" + action.metavar.lower() + \">'\"\n        if action.metavar in (\"FILE\", \"CFG\", \"DEST\"):\n            opt += \":_files\"\n\n    opts.append(opt)\n\n\nPATH = util.path(\"data/completion/_gallery-dl\")\nwith util.lazy(PATH) as fp:\n    fp.write(TEMPLATE % {\"opts\": \" \\\\\\n\".join(opts)})\n"
  },
  {
    "path": "scripts/docs_compare.py",
    "content": "#!/usr/bin/env python3\n# -*- coding: utf-8 -*-\n\n\"\"\"Find missing settings in docs/gallery.conf\"\"\"\n\nimport json\nimport util\nimport sys\nimport re\n\nfrom gallery_dl import text, extractor\n\n\ndef read(fname):\n    path = util.path(\"docs\", fname)\n    try:\n        with util.open(path) as fp:\n            return fp.read()\n    except Exception as exc:\n        sys.exit(\"Unable to read {} ({}: {})\".format(\n            path, exc.__class__.__name__, exc))\n\n\nDOCS = read(\"configuration.rst\")\nCONF = json.loads(read(\"gallery-dl.conf\"))\nEXTRS = list(extractor._list_classes())\n\n\ndef opts_general(type):\n    # general opts\n    opts = re.findall(\n        r\"(?m)^{}\\.\\*\\.([\\w-]+)\".format(type),\n        DOCS)\n    extr = CONF[type]\n\n    return {\n        type + \".*.\" + opt\n        for opt in opts\n        if opt not in extr\n    }\n\n\ndef opts_category(type):\n    # site opts\n    opts = re.findall(\n        r\"(?m)^{}\\.(?!\\*)([\\w-]+)\\.([\\w-]+)(?:\\.([\\w-]+))?\".format(type),\n        DOCS)\n    extr = CONF[type]\n\n    result = set()\n    for category, sub, opt in opts:\n        if category[0] == \"[\":\n            category = category[1:-1]\n        category_opts = extr.get(category)\n        if not category_opts:\n            result.add(category + \".*\")\n            continue\n\n        if not opt:\n            opt = sub\n            sub = None\n        elif sub:\n            category_opts = category_opts.get(sub) or ()\n        if opt not in category_opts:\n            if sub:\n                opt = sub + \".\" + opt\n            result.add(category + \".\" + opt)\n    return result\n\n\ndef userpass():\n    block = text.extr(DOCS, \"extractor.*.username\", \"extractor.*.\")\n    extr = CONF[\"extractor\"]\n\n    result = set()\n    for category in text.extract_iter(block, \"* ``\", \"``\"):\n        opts = extr[category]\n        if \"username\" not in opts or \"password\" not in opts:\n            result.add(category)\n    return result\n\n\ndef sleeprequest():\n    block = text.extr(DOCS, \"extractor.*.sleep-request\", \"extractor.*.\")\n\n    sleep = {}\n    for line in block.splitlines():\n        line = line.strip()\n        if not line:\n            continue\n\n        if line[0] == \"*\":\n            value = line.strip('* `\"')\n            if value == \"0\":\n                break\n        elif line[0] == \"`\":\n            cat, _, sub = line.strip(\"`,\").partition(\":\")\n            sleep[cat.strip(\"[]\")] = value\n\n    result = {}\n    for extr in EXTRS:\n        value = sleep.get(extr.category)\n        if value:\n            category = extr.category\n        else:\n            value = sleep.get(extr.basecategory)\n            if value:\n                category = extr.basecategory\n            else:\n                continue\n\n        min, _, max = value.partition(\"-\")\n        tup = (float(min), float(max)) if max else float(min)\n        if tup != extr.request_interval:\n            result[category] = extr.request_interval\n    return result\n\n\nwrite = sys.stdout.write\n\nopts = set()\nopts.update(opts_general(\"extractor\"))\nopts.update(opts_general(\"downloader\"))\nopts.update(opts_category(\"extractor\"))\nopts.update(opts_category(\"downloader\"))\nif opts:\n    write(\"Missing Options:\\n\")\n    for opt in sorted(opts):\n        write(\"    {}\\n\".format(opt))\n    write(\"\\n\")\n\n\ncategories = userpass()\nif categories:\n    write(\"Missing username & password:\\n\")\n    for cat in sorted(categories):\n        write(\"    {}\\n\".format(cat))\n    write(\"\\n\")\n\n\ncategories = sleeprequest()\nif categories:\n    write(\"Wrong sleep_request:\\n\")\n    for cat, value in sorted(categories.items()):\n        write(\"    {}: {}\\n\".format(cat, value))\n    write(\"\\n\")\n"
  },
  {
    "path": "scripts/generate_test_result.py",
    "content": "#!/usr/bin/env python3\n# -*- coding: utf-8 -*-\n\n# Copyright 2025-2026 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Generate test result data\"\"\"\n\nimport logging\nimport argparse\nimport json\nimport util\nfrom pyprint import pyprint\nfrom gallery_dl import extractor, job, config, exception\n\nLOG = logging.getLogger(\"gen-test\")\n\n\nclass LoggingCapture(logging.Handler):\n\n    def __init__(self, args):\n        logging.Handler.__init__(self)\n\n        if args.logging:\n            self.records = []\n            self.output = []\n            self.level = logging.INFO\n        else:\n            self.records = self.output = None\n\n    def __enter__(self):\n        if self.records is None:\n            return\n\n        logger = logging.getLogger(None)\n        logger.handlers.append(self)\n        return self\n\n    def __exit__(self, exc_type, exc_value, traceback):\n        pass\n\n    def flush(self):\n        pass\n\n    def emit(self, record):\n        self.records.append(record)\n        self.output.append(self.format(record))\n\n\ndef generate_test_result(args):\n    head = generate_head(args)\n\n    if args.only_matching:\n        opts = meta = None\n    else:\n        if args.auth:\n            cfg = util.path(\"archive\", \"config.json\")\n            config.load((cfg,), strict=True)\n        if args.options:\n            args.options_parsed = options = {}\n            for opt in args.options:\n                key, _, value = opt.partition(\"=\")\n                try:\n                    value = json.loads(value)\n                except ValueError:\n                    pass\n                options[key] = value\n                config.set((), key, value)\n        if args.range:\n            config.set((), \"image-range\"  , args.range)\n            config.set((), \"chapter-range\", args.range)\n\n        djob = job.DataJob(args.extr, file=None)\n        djob.filter = dict.copy\n        with LoggingCapture(args) as log_info:\n            djob.run()\n\n        opts = generate_opts(\n            args, djob.data_urls, djob.data_meta, djob.exception, log_info)\n        ool = (len(opts) > 1 or \"#options\" in opts)\n\n        if args.metadata:\n            meta = generate_meta(args, djob.data_meta)\n        else:\n            meta = None\n\n    result = pyprint(head, oneline=False, lmin=9)\n    if opts:\n        result = result[:-2] + pyprint(opts, oneline=ool, lmin=9)[1:]\n    if meta:\n        result = result[:-1] + pyprint(meta, sort=sort_key)[1:]\n    return result + \",\\n\\n\"\n\n\ndef generate_head(args):\n    head = {}\n    cls = args.cls\n\n    head[\"#url\"] = args.extr.url\n    if args.comment is not None:\n        head[\"#comment\"] = args.comment\n    if args.base or args.cat != cls.category or args.sub != cls.subcategory:\n        head[\"#category\"] = (args.base, args.cat, args.sub)\n    head[\"#class\"] = args.cls\n\n    return head\n\n\ndef generate_opts(args, urls, meta=(), exc=None, log=None):\n    opts = {}\n\n    if args.auth is not None:\n        opts[\"#auth\"] = args.auth\n\n    if args.options:\n        opts[\"#options\"] = args.options_parsed\n\n    if args.range:\n        opts[\"#range\"] = args.range\n\n    if exc:\n        if isinstance(exc, exception.GalleryDLException):\n            opts[\"#exception\"] = exc.__class__.__name__\n        else:\n            opts[\"#exception\"] = exc.__class__\n    elif not urls:\n        opts[\"#count\"] = 0\n    elif len(urls) == 1:\n        opts[\"#results\"] = urls[0]\n    elif len(urls) < args.limit_urls:\n        opts[\"#results\"] = tuple(urls)\n    else:\n        if meta and (extr := meta[0].get(\"_extractor\")):\n            name = extr.__module__.rpartition(\".\")[2]\n            if name[0].isdecimal():\n                name = f\"_{name}\"\n            opts[\"#pattern\"] = f\"lit:{name}.{extr.__name__}.pattern\"\n        else:\n            import re\n            opts[\"#pattern\"] = re.escape(urls[0])\n        if \"#range\" in opts:\n            opts[\"#range\"] = opts.pop(\"#range\")\n        opts[\"#count\"] = len(urls)\n\n    if log is not None:\n        if not log.records:\n            opts[\"#log\"] = ()\n        elif len(log.records) == 1:\n            opts[\"#log\"] = log.output[0]\n        else:\n            opts[\"#log\"] = log.output\n\n    return opts\n\n\ndef generate_meta(args, data):\n    if not data:\n        return {}\n\n    for kwdict in data:\n        delete = [\"category\", \"subcategory\"]\n        for key in kwdict:\n            if not key or key[0] == \"_\":\n                delete.append(key)\n        for key in delete:\n            del kwdict[key]\n\n    return data[0]\n\n\ndef sort_key(key, value):\n    if not value:\n        return 0\n    if isinstance(value, str) and \"\\n\" in value:\n        return 7000\n    if isinstance(value, list) and not small(value):\n        return 8000\n    if isinstance(value, dict) and not small(value):\n        return 9000\n    return 0\n\n\ndef small(obj):\n    if not obj:\n        return True\n    if isinstance(obj, list):\n        return False if len(obj) > 1 else small(obj[0])\n    if isinstance(obj, dict):\n        return False if len(obj) > 1 else small(next(iter(obj.values())))\n    return True\n\n\ndef insert_test_result(args, result, lines):\n    idx_block = None\n    flag = False\n\n    for idx, line in enumerate(lines):\n        line = line.lstrip()\n        if not line:\n            continue\n        elif line[0] == \"{\":\n            idx_block = idx\n        elif line.startswith('\"#class\"'):\n            if args.cls.__name__ in line:\n                flag = True\n            elif flag:\n                flag = None\n                break\n\n    if idx_block is None or flag is not None:\n        lines.insert(-1, result)\n    else:\n        lines.insert(idx_block, result)\n\n\ndef parse_args(args=None):\n    parser = argparse.ArgumentParser(args)\n    parser.add_argument(\"-a\", \"--auth\", action=\"store_true\", default=None)\n    parser.add_argument(\"-A\", \"--no-auth\", action=\"store_false\", dest=\"auth\")\n    parser.add_argument(\"-c\", \"--comment\", default=None)\n    parser.add_argument(\"-C\", dest=\"comment\", action=\"store_const\", const=\"\")\n    parser.add_argument(\"-g\", \"--git\", action=\"store_true\")\n    parser.add_argument(\"-l\", \"--logging\", action=\"store_true\")\n    parser.add_argument(\"-L\", \"--limit_urls\", type=int, default=10)\n    parser.add_argument(\"-m\", \"--metadata\", action=\"store_true\")\n    parser.add_argument(\"-o\", \"--option\", dest=\"options\", action=\"append\")\n    parser.add_argument(\"-O\", \"--only-matching\", action=\"store_true\")\n    parser.add_argument(\"-r\", \"--range\")\n    parser.add_argument(\"URL\")\n\n    return parser.parse_args()\n\n\ndef main():\n    args = parse_args()\n    args.url = args.URL\n\n    extr = extractor.find(args.url)\n    if extr is None:\n        LOG.error(\"Unsupported URL '%s'\", args.url)\n        raise SystemExit(1)\n\n    args.extr = extr\n    args.cls = extr.__class__\n    args.cat = extr.category\n    args.sub = extr.subcategory\n    args.base = extr.basecategory\n\n    LOG.info(\"Collecting data for '%s'\", args.url)\n    result = generate_test_result(args)\n\n    path = util.path(\"test\", \"results\", f\"{args.cat}.py\")\n    path_tr = util.trim(path)\n    LOG.info(\"Writing '%s' results to '%s'\", args.url, path_tr)\n    with util.lines(path) as lines:\n        insert_test_result(args, result, lines)\n\n    if args.git:\n        LOG.info(\"git add %s\", path_tr)\n        util.git(\"add\", \"--\", path_tr)\n\n\nif __name__ == \"__main__\":\n    logging.basicConfig(\n        level=logging.DEBUG,\n        format=\"[%(levelname)s] %(message)s\",\n    )\n    main()\n"
  },
  {
    "path": "scripts/hook-gallery_dl.py",
    "content": "# -*- coding: utf-8 -*-\n\nfrom gallery_dl import extractor, downloader, postprocessor\nimport os\n\nhiddenimports = [\n    f\"{package.__name__}.{module}\"\n    for package in (extractor, downloader, postprocessor)\n    for module in package.modules\n]\n\nbase = extractor.__name__ + \".utils.\"\npath = os.path.join(extractor.__path__[0], \"utils\")\nhiddenimports.extend(\n    base + file[:-3]\n    for file in os.listdir(path)\n    if not file.startswith(\"__\")\n)\n\nhiddenimports.append(\"yt_dlp\")\n\nmypyc = \"81d243bd2c585b0f4821__mypyc\"\ntry:\n    from importlib.metadata import files\n    for file in files(\"charset_normalizer\"):\n        if \"__mypyc\" in file.name:\n            mypyc = file.name.partition(\".\")[0]\n            break\nexcept Exception:\n    pass\nhiddenimports.append(mypyc)\n"
  },
  {
    "path": "scripts/init.py",
    "content": "#!/usr/bin/env python3\n# -*- coding: utf-8 -*-\n\n# Copyright 2025 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Initialize extractor modules\"\"\"\n\nimport re\nimport logging\nimport argparse\nimport datetime as dt\nimport util  # noqa\n\nfrom gallery_dl import text\n\nLOG = logging.getLogger(\"init\")\nNONE = {}\nENCODING = \"\"\"\\\n# -*- coding: utf-8 -*-\n\"\"\"\nLICENSE = \"\"\"\\\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\"\"\"\n\n\ndef init_extractor(args):\n    category = args.category\n\n    files = []\n    if args.init_module:\n        files.append((util.path(\"gallery_dl\", \"extractor\", f\"{category}.py\"),\n                      generate_module, False))\n        files.append((util.path(\"gallery_dl\", \"extractor\", \"__init__.py\"),\n                      insert_into_modules_list, True))\n    if args.init_test:\n        files.append((util.path(\"test\", \"results\", f\"{category}.py\"),\n                      generate_test, False))\n    if args.site_name:\n        files.append((util.path(\"scripts\", \"supportedsites.py\"),\n                      insert_into_supportedsites, True))\n\n    for path, func, lines in files:\n        LOG.info(util.trim(path))\n\n        if lines:\n            with util.lines(path) as lines:\n                if not func(args, lines):\n                    LOG.warning(\"'%s' already present\", category)\n        else:\n            try:\n                with util.open(path, args.open_mode) as fp:\n                    fp.write(func(args))\n            except FileExistsError:\n                LOG.warning(\"File already present\")\n            except Exception as exc:\n                LOG.error(\"%s: %s\", exc.__class__.__name__, exc, exc_info=exc)\n\n        if args.git:\n            util.git(\"add\", path)\n\n\n###############################################################################\n# Extractor ###################################################################\n\ndef generate_module(args):\n    type = args.type\n    if type == \"manga\":\n        generate_extractors = generate_extractors_manga\n    elif type == \"user\":\n        generate_extractors = generate_extractors_user\n    else:\n        generate_extractors = generate_extractors_basic\n\n    if copyright := args.copyright:\n        copyright = f\"\\n# Copyright {dt.date.today().year} {copyright}\\n#\"\n\n    return f'''\\\n{ENCODING}{copyright}\n{LICENSE}\n\"\"\"Extractors for {args.root}/\"\"\"\n\n{generate_extractors(args)}\\\n'''\n\n\ndef generate_extractors_basic(args):\n    cat = args.category\n    ccat = cat.capitalize()\n\n    result = f'''\\\nfrom .common import Extractor, Message\nfrom .. import text\n\n{build_base_pattern(args)}\n\nclass {ccat}Extractor(Extractor):\n    \"\"\"Base class for {cat} extractors\"\"\"\n    category = \"{cat}\"\n    root = \"{args.root}\"\n'''\n\n    for subcat in args.subcategories:\n        subcat = subcat.lower()\n        result = f'''{result}\n\nclass {ccat}{subcat.capitalize()}Extractor({ccat}Extractor):\n    subcategory = \"{subcat}\"\n    pattern = BASE_PATTERN + r\"/PATH\"\n    example = \"{args.root}/PATH\"\n\n    def items(self):\n        pass\n'''\n\n    return result\n\n\ndef generate_extractors_manga(args):\n    cat = args.category\n    ccat = cat.capitalize()\n\n    return f'''\\\nfrom .common import ChapterExtractor, MangaExtractor\nfrom .. import text\n\n{build_base_pattern(args)}\n\nclass {ccat}Base():\n    \"\"\"Base class for {cat} extractors\"\"\"\n    category = \"{cat}\"\n    root = \"{args.root}\"\n\n    def _manga_info(self, slug):\n        return {{}}\n\n\nclass {ccat}ChapterExtractor({ccat}Base, ChapterExtractor):\n    \"\"\"Extractor for {cat} manga chapters\"\"\"\n    pattern = BASE_PATTERN + r\"/PATH\"\n    example = \"{args.root}/PATH\"\n\n    def __init__(self, match):\n        url = f\"{{self.root}}/PATH\"\n        ChapterExtractor.__init__(self, match, url)\n\n    def metadata(self, page):\n        chapter, sep, minor = chapter.partition(\".\")\n\n        return {{\n            **self.cache(self._manga_info, manga_id),\n            \"manga\"   : text.unescape(manga),\n            \"manga_id\": text.parse_int(manga_id),\n            \"title\"   : \"\",\n            \"volume\"  : text.parse_int(volume),\n            \"chapter\" : text.parse_int(chapter),\n            \"chapter_minor\": sep + minor,\n            \"chapter_id\"   : text.parse_int(chapter_id),\n            \"lang\"    : \"en\",\n            \"language\": \"English\",\n        }}\n\n    def images(self, page):\n        return [\n            (url, None)\n            for url in text.extract_iter(page, \"\", \"\")\n        ]\n\n\nclass {ccat}MangaExtractor({ccat}Base, MangaExtractor):\n    \"\"\"Extractor for {cat} manga\"\"\"\n    chapterclass = {ccat}ChapterExtractor\n    pattern = BASE_PATTERN + r\"/PATH\"\n    example = \"{args.root}/PATH\"\n\n    def __init__(self, match):\n        url = f\"{{self.root}}/PATH\"\n        MangaExtractor.__init__(self, match, url)\n\n    def chapters(self, page):\n        results = []\n\n        while True:\n            results.append((url, None))\n\n        return results\n'''\n\n\ndef generate_extractors_user(args):\n    cat = args.category\n    ccat = cat.capitalize()\n\n    return f'''\\\nfrom .common import Extractor, Message, Dispatch\nfrom .. import text\n\n{build_base_pattern(args)}\nUSER_PATTERN = BASE_PATTERN + r\"/([^/?#]+)\"\n\nclass {ccat}Extractor(Extractor):\n    \"\"\"Base class for {cat} extractors\"\"\"\n    category = \"{cat}\"\n    root = \"{args.root}\"\n\n\nclass {ccat}UserExtractor(Dispatch, {ccat}Extractor)\n    \"\"\"Extractor for {cat} user profiles\"\"\"\n    pattern = USER_PATTERN + r\"/?(?:$|\\\\?|#)\"\n    example = \"{args.root}/USER/\"\n\n    def items(self):\n        base = self.root + \"/\"\n        return self._dispatch_extractors((\n            ({ccat}InfoExtractor, base + \"info\"),\n        ), (\"posts\",))\n'''\n\n\ndef build_base_pattern(args):\n    domain = args.domain\n    if domain.count(\".\") > 1:\n        subdomain, domain, tld = domain.rsplit(\".\", 2)\n        domain = f\"{domain}.{tld}\"\n        if subdomain == \"www\":\n            subdomain = \"(?:www\\\\.)?\"\n        else:\n            subdomain = re.escape(subdomain + \".\")\n    else:\n        subdomain = \"(?:www\\\\.)?\"\n\n    return f\"\"\"\\\nBASE_PATTERN = r\"(?:https?://)?{subdomain}{re.escape(domain)}\"\n\"\"\"\n\n\n###############################################################################\n# Test Results ################################################################\n\ndef generate_test(args):\n    category = args.category\n\n    if category[0].isdecimal():\n        import_stmt = f\"\"\"\\\ngallery_dl = __import__(\"gallery_dl.extractor.{category}\")\n_{category} = getattr(gallery_dl.extractor, \"{category}\")\n\"\"\"\n    else:\n        import_stmt = f\"\"\"\\\nfrom gallery_dl.extractor import {category}\n\"\"\"\n\n    return f\"\"\"\\\n{ENCODING}\n{LICENSE}\n{import_stmt}\n\n__tests__ = (\n)\n\"\"\"\n\n\n###############################################################################\n# Modules List ################################################################\n\ndef insert_into_modules_list(args, lines):\n    category = args.category\n\n    module_name = f'    \"{category}\",\\n'\n    if module_name in lines:\n        return False\n\n    compare = False\n    for idx, line in enumerate(lines):\n        if compare:\n            cat = text.extr(line, '\"', '\"')\n            if cat == category:\n                return False\n            if cat > category or cat == \"booru\":\n                break\n        elif line.startswith(\"modules = \"):\n            compare = True\n\n    lines.insert(idx, module_name)\n    return True\n\n\n###############################################################################\n# Supported Sites #############################################################\n\ndef insert_into_supportedsites(args, lines):\n    category = args.category\n\n    compare = False\n    for idx, line in enumerate(lines):\n        if compare:\n            cat = text.extr(line, '\"', '\"')\n            if cat == category:\n                return False\n            if cat > category:\n                break\n        elif line.startswith(\"CATEGORY_MAP = \"):\n            compare = True\n\n    ws = \" \" * max(15 - len(category), 0)\n    line = f'''    \"{category}\"{ws}: \"{args.site_name}\",\\n'''\n    lines.insert(idx, line)\n    return True\n\n\n###############################################################################\n# Command-Line Options ########################################################\n\ndef parse_args(args=None):\n    parser = argparse.ArgumentParser(args)\n\n    parser.add_argument(\n        \"-s\", \"--subcategory\",\n        dest=\"subcategories\", metavar=\"SUBCaT\", action=\"append\", default=[])\n    parser.add_argument(\n        \"-n\", \"--name\",\n        dest=\"site_name\", metavar=\"TITLE\")\n    parser.add_argument(\n        \"-c\", \"--copyright\",\n        dest=\"copyright\", metavar=\"NAME\", default=\"\")\n    parser.add_argument(\n        \"-C\",\n        dest=\"copyright\", action=\"store_const\", const=\"Mike Fährmann\")\n    parser.add_argument(\n        \"-F\", \"--force\",\n        dest=\"open_mode\", action=\"store_const\", const=\"w\", default=\"x\")\n    parser.add_argument(\n        \"-g\", \"--git\",\n        dest=\"git\", action=\"store_true\")\n    parser.add_argument(\n        \"-M\", \"--no-module\",\n        dest=\"init_module\", action=\"store_false\")\n    parser.add_argument(\n        \"-T\", \"--no-test\",\n        dest=\"init_test\", action=\"store_false\")\n    parser.add_argument(\n        \"-t\", \"--type\",\n        dest=\"type\", metavar=\"TYPE\")\n    parser.add_argument(\n        \"--manga\",\n        dest=\"type\", action=\"store_const\", const=\"manga\")\n    parser.add_argument(\n        \"--base\",\n        dest=\"type\", action=\"store_const\", const=\"base\")\n    parser.add_argument(\n        \"--user\",\n        dest=\"type\", action=\"store_const\", const=\"user\")\n\n    parser.add_argument(\"category\")\n    parser.add_argument(\"root\", nargs=\"?\")\n\n    args = parser.parse_args()\n    args.category = args.category.lower()\n\n    if \"://\" in args.category:\n        base = args.category.split(\"/\", 3)\n        if not args.root:\n            args.root = \"/\".join(base[:3])\n        args.category = re.sub(r\"\\W+\", \"\", base[2].split(\".\")[-2])\n\n    if root := args.root:\n        if \"://\" in root:\n            root = root.rstrip(\"/\")\n            domain = root[root.find(\"://\")+3:]\n        else:\n            root = root.strip(\":/\")\n            domain = root\n            root = f\"https://{root}\"\n\n        if domain.startswith(\"www.\"):\n            domain = domain[4:]\n\n        args.root = root\n        args.domain = domain\n    elif args.init_module:\n        parser.error(\"'root' URL required\")\n    else:\n        args.domain = \"\"\n\n    return args\n\n\ndef main():\n    args = parse_args()\n    init_extractor(args)\n\n\nif __name__ == \"__main__\":\n    logging.basicConfig(\n        level=logging.DEBUG,\n        format=\"[%(levelname)s] %(message)s\",\n    )\n    main()\n"
  },
  {
    "path": "scripts/man.py",
    "content": "#!/usr/bin/env python3\n# -*- coding: utf-8 -*-\n\n# Copyright 2019-2020 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Generate man pages\"\"\"\n\nimport re\nimport datetime\n\nimport util\nimport gallery_dl.option\nimport gallery_dl.version\n\n\ndef build_gallery_dl_1(path=None):\n\n    OPTS_FMT = \"\"\".TP\\n.B \"{}\" {}\\n{}\"\"\"\n\n    TEMPLATE = r\"\"\"\n.TH \"GALLERY-DL\" \"1\" \"%(date)s\" \"%(version)s\" \"gallery-dl Manual\"\n.\\\" disable hyphenation\n.nh\n\n.SH NAME\ngallery-dl \\- download image-galleries and -collections\n\n.SH SYNOPSIS\n.B gallery-dl\n[OPTION]... URL...\n\n.SH DESCRIPTION\n.B gallery-dl\nis a command-line program to download image-galleries and -collections\nfrom several image hosting sites. It is a cross-platform tool\nwith many configuration options and powerful filenaming capabilities.\n\n.SH OPTIONS\n%(options)s\n\n.SH EXAMPLES\n.TP\ngallery-dl \\f[I]URL\\f[]\nDownload images from \\f[I]URL\\f[].\n.TP\ngallery-dl -g -u <username> -p <password> \\f[I]URL\\f[]\nPrint direct URLs from a site that requires authentication.\n.TP\ngallery-dl --filter 'type == \"ugoira\"' --range '2-4' \\f[I]URL\\f[]\nApply filter and range expressions. This will only download\nthe second, third, and fourth file where its type value is equal to \"ugoira\".\n.TP\ngallery-dl r:\\f[I]URL\\f[]\nScan \\f[I]URL\\f[] for other URLs and invoke \\f[B]gallery-dl\\f[] on them.\n.TP\ngallery-dl oauth:\\f[I]SITE\\-NAME\\f[]\nGain OAuth authentication tokens for\n.IR deviantart ,\n.IR flickr ,\n.IR reddit ,\n.IR smugmug \", and\"\n.IR tumblr .\n\n.SH FILES\n.TP\n.I /etc/gallery-dl.conf\nThe system wide configuration file.\n.TP\n.I ~/.config/gallery-dl/config.json\nPer user configuration file.\n.TP\n.I ~/.gallery-dl.conf\nAlternate per user configuration file.\n\n.SH BUGS\nhttps://github.com/mikf/gallery-dl/issues\n\n.SH AUTHORS\nMike Fährmann <mike_faehrmann@web.de>\n.br\nand https://github.com/mikf/gallery-dl/graphs/contributors\n\n.SH \"SEE ALSO\"\n.BR gallery-dl.conf (5)\n\"\"\"\n\n    options = []\n    for action in gallery_dl.option.build_parser()._actions:\n        if action.help.startswith(\"==\"):\n            continue\n        options.append(OPTS_FMT.format(\n            \", \".join(action.option_strings).replace(\"-\", r\"\\-\"),\n            r\"\\f[I]{}\\f[]\".format(action.metavar) if action.metavar else \"\",\n            action.help,\n        ))\n\n    if not path:\n        path = util.path(\"data/man/gallery-dl.1\")\n    with util.lazy(path) as fp:\n        fp.write(TEMPLATE.lstrip() % {\n            \"options\": \"\\n\".join(options),\n            \"version\": gallery_dl.version.__version__,\n            \"date\"   : datetime.datetime.now().strftime(\"%Y-%m-%d\"),\n        })\n\n\ndef build_gallery_dl_conf_5(path=None):\n\n    TEMPLATE = r\"\"\"\n.TH \"GALLERY-DL.CONF\" \"5\" \"%(date)s\" \"%(version)s\" \"gallery-dl Manual\"\n.\\\" disable hyphenation\n.nh\n.\\\" disable justification (adjust text to left margin only)\n.ad l\n\n.SH NAME\ngallery-dl.conf \\- gallery-dl configuration file\n\n.SH DESCRIPTION\ngallery-dl will search for configuration files in the following places\nevery time it is started, unless\n.B --ignore-config\nis specified:\n.PP\n.RS 4\n.nf\n.I /etc/gallery-dl.conf\n.I $HOME/.config/gallery-dl/config.json\n.I $HOME/.gallery-dl.conf\n.fi\n.RE\n.PP\nIt is also possible to specify additional configuration files with the\n.B -c/--config\ncommand-line option or to add further option values with\n.B -o/--option\nas <key>=<value> pairs,\n\nConfiguration files are JSON-based and therefore don't allow any ordinary\ncomments, but, since unused keys are simply ignored, it is possible to utilize\nthose as makeshift comments by settings their values to arbitrary strings.\n\n.SH EXAMPLE\n{\n.RS 4\n\"base-directory\": \"/tmp/\",\n.br\n\"extractor\": {\n.RS 4\n\"pixiv\": {\n.RS 4\n\"directory\": [\"Pixiv\", \"Works\", \"{user[id]}\"],\n.br\n\"filename\": \"{id}{num}.{extension}\",\n.br\n\"username\": \"foo\",\n.br\n\"password\": \"bar\"\n.RE\n},\n.br\n\"flickr\": {\n.RS 4\n\"_comment\": \"OAuth keys for account 'foobar'\",\n.br\n\"access-token\": \"0123456789-0123456789abcdef\",\n.br\n\"access-token-secret\": \"fedcba9876543210\"\n.RE\n}\n.RE\n},\n.br\n\"downloader\": {\n.RS 4\n\"retries\": 3,\n.br\n\"timeout\": 2.5\n.RE\n}\n.RE\n}\n\n%(options)s\n\n.SH BUGS\nhttps://github.com/mikf/gallery-dl/issues\n\n.SH AUTHORS\nMike Fährmann <mike_faehrmann@web.de>\n.br\nand https://github.com/mikf/gallery-dl/graphs/contributors\n\n.SH \"SEE ALSO\"\n.BR gallery-dl (1)\n\"\"\"\n\n    sections = parse_docs_configuration()\n    content = []\n\n    for sec_name, section in sections.items():\n        content.append(\".SH \" + sec_name.upper())\n\n        for opt_name, option in section.items():\n            content.append(\".SS \" + opt_name)\n\n            for field, text in option.items():\n                if field in (\"Type\", \"Default\"):\n                    content.append('.IP \"{}:\" {}'.format(field, len(field)+2))\n                    content.append(strip_rst(text))\n                else:\n                    content.append('.IP \"{}:\" 4'.format(field))\n                    content.append(strip_rst(text, field != \"Example\"))\n\n    if not path:\n        path = util.path(\"data/man/gallery-dl.conf.5\")\n    with util.lazy(path) as fp:\n        fp.write(TEMPLATE.lstrip() % {\n            \"options\": \"\\n\".join(content),\n            \"version\": gallery_dl.version.__version__,\n            \"date\"   : datetime.datetime.now().strftime(\"%Y-%m-%d\"),\n        })\n\n\ndef parse_docs_configuration():\n\n    path = util.path(\"docs\", \"configuration.rst\")\n    with util.open(path) as fp:\n        doc_lines = fp.readlines()\n\n    sections = {}\n    sec_name = None\n    options = None\n    opt_name = None\n    opt_desc = None\n    name = None\n    last = None\n    for line in doc_lines:\n\n        if line[0] == \".\":\n            continue\n\n        # start of new section\n        elif re.match(r\"^=+$\", line):\n            if sec_name and options:\n                sections[sec_name] = options\n            sec_name = last.strip()\n            options = {}\n\n        # start of new option block\n        elif re.match(r\"^-+$\", line):\n            opt_name = last.strip()\n            opt_desc = {}\n\n        # end of option block\n        elif opt_name and opt_desc and line == \"\\n\" and not last:\n            options[opt_name] = opt_desc\n            opt_name = None\n            name = None\n\n        # inside option block\n        elif opt_name:\n            if line[0].isalpha():\n                name = line.strip()\n                opt_desc[name] = \"\"\n            else:\n                line = line.strip()\n                if line.startswith((\"* \", \"- \")):\n                    # list item\n                    line = \".br\\n\" + line\n                elif line.startswith(\"| \"):\n                    # line block\n                    line = line[2:] + \"\\n.br\"\n                opt_desc[name] += line + \"\\n\"\n\n        last = line\n    sections[sec_name] = options\n\n    return sections\n\n\ndef strip_rst(text, extended=True, *, ITALIC=r\"\\\\f[I]\\1\\\\f[]\", REGULAR=r\"\\1\"):\n\n    text = text.replace(\"\\\\\", \"\\\\\\\\\")\n\n    # ``foo``\n    repl = ITALIC if extended else REGULAR\n    text = re.sub(r\"``([^`]+)``\", repl, text)\n    # |foo|_\n    text = re.sub(r\"\\|([^|]+)\\|_*\", ITALIC, text)\n    # `foo <bar>`__\n    text = re.sub(r\"`([^`<]+) <[^>`]+>`_+\", ITALIC, text)\n    # `foo`_\n    text = re.sub(r\"`([^`]+)`_+\", ITALIC, text)\n    # `foo`\n    text = re.sub(r\"`([^`]+)`\", REGULAR, text)\n    # foo_\n    text = re.sub(r\"([A-Za-z0-9-]+)_+(?=\\s)\", ITALIC, text)\n    # -------\n    text = re.sub(r\"---+\", \"\", text)\n\n    return text\n\n\nif __name__ == \"__main__\":\n    build_gallery_dl_1()\n    build_gallery_dl_conf_5()\n"
  },
  {
    "path": "scripts/options.py",
    "content": "#!/usr/bin/env python3\n# -*- coding: utf-8 -*-\n\n# Copyright 2023-2026 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Generate a document listing gallery-dl's command-line arguments\"\"\"\n\nimport os\nimport re\nimport sys\n\nimport util\n\nimport gallery_dl.util\ngallery_dl.util.EXECUTABLE = True\nfrom gallery_dl import option  # noqa E402\n\n\nclass Formatter(option.Formatter):\n    def __init__(self, prog):\n        option.argparse.HelpFormatter.__init__(\n            self, prog, max_help_position=30, width=77)\n\n    def add_usage(self, usage, actions, groups):\n        pass\n\n\ndef repl(match):\n    cat = match[1].rstrip(\":\")\n    toc.append(f\"* [{cat}](#{cat.lower().replace(' ', '-')})\")\n    return f\"## {cat}:\"\n\n\nparser = option.build_parser()\nparser.formatter_class = Formatter\nparser.format_usage = lambda: \"\"\n\ntoc = []\nopts = parser.format_help()\nopts = re.sub(r\"(?m)^(\\w+.*)\", repl, opts)  # group names to headings\nopts = opts.replace(\"\\n  \", \"\\n    \")  # indent by 4\ntoc = \"\\n\".join(toc)\n\nSELF = \"/\".join(os.path.normpath(__file__).split(os.sep)[-2:])\nPATH = (sys.argv[1] if len(sys.argv) > 1 else\n        util.path(\"docs\", \"options.md\"))\n\nwith util.lazy(PATH) as fp:\n    fp.write(f\"\"\"\\\n# Command-Line Options\n\n<!-- auto-generated by {SELF} -->\n\n\n## Table of Contents\n\n{toc}\n\n{opts}\\\n\"\"\")\n"
  },
  {
    "path": "scripts/pre-commit",
    "content": "#!/bin/bash\n\nTEMP=\"$(mktemp --directory)\"\ntrap 'rm -rf -- \"$TEMP\"' EXIT\n\nwhile IFS=\"\" read -r FILE\ndo\n    mkdir -p -- \"$TEMP/$(dirname $FILE)\"\n    git cat-file -p \":$FILE\" > \"$TEMP/$FILE\"\ndone < <(git diff --name-only --cached --diff-filter=d)\n\nREPO=$PWD\ncd \"$TEMP\"\n\n# -----------------------------------------------------------------------------\npy 10 -s -m flake8 --config \"$REPO/setup.cfg\" .\nA=$?\n\n# -----------------------------------------------------------------------------\nDEBUG=\"$(grep --recursive --line-number \\\n    --exclude-dir='docs' \\\n    --regexp='\\b\\(el\\)\\?if [01]\\(:\\| and \\| or \\)' \\\n    --regexp='\\band 0\\b' \\\n    --regexp='\\bor 1\\b' \\\n    --regexp='[[:digit:]]/0\\b' \\\n    --regexp='\\bexit()' \\\n    --regexp='\\bprint(' \\\n    --regexp='\\._dump(' \\\n    . \\\n)\"\n\nif [[ \"$DEBUG\" ]]; then\n    DEBUG=\"$( printf %s \"$DEBUG \" | sed -e 's/   */\\n    /' )\"\n    printf '### Debug Remains:\\n%s\\n' \"$DEBUG\"\n    B=1\nfi\n\n# -----------------------------------------------------------------------------\nif [[ \"$A\" > 0 || \"$B\" > 0 ]]; then\n    exit 1\nfi\n"
  },
  {
    "path": "scripts/pull-request",
    "content": "#!/bin/bash\nset -e\n\nRE=\"https://github.com/([^/?#]+)/([^/?#]+)(/tree/(.+))?\"\nif [[ \"$1\" =~ $RE ]]; then\n    USER=\"${BASH_REMATCH[1]}\"\n    REPO=\"${BASH_REMATCH[2]}\"\n    BRANCH=\"${BASH_REMATCH[4]:-master}\"\n\nelse\n    echo \"invalid github repository identifier: '$1'\"\n    exit 1\n\nfi\n\n\ncall() { echo \"$@\"; \"$@\"; echo; }\n\n# {x,,} transforms value to lowercase\ncase \"${2,,}\" in\n\n\"\"|\"f\"|\"fetch\")\n    call git remote add \"$USER\" git@github.com:\"$USER\"/\"$REPO\".git || true\n    call git fetch \"$USER\" \"$BRANCH\"\n    call git branch \"$USER-$BRANCH\" \"$USER/$BRANCH\" || true\n    call git switch \"$USER-$BRANCH\"\n    ;;\n\n\"m\"|\"merge\")\n    RE='\\s*(.+)\\s+#([0-9]+)'\n    if [[ \"$3\" =~ $RE ]]; then\n        TITLE=\"${BASH_REMATCH[1]}\"\n        PULL=\"${BASH_REMATCH[2]}\"\n    fi\n\n    call git switch master\n    call git merge --no-ff --edit -m \"merge #${PULL-_}: ${TITLE-_}\" \"$USER-$BRANCH\"\n    call git branch -d \"$USER-$BRANCH\"\n    ;;\n\n\"p\"|\"push\")\n    call git push \"$USER\" HEAD:\"$BRANCH\"\n    ;;\n\n\"pf\"|\"push-force\")\n    call git push --force \"$USER\" HEAD:\"$BRANCH\"\n    ;;\n\n\"d\"|\"delete\")\n    call git switch master\n    call git branch -D \"$USER-$BRANCH\"\n    call git remote remove \"$USER\"\n    ;;\n\n*)\n    echo \"invalid action: '$2'\"\n    exit 2\n    ;;\n\nesac\n"
  },
  {
    "path": "scripts/pyinstaller.py",
    "content": "#!/usr/bin/env python3\n# -*- coding: utf-8 -*-\n\n\"\"\"Build a standalone executable using PyInstaller\"\"\"\n\nimport argparse\nimport util\nimport sys\n\n\ndef main():\n    parser = argparse.ArgumentParser()\n    parser.add_argument(\"-o\", \"--os\")\n    parser.add_argument(\"-a\", \"--arch\")\n    parser.add_argument(\"-l\", \"--label\")\n    parser.add_argument(\"-e\", \"--extension\")\n    parser.add_argument(\"-p\", \"--print\", action=\"store_true\")\n    args = parser.parse_args()\n\n    if args.label:\n        label = args.label\n    else:\n        label = \"\"\n        if args.os:\n            os = args.os.partition(\"-\")[0].lower()\n            if os == \"ubuntu\":\n                os = \"linux\"\n            label += os\n        if args.arch == \"x86\":\n            label += \"_x86\"\n\n    if args.print:\n        return print(label)\n\n    name = \"gallery-dl\"\n    if label:\n        name = \"{}_{}\".format(name, label)\n    if args.extension:\n        name = \"{}.{}\".format(name, args.extension.lower())\n\n    import PyInstaller.__main__\n    return PyInstaller.__main__.run([\n        \"--onefile\",\n        \"--console\",\n        \"--name\", name,\n\n        # https://github.com/pyinstaller/pyinstaller/issues/9149\n        \"--exclude-module\", \"pkg_resources\",\n\n        \"--additional-hooks-dir\", util.path(\"scripts\"),\n        \"--distpath\", util.path(\"dist\"),\n        \"--workpath\", util.path(\"build\"),\n        \"--specpath\", util.path(\"build\"),\n        util.path(\"gallery_dl\", \"__main__.py\"),\n    ])\n\n\nif __name__ == \"__main__\":\n    sys.exit(main())\n"
  },
  {
    "path": "scripts/pyprint.py",
    "content": "#!/usr/bin/env python3\n# -*- coding: utf-8 -*-\n\n# Copyright 2024-2025 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\ndef pyprint(obj, indent=0, sort=None, oneline=True, lmin=0, lmax=16):\n\n    if isinstance(obj, str):\n        if obj.startswith(\"lit:\"):\n            return f'''{obj[4:]}'''\n\n        if \"\\\\\" in obj or obj.startswith(\"re:\"):\n            prefix = \"r\"\n        else:\n            prefix = \"\"\n\n        quote_beg = quote_end = '\"'\n        if \"\\n\" in obj:\n            quote_beg = '\"\"\"\\\\\\n'\n            quote_end = '\\\\\\n\"\"\"'\n        elif '\"' in obj:\n            quote_beg = quote_end = \\\n                \"'''\" if obj[0] == '\"' or obj[-1] == '\"' else '\"\"\"'\n\n        return f'''{prefix}{quote_beg}{obj}{quote_end}'''\n\n    if isinstance(obj, bytes):\n        return f'''b\"{str(obj)[2:-1]}\"'''\n\n    if isinstance(obj, type):\n        if obj.__module__ == \"builtins\":\n            return f'''{obj.__name__}'''\n\n        name = obj.__module__.rpartition(\".\")[2]\n        if name[0].isdecimal():\n            name = f\"_{name}\"\n        return f'''{name}.{obj.__name__}'''\n\n    if isinstance(obj, dict):\n        if not obj:\n            return \"{}\"\n        if len(obj) == 1 and oneline:\n            key, value = next(iter(obj.items()))\n            return f'''{{\"{key}\": {pyprint(value, indent, sort)}}}'''\n\n        if sort:\n            if callable(sort):\n                lst = [(sort(key, value), key, value)\n                       for key, value in obj.items()]\n                lst.sort()\n                obj = {key: value for _, key, value in lst}\n            else:\n                keys = list(obj)\n                keys.sort()\n                obj = {key: obj[key] for key in keys}\n\n        try:\n            keylen = max(kl for kl in map(len, obj) if kl <= lmax)\n        except Exception:\n            keylen = lmin\n        if keylen < lmin:\n            keylen = lmin\n        ws = \" \" * indent\n\n        lines = [\"{\"]\n        for key, value in obj.items():\n            if key.startswith(\"#blank-\"):\n                lines.append(\"\")\n            else:\n                lines.append(\n                    f'''{ws}    \"{key}\"'''\n                    f'''{' '*(keylen - len(key))}: '''\n                    f'''{pyprint(value, indent+4, sort)},'''\n                )\n        lines.append(f'''{ws}}}''')\n        return \"\\n\".join(lines)\n\n    if isinstance(obj, list):\n        if not obj:\n            return \"[]\"\n        if len(obj) == 1 and oneline:\n            return f'''[{pyprint(obj[0], indent, sort)}]'''\n\n        ws = \" \" * indent\n        lines = [\"[\"]\n        for value in obj:\n            lines.append(f'''{ws}    {pyprint(value, indent+4, sort)},''')\n        lines.append(f'''{ws}]''')\n        return \"\\n\".join(lines)\n\n    if isinstance(obj, tuple):\n        if not obj:\n            return \"()\"\n        if len(obj) == 1:\n            return f'''({pyprint(obj[0], indent, sort)},)'''\n\n        result = f'''({\", \".join(pyprint(v, indent+4, sort) for v in obj)})'''\n        if len(result) < 80:\n            return result\n\n        ws = \" \" * indent\n        lines = [\"(\"]\n        for value in obj:\n            lines.append(f'''{ws}    {pyprint(value, indent+4, sort)},''')\n        lines.append(f'''{ws})''')\n        return \"\\n\".join(lines)\n\n    if isinstance(obj, set):\n        if not obj:\n            return \"set()\"\n        return f'''{{{\", \".join(pyprint(v, indent+4, sort) for v in obj)}}}'''\n\n    if obj.__class__.__name__ == \"datetime\":\n        if (off := obj.utcoffset()) is not None:\n            obj = obj.replace(tzinfo=None, microsecond=0) - off\n        elif obj.microsecond:\n            obj = obj.replace(microsecond=0)\n        return f'''\"dt:{obj}\"'''\n\n    return f'''{obj}'''\n"
  },
  {
    "path": "scripts/release.sh",
    "content": "#!/bin/bash\nset -e\n\nprompt() {\n    echo \"root: ${ROOTDIR} old: ${OLDVERSION} - new: ${NEWVERSION}\"\n    read -n 1 -r -p \"Proceed? [Y/n] \" P\n    echo\n    if [ \"$P\" == y -o \"$P\" == Y -o -z \"$P\" ]; then\n        return 0\n    else\n        exit 1\n    fi\n}\n\ncleanup() {\n    cd \"${ROOTDIR}\"\n    echo Removing old build directory\n\n    if [ -d ./build ]; then\n        rm -rf ./build\n    fi\n}\n\nupdate() {\n    cd \"${ROOTDIR}\"\n    echo Updating version to ${NEWVERSION}\n\n    sed -i \"s#\\\"${PYVERSION}\\\"#\\\"${NEWVERSION}\\\"#\" \"gallery_dl/version.py\"\n    sed -i \"s#v[0-9]\\.[0-9]\\+\\.[0-9]\\+#v${NEWVERSION}#\" \"${README}\"\n    make man\n}\n\nupdate-dev() {\n    cd \"${ROOTDIR}\"\n\n    IFS=\".\" read MAJOR MINOR BUILD <<< \"${NEWVERSION}\"\n    BUILD=$((BUILD+1))\n\n    # update version to -dev\n    sed -i \"s#\\\"${NEWVERSION}\\\"#\\\"${MAJOR}.${MINOR}.${BUILD}-dev\\\"#\" \"gallery_dl/version.py\"\n\n    git add \"gallery_dl/version.py\"\n}\n\nbuild-python() {\n    cd \"${ROOTDIR}\"\n    echo Building sdist and wheel\n\n    python -m build\n}\n\nbuild-linux() {\n    cd \"${ROOTDIR}\"\n    echo Building Linux executable\n\n    build-vm 'ubuntu22.04' 'gallery-dl.bin' 'gallery-dl.bin' 'linux' 24000000\n}\n\nbuild-windows() {\n    cd \"${ROOTDIR}\"\n    echo Building Windows executable\n\n    build-vm 'win10' 'gallery-dl.exe' 'gallery-dl.exe' 'windows' 21000000\n}\n\nbuild-windows_x86() {\n    cd \"${ROOTDIR}\"\n    echo Building Windows X86 executable\n\n    build-vm 'windows7_x86_sp1' 'gallery-dl_x86.exe' 'gallery-dl.exe' 'windows_x86' 13000000\n}\n\nbuild-vm() {\n    VMNAME=\"$1\"\n    BINNAME=\"$2\"\n    TMPNAME=\"$3\"\n    LABEL=\"$4\"\n    MINSIZE=\"$5\"\n    TMPPATH=\"/tmp/gallery-dl/dist/$TMPNAME\"\n\n    # launch VM\n    vmstart \"$VMNAME\" &\n    disown\n\n    # copy source files\n    mkdir -p /tmp/gallery-dl/dist\n    cp -a -t /tmp/gallery-dl -- \\\n        ./gallery_dl ./scripts ./data ./setup.py ./README.rst\n\n    # update __variant__\n    sed -i \\\n        -e \"s#\\(__variant__ *=\\).*#\\1 \\\"stable/${LABEL}\\\"#\" \\\n        /tmp/gallery-dl/gallery_dl/version.py\n\n    # remove old executable\n    rm -f \"./dist/$BINNAME\"\n\n    # wait for new executable\n    while true; do\n        sleep 5\n\n        if [ ! -e \"$TMPPATH\" ]; then\n            continue\n        fi\n\n        sleep 2\n        SIZE=\"$(stat -c %s \"$TMPPATH\")\"\n        if [ \"$SIZE\" -lt \"$MINSIZE\" ]; then\n            echo Size of \"'$TMPPATH'\" is less than \"$MINSIZE\" bytes \"($SIZE)\"\n            continue\n        fi\n\n        break\n    done\n\n    # move\n    mv \"$TMPPATH\" \"./dist/$BINNAME\"\n\n    rm -r /tmp/gallery-dl\n}\n\nsign() {\n    cd \"${ROOTDIR}/dist\"\n    echo Signing files\n\n    gpg --detach-sign --armor gallery_dl-${NEWVERSION}-py3-none-any.whl\n    gpg --detach-sign --armor gallery_dl-${NEWVERSION}.tar.gz\n    gpg --detach-sign --yes gallery-dl.exe\n    gpg --detach-sign --yes gallery-dl_x86.exe\n    gpg --detach-sign --yes gallery-dl.bin\n}\n\nchangelog() {\n    cd \"${ROOTDIR}\"\n    echo Updating \"${CHANGELOG}\"\n\n    # - replace \"#NN\" with link to actual issue\n    # - insert new version and date\n    sed -i \\\n        -e \"s*\\([( ]\\)#\\([0-9]\\+\\)*\\1[#\\2](https://github.com/mikf/gallery-dl/issues/\\2)*g\" \\\n        -e \"s*^## \\w\\+\\$*## ${NEWVERSION} - $(date +%Y-%m-%d)*\" \\\n        \"${CHANGELOG}\"\n\n    mv --no-clobber -- \"${CHANGELOG}\" \"${CHANGELOG}.orig\"\n\n    # - remove all but the latest entries\n    sed -n \\\n        -e '/^## /,/^$/ { /^$/q; p }' \\\n        \"${CHANGELOG}.orig\" \\\n    > \"${CHANGELOG}\"\n}\n\nprepare() {\n    cd \"${ROOTDIR}\"\n\n    echo Checking if \"${SUPPORTEDSITES}\" is up to date\n    ./scripts/supportedsites.py\n    if ! git diff --quiet -- \"${SUPPORTEDSITES}\"; then\n        echo \"updated ${SUPPORTEDSITES} contains changes\"\n        exit 4\n    fi\n\n    echo Checking changed files\n    DIFF=\"$(git diff --name-only)\"\n    if [[ \"$DIFF\" != \"${CHANGELOG}\" ]]; then\n        if [[ \"$DIFF\" != *\"${CHANGELOG}\"* ]]; then\n            echo \"Missing ${NEWVERSION} '${CHANGELOG}' entries\"\n        else\n            printf \"Uncommited changes to files other than '${CHANGELOG}':\\n%s\\n\" \"$DIFF\"\n        fi\n        exit 4\n    fi\n\n    echo Syncing local branch with origin/master\n    git pull --autostash\n}\n\nupload-git() {\n    cd \"${ROOTDIR}\"\n    echo Pushing changes to github\n\n    mv -- \"${CHANGELOG}.orig\" \"${CHANGELOG}\" || true\n    git add \"gallery_dl/version.py\" \"${README}\" \"${CHANGELOG}\"\n    git commit -S -m \"release version ${NEWVERSION}\"\n    git tag -s -m \"version ${NEWVERSION}\" \"v${NEWVERSION}\"\n    git push --atomic origin master \"v${NEWVERSION}\"\n}\n\nupload-pypi() {\n    cd \"${ROOTDIR}/dist\"\n    echo Uploading to PyPI\n\n    twine upload gallery_dl-${NEWVERSION}*\n}\n\n\nROOTDIR=\"$(realpath \"$(dirname \"$0\")/..\")/\"\nREADME=\"README.rst\"\nCHANGELOG=\"CHANGELOG.md\"\nSUPPORTEDSITES=\"./docs/supportedsites.md\"\n\nLASTTAG=\"$(git describe --abbrev=0 --tags)\"\nOLDVERSION=\"${LASTTAG#v}\"\nPYVERSION=\"$(python -c \"import gallery_dl as g; print(g.__version__)\")\"\n\nif [[ \"$1\" ]]; then\n    NEWVERSION=\"$1\"\nelse\n    NEWVERSION=\"${PYVERSION%-dev}\"\nfi\n\nif [[ ! $NEWVERSION =~ [0-9]+\\.[0-9]+\\.[0-9]+(-[a-z]+(\\.[0-9]+)?)?$ ]]; then\n    echo \"invalid version: $NEWVERSION\"\n    exit 2\nfi\n\n\nprompt\nprepare\ncleanup\nupdate\nchangelog\nbuild-python\nbuild-linux\nbuild-windows\nbuild-windows_x86\nsign\nupload-pypi\nupload-git\nupdate-dev\n"
  },
  {
    "path": "scripts/rm.py",
    "content": "#!/usr/bin/env python3\n# -*- coding: utf-8 -*-\n\n# Copyright 2025 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Delete an extractor module\"\"\"\n\nimport os\nimport re\nimport logging\nimport argparse\nimport util\n\nLOG = logging.getLogger(\"rm\")\n\n\ndef remove_file(args, path):\n    try:\n        os.unlink(path)\n    except Exception:\n        pass\n\n\ndef remove_from_docs_configurationrst(args, path):\n    with util.lines(path) as lines:\n        needle = f'{args.category}`'\n        for idx, line in enumerate(lines):\n            if needle in line:\n                lines[idx] = \"\"\n\n        rm = False\n        needle = f'extractor.{args.category}.'\n        for idx, line in enumerate(lines):\n            if rm:\n                lines[idx] = \"\"\n                if line == \"\\n\":\n                    rm -= 1\n            elif line.startswith(needle):\n                lines[idx] = \"\"\n                rm = 2\n\n\ndef remove_from_docs_gallerydlconf(args, path):\n    rm = False\n    needle = f'        \"{args.category}\":\\n'\n\n    with util.lines(path) as lines:\n        for idx, line in enumerate(lines):\n            if rm:\n                lines[idx] = \"\"\n                if line.startswith(\"        }\"):\n                    break\n            elif line == needle:\n                lines[idx] = \"\"\n                rm = True\n\n\ndef remove_from_extractor_init(args, path):\n    needle = f'    \"{args.category}\",\\n'\n\n    with util.lines(path) as lines:\n        try:\n            lines.remove(needle)\n        except ValueError:\n            pass\n\n\ndef remove_from_scripts_supportedsites(args, path):\n    pattern = re.compile(\n        r'\\s+(\\w+\\[)?'\n        f'\"{args.category}\"'\n        r'(: (\\{)?|\\] = )')\n\n    with util.lines(path) as lines:\n        for idx, line in enumerate(lines):\n            if pattern.match(line):\n                lines[idx] = \"\"\n\n\ndef update_docs_supportedsites(args, path):\n    import supportedsites\n    supportedsites.main()\n\n\ndef parse_args(args=None):\n    parser = argparse.ArgumentParser(args)\n\n    parser.add_argument(\"-g\", \"--git\", action=\"store_true\")\n    parser.add_argument(\"CATEGORY\")\n\n    args = parser.parse_args()\n    args.category = args.cat = args.CATEGORY\n    return args\n\n\ndef main():\n    args = parse_args()\n\n    files = [\n        (util.path(\"gallery_dl\", \"extractor\", f\"{args.category}.py\"),\n         remove_file),\n\n        (util.path(\"test\", \"results\", f\"{args.category}.py\"),\n         remove_file),\n\n        (util.path(\"docs\", \"configuration.rst\"),\n         remove_from_docs_configurationrst),\n\n        (util.path(\"docs\", \"gallery-dl.conf\"),\n         remove_from_docs_gallerydlconf),\n\n        (util.path(\"gallery_dl\", \"extractor\", \"__init__.py\"),\n         remove_from_extractor_init),\n\n        (util.path(\"scripts\", \"supportedsites.py\"),\n         remove_from_scripts_supportedsites),\n\n        (util.path(\"docs\", \"supportedsites.md\"),\n         update_docs_supportedsites),\n    ]\n\n    for path, func in files:\n        path_tr = util.trim(path)\n        LOG.info(path_tr)\n\n        func(args, path)\n\n        if args.git:\n            util.git(\"add\", \"--\", path_tr)\n\n\nif __name__ == \"__main__\":\n    logging.basicConfig(\n        level=logging.DEBUG,\n        format=\"[%(levelname)s] %(message)s\",\n    )\n    main()\n"
  },
  {
    "path": "scripts/run_tests.py",
    "content": "#!/usr/bin/env python3\n# -*- coding: utf-8 -*-\n\n# Copyright 2021 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nimport os\nimport sys\nimport unittest\n\nTEST_DIRECTORY = os.path.join(\n    os.path.dirname(os.path.dirname(os.path.abspath(__file__))), \"test\")\n\nsys.path.insert(0, TEST_DIRECTORY)\n\nif len(sys.argv) <= 1:\n    TESTS = [\n        file.rpartition(\".\")[0]\n        for file in os.listdir(TEST_DIRECTORY)\n        if file.startswith(\"test_\") and file != \"test_results.py\"\n    ]\nelse:\n    TESTS = [\n        name if name.startswith(\"test_\") else \"test_\" + name\n        for name in sys.argv[1:]\n    ]\n\n\nsuite = unittest.TestSuite()\n\nfor test in TESTS:\n    try:\n        module = __import__(test)\n    except Exception as exc:\n        sys.stderr.write(f\"Failed to import {test}: {exc}\\n\")\n    else:\n        tests = unittest.defaultTestLoader.loadTestsFromModule(module)\n        suite.addTests(tests)\n\nif __name__ == \"__main__\":\n    result = unittest.TextTestRunner(verbosity=2).run(suite)\n    if not result.wasSuccessful():\n        sys.exit(1)\n"
  },
  {
    "path": "scripts/supportedsites.py",
    "content": "#!/usr/bin/env python3\n# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\n\"\"\"Generate a Markdown document listing all supported sites\"\"\"\n\nimport os\nimport sys\nimport collections\n\nimport util\nfrom gallery_dl import extractor\n\ntry:\n    from test import results\nexcept ImportError:\n    results = None\n\n\nCATEGORY_MAP = {\n    \"2chan\"          : \"Futaba Channel\",\n    \"35photo\"        : \"35PHOTO\",\n    \"adultempire\"    : \"Adult Empire\",\n    \"agnph\"          : \"AGNPH\",\n    \"ahottie\"        : \"AHottie\",\n    \"aibooru\"        : \"AIBooru\",\n    \"allgirlbooru\"   : \"All girl\",\n    \"allporncomic\"   : \"AllPornComic\",\n    \"ao3\"            : \"Archive of Our Own\",\n    \"archivedmoe\"    : \"Archived.Moe\",\n    \"archiveofsins\"  : \"Archive of Sins\",\n    \"arena\"          : \"Are.na\",\n    \"artstation\"     : \"ArtStation\",\n    \"aryion\"         : \"Eka's Portal\",\n    \"atfbooru\"       : \"ATFBooru\",\n    \"atfforum\"       : \"All The Fallen\",\n    \"azurlanewiki\"   : \"Azur Lane Wiki\",\n    \"b4k\"            : \"arch.b4k.dev\",\n    \"baraag\"         : \"baraag\",\n    \"batoto\"         : \"BATO.TO\",\n    \"bbc\"            : \"BBC\",\n    \"blacktowhite\"   : \"BlacktoWhite\",\n    \"booth\"          : \"BOOTH\",\n    \"celebforum\"     : \"celebforum\",\n    \"cfake\"          : \"Celebrity Fakes\",\n    \"cien\"           : \"Ci-en\",\n    \"cohost\"         : \"cohost!\",\n    \"comedywildlifephoto\": \"Comedy Wildlife Photography Awards\",\n    \"comicvine\"      : \"Comic Vine\",\n    \"cyberfile\"      : \"CyberFile\",\n    \"dankefuerslesen\": \"Danke fürs Lesen\",\n    \"deviantart\"     : \"DeviantArt\",\n    \"drawfriends\"    : \"Draw Friends\",\n    \"dynastyscans\"   : \"Dynasty Reader\",\n    \"e621\"           : \"e621\",\n    \"e926\"           : \"e926\",\n    \"e6ai\"           : \"e6AI\",\n    \"erome\"          : \"EroMe\",\n    \"eporner\"        : \"EPORNER\",\n    \"everia\"         : \"EVERIA.CLUB\",\n    \"e-hentai\"       : \"E-Hentai\",\n    \"exhentai\"       : \"ExHentai\",\n    \"fallenangels\"   : \"Fallen Angels Scans\",\n    \"fanbox\"         : \"pixivFANBOX\",\n    \"fappic\"         : \"Fappic.com\",\n    \"fashionnova\"    : \"Fashion Nova\",\n    \"fikfap\"         : \"FikFap\",\n    \"filester\"       : \"filester.me\",\n    \"fitnakedgirls\"  : \"FitNakedGirls\",\n    \"foriio\"         : \"foriio\",\n    \"furaffinity\"    : \"Fur Affinity\",\n    \"furry34\"        : \"Furry 34 com\",\n    \"girlswithmuscle\": \"Girls with Muscle\",\n    \"hatenablog\"     : \"HatenaBlog\",\n    \"hbrowse\"        : \"HBrowse\",\n    \"hdoujin\"        : \"HDoujin Galleries\",\n    \"hentai2read\"    : \"Hentai2Read\",\n    \"hentaicosplay\"  : \"Hentai Cosplay\",\n    \"hentaienvy\"     : \"HentaiEnvy\",\n    \"hentaiera\"      : \"HentaiEra\",\n    \"hentaifoundry\"  : \"Hentai Foundry\",\n    \"hentaifox\"      : \"HentaiFox\",\n    \"hentaihand\"     : \"HentaiHand\",\n    \"hentaihere\"     : \"HentaiHere\",\n    \"hentaiimg\"      : \"Hentai Image\",\n    \"hentainexus\"    : \"HentaiNexus\",\n    \"hentairox\"      : \"HentaiRox\",\n    \"hentaizap\"      : \"HentaiZap\",\n    \"hiperdex\"       : \"HiperDEX\",\n    \"hitomi\"         : \"Hitomi.la\",\n    \"horne\"          : \"horne\",\n    \"idolcomplex\"    : \"Idol Complex\",\n    \"illusioncardsbooru\": \"Illusion Game Cards\",\n    \"imagebam\"       : \"ImageBam\",\n    \"imagefap\"       : \"ImageFap\",\n    \"imagepond\"      : \"ImagePond\",\n    \"imagetwist\"     : \"ImageTwist\",\n    \"imgadult\"       : \"ImgAdult\",\n    \"imgbb\"          : \"ImgBB\",\n    \"imgbox\"         : \"imgbox\",\n    \"imagechest\"     : \"ImageChest\",\n    \"imgdrive\"       : \"ImgDrive.net\",\n    \"imgkiwi\"        : \"IMG.Kiwi\",\n    \"imglike\"        : \"Nude Celeb\",\n    \"imgpile\"        : \"imgpile\",\n    \"imgpv\"          : \"IMGPV\",\n    \"imgtaxi\"        : \"ImgTaxi.com\",\n    \"imgth\"          : \"imgth\",\n    \"imgur\"          : \"imgur\",\n    \"imgwallet\"      : \"ImgWallet.com\",\n    \"imhentai\"       : \"IMHentai\",\n    \"imxto\"          : \"IMX.to\",\n    \"joyreactor\"     : \"JoyReactor\",\n    \"itchio\"         : \"itch.io\",\n    \"jpgfish\"        : \"JPG Fish\",\n    \"kabeuchi\"       : \"かべうち\",\n    \"kaliscan\"       : \"KaliScan\",\n    \"mangafire\"      : \"MangaFire\",\n    \"mangafreak\"     : \"MangaFreak\",\n    \"mangareader\"    : \"MangaReader\",\n    \"mangataro\"      : \"MangaTaro\",\n    \"mgewiki\"        : \"Monster Girl Encyclopedia Wiki\",\n    \"s3ndpics\"       : \"S3ND\",\n    \"schalenetwork\"  : \"Schale Network\",\n    \"leakgallery\"    : \"Leak Gallery\",\n    \"livedoor\"       : \"livedoor Blog\",\n    \"lofter\"         : \"LOFTER\",\n    \"ohpolly\"        : \"Oh Polly\",\n    \"omgmiamiswimwear\": \"Omg Miami Swimwear\",\n    \"mangadex\"       : \"MangaDex\",\n    \"mangafox\"       : \"Manga Fox\",\n    \"mangahere\"      : \"Manga Here\",\n    \"mangakakalot\"   : \"MangaKakalot\",\n    \"mangatown\"      : \"MangaTown\",\n    \"manganato\"      : \"MangaNato\",\n    \"mangapark\"      : \"MangaPark\",\n    \"mangaread\"      : \"MangaRead\",\n    \"mariowiki\"      : \"Super Mario Wiki\",\n    \"mastodon.social\": \"mastodon.social\",\n    \"mediawiki\"      : \"MediaWiki\",\n    \"micmicidol\"     : \"MIC MIC IDOL\",\n    \"mixdrop\"        : \"MixDrop\",\n    \"myhentaigallery\": \"My Hentai Gallery\",\n    \"myportfolio\"    : \"Adobe Portfolio\",\n    \"natomanga\"      : \"MangaNato\",\n    \"naver-blog\"     : \"Naver Blog\",\n    \"naver-chzzk\"    : \"CHZZK\",\n    \"naver-webtoon\"  : \"Naver Webtoon\",\n    \"nelomanga\"      : \"MangaNelo\",\n    \"nhentai\"        : \"nhentai\",\n    \"nijie\"          : \"nijie\",\n    \"nozomi\"         : \"Nozomi.la\",\n    \"nozrip\"         : \"GaryC Booru\",\n    \"nsfwalbum\"      : \"NSFWalbum.com\",\n    \"nudostar\"       : \"NudoStar.TV\",\n    \"nudostarforum\"  : \"NudoStar Forums\",\n    \"okporn\"         : \"OK.PORN\",\n    \"paheal\"         : \"Rule 34\",\n    \"pholder\"        : \"pholder\",\n    \"photovogue\"     : \"PhotoVogue\",\n    \"picstate\"       : \"PicState\",\n    \"pidgiwiki\"      : \"PidgiWiki\",\n    \"pixeldrain\"     : \"pixeldrain\",\n    \"pixhost\"        : \"PiXhost\",\n    \"pixiv\"          : \"[pixiv]\",\n    \"pixiv-novel\"    : \"[pixiv] Novels\",\n    \"pornimage\"      : \"Porn Image\",\n    \"pornpics\"       : \"PornPics.com\",\n    \"pornreactor\"    : \"PornReactor\",\n    \"pornstarstube\"  : \"PORNSTARS.TUBE\",\n    \"postimg\"        : \"Postimages\",\n    \"readcomiconline\": \"Read Comic Online\",\n    \"rbt\"            : \"RebeccaBlackTech\",\n    \"redgifs\"        : \"RedGIFs\",\n    \"rozenarcana\"    : \"Rozen Arcana\",\n    \"rule34\"         : \"Rule 34\",\n    \"rule34hentai\"   : \"Rule34Hentai\",\n    \"rule34us\"       : \"Rule 34\",\n    \"rule34vault\"    : \"R34 Vault\",\n    \"rule34world\"    : \"Rule 34 World\",\n    \"rule34xyz\"      : \"Rule 34 XYZ\",\n    \"sankaku\"        : \"Sankaku Channel\",\n    \"sankakucomplex\" : \"Sankaku Complex\",\n    \"seiga\"          : \"Niconico Seiga\",\n    \"senmanga\"       : \"Sen Manga\",\n    \"sensescans\"     : \"Sense-Scans\",\n    \"sexcom\"         : \"Sex.com\",\n    \"silverpic\"      : \"SilverPic.com\",\n    \"simpcity\"       : \"SimpCity Forums\",\n    \"simplyhentai\"   : \"Simply Hentai\",\n    \"sizebooru\"      : \"Size Booru\",\n    \"slickpic\"       : \"SlickPic\",\n    \"slideshare\"     : \"SlideShare\",\n    \"smugmug\"        : \"SmugMug\",\n    \"socialmediagirlsforum\": \"Social Media Girls Forums\",\n    \"speakerdeck\"    : \"Speaker Deck\",\n    \"steamgriddb\"    : \"SteamGridDB\",\n    \"subscribestar\"  : \"SubscribeStar\",\n    \"tbib\"           : \"The Big ImageBoard\",\n    \"tcbscans\"       : \"TCB Scans\",\n    \"tco\"            : \"Twitter t.co\",\n    \"thatpervert\"    : \"ThatPervert\",\n    \"thebarchive\"    : \"The /b/ Archive\",\n    \"thecollection\"  : \"The /co/llection\",\n    \"thecollectionS\" : \"The /co/llection\",\n    \"thefap\"         : \"TheFap\",\n    \"thehentaiworld\" : \"The Hentai World\",\n    \"tiktok\"         : \"TikTok\",\n    \"titsintops\"     : \"Tits In Tops Forum\",\n    \"tmohentai\"      : \"TMOHentai\",\n    \"tumblrgallery\"  : \"TumblrGallery\",\n    \"turboimagehost\" : \"TurboImageHost.com\",\n    \"turbo\"          : \"turbo.cr\",\n    \"vanillarock\"    : \"もえぴりあ\",\n    \"vidyart2\"       : \"/v/idyart2\",\n    \"vidyapics\"      : \"Vidya Booru\",\n    \"vipr\"           : \"Vipr.im\",\n    \"visuabusters\"   : \"VISUABUSTERS\",\n    \"vk\"             : \"VK\",\n    \"vsco\"           : \"VSCO\",\n    \"wallpapercave\"  : \"Wallpaper Cave\",\n    \"webmshare\"      : \"webmshare\",\n    \"webtoons\"       : \"WEBTOON\",\n    \"weebcentral\"    : \"Weeb Central\",\n    \"weebdex\"        : \"WeebDex\",\n    \"wikiart\"        : \"WikiArt.org\",\n    \"wikigg\"         : \"wiki.gg\",\n    \"wikimediacommons\": \"Wikimedia Commons\",\n    \"xbunkr\"         : \"xBunkr\",\n    \"xhamster\"       : \"xHamster\",\n    \"xvideos\"        : \"XVideos\",\n    \"yandere\"        : \"yande.re\",\n    \"yiffverse\"      : \"Yiff verse\",\n    \"yourlesbians\"   : \"YourLesbians\",\n}\n\nSUBCATEGORY_MAP = {\n    \"\"       : \"\",\n    \"art\"    : \"Art\",\n    \"audio\"  : \"Audio\",\n    \"doujin\" : \"Doujin\",\n    \"home\"   : \"Home Feed\",\n    \"image\"  : \"individual Images\",\n    \"index\"  : \"Site Index\",\n    \"info\"   : \"User Profile Information\",\n    \"issue\"  : \"Comic Issues\",\n    \"manga\"  : \"Manga\",\n    \"media\"  : \"Media Files\",\n    \"people\" : \"People\",\n    \"popular\": \"Popular Images\",\n    \"recent\" : \"Recent Images\",\n    \"saved\"  : \"Saved Posts\",\n    \"search\" : \"Search Results\",\n    \"status\" : \"Images from Statuses\",\n    \"tag\"    : \"Tag Searches\",\n    \"tweets\" : \"\",\n    \"user\"   : \"User Profiles\",\n    \"watch\"  : \"Watches\",\n    \"direct-messages\": \"DMs\",\n    \"following\"      : \"Followed Users\",\n    \"related-pin\"    : \"related Pins\",\n    \"related-board\"  : \"\",\n\n    \"arcalive\": {\n        \"user\": \"User Posts\",\n    },\n    \"artstation\": {\n        \"artwork\": \"Artwork Listings\",\n        \"collections\": \"\",\n    },\n    \"audiochan\": {\n        \"audio\": \"Audios\",\n    },\n    \"bilibili\": {\n        \"user-articles-favorite\": \"User Article Favorites\",\n    },\n    \"bluesky\": {\n        \"posts\": \"\",\n    },\n    \"boosty\": {\n        \"feed\": \"Subscriptions Feed\",\n    },\n    \"booth\": {\n        \"category\": \"Item Categories\",\n    },\n    \"cfake\": {\n        \"created\": \"Created\",\n    },\n    \"civitai\": {\n        \"models\": \"Model Listings\",\n        \"images\": \"Image Listings\",\n        \"videos\": \"Video Listings\",\n        \"posts\" : \"Post Listings\",\n        \"search-models\": \"Model Searches\",\n        \"search-images\": \"Image Searches\",\n        \"user-images\": (\"User Images\", \"Image Reactions\"),\n        \"user-videos\": (\"User Videos\", \"Video Reactions\"),\n        \"generated\": \"Generated Files\",\n    },\n    \"coomer\": {\n        \"discord\"       : \"\",\n        \"discord-server\": \"\",\n        \"posts\"         : \"\",\n    },\n    \"cyberfile\": {\n        \"shared\": \"Shares\",\n    },\n    \"Danbooru\": {\n        \"favgroup\": \"Favorite Groups\",\n        \"random\"  : \"Random Posts\",\n    },\n    \"desktopography\": {\n        \"site\": \"\",\n    },\n    \"deviantart\": {\n        \"stash\" : \"Sta.sh\",\n        \"status\": \"Status Updates\",\n        \"watch-posts\": \"\",\n    },\n    \"discord\": {\n        \"direct-message\" : \"\",\n    },\n    \"facebook\": {\n        \"photos\" : \"Profile Photos\",\n    },\n    \"fanbox\": {\n        \"supporting\": \"Supported User Feed\",\n        \"redirect\"  : \"Pixiv Redirects\",\n    },\n    \"fantia\": {\n        \"supporting\": \"Supported Creators\",\n    },\n    \"fansly\": {\n        \"lists\": \"Account Lists\",\n    },\n    \"fapello\": {\n        \"path\": [\"Videos\", \"Trending Posts\", \"Popular Videos\", \"Top Models\"],\n    },\n    \"furaffinity\": {\n        \"submissions\": \"New Submissions\",\n    },\n    \"hatenablog\": {\n        \"archive\": \"Archive\",\n        \"entry\"  : \"Individual Posts\",\n    },\n    \"hentaifoundry\": {\n        \"story\": \"\",\n    },\n    \"imgur\": {\n        \"favorite-folder\": \"Favorites Folders\",\n        \"me\": \"Personal Posts\",\n    },\n    \"inkbunny\": {\n        \"unread\": \"Unread Submissions\",\n    },\n    \"instagram\": {\n        \"posts\": \"\",\n        \"tagged\": \"Tagged Posts\",\n        \"stories-tray\": \"Stories Home Tray\",\n    },\n    \"itaku\": {\n        \"posts\": \"\",\n    },\n    \"kemono\": {\n        \"discord\"       : \"Discord Servers\",\n        \"discord-server\": \"\",\n        \"posts\"         : \"\",\n    },\n    \"koofr\": {\n        \"shared\": \"Shared Links\",\n    },\n    \"leakgallery\": {\n        \"trending\" : \"Trending Medias\",\n        \"mostliked\": \"Most Liked Posts\",\n    },\n    \"lensdump\": {\n        \"albums\": \"\",\n    },\n    \"mangadex\": {\n        \"feed\": \"Updates Feed\",\n        \"following\" : \"Library\",\n        \"list\": \"MDLists\",\n    },\n    \"misskey\": {\n        \"notes\": \"User Notes\",\n    },\n    \"nijie\": {\n        \"followed\": \"Followed Users\",\n        \"nuita\" : \"Nuita History\",\n    },\n    \"pinterest\": {\n        \"board\": \"\",\n        \"pinit\": \"pin.it Links\",\n        \"created\": \"Created Pins\",\n        \"allpins\": \"All Pins\",\n    },\n    \"pixeldrain\": {\n        \"folder\": \"Filesystems\",\n    },\n    \"pixiv\": {\n        \"followed\": \"Follows\",\n        \"me\"  : \"pixiv.me Links\",\n        \"pixivision\": \"pixivision\",\n        \"sketch\": \"Sketch\",\n        \"unlisted\": \"Unlisted Works\",\n        \"work\": \"individual Images\",\n    },\n    \"poringa\": {\n        \"post\": \"Posts Images\",\n    },\n    \"pornhub\": {\n        \"gifs\": \"\",\n    },\n    \"raddle\": {\n        \"usersubmissions\": \"User Profiles\",\n        \"post\"           : \"Individual Posts\",\n        \"shorturl\"       : \"\",\n    },\n    \"redgifs\": {\n        \"collections\": \"\",\n    },\n    \"sankaku\": {\n        \"books\": \"Book Searches\",\n    },\n    \"scrolller\": {\n        \"user\"     : \"Reddit Users\",\n        \"following\": \"Followed Subreddits\",\n    },\n    \"sexcom\": {\n        \"pins\": \"User Pins\",\n        \"feed\": \"Feed\",\n    },\n    \"sizebooru\": {\n        \"user\": \"User Uploads\",\n    },\n    \"skeb\": {\n        \"following\"      : \"Followed Creators\",\n        \"following-users\": \"Followed Users\",\n        \"sentrequests\"   : \"Sent Requests\",\n    },\n    \"smugmug\": {\n        \"path\": \"Images from Users and Folders\",\n    },\n    \"steamgriddb\": {\n        \"asset\": \"Individual Assets\",\n    },\n    \"tiktok\": {\n        \"posts\": \"User Posts\",\n        \"vmpost\": \"VM Posts\",\n        \"following\": \"Followed Users (Stories Only)\",\n    },\n    \"tumblr\": {\n        \"day\": \"Days\",\n    },\n    \"twitter\": {\n        \"media\": \"Media Timelines\",\n        \"tweets\": \"\",\n        \"community\": \"\",\n        \"with-replies\": \"\",\n        \"list-members\": \"List Members\",\n    },\n    \"vk\": {\n        \"tagged\": \"Tagged Photos\",\n        \"wall-post\": \"individual Wall Posts\",\n    },\n    \"vsco\": {\n        \"spaces\": \"\",\n    },\n    \"wallhaven\": {\n        \"collections\": \"\",\n        \"uploads\"    : \"\",\n    },\n    \"wallpapercave\": {\n        \"image\": [\"individual Images\", \"Search Results\"],\n    },\n    \"weasyl\": {\n        \"journals\"   : \"\",\n        \"submissions\": \"\",\n    },\n    \"weibo\": {\n        \"home\": \"\",\n        \"newvideo\": \"\",\n    },\n    \"wikiart\": {\n        \"artists\": \"Artist Listings\",\n    },\n    \"wikimedia\": {\n        \"article\": [\"Articles\", \"Categories\", \"Files\"],\n    },\n    \"xenforo\": {\n        \"media-user\": \"User Media\",\n        \"media-item\": \"Media Files\",\n        \"media-category\": \"Media Categories\",\n        \"media-album\"   : \"Albums\",\n    },\n}\n\nBASE_MAP = {\n    \"E621\"        : \"e621 Instances\",\n    \"foolfuuka\"   : \"FoolFuuka 4chan Archives\",\n    \"foolslide\"   : \"FoOlSlide Instances\",\n    \"gelbooru_v01\": \"Gelbooru Beta 0.1.11\",\n    \"gelbooru_v02\": \"Gelbooru Beta 0.2\",\n    \"hentaicosplays\": \"Hentai Cosplay Instances\",\n    \"imagehost\"   : \"Image Hosting Sites\",\n    \"IMHentai\"    : \"IMHentai and Mirror Sites\",\n    \"jschan\"      : \"jschan Imageboards\",\n    \"lolisafe\"    : \"lolisafe and chibisafe\",\n    \"lynxchan\"    : \"LynxChan Imageboards\",\n    \"manganelo\"   : \"MangaNelo and Mirror Sites\",\n    \"moebooru\"    : \"Moebooru and MyImouto\",\n    \"szurubooru\"  : \"szurubooru Instances\",\n    \"urlshortener\": \"URL Shorteners\",\n    \"vichan\"      : \"vichan Imageboards\",\n    \"xenforo\"     : \"XenForo Forums\",\n}\n\nURL_MAP = {\n    \"blogspot\" : \"https://www.blogger.com/\",\n    \"wikimedia\": \"https://www.wikimedia.org/\",\n}\n\n_OAUTH = '<a href=\"https://github.com/mikf/gallery-dl#oauth\">OAuth</a>'\n_COOKIES = '<a href=\"https://github.com/mikf/gallery-dl#cookies\">Cookies</a>'\n_APIKEY_DB = ('<a href=\"https://gdl-org.github.io/docs/configuration.html'\n              '#extractor-derpibooru-api-key\">API Key</a>')\n_APIKEY_WH = ('<a href=\"https://gdl-org.github.io/docs/configuration.html'\n              '#extractor-wallhaven-api-key\">API Key</a>')\n_APIKEY_WY = ('<a href=\"https://gdl-org.github.io/docs/configuration.html'\n              '#extractor-weasyl-api-key\">API Key</a>')\n\nAUTH_MAP = {\n    \"aibooru\"        : \"Supported\",\n    \"ao3\"            : \"Supported\",\n    \"aryion\"         : \"Supported\",\n    \"atfbooru\"       : \"Supported\",\n    \"baraag\"         : _OAUTH,\n    \"bluesky\"        : \"Supported\",\n    \"booruvar\"       : \"Supported\",\n    \"boosty\"         : _COOKIES,\n    \"coomer\"         : \"Supported\",\n    \"danbooru\"       : \"Supported\",\n    \"derpibooru\"     : _APIKEY_DB,\n    \"deviantart\"     : _OAUTH,\n    \"e621\"           : \"Supported\",\n    \"e6ai\"           : \"Supported\",\n    \"e926\"           : \"Supported\",\n    \"e-hentai\"       : \"Supported\",\n    \"exhentai\"       : \"Supported\",\n    \"facebook\"       : _COOKIES,\n    \"fanbox\"         : _COOKIES,\n    \"fantia\"         : _COOKIES,\n    \"flickr\"         : _OAUTH,\n    \"furaffinity\"    : _COOKIES,\n    \"furbooru\"       : \"API Key\",\n    \"girlswithmuscle\": \"Supported\",\n    \"horne\"          : \"Required\",\n    \"idolcomplex\"    : \"Supported\",\n    \"imgbb\"          : \"Supported\",\n    \"inkbunny\"       : \"Supported\",\n    \"instagram\"      : _COOKIES,\n    \"iwara\"          : \"Supported\",\n    \"kemono\"         : \"Supported\",\n    \"madokami\"       : \"Required\",\n    \"mangadex\"       : \"Supported\",\n    \"mangoxo\"        : \"Supported\",\n    \"mastodon.social\": _OAUTH,\n    \"newgrounds\"     : \"Supported\",\n    \"nijie\"          : \"Required\",\n    \"nudostarforum\"  : \"Supported\",\n    \"patreon\"        : _COOKIES,\n    \"pawoo\"          : _OAUTH,\n    \"pillowfort\"     : \"Supported\",\n    \"pinterest\"      : _COOKIES,\n    \"pixiv\"          : _OAUTH,\n    \"pixiv-novel\"    : _OAUTH,\n    \"poipiku\"        : _COOKIES,\n    \"ponybooru\"      : \"API Key\",\n    \"reddit\"         : _OAUTH,\n    \"rule34world\"    : \"Supported\",\n    \"rule34xyz\"      : \"Supported\",\n    \"sankaku\"        : \"Supported\",\n    \"scrolller\"      : \"Supported\",\n    \"seiga\"          : \"Supported\",\n    \"simpcity\"       : \"Supported\",\n    \"smugmug\"        : _OAUTH,\n    \"subscribestar\"  : \"Supported\",\n    \"tapas\"          : \"Supported\",\n    \"tiktok\"         : _COOKIES,\n    \"tsumino\"        : \"Supported\",\n    \"tumblr\"         : _OAUTH,\n    \"twitter\"        : _COOKIES,\n    \"vipergirls\"     : \"Supported\",\n    \"wallhaven\"      : _APIKEY_WH,\n    \"weasyl\"         : _APIKEY_WY,\n    \"zerochan\"       : \"Supported\",\n}\n\nIGNORE_LIST = (\n    \"directlink\",\n    \"oauth\",\n    \"recursive\",\n    \"test\",\n    \"ytdl\",\n    \"generic\",\n    \"noop\",\n)\n\n\ndef domain(cls):\n    \"\"\"Return the domain name associated with an extractor class\"\"\"\n    try:\n        url = sys.modules[cls.__module__].__doc__.split()[-1]\n        if url.startswith(\"http\"):\n            return url\n    except Exception:\n        pass\n\n    if hasattr(cls, \"root\") and cls.root:\n        return cls.root + \"/\"\n\n    url = cls.example\n    return url[:url.find(\"/\", 8)+1]\n\n\ndef category_text(c):\n    \"\"\"Return a human-readable representation of a category\"\"\"\n    return CATEGORY_MAP.get(c) or c.capitalize()\n\n\ndef subcategory_text(bc, c, sc):\n    \"\"\"Return a human-readable representation of a subcategory\"\"\"\n    if c in SUBCATEGORY_MAP:\n        scm = SUBCATEGORY_MAP[c]\n        if sc in scm:\n            txt = scm[sc]\n            if not isinstance(txt, str):\n                txt = \", \".join(txt)\n            return txt\n\n    if bc and bc in SUBCATEGORY_MAP:\n        scm = SUBCATEGORY_MAP[bc]\n        if sc in scm:\n            txt = scm[sc]\n            if not isinstance(txt, str):\n                txt = \", \".join(txt)\n            return txt\n\n    if sc in SUBCATEGORY_MAP:\n        return SUBCATEGORY_MAP[sc]\n\n    if \"-\" in sc:\n        sc = \" \".join(s.capitalize() for s in sc.split(\"-\"))\n    else:\n        sc = sc.capitalize()\n\n    if sc.endswith(\"y\"):\n        sc = f\"{sc[:-1]}ies\"\n    elif sc.endswith(\"h\"):\n        sc = f\"{sc}es\"\n    elif not sc.endswith(\"s\") and not sc.endswith(\"edia\"):\n        sc = f\"{sc}s\"\n    return sc\n\n\ndef category_key(c):\n    \"\"\"Generate sorting keys by category\"\"\"\n    return category_text(c[0]).lower().lstrip(\"[\")\n\n\ndef subcategory_key(sc):\n    \"\"\"Generate sorting keys by subcategory\"\"\"\n    return \"A\" if sc == \"issue\" else sc\n\n\ndef build_extractor_list():\n    \"\"\"Generate a sorted list of lists of extractor classes\"\"\"\n    categories = collections.defaultdict(lambda: collections.defaultdict(list))\n    default = categories[\"\"]\n    domains = {\"\": \"\"}\n\n    for extr in extractor._list_classes():\n        category = extr.category\n        if category in IGNORE_LIST:\n            continue\n        if category:\n            if extr.basecategory == \"imagehost\":\n                base = categories[extr.basecategory]\n            else:\n                base = default\n            base[category].append(extr.subcategory)\n            if category not in domains:\n                domains[category] = domain(extr)\n        else:\n            base = categories[extr.basecategory]\n            if not extr.instances:\n                base[\"\"].append(extr.subcategory)\n                continue\n            for category, root, info in extr.instances:\n                base[category].append(extr.subcategory)\n                if category not in domains:\n                    if not root:\n                        if category in URL_MAP:\n                            root = URL_MAP[category].rstrip(\"/\")\n                        elif results:\n                            # use domain from first matching test\n                            test = results.category(category)[0]\n                            root = test[\"#class\"].from_url(test[\"#url\"]).root\n                    domains[category] = root + \"/\"\n\n    # sort subcategory lists\n    for base in categories.values():\n        for subcategories in base.values():\n            subcategories.sort(key=subcategory_key)\n\n    domains[\"pixiv-novel\"] += \"novel\"\n\n    # add e-hentai.org\n    default[\"e-hentai\"] = default[\"exhentai\"]\n    domains[\"e-hentai\"] = domains[\"exhentai\"].replace(\"x\", \"-\")\n\n    # add coomer.st\n    default[\"coomer\"] = default[\"kemono\"]\n    domains[\"coomer\"] = \"https://coomer.st/\"\n\n    # add wikifeetx.com\n    default[\"wikifeetx\"] = default[\"wikifeet\"]\n    domains[\"wikifeetx\"] = \"https://www.wikifeetx.com/\"\n\n    # add rule34.world\n    default[\"rule34world\"] = default[\"rule34xyz\"]\n    domains[\"rule34world\"] = \"https://rule34.world/\"\n\n    # imgdrive / imgtaxi / imgwallet\n    base = categories[\"imagehost\"]\n    base[\"imgtaxi\"] = base[\"imgdrive\"]\n    base[\"imgwallet\"] = base[\"imgdrive\"]\n    categories[\"imagehost\"] = {k: base[k] for k in sorted(base)}\n    domains[\"postimg\"] = \"https://postimages.org/\"\n    domains[\"imgtaxi\"] = \"https://imgtaxi.com/\"\n    domains[\"imgwallet\"] = \"https://imgwallet.com/\"\n\n    # add extra e621 extractors\n    categories[\"E621\"][\"e621\"].extend(default.pop(\"e621\", ()))\n\n    return categories, domains\n\n\n# define table columns\nCOLUMNS = (\n    (\"Site\", 20,\n     lambda bc, c, scs, d: category_text(c)),\n    (\"URL\" , 35,\n     lambda bc, c, scs, d: d),\n    (\"Capabilities\", 50,\n     lambda bc, c, scs, d: \", \".join(subcategory_text(bc, c, sc) for sc in scs\n                                     if subcategory_text(bc, c, sc))),\n    (\"Authentication\", 16,\n     lambda bc, c, scs, d: AUTH_MAP.get(c, \"\")),\n)\n\n\ndef generate_output(columns, categories, domains):\n\n    thead = []\n    thead.append(\"<tr>\")\n    for column in columns:\n        thead.append(f\"    <th>{column[0]}</th>\")\n    thead.append(\"</tr>\")\n\n    tbody = []\n    for bcat, base in categories.items():\n        if bcat and base:\n            name = BASE_MAP.get(bcat) or (bcat.capitalize() + \" Instances\")\n            tbody.append(f\"\"\"\n<tr id=\"{bcat}\" title=\"{bcat}\">\n    <td colspan=\"4\"><strong>{name}</strong></td>\n</tr>\\\n\"\"\")\n            clist = base.items()\n        else:\n            clist = sorted(base.items(), key=category_key)\n\n        for category, subcategories in clist:\n            tbody.append(f\"\"\"<tr id=\"{category}\" title=\"{category}\">\"\"\")\n            for column in columns:\n                domain = domains[category]\n                content = column[2](bcat, category, subcategories, domain)\n                tbody.append(f\"    <td>{content}</td>\")\n            tbody.append(\"</tr>\")\n\n    NL = \"\\n\"\n    GENERATOR = \"/\".join(os.path.normpath(__file__).split(os.sep)[-2:])\n    return f\"\"\"\\\n# Supported Sites\n\n<!-- auto-generated by {GENERATOR} -->\nConsider all listed sites to potentially be NSFW.\n\n<table>\n<thead valign=\"bottom\">\n{NL.join(thead)}\n</thead>\n<tbody valign=\"top\">\n{NL.join(tbody)}\n</tbody>\n</table>\n\"\"\"\n\n\ndef main(path=None):\n    categories, domains = build_extractor_list()\n\n    if path is None:\n        path = util.path(\"docs\", \"supportedsites.md\")\n    with util.lazy(path) as fp:\n        fp.write(generate_output(COLUMNS, categories, domains))\n\n\nif __name__ == \"__main__\":\n    main(sys.argv[1] if len(sys.argv) > 1 else None)\n"
  },
  {
    "path": "scripts/util.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nimport os\nimport io\nimport sys\nimport builtins\n\nROOTDIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))\nsys.path.insert(0, os.path.realpath(ROOTDIR))\n\n\ndef path(*segments):\n    result = os.path.join(ROOTDIR, *segments)\n    os.makedirs(os.path.dirname(result), exist_ok=True)\n    return result\n\n\ndef trim(path):\n    return path[len(ROOTDIR)+1:]\n\n\ndef open(path, mode=\"r\"):\n    return builtins.open(path, mode, encoding=\"utf-8\", newline=\"\\n\")\n\n\ndef git(command, *args):\n    import subprocess\n    return subprocess.Popen(\n        [\"git\", command, *args],\n        stdout=subprocess.PIPE,\n        cwd=ROOTDIR,\n    ).communicate()[0].strip().decode()\n\n\nclass lazy():\n\n    def __init__(self, path):\n        self.path = path\n        self.buffer = io.StringIO()\n\n    def __enter__(self):\n        return self.buffer\n\n    def __exit__(self, exc_type, exc_value, traceback):\n        # get content of old file\n        try:\n            with builtins.open(self.path, encoding=\"utf-8\", newline=\"\") as fp:\n                old = fp.read()\n        except Exception:\n            old = None\n\n        # get new content\n        new = self.buffer.getvalue()\n\n        if new != old:\n            # rewrite entire file\n            with builtins.open(\n                    self.path, \"w\", encoding=\"utf-8\", newline=\"\") as fp:\n                fp.write(new)\n        else:\n            # only update atime and mtime\n            os.utime(self.path)\n\n\nclass lines():\n\n    def __init__(self, path, lazy=True):\n        self.path = path\n        self.lazy = lazy\n        self.lines = ()\n\n    def __enter__(self):\n        with open(self.path) as fp:\n            self.lines = lines = fp.readlines()\n        return lines\n\n    def __exit__(self, exc_type, exc_value, traceback):\n        ctx = lazy(self.path) if self.lazy else open(self.path, \"w\")\n        with ctx as fp:\n            fp.writelines(self.lines)\n"
  },
  {
    "path": "setup.cfg",
    "content": "[flake8]\nexclude = .git,__pycache__,build,dist,archive\nignore = E203,E226,W504\nper-file-ignores =\n    setup.py: E501\n    gallery_dl/extractor/utils/500px_graphql.py: E501\n    gallery_dl/extractor/utils/mangapark_graphql.py: E501\n    test/results/*.py: E122,E241,E402,E501\n"
  },
  {
    "path": "setup.py",
    "content": "#!/usr/bin/env python3\n# -*- coding: utf-8 -*-\n\nimport re\nimport sys\nimport os.path\nimport warnings\n\n\ndef read(fname):\n    path = os.path.join(os.path.dirname(__file__), fname)\n    with open(path, encoding=\"utf-8\") as fp:\n        return fp.read()\n\n\ndef check_file(fname):\n    path = os.path.join(os.path.dirname(__file__), fname)\n    if os.path.exists(path):\n        return True\n    warnings.warn(\n        \"Not including file '{}' since it is not present. \"\n        \"Run 'make' to build all automatically generated files.\".format(fname)\n    )\n    return False\n\n\n# get version without importing the package\nVERSION = re.search(\n    r'__version__\\s*=\\s*\"([^\"]+)\"',\n    read(\"gallery_dl/version.py\"),\n)[1]\n\nFILES = [\n    (path, [f for f in files if check_file(f)])\n    for (path, files) in [\n        (\"share/bash-completion/completions\", [\"data/completion/gallery-dl\"]),\n        (\"share/zsh/site-functions\"         , [\"data/completion/_gallery-dl\"]),\n        (\"share/fish/vendor_completions.d\"  , [\"data/completion/gallery-dl.fish\"]),\n        (\"share/man/man1\"                   , [\"data/man/gallery-dl.1\"]),\n        (\"share/man/man5\"                   , [\"data/man/gallery-dl.conf.5\"]),\n    ]\n]\n\nPACKAGES = [\n    \"gallery_dl\",\n    \"gallery_dl.extractor\",\n    \"gallery_dl.extractor.utils\",\n    \"gallery_dl.downloader\",\n    \"gallery_dl.postprocessor\",\n]\n\nDESCRIPTION = (\"Command-line program to download image galleries and \"\n               \"collections from several image hosting sites\")\nLONG_DESCRIPTION = read(\"README.rst\").replace(\n    \"<docs/\", \"<https://github.com/mikf/gallery-dl/blob/master/docs/\")\n\n\ndef build_py2exe():\n    from py2exe import freeze\n\n    # py2exe dislikes version specifiers with a trailing '-dev'\n    VERSION_ = VERSION.partition(\"-\")[0]\n\n    freeze(\n        console=[{\n            \"script\"         : \"./gallery_dl/__main__.py\",\n            \"dest_base\"      : \"gallery-dl\",\n        }],\n        version_info={\n            \"version\"        : VERSION_,\n            \"description\"    : DESCRIPTION,\n            \"comments\"       : LONG_DESCRIPTION,\n            \"product_name\"   : \"gallery-dl\",\n            \"product_version\": VERSION_,\n        },\n        options={\n            \"bundle_files\"   : 0,\n            \"compressed\"     : 1,\n            \"optimize\"       : 1,\n            \"dist_dir\"       : \"./dist\",\n            \"packages\"       : PACKAGES,\n            \"includes\"       : [\"youtube_dl\"],\n            \"dll_excludes\"   : [\"w9xpopen.exe\"],\n        },\n        zipfile=None,\n    )\n\n\ndef build_setuptools():\n    from setuptools import setup\n\n    setup(\n        name=\"gallery_dl\",\n        version=VERSION,\n        description=DESCRIPTION,\n        long_description=LONG_DESCRIPTION,\n        long_description_content_type=\"text/x-rst\",\n        url=\"https://github.com/mikf/gallery-dl\",\n        download_url=\"https://github.com/mikf/gallery-dl/releases/latest\",\n        author=\"Mike Fährmann\",\n        author_email=\"mike_faehrmann@web.de\",\n        maintainer=\"Mike Fährmann\",\n        maintainer_email=\"mike_faehrmann@web.de\",\n        license=\"GPL-2.0-only\",\n        python_requires=\">=3.8\",\n        install_requires=[\n            \"requests>=2.11.0\",\n        ],\n        extras_require={\n            \"video\": [\n                \"yt-dlp\",\n            ],\n            \"extra\": [\n                \"requests[socks]\",\n                \"yt-dlp[default]\",\n                \"jinja2\",\n                \"pyyaml\",\n                \"toml; python_version < '3.11'\",\n                \"truststore; python_version >= '3.10'\",\n                \"secretstorage; sys_platform == 'linux'\",\n            ],\n        },\n        entry_points={\n            \"console_scripts\": [\n                \"gallery-dl = gallery_dl:main\",\n            ],\n        },\n        packages=PACKAGES,\n        data_files=FILES,\n        test_suite=\"test\",\n        keywords=\"image gallery downloader crawler scraper\",\n        classifiers=[\n            \"Development Status :: 5 - Production/Stable\",\n            \"Environment :: Console\",\n            \"Intended Audience :: End Users/Desktop\",\n            \"License :: OSI Approved :: GNU General Public License v2 (GPLv2)\",\n            \"Operating System :: OS Independent\",\n            \"Programming Language :: Python\",\n            \"Programming Language :: Python :: 3\",\n            \"Programming Language :: Python :: 3 :: Only\",\n            \"Programming Language :: Python :: 3.8\",\n            \"Programming Language :: Python :: 3.9\",\n            \"Programming Language :: Python :: 3.10\",\n            \"Programming Language :: Python :: 3.11\",\n            \"Programming Language :: Python :: 3.12\",\n            \"Programming Language :: Python :: 3.13\",\n            \"Programming Language :: Python :: 3.14\",\n            \"Programming Language :: Python :: Implementation :: CPython\",\n            \"Programming Language :: Python :: Implementation :: PyPy\",\n            \"Topic :: Internet :: WWW/HTTP\",\n            \"Topic :: Multimedia :: Graphics\",\n            \"Topic :: Utilities\",\n        ],\n    )\n\n\nif \"py2exe\" in sys.argv:\n    build_py2exe()\nelse:\n    build_setuptools()\n"
  },
  {
    "path": "snap/local/launchers/gallery-dl-launch",
    "content": "#!/usr/bin/env bash\n# This is the maintenance launcher for the snap, make necessary runtime environment changes to make the snap work here.  You may also insert security confinement/deprecation/obsoletion notice of the snap here.\n\nset \\\n\t-o errexit \\\n\t-o errtrace \\\n\t-o nounset \\\n\t-o pipefail\n\n# Use user's real home directory for canonical configuration path access\ndeclare REALHOME=\"$(\n\tgetent passwd \"${USER}\" \\\n\t\t| cut --delimiter=: --fields=6\n)\"\nHOME=\"${REALHOME}\"\n\nif ! test -f \"${SNAP_USER_COMMON}\"/marker_disable_interface_warning; then\n\t# Warn if the `removable-media` interface isn't connected\n\tif ! ls /media &>/dev/null; then\n\t\tprintf -- \\\n\t\t\t\"It seems that this snap isn't connected to the \\`removable-media\\` security confinement interface.  If you want to save the files under \\`/media\\`, \\`/run/media\\`, or \\`/mnt\\` directories you need to connect this snap to the \\`removable-media\\` interface by running the following command in a terminal:\\\\n\\\\n    sudo snap connect %s:removable-media\\\\n\\\\n\" \\\n\t\t\t\"${SNAP_NAME}\" \\\n\t\t\t>&2\n\t\tprintf -- \\\n\t\t\t\"To disable this warning create an empty file at the following path:\\\\n\\\\n    %s/marker_disable_interface_warning\\\\n\\\\n\" \\\n\t\t\t\"${SNAP_USER_COMMON}\" \\\n\t\t\t>&2\n\tfi\nfi\n\n# Finally run the next part of the command chain\nexec \"${@}\"\n"
  },
  {
    "path": "snap/local/scriptlets/selective-checkout",
    "content": "#!/usr/bin/env bash\n# This scriptlet enhances the pull step that will only build\n# development snapshots snaps if the latest tagged release has been\n# promoted to the stable channel.  This ensures that there's always\n# a revision of the stable release snap available in the edge channel\n# for the publisher to promote to stable as currently the build\n# infrastructure only supports build on code push (but not new tagged\n# releases) at this time.\n# https://forum.snapcraft.io/t/selective-checkout-check-out-the-tagged-release-revision-if-it-isnt-promoted-to-the-stable-channel/10617\n#\n# Copyright 2025 林博仁(Buo-ren Lin) <buo.ren.lin@gmail.com>\n# SPDX-License-Identifier: CC-BY-SA-4.0\n\nSELECTIVE_CHECKOUT_DEBUG=\"${SELECTIVE_CHECKOUT_DEBUG:-false}\"\n\nset \\\n    -o errexit \\\n    -o errtrace \\\n    -o nounset \\\n    -o pipefail\n\nfor required_command in \\\n    curl \\\n    cut \\\n    head \\\n    jq \\\n    realpath \\\n    sed \\\n    sort \\\n    tail \\\n    tr; do\n    if ! command -v \"${required_command}\" >/dev/null; then\n        printf -- \\\n            'Fatal: This script requires the \"%s\" command in your command search PATHs.\\n' \\\n            \"${required_command}\" \\\n            >&2\n        exit 1\n    fi\ndone\n\ninit(){\n    script=\"$(\n        realpath \\\n            --strip \\\n            \"${BASH_SOURCE[0]}\"\n    )\"\n    script_filename=\"${script##*/}\"\n    script_name=\"${script_filename%%.*}\"\n    export SCRIPT_NAME=\"${script_name}\"\n\n    # checkout_mode:\n    # - snapshot: Build as-is\n    # - release: Build the latest tagged release\n    # tag_pattern_release: We assume all tags contains dots or underscores release tags\n    #\n    # Indirection used\n    # shellcheck disable=SC2034\n    local \\\n        checkout_mode \\\n        flag_append_packaging_version=false \\\n        flag_dry_run=false \\\n        flag_debug_tracing=false \\\n        flag_force_snapshot=false \\\n        flag_force_stable=false \\\n        packaging_revision \\\n        postfix_dirty_marker_packaging=-d \\\n        postfix_dirty_marker_upstream=-dirty \\\n        tag_pattern_beta='-beta[[:digit:]]+$' \\\n        tag_pattern_release='.*[._].*' \\\n        tag_pattern_release_candidate='-rc[[:digit:]]+$' \\\n        tag_pattern_stable \\\n        tag_prefix_release=v \\\n        revision_minimal_length_packaging=4 \\\n        revision_minimal_length_upstream=7 \\\n        snap_version \\\n        snap_version_postfix_seperator=+ \\\n        upstream_version\n\n    if ! determining_runtime_parameters \\\n        flag_append_packaging_version \\\n        tag_pattern_beta \\\n        flag_debug_tracing \\\n        flag_dry_run \\\n        flag_force_snapshot \\\n        flag_force_stable \\\n        postfix_dirty_marker_packaging \\\n        revision_minimal_length_packaging \\\n        tag_pattern_release_candidate \\\n        tag_pattern_release \\\n        tag_prefix_release \\\n        snap_version_postfix_seperator \\\n        tag_pattern_stable \\\n        postfix_dirty_marker_upstream \\\n        revision_minimal_length_upstream \\\n        \"${@}\"; then\n        printf \\\n            'Error: Error(s) occurred while determining the runtime parameters.\\n' \\\n            1>&2\n        exit 1\n    fi\n\n    if test \"${flag_debug_tracing}\" = true; then\n        set -o xtrace\n    fi\n\n    vcs_check_runtime_dependencies \\\n        \"${PWD}\"\n\n    if test \"${flag_force_snapshot}\" = true; then\n        printf -- \\\n            '%s: Info: Force building development snapshots\\n' \\\n            \"${script_name}\"\n        checkout_mode=snapshot\n    elif test \"$(vcs_detect \"${PWD}\")\" = not_found; then\n        printf -- \\\n            '%s: Info: Build from source archive\\n' \\\n            \"${script_name}\"\n        checkout_mode=snapshot\n    elif vcs_is_dirty \\\n        \"${PWD}\"; then\n        # If tracked files are modified\n        # or staging area not empty\n        printf -- \\\n            '%s: Info: Working tree is dirty, building development snapshot with additional changes\\n' \\\n            \"${script_name}\"\n        checkout_mode=snapshot\n    elif ! \\\n        vcs_has_release_tags \\\n            \"${PWD}\" \\\n            \"${tag_pattern_release}\"; then\n        printf -- \\\n            '%s: Warning: No release tags found, assuming building from development snapshots.\\n' \\\n            \"${script_name}\" \\\n            1>&2\n        checkout_mode=snapshot\n    else\n        printf -- \\\n            '%s: Info: Determining version to be built...\\n' \\\n            \"${script_name}\"\n        local \\\n            release_type_to_build=development-snapshot \\\n            last_stable_tag \\\n            last_stable_version \\\n            last_stable_version_on_the_snap_store \\\n            last_release_candidate_tag \\\n            last_release_candidate_version \\\n            last_release_candidate_version_on_the_snap_store \\\n            last_beta_tag \\\n            last_beta_version \\\n            last_beta_version_on_the_snap_store\n\n        local -a all_release_tags=()\n        local -A \\\n            map_of_tag_to_normalized_version \\\n            map_of_release_type_to_snap_channel\n\n        map_of_release_type_to_snap_channel=(\n            [stable]=stable\n            [release-candidate]=candidate\n            [beta]=beta\n            [development-snapshot]=edge\n        )\n\n        if ! {\n            test -v CRAFT_PROJECT_NAME \\\n            || test -v SNAPCRAFT_PROJECT_NAME\n            }; then\n            printf -- \\\n                \"%s: Error: This script requires either the CRAFT_PROJECT_NAME or the SNAPCRAFT_PROJECT_NAME environment variable to be set.\\\\n\" \\\n                \"${script_name}\" \\\n                >&2\n            exit 1\n        fi\n        local project_name=\"${CRAFT_PROJECT_NAME:-\"${SNAPCRAFT_PROJECT_NAME}\"}\"\n\n        mapfile \\\n            -t all_release_tags \\\n            < <(\n            vcs_query_release_tags \\\n                \"${PWD}\" \\\n                \"${tag_pattern_release}\"\n        )\n\n        if ! {\n            test -v CRAFT_PROJECT_DIR \\\n            || test -v SNAPCRAFT_PROJECT_DIR\n            }; then\n            printf -- \\\n                \"%s: Error: This script requires either the CRAFT_PROJECT_DIR or the SNAPCRAFT_PROJECT_DIR environment variable to be set.\\\\n\" \\\n                \"${script_name}\" \\\n                >&2\n            exit 1\n        fi\n        project_dir=\"${CRAFT_PROJECT_DIR:-\"${SNAPCRAFT_PROJECT_DIR}\"}\"\n\n        printf -- \\\n            '%s: INFO: Determining normalized release version strings.\\n' \\\n            \"${script_name}\"\n        determine_normalized_release_version_string \\\n            map_of_tag_to_normalized_version \\\n            \"${all_release_tags[@]}\"\n\n        printf -- \\\n            '%s: INFO: Detecting stable releases...\\n' \\\n            \"${script_name}\"\n        determine_stable_release_details \\\n            tag_pattern_stable \\\n            \"${tag_pattern_release_candidate}\" \\\n            \"${tag_pattern_beta}\" \\\n            last_stable_tag \\\n            last_stable_version \\\n            last_stable_version_on_the_snap_store \\\n            \"${snap_version_postfix_seperator}\" \\\n            \"${flag_force_stable}\" \\\n            \"${all_release_tags[@]}\"\n\n        printf -- \\\n            '%s: INFO: Detecting release candidate releases...\\n' \\\n            \"${script_name}\"\n        determine_release_candidate_release_details \\\n            \"${tag_pattern_release_candidate}\" \\\n            last_release_candidate_tag \\\n            last_release_candidate_version \\\n            last_release_candidate_version_on_the_snap_store \\\n            \"${snap_version_postfix_seperator}\" \\\n            \"${all_release_tags[@]}\"\n\n        printf -- \\\n            '%s: INFO: Detecting beta releases...\\n' \\\n            \"${script_name}\"\n        determine_beta_release_details \\\n            \"${tag_pattern_beta}\" \\\n            last_beta_tag \\\n            last_beta_version \\\n            last_beta_version_on_the_snap_store \\\n            \"${snap_version_postfix_seperator}\" \\\n            \"${all_release_tags[@]}\"\n\n        local \\\n            selected_release_tag \\\n            selected_release_version \\\n            selected_snap_channel_version\n        release_type_to_build=\"$(\n            determine_which_version_to_build \\\n                \"${last_stable_tag}\" \\\n                \"${last_stable_version}\" \\\n                \"${last_stable_version_on_the_snap_store}\" \\\n                \"${last_release_candidate_tag}\" \\\n                \"${last_release_candidate_version}\" \\\n                \"${last_release_candidate_version_on_the_snap_store}\" \\\n                \"${last_beta_tag}\" \\\n                \"${last_beta_version}\" \\\n                \"${last_beta_version_on_the_snap_store}\" \\\n                \"${flag_force_stable}\" \\\n                \"${flag_force_snapshot}\"\n        )\"\n\n\n        case \"${release_type_to_build}\" in\n            stable)\n                selected_release_tag=\"${last_stable_tag}\"\n                selected_release_version=\"${last_stable_version}\"\n                selected_snap_channel_version=\"${last_stable_version_on_the_snap_store}\"\n            ;;\n            release-candidate)\n                selected_release_tag=\"${last_release_candidate_tag}\"\n                selected_release_version=\"${last_release_candidate_version}\"\n                selected_snap_channel_version=\"${last_release_candidate_version_on_the_snap_store}\"\n            ;;\n            beta)\n                selected_release_tag=\"${last_beta_tag}\"\n                selected_release_version=\"${last_beta_version}\"\n                selected_snap_channel_version=\"${last_beta_version_on_the_snap_store}\"\n            ;;\n            development-snapshot)\n                : # Nothing to do here\n            ;;\n            *)\n                printf -- \\\n                    '%s: %s: FATAL: Invalid release_type_to_build(%s), report bug.\\n' \\\n                    \"${script_name}\" \\\n                    \"${FUNCNAME[0]}\" \\\n                    \"${release_type_to_build}\" \\\n                    1>&2\n                exit 1\n            ;;\n        esac\n\n        unset \\\n            flag_version_mismatch_stable \\\n            flag_version_mismatch_beta \\\n            flag_version_mismatch_release_candidate \\\n            flag_force_stable \\\n            last_stable_tag \\\n            last_stable_version \\\n            last_stable_version_on_the_snap_store \\\n            last_release_candidate_tag \\\n            last_release_candidate_version \\\n            last_release_candidate_version_on_the_snap_store \\\n            last_beta_tag \\\n            last_beta_version \\\n            last_beta_version_on_the_snap_store\n\n        if test \"${release_type_to_build}\" != development-snapshot; then\n            printf -- \\\n                \"%s: Info: The last tagged %s release(%s) hasn't been promoted to the %s channel(%s) on the Snap Store yet, checking out %s.\\\\n\" \\\n                \"${script_name}\" \\\n                \"${release_type_to_build}\" \\\n                \"${selected_release_version}\" \\\n                \"${map_of_release_type_to_snap_channel[\"${release_type_to_build}\"]}\" \\\n                \"${selected_snap_channel_version}\" \\\n                \"${selected_release_version}\"\n            checkout_mode=release\n        else\n            printf -- '%s: Info: Last tagged releases is all in their respective channels, building development snapshot\\n' \\\n                \"${script_name}\"\n            checkout_mode=snapshot\n        fi\n\n        unset \\\n            all_release_tags \\\n            map_of_release_type_to_snap_channel \\\n            release_type_to_build \\\n            selected_release_version \\\n            selected_snap_channel_version\n    fi\n\n    unset \\\n        tag_pattern_release\n\n    case \"${checkout_mode}\" in\n        snapshot)\n            : # do nothing\n        ;;\n        release)\n            if test \"${flag_dry_run}\" == true; then\n                printf -- \\\n                    '%s: Info: Would check out \"%s\" tag.\\n' \\\n                    \"${script_name}\" \\\n                    \"${selected_release_tag}\"\n            else\n                vcs_checkout_tag \\\n                    \"${PWD}\" \\\n                    \"${selected_release_tag}\"\n            fi\n        ;;\n        *)\n            printf -- \\\n                '%s: Error: Invalid checkout_mode selected.\\n' \\\n                \"${script_name}\" \\\n                >&2\n            exit 1\n        ;;\n    esac\n\n    unset \\\n        checkout_mode \\\n        selected_release_tag\n\n    upstream_version=\"$(\n        vcs_describe_version \\\n            \"${PWD}\" \\\n            \"${revision_minimal_length_upstream}\" \\\n            \"${postfix_dirty_marker_upstream}\" \\\n            | normalize_version\n    )\"\n\n    if test \"${flag_append_packaging_version}\" = true; then\n        packaging_revision=\"$(\n            vcs_describe_revision \\\n                \"${project_dir}\" \\\n                \"${revision_minimal_length_packaging}\" \\\n                \"${postfix_dirty_marker_packaging}\"\n        )\"\n        snap_version=\"${upstream_version}+pkg-${packaging_revision}\"\n        unset \\\n            project_dir \\\n            packaging_revision \\\n            postfix_dirty_marker_packaging \\\n            revision_minimal_length_packaging\n    else\n        snap_version=\"${upstream_version}\"\n    fi\n    unset \\\n        postfix_dirty_marker_upstream \\\n        revision_minimal_length_upstream \\\n        tag_prefix_release \\\n        upstream_version\n\n    printf -- '%s: Info: Snap version determined to be \"%s\".\\n' \\\n        \"${script_name}\" \\\n        \"${snap_version}\"\n    if test \"${flag_dry_run}\" = false; then\n        if command -v craftctl >/dev/null; then\n            if ! craftctl set version=\"${snap_version}\"; then\n                printf \\\n                    'Error: Unable to set the snap version string.\\n' \\\n                    1>&2\n                exit 2\n            fi\n        else\n            if ! snapcraftctl set-version \"${snap_version}\"; then\n                printf \\\n                    'Error: Unable to set the snap version string.\\n' \\\n                    1>&2\n                exit 2\n            fi\n        fi\n    fi\n    exit 0\n}\n\ndetermining_runtime_parameters(){\n    local -n flag_append_packaging_version_ref=\"${1}\"; shift\n    local -n tag_pattern_beta_ref=\"${1}\"; shift\n    local -n flag_debug_tracing_ref=\"${1}\"; shift\n    local -n flag_dry_run_ref=\"${1}\"; shift\n    local -n flag_force_snapshot_ref=\"${1}\"; shift\n    local -n flag_force_stable_ref=\"${1}\"; shift\n    local -n postfix_dirty_marker_packaging_ref=\"${1}\"; shift\n    local -n revision_minimal_length_packaging_ref=\"${1}\"; shift\n    local -n tag_pattern_release_candidate_ref=\"${1}\"; shift\n    local -n tag_pattern_release_ref=\"${1}\"; shift\n    local -n tag_prefix_release_ref=\"${1}\"; shift\n    local -n snap_version_postfix_seperator_ref=\"${1}\"; shift\n    local -n tag_pattern_stable_ref=\"${1}\"; shift\n    local -n postfix_dirty_marker_upstream_ref=\"${1}\"; shift\n    local -n revision_minimal_length_upstream_ref=\"${1}\"; shift\n\n    while true; do\n        if test \"${#}\" -eq 0; then\n            break\n        else\n            case \"${1}\" in\n                # Append packaging revision after snap version\n                --append-packaging-revision)\n                    # Indirect access\n                    # shellcheck disable=SC2034\n                    flag_append_packaging_version_ref=true\n                ;;\n                --beta-tag-pattern*)\n                    if test \"${1}\" != --beta-tag-pattern; then\n                        # Indirect access\n                        # shellcheck disable=SC2034\n                        tag_pattern_beta_ref=\"$(\n                            cut \\\n                                --delimiter== \\\n                                --fields=2 \\\n                                <<< \"${1}\"\n                        )\"\n                    else\n                        if test \"${#}\" -eq 0; then\n                            printf -- \\\n                                '%s: %s: Error: --beta-tag-pattern requires one argument.\\n' \\\n                                \"${SCRIPT_NAME}\" \\\n                                \"${FUNCNAME[0]}\" \\\n                                >&2\n                            return 1\n                        fi\n                        # Indirect access\n                        # shellcheck disable=SC2034\n                        tag_pattern_beta_ref=\"${2}\"\n                        shift 1\n                    fi\n                ;;\n                # Enable execution tracing\n                --debug)\n                    printf -- \\\n                        '%s: %s: Warning: The --debug command option is deprecated, set the SELECTIVE_CHECKOUT_DEBUG environment variable to \"true\" for a better debugging experience.\\n' \\\n                        \"${SCRIPT_NAME}\" \\\n                        \"${FUNCNAME[0]}\" \\\n                        >&2\n                    export SELECTIVE_CHECKOUT_DEBUG=true\n                ;;\n                --debug-tracing)\n                    # Indirect access\n                    # shellcheck disable=SC2034\n                    flag_debug_tracing_ref=true\n                ;;\n                # Don't run snapcraftctl for testing purpose\n                --dry-run)\n                    # Indirect access\n                    # shellcheck disable=SC2034\n                    flag_dry_run_ref=true\n                ;;\n                # Force building development snapshot regardless the status of the snap\n                --force-snapshot)\n                    # Indirect access\n                    # shellcheck disable=SC2034\n                    flag_force_snapshot_ref=true\n                ;;\n                --force-stable)\n                    # Indirect access\n                    # shellcheck disable=SC2034\n                    flag_force_stable_ref=true\n                ;;\n                --packaging-dirty-marker-postfix*)\n                    if test \"${1}\" != --packaging-dirty-marker-postfix; then\n                        # Indirect access\n                        # shellcheck disable=SC2034\n                        postfix_dirty_marker_packaging_ref=\"$(\n                            cut \\\n                                --delimiter== \\\n                                --fields=2 \\\n                                <<< \"${1}\"\n                        )\"\n                    else\n                        if test \"${#}\" -eq 0; then\n                            printf -- \\\n                                '%s: %s: Error: --packaging-dirty-marker-postfix requires one argument.\\n' \\\n                                \"${SCRIPT_NAME}\" \\\n                                \"${FUNCNAME[0]}\" \\\n                                >&2\n                            return 1\n                        fi\n                        # Indirect access\n                        # shellcheck disable=SC2034\n                        postfix_dirty_marker_packaging_ref=\"${2}\"\n                        shift 1\n                    fi\n                ;;\n                --packaging-revision-minimal-length*)\n                    if test \"${1}\" != --packaging-revision-minimal-length; then\n                        # Indirect access\n                        # shellcheck disable=SC2034\n                        revision_minimal_length_packaging_ref=\"$(\n                            cut \\\n                                --delimiter== \\\n                                --fields=2 \\\n                                <<< \"${1}\"\n                        )\"\n                    else\n                        if test \"${#}\" -eq 0; then\n                            printf -- \\\n                                '%s: %s: Error: --packaging-revision-minimal-length requires one argument.\\n' \\\n                                \"${SCRIPT_NAME}\" \\\n                                \"${FUNCNAME[0]}\" \\\n                                >&2\n                            return 1\n                        fi\n                        # Indirect access\n                        # shellcheck disable=SC2034\n                        revision_minimal_length_packaging_ref=\"${2}\"\n                        shift 1\n                    fi\n                ;;\n                --release-candidate-tag-pattern*)\n                    if test \"${1}\" != --release-candidate-tag-pattern; then\n                        # Indirect access\n                        # shellcheck disable=SC2034\n                        tag_pattern_release_candidate_ref=\"$(\n                            cut \\\n                                --delimiter== \\\n                                --fields=2 \\\n                                <<< \"${1}\"\n                        )\"\n                    else\n                        if test \"${#}\" -eq 0; then\n                            printf -- \\\n                                '%s: %s: Error: --release-candidate-tag-pattern requires one argument.\\n' \\\n                                \"${SCRIPT_NAME}\" \\\n                                \"${FUNCNAME[0]}\" \\\n                                >&2\n                            return 1\n                        fi\n                        # Indirect access\n                        # shellcheck disable=SC2034\n                        tag_pattern_release_candidate_ref=\"${2}\"\n                        shift 1\n                    fi\n                ;;\n                --release-tag-pattern*)\n                    if test \"${1}\" != --release-tag-pattern; then\n                        # Indirect access\n                        # shellcheck disable=SC2034\n                        tag_pattern_release_ref=\"$(\n                            cut \\\n                                --delimiter== \\\n                                --fields=2 \\\n                                <<< \"${1}\"\n                        )\"\n                    else\n                        if test \"${#}\" -eq 0; then\n                            printf -- \\\n                                '%s: %s: Error: --release-tag-pattern requires one argument.\\n' \\\n                                \"${SCRIPT_NAME}\" \\\n                                \"${FUNCNAME[0]}\" \\\n                                >&2\n                            return 1\n                        fi\n                        # Indirect access\n                        # shellcheck disable=SC2034\n                        tag_pattern_release_ref=\"${2}\"\n                        shift 1\n                    fi\n                ;;\n                # Set the prefix for all release tags(default: `v`), the prefix will be stripped from snap version string\n                --release-tag-prefix*)\n                    if test \"${1}\" != --release-tag-prefix; then\n                        # Indirect access\n                        # shellcheck disable=SC2034\n                        tag_prefix_release_ref=\"$(\n                            cut \\\n                                --delimiter== \\\n                                --fields=2 \\\n                                <<< \"${1}\"\n                        )\"\n                    else\n                        if test \"${#}\" -eq 0; then\n                            printf -- \\\n                                '%s: %s: Error: --release-tag-prefix requires one argument.\\n' \\\n                                \"${SCRIPT_NAME}\" \\\n                                \"${FUNCNAME[0]}\" \\\n                                >&2\n                            return 1\n                        fi\n                        # Indirect access\n                        # shellcheck disable=SC2034\n                        tag_prefix_release_ref=\"${2}\"\n                        shift 1\n                    fi\n                ;;\n                # Set the seperator for the postfixed string in the snap version string\n                # the postfixed string will be stripped before comparing with the stripped\n                # uptream release version\n                --snap-postfix-seperator*)\n                    if test \"${1}\" != --snap-postfix-seperator; then\n                        # Indirect access\n                        # shellcheck disable=SC2034\n                        snap_version_postfix_seperator_ref=\"$(\n                            cut \\\n                                --delimiter== \\\n                                --fields=2 \\\n                                <<< \"${1}\"\n                        )\"\n                    else\n                        if test \"${#}\" -eq 0; then\n                            printf -- \\\n                                '%s: %s: Error: --snap-postfix-seperator requires one argument.\\n' \\\n                                \"${SCRIPT_NAME}\" \\\n                                \"${FUNCNAME[0]}\" \\\n                                >&2\n                            return 1\n                        fi\n                        # Indirect access\n                        # shellcheck disable=SC2034\n                        snap_version_postfix_seperator_ref=\"${2}\"\n                        shift 1\n                    fi\n                ;;\n                --stable-tag-pattern*)\n                    if test \"${1}\" != --stable-tag-pattern; then\n                        # Indirect access\n                        # shellcheck disable=SC2034\n                        tag_pattern_stable_ref=\"$(\n                            cut \\\n                                --delimiter== \\\n                                --fields=2 \\\n                                <<< \"${1}\"\n                        )\"\n                    else\n                        if test \"${#}\" -eq 0; then\n                            printf -- \\\n                                '%s: %s: Error: --stable-tag-pattern requires one argument.\\n' \\\n                                \"${SCRIPT_NAME}\" \\\n                                \"${FUNCNAME[0]}\" \\\n                                >&2\n                            return 1\n                        fi\n                        # Indirect access\n                        # shellcheck disable=SC2034\n                        tag_pattern_stable_ref=\"${2}\"\n                        shift 1\n                    fi\n                ;;\n                --upstream-dirty-marker-postfix*)\n                    if test \"${1}\" != --upstream-dirty-marker-postfix; then\n                        # Indirect access\n                        # shellcheck disable=SC2034\n                        postfix_dirty_marker_upstream_ref=\"$(\n                            cut \\\n                                --delimiter== \\\n                                --fields=2 \\\n                                <<< \"${1}\"\n                        )\"\n                    else\n                        if test \"${#}\" -eq 0; then\n                            printf -- \\\n                                '%s: %s: Error: --upstream-dirty-marker-postfix requires one argument.\\n' \\\n                                \"${SCRIPT_NAME}\" \\\n                                \"${FUNCNAME[0]}\" \\\n                                >&2\n                            return 1\n                        fi\n                        # Indirect access\n                        # shellcheck disable=SC2034\n                        postfix_dirty_marker_upstream_ref=\"${2}\"\n                        shift 1\n                    fi\n                ;;\n                --upstream-revision-minimal-length*)\n                    if test \"${1}\" != --upstream-revision-minimal-length; then\n                        # Indirect access\n                        # shellcheck disable=SC2034\n                        revision_minimal_length_upstream_ref=\"$(\n                            cut \\\n                                --delimiter== \\\n                                --fields=2 \\\n                                <<< \"${1}\"\n                        )\"\n                    else\n                        if test \"${#}\" -eq 0; then\n                            printf -- \\\n                                '%s: %s: Error: --upstream-revision-minimal-length requires one argument.\\n' \\\n                                \"${SCRIPT_NAME}\" \\\n                                \"${FUNCNAME[0]}\" \\\n                                >&2\n                            return 1\n                        fi\n                        # Indirect access\n                        # shellcheck disable=SC2034\n                        revision_minimal_length_upstream_ref=\"${2}\"\n                        shift 1\n                    fi\n                ;;\n                *)\n                    printf -- \\\n                        '%s: %s: Error: Invalid command-line argument \"%s\".\\n' \\\n                        \"${SCRIPT_NAME}\" \\\n                        \"${FUNCNAME[0]}\" \\\n                        \"${1}\" \\\n                        >&2\n                    return 1\n                ;;\n            esac\n            shift 1\n        fi\n    done\n}\n\nnormalize_version(){\n    # This is not a simple substitution\n    # shellcheck disable=SC2001\n    sed \"s#^${tag_prefix_release}##\" \\\n        | tr _ .\n}\n\ndetermine_normalized_release_version_string(){\n    local -n map_of_tag_to_normalized_version_ref=\"${1}\"; shift\n    local -a all_release_tags=(\"${@}\"); set --\n\n    local normalized_release_version\n    for tag in \"${all_release_tags[@]}\"; do\n        if test \"${SELECTIVE_CHECKOUT_DEBUG}\" == true; then\n            printf -- \\\n                '%s: %s: DEBUG: Found release tag: %s\\n' \\\n                \"${SCRIPT_NAME}\" \\\n                \"${FUNCNAME[0]}\" \\\n                \"${tag}\" \\\n                1>&2\n        fi\n        normalized_release_version=\"$(\n            normalize_version <<< \"${tag}\"\n        )\"\n\n        if test \"${SELECTIVE_CHECKOUT_DEBUG}\" == true; then\n            printf -- \\\n                '%s: %s: DEBUG: Normalized release version: %s\\n' \\\n                \"${SCRIPT_NAME}\" \\\n                \"${FUNCNAME[0]}\" \\\n                \"${normalized_release_version}\" \\\n                1>&2\n        fi\n        # Indirection\n        # shellcheck disable=SC2034\n        map_of_tag_to_normalized_version_ref[\"${tag}\"]=\"${normalized_release_version}\"\n    done\n    unset \\\n        normalized_release_version \\\n        tag\n}\n\ndetermine_stable_release_details(){\n    # Indirection\n    # shellcheck disable=SC2034\n    local -n tag_pattern_stable_ref=\"${1}\"; shift\n    local tag_pattern_release_candidate=\"${1}\"; shift\n    local tag_pattern_beta=\"${1}\"; shift\n    local -n last_stable_tag_ref=\"${1}\"; shift\n    local -n last_stable_version_ref=\"${1}\"; shift\n    local -n last_stable_version_on_the_snap_store_ref=\"${1}\"; shift\n    local snap_version_postfix_seperator=\"${1}\"; shift\n    local flag_force_stable=\"${1}\"; shift\n    local -a all_release_tags=(\"${@}\"); set --\n\n    local stable_tag_pattern_grep_opts\n    if test -v tag_pattern_stable_ref; then\n        stable_tag_pattern_grep_opts=\n    else\n        stable_tag_pattern_grep_opts=--invert-match\n        tag_pattern_stable_ref=\"${tag_pattern_beta}|${tag_pattern_release_candidate}\"\n    fi\n\n    local -a stable_tags=()\n    mapfile \\\n        -t stable_tags \\\n        < <(\n        echo -n \"${all_release_tags[@]}\" \\\n            | tr ' ' \"\\\\n\" \\\n            | grep \\\n                --extended-regexp \\\n                ${stable_tag_pattern_grep_opts} \\\n                --regexp=\"(${tag_pattern_stable_ref})\"\n    )\n\n    if test \"${#stable_tags[@]}\" -eq 0; then\n        last_stable_tag_ref=\n        last_stable_version_ref=\n\n        # NOTE: The store CAN have releases, though...\n        last_stable_version_on_the_snap_store_ref=\n    else\n        last_stable_tag_ref=\"$(\n            echo -n \"${stable_tags[@]}\" \\\n                | tr ' ' \"\\\\n\" \\\n                | sort --version-sort \\\n                | tail --lines=1\n\n        )\"\n        if test \"${SELECTIVE_CHECKOUT_DEBUG}\" == true; then\n            printf -- \\\n                '%s: %s: DEBUG: Last stable release tag determines to be \"%s\".\\n' \\\n                \"${SCRIPT_NAME}\" \\\n                \"${FUNCNAME[0]}\" \\\n                \"${last_stable_tag_ref}\" \\\n                1>&2\n        fi\n\n        last_stable_version_ref=\"${map_of_tag_to_normalized_version[\"${last_stable_tag}\"]}\"\n        if test \"${SELECTIVE_CHECKOUT_DEBUG}\" == true; then\n            printf -- \\\n                '%s: %s: DEBUG: Last stable version determines to be \"%s\".\\n' \\\n                \"${SCRIPT_NAME}\" \\\n                \"${FUNCNAME[0]}\" \\\n                \"${last_stable_version_ref}\" \\\n                1>&2\n        fi\n\n        last_stable_version_on_the_snap_store_ref=\"$(\n            snap_query_version \\\n                \"${project_name}\" \\\n                stable \\\n                \"${snap_version_postfix_seperator}\"\n        )\"\n        if test \"${SELECTIVE_CHECKOUT_DEBUG}\" == true; then\n            printf -- \\\n                '%s: %s: DEBUG: Last stable version on the snap store determines to be \"%s\".\\n' \\\n                \"${SCRIPT_NAME}\" \\\n                \"${FUNCNAME[0]}\" \\\n                \"${last_stable_version_on_the_snap_store_ref}\" \\\n                1>&2\n        fi\n    fi\n    unset \\\n        stable_tag_pattern_grep_opts \\\n        tag_pattern_stable\n}\n\ndetermine_release_candidate_release_details(){\n    local tag_pattern_release_candidate=\"${1}\"; shift\n    local -n last_release_candidate_tag_ref=\"${1}\"; shift\n    local -n last_release_candidate_version_ref=\"${1}\"; shift\n    local -n last_release_candidate_version_on_the_snap_store_ref=\"${1}\"; shift\n    local snap_version_postfix_seperator=\"${1}\"; shift\n    local -a all_release_tags=(\"${@}\"); set --\n\n    local -a release_candidate_tags\n    mapfile \\\n        -t release_candidate_tags \\\n        < <(\n        echo -n \"${all_release_tags[@]}\" \\\n            | tr ' ' \"\\\\n\" \\\n            | grep \\\n                --extended-regexp \\\n                --regexp=\"${tag_pattern_release_candidate}\"\n    )\n\n    if test \"${#release_candidate_tags[@]}\" -eq 0; then\n        if test \"${SELECTIVE_CHECKOUT_DEBUG}\" == true; then\n            printf -- \\\n                '%s: DEBUG: No release candidate release tags found.\\n' \\\n                \"${SCRIPT_NAME}\" \\\n                1>&2\n        fi\n        last_release_candidate_tag_ref=\n        last_release_candidate_version_ref=\n        last_release_candidate_version_on_the_snap_store_ref=\n    else\n        last_release_candidate_tag_ref=\"$(\n            echo -n \"${release_candidate_tags[@]}\" \\\n                | tr ' ' \"\\\\n\" \\\n                | grep \\\n                    --extended-regexp \\\n                    --regexp=\"${tag_pattern_release_candidate}\" \\\n                | sort --version-sort \\\n                | tail --lines=1\n        )\"\n        if test \"${SELECTIVE_CHECKOUT_DEBUG}\" == true; then\n            printf -- \\\n                '%s: DEBUG: Last release candidate release tag determines to be \"%s\".\\n' \\\n                \"${SCRIPT_NAME}\" \\\n                \"${last_release_candidate_tag_ref}\" \\\n                1>&2\n        fi\n\n        last_release_candidate_version_ref=\"${map_of_tag_to_normalized_version[\"${last_release_candidate_tag_ref}\"]}\"\n        if test \"${SELECTIVE_CHECKOUT_DEBUG}\" == true; then\n            printf -- \\\n                '%s: DEBUG: Last release candidate version determines to be \"%s\".\\n' \\\n                \"${SCRIPT_NAME}\" \\\n                \"${last_release_candidate_version_ref}\" \\\n                1>&2\n        fi\n\n        last_release_candidate_version_on_the_snap_store_ref=\"$(\n            snap_query_version \\\n                \"${project_name}\" \\\n                candidate \\\n                \"${snap_version_postfix_seperator}\"\n        )\"\n        if test \"${SELECTIVE_CHECKOUT_DEBUG}\" == true; then\n            printf -- \\\n                '%s: DEBUG: Last release candidate version on the snap store determines to be \"%s\".\\n' \\\n                \"${SCRIPT_NAME}\" \\\n                \"${last_release_candidate_version_on_the_snap_store_ref}\" \\\n                1>&2\n        fi\n    fi\n    unset \\\n        tag_pattern_release_candidate\n}\n\ndetermine_beta_release_details(){\n    local tag_pattern_beta=\"${1}\"; shift\n    local -n last_beta_tag_ref=\"${1}\"; shift\n    local -n last_beta_version_ref=\"${1}\"; shift\n    local -n last_beta_version_on_the_snap_store_ref=\"${1}\"; shift\n    local snap_version_postfix_seperator=\"${1}\"; shift\n    local all_release_tags=(\"${@}\"); set --\n\n    local -a beta_tags\n    mapfile \\\n        -t beta_tags \\\n        < <(\n        echo -n \"${all_release_tags[@]}\" \\\n            | tr ' ' \"\\\\n\" \\\n            | grep \\\n                --extended-regexp \\\n                --regexp=\"${tag_pattern_beta}\"\n    )\n\n    if test \"${#beta_tags[@]}\" -eq 0; then\n        if test \"${SELECTIVE_CHECKOUT_DEBUG}\" == true; then\n            printf -- \\\n                '%s: DEBUG: No beta release tags found.\\n' \\\n                \"${SCRIPT_NAME}\" \\\n                1>&2\n        fi\n        last_beta_tag_ref=\n        last_beta_version_ref=\n        last_beta_version_on_the_snap_store=\n    else\n        last_beta_tag_ref=\"$(\n            echo -n \"${beta_tags[@]}\" \\\n                | tr ' ' \"\\\\n\" \\\n                | grep \\\n                    --extended-regexp \\\n                    --regexp=\"${tag_pattern_beta}\" \\\n                | sort --version-sort \\\n                | tail --lines=1\n        )\"\n        if test \"${SELECTIVE_CHECKOUT_DEBUG}\" == true; then\n            printf -- \\\n                '%s: DEBUG: Last beta release tag determines to be \"%s\".\\n' \\\n                \"${SCRIPT_NAME}\" \\\n                \"${last_beta_tag_ref}\" \\\n                1>&2\n        fi\n\n        last_beta_version_ref=\"${map_of_tag_to_normalized_version[\"${last_beta_tag_ref}\"]}\"\n        if test \"${SELECTIVE_CHECKOUT_DEBUG}\" == true; then\n            printf -- \\\n                '%s: DEBUG: Last beta version determines to be \"%s\".\\n' \\\n                \"${SCRIPT_NAME}\" \\\n                \"${last_beta_version_ref}\" \\\n                1>&2\n        fi\n\n        last_beta_version_on_the_snap_store=\"$(\n            snap_query_version \\\n                \"${project_name}\" \\\n                beta \\\n                \"${snap_version_postfix_seperator}\"\n        )\"\n        if test \"${SELECTIVE_CHECKOUT_DEBUG}\" == true; then\n            printf -- \\\n                '%s: DEBUG: Last beta version on the snap store determines to be \"%s\".\\n' \\\n                \"${SCRIPT_NAME}\" \\\n                \"${last_beta_version_on_the_snap_store_ref}\" \\\n                1>&2\n        fi\n    fi\n    unset \\\n        map_of_tag_to_normalized_version \\\n        snap_version_postfix_seperator \\\n        tag_pattern_beta\n}\n\ndetermine_which_version_to_build(){\n    local last_stable_tag=\"${1}\"; shift\n    local last_stable_version=\"${1}\"; shift\n    local last_stable_version_on_the_snap_store=\"${1}\"; shift\n    local last_release_candidate_tag=\"${1}\"; shift\n    local last_release_candidate_version=\"${1}\"; shift\n    local last_release_candidate_version_on_the_snap_store=\"${1}\"; shift\n    local last_beta_tag=\"${1}\"; shift\n    local last_beta_version=\"${1}\"; shift\n    local last_beta_version_on_the_snap_store=\"${1}\"; shift\n    local flag_force_stable=\"${1}\"; shift\n    local flag_force_snapshot=\"${1}\"; shift\n\n    local release_type_to_build=undetermined\n\n    # Case: --force-snapshot is specified\n    if test \"${flag_force_snapshot}\" == true; then\n        printf -- \\\n            '%s: %s: DEBUG: The --force-snapshot command option is specified, development snapshot will be built.\\n' \\\n            \"${SCRIPT_NAME}\" \\\n            \"${FUNCNAME[0]}\" \\\n            1>&2\n        release_type_to_build=development-snapshot\n    # Case: Build the stable version when:\n    #\n    # --force-stable command-line option is specified\n    #   AND There is stable release version to build on\n    elif test \"${flag_force_stable}\" == true; then\n        if test -z \"${last_stable_version}\"; then\n            printf -- \\\n                '%s: %s: Error: The --force-stable command-line option is specified, but no stable versions are found.\\n' \\\n                \"${SCRIPT_NAME}\" \\\n                \"${FUNCNAME[0]}\" \\\n                1>&2\n            return 1\n        fi\n\n        printf -- \\\n            '%s: %s: DEBUG: The --force-stable command-line option is specified, stable release will be built.\\n' \\\n            \"${SCRIPT_NAME}\" \\\n            \"${FUNCNAME[0]}\" \\\n            1>&2\n        release_type_to_build=stable\n    # Case: Build the stable version when:\n    #\n    # The stable version is available\n    #   AND (\n    #     There's no snap available in the stable channel of the Snap\n    #     Store\n    #       OR The stable version is newer than the one on the stable\n    #          channel of the snap store\n    #   )\n    elif test -n \"${last_stable_version}\" \\\n        && {\n            test -z \"${last_stable_version_on_the_snap_store}\" \\\n            || version_former_is_greater_than_latter \\\n                \"${last_stable_version}\" \\\n                \"${last_stable_version_on_the_snap_store}\"\n        }; then\n        if test \"${SELECTIVE_CHECKOUT_DEBUG}\" == true; then\n            printf -- \\\n                '%s: %s: DEBUG: Stable version(%s) is newer than the one on the Snap Store(%s), stable release will be built.\\n' \\\n                \"${SCRIPT_NAME}\" \\\n                \"${FUNCNAME[0]}\" \\\n                \"${last_stable_version}\" \\\n                \"${last_stable_version_on_the_snap_store:-none}\" \\\n                1>&2\n        fi\n        release_type_to_build=stable\n    # Case: Build release candidate version when:\n    #\n    # Release candidate version is available\n    #   AND (\n    #     There's no snap on the Snap Store's stable channel\n    #       OR The release candidate version is newer than the version\n    #          on the Snap Store's stable channel\n    #   )\n    #   AND (\n    #     There's no release in the Snap Store's candidate channel\n    #       OR The release candidate version is newer than the version\n    #          in the Snap Store's candidate channel\n    #   )\n    elif test -n \"${last_release_candidate_version}\" \\\n        && {\n            test -z \"${last_stable_version_on_the_snap_store}\" \\\n                || version_former_is_greater_than_latter \\\n                    \"${last_release_candidate_version}\" \\\n                    \"${last_stable_version_on_the_snap_store}\"\n        } && {\n            test -z \"${last_release_candidate_version_on_the_snap_store}\" \\\n                ||  version_former_is_greater_than_latter \\\n                    \"${last_release_candidate_version}\" \\\n                    \"${last_release_candidate_version_on_the_snap_store}\"\n        }; then\n        if test \"${SELECTIVE_CHECKOUT_DEBUG}\" == true; then\n            printf -- \\\n                \"%s: %s: DEBUG: Release candidate version(%s) is not on the Snap Store's candidate channel and is newer than the version on the stable channel(%s), release candidate version will be built.\\\\n\" \\\n                \"${SCRIPT_NAME}\" \\\n                \"${FUNCNAME[0]}\" \\\n                \"${last_release_candidate_version}\" \\\n                \"${last_stable_version_on_the_snap_store:-none}\" \\\n                1>&2\n        fi\n        release_type_to_build=release-candidate\n    # Case: Build beta version when\n    #\n    # The beta version is available\n    #   AND (\n    #     There's NO snap on the stable channel of the Snap Store\n    #       OR The beta version is newer than the stable version on\n    #          the Snap Store\n    #   )\n    #   AND (\n    #     There's NO snap on the candidate channel of the Snap Store\n    #       OR The beta version is newer than the candidate version\n    #          on the Snap Store\n    #   )\n    #   AND (\n    #     There's NO snap on the beta channel of the Snap Store\n    #       OR The beta version is newer than the beta version on\n    #          the Snap Store\n    #   )\n    elif test -n \"${last_beta_version}\" \\\n        && {\n            test -z \"${last_stable_version_on_the_snap_store}\" \\\n                || version_former_is_greater_than_latter \\\n                    \"${last_beta_version}\" \\\n                    \"${last_stable_version_on_the_snap_store}\"\n        } && {\n            test -z \"${last_release_candidate_version_on_the_snap_store}\" \\\n                || version_former_is_greater_than_latter \\\n                    \"${last_beta_version}\" \\\n                    \"${last_release_candidate_version_on_the_snap_store}\"\n        } && {\n            test -z \"${last_beta_version_on_the_snap_store}\" \\\n                || version_former_is_greater_than_latter \\\n                    \"${last_beta_version}\" \\\n                    \"${last_beta_version_on_the_snap_store}\"\n        }; then\n        if test \"${SELECTIVE_CHECKOUT_DEBUG}\" == true; then\n            printf -- \\\n                \"%s: %s: DEBUG: Beta version(%s) is newer than the versions in the stabler channels(stable(%s), candidate(%s)), and is newer than the one shipped in the Snap Store's beta channel(%s), beta version will be built.\\\\n\" \\\n                \"${SCRIPT_NAME}\" \\\n                \"${FUNCNAME[0]}\" \\\n                \"${last_beta_version}\" \\\n                \"${last_stable_version_on_the_snap_store:-none}\" \\\n                \"${last_release_candidate_version_on_the_snap_store:-none}\" \\\n                \"${last_beta_version_on_the_snap_store:-none}\" \\\n                1>&2\n        fi\n        release_type_to_build=beta\n    # Case: Build development snapshot when:\n    #\n    # All other versions are built\n    else\n        if test \"${SELECTIVE_CHECKOUT_DEBUG}\" == true; then\n            printf -- \\\n                \"%s: %s: DEBUG: All other versions are on the Snap Store(stable(%s), candidate(%s), beta(%s)), development snapshot will be built.\\\\n\" \\\n                \"${SCRIPT_NAME}\" \\\n                \"${FUNCNAME[0]}\" \\\n                \"${last_stable_version_on_the_snap_store:-none}\" \\\n                \"${last_release_candidate_version_on_the_snap_store:-none}\" \\\n                \"${last_beta_version_on_the_snap_store:-none}\" \\\n                1>&2\n        fi\n        release_type_to_build=development-snapshot\n    fi\n\n    printf \\\n        '%s' \\\n        \"${release_type_to_build}\"\n}\n\n# Query snap version for specific channel, we use the Snap Store API\n# Output: snap version string, or null string if the channel has no\n# snap\n# http://api.snapcraft.io/docs/info.html#snap_info\nsnap_query_version(){\n    if test $# -ne 3; then\n        printf 'FATAL: %s: Parameter quantity mismatch.\\n' \"${FUNCNAME[0]}\" >&2\n        exit 1\n    fi\n\n    # positional parameters\n    local snap_identifier=\"${1}\"; shift\n    local release_channel=\"${1}\"; shift\n    local snap_version_postfix_seperator=\"${1}\"; shift\n\n    snap_arch=\"${SNAP_ARCH:-amd64}\"\n\n    local snap_version_in_release_channel\n    local info_store_api_call_response\n\n    if test \"${SELECTIVE_CHECKOUT_DEBUG}\" == true; then\n        printf -- \\\n            '%s: %s: DEBUG: Checking what snap revisions are available for the %s snap at the %s release channel...\\n' \\\n            \"${SCRIPT_NAME}\" \\\n            \"${FUNCNAME[0]}\" \\\n            \"${snap_identifier}\" \\\n            \"${release_channel}\" \\\n            1>&2\n    fi\n    if ! info_store_api_call_response=\"$(\n        curl \\\n            --fail \\\n            --silent \\\n            --show-error \\\n            --header 'Snap-Device-Series: 16' \\\n            \"https://api.snapcraft.io/v2/snaps/info/${snap_identifier}?fields=version&architecture=${snap_arch}\" \\\n            2>&1\n        )\"; then\n        # If the response is 404, the snap hasn't published to the\n        # the specified channel(or, at all) at the Snap Store yet\n        regex_curl_request_returns_404='\\(22\\).*: 404$'\n        grep_opts=(\n            --extended-regexp\n            --regexp=\"${regex_curl_request_returns_404}\"\n            --quiet\n        )\n        if grep \"${grep_opts[@]}\" <<<\"${info_store_api_call_response}\"; then\n            if test \"${SELECTIVE_CHECKOUT_DEBUG}\" == true; then\n                printf -- \\\n                    \"%s: %s: DEBUG: The /snaps/info/ Snap Store API call returns 404, interpreting as the snap hasn't published to the store...\\\\n\" \\\n                    \"${SCRIPT_NAME}\" \\\n                    \"${FUNCNAME[0]}\" \\\n                    1>&2\n            fi\n            return 0\n        fi\n\n        printf \\\n            '%s: %s: Error: Unable to call the Snap Store info API to retrieve snap revision info: %s\\n' \\\n            \"${SCRIPT_NAME}\" \\\n            \"${FUNCNAME[0]}\" \\\n            \"${info_store_api_call_response}\" \\\n            1>&2\n        return 1\n    fi\n\n    if ! snap_version_in_release_channel=\"$(\n        jq \\\n            --raw-output \\\n            \".\\\"channel-map\\\"[]\n            | select(\n                .channel.name == \\\"${release_channel}\\\"\n            ).version\" \\\n            <<< \"${info_store_api_call_response}\" \\\n            2>&1\n        )\"; then\n        printf \\\n            '%s: %s: Error: Unable to query the snap version from the store info API response: %s\\n' \\\n            \"${SCRIPT_NAME}\" \\\n            \"${FUNCNAME[0]}\" \\\n            \"${snap_version_in_release_channel}\" \\\n            1>&2\n        return 2\n    fi\n    if test -z \"${snap_version_in_release_channel}\"; then\n        # No snaps available in the channel\n        return 0\n    fi\n    if test \"${SELECTIVE_CHECKOUT_DEBUG}\" == true; then\n        printf \\\n            '%s: %s: DEBUG: Snap version determined to be: \"%s\".\\n' \\\n            \"${SCRIPT_NAME}\" \\\n            \"${FUNCNAME[0]}\" \\\n            \"${snap_version_in_release_channel}\" \\\n            1>&2\n    fi\n\n    printf %s \"${snap_version_in_release_channel}\"\n}\n\n# Determine which VCS is used\n# FIXME: Allow specifying any node under the working directory, not just the root node\nvcs_detect(){\n    if test $# -ne 1; then\n        printf 'FATAL: %s: Parameter quantity mismatch.\\n' \"${FUNCNAME[0]}\" >&2\n        exit 1\n    fi\n\n    local source_tree_root_dir=\"${1}\"; shift 1\n\n    if test -e \"${source_tree_root_dir}\"/.git; then\n        printf git\n    elif test -e \"${source_tree_root_dir}\"/.hg; then\n        printf mercurial\n    elif test -e \"${source_tree_root_dir}\"/.svn; then\n        printf subversion\n    else\n        printf not_found\n    fi\n}\n\n# Ensure depending software is available before using them\nvcs_check_runtime_dependencies(){\n    if test $# -ne 1; then\n        printf 'FATAL: %s: Parameter quantity mismatch.\\n' \"${FUNCNAME[0]}\" >&2\n        exit 1\n    fi\n\n    local -r source_tree_root_dir=\"${1}\"; shift 1\n\n    case \"$(vcs_detect \"${source_tree_root_dir}\")\" in\n        git)\n            if ! command -v git &>/dev/null; then\n                # Markdown code markup is not Bash tilde expression\n                # shellcheck disable=SC2016\n                printf -- \\\n                    '%s: %s: Error: `git` command not found in the command search PATHs.\\n' \\\n                    \"${SCRIPT_NAME}\" \\\n                    \"${FUNCNAME[0]}\" \\\n                    >&2\n                return 1\n            fi\n        ;;\n        mercurial)\n            if ! command -v hg &>/dev/null; then\n                # Markdown code markup is not Bash tilde expression\n                # shellcheck disable=SC2016\n                printf -- \\\n                    '%s: %s: Error: `hg` command not found in the command search PATHs.\\n' \\\n                    \"${SCRIPT_NAME}\" \\\n                    \"${FUNCNAME[0]}\" \\\n                    >&2\n                return 1\n            fi\n        ;;\n        subversion)\n            if ! command -v svn &>/dev/null; then\n                # Markdown code markup is not Bash tilde expression\n                # shellcheck disable=SC2016\n                printf -- \\\n                    '%s: %s: Error: `svn` command not found in the command search PATHs.\\n' \\\n                    \"${SCRIPT_NAME}\" \\\n                    \"${FUNCNAME[0]}\" \\\n                    >&2\n                return 1\n            fi\n        ;;\n        *)\n            printf -- \\\n                '%s: %s: Warning: Unknown VCS type, assuming none.\\n' \\\n                \"${SCRIPT_NAME}\" \\\n                \"${FUNCNAME[0]}\" \\\n                >&2\n            return 0\n        ;;\n    esac\n}\n\nvcs_is_dirty(){\n    if test $# -ne 1; then\n        printf 'FATAL: %s: Parameter quantity mismatch.\\n' \"${FUNCNAME[0]}\" >&2\n        exit 1\n    fi\n\n    local -r source_tree_root_dir=\"${1}\"; shift 1\n\n    case \"$(vcs_detect \"${source_tree_root_dir}\")\" in\n        git)\n            # If tracked files are modified\n            # or staging area not empty\n            if ! \\\n                git -C \"${source_tree_root_dir}\" diff \\\n                    --quiet \\\n                || ! \\\n                git -C \"${source_tree_root_dir}\"  diff \\\n                    --staged \\\n                    --quiet; then\n                return 0\n            else\n                return 1\n            fi\n        ;;\n        mercurial)\n            # NOTE:\n            # The existence of untracked files is not consider dirty,\n            # imitating Git's `--dirty` option of the describe\n            # subcommand\n            if test -n \"$(\n                hg --cwd \"${source_tree_root_dir}\" status \\\n                    --added \\\n                    --deleted \\\n                    --modified \\\n                    --removed\n            )\"; then\n                return 0\n            else\n                return 1\n            fi\n        ;;\n        subversion)\n            # NOTE: Is there any straightforward way to check this?\n            if test -n \"$(\n                svn status -q\n            )\"; then\n                return 0\n            else\n                return 1\n            fi\n        ;;\n        *)\n            printf -- \\\n                '%s: %s: Warning: Unknown VCS type, assuming dirty.\\n' \\\n                \"${SCRIPT_NAME}\" \\\n                \"${FUNCNAME[0]}\" \\\n                >&2\n            return 0\n        ;;\n    esac\n}\n\nvcs_has_release_tags() {\n    if test $# -ne 2; then\n        printf 'FATAL: %s: Parameter quantity mismatch.\\n' \"${FUNCNAME[0]}\" >&2\n        exit 1\n    fi\n\n    local -r source_tree_root_dir=\"${1}\"; shift 1\n\n    # Pattern to match a release tag, in extended regular expression(ERE)\n    local -r tag_pattern_release=\"${1}\"; shift 1\n\n    case \"$(vcs_detect \"${source_tree_root_dir}\")\" in\n        git)\n            if git -C \"${source_tree_root_dir}\" tag \\\n                --list \\\n                | grep \\\n                    --extended-regexp \\\n                    --quiet \\\n                    --regexp=\"${tag_pattern_release}\"; then\n                return 0\n            else\n                return 1\n            fi\n        ;;\n        mercurial)\n            if hg --cwd \"${source_tree_root_dir}\" tags \\\n                | grep \\\n                    --extended-regexp \\\n                    --quiet \\\n                    --regexp=\"${tag_pattern_release}\"; then\n                return 0\n            else\n                return 1\n            fi\n        ;;\n        subversion)\n            local \\\n                source_tree_root_url \\\n                tags_dir_url\n\n            source_tree_root_url=\"$(\n                svn info \\\n                    --show-item url \\\n                    \"${source_tree_root_dir}\"\n            )\"\n\n            # Supported source URLs:\n            # * /trunk\n            # * /tags/_tag_name_\n            case \"${source_tree_root_url}\" in\n                */trunk)\n                    # Strip trailing shortest matched pattern\n                    # https://www.gnu.org/software/bash/manual/html_node/Shell-Parameter-Expansion.html\n                    tags_dir_url=\"${source_tree_root_url%/trunk}/tags\"\n                ;;\n                */tags/*)\n                    tags_dir_url=\"${source_tree_root_url%/*}\"\n                ;;\n                *)\n                    printf -- \\\n                        'Warning: %s: Unsupported SVN source URL, assuming no release tags found.\\n' \\\n                        \"${FUNCNAME[0]}\" \\\n                        >&2\n                    return 1\n                ;;\n            esac\n\n            if svn list \\\n                \"${tags_dir_url}\" \\\n                | sed 's#/$##' \\\n                | grep \\\n                    --extended-regexp \\\n                    --quiet \\\n                    --regexp=\"${tag_pattern_release}\"; then\n                return 0\n            else\n                return 1\n            fi\n        ;;\n        *)\n            printf -- \\\n                '%s: Warning: Unknown VCS type, assuming no release tags found.\\n' \\\n                \"${FUNCNAME[0]}\" \\\n                >&2\n            return 1\n        ;;\n    esac\n}\n\nvcs_query_release_tags(){\n    if test $# -ne 2; then\n        printf 'FATAL: %s: Parameter quantity mismatch.\\n' \"${FUNCNAME[0]}\" >&2\n        exit 1\n    fi\n\n    local -r source_tree_root_dir=\"${1}\"; shift 1\n\n    # Pattern to match a release tag, in extended regular expression(ERE)\n    local -r tag_pattern_release=\"${1}\"; shift 1\n\n    case \"$(vcs_detect \"${source_tree_root_dir}\")\" in\n        git)\n            if git -C \"${source_tree_root_dir}\" tag \\\n                --list \\\n                | grep \\\n                    --extended-regexp \\\n                    --regexp=\"${tag_pattern_release}\"; then\n                return 0\n            else\n                return 1\n            fi\n        ;;\n        mercurial)\n            if hg --cwd \"${source_tree_root_dir}\" tags \\\n                --quiet \\\n                | grep \\\n                    --extended-regexp \\\n                    --regexp=\"${tag_pattern_release}\"; then\n                return 0\n            else\n                return 1\n            fi\n        ;;\n        subversion)\n            local \\\n                source_tree_root_url \\\n                tags_dir_url\n\n            source_tree_root_url=\"$(\n                svn info \\\n                    --show-item url \\\n                    \"${source_tree_root_dir}\"\n            )\"\n\n            # Supported source URLs:\n            # * /trunk\n            # * /tags/_tag_name_\n            case \"${source_tree_root_url}\" in\n                */trunk)\n                    # Strip trailing shortest matched pattern\n                    # https://www.gnu.org/software/bash/manual/html_node/Shell-Parameter-Expansion.html\n                    tags_dir_url=\"${source_tree_root_url%/trunk}/tags\"\n                ;;\n                */tags/*)\n                    tags_dir_url=\"${source_tree_root_url%/*}\"\n                ;;\n                *)\n                    printf -- \\\n                        'Warning: %s: Unsupported SVN source URL, assuming no release tags found.\\n' \\\n                        \"${FUNCNAME[0]}\" \\\n                        >&2\n                    return 1\n                ;;\n            esac\n\n            if svn list \\\n                \"${tags_dir_url}\" \\\n                | sed 's#/$##' \\\n                | grep \\\n                    --extended-regexp \\\n                    --regexp=\"${tag_pattern_release}\"; then\n                return 0\n            else\n                return 1\n            fi\n        ;;\n        *)\n            printf -- \\\n                '%s: Warning: Unknown VCS type, assuming no release tags found.\\n' \\\n                \"${FUNCNAME[0]}\" \\\n                >&2\n            return 1\n        ;;\n    esac\n}\n\nvcs_checkout_tag(){\n    if test $# -ne 2; then\n        printf 'FATAL: %s: Parameter quantity mismatch.\\n' \"${FUNCNAME[0]}\" >&2\n        exit 1\n    fi\n\n    local -r source_tree_root_dir=\"${1}\"; shift 1\n    local -r tag_to_be_checked_out=\"${1}\"; shift 1\n\n    case \"$(vcs_detect \"${source_tree_root_dir}\")\" in\n        git)\n            git -C \"${source_tree_root_dir}\" checkout \\\n                \"${tag_to_be_checked_out}\"\n            return 0\n        ;;\n        mercurial)\n            hg --cwd \"${source_tree_root_dir}\" checkout \\\n                --rev \"${tag_to_be_checked_out}\"\n            return 0\n        ;;\n        subversion)\n            local \\\n                source_tree_root_url \\\n                tags_dir_url\n\n            source_tree_root_url=\"$(\n                svn info \\\n                    --show-item url \\\n                    \"${source_tree_root_dir}\"\n            )\"\n\n            # Supported source URLs:\n            # * /trunk\n            # * /tags/_tag_name_\n            case \"${source_tree_root_url}\" in\n                */trunk)\n                    # Strip trailing shortest matched pattern\n                    # https://www.gnu.org/software/bash/manual/html_node/Shell-Parameter-Expansion.html\n                    tags_dir_url=\"${source_tree_root_url%/trunk}/tags\"\n                ;;\n                */tags/*)\n                    tags_dir_url=\"${source_tree_root_url%/*}\"\n                ;;\n                *)\n                    printf -- \\\n                        'Warning: %s: Unsupported SVN source URL, assuming check out failed.\\n' \\\n                        \"${FUNCNAME[0]}\" \\\n                        >&2\n                    return 1\n                ;;\n            esac\n\n            if svn checkout \\\n                \"${tags_dir_url}\"/\"${tag_to_be_checked_out}\"; then\n                return 0\n            else\n                return 1\n            fi\n        ;;\n        *)\n            printf -- \\\n                '%s: Warning: Unknown VCS type, not doing anything.\\n' \\\n                \"${FUNCNAME[0]}\" \\\n                >&2\n            return 1\n        ;;\n    esac\n}\n\n# Describe software version, use tags when available\nvcs_describe_version(){\n    if test $# -ne 3; then\n        printf 'FATAL: %s: Parameter quantity mismatch.\\n' \"${FUNCNAME[0]}\" >&2\n        exit 1\n    fi\n\n    local -r source_tree_root_dir=\"${1}\"; shift 1\n    local -r revision_identifier_length_minimum=\"${1}\"; shift 1\n    local -r dirty_postfix=\"${1}\"; shift 1\n\n    case \"$(vcs_detect \"${source_tree_root_dir}\")\" in\n        git)\n            git describe \\\n                --abbrev=\"${revision_identifier_length_minimum}\" \\\n                --always \\\n                --dirty=\"${dirty_postfix}\" \\\n                --tags\n            return 0\n        ;;\n        mercurial)\n            hg --cwd \"${source_tree_root_dir}\" log \\\n                --rev . \\\n                --template \"{latesttag}{sub('^-0-.*', '', '-{latesttagdistance}-m{shortest(node, ${revision_identifier_length_minimum})}')}\"\n            if vcs_is_dirty \"${source_tree_root_dir}\"; then\n                printf -- %s \"${dirty_postfix}\"\n            fi\n            return 0\n        ;;\n        subversion)\n            local \\\n                source_tree_root_url\n\n            source_tree_root_url=\"$(\n                svn info \\\n                    --show-item url \\\n                    \"${source_tree_root_dir}\"\n            )\"\n\n            # Supported source URLs:\n            # * /trunk\n            # * /tags/_tag_name_\n            case \"${source_tree_root_url}\" in\n                */trunk)\n                    printf %s \\\n                        \"rev$(\n                            svn info \\\n                                --show-item revision \\\n                                \"${source_tree_root_dir}\"\n                        )\"\n                ;;\n                */tags/*)\n                    local tag\n                    tag=\"${source_tree_root_url##*/}\"\n                    printf %s \"${tag}\"\n                ;;\n                *)\n                    printf -- \\\n                        'Warning: %s: Unsupported SVN source URL.\\n' \\\n                        \"${FUNCNAME[0]}\" \\\n                        >&2\n                    printf unknown\n                    return 1\n                ;;\n            esac\n\n            if vcs_is_dirty \"${source_tree_root_dir}\"; then\n                printf -- %s \"${dirty_postfix}\"\n            fi\n        ;;\n        *)\n            printf -- \\\n                '%s: Warning: Unknown VCS type.\\n' \\\n                \"${FUNCNAME[0]}\" \\\n                >&2\n            printf unknown\n            return 0\n        ;;\n    esac\n}\n\n# Describe version control revision, only revision identifier/hash with\n# customizable minimum length\n# FIXME: Some node among source tree should be enough for source_tree_root_dir\nvcs_describe_revision(){\n    if test $# -ne 3; then\n        printf 'FATAL: %s: Parameter quantity mismatch.\\n' \"${FUNCNAME[0]}\" >&2\n        exit 1\n    fi\n\n    local -r source_tree_root_dir=\"${1}\"; shift 1\n    local -r revision_identifier_length_minimum=\"${1}\"; shift 1\n    local -r dirty_postfix=\"${1}\"; shift 1\n\n    case \"$(vcs_detect \"${source_tree_root_dir}\")\" in\n        git)\n            if ! git -C \"${source_tree_root_dir}\" describe --always >/dev/null; then\n                printf unknown\n            else\n                git -C \"${source_tree_root_dir}\" describe \\\n                    --abbrev=\"${revision_identifier_length_minimum}\" \\\n                    --always \\\n                    --dirty=\"${dirty_postfix}\" \\\n                    --match=nothing\n            fi\n            return 0\n        ;;\n        mercurial)\n            if ! hg --cwd \"${source_tree_root_dir}\" status >/dev/null; then\n                printf unknown\n            else\n                # FIXME: Is there a better way of generating this only using Mercurial?\n                local hg_revision\n\n                hg_revision+=\"$(\n                    hg --cwd \"${source_tree_root_dir}\" log \\\n                        --rev . \\\n                        --template \"{shortest(node, ${revision_identifier_length_minimum})}\"\n                )\"\n\n                if vcs_is_dirty \\\n                    \"${SCRIPT_NAME}\" \\\n                    \"${source_tree_root_dir}\"; then\n                    hg_revision+=\"${dirty_postfix}\"\n                fi\n\n                printf -- %s \"${hg_revision}\"\n            fi\n            return 0\n        ;;\n        subversion)\n            svn info \\\n                --show-item revision \\\n                    \"${source_tree_root_dir}\"\n\n            if vcs_is_dirty \\\n                \"${SCRIPT_NAME}\" \\\n                \"${source_tree_root_dir}\"; then\n                printf -- %s \"${dirty_postfix}\"\n            fi\n        ;;\n        *)\n            printf -- \\\n                '%s: %s: Warning: Unknown VCS type.\\n' \\\n                \"${SCRIPT_NAME}\" \\\n                \"${FUNCNAME[0]}\" \\\n                >&2\n            printf unknown\n            return 0\n        ;;\n    esac\n}\n\n# Check whether a version string is newer than the other version string\n# identical version is considered \"not newer\"\nversion_former_is_greater_than_latter(){\n    if test $# -ne 2; then\n        printf 'FATAL: %s: Parameter quantity mismatch.\\n' \"${FUNCNAME[0]}\" >&2\n        exit 1\n    fi\n\n    local -r former=\"${1}\"; shift\n    local -r latter=\"${1}\"; shift\n\n    if test \"${former}\" = \"${latter}\"; then\n        return 1\n    fi\n\n    local newer_version\n    newer_version=\"$(\n        printf \\\n            -- \\\n            '%s\\n%s' \\\n            \"${former}\" \\\n            \"${latter}\" \\\n            | sort \\\n                --version-sort \\\n                --reverse \\\n            | head \\\n                --lines=1\n    )\"\n\n    if test \"${newer_version}\" = \"${former}\"; then\n        return 0\n    else\n        return 1\n    fi\n}\n\ninit \"${@}\"\n"
  },
  {
    "path": "snap/snapcraft.yaml",
    "content": "%YAML 1.1\n---\n# Snapcraft Recipe for gallery-dl\n# ------------------------------\n# This file is in the YAML data serialization format:\n# http://yaml.org\n# For the spec. of writing this file refer the following documentation:\n# * The snapcraft format\n#   https://docs.snapcraft.io/the-snapcraft-format/8337\n# * Snap Documentation\n#   https://docs.snapcraft.io\n# * Topics under the doc category in the Snapcraft Forum\n#   https://forum.snapcraft.io/c/doc\n# For support refer to the snapcraft section in the Snapcraft Forum:\n# https://forum.snapcraft.io/c/snapcraft\nname: gallery-dl\nlicense: GPL-2.0\nbase: core22\nsummary: Download image-galleries and -collections from several image hosting sites\ndescription: |\n  `gallery-dl` is a command-line program to download image-galleries and -collections from several image hosting sites (see [Supported Sites][1]). It is a cross-platform tool with many configuration options and powerful filenaming capabilities.\n\n  [1]: https://github.com/mikf/gallery-dl/blob/master/docs/supportedsites.rst\n\nadopt-info: gallery-dl\nconfinement: strict\ngrade: stable\n\n# Configuration access\nplugs:\n  config-gallery-dl:\n    interface: personal-files\n    read:\n    - $HOME/.config/gallery-dl\n    - $HOME/.gallery-dl.conf\n  etc-gallery-dl:\n    interface: system-files\n    read:\n    - /etc/gallery-dl.conf\n  dot-netrc:\n    interface: personal-files\n    read:\n    - $HOME/.netrc\n\nparts:\n  # Launcher programs to fix problems at runtime\n  launchers:\n    source: snap/local/launchers\n    plugin: dump\n    organize:\n      '*': bin/\n\n  gallery-dl:\n    source: .\n    override-pull: |\n      craftctl default\n      ${CRAFT_PROJECT_DIR}/snap/local/scriptlets/selective-checkout\n\n    plugin: python\n    build-packages:\n    - make\n\n    # selective-checkout scriptlet dependencies\n    - curl\n    - git\n    - jq\n    - sed\n    python-packages:\n    - yt-dlp[default]\n    override-build: |\n      # build manpages and bash completion\n      make man completion\n\n      craftctl default\n\n  ffmpeg:\n    plugin: nil\n    stage-packages:\n    - ffmpeg\n    - libslang2\n\napps:\n  gallery-dl:\n    command-chain:\n    - bin/gallery-dl-launch\n    command: bin/gallery-dl\n    completer: share/bash-completion/completions/gallery-dl\n    plugs:\n      # For `xdg-open` command access for opening OAuth authentication webpages\n      - desktop\n      # Storage access\n      - home\n      - removable-media\n      # Network access\n      - network\n      # For network service for receiving OAuth callback tokens\n      - network-bind\n    environment:\n      LANG: C.UTF-8\n      LC_ALL: C.UTF-8\n\n      # Satisfy FFmpeg's libpulsecommon dependency\n      LD_LIBRARY_PATH: $LD_LIBRARY_PATH:$SNAP/usr/lib/$CRAFT_ARCH_TRIPLET_BUILD_FOR/pulseaudio\n"
  },
  {
    "path": "test/__init__.py",
    "content": ""
  },
  {
    "path": "test/results/2ch.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\ngallery_dl = __import__(\"gallery_dl.extractor.2ch\")\n_2ch = getattr(gallery_dl.extractor, \"2ch\")\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://2ch.hk/a/res/6202876.html\",\n    \"#category\": (\"\", \"2ch\", \"thread\"),\n    \"#class\"   : _2ch._2chThreadExtractor,\n    \"#pattern\" : r\"https://2ch\\.org/a/src/6202876/\\d+\\.\\w+\",\n    \"#count\"   : range(450, 1000),\n\n    \"banned\"   : 0,\n    \"board\"    : \"a\",\n    \"closed\"   : 0,\n    \"comment\"  : str,\n    \"date\"     : \"type:datetime\",\n    \"displayname\": str,\n    \"email\"    : str,\n    \"endless\"  : 1,\n    \"extension\": str,\n    \"filename\" : str,\n    \"fullname\" : str,\n    \"height\"   : int,\n    \"lasthit\"  : range(1705000000, 1900000000),\n    \"md5\"      : r\"re:[0-9a-f]{32}\",\n    \"name\"     : r\"re:\\d+\\.\\w+\",\n    \"num\"      : int,\n    \"number\"   : range(1, 1000),\n    \"op\"       : 0,\n    \"parent\"   : int,\n    \"path\"     : r\"re:/a/src/6202876/\\d+\\.\\w+\",\n    \"post_name\": str,\n    \"size\"     : int,\n    \"sticky\"   : 0,\n    \"subject\"  : str,\n    \"thread\"   : \"6202876\",\n    \"thumbnail\": str,\n    \"tim\"      : r\"re:\\d+\",\n    \"timestamp\": int,\n    \"title\"    : \"MP4/WEBM\",\n    \"tn_height\": int,\n    \"tn_width\" : int,\n    \"trip\"     : \"\",\n    \"type\"     : int,\n    \"views\"    : int,\n    \"width\"    : int,\n},\n\n{\n    \"#url\"     : \"https://2ch.org/a/res/6202876.html\",\n    \"#class\"   : _2ch._2chThreadExtractor,\n},\n\n{\n    \"#url\"     : \"https://2ch.su/a/res/6202876.html\",\n    \"#class\"   : _2ch._2chThreadExtractor,\n},\n\n{\n    \"#url\"     : \"https://2ch.life/a/res/6202876.html\",\n    \"#class\"   : _2ch._2chThreadExtractor,\n    \"#pattern\" : r\"https://2ch\\.life/a/src/6202876/\\d+\\.\\w+\",\n    \"#count\"   : range(450, 1000),\n},\n\n{\n    \"#url\"     : \"https://2ch.hk/a/res/6202876.html\",\n    \"#class\"   : _2ch._2chThreadExtractor,\n    \"#pattern\" : r\"https://2ch\\.org/a/src/6202876/\\d+\\.\\w+\",\n    \"#count\"   : range(450, 1000),\n},\n\n{\n    \"#url\"     : \"https://2ch.org/a/\",\n    \"#class\"   : _2ch._2chBoardExtractor,\n    \"#pattern\" : _2ch._2chThreadExtractor.pattern,\n    \"#count\"   : range(200, 400),\n},\n\n{\n    \"#url\"     : \"https://2ch.su/a/\",\n    \"#class\"   : _2ch._2chBoardExtractor,\n    \"#pattern\" : _2ch._2chThreadExtractor.pattern,\n    \"#count\"   : range(200, 400),\n},\n\n{\n    \"#url\"     : \"https://2ch.life/a/\",\n    \"#class\"   : _2ch._2chBoardExtractor,\n    \"#pattern\" : _2ch._2chThreadExtractor.pattern,\n    \"#range\"   : \"1-80\",\n    \"#count\"   : 80,\n},\n\n{\n    \"#url\"     : \"https://2ch.hk/a/\",\n    \"#class\"   : _2ch._2chBoardExtractor,\n    \"#pattern\" : _2ch._2chThreadExtractor.pattern,\n    \"#range\"   : \"1-80\",\n    \"#count\"   : 80,\n},\n\n)\n"
  },
  {
    "path": "test/results/2chan.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\ngallery_dl = __import__(\"gallery_dl.extractor.2chan\")\n_2chan = getattr(gallery_dl.extractor, \"2chan\")\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://dec.2chan.net/70/res/17222.htm\",\n    \"#category\": (\"\", \"2chan\", \"thread\"),\n    \"#class\"   : _2chan._2chanThreadExtractor,\n    \"#pattern\" : r\"https://dec\\.2chan\\.net/70/src/\\d{13}\\.jpg\",\n    \"#count\"   : \">= 2\",\n\n    \"board\"     : \"70\",\n    \"board_name\": \"新板提案\",\n    \"com\"       : str,\n    \"fsize\"     : r\"re:\\d+\",\n    \"name\"      : \"名無し\",\n    \"no\"        : r\"re:17\\d\\d\\d\",\n    \"now\"       : r\"re:2[34]/../..\\(.\\)..:..:..\",\n    \"post\"      : \"無題\",\n    \"server\"    : \"dec\",\n    \"thread\"    : \"17222\",\n    \"tim\"       : r\"re:^\\d{13}$\",\n    \"time\"      : r\"re:^\\d{10}$\",\n    \"title\"     : \"画像会話板\",\n},\n\n)\n"
  },
  {
    "path": "test/results/35photo.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\ngallery_dl = __import__(\"gallery_dl.extractor.35photo\")\n_35photo = getattr(gallery_dl.extractor, \"35photo\")\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://35photo.pro/liya\",\n    \"#category\": (\"\", \"35photo\", \"user\"),\n    \"#class\"   : _35photo._35photoUserExtractor,\n    \"#pattern\" : r\"https://([a-z][0-9]\\.)?35photo\\.pro/photos_(main|series)/.*\\.jpg\",\n    \"#count\"   : 9,\n},\n\n{\n    \"#url\"     : \"https://35photo.pro/suhoveev\",\n    \"#comment\" : \"last photo ID (1267028) isn't given as 'photo-id=\\\"<id>\\\" - \"\n                 \"there are only 23 photos without the last one\",\n    \"#category\": (\"\", \"35photo\", \"user\"),\n    \"#class\"   : _35photo._35photoUserExtractor,\n    \"#count\"   : \">= 33\",\n},\n\n{\n    \"#url\"     : \"https://en.35photo.pro/liya\",\n    \"#category\": (\"\", \"35photo\", \"user\"),\n    \"#class\"   : _35photo._35photoUserExtractor,\n},\n\n{\n    \"#url\"     : \"https://ru.35photo.pro/liya\",\n    \"#category\": (\"\", \"35photo\", \"user\"),\n    \"#class\"   : _35photo._35photoUserExtractor,\n},\n\n{\n    \"#url\"     : \"https://35photo.pro/tags/landscape/\",\n    \"#category\": (\"\", \"35photo\", \"tag\"),\n    \"#class\"   : _35photo._35photoTagExtractor,\n    \"#range\"   : \"1-25\",\n    \"#count\"   : 25,\n    \"#archive\" : False,\n},\n\n{\n    \"#url\"     : \"https://35photo.pro/genre_109/\",\n    \"#category\": (\"\", \"35photo\", \"genre\"),\n    \"#class\"   : _35photo._35photoGenreExtractor,\n},\n\n{\n    \"#url\"     : \"https://35photo.pro/photo_753340/\",\n    \"#category\": (\"\", \"35photo\", \"image\"),\n    \"#class\"   : _35photo._35photoImageExtractor,\n    \"#count\"   : 1,\n\n    \"url\"        : r\"re:https://35photo\\.pro/photos_main/.*\\.jpg\",\n    \"id\"         : 753340,\n    \"title\"      : \"Winter walk\",\n    \"description\": str,\n    \"tags\"       : list,\n    \"views\"      : int,\n    \"favorites\"  : int,\n    \"score\"      : int,\n    \"type\"       : 0,\n    \"date\"       : \"15 авг, 2014\",\n    \"user\"       : \"liya\",\n    \"user_id\"    : 20415,\n    \"user_name\"  : \"Liya Mirzaeva\",\n},\n\n)\n"
  },
  {
    "path": "test/results/3dbooru.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\ngallery_dl = __import__(\"gallery_dl.extractor.3dbooru\")\n_3dbooru = getattr(gallery_dl.extractor, \"3dbooru\")\n\n\n__tests__ = (\n{\n    \"#url\"     : \"http://behoimi.org/post?tags=himekawa_azuru+dress\",\n    \"#category\": (\"booru\", \"3dbooru\", \"tag\"),\n    \"#class\"   : _3dbooru._3dbooruTagExtractor,\n    \"#sha1_url\"    : \"ecb30c6aaaf8a6ff8f55255737a9840832a483c1\",\n    \"#sha1_content\": \"11cbda40c287e026c1ce4ca430810f761f2d0b2a\",\n},\n\n{\n    \"#url\"     : \"http://behoimi.org/pool/show/27\",\n    \"#category\": (\"booru\", \"3dbooru\", \"pool\"),\n    \"#class\"   : _3dbooru._3dbooruPoolExtractor,\n    \"#sha1_url\"    : \"da75d2d1475449d5ef0c266cb612683b110a30f2\",\n    \"#sha1_content\": \"fd5b37c5c6c2de4b4d6f1facffdefa1e28176554\",\n},\n\n{\n    \"#url\"     : \"http://behoimi.org/post/show/140852\",\n    \"#category\": (\"booru\", \"3dbooru\", \"post\"),\n    \"#class\"   : _3dbooru._3dbooruPostExtractor,\n    \"#options\"     : {\"tags\": True},\n    \"#sha1_url\"    : \"ce874ea26f01d6c94795f3cc3aaaaa9bc325f2f6\",\n    \"#sha1_content\": \"26549d55b82aa9a6c1686b96af8bfcfa50805cd4\",\n\n    \"tags_character\": \"furude_rika\",\n    \"tags_copyright\": \"higurashi_no_naku_koro_ni\",\n    \"tags_model\"    : \"himekawa_azuru\",\n    \"tags_general\"  : str,\n},\n\n{\n    \"#url\"     : \"http://behoimi.org/post/popular_by_month?month=2&year=2013\",\n    \"#category\": (\"booru\", \"3dbooru\", \"popular\"),\n    \"#class\"   : _3dbooru._3dbooruPopularExtractor,\n    \"#pattern\" : r\"http://behoimi\\.org/data/../../[0-9a-f]{32}\\.jpg\",\n    \"#count\"   : 20,\n},\n\n)\n"
  },
  {
    "path": "test/results/4archive.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\ngallery_dl = __import__(\"gallery_dl.extractor.4archive\")\n_4archive = getattr(gallery_dl.extractor, \"4archive\")\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://4archive.org/board/u/thread/2397221\",\n    \"#category\": (\"\", \"4archive\", \"thread\"),\n    \"#class\"   : _4archive._4archiveThreadExtractor,\n    \"#pattern\" : r\"https://(cdn\\.4archive\\.org/u/image/150\\d/\\d\\d\\d/\\d+\\.\\w+|4archive\\.org/image/image-404\\.png)\",\n    \"#count\"   : 16,\n\n    \"board\" : \"u\",\n    \"com\"   : str,\n    \"date\"  : \"type:datetime\",\n    \"name\"  : \"Anonymous\",\n    \"no\"    : range(2397221, 2418158),\n    \"thread\": 2397221,\n    \"time\"  : int,\n    \"title\" : \"best anime\",\n    \"url\"   : str,\n    \"width\" : int,\n    \"height\": int,\n    \"size\"  : int,\n},\n\n{\n    \"#url\"     : \"https://4archive.org/board/jp/thread/17611798\",\n    \"#category\": (\"\", \"4archive\", \"thread\"),\n    \"#class\"   : _4archive._4archiveThreadExtractor,\n    \"#pattern\" : r\"https://(cdn\\.4archive\\.org/jp/image/\\d\\d\\d\\d/\\d\\d\\d/\\d+\\.\\w+|4archive\\.org/image/image-404\\.png)\",\n    \"#count\"   : 85,\n},\n\n{\n    \"#url\"     : \"https://4archive.org/board/u\",\n    \"#category\": (\"\", \"4archive\", \"board\"),\n    \"#class\"   : _4archive._4archiveBoardExtractor,\n    \"#pattern\" : _4archive._4archiveThreadExtractor.pattern,\n    \"#range\"   : \"1-20\",\n    \"#count\"   : 20,\n},\n\n{\n    \"#url\"     : \"https://4archive.org/board/jp/10\",\n    \"#category\": (\"\", \"4archive\", \"board\"),\n    \"#class\"   : _4archive._4archiveBoardExtractor,\n    \"#pattern\" : _4archive._4archiveThreadExtractor.pattern,\n    \"#range\"   : \"1-50\",\n    \"#count\"   : 50,\n}\n\n)\n"
  },
  {
    "path": "test/results/4chan.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\ngallery_dl = __import__(\"gallery_dl.extractor.4chan\")\n_4chan = getattr(gallery_dl.extractor, \"4chan\")\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://boards.4chan.org/tg/thread/15396072/\",\n    \"#category\": (\"\", \"4chan\", \"thread\"),\n    \"#class\"   : _4chan._4chanThreadExtractor,\n    \"#sha1_url\"     : \"39082ad166161966d7ba8e37f2173a824eb540f0\",\n    \"#sha1_metadata\": \"2cadd32796492baca25f5060dc95e9f4e24a0ff2\",\n    \"#sha1_content\" : \"742c6d256c813b29f246e1d765bba949fc3ac453\",\n},\n\n{\n    \"#url\"     : \"https://boards.4channel.org/tg/thread/15396072/\",\n    \"#category\": (\"\", \"4chan\", \"thread\"),\n    \"#class\"   : _4chan._4chanThreadExtractor,\n    \"#sha1_url\"     : \"39082ad166161966d7ba8e37f2173a824eb540f0\",\n    \"#sha1_metadata\": \"2cadd32796492baca25f5060dc95e9f4e24a0ff2\",\n},\n\n{\n    \"#url\"     : \"https://boards.4chan.org/wg/thread/8010591\",\n    \"#comment\" : \"file contents filled with null bytes (#7883)\",\n    \"#class\"   : _4chan._4chanThreadExtractor,\n    \"#range\"   : \"1\",\n    \"#log\"     : \"File data consists of null bytes\",\n    \"#results\"     : \"https://i.4cdn.org/wg/1694023485631944.jpg\",\n    \"#sha1_content\": \"da39a3ee5e6b4b0d3255bfef95601890afd80709\",\n},\n\n{\n    \"#url\"     : \"https://boards.4channel.org/po/\",\n    \"#category\": (\"\", \"4chan\", \"board\"),\n    \"#class\"   : _4chan._4chanBoardExtractor,\n    \"#pattern\" : _4chan._4chanThreadExtractor.pattern,\n    \"#count\"   : \">= 100\",\n},\n\n)\n"
  },
  {
    "path": "test/results/4chanarchives.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\ngallery_dl = __import__(\"gallery_dl.extractor.4chanarchives\")\n_4chanarchives = getattr(gallery_dl.extractor, \"4chanarchives\")\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://4chanarchives.com/board/c/thread/2707110\",\n    \"#category\": (\"\", \"4chanarchives\", \"thread\"),\n    \"#class\"   : _4chanarchives._4chanarchivesThreadExtractor,\n    \"#pattern\" : r\"https://i\\.imgur\\.com/(0wLGseE|qbByWDc)\\.jpg\",\n    \"#count\"   : 2,\n\n    \"board\" : \"c\",\n    \"com\"   : str,\n    \"name\"  : \"Anonymous\",\n    \"no\"    : int,\n    \"thread\": \"2707110\",\n    \"time\"  : r\"re:2016-07-1\\d \\d\\d:\\d\\d:\\d\\d\",\n    \"title\" : \"Ren Kagami from 'Oyako Neburi'\",\n},\n\n{\n    \"#url\"     : \"https://4chanarchives.com/board/c/\",\n    \"#category\": (\"\", \"4chanarchives\", \"board\"),\n    \"#class\"   : _4chanarchives._4chanarchivesBoardExtractor,\n    \"#pattern\" : _4chanarchives._4chanarchivesThreadExtractor.pattern,\n    \"#range\"   : \"1-40\",\n    \"#count\"   : 40,\n},\n\n{\n    \"#url\"     : \"https://4chanarchives.com/board/c\",\n    \"#category\": (\"\", \"4chanarchives\", \"board\"),\n    \"#class\"   : _4chanarchives._4chanarchivesBoardExtractor,\n},\n\n{\n    \"#url\"     : \"https://4chanarchives.com/board/c/10\",\n    \"#category\": (\"\", \"4chanarchives\", \"board\"),\n    \"#class\"   : _4chanarchives._4chanarchivesBoardExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/4plebs.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import foolfuuka\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://archive.4plebs.org/tg/thread/54059290\",\n    \"#category\": (\"foolfuuka\", \"4plebs\", \"thread\"),\n    \"#class\"   : foolfuuka.FoolfuukaThreadExtractor,\n    \"#pattern\" : r\"https://i\\.4pcdn\\.org/tg/1[34]\\d{11}\\.(jpg|png|gif)\",\n    \"#count\"   : 30,\n},\n\n{\n    \"#url\"     : \"https://archive.4plebs.org/tg/\",\n    \"#category\": (\"foolfuuka\", \"4plebs\", \"board\"),\n    \"#class\"   : foolfuuka.FoolfuukaBoardExtractor,\n},\n\n{\n    \"#url\"     : \"https://archive.4plebs.org/_/search/text/test/\",\n    \"#category\": (\"foolfuuka\", \"4plebs\", \"search\"),\n    \"#class\"   : foolfuuka.FoolfuukaSearchExtractor,\n},\n\n{\n    \"#url\"     : \"https://archive.4plebs.org/tg/gallery/1\",\n    \"#category\": (\"foolfuuka\", \"4plebs\", \"gallery\"),\n    \"#class\"   : foolfuuka.FoolfuukaGalleryExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/500px.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\ngallery_dl = __import__(\"gallery_dl.extractor.500px\")\n_500px = getattr(gallery_dl.extractor, \"500px\")\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://500px.com/p/fashvamp\",\n    \"#category\": (\"\", \"500px\", \"user\"),\n    \"#class\"   : _500px._500pxUserExtractor,\n    \"#pattern\" : r\"https?://drscdn.500px.org/photo/\\d+/m%3D4096(_k%3D1)?/v2\\?sig=\",\n    \"#range\"   : \"1-99\",\n    \"#count\"   : 99,\n},\n\n{\n    \"#url\"     : \"https://500px.com/fashvamp\",\n    \"#category\": (\"\", \"500px\", \"user\"),\n    \"#class\"   : _500px._500pxUserExtractor,\n},\n\n{\n    \"#url\"     : \"https://web.500px.com/fashvamp\",\n    \"#category\": (\"\", \"500px\", \"user\"),\n    \"#class\"   : _500px._500pxUserExtractor,\n},\n\n{\n    \"#url\"     : \"https://500px.com/p/fashvamp/galleries/lera\",\n    \"#category\": (\"\", \"500px\", \"gallery\"),\n    \"#class\"   : _500px._500pxGalleryExtractor,\n    \"#pattern\" : r\"https?://drscdn.500px.org/photo/\\d+/m%3D4096_k%3D1/v2\\?sig=\",\n    \"#count\"   : 3,\n\n    \"gallery\": dict,\n    \"user\"   : dict,\n},\n\n{\n    \"#url\"     : \"https://500px.com/fashvamp/galleries/lera\",\n    \"#category\": (\"\", \"500px\", \"gallery\"),\n    \"#class\"   : _500px._500pxGalleryExtractor,\n},\n\n{\n    \"#url\"     : \"https://500px.com/liked\",\n    \"#category\": (\"\", \"500px\", \"favorite\"),\n    \"#class\"   : _500px._500pxFavoriteExtractor,\n},\n\n{\n    \"#url\"     : \"https://500px.com/photo/222049255/queen-of-coasts\",\n    \"#category\": (\"\", \"500px\", \"image\"),\n    \"#class\"   : _500px._500pxImageExtractor,\n    \"#pattern\" : r\"https://drscdn\\.500px\\.org/photo/222049255/m%3D4096_k%3D1/v2\\?sig=\\w+\",\n    \"#count\"   : 1,\n\n    \"camera\"          : \"Canon EOS 600D\",\n    \"camera_info\"     : dict,\n    \"comments\"        : list,\n    \"comments_count\"  : int,\n    \"created_at\"      : \"2017-08-01T08:40:05+00:00\",\n    \"description\"     : str,\n    \"editored_by\"     : None,\n    \"editors_choice\"  : False,\n    \"extension\"       : \"jpg\",\n    \"feature\"         : \"popular\",\n    \"feature_date\"    : \"2017-08-01T09:58:28+00:00\",\n    \"focal_length\"    : \"208\",\n    \"height\"          : 3111,\n    \"id\"              : 222049255,\n    \"image_format\"    : \"jpg\",\n    \"image_url\"       : list,\n    \"images\"          : list,\n    \"iso\"             : \"100\",\n    \"lens\"            : \"EF-S55-250mm f/4-5.6 IS II\",\n    \"lens_info\"       : dict,\n    \"liked\"           : None,\n    \"location\"        : None,\n    \"location_details\": dict,\n    \"name\"            : \"Queen Of Coasts\",\n    \"nsfw\"            : False,\n    \"privacy\"         : False,\n    \"profile\"         : True,\n    \"rating\"          : float,\n    \"status\"          : 1,\n    \"tags\"            : list,\n    \"taken_at\"        : \"2017-05-04T17:36:51+00:00\",\n    \"times_viewed\"    : int,\n    \"url\"             : \"/photo/222049255/Queen-Of-Coasts-by-Alice-Nabieva\",\n    \"user\"            : dict,\n    \"user_id\"         : 12847235,\n    \"votes_count\"     : int,\n    \"watermark\"       : True,\n    \"width\"           : 4637,\n},\n\n)\n"
  },
  {
    "path": "test/results/8chan.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\ngallery_dl = __import__(\"gallery_dl.extractor.8chan\")\n_8chan = getattr(gallery_dl.extractor, \"8chan\")\n\n\n__tests__ = (\n{\n    \"#url\"  : \"https://8chan.moe/vhs/res/4.html\",\n    \"#class\": _8chan._8chanThreadExtractor,\n    \"#pattern\": r\"https://8chan\\.moe/\\.media/[0-9a-f]{64}\\.\\w+$\",\n    \"#count\"  : 14,\n\n    \"archived\"        : False,\n    \"autoSage\"        : False,\n    \"boardDescription\": \"Film and Cinema\",\n    \"boardMarkdown\"   : None,\n    \"boardName\"       : \"Movies\",\n    \"boardUri\"        : \"vhs\",\n    \"creation\"        : r\"re:\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}.\\d{3}Z\",\n    \"cyclic\"          : False,\n    \"email\"           : None,\n    \"id\"              : r\"re:^[0-9a-f]{6}$\",\n    \"locked\"          : False,\n    \"markdown\"        : str,\n    \"maxFileCount\"    : 5,\n    \"maxFileSize\"     : \"32.00 MB\",\n    \"maxMessageLength\": 12000,\n    \"message\"         : str,\n    \"mime\"            : str,\n    \"name\"            : \"Anonymous\",\n    \"num\"             : int,\n    \"originalName\"    : str,\n    \"path\"            : r\"re:/.media/[0-9a-f]{64}\\.\\w+$\",\n    \"pinned\"          : False,\n    \"postId\"          : int,\n    \"signedRole\"      : None,\n    \"size\"            : int,\n    \"threadId\"        : 4,\n    \"thumb\"           : r\"re:/.media/t_[0-9a-f]{64}$\",\n    \"uniquePosters\"   : 9,\n    \"usesCustomCss\"   : True,\n    \"usesCustomJs\"    : False,\n    \"?wsPort\"         : int,\n    \"?wssPort\"        : int,\n},\n\n{\n    \"#url\"  : \"https://8chan.moe/vhs/last/4.html\",\n    \"#class\": _8chan._8chanThreadExtractor,\n},\n\n{\n    \"#url\"  : \"https://8chan.se/vhs/res/4.html\",\n    \"#class\": _8chan._8chanThreadExtractor,\n},\n\n{\n    \"#url\"  : \"https://8chan.cc/vhs/res/4.html\",\n    \"#class\": _8chan._8chanThreadExtractor,\n},\n\n{\n    \"#url\"  : \"https://8chan.moe/vhs/\",\n    \"#class\": _8chan._8chanBoardExtractor,\n},\n\n{\n    \"#url\"  : \"https://8chan.moe/vhs/2.html\",\n    \"#class\": _8chan._8chanBoardExtractor,\n    \"#pattern\": _8chan._8chanThreadExtractor.pattern,\n    \"#count\"  : range(24, 32),\n},\n\n{\n    \"#url\"  : \"https://8chan.se/vhs/\",\n    \"#class\": _8chan._8chanBoardExtractor,\n},\n\n{\n    \"#url\"  : \"https://8chan.cc/vhs/\",\n    \"#class\": _8chan._8chanBoardExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/8kun.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import vichan\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://8kun.top/test/res/65248.html\",\n    \"#category\": (\"vichan\", \"8kun\", \"thread\"),\n    \"#class\"   : vichan.VichanThreadExtractor,\n    \"#pattern\" : r\"https://media\\.128ducks\\.com/file_store/\\w{64}\\.\\w+\",\n    \"#count\"   : \">= 8\",\n},\n\n{\n    \"#url\"     : \"https://8kun.top/v/index.html\",\n    \"#category\": (\"vichan\", \"8kun\", \"board\"),\n    \"#class\"   : vichan.VichanBoardExtractor,\n    \"#pattern\" : vichan.VichanThreadExtractor.pattern,\n    \"#count\"   : \">= 100\",\n},\n\n{\n    \"#url\"     : \"https://8kun.top/v/2.html\",\n    \"#category\": (\"vichan\", \"8kun\", \"board\"),\n    \"#class\"   : vichan.VichanBoardExtractor,\n},\n\n{\n    \"#url\"     : \"https://8kun.top/v/index.html?PageSpeed=noscript\",\n    \"#category\": (\"vichan\", \"8kun\", \"board\"),\n    \"#class\"   : vichan.VichanBoardExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/8muses.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\ngallery_dl = __import__(\"gallery_dl.extractor.8muses\")\n_8muses = getattr(gallery_dl.extractor, \"8muses\")\nfrom gallery_dl import exception\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://comics.8muses.com/comics/album/Fakku-Comics/mogg/Liar\",\n    \"#category\": (\"\", \"8muses\", \"album\"),\n    \"#class\"   : _8muses._8musesAlbumExtractor,\n    \"#pattern\" : r\"https://comics.8muses.com/image/fl/[\\w-]+\",\n    \"#sha1_url\": \"6286ac33087c236c5a7e51f8a9d4e4d5548212d4\",\n\n    \"url\"  : str,\n    \"hash\" : str,\n    \"page\" : int,\n    \"count\": 6,\n    \"album\": {\n        \"id\"     : 10467,\n        \"title\"  : \"Liar\",\n        \"path\"   : \"Fakku Comics/mogg/Liar\",\n        \"parts\"  : [\n            \"Fakku Comics\",\n            \"mogg\",\n            \"Liar\",\n        ],\n        \"private\": False,\n        \"url\"    : \"https://comics.8muses.com/comics/album/Fakku-Comics/mogg/Liar\",\n        \"parent\" : 10464,\n        \"views\"  : int,\n        \"likes\"  : int,\n        \"date\"   : \"dt:2018-07-10 00:00:00\",\n    },\n},\n\n{\n    \"#url\"     : \"https://www.8muses.com/comics/album/Fakku-Comics/santa\",\n    \"#category\": (\"\", \"8muses\", \"album\"),\n    \"#class\"   : _8muses._8musesAlbumExtractor,\n    \"#pattern\" : _8muses._8musesAlbumExtractor.pattern,\n    \"#count\"   : \">= 3\",\n\n    \"url\"    : str,\n    \"name\"   : str,\n    \"private\": False,\n},\n\n{\n    \"#url\"     : \"https://www.8muses.com/comics/album/Fakku-Comics/11?sort=az\",\n    \"#comment\" : \"custom sorting\",\n    \"#category\": (\"\", \"8muses\", \"album\"),\n    \"#class\"   : _8muses._8musesAlbumExtractor,\n    \"#count\"   : \">= 70\",\n\n    \"name\": r\"re:^[R-Zr-z]\",\n},\n\n{\n    \"#url\"     : \"https://comics.8muses.com/comics/album/Various-Authors/Chessire88/From-Trainers-to-Pokmons\",\n    \"#comment\" : \"non-ASCII characters\",\n    \"#category\": (\"\", \"8muses\", \"album\"),\n    \"#class\"   : _8muses._8musesAlbumExtractor,\n    \"#exception\": exception.HttpError,\n},\n\n{\n    \"#url\"     : \"https://comics.8muses.com/comics/album/Tufos-Comics/Gallery\",\n    \"#comment\" : \"private albums without 'permalink' (#6717)\",\n    \"#category\": (\"\", \"8muses\", \"album\"),\n    \"#class\"   : _8muses._8musesAlbumExtractor,\n    \"#count\"   : range(100, 150),\n},\n\n)\n"
  },
  {
    "path": "test/results/94chan.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import jschan\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://94chan.org/art/thread/25.html\",\n    \"#category\": (\"jschan\", \"94chan\", \"thread\"),\n    \"#class\"   : jschan.JschanThreadExtractor,\n    \"#pattern\" : r\"https://94chan.org/file/[0-9a-f]{64}(\\.\\w+)?\",\n    \"#count\"   : \">= 15\",\n},\n\n{\n    \"#url\"     : \"https://94chan.org/art/\",\n    \"#category\": (\"jschan\", \"94chan\", \"board\"),\n    \"#class\"   : jschan.JschanBoardExtractor,\n    \"#pattern\" : jschan.JschanThreadExtractor.pattern,\n    \"#count\"   : \">= 30\",\n},\n\n{\n    \"#url\"     : \"https://94chan.org/art/2.html\",\n    \"#category\": (\"jschan\", \"94chan\", \"board\"),\n    \"#class\"   : jschan.JschanBoardExtractor,\n},\n\n{\n    \"#url\"     : \"https://94chan.org/art/catalog.html\",\n    \"#category\": (\"jschan\", \"94chan\", \"board\"),\n    \"#class\"   : jschan.JschanBoardExtractor,\n},\n\n{\n    \"#url\"     : \"https://94chan.org/art/index.html\",\n    \"#category\": (\"jschan\", \"94chan\", \"board\"),\n    \"#class\"   : jschan.JschanBoardExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/__init__.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nimport os\nimport functools\n\n__directory__ = os.path.dirname(__file__)\n\n\n@functools.lru_cache(maxsize=None)\ndef tests(name):\n    module = __import__(name, globals(), None, (), 1)\n    return module.__tests__\n\n\ndef all():\n    ignore = (\"__init__.py\", \"__pycache__\")\n    for filename in os.listdir(__directory__):\n        if filename not in ignore:\n            yield from tests(filename[:-3])\n\n\ndef category(category):\n    return tests(category.replace(\".\", \"\"))\n"
  },
  {
    "path": "test/results/acidimg.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import imagehosts\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://acidimg.cc/img-5acb6b9de4640.html\",\n    \"#category\": (\"imagehost\", \"acidimg\", \"image\"),\n    \"#class\"   : imagehosts.AcidimgImageExtractor,\n    \"#sha1_url\"     : \"f132a630006e8d84f52d59555191ed82b3b64c04\",\n    \"#sha1_metadata\": \"135347ab4345002fc013863c0d9419ba32d98f78\",\n    \"#sha1_content\" : \"0c8768055e4e20e7c7259608b67799171b691140\",\n},\n\n)\n"
  },
  {
    "path": "test/results/adultempire.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import adultempire\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://www.adultempire.com/5998/gallery.html\",\n    \"#category\": (\"\", \"adultempire\", \"gallery\"),\n    \"#class\"   : adultempire.AdultempireGalleryExtractor,\n    \"#range\"        : \"1\",\n    \"#sha1_metadata\": \"5b3266e69801db0d78c22181da23bc102886e027\",\n    \"#sha1_content\" : \"5c6beb31e5e3cdc90ee5910d5c30f9aaec977b9e\",\n},\n\n{\n    \"#url\"     : \"https://www.adultdvdempire.com/5683/gallery.html\",\n    \"#category\": (\"\", \"adultempire\", \"gallery\"),\n    \"#class\"   : adultempire.AdultempireGalleryExtractor,\n    \"#sha1_url\"     : \"b12cd1a65cae8019d837505adb4d6a2c1ed4d70d\",\n    \"#sha1_metadata\": \"8d448d79c4ac5f5b10a3019d5b5129ddb43655e5\",\n},\n\n)\n"
  },
  {
    "path": "test/results/agnph.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import agnph\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://agn.ph/gallery/post/?search=azuu\",\n    \"#category\": (\"booru\", \"agnph\", \"tag\"),\n    \"#class\"   : agnph.AgnphTagExtractor,\n    \"#pattern\" : r\"http://agn\\.ph/gallery/data/../../\\w{32}\\.jpg\",\n    \"#count\"   : \">= 50\",\n},\n\n{\n    \"#url\"     : \"https://agn.ph/gallery/post/show/501604/\",\n    \"#category\": (\"booru\", \"agnph\", \"post\"),\n    \"#class\"   : agnph.AgnphPostExtractor,\n    \"#options\" : {\"tags\": True},\n    \"#results\"     : \"http://agn.ph/gallery/data/7d/a5/7da50021f3e86f6cf1c215652060d772.png\",\n    \"#sha1_content\": \"93c8b2d3f53e891ad8fa68d5f60f8c7a70acd836\",\n\n    \"artist\"      : \"reyn_goldfur\",\n    \"created_at\"  : \"1722041591\",\n    \"creator_id\"  : \"-1\",\n    \"date\"        : \"dt:2024-07-27 00:53:11\",\n    \"description\" : None,\n    \"fav_count\"   : \"0\",\n    \"file_ext\"    : \"png\",\n    \"file_url\"    : \"http://agn.ph/gallery/data/7d/a5/7da50021f3e86f6cf1c215652060d772.png\",\n    \"has_children\": False,\n    \"height\"      : \"1000\",\n    \"id\"          : \"501604\",\n    \"md5\"         : \"7da50021f3e86f6cf1c215652060d772\",\n    \"num_comments\": \"0\",\n    \"parent_id\"   : None,\n    \"rating\"      : \"e\",\n    \"source\"      : \"https://inkbunny.net/s/2886519\",\n    \"status\"      : \"approved\",\n    \"tags\"        : \"anthro female hisuian_sneasel regional_form reyn_goldfur shelly_the_sneasel sneasel solo\",\n    \"tags_artist\" : \"reyn_goldfur\",\n    \"tags_character\": \"shelly_the_sneasel\",\n    \"tags_general\": \"anthro female solo\",\n    \"tags_species\": \"hisuian_sneasel regional_form sneasel\",\n    \"thumbnail_url\": \"http://agn.ph/gallery/data/thumb/7d/a5/7da50021f3e86f6cf1c215652060d772.png\",\n    \"width\"       : \"953\",\n\n},\n\n)\n"
  },
  {
    "path": "test/results/ahottie.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import ahottie\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://ahottie.top/albums/5d54b221c19ff9c9126ffd62859c6603\",\n    \"#comment\" : \"multiple pages (#8862)\",\n    \"#class\"   : ahottie.AhottieGalleryExtractor,\n    \"#pattern\" : r\"https://images2\\.imgbox\\.com/../../\\w+_o\\.jpg\",\n    \"#count\"   : 14,\n\n    \"count\"     : 14,\n    \"num\"       : range(1, 14),\n    \"date\"      : \"dt:2024-12-30 00:00:00\",\n    \"extension\" : \"jpg\",\n    \"filename\"  : str,\n    \"gallery_id\": \"5d54b221c19ff9c9126ffd62859c6603\",\n    \"title\"     : \"大熊杏優・かれしちゃん, Young Champion 2025 No.02 (ヤングチャンピオン 2025年2号)\",\n    \"tags\"      : [\n        \"Ayu Okuma 大熊杏優\",\n        \"Kareshichan かれしちゃん\",\n        \"Young Champion ヤングチャンピオン\",\n    ],\n},\n\n{\n    \"#url\"     : \"https://ahottie.top/tags/Ayu%20Okuma%20%E5%A4%A7%E7%86%8A%E6%9D%8F%E5%84%AA\",\n    \"#class\"   : ahottie.AhottieTagExtractor,\n    \"#pattern\" : ahottie.AhottieGalleryExtractor.pattern,\n    \"#count\"   : 17,\n\n    \"date\"       : \"type:datetime\",\n    \"search_tags\": \"Ayu Okuma 大熊杏優\",\n    \"title\"      : str,\n    \"url\"        : str,\n},\n\n{\n    \"#url\"     : \"https://ahottie.top/search?kw=ayu&page=10\",\n    \"#class\"   : ahottie.AhottieSearchExtractor,\n    \"#pattern\" : ahottie.AhottieGalleryExtractor.pattern,\n    \"#count\"   : range(80, 200),\n\n    \"date\"       : \"type:datetime\",\n    \"search_tags\": \"ayu\",\n    \"title\"      : str,\n    \"url\"        : str,\n},\n\n)\n"
  },
  {
    "path": "test/results/aibooru.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import danbooru\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://aibooru.online/posts?tags=center_frills&z=1\",\n    \"#category\": (\"Danbooru\", \"aibooru\", \"tag\"),\n    \"#class\"   : danbooru.DanbooruTagExtractor,\n    \"#pattern\" : r\"https://cdn\\.aibooru\\.download/original/[0-9a-f]{2}/[0-9a-f]{2}/[0-9a-f]{32}\\.\\w+\",\n    \"#count\"   : range(160, 200),\n},\n\n{\n    \"#url\"     : \"https://aibooru.download/posts?tags=center_frills\",\n    \"#category\": (\"Danbooru\", \"aibooru\", \"tag\"),\n    \"#class\"   : danbooru.DanbooruTagExtractor,\n    \"#pattern\" : r\"https://cdn\\.aibooru\\.download/original/[0-9a-f]{2}/[0-9a-f]{2}/[0-9a-f]{32}\\.\\w+\",\n    \"#count\"   : range(160, 200),\n},\n\n{\n    \"#url\"     : \"https://safe.aibooru.online/posts?tags=center_frills\",\n    \"#category\": (\"Danbooru\", \"aibooru\", \"tag\"),\n    \"#class\"   : danbooru.DanbooruTagExtractor,\n},\n\n{\n    \"#url\"     : \"https://general.aibooru.online/posts?tags=center_frills\",\n    \"#category\": (\"Danbooru\", \"aibooru\", \"tag\"),\n    \"#class\"   : danbooru.DanbooruTagExtractor,\n    \"#pattern\" : r\"https://cdn\\.aibooru\\.download/original/[0-9a-f]{2}/[0-9a-f]{2}/[0-9a-f]{32}\\.\\w+\",\n    \"#count\"   : range(100, 120),\n},\n\n{\n    \"#url\"     : \"https://aibooru.online/pools/1\",\n    \"#category\": (\"Danbooru\", \"aibooru\", \"pool\"),\n    \"#class\"   : danbooru.DanbooruPoolExtractor,\n},\n\n{\n    \"#url\"     : \"https://aibooru.online/posts/1\",\n    \"#category\": (\"Danbooru\", \"aibooru\", \"post\"),\n    \"#class\"   : danbooru.DanbooruPostExtractor,\n    \"#results\" : \"https://cdn.aibooru.download/original/8b/af/8baf2d5bb3d6f45deeabf7e1e659f562.png\",\n    \"#sha1_content\": \"54d548743cd67799a62c77cbae97cfa0fec1b7e9\",\n},\n\n{\n    \"#url\"     : \"https://aibooru.download/posts/1\",\n    \"#category\": (\"Danbooru\", \"aibooru\", \"post\"),\n    \"#class\"   : danbooru.DanbooruPostExtractor,\n    \"#results\" : \"https://cdn.aibooru.download/original/8b/af/8baf2d5bb3d6f45deeabf7e1e659f562.png\",\n},\n\n{\n    \"#url\"     : \"https://aibooru.online/posts/18315\",\n    \"#comment\" : \"ugoira (#7630)\",\n    \"#category\": (\"Danbooru\", \"aibooru\", \"post\"),\n    \"#class\"   : danbooru.DanbooruPostExtractor,\n    \"#options\" : {\"ugoira\": True},\n    \"#results\" : \"https://cdn.aibooru.download/original/f9/6b/f96b2b3254884ab527fab0a7e9c39ba9.zip\",\n\n    \"_ugoira_original\"     : False,\n    \"_ugoira_frame_data[*]\": {\n        \"file\" : r\"re:^0000\\d\\d\\.jpg$\",\n        \"delay\": int,\n    },\n},\n\n{\n    \"#url\"     : \"https://aibooru.online/explore/posts/popular\",\n    \"#category\": (\"Danbooru\", \"aibooru\", \"popular\"),\n    \"#class\"   : danbooru.DanbooruPopularExtractor,\n},\n\n{\n    \"#url\"     : \"https://aibooru.online/posts/random?tags=center_frills&z=1\",\n    \"#category\": (\"Danbooru\", \"aibooru\", \"random\"),\n    \"#class\"   : danbooru.DanbooruRandomExtractor,\n    \"#pattern\" : \"https://cdn.aibooru.download/original/.+\",\n    \"#count\"   : 1,\n\n    \"search_tags\": \"center_frills\",\n},\n\n{\n    \"#url\"     : \"https://aibooru.online/posts/random\",\n    \"#category\": (\"Danbooru\", \"aibooru\", \"random\"),\n    \"#class\"   : danbooru.DanbooruRandomExtractor,\n    \"#pattern\" : \"https://cdn.aibooru.download/original/.+\",\n    \"#count\"   : 1,\n\n    \"search_tags\": \"\",\n},\n\n)\n"
  },
  {
    "path": "test/results/allgirlbooru.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import gelbooru_v01\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://allgirl.booru.org/index.php?page=post&s=list&tags=dress\",\n    \"#category\": (\"gelbooru_v01\", \"allgirlbooru\", \"tag\"),\n    \"#class\"   : gelbooru_v01.GelbooruV01TagExtractor,\n    \"#range\"   : \"1-25\",\n    \"#count\"   : 25,\n},\n\n{\n    \"#url\"     : \"https://allgirl.booru.org/index.php?page=favorites&s=view&id=380\",\n    \"#category\": (\"gelbooru_v01\", \"allgirlbooru\", \"favorite\"),\n    \"#class\"   : gelbooru_v01.GelbooruV01FavoriteExtractor,\n    \"#count\"   : 4,\n},\n\n{\n    \"#url\"     : \"https://allgirl.booru.org/index.php?page=post&s=view&id=107213\",\n    \"#category\": (\"gelbooru_v01\", \"allgirlbooru\", \"post\"),\n    \"#class\"   : gelbooru_v01.GelbooruV01PostExtractor,\n    \"#sha1_url\"    : \"b416800d2d2b072f80d3b37cfca9cb806fb25d51\",\n    \"#sha1_content\": \"3e3c65e0854a988696e11adf0de52f8fa90a51c7\",\n\n    \"created_at\": \"2021-02-13 16:27:39\",\n    \"date\"      : \"dt:2021-02-13 16:27:39\",\n    \"file_url\"  : \"https://img.booru.org/allgirl//images/107/2aaa0438d58fc7baa75a53b4a9621bb89a9d3fdb.jpg\",\n    \"height\"    : \"1200\",\n    \"id\"        : \"107213\",\n    \"md5\"       : \"2aaa0438d58fc7baa75a53b4a9621bb89a9d3fdb\",\n    \"rating\"    : \"s\",\n    \"score\"     : str,\n    \"source\"    : \"\",\n    \"tags\"      : \"blush dress green_eyes green_hair hatsune_miku long_hair twintails vocaloid\",\n    \"uploader\"  : \"Honochi31\",\n    \"width\"     : \"1600\",\n},\n\n)\n"
  },
  {
    "path": "test/results/allporncomic.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import allporncomic\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://allporncomic.com/porncomic/amerikan-bikini-car-wash-kantai-collection-kancolle-kekocha/1-amerikan-bikini-car-wash/\",\n    \"#class\"   : allporncomic.AllporncomicChapterExtractor,\n    \"#pattern\" : r\"https://allporncomic\\.com/wp\\-content/uploads/WP\\-manga/data/manga_5d0663f71649f/4c11e870974d3cf877e3b83512ffc303/\\d+\\.jpg\",\n    \"#count\"   : 16,\n\n    \"artist\"       : [\"Kekocha\"],\n    \"bookmarks\"    : range(100, 500),\n    \"chapter\"      : 1,\n    \"chapter_id\"   : 6480,\n    \"chapter_minor\": \"\",\n    \"comments\"     : 0,\n    \"count\"        : 16,\n    \"description\"  : \"A full colored KanColle parody hentai manga featuring an FFM threesome with Iowa, Saratoga, and Teitoku by Kokocha.\",\n    \"extension\"    : \"jpg\",\n    \"filename\"     : str,\n    \"group\"        : [\"CHIBIKKO KINGDOM\"],\n    \"lang\"         : \"en\",\n    \"manga\"        : \"Amerikan Bikini Car Wash\",\n    \"manga_cover\"  : \"https://allporncomic.com/wp-content/uploads/2019/06/01-31.jpg\",\n    \"manga_date\"   : \"dt:2019-06-16 15:49:12\",\n    \"manga_id\"     : 5210,\n    \"manga_slug\"   : \"amerikan-bikini-car-wash-kantai-collection-kancolle-kekocha\",\n    \"page\"         : range(1, 16),\n    \"parody\"       : [\"Kantai Collection\"],\n    \"rating\"       : range(3, 4),\n    \"status\"       : \"Completed\",\n    \"type\"         : \"Hentai\",\n    \"votes\"        : range(60, 200),\n    \"characters\"   : [\n        \"Iowa\",\n        \"Saratoga\",\n        \"Teitoku\",\n    ],\n    \"path\"         : [\n        \"Amerikan Bikini Car Wash (Kantai Collection -KanColle-) [Kekocha]\",\n        \"1 . Amerikan Bikini Car Wash - Chapter 1 (Kantai Collection -KanColle-) [Kekocha]\",\n    ],\n    \"tags\"         : [\n        \"Big Breasts\",\n        \"Bikini\",\n        \"FFM Threesome\",\n        \"Full Color\",\n        \"Game\",\n        \"Group\",\n        \"Hentai\",\n        \"Nakadashi\",\n        \"Ponytail\",\n        \"Sole Male\",\n        \"Swimsuit\",\n        \"X-RAY\",\n    ],\n},\n\n{\n    \"#url\"     : \"https://allporncomic.com/porncomic/trick-or-treat-pegasus-smithb/0-1-tina-bonus-images/\",\n    \"#class\"   : allporncomic.AllporncomicChapterExtractor,\n    \"#pattern\" : r\"https://allporncomic\\.com/wp\\-content/uploads/WP\\-manga/data/manga_5eb6fffc1d757/01371b1c90c756cf8910e0b2e8d41711/\\d+\\.jpg\",\n    \"#count\"   : 17,\n\n    \"artist\"       : [\"Pegasus Smith\"],\n    \"chapter_id\"   : 20987,\n    \"chapter\"      : 0,\n    \"chapter_minor\": \".1\",\n    \"characters\"   : [],\n    \"description\"  : \"Tina and her son are out trick or treating when they get invited to a party that will change both of their lives forever. A 3D porn comic including blackmail, incest, and cheating by Pegasus Smith. Prequel to Timmy Strikes Back.\",\n    \"extension\"    : \"jpg\",\n    \"lang\"         : \"en\",\n    \"manga\"        : \"Trick Or Treat\",\n    \"manga_cover\"  : \"https://allporncomic.com/wp-content/uploads/2020/05/000a-scaled.jpg\",\n    \"manga_date\"   : \"dt:2020-05-09 19:36:30\",\n    \"manga_date_updated\": \"dt:2025-10-31 08:58:38\",\n    \"manga_id\"     : 14455,\n    \"manga_slug\"   : \"trick-or-treat-pegasus-smithb\",\n    \"parody\"       : [],\n    \"rating\"       : float,\n    \"status\"       : \"OnGoing\",\n    \"type\"         : \"3D\",\n    \"path\"         : [\n        \"Trick Or Treat [Pegasus Smith]\",\n        \"0.1 . Tina Bonus Images - [Pegasus Smith]\",\n    ],\n    \"tags\"         : [\n        \"3D\",\n        \"Anal\",\n        \"Big Breasts\",\n        \"Big Penis\",\n        \"Blackmail\",\n        \"Cheating\",\n        \"Cosplaying\",\n        \"Dark Skin\",\n        \"DILF\",\n        \"Double Penetration\",\n        \"Eyemask\",\n        \"Gangbang\",\n        \"Group\",\n        \"Handjob\",\n        \"Interracial\",\n        \"MILF\",\n        \"Nakadashi\",\n        \"Twintails\",\n        \"Virginity\",\n    ],\n},\n\n{\n    \"#url\"     : \"https://allporncomic.com/porncomic/artworks-crisisbeat/crisisbeat-vampire-vs-goth/\",\n    \"#comment\" : \"\",\n    \"#class\"   : allporncomic.AllporncomicChapterExtractor,\n},\n\n{\n    \"#url\"     : \"https://allporncomic.com/porncomic/artworks-crisisbeat/crisisbeat-vampire-vs-goth/\",\n    \"#comment\" : \"missing chapter number\",\n    \"#class\"   : allporncomic.AllporncomicChapterExtractor,\n    \"#pattern\" : r\"https://allporncomic\\.com/wp\\-content/uploads/WP\\-manga/data/manga_610296e0c5918/e7b766aa30d32cc736bf46d8febb6610/\\w+\\.jpg\",\n    \"#count\"   : 11,\n\n    \"chapter\"      : 0,\n    \"chapter_minor\": \"\",\n    \"chapter_id\"   : 32839,\n    \"description\"  : \"Parody artworks and animations by Crisisbeat .\",\n    \"manga\"        : \"Artworks\",\n    \"manga_date\"   : \"dt:2021-07-29 13:07:50\",\n    \"manga_id\"     : 21674,\n    \"manga_slug\"   : \"artworks-crisisbeat\",\n    \"status\"       : \"Completed\",\n    \"type\"         : \"3D\",\n    \"artist\"       : [\"Crisisbeat\"],\n    \"path\"         : [\n        \"Artworks [Crisisbeat]\",\n        \"[CrisisBeat] Vampire vs. Goth\",\n    ],\n    \"tags\"         : [\n        \"3D\",\n        \"Animated\",\n        \"Cartoon\",\n    ],\n},\n\n{\n    \"#url\"     : \"https://allporncomic.com/porncomic/amerikan-bikini-car-wash-kantai-collection-kancolle-kekocha/\",\n    \"#class\"   : allporncomic.AllporncomicMangaExtractor,\n    \"#results\" : \"https://allporncomic.com/porncomic/amerikan-bikini-car-wash-kantai-collection-kancolle-kekocha/1-amerikan-bikini-car-wash/\",\n\n    \"artist\"     : [\"Kekocha\"],\n    \"date\"       : \"dt:2019-06-16 00:00:00\",\n    \"description\": \"A full colored KanColle parody hentai manga featuring an FFM threesome with Iowa, Saratoga, and Teitoku by Kokocha.\",\n    \"group\"      : [\"CHIBIKKO KINGDOM\"],\n    \"lang\"       : \"en\",\n    \"manga\"      : \"Amerikan Bikini Car Wash\",\n    \"manga_cover\": \"https://allporncomic.com/wp-content/uploads/2019/06/01-31.jpg\",\n    \"manga_date\" : \"dt:2019-06-16 15:49:12\",\n    \"manga_id\"   : 5210,\n    \"manga_slug\" : \"amerikan-bikini-car-wash-kantai-collection-kancolle-kekocha\",\n    \"parody\"     : [\"Kantai Collection\"],\n    \"rating\"     : float,\n    \"status\"     : \"Completed\",\n    \"type\"       : \"Hentai\",\n    \"characters\" : [\n        \"Iowa\",\n        \"Saratoga\",\n        \"Teitoku\",\n    ],\n    \"tags\"       : [\n        \"Big Breasts\",\n        \"Bikini\",\n        \"FFM Threesome\",\n        \"Full Color\",\n        \"Game\",\n        \"Group\",\n        \"Hentai\",\n        \"Nakadashi\",\n        \"Ponytail\",\n        \"Sole Male\",\n        \"Swimsuit\",\n        \"X-RAY\",\n    ],\n},\n\n{\n    \"#url\"     : \"https://allporncomic.com/porncomic-genre/bikinia/\",\n    \"#class\"   : allporncomic.AllporncomicTagExtractor,\n    \"#pattern\" : allporncomic.AllporncomicMangaExtractor.pattern,\n    \"#range\"   : \"1-50\",\n    \"#count\"   : 50,\n},\n\n{\n    \"#url\"     : \"https://allporncomic.com/series/kantai-collection/\",\n    \"#class\"   : allporncomic.AllporncomicTagExtractor,\n    \"#pattern\" : allporncomic.AllporncomicMangaExtractor.pattern,\n    \"#range\"   : \"1-50\",\n    \"#count\"   : 28,\n},\n\n{\n    \"#url\"     : \"https://allporncomic.com/porncomic-artist/kekocha/?m_orderby=new-manga\",\n    \"#class\"   : allporncomic.AllporncomicTagExtractor,\n    \"#results\" : (\n        \"https://allporncomic.com/porncomic/dumbbell-motenakutemo-daijoubu-how-heavy-are-the-dumbbells-you-lift-kekocha/\",\n        \"https://allporncomic.com/porncomic/amerikan-bikini-car-wash-kantai-collection-kancolle-kekocha/\",\n    ),\n},\n\n{\n    \"#url\"     : \"https://allporncomic.com/group/chibikko-kingdom/\",\n    \"#class\"   : allporncomic.AllporncomicTagExtractor,\n    \"#results\" : (\n        \"https://allporncomic.com/porncomic/dumbbell-motenakutemo-daijoubu-how-heavy-are-the-dumbbells-you-lift-kekocha/\",\n        \"https://allporncomic.com/porncomic/amerikan-bikini-car-wash-kantai-collection-kancolle-kekocha/\",\n    ),\n},\n\n{\n    \"#url\"     : \"https://allporncomic.com/characters/princess-zeldab/page/2/?m_orderby=latest\",\n    \"#class\"   : allporncomic.AllporncomicTagExtractor,\n    \"#results\" : (\n        \"https://allporncomic.com/porncomic/magical-music-various-stormfeder/\",\n        \"https://allporncomic.com/porncomic/taking-steps-to-ensure-hyrules-prosperity-the-legend-of-zelda-morikoke/\",\n        \"https://allporncomic.com/porncomic/breasts-of-the-wild-the-legend-of-zelda-stormfeder/\",\n        \"https://allporncomic.com/porncomic/super-smash-bros-various-witchking00/\",\n        \"https://allporncomic.com/porncomic/smash-bros-extreme-super-smash-bros-witchking00/\",\n    ),\n},\n\n)\n"
  },
  {
    "path": "test/results/animereactor.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import reactor\n\n\n__tests__ = (\n{\n    \"#url\"     : \"http://anime.reactor.cc/tag/Anime+Art\",\n    \"#category\": (\"reactor\", \"anime.reactor\", \"tag\"),\n    \"#class\"   : reactor.ReactorTagExtractor,\n},\n\n{\n    \"#url\"     : \"http://anime.reactor.cc/user/Shuster\",\n    \"#category\": (\"reactor\", \"anime.reactor\", \"user\"),\n    \"#class\"   : reactor.ReactorUserExtractor,\n},\n\n{\n    \"#url\"     : \"http://anime.reactor.cc/post/3576250\",\n    \"#category\": (\"reactor\", \"anime.reactor\", \"post\"),\n    \"#class\"   : reactor.ReactorPostExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/ao3.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import ao3\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://archiveofourown.org/works/47802076\",\n    \"#class\"   : ao3.Ao3WorkExtractor,\n    \"#results\" : \"https://archiveofourown.org/downloads/47802076/The_Wildcard.pdf?updated_at=1720398424\",\n\n    \"author\"   : \"Flowers_for_ghouls\",\n    \"bookmarks\": range(100, 300),\n    \"chapters\": {\n        \"120506833\": \"1. Showtime\",\n        \"120866506\": \"2. A Comedy of Errors\",\n        \"121739140\": \"3. Gifts\",\n        \"121941313\": \"4. Date Night\",\n        \"123054364\": \"5. Breaking the News\",\n        \"123579898\": \"6. Isolated Events\",\n        \"124258153\": \"7. The Home Stretch\",\n        \"124886536\": \"8. Domestic Bliss\",\n        \"125335270\": \"9. The Offer\",\n        \"125871166\": \"10. The Promise\",\n        \"126223879\": \"11. Gifts II\",\n        \"126692398\": \"12. On the Move\",\n        \"127471375\": \"13. The Fruit Vignettes\",\n        \"128496448\": \"14. Respite\",\n        \"128994919\": \"15. Changes\",\n        \"129492154\": \"16. Halloween\",\n        \"130379002\": \"17. GIfts III\",\n        \"131066743\": \"18. R.A.S.B.E.W.\",\n        \"131884072\": \"19. The Longest Night\",\n        \"132730264\": \"20. Meeting the Pack\",\n        \"133714876\": \"21. A Mystery\",\n        \"134663854\": \"22. Growing Pains\",\n        \"135499822\": \"23. Presentation Day\",\n        \"136500946\": \"24. Revelations\",\n        \"137857876\": \"25. The Retirement Plan\",\n        \"139463056\": \"26. Two Birds, One Stone\",\n        \"141697141\": \"27. New Management\",\n    },\n    \"comments\" : range(800, 2000),\n    \"date\"     : \"dt:2023-06-11 00:00:00\",\n    \"date_completed\": \"dt:2024-05-10 00:00:00\",\n    \"date_updated\"  : \"dt:2024-07-08 00:27:04\",\n    \"extension\": \"pdf\",\n    \"filename\" : \"The_Wildcard\",\n    \"id\"       : 47802076,\n    \"lang\"     : \"en\",\n    \"language\" : \"English\",\n    \"likes\"    : range(1000, 2000),\n    \"series\"   : {\n        \"id\"   : \"4237024\",\n        \"prev\" : \"\",\n        \"next\" : \"57205801\",\n        \"index\": \"1\",\n        \"name\" : \"The Wildcard Universe\",\n    },\n    \"title\"    : \"The Wildcard\",\n    \"views\"    : range(34000, 50000),\n    \"words\"    : 217549,\n\n    \"categories\": [\n        \"Gen\",\n        \"M/M\",\n    ],\n    \"characters\": [\n        \"Dewdrop Ghoul | Fire Ghoul\",\n        \"Aether | Quintessence Ghoul\",\n        \"Multi Ghoul | Swiss Army Ghoul\",\n        \"Rain | Water Ghoul\",\n        \"Cirrus | Air Ghoulette\",\n        \"Cumulus | Air Ghoulette\",\n        \"Sunshine Ghoulette\",\n        \"Mountain | Earth Ghoul\",\n        \"Cardinal Copia\",\n        \"Phantom Ghoul\",\n        \"Aurora Ghoulette\",\n        \"Sister Imperator (Ghost Sweden Band)\",\n    ],\n    \"fandom\": [\n        \"Ghost (Sweden Band)\",\n    ],\n    \"rating\": [\n        \"Mature\",\n    ],\n    \"relationships\": [\n        \"Aether | Quintessence Ghoul/Dewdrop Ghoul | Fire Ghoul\",\n        \"Multi Ghoul | Swiss Army Ghoul/Rain | Water Ghoul\",\n    ],\n    \"summary\": [\n        \"Aether has been asked to stay at the ministry to manage the renovation of the new infirmary. It couldn’t have been worse timing. Barely days into the new tour, Dew realizes he’s carrying their first kit.\",\n    ],\n    \"tags\": [\n        \"Domestic Fluff\",\n        \"Pack Dynamics\",\n        \"gratuitous fluff\",\n        \"How do ghouls work?\",\n        \"they don't even know\",\n        \"but it's cute\",\n        \"Pregnant Dewdrop\",\n        \"Recreational Drug Use\",\n        \"Cowbell!\",\n        \"Protective Ghouls\",\n        \"no beta we die like Nihil\",\n        \"sick dewdrop\",\n        \"TW: Vomiting\",\n        \"Aether really loves Dew\",\n        \"Nesting\",\n        \"Ghoul Piles (Ghost Sweden Band)\",\n        \"Angst\",\n        \"Hurt/Comfort\",\n        \"Original Ghoul Kit(s) (Ghost Sweden Band)\",\n        \"Kit fic\",\n    ],\n    \"warnings\": [\n        \"No Archive Warnings Apply\",\n    ],\n},\n\n{\n    \"#url\"     : \"https://archiveofourown.org/works/47802076\",\n    \"#class\"   : ao3.Ao3WorkExtractor,\n    \"#options\" : {\"formats\": [\"epub\", \"mobi\", \"azw3\", \"pdf\", \"html\"]},\n    \"#results\" : (\n        \"https://archiveofourown.org/downloads/47802076/The_Wildcard.epub?updated_at=1720398424\",\n        \"https://archiveofourown.org/downloads/47802076/The_Wildcard.mobi?updated_at=1720398424\",\n        \"https://archiveofourown.org/downloads/47802076/The_Wildcard.azw3?updated_at=1720398424\",\n        \"https://archiveofourown.org/downloads/47802076/The_Wildcard.pdf?updated_at=1720398424\",\n        \"https://archiveofourown.org/downloads/47802076/The_Wildcard.html?updated_at=1720398424\",\n    ),\n},\n\n{\n    \"#url\"     : \"https://archiveofourown.org/works/12345\",\n    \"#comment\" : \"restricted work / login required\",\n    \"#class\"   : ao3.Ao3WorkExtractor,\n    \"#auth\"    : True,\n    \"#results\" : \"https://archiveofourown.org/downloads/12345/Unquenchable.pdf?updated_at=1716029699\",\n},\n\n{\n    \"#url\"     : \"https://archiveofourown.org/series/1903930\",\n    \"#class\"   : ao3.Ao3SeriesExtractor,\n    \"#results\" : (\n        \"https://archiveofourown.org/works/26131546\",\n        \"https://archiveofourown.org/works/26291101\",\n        \"https://archiveofourown.org/works/26325292\",\n    ),\n},\n\n{\n    \"#url\"     : \"https://archiveofourown.org/tags/Sunshine%20(Ghost%20Sweden%20Band)/works\",\n    \"#class\"   : ao3.Ao3TagExtractor,\n    \"#pattern\" : ao3.Ao3WorkExtractor.pattern,\n    \"#range\"   : \"1-50\",\n    \"#count\"   : 50,\n},\n\n{\n    \"#url\"     : \"https://archiveofourown.org/works/search?work_search%5Bquery%5D=air+fire+ice+water\",\n    \"#class\"   : ao3.Ao3SearchExtractor,\n    \"#pattern\" : ao3.Ao3WorkExtractor.pattern,\n    \"#range\"   : \"1-50\",\n    \"#count\"   : 50,\n},\n\n{\n    \"#url\"     : \"https://archiveofourown.org/users/Fyrelass\",\n    \"#class\"   : ao3.Ao3UserExtractor,\n    \"#results\" : (\n        \"https://archiveofourown.org/users/Fyrelass/works\",\n        \"https://archiveofourown.org/users/Fyrelass/series\",\n    ),\n},\n{\n    \"#url\"     : \"https://archiveofourown.com/users/Fyrelass\",\n    \"#class\"   : ao3.Ao3UserExtractor,\n},\n{\n    \"#url\"     : \"https://archiveofourown.net/users/Fyrelass\",\n    \"#class\"   : ao3.Ao3UserExtractor,\n},\n{\n    \"#url\"     : \"https://ao3.org/users/Fyrelass\",\n    \"#class\"   : ao3.Ao3UserExtractor,\n},\n\n{\n    \"#url\"     : \"https://archiveofourown.org/users/Fyrelass/profile\",\n    \"#class\"   : ao3.Ao3UserExtractor,\n},\n\n{\n    \"#url\"     : \"https://archiveofourown.org/users/Fyrelass/pseuds/Aileen%20Autarkeia\",\n    \"#class\"   : ao3.Ao3UserExtractor,\n},\n\n{\n    \"#url\"     : \"https://archiveofourown.org/users/Fyrelass/works\",\n    \"#class\"   : ao3.Ao3UserWorksExtractor,\n    \"#auth\"    : False,\n    \"#results\" : (\n        \"https://archiveofourown.org/works/55035061\",\n        \"https://archiveofourown.org/works/58979287\",\n        \"https://archiveofourown.org/works/52704457\",\n        \"https://archiveofourown.org/works/52502743\",\n        \"https://archiveofourown.org/works/52170409\",\n        \"https://archiveofourown.org/works/52078558\",\n        \"https://archiveofourown.org/works/51699982\",\n        \"https://archiveofourown.org/works/51975193\",\n        \"https://archiveofourown.org/works/51633877\",\n        \"https://archiveofourown.org/works/51591436\",\n        \"https://archiveofourown.org/works/50860891\",\n    ),\n},\n\n{\n    \"#url\"     : \"https://archiveofourown.org/users/Fyrelass/series\",\n    \"#class\"   : ao3.Ao3UserSeriesExtractor,\n    \"#auth\"    : False,\n    \"#results\" : (\n        \"https://archiveofourown.org/series/3821575\",\n    ),\n},\n\n{\n    \"#url\"     : \"https://archiveofourown.org/users/Fyrelass/bookmarks\",\n    \"#class\"   : ao3.Ao3UserBookmarkExtractor,\n    \"#pattern\" : r\"https://archiveofourown\\.org/(work|serie)s/\\d+\",\n    \"#range\"   : \"1-50\",\n    \"#count\"   : 50,\n},\n\n{\n    \"#url\"     : \"https://archiveofourown.org/users/mikf/subscriptions\",\n    \"#class\"   : ao3.Ao3SubscriptionsExtractor,\n    \"#auth\"    : True,\n    \"#pattern\" : r\"https://archiveofourown\\.org/(work|serie|user)s/\\w+\",\n    \"#count\"   : range(20, 30),\n},\n\n)\n"
  },
  {
    "path": "test/results/arcalive.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import arcalive\n\n\n__tests__ = (\n{\n    \"#url\"    : \"https://arca.live/b/arknights/66031722?p=1\",\n    \"#class\"  : arcalive.ArcalivePostExtractor,\n    \"#pattern\": r\"https://ac.namu.la/20221225sac2/e06dcf8edd29c597240898a6752c74dbdd0680fc932cfd0ecc898795f1db34b5.jpg\\?type=orig&expires=\\d+&key=\\w+\",\n    \"#count\"  : 1,\n\n    \"isEditable\": False,\n    \"isDeletable\": False,\n    \"isReportable\": False,\n    \"id\": 66031722,\n    \"nickname\": \"Si리링\",\n    \"title\": \"엑샤 스작함\",\n    \"contentType\": \"html\",\n    \"content\": r\"re:^<p>알게또 뽑으려했는데 못뽑아서 엑샤 스작함<br />엑샤에 보카디 3스나 와파린 2스 붙이는거 맞음.+/></p>$\",\n    \"viewCount\": range(8000, 20000),\n    \"ratingUp\": 0,\n    \"ratingDown\": 0,\n    \"ratingUpIp\": 0,\n    \"ratingDownIp\": 0,\n    \"createdAt\": \"2022-12-25T05:16:55.000Z\",\n    \"updatedAt\": \"2022-12-25T05:16:55.000Z\",\n    \"lastComment\": \"2022-12-25T05:22:12.000Z\",\n    \"commentCount\": range(2, 9),\n    \"publicId\": None,\n    \"token\": \"44bb2dfd0bbc672e\",\n    \"isUser\": True,\n    \"gravatar\": \"//secure.gravatar.com/avatar/6c3fdbdeea149b29eea8d887c37fc119?d=retro&f=y\",\n    \"preventDelete\": False,\n    \"channelPermission\": dict,\n    \"captcha\": True,\n    \"isSensitive\": False,\n    \"categoryDisplayName\": None,\n    \"blockPreview\": False,\n    \"isSpoilerAlert\": False,\n    \"boardName\": \"명일방주 채널\",\n    \"boardSlug\": \"arknights\",\n    \"isBest\": False,\n    \"vote\": [],\n    \"date\": \"dt:2022-12-25 05:16:55\",\n    \"post_url\": \"https://arca.live/b/arknights/66031722\",\n    \"count\": 1,\n    \"num\": 1,\n    \"url\": str,\n    \"width\": 3200,\n    \"height\": 1440,\n    \"filename\": \"e06dcf8edd29c597240898a6752c74dbdd0680fc932cfd0ecc898795f1db34b5\",\n    \"extension\": \"jpg\",\n},\n\n{\n    \"#url\"    : \"https://arca.live/b/breaking/66031722\",\n    \"#comment\": \"/b/breaking page URL\",\n    \"#class\"  : arcalive.ArcalivePostExtractor,\n    \"#pattern\": r\"https://ac.namu.la/20221225sac2/e06dcf8edd29c597240898a6752c74dbdd0680fc932cfd0ecc898795f1db34b5.jpg\\?type=orig\",\n},\n\n{\n    \"#url\"    : \"https://arca.live/b/bluearchive/65031202\",\n    \"#comment\": \"animated gif\",\n    \"#class\"  : arcalive.ArcalivePostExtractor,\n    \"#pattern\": (\n        r\"https://ac.namu.la/20221211sac/5ea7fbca5e49ec16beb099fc6fc991690d37552e599b1de8462533908346241e.png\\?type=orig\",\n        r\"https://ac.namu.la/20221211sac/7f73beefc4f18a2f986bc4c6821caba706e27f4c94cb828fc16e2af1253402d9.gif\\?type=orig\",\n        r\"https://ac.namu.la/20221211sac2/3e72f9e05ca97c0c3c0fe5f25632b06eb21ab9f211e9ea22816e16468ee241ca.png\\?type=orig\",\n    ),\n},\n\n{\n    \"#url\"    : \"https://arca.live/b/arknights/122263340\",\n    \"#comment\": \"animated webp\",\n    \"#class\"  : arcalive.ArcalivePostExtractor,\n    \"#pattern\": (\n        r\"https://ac.namu.la/20241126sac/b2175d9ef4504945d3d989526120dbb6aded501ddedfba8ecc44a64e7aae9059.gif\\?type=orig\",\n        r\"https://ac.namu.la/20241126sac/bc1f3cb388a3a2d099ab67bc09b28f0a93c2c4755152b3ef9190690a9f0a28fb.webp\\?type=orig\",\n    ),\n},\n\n{\n    \"#url\"    : \"https://arca.live/b/bluearchive/117240135\",\n    \"#comment\": \".mp4 video\",\n    \"#class\"  : arcalive.ArcalivePostExtractor,\n    \"#options\": {\"gifs\": \"check\"},\n    \"#pattern\": r\"https://ac.namu.la/20240926sac/16f07778a97f91b935c8a3394ead01a223d96b2a619fdb25c4628ddba88b5fad.mp4\\?type=orig\",\n},\n\n{\n    \"#url\"    : \"https://arca.live/b/bluearchive/111191955\",\n    \"#comment\": \"fake .mp4 GIF\",\n    \"#class\"  : arcalive.ArcalivePostExtractor,\n    \"#options\": {\"gifs\": True},\n    \"#pattern\": r\"https://ac.namu.la/20240714sac/c8fcadeb0b578e5121eb7a7e8fb05984cb87c68e7a6e0481a1c8869bf0ecfd2b.gif\\?type=orig\",\n\n    \"_fallback\": \"len:tuple:1\",\n},\n\n{\n    \"#url\"    : \"https://arca.live/b/bluearchive/111191955\",\n    \"#comment\": \"fake .mp4 GIF\",\n    \"#class\"  : arcalive.ArcalivePostExtractor,\n    \"#options\": {\"gifs\": False},\n    \"#pattern\": r\"https://ac.namu.la/20240714sac/c8fcadeb0b578e5121eb7a7e8fb05984cb87c68e7a6e0481a1c8869bf0ecfd2b.mp4\\?type=orig\",\n},\n\n{\n    \"#url\"    : \"https://arca.live/b/arknights/49406926\",\n    \"#comment\": \"static emoticon\",\n    \"#class\"  : arcalive.ArcalivePostExtractor,\n    \"#pattern\": r\"https://ac.namu.la/20220428sac2/41f472adcea674aff75f15f146e81c27032bc4d6c8073bd7c19325bd1c97d335.png\\?type=orig\",\n},\n\n{\n    \"#url\"    : \"https://arca.live/b/commission/63658702\",\n    \"#comment\": \"animated emoticon\",\n    \"#class\"  : arcalive.ArcalivePostExtractor,\n    \"#options\": {\"emoticons\": True},\n    \"#pattern\": (\n        r\"https://ac.namu.la/20221123sac2/14925c5e22ab9f17f2923ae60a39b7af0794c43e478ecaba054ab6131e57e022.png\\?type=orig\",\n        r\"https://ac.namu.la/20221123sac2/50c385a4004bca44271a2f6133990f086cfefd29a7968514e9c14d6017d61265.png\\?type=orig\",\n        r\"https://ac.namu.la/20221005sac2/28ebe073fffbb2b88f710c2d380b0fe6dd99a856070c4a836db57634a5371366.gif\\?type=orig\",\n    ),\n},\n\n{\n    \"#url\"    : \"https://arca.live/b/arknights\",\n    \"#class\"  : arcalive.ArcaliveBoardExtractor,\n    \"#pattern\": arcalive.ArcalivePostExtractor.pattern,\n    \"#range\"  : \"1-100\",\n    \"#count\"  : 100,\n\n    \"category\"    : {str, None},\n    \"categoryDisplayName\": {str, None},\n    \"commentCount\": int,\n    \"createdAt\"   : str,\n    \"id\"          : int,\n    \"isUser\"      : bool,\n    \"?mark\"       : str,\n    \"nickname\"    : str,\n    \"publicId\"    : {int, None},\n    \"ratingDown\"  : int,\n    \"ratingUp\"    : int,\n    \"thumbnailUrl\": {str, None},\n    \"title\"       : str,\n    \"viewCount\"   : int,\n},\n\n{\n    \"#url\"  : \"https://arca.live/u/@Si%EB%A6%AC%EB%A7%81\",\n    \"#class\": arcalive.ArcaliveUserExtractor,\n    \"#range\": \"1-5\",\n    \"#results\": (\n        \"https://arca.live/b/vrchat/107257886\",\n        \"https://arca.live/b/soulworkers/95371697\",\n        \"https://arca.live/b/arcalivebreverse/90843346\",\n        \"https://arca.live/b/arcalivebreverse/90841126\",\n        \"https://arca.live/b/arcalivebreverse/90769916\",\n    ),\n\n    \"boardName\"   : str,\n    \"boardSlug\"   : {\"vrchat\", \"soulworkers\", \"arcalivebreverse\"},\n    \"category\"    : {str, None},\n    \"categoryDisplayName\": {str, None},\n    \"commentCount\": int,\n    \"createdAt\"   : str,\n    \"id\"          : int,\n    \"isUser\"      : True,\n    \"?mark\"       : \"image\",\n    \"nickname\"    : \"Si리링\",\n    \"publicId\"    : {int, None},\n    \"ratingDown\"  : int,\n    \"ratingUp\"    : int,\n    \"thumbnailUrl\": {str, None},\n    \"title\"       : str,\n    \"viewCount\"   : int,\n},\n\n)\n"
  },
  {
    "path": "test/results/architizer.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import architizer\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://architizer.com/projects/house-lo/\",\n    \"#category\": (\"\", \"architizer\", \"project\"),\n    \"#class\"   : architizer.ArchitizerProjectExtractor,\n    \"#pattern\" : r\"https://architizer-prod\\.imgix\\.net/media/mediadata/uploads/.+\\.jpg$\",\n\n    \"count\"      : 27,\n    \"description\": str,\n    \"firm\"       : \"Atelier Lina Bellovicova\",\n    \"gid\"        : \"225496\",\n    \"location\"   : \"Czechia\",\n    \"num\"        : int,\n    \"size\"       : \"1000 sqft - 3000 sqft\",\n    \"slug\"       : \"house-lo\",\n    \"status\"     : \"Built\",\n    \"subcategory\": \"project\",\n    \"title\"      : \"House LO\",\n    \"type\"       : \"Residential › Private House\",\n    \"year\"       : \"2020\",\n},\n\n{\n    \"#url\"     : \"https://architizer.com/firms/olson-kundig/\",\n    \"#category\": (\"\", \"architizer\", \"firm\"),\n    \"#class\"   : architizer.ArchitizerFirmExtractor,\n    \"#pattern\" : architizer.ArchitizerProjectExtractor.pattern,\n    \"#count\"   : \">= 90\",\n},\n\n)\n"
  },
  {
    "path": "test/results/archivedmoe.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import foolfuuka\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://archived.moe/gd/thread/309639/\",\n    \"#category\": (\"foolfuuka\", \"archivedmoe\", \"thread\"),\n    \"#class\"   : foolfuuka.FoolfuukaThreadExtractor,\n    \"#sha1_url\"    : \"fdd533840e2d535abd162c02d6dfadbc12e2dcd8\",\n    \"#sha1_content\": \"c27e2a7be3bc989b5dd859f7789cc854db3f5573\",\n},\n\n{\n    \"#url\"     : \"https://archived.moe/a/thread/159767162/\",\n    \"#category\": (\"foolfuuka\", \"archivedmoe\", \"thread\"),\n    \"#class\"   : foolfuuka.FoolfuukaThreadExtractor,\n    \"#sha1_url\": \"ffec05a1a1b906b5ca85992513671c9155ee9e87\",\n},\n\n{\n    \"#url\"     : \"https://archived.moe/b/thread/912594917/\",\n    \"#comment\" : \"broken thebarchive .webm URLs (#5116)\",\n    \"#category\": (\"foolfuuka\", \"archivedmoe\", \"thread\"),\n    \"#class\"   : foolfuuka.FoolfuukaThreadExtractor,\n    \"#results\" : (\n        \"https://thebarchive.com/b/full_image/1705625299234839.gif\",\n        \"https://thebarchive.com/b/full_image/1705625431133806.web\",\n        \"https://thebarchive.com/b/full_image/1705626190307840.web\",\n    ),\n},\n\n{\n    \"#url\"     : \"https://archived.moe/a/thread/279540316/\",\n    \"#comment\" : \"filename/timestamp fixup for redirect URL (#7652)\",\n    \"#category\": (\"foolfuuka\", \"archivedmoe\", \"thread\"),\n    \"#class\"   : foolfuuka.FoolfuukaThreadExtractor,\n    \"#results\" : (\n        \"http://desuarchive.org/a/full_image/1749537017533.jpg\",\n    ),\n},\n\n{\n    \"#url\"     : \"https://archived.moe/gd/\",\n    \"#category\": (\"foolfuuka\", \"archivedmoe\", \"board\"),\n    \"#class\"   : foolfuuka.FoolfuukaBoardExtractor,\n},\n\n{\n    \"#url\"     : \"https://archived.moe/_/search/text/test/\",\n    \"#category\": (\"foolfuuka\", \"archivedmoe\", \"search\"),\n    \"#class\"   : foolfuuka.FoolfuukaSearchExtractor,\n},\n\n{\n    \"#url\"     : \"https://archived.moe/gd/gallery/2\",\n    \"#category\": (\"foolfuuka\", \"archivedmoe\", \"gallery\"),\n    \"#class\"   : foolfuuka.FoolfuukaGalleryExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/archiveofsins.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import foolfuuka\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://archiveofsins.com/h/thread/4668813/\",\n    \"#category\": (\"foolfuuka\", \"archiveofsins\", \"thread\"),\n    \"#class\"   : foolfuuka.FoolfuukaThreadExtractor,\n    \"#sha1_url\"    : \"f612d287087e10a228ef69517cf811539db9a102\",\n    \"#sha1_content\": \"0dd92d0d8a7bf6e2f7d1f5ac8954c1bcf18c22a4\",\n},\n\n{\n    \"#url\"     : \"https://archiveofsins.com/h/\",\n    \"#category\": (\"foolfuuka\", \"archiveofsins\", \"board\"),\n    \"#class\"   : foolfuuka.FoolfuukaBoardExtractor,\n},\n\n{\n    \"#url\"     : \"https://archiveofsins.com/_/search/text/test/\",\n    \"#category\": (\"foolfuuka\", \"archiveofsins\", \"search\"),\n    \"#class\"   : foolfuuka.FoolfuukaSearchExtractor,\n},\n\n{\n    \"#url\"     : \"https://archiveofsins.com/_/search/text/test/\",\n    \"#category\": (\"foolfuuka\", \"archiveofsins\", \"search\"),\n    \"#class\"   : foolfuuka.FoolfuukaSearchExtractor,\n},\n\n{\n    \"#url\"     : \"https://archiveofsins.com/h/gallery/3\",\n    \"#category\": (\"foolfuuka\", \"archiveofsins\", \"gallery\"),\n    \"#class\"   : foolfuuka.FoolfuukaGalleryExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/arena.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import arena\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://www.are.na/mikf/touhou-zr5p8idnkag\",\n    \"#class\"   : arena.ArenaChannelExtractor,\n    \"#results\" : (\n        \"https://d2w9rnfcy7mm78.cloudfront.net/40871580/original_3fb729c818f92de4539d4ff263eb6056.png?1762357121?bc=0\",\n        \"https://d2w9rnfcy7mm78.cloudfront.net/40871591/original_91d7c8144a5ba9776118f0af6d923f94.png?1762357155?bc=0\",\n        \"https://d2w9rnfcy7mm78.cloudfront.net/40871607/original_766f89eb3b06cc84372bea9d58132c93.png?1762357207?bc=0\",\n        \"https://attachments.are.na/40873309/ebf4eae61a70773f7494e10a98b18fe3.mp4?1762359389\",\n        \"https://d2w9rnfcy7mm78.cloudfront.net/40873379/original_289824f61eade100785db100652abd9a.jpg?1762359483?bc=0\",\n    ),\n\n    \"extension\": {\"jpg\", \"png\", \"mp4\"},\n    \"filename\" : str,\n    \"count\"    : 6,\n    \"num\"      : range(1, 5),\n    \"source\"   : {None, dict},\n    \"block\"    : {\n        \"attachment\"      : {None, dict},\n        \"base_class\"      : \"Block\",\n        \"class\"           : {\"Link\", \"Attachment\", \"Image\"},\n        \"comment_count\"   : 0,\n        \"connected_at\"    : \"iso:datetime\",\n        \"connected_by_user_id\": 1127493,\n        \"connected_by_user_slug\": \"mikf\",\n        \"connected_by_username\": \"mikf .\",\n        \"connection_id\"   : int,\n        \"content\"         : {None, \"\"},\n        \"content_html\"    : {None, \"\"},\n        \"created_at\"      : \"iso:datetime\",\n        \"date\"            : \"type:datetime\",\n        \"date_updated\"    : \"type:datetime\",\n        \"description\"     : str,\n        \"description_html\": str,\n        \"embed\"           : None,\n        \"generated_title\" : str,\n        \"id\"              : int,\n        \"position\"        : int,\n        \"selected\"        : False,\n        \"state\"           : \"available\",\n        \"title\"           : str,\n        \"updated_at\"      : \"iso:datetime\",\n        \"visibility\"      : \"public\",\n        \"image\"           : dict,\n        \"user\"            : dict,\n    },\n    \"channel\"  : {\n        \"added_to_at\"   : \"2025-11-10T19:52:52.729Z\",\n        \"base_class\"    : \"Channel\",\n        \"can_index\"     : False,\n        \"class_name\"    : \"Channel\",\n        \"collaboration\" : False,\n        \"collaborator_count\": 0,\n        \"collaborators\" : [],\n        \"created_at\"    : \"2025-11-05T15:37:40.626Z\",\n        \"date\"          : \"dt:2025-11-05 15:37:40\",\n        \"date_updated\"  : \"type:datetime\",\n        \"follower_count\": 0,\n        \"id\"            : 4422732,\n        \"kind\"          : \"default\",\n        \"length\"        : 6,\n        \"manifest\"      : dict,\n        \"metadata\"      : {\"description\": \"\"},\n        \"nsfw?\"         : False,\n        \"open\"          : False,\n        \"page\"          : 1,\n        \"per\"           : 20,\n        \"published\"     : True,\n        \"share_link\"    : None,\n        \"slug\"          : \"touhou-zr5p8idnkag\",\n        \"status\"        : \"closed\",\n        \"title\"         : '''Touhou \"東方\"''',\n        \"updated_at\"    : \"iso:datetime\",\n        \"user_id\"       : 1127493,\n    },\n    \"owner\"    : {\n        \"avatar\"         : \"\",\n        \"badge\"          : None,\n        \"base_class\"     : \"User\",\n        \"can_index\"      : False,\n        \"channel_count\"  : 3,\n        \"class\"          : \"User\",\n        \"created_at\"     : \"2025-11-05T15:35:15.242Z\",\n        \"first_name\"     : \"mikf\",\n        \"follower_count\" : 0,\n        \"following_count\": 0,\n        \"full_name\"      : \"mikf .\",\n        \"id\"             : 1127493,\n        \"initials\"       : \"m.\",\n        \"is_confirmed\"   : True,\n        \"is_exceeding_connections_limit\": False,\n        \"is_lifetime_premium\": False,\n        \"is_pending_confirmation\": False,\n        \"is_pending_reconfirmation\": False,\n        \"is_premium\"     : False,\n        \"is_supporter\"   : False,\n        \"last_name\"      : \".\",\n        \"metadata\"       : {\"description\": None},\n        \"profile_id\"     : 4422723,\n        \"slug\"           : \"mikf\",\n        \"username\"       : \"mikf .\",\n        \"avatar_image\"   : {\n            \"display\": \"\",\n            \"thumb\"  : \"\",\n        },\n    },\n    \"user\"     : {\n        \"avatar\"         : \"\",\n        \"badge\"          : None,\n        \"base_class\"     : \"User\",\n        \"can_index\"      : False,\n        \"channel_count\"  : 3,\n        \"class\"          : \"User\",\n        \"created_at\"     : \"2025-11-05T15:35:15.242Z\",\n        \"first_name\"     : \"mikf\",\n        \"follower_count\" : 0,\n        \"following_count\": 0,\n        \"full_name\"      : \"mikf .\",\n        \"id\"             : 1127493,\n        \"initials\"       : \"m.\",\n        \"is_confirmed\"   : True,\n        \"is_exceeding_connections_limit\": False,\n        \"is_lifetime_premium\": False,\n        \"is_pending_confirmation\": False,\n        \"is_pending_reconfirmation\": False,\n        \"is_premium\"     : False,\n        \"is_supporter\"   : False,\n        \"last_name\"      : \".\",\n        \"metadata\"       : {\"description\": None},\n        \"profile_id\"     : 4422723,\n        \"slug\"           : \"mikf\",\n        \"username\"       : \"mikf .\",\n        \"avatar_image\"   : {\n            \"display\": \"\",\n            \"thumb\"  : \"\",\n        },\n    },\n},\n\n{\n    \"#url\"     : \"https://are.na/evan-collins-1522646491/cassette-futurism\",\n    \"#class\"   : arena.ArenaChannelExtractor,\n    \"#pattern\" : r\"https://d2w9rnfcy7mm78\\.cloudfront\\.net/\\d+/original_\\w+\\.\\w+\\?\\d+\\?bc=\\d\",\n    \"#count\"   : 160,\n\n    \"extension\": str,\n    \"filename\" : str,\n    \"count\"    : 160,\n    \"num\"      : range(1, 160),\n    \"source\"   : None,\n    \"block\"    : dict,\n    \"channel\"  : {\n        \"base_class\"    : \"Channel\",\n        \"can_index\"     : True,\n        \"class_name\"    : \"Channel\",\n        \"collaboration\" : False,\n        \"collaborator_count\": 0,\n        \"collaborators\" : [],\n        \"created_at\"    : \"2021-05-31T20:38:28.898Z\",\n        \"date\"          : \"dt:2021-05-31 20:38:28\",\n        \"date_updated\"  : \"type:datetime\",\n        \"follower_count\": int,\n        \"id\"            : 1102343,\n        \"kind\"          : \"default\",\n        \"length\"        : 160,\n        \"manifest\"      : dict,\n        \"metadata\"      : {\"description\": \"The 70s-and-80s bulky, gray, angular scifi &amp; hardware aesthetic. Eg. Syd Mead\"},\n        \"nsfw?\"         : False,\n        \"open\"          : False,\n        \"published\"     : True,\n        \"share_link\"    : None,\n        \"slug\"          : \"cassette-futurism\",\n        \"status\"        : \"closed\",\n        \"title\"         : \"Cassette Futurism\",\n        \"updated_at\"    : \"iso:datetime\",\n        \"user_id\"       : 51156,\n    },\n    \"owner\"    : {\n        \"avatar\"         : \"https://static.avatars.are.na/51156/small_8c6098f64217eca6b4bcff44a7abf2d7.jpg?1563035757\",\n        \"badge\"          : \"premium\",\n        \"base_class\"     : \"User\",\n        \"can_index\"      : True,\n        \"channel_count\"  : range(250, 300),\n        \"class\"          : \"User\",\n        \"created_at\"     : \"2018-04-02T05:21:30.282Z\",\n        \"first_name\"     : \"Evan\",\n        \"follower_count\" : range(4900, 6000),\n        \"following_count\": range(10, 20),\n        \"full_name\"      : \"Evan Collins\",\n        \"id\"             : 51156,\n        \"initials\"       : \"EC\",\n        \"is_confirmed\"   : True,\n        \"is_exceeding_connections_limit\": False,\n        \"is_lifetime_premium\": False,\n        \"is_pending_confirmation\": False,\n        \"is_pending_reconfirmation\": False,\n        \"is_premium\"     : True,\n        \"is_supporter\"   : False,\n        \"last_name\"      : \"Collins\",\n        \"metadata\"       : {\"description\": None},\n        \"profile_id\"     : 171860,\n        \"slug\"           : \"evan-collins-1522646491\",\n        \"username\"       : \"Evan Collins\",\n        \"avatar_image\"   : {\n            \"display\": \"https://static.avatars.are.na/51156/medium_8c6098f64217eca6b4bcff44a7abf2d7.jpg?1563035757\",\n            \"thumb\"  : \"https://static.avatars.are.na/51156/small_8c6098f64217eca6b4bcff44a7abf2d7.jpg?1563035757\",\n        },\n    },\n    \"user\"     : {\n        \"avatar\"         : \"https://static.avatars.are.na/51156/small_8c6098f64217eca6b4bcff44a7abf2d7.jpg?1563035757\",\n        \"badge\"          : \"premium\",\n        \"base_class\"     : \"User\",\n        \"can_index\"      : True,\n        \"channel_count\"  : range(250, 300),\n        \"class\"          : \"User\",\n        \"created_at\"     : \"2018-04-02T05:21:30.282Z\",\n        \"first_name\"     : \"Evan\",\n        \"follower_count\" : range(4900, 6000),\n        \"following_count\": range(10, 20),\n        \"full_name\"      : \"Evan Collins\",\n        \"id\"             : 51156,\n        \"initials\"       : \"EC\",\n        \"is_confirmed\"   : True,\n        \"is_exceeding_connections_limit\": False,\n        \"is_lifetime_premium\": False,\n        \"is_pending_confirmation\": False,\n        \"is_pending_reconfirmation\": False,\n        \"is_premium\"     : True,\n        \"is_supporter\"   : False,\n        \"last_name\"      : \"Collins\",\n        \"metadata\"       : {\"description\": None},\n        \"profile_id\"     : 171860,\n        \"slug\"           : \"evan-collins-1522646491\",\n        \"username\"       : \"Evan Collins\",\n        \"avatar_image\"   : {\n            \"display\": \"https://static.avatars.are.na/51156/medium_8c6098f64217eca6b4bcff44a7abf2d7.jpg?1563035757\",\n            \"thumb\"  : \"https://static.avatars.are.na/51156/small_8c6098f64217eca6b4bcff44a7abf2d7.jpg?1563035757\",\n        },\n    },\n},\n\n)\n"
  },
  {
    "path": "test/results/artstation.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import artstation\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://www.artstation.com/sungchoi/\",\n    \"#class\"   : artstation.ArtstationUserExtractor,\n    \"#pattern\" : r\"https://\\w+\\.artstation\\.com/p/assets/images/images/\\d+/\\d+/\\d+/8k/[^/]+\",\n    \"#range\"   : \"1-10\",\n    \"#count\"   : \">= 10\",\n},\n\n{\n    \"#url\"     : \"https://www.artstation.com/sungchoi/albums/all/\",\n    \"#class\"   : artstation.ArtstationUserExtractor,\n},\n\n{\n    \"#url\"     : \"https://sungchoi.artstation.com/\",\n    \"#class\"   : artstation.ArtstationUserExtractor,\n},\n\n{\n    \"#url\"     : \"https://sungchoi.artstation.com/projects/\",\n    \"#comment\" : \"alternate user URL format\",\n    \"#class\"   : artstation.ArtstationUserExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.artstation.com/huimeiye/albums/770899\",\n    \"#comment\" : \"'Hellboy' album\",\n    \"#class\"   : artstation.ArtstationAlbumExtractor,\n    \"#count\"   : 2,\n},\n\n{\n    \"#url\"     : \"https://www.artstation.com/huimeiye/albums/770898\",\n    \"#comment\" : \"non-existent album\",\n    \"#class\"   : artstation.ArtstationAlbumExtractor,\n    \"#exception\": \"NotFoundError\",\n},\n\n{\n    \"#url\"     : \"https://huimeiye.artstation.com/albums/770899\",\n    \"#comment\" : \"alternate user URL format\",\n    \"#class\"   : artstation.ArtstationAlbumExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.artstation.com/mikf/likes\",\n    \"#class\"   : artstation.ArtstationLikesExtractor,\n    \"#pattern\" : r\"https://\\w+\\.artstation\\.com/p/assets/images/images/\\d+/\\d+/\\d+/8k/[^/]+\",\n    \"#count\"   : 6,\n},\n\n{\n    \"#url\"     : \"https://www.artstation.com/mikf/collections/2647023\",\n    \"#class\"   : artstation.ArtstationCollectionExtractor,\n    \"#count\"   : 10,\n\n    \"collection\": {\n        \"id\"            : 2647023,\n        \"is_private\"    : False,\n        \"name\"          : \"テスト\",\n        \"projects_count\": 3,\n        \"user_id\"       : 697975,\n        \"active_projects_count\" : 3,\n        \"micro_square_image_url\": \"https://cdna.artstation.com/p/assets/images/images/005/131/434/micro_square/gaeri-kim-cat-front.jpg?1488720625\",\n        \"small_square_image_url\": \"https://cdna.artstation.com/p/assets/images/images/005/131/434/small_square/gaeri-kim-cat-front.jpg?1488720625\",\n    },\n    \"user\": \"mikf\",\n},\n\n{\n    \"#url\"     : \"https://www.artstation.com/mikf/collections\",\n    \"#class\"   : artstation.ArtstationCollectionsExtractor,\n    \"#results\" : (\n        \"https://www.artstation.com/mikf/collections/2647023\",\n        \"https://www.artstation.com/mikf/collections/2647719\",\n    ),\n\n    \"id\"            : range(2647023, 2647719),\n    \"is_private\"    : False,\n    \"name\"          : r\"re:テスト|empty\",\n    \"projects_count\": int,\n    \"user_id\"       : 697975,\n    \"active_projects_count\" : int,\n    \"micro_square_image_url\": str,\n    \"small_square_image_url\": str,\n},\n\n{\n    \"#url\"     : \"https://www.artstation.com/sungchoi/likes\",\n    \"#comment\" : \"no likes\",\n    \"#class\"   : artstation.ArtstationLikesExtractor,\n    \"#count\"   : 0,\n},\n\n{\n    \"#url\"     : \"https://www.artstation.com/contests/thu-2017/challenges/20\",\n    \"#class\"   : artstation.ArtstationChallengeExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.artstation.com/challenges/beyond-human/categories/23/submissions\",\n    \"#class\"   : artstation.ArtstationChallengeExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.artstation.com/contests/beyond-human/challenges/23?sorting=popular\",\n    \"#class\"   : artstation.ArtstationChallengeExtractor,\n    \"#range\"   : \"1-30\",\n    \"#count\"   : 30,\n\n    \"challenge\": {\n        \"id\"        : 23,\n        \"headline\"  : \"Imagining Where Future Humans Live\",\n        \"created_at\": \"2017-06-26T14:45:43+00:00\",\n        \"contest\"   : {\n            \"archived\" : True,\n            \"published\": True,\n            \"slug\"     : \"beyond-human\",\n            \"title\"    : \"Beyond Human\",\n            \"submissions_count\": 4258,\n        },\n    },\n},\n\n{\n    \"#url\"     : \"https://www.artstation.com/search?query=ancient&sort_by=rank\",\n    \"#class\"   : artstation.ArtstationSearchExtractor,\n    \"#range\"   : \"1-20\",\n    \"#count\"   : 20,\n},\n\n{\n    \"#url\"     : \"https://www.artstation.com/artwork?sorting=latest\",\n    \"#class\"   : artstation.ArtstationArtworkExtractor,\n    \"#range\"   : \"1-20\",\n    \"#count\"   : 20,\n},\n\n{\n    \"#url\"     : \"https://www.artstation.com/artwork/LQVJr\",\n    \"#class\"   : artstation.ArtstationImageExtractor,\n    \"#pattern\"     : r\"https?://\\w+\\.artstation\\.com/p/assets/images/images/008/760/279/8k/.+\",\n    \"#sha1_content\": \"3f211ce0d6ecdb502db2cdf7bbeceb11d8421170\",\n},\n\n{\n    \"#url\"     : \"https://www.artstation.com/artwork/Db3dy\",\n    \"#comment\" : \"multiple images per project\",\n    \"#class\"   : artstation.ArtstationImageExtractor,\n    \"#count\"   : 4,\n},\n\n{\n    \"#url\"     : \"https://www.artstation.com/artwork/lR8b5k\",\n    \"#comment\" : \"artstation video clips (#2566)\",\n    \"#class\"   : artstation.ArtstationImageExtractor,\n    \"#options\" : {\"videos\": True},\n    \"#range\"   : \"2-3\",\n    \"#results\" : (\n        \"https://cdn.artstation.com/p/video_sources/000/819/843/infection-4.mp4\",\n        \"https://cdn.artstation.com/p/video_sources/000/819/725/infection-veinonly-2.mp4\",\n    ),\n},\n\n{\n    \"#url\"     : \"https://www.artstation.com/artwork/r8zRm\",\n    \"#comment\" : \"mview embeds (#2566)\",\n    \"#class\"   : artstation.ArtstationImageExtractor,\n    \"#options\" : {\"mviews\": True},\n    \"#range\"   : \"4\",\n    \"#results\" : (\n        \"https://cdna.artstation.com/p/assets/marmosets/attachments/010/915/068/original/Orca-MarmosetViewer.mview?1526922111\",\n    ),\n\n    \"extension\": \"mview\",\n},\n\n{\n    \"#url\"     : \"https://www.artstation.com/artwork/g4WPK\",\n    \"#comment\" : \"embedded youtube video\",\n    \"#class\"   : artstation.ArtstationImageExtractor,\n    \"#options\" : {\"external\": True},\n    \"#pattern\" : r\"ytdl:https://www\\.youtube(-nocookie)?\\.com/embed/JNFfJtwwrU0\",\n    \"#range\"   : \"2\",\n},\n\n{\n    \"#url\"     : \"https://www.artstation.com/artwork/3q3mXB\",\n    \"#comment\" : \"404 (#3016)\",\n    \"#class\"   : artstation.ArtstationImageExtractor,\n    \"#exception\": \"NotFoundError\",\n},\n\n{\n    \"#url\"     : \"https://sungchoi.artstation.com/projects/LQVJr\",\n    \"#comment\" : \"alternate URL patterns\",\n    \"#class\"   : artstation.ArtstationImageExtractor,\n},\n\n{\n    \"#url\"     : \"https://artstn.co/p/LQVJr\",\n    \"#class\"   : artstation.ArtstationImageExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.artstation.com/sungchoi/following\",\n    \"#class\"   : artstation.ArtstationFollowingExtractor,\n    \"#pattern\" : artstation.ArtstationUserExtractor.pattern,\n    \"#count\"   : \">= 40\",\n},\n\n{\n    \"#url\"     : \"https://fede-x-rojas.artstation.com/projects/WBdaZy\",\n    \"#comment\" : \"dash in username\",\n    \"#class\"   : artstation.ArtstationImageExtractor,\n},\n\n{\n    \"#url\"     : \"https://fede-x-rojas.artstation.com/albums/8533110\",\n    \"#comment\" : \"dash in username\",\n    \"#class\"   : artstation.ArtstationAlbumExtractor,\n},\n\n{\n    \"#url\"     : \"https://fede-x-rojas.artstation.com/\",\n    \"#comment\" : \"dash in username\",\n    \"#class\"   : artstation.ArtstationUserExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/aryion.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import aryion\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://aryion.com/g4/gallery/jameshoward\",\n    \"#category\": (\"\", \"aryion\", \"gallery\"),\n    \"#class\"   : aryion.AryionGalleryExtractor,\n    \"#options\" : {\"recursive\": False},\n    \"#pattern\" : r\"https://aryion\\.com/g4/data\\.php\\?id=\\d+$\",\n    \"#range\"   : \"48-52\",\n    \"#count\"   : 5,\n},\n\n{\n    \"#url\"     : \"https://aryion.com/g4/user/jameshoward\",\n    \"#category\": (\"\", \"aryion\", \"gallery\"),\n    \"#class\"   : aryion.AryionGalleryExtractor,\n},\n\n{\n    \"#url\"     : \"https://aryion.com/g4/latest.php?name=jameshoward\",\n    \"#category\": (\"\", \"aryion\", \"gallery\"),\n    \"#class\"   : aryion.AryionGalleryExtractor,\n},\n\n{\n    \"#url\"     : \"https://aryion.com/g4/favorites/jameshoward\",\n    \"#category\": (\"\", \"aryion\", \"favorite\"),\n    \"#class\"   : aryion.AryionFavoriteExtractor,\n    \"#range\"   : \"1-10\",\n    \"#count\"   : 10,\n\n    \"user\"     : \"jameshoward\",\n    \"artist\"   : \"re:^((?!jameshoward).)*$\",\n},\n\n{\n    \"#url\"     : \"https://aryion.com/g4/favorites/CultOfTheShyCorpus\",\n    \"#class\"   : aryion.AryionFavoriteExtractor,\n    \"#range\"   : \"1-3\",\n    \"#results\" : (\n        \"https://aryion.com/g4/data.php?id=373076\",\n        \"https://aryion.com/g4/data.php?id=373075\",\n        \"https://aryion.com/g4/data.php?id=373074\",\n    ),\n\n    \"user\"  : \"CultOfTheShyCorpus\",\n    \"folder\": \"Camilla Swallows Corrin\",\n    \"path\"  : [\n        \"Older Art!\",\n        \"Older Fanart!\",\n    ],\n},\n\n{\n    \"#url\"     : \"https://aryion.com/g4/favorites/CultOfTheShyCorpus/Elf%27s%20Revenge\",\n    \"#comment\" : \"'category' URL (#8705)\",\n    \"#class\"   : aryion.AryionFavoriteExtractor,\n    \"#range\"   : \"1-3\",\n    \"#results\" : (\n        \"https://aryion.com/g4/data.php?id=531328\",\n        \"https://aryion.com/g4/data.php?id=403354\",\n        \"https://aryion.com/g4/data.php?id=361515\",\n    ),\n\n    \"folder\"     : \"Elf's Revenge\",\n    \"path\"       : [],\n},\n\n{\n    \"#url\"     : \"https://aryion.com/g4/tags.php?tag=star+wars&p=28\",\n    \"#category\": (\"\", \"aryion\", \"tag\"),\n    \"#class\"   : aryion.AryionTagExtractor,\n    \"#count\"   : \">= 5\",\n},\n\n{\n    \"#url\"     : \"https://aryion.com/g4/view/510079\",\n    \"#category\": (\"\", \"aryion\", \"post\"),\n    \"#class\"   : aryion.AryionPostExtractor,\n    \"#sha1_url\": \"f233286fa5558c07ae500f7f2d5cb0799881450e\",\n\n    \"artist\"     : \"jameshoward\",\n    \"user\"       : \"jameshoward\",\n    \"filename\"   : \"jameshoward-510079-subscribestar_150\",\n    \"extension\"  : \"jpg\",\n    \"id\"         : 510079,\n    \"width\"      : 1665,\n    \"height\"     : 1619,\n    \"size\"       : 784239,\n    \"title\"      : \"I'm on subscribestar now too!\",\n    \"description\": r\"re:Doesn't hurt to have a backup, right\\?\",\n    \"tags\"       : [\n        \"Non-Vore\",\n        \"subscribestar\",\n    ],\n    \"date\"       : \"dt:2019-02-16 19:30:34\",\n    \"path\"       : [],\n    \"views\"      : int,\n    \"favorites\"  : int,\n    \"comments\"   : int,\n    \"_http_lastmodified\": \"Sat, 16 Feb 2019 19:30:34 GMT\",\n},\n\n{\n    \"#url\"     : \"https://aryion.com/g4/view/588928\",\n    \"#comment\" : \"x-folder (#694)\",\n    \"#category\": (\"\", \"aryion\", \"post\"),\n    \"#class\"   : aryion.AryionPostExtractor,\n    \"#pattern\" : aryion.AryionPostExtractor.pattern,\n    \"#count\"   : \">= 8\",\n},\n\n{\n    \"#url\"     : \"https://aryion.com/g4/view/537379\",\n    \"#comment\" : \"x-comic-folder (#945)\",\n    \"#category\": (\"\", \"aryion\", \"post\"),\n    \"#class\"   : aryion.AryionPostExtractor,\n    \"#pattern\" : aryion.AryionPostExtractor.pattern,\n    \"#count\"   : 2,\n},\n\n{\n    \"#url\"     : \"https://aryion.com/g4/search.php?q=forest1\",\n    \"#class\"   : aryion.AryionSearchExtractor,\n    \"#results\" : (\n        \"https://aryion.com/g4/data.php?id=165068\",\n        \"https://aryion.com/g4/data.php?id=165069\",\n        \"https://aryion.com/g4/data.php?id=165070\",\n        \"https://aryion.com/g4/data.php?id=165071\",\n        \"https://aryion.com/g4/data.php?id=165064\",\n    ),\n\n    \"search\"     : {\n        \"prefix\": \"\",\n        \"q\"     : \"forest1\",\n    },\n},\n\n{\n    \"#url\"     : \"https://aryion.com/g4/search.php?q=&tags=water%2C+&type_search=&user=&from_date=04%2F01%2F2025&to_date=07%2F01%2F2025&sort=view_count&p=2\",\n    \"#class\"   : aryion.AryionSearchExtractor,\n    \"#range\"   : \"1-3\",\n    \"#results\" : (\n        \"https://aryion.com/g4/data.php?id=1134439\",\n        \"https://aryion.com/g4/data.php?id=1124899\",\n        \"https://aryion.com/g4/data.php?id=1133691\",\n    ),\n\n    \"search\"     : {\n        \"from_date\"  : \"04/01/2025\",\n        \"p\"          : \"2\",\n        \"prefix\"     : \"t_\",\n        \"q\"          : \"\",\n        \"sort\"       : \"view_count\",\n        \"tags\"       : \"water, \",\n        \"to_date\"    : \"07/01/2025\",\n        \"type_search\": \"\",\n        \"user\"       : \"\",\n    },\n},\n\n{\n    \"#url\"     : \"https://aryion.com/g4/messagepage.php\",\n    \"#class\"   : aryion.AryionWatchExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/atfbooru.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import danbooru\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://booru.allthefallen.moe/posts?tags=yume_shokunin\",\n    \"#category\": (\"Danbooru\", \"atfbooru\", \"tag\"),\n    \"#class\"   : danbooru.DanbooruTagExtractor,\n    \"#count\"   : 12,\n},\n\n{\n    \"#url\"     : \"https://booru.allthefallen.moe/pools/9\",\n    \"#category\": (\"Danbooru\", \"atfbooru\", \"pool\"),\n    \"#class\"   : danbooru.DanbooruPoolExtractor,\n    \"#count\"   : 6,\n    \"#sha1_url\": \"902549ffcdb00fe033c3f63e12bc3cb95c5fd8d5\",\n},\n\n{\n    \"#url\"     : \"https://booru.allthefallen.moe/posts/22\",\n    \"#category\": (\"Danbooru\", \"atfbooru\", \"post\"),\n    \"#class\"   : danbooru.DanbooruPostExtractor,\n    \"#sha1_content\": \"21dda68e1d7e0a554078e62923f537d8e895cac8\",\n},\n\n{\n    \"#url\"     : \"https://booru.allthefallen.moe/explore/posts/popular\",\n    \"#category\": (\"Danbooru\", \"atfbooru\", \"popular\"),\n    \"#class\"   : danbooru.DanbooruPopularExtractor,\n},\n\n{\n    \"#url\"     : \"https://booru.allthefallen.moe/posts/random?tags=yume_shokunin\",\n    \"#category\": (\"Danbooru\", \"atfbooru\", \"random\"),\n    \"#class\"   : danbooru.DanbooruRandomExtractor,\n},\n\n{\n    \"#url\"     : \"https://booru.allthefallen.moe/posts/random\",\n    \"#category\": (\"Danbooru\", \"atfbooru\", \"random\"),\n    \"#class\"   : danbooru.DanbooruRandomExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/atfforum.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import xenforo\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://www.allthefallen.moe/forum/index.php?threads/final-fantasy-xiv.57090/post-21765744\",\n    \"#category\": (\"xenforo\", \"atfforum\", \"post\"),\n    \"#class\"   : xenforo.XenforoPostExtractor,\n    \"#auth\"    : True,\n    \"#results\" : \"https://allthefallen.moe/forum/index.php?attachments/ffxiv_dx11-2025-04-28-09-16-54-png.4368606/\",\n\n    \"count\"       : 1,\n    \"extension\"   : \"png\",\n    \"filename\"    : \"ffxiv_dx11 2025-04-28 09-16-54\",\n    \"id\"          : 4368606,\n    \"num\"         : 1,\n    \"num_external\": 0,\n    \"num_internal\": 1,\n    \"type\"        : \"inline\",\n    \"post\"        : {\n        \"attachments\": \"\",\n        \"author\"     : \"mayumiXIV\",\n        \"author_id\"  : \"965353\",\n        \"author_url\" : \"https://allthefallen.moe/forum/index.php?members/mayumixiv.965353/\",\n        \"count\"      : 1,\n        \"date\"       : \"dt:2025-04-28 15:28:24\",\n        \"id\"         : \"21765744\",\n        \"content\"    : str\n    },\n    \"thread\"      : {\n        \"author\"    : \"Kupowo\",\n        \"author_id\" : \"649590\",\n        \"author_url\": \"https://allthefallen.moe/forum/index.php?members/kupowo.649590/\",\n        \"date\"      : \"dt:2023-12-25 21:15:53\",\n        \"id\"        : \"57090\",\n        \"section\"   : \"Gaming\",\n        \"title\"     : \"Final Fantasy XIV\",\n        \"url\"       : \"https://allthefallen.moe/forum/index.php?threads/final-fantasy-xiv.57090/\",\n        \"posts\"     : range(210, 280),\n        \"views\"     : range(7300, 9000),\n        \"tags\"      : [\n            \"ff14\",\n            \"final fantasy 14\",\n            \"final fantasy xiv\",\n        ],\n    },\n},\n\n{\n    \"#url\"     : \"https://www.allthefallen.moe/forum/index.php?threads/final-fantasy-xiv.57090/#post-21765744\",\n    \"#category\": (\"xenforo\", \"atfforum\", \"post\"),\n    \"#class\"   : xenforo.XenforoPostExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.allthefallen.moe/forum/index.php?threads/shoujo-ramune-episode-1-decensored-by-deepcreampy.17050/#post-19803487\",\n    \"#comment\" : \"incomplete video URL (#8786)\",\n    \"#category\": (\"xenforo\", \"atfforum\", \"post\"),\n    \"#class\"   : xenforo.XenforoPostExtractor,\n    \"#auth\"    : True,\n    \"#results\" : \"https://allthefallen.moe/forum/data/video/1094/1094367-e46ad8636dee0d4488db56d3770919cc.mp4\",\n},\n\n{\n    \"#url\"     : \"https://www.allthefallen.moe/forum/index.php?threads/final-fantasy-xiv.57090/\",\n    \"#category\": (\"xenforo\", \"atfforum\", \"thread\"),\n    \"#class\"   : xenforo.XenforoThreadExtractor,\n    \"#auth\"    : True,\n    \"#count\"   : range(50, 90),\n\n    \"count\"       : int,\n    \"num\"         : int,\n    \"num_external\": int,\n    \"num_internal\": int,\n    \"type\"        : {\"inline\", \"external\"},\n    \"post\"        : {\n        \"attachments\": str,\n        \"author\"     : str,\n        \"author_id\"  : r\"re:\\d\",\n        \"author_url\" : r\"re:https://allthefallen.moe/forum/index.php\\?members/.+\",\n        \"count\"      : range(0, 9),\n        \"date\"       : \"type:datetime\",\n        \"id\"         : r\"re:\\d+\",\n        \"content\"    : str,\n    },\n    \"thread\"      : {\n        \"author\"    : \"Kupowo\",\n        \"author_id\" : \"649590\",\n        \"author_url\": \"https://allthefallen.moe/forum/index.php?members/kupowo.649590/\",\n        \"date\"      : \"dt:2023-12-25 21:15:53\",\n        \"id\"        : \"57090\",\n        \"section\"   : \"Gaming\",\n        \"title\"     : \"Final Fantasy XIV\",\n        \"url\"       : \"https://allthefallen.moe/forum/index.php?threads/final-fantasy-xiv.57090/\",\n        \"posts\"     : range(210, 280),\n        \"views\"     : range(7300, 9000),\n        \"tags\"      : [\n            \"ff14\",\n            \"final fantasy 14\",\n            \"final fantasy xiv\",\n        ],\n    },\n},\n\n{\n    \"#url\"     : \"https://www.allthefallen.moe/forum/index.php?forums/announcements.16/\",\n    \"#category\": (\"xenforo\", \"atfforum\", \"forum\"),\n    \"#class\"   : xenforo.XenforoForumExtractor,\n    \"#pattern\" : xenforo.XenforoThreadExtractor.pattern,\n    \"#auth\"    : True,\n    \"#count\"   : range(100, 200),\n},\n\n{\n    \"#url\"     : \"https://allthefallen.moe/forum/index.php?media/1737485564664-png.224260/\",\n    \"#category\": (\"xenforo\", \"atfforum\", \"media-item\"),\n    \"#class\"   : xenforo.XenforoMediaItemExtractor,\n    \"#options\" : {\"metadata\": False},\n    \"#results\" : \"https://allthefallen.moe/forum/index.php?media/1737485564664-png.224260/full\",\n\n    \"extension\": \"png\",\n    \"filename\" : \"1737485564664\",\n    \"id\"       : \"224260\",\n},\n\n{\n    \"#url\"     : \"https://allthefallen.moe/forum/index.php?media/users/peters.150992/\",\n    \"#category\": (\"xenforo\", \"atfforum\", \"media-user\"),\n    \"#class\"   : xenforo.XenforoMediaUserExtractor,\n    \"#options\" : {\"metadata\": False},\n    \"#auth\"    : True,\n    \"#results\" : (\n        \"https://allthefallen.moe/forum/index.php?media/eden-invitation-jpg.254624/full\",\n        \"https://allthefallen.moe/forum/index.php?media/1737485564664-png.224260/full\",\n        \"https://allthefallen.moe/forum/index.php?media/laughing-cat-emoji-png.243825/full\",\n    ),\n},\n\n{\n    \"#url\"     : \"https://allthefallen.moe/forum/index.php?members/peters.150992/#xfmgMedia\",\n    \"#category\": (\"xenforo\", \"atfforum\", \"media-user\"),\n    \"#class\"   : xenforo.XenforoMediaUserExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.allthefallen.moe/forum/index.php?media/categories/translations.2/\",\n    \"#category\": (\"xenforo\", \"atfforum\", \"media-category\"),\n    \"#class\"   : xenforo.XenforoMediaCategoryExtractor,\n},\n\n{\n    \"#url\"     : \"https://allthefallen.moe/forum/index.php?media/albums/2-%E9%AD%94%E6%B3%95%E5%B0%91%E5%A5%B3%E3%81%AB%E3%81%82%E3%81%93%E3%81%8C%E3%82%8C%E3%81%A6-mahou-shoujo-ni-akogarete.7385/\",\n    \"#category\": (\"xenforo\", \"atfforum\", \"media-album\"),\n    \"#class\"   : xenforo.XenforoMediaAlbumExtractor,\n},\n\n{\n    \"#url\"     : \"https://allthefallen.moe/forum/index.php?members/spammy.195035/\",\n    \"#category\": (\"xenforo\", \"atfforum\", \"profile\"),\n    \"#class\"   : xenforo.XenforoProfileExtractor,\n},\n\n{\n    \"#url\"     : \"https://allthefallen.moe/forum/index.php?members/spammy.195035/page-2\",\n    \"#category\": (\"xenforo\", \"atfforum\", \"profile\"),\n    \"#class\"   : xenforo.XenforoProfileExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/audiochan.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import audiochan\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://audiochan.com/a/pBP1V1ODEV2od9CjLu\",\n    \"#class\"   : audiochan.AudiochanAudioExtractor,\n    \"#pattern\" : r\"https://stream.audiochan.com/v\\?token=YXVkaW9zL2Q4YjA1ZWEzLWU0ZGItNGU2NC05MzZiLTQzNmI3MmM4OTViMS9sOTBCOFI0ajhjS0NFSmNwa2kubXAz&exp=\\d+&st=.+\",\n    \"#count\"   : 1,\n\n    \"description\": [\n        \"author summary: \",\n        \"You wake up in the middle of the night, noticing that your boyfriend is still awake and restlessly moving around. Work-related worries are making him anxious and keeping him from falling asleep so you do your best to take care of him, calm him down, and help him get some rest.\"\n    ],\n    \"user\": {\n        \"username\": \"lil-lovergirl\",\n    },\n},\n\n{\n    \"#url\"     : \"https://audiochan.com/u/lil-lovergirl\",\n    \"#class\"   : audiochan.AudiochanUserExtractor,\n    \"#pattern\" : r\"https://stream\\.audiochan\\.com/v\\?token=\\w+\\&exp=\\d+\\&st=.+\",\n    \"#count\"   : range(35, 50),\n\n    \"user\": {\n        \"username\": \"lil-lovergirl\",\n    },\n},\n\n{\n    \"#url\"     : \"https://audiochan.com/c/qzrByaXAwTLVXRgC9m\",\n    \"#class\"   : audiochan.AudiochanCollectionExtractor,\n    \"#results\" : (\n        \"https://content.audiochan.com/audios/d8b05ea3-e4db-4e64-936b-436b72c895b1/l90B8R4j8cKCEJcpki.mp3\",\n        \"https://content.audiochan.com/audios/d8b05ea3-e4db-4e64-936b-436b72c895b1/IPI4XoXS1Z1Qn7oEiN.mp3\",\n        \"https://content.audiochan.com/audios/d8b05ea3-e4db-4e64-936b-436b72c895b1/6kwizqnvUHttvUkXm6.mp3\",\n        \"https://content.audiochan.com/audios/d8b05ea3-e4db-4e64-936b-436b72c895b1/zn81mtgXslfDd20Tu8.wav\",\n        \"https://content.audiochan.com/audios/d8b05ea3-e4db-4e64-936b-436b72c895b1/Q33gP6yAg8jEM1C4Ic.mp3\",\n        \"https://content.audiochan.com/audios/d8b05ea3-e4db-4e64-936b-436b72c895b1/Fwy5YxgK4zc7sQ9xx3.mp3\",\n        \"https://content.audiochan.com/audios/d8b05ea3-e4db-4e64-936b-436b72c895b1/P3YrtAdKVekYb3BTgy.mp3\",\n        \"https://content.audiochan.com/audios/d8b05ea3-e4db-4e64-936b-436b72c895b1/kWYsadsb4XgVh7YPVW.mp3\",\n    ),\n\n    \"collection\": {\n        \"id\": \"6d7a89a4-e752-4772-923d-65783aee332e\",\n        \"slug\": \"qzrByaXAwTLVXRgC9m\",\n        \"title\": \"💗SFW\",\n    },\n    \"user\": {\n        \"username\": \"lil-lovergirl\",\n    },\n},\n\n{\n    \"#url\"     : \"https://audiochan.com/search?q=Cozy&sort=trending&timeRange=all\",\n    \"#class\"   : audiochan.AudiochanSearchExtractor,\n    \"#count\"   : range(15, 40),\n\n    \"search_tags\": \"Cozy\",\n    \"description\": list,\n    \"user\": dict,\n    \"tags\": list,\n},\n\n)\n"
  },
  {
    "path": "test/results/azurlanewiki.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import wikimedia\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://azurlane.koumakan.jp/wiki/Azur_Lane_Wiki\",\n    \"#category\": (\"wikimedia\", \"azurlanewiki\", \"article\"),\n    \"#class\"   : wikimedia.WikimediaArticleExtractor,\n},\n\n{\n    \"#url\"     : \"https://azurlane.koumakan.jp/wiki/Louisville/Gallery\",\n    \"#comment\" : \"entries with missing 'imageinfo' (#5384)\",\n    \"#category\": (\"wikimedia\", \"azurlanewiki\", \"article\"),\n    \"#class\"   : wikimedia.WikimediaArticleExtractor,\n    \"#count\"   : \"> 10\",\n},\n\n)\n"
  },
  {
    "path": "test/results/b4k.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import foolfuuka\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://arch.b4k.dev/meta/thread/196/\",\n    \"#category\": (\"foolfuuka\", \"b4k\", \"thread\"),\n    \"#class\"   : foolfuuka.FoolfuukaThreadExtractor,\n    \"#results\" : \"https://arch.b4k.dev/media/meta/image/1481/33/14813348737492.jpg\",\n},\n\n{\n    \"#url\"     : \"https://arch.b4k.co/meta/thread/196/\",\n    \"#category\": (\"foolfuuka\", \"b4k\", \"thread\"),\n    \"#class\"   : foolfuuka.FoolfuukaThreadExtractor,\n    \"#results\" : \"https://arch.b4k.dev/media/meta/image/1481/33/14813348737492.jpg\",\n},\n\n{\n    \"#url\"     : \"https://arch.b4k.dev/meta/\",\n    \"#category\": (\"foolfuuka\", \"b4k\", \"board\"),\n    \"#class\"   : foolfuuka.FoolfuukaBoardExtractor,\n},\n\n{\n    \"#url\"     : \"https://arch.b4k.dev/meta/gallery/\",\n    \"#category\": (\"foolfuuka\", \"b4k\", \"gallery\"),\n    \"#class\"   : foolfuuka.FoolfuukaGalleryExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/baraag.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import mastodon\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://baraag.net/@pumpkinnsfw\",\n    \"#category\": (\"mastodon\", \"baraag\", \"user\"),\n    \"#class\"   : mastodon.MastodonUserExtractor,\n},\n\n{\n    \"#url\"     : \"https://baraag.net/bookmarks\",\n    \"#category\": (\"mastodon\", \"baraag\", \"bookmark\"),\n    \"#class\"   : mastodon.MastodonBookmarkExtractor,\n},\n\n{\n    \"#url\"     : \"https://baraag.net/users/pumpkinnsfw/following\",\n    \"#category\": (\"mastodon\", \"baraag\", \"following\"),\n    \"#class\"   : mastodon.MastodonFollowingExtractor,\n},\n\n{\n    \"#url\"     : \"https://baraag.net/@pumpkinnsfw/104364170556898443\",\n    \"#category\": (\"mastodon\", \"baraag\", \"status\"),\n    \"#class\"   : mastodon.MastodonStatusExtractor,\n    \"#sha1_content\": \"67748c1b828c58ad60d0fe5729b59fb29c872244\",\n},\n\n)\n"
  },
  {
    "path": "test/results/bbc.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import bbc\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://www.bbc.co.uk/programmes/p084qtzs/p085g9kg\",\n    \"#category\": (\"\", \"bbc\", \"gallery\"),\n    \"#class\"   : bbc.BbcGalleryExtractor,\n    \"#pattern\" : r\"https://ichef\\.bbci\\.co\\.uk/images/ic/1920xn/\\w+\\.jpg\",\n    \"#count\"   : 37,\n\n    \"count\"      : 37,\n    \"num\"        : range(1, 37),\n    \"description\": \"The Cybermen attack. And for the Doctor, nothing will ever be the same.\",\n    \"programme\"  : \"p084qtzs\",\n    \"synopsis\"   : \"The Cybermen attack. And for the Doctor, nothing will ever be the same.\",\n    \"title\"      : \"The Timeless Children\",\n    \"title_image\": {\"The Timeless Children\", \": The Timeless Children\"},\n    \"path\"       : [\n        \"BBC One\",\n        \"Doctor Who (2005–2022)\",\n        \"The Timeless Children\",\n    ],\n},\n\n{\n    \"#url\"     : \"https://www.bbc.co.uk/programmes/p086f8yf/p086f8j6\",\n    \"#category\": (\"\", \"bbc\", \"gallery\"),\n    \"#class\"   : bbc.BbcGalleryExtractor,\n    \"#pattern\" : r\"https://ichef\\.bbci\\.co\\.uk/images/ic/1920xn/\\w+\\.jpg\",\n    \"#range\"   : \"1-2\",\n    \"#count\"   : 2,\n\n    \"count\"      : 9,\n    \"num\"        : {1, 2},\n    \"description\": \"Continuing his journey, Colin gives unique insights into the unique animals he finds.\",\n    \"extension\"  : \"jpg\",\n    \"filename\"   : {\"p086f7yn\", \"p086f80n\"},\n    \"programme\"  : \"p086f8yf\",\n    \"title\"      : \"Wild Cuba: A Caribbean Journey - Part 2\",\n    \"title_image\": {\n        \"Cuba is home to many unique birds\",\n        \"A Cuban pygmy owl looks out of its tree hole\",\n    },\n    \"synopsis\"   : {\n        \"This vibrant Cuban tody is just one of more than 300 species of bird found in Cuba.\",\n        \"Cuban pygmy owls nest in abandoned holes carved out by woodpeckers.\",\n    },\n    \"path\"       : [\n        \"BBC Two\",\n        \"Natural World\",\n        \"2019-2020\",\n        \"Wild Cuba: A Caribbean Journey - Part 2\",\n        \"Wildlife camera operator Colin Stafford-Johnson has loved Cuba since he was a little boy\"\n    ],\n},\n\n{\n    \"#url\"     : \"https://www.bbc.co.uk/programmes/p084qtzs\",\n    \"#category\": (\"\", \"bbc\", \"gallery\"),\n    \"#class\"   : bbc.BbcGalleryExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.bbc.co.uk/programmes/b006q2x0/galleries\",\n    \"#category\": (\"\", \"bbc\", \"programme\"),\n    \"#class\"   : bbc.BbcProgrammeExtractor,\n    \"#pattern\" : bbc.BbcGalleryExtractor.pattern,\n    \"#range\"   : \"1-50\",\n    \"#count\"   : \">= 50\",\n},\n\n{\n    \"#url\"     : \"https://www.bbc.co.uk/programmes/b006q2x0/galleries?page=25\",\n    \"#category\": (\"\", \"bbc\", \"programme\"),\n    \"#class\"   : bbc.BbcProgrammeExtractor,\n    \"#pattern\" : bbc.BbcGalleryExtractor.pattern,\n    \"#count\"   : \">= 100\",\n},\n\n)\n"
  },
  {
    "path": "test/results/bbw-chan.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import lynxchan\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://bbw-chan.link/bbwdraw/res/499.html\",\n    \"#category\": (\"lynxchan\", \"bbw-chan\", \"thread\"),\n    \"#class\"   : lynxchan.LynxchanThreadExtractor,\n    \"#pattern\" : r\"https://bbw-chan\\.link/\\.media/[0-9a-f]{64}(\\.\\w+)?$\",\n    \"#count\"   : \">= 352\",\n},\n\n{\n    \"#url\"     : \"https://bbw-chan.nl/bbwdraw/res/489.html\",\n    \"#category\": (\"lynxchan\", \"bbw-chan\", \"thread\"),\n    \"#class\"   : lynxchan.LynxchanThreadExtractor,\n},\n\n{\n    \"#url\"     : \"https://bbw-chan.link/bbwdraw/\",\n    \"#category\": (\"lynxchan\", \"bbw-chan\", \"board\"),\n    \"#class\"   : lynxchan.LynxchanBoardExtractor,\n    \"#pattern\" : lynxchan.LynxchanThreadExtractor.pattern,\n    \"#count\"   : \">= 148\",\n},\n\n{\n    \"#url\"     : \"https://bbw-chan.nl/bbwdraw/2.html\",\n    \"#category\": (\"lynxchan\", \"bbw-chan\", \"board\"),\n    \"#class\"   : lynxchan.LynxchanBoardExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/bcbnsfw.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import szurubooru\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://booru.bcbnsfw.space/posts/query=simple_background\",\n    \"#category\": (\"szurubooru\", \"bcbnsfw\", \"tag\"),\n    \"#class\"   : szurubooru.SzurubooruTagExtractor,\n},\n\n{\n    \"#url\"     : \"https://booru.bcbnsfw.space/post/1599\",\n    \"#comment\" : \"now only available as WebP\",\n    \"#category\": (\"szurubooru\", \"bcbnsfw\", \"post\"),\n    \"#class\"   : szurubooru.SzurubooruPostExtractor,\n    \"#pattern\"     : r\"https://booru\\.bcbnsfw\\.space/data/posts/1599_53784518e92086bd\\.png\",\n    \"#sha1_content\": \"55f8b8d187adc82f2dcaf2aa89db0ae21b08c0b0\",\n},\n\n)\n"
  },
  {
    "path": "test/results/behance.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import behance\nfrom gallery_dl import exception\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://www.behance.net/gallery/17386197/A-Short-Story\",\n    \"#class\"   : behance.BehanceGalleryExtractor,\n    \"#results\" : (\n        \"ytdl:https://player.vimeo.com/video/97189640?title=0&byline=0&portrait=0&color=ffffff\",\n        \"https://mir-s3-cdn-cf.behance.net/project_modules/source/a5a12417386197.562bc055a107d.jpg\",\n    ),\n\n    \"id\"    : 17386197,\n    \"date\"  : \"dt:2014-06-03 15:41:51\",\n    \"name\"  : r\"re:\\\"Hi\\\". A short story about the important things \",\n    \"module\": dict,\n\n    \"owners\": [\n        \"Place Studio\",\n        \"Julio César Velazquez\",\n    ],\n    \"creator\": {\n        \"displayName\"     : \"Place Studio\",\n        \"hasAllowEmbeds\"  : True,\n        \"hasPremiumAccess\": False,\n        \"id\"              : 119690,\n        \"name\"            : \"weareplace\",\n        \"url\"             : \"https://www.behance.net/weareplace\",\n    },\n    \"?fields\": [\n        \"Animation\",\n        \"Character Design\",\n        \"Directing\",\n    ],\n    \"tags\": [\n        \"short\",\n        \"life\",\n        \"motion\",\n        \"hi\",\n        \"toon\",\n        \"kids\",\n        \"Character\",\n        \"story\",\n        \"happy\",\n        \"shape\",\n        \"disney\",\n    ],\n},\n\n{\n    \"#url\"     : \"https://www.behance.net/gallery/21324767/Nevada-City\",\n    \"#class\"   : behance.BehanceGalleryExtractor,\n    \"#results\" : (\n        \"https://mir-s3-cdn-cf.behance.net/project_modules/source/f5230a21324767.562ff473c2945.jpg\",\n        \"https://mir-s3-cdn-cf.behance.net/project_modules/source/5674c921324767.562ff473a3ef8.jpg\",\n        \"https://mir-s3-cdn-cf.behance.net/project_modules/source/9f6d3b21324767.562ff473c9da5.jpg\",\n        \"https://mir-s3-cdn-cf.behance.net/project_modules/source/3781c921324767.562ff473afa1c.jpg\",\n        \"https://mir-s3-cdn-cf.behance.net/project_modules/source/02011a21324767.562ff473bed3d.jpg\",\n        \"https://mir-s3-cdn-cf.behance.net/project_modules/source/2a65cf21324767.562ff473b7e3d.jpg\",\n    ),\n\n    \"creator\": {\"name\": \"alexstrohl\"},\n    \"owners\" : [\"Alex Strohl\"],\n},\n\n{\n    \"#url\"     : \"https://www.behance.net/gallery/88276087/Audi-R8-RWD\",\n    \"#comment\" : \"'media_collection' modules\",\n    \"#class\"   : behance.BehanceGalleryExtractor,\n    \"#pattern\" : r\"https://mir-s3-cdn-cf\\.behance\\.net/project_modules/source/[0-9a-f]+.[0-9a-f]+\\.jpg\",\n    \"#count\"   : 20,\n    \"#sha1_url\": \"6bebff0d37f85349f9ad28bd8b76fd66627c1e2f\",\n\n    \"creator\": {\"name\": \"AgnieszkaDoroszewicz\"},\n    \"owners\" : [\"Agnieszka Doroszewicz\"],\n},\n\n{\n    \"#url\"     : \"https://www.behance.net/gallery/101185577/COLCCI\",\n    \"#comment\" : \"'video' modules (#1282)\",\n    \"#class\"   : behance.BehanceGalleryExtractor,\n    \"#pattern\" : r\"ytdl:https://cdn-prod-ccv\\.adobe\\.com/\\w+/rend/master\\.m3u8\\?\",\n    \"#count\"   : 3,\n\n    \"creator\": {\"name\": \"brnsimao\"},\n    \"owners\" : [\"Bruno Simao\"],\n},\n\n{\n    \"#url\"     : \"https://www.behance.net/gallery/89270715/Moevir\",\n    \"#comment\" : \"'text' modules (#4799)\",\n    \"#class\"   : behance.BehanceGalleryExtractor,\n    \"#options\" : {\"modules\": \"text\"},\n    \"#results\" : \"\"\"text:<div>Make Shift<br><a href=\"https://www.moevir.com/News/make-shif?fbclid=IwAR2MXL7mVDskdXHitLs4tv_RQFqB1tpAYix2EMIzea4lOSIPdPOR45wEJMA\" target=\"_blank\" rel=\"nofollow\">https://www.moevir.com/News/make-shif</a><br>Moevir Magazine November Issue 2019<br>Photography by Caesar Lima @caephoto <br>Model: Bee @phamhuongbee <br>Makeup by Monica Alvarez @monicaalvarezmakeup <br>Styling by Jessica Boal @jessicaboal <br>Hair by James Gilbert @brandnewjames<br>Shot at Vila Sophia<br></div>\"\"\",\n\n    \"creator\": {\"name\": \"caephoto\"},\n    \"owners\" : [\"Caesar Lima\"],\n},\n\n{\n    \"#url\"     : \"https://www.behance.net/gallery/177464639/Kimori\",\n    \"#comment\" : \"mature content (#4417)\",\n    \"#class\"   : behance.BehanceGalleryExtractor,\n    \"#exception\": exception.AuthorizationError,\n},\n\n{\n    \"#url\"     : \"https://www.behance.net/alexstrohl\",\n    \"#class\"   : behance.BehanceUserExtractor,\n    \"#pattern\" : behance.BehanceGalleryExtractor.pattern,\n    \"#count\"   : \">= 11\",\n},\n\n{\n    \"#url\"     : \"https://www.behance.net/collection/71340149/inspiration\",\n    \"#class\"   : behance.BehanceCollectionExtractor,\n    \"#pattern\" : behance.BehanceGalleryExtractor.pattern,\n    \"#count\"   : \">= 150\",\n\n    \"!creator\": dict,\n},\n\n)\n"
  },
  {
    "path": "test/results/bellazon.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import bellazon\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://www.bellazon.com/main/topic/57872-millie-brady/#findComment-4351049\",\n    \"#class\"   : bellazon.BellazonPostExtractor,\n    \"#results\" : (\n        \"https://www.bellazon.com/main/uploads/monthly_2017_06/595482b77fd89_millieb280617BZNImage101.jpg.10b91b9141b374e657a1a4c3d0c96b64.jpg\",\n        \"https://www.bellazon.com/main/uploads/monthly_2017_06/595482c3f2fa0_millieb280617BZNImage102.jpg.1b706048fc525151775cf4b7c734b283.jpg\",\n        \"https://www.bellazon.com/main/uploads/monthly_2017_06/595482cdc66ad_millieb280617BZNImage103.jpg.6fa2226a314d0f0e9f9426e7f90f4808.jpg\",\n        \"https://www.bellazon.com/main/uploads/monthly_2017_06/595482dac786c_millieb280617BZNImage104.jpg.e579be6b585cef90b965d4d09969a66a.jpg\",\n        \"https://www.bellazon.com/main/uploads/monthly_2017_06/595482e772acd_millieb280617BZNImage105.jpg.428df8a841957b48452a6a6ab64ddacb.jpg\",\n    ),\n\n    \"id\"       : r\"re:55\\d+\",\n    \"filename\" : str,\n    \"extension\": \"jpg\",\n    \"count\"    : 5,\n    \"num\"         : range(1, 5),\n    \"num_internal\": range(1, 5),\n    \"num_external\": 0,\n    \"post\"     : {\n        \"author_id\"  : \"72476\",\n        \"author_slug\": \"shepherd\",\n        \"author_url\" : \"https://www.bellazon.com/main/profile/72476-shepherd/\",\n        \"count\"      : 5,\n        \"date\"       : \"dt:2017-06-29 04:32:43\",\n        \"id\"         : \"4351049\",\n        \"content\"    : \"\"\"\\\n<p>\n\\tSerpentine Galleries Summer Party, London, Jun 28 '17\n</p>\n\n<p>\n\\t \n</p>\n\n<p>\n\\t<a class=\"ipsAttachLink ipsAttachLink_image\" href=\"https://www.bellazon.com/main/uploads/monthly_2017_06/595482b77fd89_millieb280617BZNImage101.jpg.10b91b9141b374e657a1a4c3d0c96b64.jpg\" data-fileid=\"5550073\" rel=\"\"><img alt=\"millieb280617BZNImage101.jpg\" class=\"ipsImage ipsImage_thumbnailed\" data-fileid=\"5550073\" src=\"https://www.bellazon.com/main/uploads/monthly_2017_06/595482b7c4730_millieb280617BZNImage101.thumb.jpg.5b5240deead09ec5546a6bbf68aff724.jpg\" data-ratio=\"66.56\" loading=\"lazy\"></a> <a class=\"ipsAttachLink ipsAttachLink_image\" href=\"https://www.bellazon.com/main/uploads/monthly_2017_06/595482c3f2fa0_millieb280617BZNImage102.jpg.1b706048fc525151775cf4b7c734b283.jpg\" data-fileid=\"5550074\" rel=\"\"><img alt=\"millieb280617BZNImage102.jpg\" class=\"ipsImage ipsImage_thumbnailed\" data-fileid=\"5550074\" src=\"https://www.bellazon.com/main/uploads/monthly_2017_06/595482c4529af_millieb280617BZNImage102.thumb.jpg.1b9f9ec5f002eaaaa80a174d1a7853d0.jpg\" data-ratio=\"150\" loading=\"lazy\"></a> <a class=\"ipsAttachLink ipsAttachLink_image\" href=\"https://www.bellazon.com/main/uploads/monthly_2017_06/595482cdc66ad_millieb280617BZNImage103.jpg.6fa2226a314d0f0e9f9426e7f90f4808.jpg\" data-fileid=\"5550075\" rel=\"\"><img alt=\"millieb280617BZNImage103.jpg\" class=\"ipsImage ipsImage_thumbnailed\" data-fileid=\"5550075\" src=\"https://www.bellazon.com/main/uploads/monthly_2017_06/595482ce268f7_millieb280617BZNImage103.thumb.jpg.580d38335424d6fa65bd5d476625864b.jpg\" data-ratio=\"150.23\" loading=\"lazy\"></a>\n</p>\n\n<p>\n\\t<a class=\"ipsAttachLink ipsAttachLink_image\" href=\"https://www.bellazon.com/main/uploads/monthly_2017_06/595482dac786c_millieb280617BZNImage104.jpg.e579be6b585cef90b965d4d09969a66a.jpg\" data-fileid=\"5550076\" rel=\"\"><img alt=\"millieb280617BZNImage104.jpg\" class=\"ipsImage ipsImage_thumbnailed\" data-fileid=\"5550076\" src=\"https://www.bellazon.com/main/uploads/monthly_2017_06/595482db10e03_millieb280617BZNImage104.thumb.jpg.958eba72b585110a4b8c08f1efd9cfc8.jpg\" title=\"\" data-ratio=\"150.26\" loading=\"lazy\"></a> <a class=\"ipsAttachLink ipsAttachLink_image\" href=\"https://www.bellazon.com/main/uploads/monthly_2017_06/595482e772acd_millieb280617BZNImage105.jpg.428df8a841957b48452a6a6ab64ddacb.jpg\" data-fileid=\"5550077\" rel=\"\"><img alt=\"millieb280617BZNImage105.jpg\" class=\"ipsImage ipsImage_thumbnailed\" data-fileid=\"5550077\" src=\"https://www.bellazon.com/main/uploads/monthly_2017_06/595482e7e6bc1_millieb280617BZNImage105.thumb.jpg.1e5ce2b85f7ceed7446d7f13caa9ce2b.jpg\" data-ratio=\"150.22\" loading=\"lazy\"></a>\n</p>\\\n\"\"\",\n    },\n    \"thread\"   : {\n        \"author\"      : \"Shepherd\",\n        \"author_id\"   : \"72476\",\n        \"author_slug\" : \"shepherd\",\n        \"author_url\"  : \"https://www.bellazon.com/main/profile/72476-shepherd/\",\n        \"date\"        : \"dt:2015-06-20 21:34:31\",\n        \"date_updated\": \"dt:2017-06-29 04:32:43\",\n        \"description\" : \"Previously featured in the popular TV series, Mr Selfridge, emerging British born actress Millie Brady is set for huge success. \\nMillie has just been confirmed as the lead role in ‘The Clan of the Cave Bear’ which will begin filming in May 2015. The drama pilot is from Imagine TV, Allison Shearmur Productions, Fox 21 TV and Lionsgate TV. Millie is also due to appear in the eagerly awaited black comedy, 'Pride and Prejudice and Zombies', staring alongside Matt Smith, Sally Philiips, Douglas Booth, Lily james and Sam Riley. She is currently filming 'Knights of the Roundtable: King Arthur' directed by Guy Ritchie. \\n  \\n  \\nFarfetch, Jun 2015 \\nLinda Brownlee photos\",\n        \"id\"          : \"57872\",\n        \"posts\"       : 1,\n        \"section\"     : \"Actresses\",\n        \"slug\"        : \"millie-brady\",\n        \"title\"       : \"Millie Brady\",\n        \"url\"         : \"https://www.bellazon.com/main/topic/57872-millie-brady/\",\n        \"views\"       : range(3_800, 5_000),\n        \"path\"        : [\n            \"Females\",\n            \"Actresses\",\n            \"Millie Brady\",\n        ],\n    },\n},\n\n{\n    \"#url\"     : \"https://www.bellazon.com/main/topic/57872-millie-brady/#comment-4351049\",\n    \"#class\"   : bellazon.BellazonPostExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.bellazon.com/main/topic/3556-bipasha-basu/#findComment-2134610\",\n    \"#class\"   : bellazon.BellazonPostExtractor,\n    \"#results\" : \"https://www.bellazon.com/main/uploads/monthly_04_2010/post-35864-1270985307.jpg\",\n\n    \"id\"       : \"1002749\",\n    \"filename\" : \"post-35864-1270985307\",\n    \"extension\": \"jpg\",\n    \"count\"    : 1,\n    \"num\"      : 1,\n    \"post\"     : {\n        \"author_id\"  : \"35864\",\n        \"author_slug\": \"egluze\",\n        \"author_url\" : \"https://www.bellazon.com/main/profile/35864-egluze/\",\n        \"count\"      : 1,\n        \"date\"       : \"dt:2010-04-11 11:28:43\",\n        \"id\"         : \"2134610\",\n        \"content\"    : \"\"\"\\\n<p><strong>Marie Claire India April 2010</strong></p>\n<p><a class=\"ipsAttachLink ipsAttachLink_image\" href=\"https://www.bellazon.com/main/uploads/monthly_04_2010/post-35864-1270985307.jpg\" rel=\"external nofollow\"><img class=\"ipsImage ipsImage_thumbnailed\" src=\"https://www.bellazon.com/main/uploads/monthly_04_2010/post-35864-1270985307_thumb.jpg\" data-fileid=\"1002749\" alt=\"post-35864-1270985307_thumb.jpg\" data-ratio=\"133.67\" loading=\"lazy\"></a></p>\\\n\"\"\",\n    },\n    \"thread\"   : {\n        \"author\"      : \"SaBrIaNa\",\n        \"author_id\"   : \"1324\",\n        \"author_slug\" : \"sabriana\",\n        \"author_url\"  : \"https://www.bellazon.com/main/profile/1324-sabriana/\",\n        \"date\"        : \"dt:2005-12-26 20:31:33\",\n        \"date_updated\": \"dt:2017-06-17 05:19:09\",\n        \"description\" : str,\n        \"id\"          : \"3556\",\n        \"posts\"       : 44,\n        \"section\"     : \"Actresses\",\n        \"slug\"        : \"bipasha-basu\",\n        \"title\"       : \"Bipasha Basu\",\n        \"url\"         : \"https://www.bellazon.com/main/topic/3556-bipasha-basu/\",\n        \"views\"       : range(20_000, 50_000),\n        \"path\"        : [\n            \"Females\",\n            \"Actresses\",\n            \"Bipasha Basu\",\n        ],\n    },\n},\n\n{\n    \"#url\"     : \"https://www.bellazon.com/main/topic/66334-charly-jordan/page/3/#findComment-4576614\",\n    \"#comment\" : \"video attachments (#8239)\",\n    \"#class\"   : bellazon.BellazonPostExtractor,\n    \"#pattern\" : r\"https://www\\.bellazon\\.com/main/applications/core/interface/file/attachment\\.php\\?id=\\d+$\",\n    \"#range\"   : \"2-\",\n    \"#count\"   : 10,\n\n    \"count\"    : 12,\n    \"extension\": \"mp4\",\n    \"filename\" : r\"re:^\\d+$\",\n    \"id\"       : r\"re:6361\\d\\d\\d\",\n    \"num\"      : range(2, 12),\n    \"post\"     : {\n        \"author_id\"  : \"101807\",\n        \"author_slug\": \"rogerdanish\",\n        \"author_url\" : \"https://www.bellazon.com/main/profile/101807-rogerdanish/\",\n        \"count\"      : 12,\n        \"date\"       : \"dt:2018-04-06 19:06:06\",\n        \"id\"         : \"4576614\",\n        \"content\"    : str\n    },\n    \"thread\"   : {\n        \"author\"      : \"gtemt\",\n        \"author_id\"   : \"29506\",\n        \"author_slug\" : \"gtemt\",\n        \"author_url\"  : \"https://www.bellazon.com/main/profile/29506-gtemt/\",\n        \"date\"        : \"dt:2017-12-19 12:18:46\",\n        \"date_updated\": \"type:datetime\",\n        \"description\" : \"VID\",\n        \"id\"          : \"66334\",\n        \"posts\"       : range(750, 999),\n        \"section\"     : \"Other Females of Interest\",\n        \"slug\"        : \"charly-jordan\",\n        \"title\"       : \"Charly Jordan\",\n        \"url\"         : \"https://www.bellazon.com/main/topic/66334-charly-jordan/\",\n        \"views\"       : int,\n        \"path\"        : [\n            \"Females\",\n            \"Other Females of Interest\",\n            \"Charly Jordan\",\n        ],\n    },\n},\n\n{\n    \"#url\"     : \"https://www.bellazon.com/main/topic/66334-charly-jordan/page/3/#findComment-4571129\",\n    \"#comment\" : \"video attachment with '//www.bellazon.com/main/' as URL (#8239)\",\n    \"#class\"   : bellazon.BellazonPostExtractor,\n    \"#results\" : (\n        \"https://www.bellazon.com/main/uploads/monthly_2018_03/charlyjordan10_Bg6mLKlFBuU.jpg.07b89fe216300157ff5dad0652df11cb.jpg\",\n        \"https://www.bellazon.com/main/uploads/monthly_2018_03/charlyjordan10_Bg6mLRzlFPz.jpg.3c846bc3d7a2ec4854012ca3bab0af99.jpg\",\n        \"https://www.bellazon.com/main/uploads/monthly_2018_03/charlyjordan10_Bg6mLVYlQUL.jpg.7e32ef45d5ba5270a330b250f83639dd.jpg\",\n        \"https://www.bellazon.com/main/applications/core/interface/file/attachment.php?id=6341394\",\n    ),\n},\n\n{\n    \"#url\"     : \"https://www.bellazon.com/main/topic/66334-charly-jordan/page/31/#comment-5317926\",\n    \"#comment\" : \"video embed (#8239)\",\n    \"#class\"   : bellazon.BellazonPostExtractor,\n    \"#results\" : \"https://www.bellazon.com/main/uploads/monthly_2021_07/215731864_146910617534875_8340126104113274819_n.mp4.2f3cd5cd8cac6bf04c51d511892f187b.mp4\",\n\n    \"extension\": \"mp4\",\n    \"filename\" : \"215731864_146910617534875_8340126104113274819_n.mp4.2f3cd5cd8cac6bf04c51d511892f187b\",\n    \"id\"       : \"10919171\",\n},\n\n{\n    \"#url\"     : \"https://www.bellazon.com/main/topic/66334-charly-jordan/page/3/#findComment-4602714\",\n    \"#comment\" : \"'/profile/' link\",\n    \"#class\"   : bellazon.BellazonPostExtractor,\n    \"#count\"   : 0,\n},\n\n{\n    \"#url\"     : \"https://www.bellazon.com/main/topic/66334-charly-jordan/page/3/#findComment-4603172\",\n    \"#comment\" : \"'inline' image\",\n    \"#class\"   : bellazon.BellazonPostExtractor,\n    \"#results\" : \"https://www.bellazon.com/main/uploads/monthly_2018_04/30602369_1891291154222843_1650952189830496256_n.jpg.33e6ab78dd0e8723f790ad4f58f3761a.jpg\",\n},\n\n{\n    \"#url\"     : \"https://www.bellazon.com/main/topic/70367-elyzaveta-kovalenko/page/5/#comment-5464973\",\n    \"#comment\" : \"(#8392)\",\n    \"#class\"   : bellazon.BellazonPostExtractor,\n    \"#results\" : (\n        \"https://www.bellazon.com/main/uploads/monthly_2022_05/917305269_LizaKovalenko-Instagram2021_04_19.mp4.467d190a54e1bcabc50767a69706501d.mp4\",\n        \"https://www.bellazon.com/main/uploads/monthly_2022_05/2027180206_LizaKovalenko-Instagram2021_04_23.mp4.2eae87d7e9d6f1a993611fa1f73e8e7b.mp4\",\n    ),\n},\n\n{\n    \"#url\"     : \"https://www.bellazon.com/main/topic/70367-elyzaveta-kovalenko/page/7/#comment-5506079\",\n    \"#comment\" : \"links to other threads (#8392)\",\n    \"#class\"   : bellazon.BellazonPostExtractor,\n    \"#count\"   : 0,\n},\n\n{\n    \"#url\"     : \"https://www.bellazon.com/main/topic/4322-candids/page/1852/#comment-5902385\",\n    \"#comment\" : \"attachment 'id' with query parameters (#8544)\",\n    \"#class\"   : bellazon.BellazonPostExtractor,\n    \"#range\"   : \"4\",\n    \"#results\" : \"https://www.bellazon.com/main/applications/core/interface/file/attachment.php?id=14418097&key=491d27ee2482b1808483f1d544873b06\",\n\n    \"count\"       : 4,\n    \"num\"         : 4,\n    \"filename\"    : \"download\",\n    \"extension\"   : \"jfif\",\n    \"id\"          : \"14418097\",\n},\n\n{\n    \"#url\"     : \"https://www.bellazon.com/main/topic/4322-candids/page/1066/#comment-3956772\",\n    \"#comment\" : \"weird/wrong 'filename' & 'extension' (#8544)\",\n    \"#class\"   : bellazon.BellazonPostExtractor,\n    \"#count\"   : 16,\n\n    \"extension\"   : \"jpg\",\n    \"filename\"    : r\"re:^[^.]+$\",\n    \"id\"          : r\"re:^\\d+$\",\n},\n\n{\n    \"#url\"     : \"https://www.bellazon.com/main/topic/79152-sydney-sweeney/page/42/#comment-6113627\",\n    \"#comment\" : \"'data-full-image' URLs (#8833)\",\n    \"#class\"   : bellazon.BellazonPostExtractor,\n    \"#results\" : (\n        \"https://www.wmagazine.com/culture/sydney-sweeney-cover-interview-the-housemaid-christy\",\n        \"https://www.bellazon.com/main/uploads/monthly_2026_01/1222250126covershrinstagram2.jpg.9bca664c750694127c5c77c0e99db770.jpg\",\n        \"https://www.bellazon.com/main/uploads/monthly_2026_01/1222250126covershrcms2.jpg.a4d33f2e157aec446f9e268cce576ddc.jpg\",\n        \"https://www.bellazon.com/main/uploads/monthly_2026_01/1-0126broadsheetcmslo13-14.jpg.21f087b58d0d3cc5c7d03ea2bb62a979.jpg\",\n    ),\n\n    \"post\"        : {\n        \"author_id\"  : \"145049\",\n        \"author_slug\": \"matt\",\n        \"author_url\" : \"https://www.bellazon.com/main/profile/145049-matt/\",\n        \"content\"    : \"\"\"<p style=\"text-align:center;\">W Magazine's 2026 Best Performances issue</p><p style=\"text-align:center;\">Sydney Sweeney Talks The Housemaid, Christy, and Bonding With Amanda Seyfried</p><p style=\"text-align:center;\">Ph. Tyrone Lebon</p><p style=\"text-align:center;\"><a rel=\"external nofollow\" href=\"https://www.wmagazine.com/culture/sydney-sweeney-cover-interview-the-housemaid-christy\">https://www.wmagazine.com/culture/sydney-sweeney-cover-interview-the-housemaid-christy</a></p><p style=\"text-align:center;\"></p><p style=\"text-align:center;\"><img class=\"ipsImage ipsImage_thumbnailed ipsRichText__align--block\" data-fileid=\"15813191\" src=\"https://www.bellazon.com/main/uploads/monthly_2026_01/1222250126covershrinstagram2.thumb.jpg.7bfbdc57ebcbfd61e4ba72c2b55dfbf3.jpg\" alt=\"1222250126covershrinstagram2.jpg\" title=\"\" width=\"230\" height=\"300\" data-full-image=\"https://www.bellazon.com/main/uploads/monthly_2026_01/1222250126covershrinstagram2.jpg.9bca664c750694127c5c77c0e99db770.jpg\" loading=\"lazy\"><img class=\"ipsImage ipsImage_thumbnailed ipsRichText__align--block\" data-fileid=\"15813196\" src=\"https://www.bellazon.com/main/uploads/monthly_2026_01/1222250126covershrcms2.thumb.jpg.4ae359d3f0927aca1f7ab6a4d44b47cf.jpg\" alt=\"1222250126covershrcms2.jpg\" title=\"\" width=\"231\" height=\"300\" data-full-image=\"https://www.bellazon.com/main/uploads/monthly_2026_01/1222250126covershrcms2.jpg.a4d33f2e157aec446f9e268cce576ddc.jpg\" loading=\"lazy\"><img class=\"ipsImage ipsImage_thumbnailed ipsRichText__align--block\" data-fileid=\"15813194\" src=\"https://www.bellazon.com/main/uploads/monthly_2026_01/1-0126broadsheetcmslo13-14.thumb.jpg.904162a4ebd4a340d5a595df82e7c982.jpg\" alt=\"1-0126broadsheetcmslo13-14.jpg\" title=\"\" width=\"300\" height=\"194\" data-full-image=\"https://www.bellazon.com/main/uploads/monthly_2026_01/1-0126broadsheetcmslo13-14.jpg.21f087b58d0d3cc5c7d03ea2bb62a979.jpg\" loading=\"lazy\"></p>\"\"\",\n        \"count\"      : 4,\n        \"date\"       : \"dt:2026-01-06 16:34:53\",\n        \"id\"         : \"6113627\",\n    },\n},\n\n{\n    \"#url\"     : \"https://www.bellazon.com/main/topic/123434-%D0%BD%D0%B0-%D1%84%D0%BE%D1%82%D0%BE-%D0%B2%D0%B8%D0%BA%D1%82%D0%BE%D1%80%D0%B8%D1%8F-%D0%BA%D0%BE%D0%BB%D0%B5%D1%81%D0%BD%D0%B8%D0%BA%D0%BE%D0%B2%D0%B0/#comment-6112956\",\n    \"#comment\" : \"URL-escaped 'slug'\",\n    \"#class\"   : bellazon.BellazonPostExtractor,\n    \"#results\" : \"https://www.bellazon.com/main/uploads/monthly_2026_01/IMG_3177.png.b057c59b2168b2ff52d45cf6b1eba86e.png\",\n\n    \"extension\"   : \"png\",\n    \"filename\"    : \"IMG_3177\",\n    \"id\"          : \"15808003\",\n    \"post\"        : {\n        \"author_id\"  : \"328354\",\n        \"author_slug\": \"ghhhv\",\n        \"author_url\" : \"https://www.bellazon.com/main/profile/328354-ghhhv/\",\n        \"content\"    : \"\"\"<a href=\"https://www.bellazon.com/main/uploads/monthly_2026_01/IMG_3177.png.b057c59b2168b2ff52d45cf6b1eba86e.png\" class=\"ipsAttachLink ipsAttachLink_image\" ><img data-fileid=\"15808003\" src=\"https://www.bellazon.com/main/uploads/monthly_2026_01/IMG_3177.thumb.png.a48d590d47aa78f5da7e7dddeb6c284d.png\" height=\"300\" width=\"172\" class=\"ipsImage ipsImage_thumbnailed\" alt=\"IMG_3177.png\" loading='lazy'></a>\"\"\",\n        \"count\"      : 1,\n        \"date\"       : \"dt:2026-01-04 22:14:17\",\n        \"id\"         : \"6112956\",\n    },\n    \"thread\"      : {\n        \"author\"      : \"Ghhhv\",\n        \"author_id\"   : \"328354\",\n        \"author_slug\" : \"ghhhv\",\n        \"author_url\"  : \"https://www.bellazon.com/main/profile/328354-ghhhv/\",\n        \"date\"        : \"dt:2026-01-04 22:14:17\",\n        \"date_updated\": \"dt:2026-01-04 22:14:17\",\n        \"id\"          : \"123434\",\n        \"section\"     : \"Actresses\",\n        \"slug\"        : \"на-фото-виктория-колесникова\",\n        \"title\"       : \"на фото Виктория Колесникова\",\n        \"url\"         : \"https://www.bellazon.com/main/topic/123434-%D0%BD%D0%B0-%D1%84%D0%BE%D1%82%D0%BE-%D0%B2%D0%B8%D0%BA%D1%82%D0%BE%D1%80%D0%B8%D1%8F-%D0%BA%D0%BE%D0%BB%D0%B5%D1%81%D0%BD%D0%B8%D0%BA%D0%BE%D0%B2%D0%B0/\",\n        \"path\"        : [\n            \"Females\",\n            \"Actresses\",\n            \"на фото Виктория Колесникова\",\n        ],\n    },\n},\n\n{\n    \"#url\"     : \"https://www.bellazon.com/main/topic/72551-ren%C3%A9e-murden/page/45/#findComment-5468001\",\n    \"#comment\" : \"quote / missing parts of 'content' (#9140)\",\n    \"#class\"   : bellazon.BellazonPostExtractor,\n    \"#results\" : (\n        \"https://www.bellazon.com/main/uploads/monthly_2022_05/QT_hotels.jpg.280653cd3cdf649a55eb5be700b4206d.jpg\",\n        \"https://www.bellazon.com/main/uploads/monthly_2022_05/Renee_Test.jpg.410ac3d96ec14f535e7935a488f1abb0.jpg\",\n        \"https://www.bellazon.com/main/uploads/monthly_2022_05/Renee.jpg.5f84145f3b0bb98a4a0e48198354ccb5.jpg\",\n        \"http://depositfiles.com/files/2rmz400vm\",\n        \"https://k2s.cc/file/87f706e69800a/RM_DanielShortt.7z\",\n    ),\n\n    \"post\"        : {\n        \"author_id\"  : \"194076\",\n        \"author_slug\": \"zorzabosti\",\n        \"author_url\" : \"https://www.bellazon.com/main/profile/194076-zorzabosti/\",\n        \"date\"       : \"dt:2022-05-08 17:06:36\",\n        \"id\"         : \"5468001\",\n        \"content\"    : str,\n    },\n    \"thread\"      : {\n        \"date\": \"dt:2019-04-15 17:00:08\",\n        \"id\"  : \"72551\",\n    },\n},\n\n{\n    \"#url\"     : \"https://www.bellazon.com/main/topic/46019-alexis-ren/page/19/#findComment-3649926\",\n    \"#comment\" : \"ignore forum signature (#9140)\",\n    \"#class\"   : bellazon.BellazonPostExtractor,\n    \"#auth\"    : True,\n    \"#count\"   : 0,\n    \"#metadata\": \"post\",\n\n    \"post\": {\n        \"author_id\"  : \"18831\",\n        \"author_slug\": \"clauds\",\n        \"author_url\" : \"https://www.bellazon.com/main/profile/18831-clauds/\",\n        \"content\"    : \"<p><span style=\\\"font-family:tahoma, geneva, sans-serif;\\\">I don't know for VS Pink tbh, I think she's better fitted for SI tbh</span></p>\",\n        \"count\"      : 0,\n        \"date\"       : \"dt:2015-03-20 23:50:18\",\n        \"id\"         : \"3649926\",\n    },\n},\n\n{\n    \"#url\"     : \"https://www.bellazon.com/main/topic/46019-alexis-ren/page/8/#findComment-3383650\",\n    \"#comment\" : \"quote in quote (#9140)\",\n    \"#class\"   : bellazon.BellazonPostExtractor,\n    \"#results\" : \"https://www.bellazon.com/main/uploads/monthly_03_2014/post-57667-0-98883500-1395169341.jpg\",\n},\n\n{\n    \"#url\"     : \"https://www.bellazon.com/main/topic/46019-alexis-ren/page/8/#findComment-3383650\",\n    \"#comment\" : \"quote in quote (#9140)\",\n    \"#class\"   : bellazon.BellazonPostExtractor,\n    \"#options\" : {\"quoted\": True},\n    \"#archive\" : False,\n    \"#pattern\" : r\"^https?://(imgbox.com|bryant.photography|www.bellazon.com/main/(uploads|index.php\\?app=core&module=attach))\",\n    \"#count\"   : 20,\n},\n\n{\n    \"#url\"     : \"https://www.bellazon.com/main/topic/41694-taylor-hill/page/59/#findComment-3481833\",\n    \"#comment\" : \"'/public/style_emoticons/' emoticon\",\n    \"#class\"   : bellazon.BellazonPostExtractor,\n    \"#count\"   : 0,\n},\n\n{\n    \"#url\"     : \"https://www.bellazon.com/main/topic/7459-candice-swanepoel/page/1122/#findComment-3442969\",\n    \"#comment\" : \"'/main/index.php' attachment (#9140)\",\n    \"#class\"   : bellazon.BellazonPostExtractor,\n    \"#results\" : \"https://www.bellazon.com/main/index.php?app=core&module=attach&section=attach&attach_rel_module=post&attach_id=2021999\",\n\n    \"extension\"   : \"jpg\",\n    \"filename\"    : \"image\",\n    \"id\"          : \"2021999\",\n    \"name\"        : \"image.jpg\",\n    \"post\"        : {\n        \"author_id\"  : \"51006\",\n        \"author_slug\": \"badboy_207\",\n        \"date\"       : \"dt:2014-06-27 21:23:51\",\n        \"id\"         : \"3442969\",\n    },\n},\n\n{\n    \"#url\"     : \"https://www.bellazon.com/main/topic/57872-millie-brady/\",\n    \"#class\"   : bellazon.BellazonThreadExtractor,\n    \"#pattern\" : r\"https://www\\.bellazon\\.com/main/uploads/monthly_\\d+_\\d+/.+\\.jpg\",\n    \"#count\"   : 13,\n\n    \"id\"       : r\"re:\\d+\",\n    \"filename\" : str,\n    \"extension\": \"jpg\",\n    \"count\"    : {5, 8},\n    \"num\"      : range(1, 8),\n    \"post\"     : {\n        \"id\"       : {\"3721257\", \"4351049\"},\n        \"count\"    : {5, 8},\n        \"author_id\": \"72476\",\n        \"date\"     : \"type:datetime\",\n    },\n    \"thread\"   : {\n        \"id\"       : \"57872\",\n        \"title\"    : \"Millie Brady\",\n        \"author\"   : \"Shepherd\",\n        \"author_id\": \"72476\",\n        \"date\"     : \"dt:2015-06-20 21:34:31\",\n    },\n},\n\n{\n    \"#url\"     : \"https://www.bellazon.com/main/topic/3556-bipasha-basu/\",\n    \"#class\"   : bellazon.BellazonThreadExtractor,\n    \"#pattern\" : r\"https?://(www\\.bellazon\\.com/main/uploads/.+\\.\\w+|www\\.[^.]+\\.(com|ru)|img\\d+.imagevenue.com|imagesion.com)\",\n    \"#count\"   : 247,\n\n    \"count\"    : range(0, 30),\n    \"num\"      : range(0, 30),\n    \"post\"     : {\n        \"id\"       : r\"re:\\d+\",\n        \"author_id\": r\"re:\\d+\",\n        \"count\"    : range(0, 30),\n        \"date\"     : \"type:datetime\",\n    },\n    \"thread\"   : {\n        \"id\"          : \"3556\",\n        \"title\"       : \"Bipasha Basu\",\n        \"author\"      : \"SaBrIaNa\",\n        \"author_id\"   : \"1324\",\n        \"date\"        : \"dt:2005-12-26 20:31:33\",\n        \"date_updated\": \"dt:2017-06-17 05:19:09\",\n    },\n},\n\n{\n    \"#url\"     : \"https://www.bellazon.com/main/topic/1774-zhang-ziyi/\",\n    \"#class\"   : bellazon.BellazonThreadExtractor,\n    \"#range\"   : \"1-5\",\n    \"#options\" : {\"order-posts\": \"asc\"},\n    \"#results\" : (\n        \"http://img292.echo.cx/my.php?image=4moon011rk.jpg\",\n        \"http://img294.echo.cx/my.php?image=heroclip3jb.jpg\",\n        \"http://img294.echo.cx/my.php?image=heroclip29ut.jpg\",\n        \"http://img294.echo.cx/my.php?image=heroclip35lp.jpg\",\n        \"http://img36.echo.cx/my.php?image=895welzz4514nv.jpg\",\n    ),\n\n    \"thread\": {\n        \"author\"      : \"Hiro\",\n        \"author_id\"   : \"26\",\n        \"author_slug\" : \"hiro\",\n        \"author_url\"  : \"https://www.bellazon.com/main/profile/26-hiro/\",\n        \"date\"        : \"dt:2005-06-08 03:02:03\",\n        \"date_updated\": \"dt:2023-07-09 07:33:19\",\n        \"description\" : str,\n        \"id\"          : \"1774\",\n        \"posts\"       : 480,\n        \"section\"     : \"Actresses\",\n        \"slug\"        : \"zhang-ziyi\",\n        \"title\"       : \"Zhang Ziyi\",\n        \"url\"         : \"https://www.bellazon.com/main/topic/1774-zhang-ziyi/\",\n        \"views\"       : int,\n        \"path\"        : [\n            \"Females\",\n            \"Actresses\",\n            \"Zhang Ziyi\",\n        ],\n    },\n},\n\n{\n    \"#url\"     : \"https://www.bellazon.com/main/topic/56-candids/\",\n    \"#comment\" : \"'Guest' author (#8397)\",\n    \"#class\"   : bellazon.BellazonThreadExtractor,\n    \"#options\" : {\"order-posts\": \"asc\"},\n    \"#range\"   : \"1\",\n    \"#results\" : (\n        \"https://www.bellazon.com/main/uploads/monthly_11_2004/post-0-0-1593851439-26962.jpg\",\n    ),\n\n    \"post\"     : {\n        \"author_id\"  : \"\",\n        \"author_slug\": \"\",\n        \"author_url\" : \"\",\n        \"content\"    : \"\"\"<a href=\"https://www.bellazon.com/main/uploads/monthly_11_2004/post-0-0-1593851439-26962.jpg\" class=\"ipsAttachLink ipsAttachLink_image\"><img data-fileid=\"292\" src=\"https://www.bellazon.com/main/uploads/monthly_11_2004/post-0-0-1593851439-26962_thumb.jpg\" class=\"ipsImage ipsImage_thumbnailed\" alt=\"sss.jpg\" loading=\"lazy\"></a>\"\"\",\n        \"count\"      : 1,\n        \"date\"       : \"dt:2004-11-21 03:09:51\",\n        \"id\"         : \"292\",\n    },\n    \"thread\"   : {\n        \"author\"      : \"Guest\",\n        \"author_id\"   : \"\",\n        \"author_slug\" : \"\",\n        \"author_url\"  : \"\",\n        \"date\"        : \"dt:2004-11-21 01:44:59\",\n        \"date_updated\": \"type:datetime\",\n        \"description\" : \"Welcome to the Alessandra Ambrosio Candids gallery.  Please only post candid, unposed, off-guard, or funny images in this thread.\",\n        \"id\"          : \"56\",\n        \"posts\"       : range(13_000, 30_000),\n        \"section\"     : \"Alessandra Ambrosio\",\n        \"slug\"        : \"candids\",\n        \"title\"       : \"Candids\",\n        \"url\"         : \"https://www.bellazon.com/main/topic/56-candids/\",\n        \"views\"       : range(8_000_000, 10_000_000),\n        \"path\"        : [\n            \"Females\",\n            \"Female Fashion Models\",\n            \"Alessandra Ambrosio\",\n            \"Candids\",\n        ],\n    },\n},\n\n{\n    \"#url\"     : \"https://www.bellazon.com/main/topic/123434-%D0%BD%D0%B0-%D1%84%D0%BE%D1%82%D0%BE-%D0%B2%D0%B8%D0%BA%D1%82%D0%BE%D1%80%D0%B8%D1%8F-%D0%BA%D0%BE%D0%BB%D0%B5%D1%81%D0%BD%D0%B8%D0%BA%D0%BE%D0%B2%D0%B0/\",\n    \"#class\"   : bellazon.BellazonThreadExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.bellazon.com/main/forum/3-actresses/\",\n    \"#class\"   : bellazon.BellazonForumExtractor,\n    \"#pattern\" : bellazon.BellazonThreadExtractor.pattern,\n    \"#range\"   : \"1-100\",\n    \"#count\"   : 100,\n},\n\n)\n"
  },
  {
    "path": "test/results/bilibili.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import bilibili\n\n\n__tests__ = (\n{\n    \"#url\"  : \"https://www.bilibili.com/opus/988425412565532689\",\n    \"#class\": bilibili.BilibiliArticleExtractor,\n    \"#results\": (\n        \"http://i0.hdslb.com/bfs/new_dyn/311264c4dcf45261f7d7a7fe451b05b9405279279.png\",\n        \"http://i0.hdslb.com/bfs/new_dyn/b60d8bc6996529613d617443a12c0a93405279279.png\",\n        \"http://i0.hdslb.com/bfs/new_dyn/d4494543210d9eee5310e11dc62581e4405279279.png\",\n        \"http://i0.hdslb.com/bfs/new_dyn/45268e63086b2d99811b2e6490130937405279279.png\",\n    ),\n\n    \"count\"    : 4,\n    \"detail\"   : dict,\n    \"extension\": \"png\",\n    \"filename\" : str,\n    \"height\"   : 800,\n    \"id\"       : \"988425412565532689\",\n    \"isClient\" : False,\n    \"isPreview\": False,\n    \"num\"      : range(1, 4),\n    \"size\"     : float,\n    \"theme\"    : str,\n    \"themeMode\": \"light\",\n    \"url\"      : str,\n    \"username\" : \"平平出击\",\n    \"width\"    : 800,\n},\n\n{\n    \"#url\"    : \"https://www.bilibili.com/opus/977981688469520405\",\n    \"#comment\": \"'module_top' file (#6687)\",\n    \"#class\"  : bilibili.BilibiliArticleExtractor,\n    \"#results\": (\n        \"http://i0.hdslb.com/bfs/new_dyn/c74018e8272c56a6c28a1a1dc3c586311242656443.jpg\",\n    ),\n\n    \"count\"    : 1,\n    \"filename\" : \"c74018e8272c56a6c28a1a1dc3c586311242656443\",\n    \"extension\": \"jpg\",\n    \"width\"    : 712,\n    \"height\"   : 1068,\n    \"size\"     : 115.80999755859375,\n    \"id\"       : \"977981688469520405\",\n    \"username\" : \"诗月饼\",\n},\n\n{\n    \"#url\"     : \"https://www.bilibili.com/opus/1047501858770255875\",\n    \"#comment\" : \"blocked/paid article (#7880)\",\n    \"#class\"   : bilibili.BilibiliArticleExtractor,\n    \"#count\"   : 0,\n    \"#log\"     : \"\"\"\\\n1047501858770255875: Blocked Article\n乌龙茶专属动态\n加入当前UP主的6元档包月充电即可解锁观看\\\n\"\"\",\n},\n\n{\n    \"#url\"     : \"https://www.bilibili.com/opus/1154738799821979656\",\n    \"#comment\" : \"livephoto (#8860)\",\n    \"#class\"   : bilibili.BilibiliArticleExtractor,\n    \"#results\" : (\n        \"http://i0.hdslb.com/bfs/new_dyn/live_958a5cffe9177b196ada011867abd0a031968078.jpg\",\n        \"https://i0.hdslb.com/bfs/dyn_video/_000003lud8wlka5eq2kxctgfx3fwo3b-1-152111110022.mp4\",\n    ),\n\n    \"extension\"   : {\"jpg\", \"mp4\"},\n    \"width\"       : 4096,\n    \"height\"      : 3072,\n    \"id\"          : \"1154738799821979656\",\n    \"suffix\"      : {\"\", \"l\"},\n    \"isPreview\"   : False,\n    \"live_url\"    : \"https://i0.hdslb.com/bfs/dyn_video/_000003lud8wlka5eq2kxctgfx3fwo3b-1-152111110022.mp4\",\n    \"modern\"      : True,\n    \"theme\"       : \"light\",\n    \"themeMode\"   : \"light\",\n    \"user_id\"     : 31968078,\n    \"username\"    : \"粽子淞\",\n},\n\n{\n    \"#url\"     : \"https://www.bilibili.com/opus/1172711958019833880\",\n    \"#comment\" : \"multiple 'livephoto' files (#9210)\",\n    \"#class\"   : bilibili.BilibiliArticleExtractor,\n    \"#count\"   : 10,\n\n    \"count\"    : 5,\n    \"extension\": {\"jpg\", \"mp4\"},\n    \"id\"       : \"1172711958019833880\",\n    \"live_url\" : {None, str},\n    \"suffix\"   : {\"\", \"l\"},\n    \"user_id\"  : 3546898287823414,\n    \"username\" : \"锦鲤的重度依赖\",\n},\n\n{\n    \"#url\"    : \"https://space.bilibili.com/405279279/article\",\n    \"#class\"  : bilibili.BilibiliUserArticlesExtractor,\n    \"#pattern\": bilibili.BilibiliArticleExtractor.pattern,\n    \"#count\"  : range(50, 100),\n},\n\n{\n    \"#url\"    : \"https://space.bilibili.com/405279279/upload/opus\",\n    \"#class\"  : bilibili.BilibiliUserArticlesExtractor,\n},\n\n{\n    \"#url\"    : \"https://space.bilibili.com/405279279/dynamic\",\n    \"#class\"  : bilibili.BilibiliUserArticlesExtractor,\n},\n\n{\n    \"#url\"    : \"https://space.bilibili.com/405279279/favlist?fid=opus\",\n    \"#class\"  : bilibili.BilibiliUserArticlesFavoriteExtractor,\n    \"#auth\"   : True,\n},\n\n)\n"
  },
  {
    "path": "test/results/bitly.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import urlshortener\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://bit.ly/3cWIUgq\",\n    \"#category\": (\"urlshortener\", \"bitly\", \"link\"),\n    \"#class\"   : urlshortener.UrlshortenerLinkExtractor,\n    \"#pattern\" : \"^https://gumroad.com/l/storm_b1\",\n    \"#count\"   : 1,\n},\n\n)\n"
  },
  {
    "path": "test/results/blacktowhite.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import xenforo\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://www.blacktowhite.net/threads/rules-for-posting-photos-gifs-and-videos.337383/post-6545937\",\n    \"#category\": (\"xenforo\", \"blacktowhite\", \"post\"),\n    \"#class\"   : xenforo.XenforoPostExtractor,\n    \"#results\" : \"https://www.blacktowhite.net/proxy.php?image=https%3A%2F%2Fwww.dailycartoonist.com%2Fwp-content%2Fuploads%2F2020%2F01%2Fdemdonk-jacksonface.jpg&amp;hash=ee3cee17bf3f9b2dcdd41dbe7c1ea35e\",\n\n    \"count\"       : 1,\n    \"extension\"   : \"jpg\",\n    \"filename\"    : \"demdonk-jacksonface\",\n    \"post\"        : {\n        \"author\"     : \"Truthman\",\n        \"author_id\"  : \"40508\",\n        \"author_slug\": \"truthman\",\n        \"date\"       : \"dt:2025-08-29 20:45:50\",\n        \"id\"         : \"6545937\",\n    },\n},\n\n{\n    \"#url\"     : \"https://www.blacktowhite.net/threads/rules-for-posting-photos-gifs-and-videos.337383/\",\n    \"#category\": (\"xenforo\", \"blacktowhite\", \"thread\"),\n    \"#class\"   : xenforo.XenforoThreadExtractor,\n    \"#results\" : (\n        \"https://twitter.com/x/status/1986133525955743868\",\n        \"https://www.blacktowhite.net/attachments/bbc-worship-very-attractive-woman-jpg.8426431/\",\n        \"https://www.blacktowhite.net/proxy.php?image=https%3A%2F%2Fwww.dailycartoonist.com%2Fwp-content%2Fuploads%2F2020%2F01%2Fdemdonk-jacksonface.jpg&amp;hash=ee3cee17bf3f9b2dcdd41dbe7c1ea35e\",\n    ),\n\n    \"thread\"      : {\n        \"author\"     : \"MacNfries\",\n        \"author_id\"  : \"1129\",\n        \"author_slug\": \"macnfries\",\n        \"author_url\" : \"https://www.blacktowhite.net/members/macnfries.1129/\",\n        \"date\"       : \"dt:2025-07-13 04:15:28\",\n        \"id\"         : \"337383\",\n        \"section\"    : \"Off-Topic Discussion\",\n        \"tags\"       : (),\n        \"title\"      : \"RULES For POSTING PHOTOS, GIFS, and VIDEOS\",\n        \"url\"        : \"https://www.blacktowhite.net/threads/rules-for-posting-photos-gifs-and-videos.337383/\",\n    },\n},\n\n{\n    \"#url\"     : \"https://www.blacktowhite.net/media/baby-goddess-energy.845601\",\n    \"#comment\" : \"video\",\n    \"#category\": (\"xenforo\", \"blacktowhite\", \"media-item\"),\n    \"#class\"   : xenforo.XenforoMediaItemExtractor,\n    \"#auth\"    : False,\n    \"#results\" : \"https://www.blacktowhite.net/media/baby-goddess-energy.845601/full\",\n},\n\n{\n    \"#url\"     : \"https://www.blacktowhite.net/media/baby-goddess-energy.845601\",\n    \"#comment\" : \"video\",\n    \"#category\": (\"xenforo\", \"blacktowhite\", \"media-item\"),\n    \"#class\"   : xenforo.XenforoMediaItemExtractor,\n    \"#auth\"    : False,\n    \"#options\" : {\"metadata\": True},\n    \"#results\" : \"https://www.blacktowhite.net/data/xfmg/video/8173/8173378-a16bd8e0c10523da2f99e8a9af17c03a.mov\",\n},\n\n{\n    \"#url\"     : \"https://www.blacktowhite.net/media/img_5727-jpeg.840519\",\n    \"#comment\" : \"image\",\n    \"#category\": (\"xenforo\", \"blacktowhite\", \"media-item\"),\n    \"#class\"   : xenforo.XenforoMediaItemExtractor,\n    \"#results\" : \"https://www.blacktowhite.net/media/img_5727-jpeg.840519/full\",\n    \"#sha1_content\": \"d8cfca63c71bc7330fdfa7d17d6247392cbb4472\",\n},\n\n{\n    \"#url\"     : \"https://www.blacktowhite.net/media/albums/my-slutty-hotwife-gf-on-holidays-without-hubby-more-of-7000kms-december-2k25.47700\",\n    \"#category\": (\"xenforo\", \"blacktowhite\", \"media-album\"),\n    \"#class\"   : xenforo.XenforoMediaAlbumExtractor,\n    \"#auth\"    : False,\n    \"#results\" : (\n        \"https://www.blacktowhite.net/media/on-the-cave.947141/full\",\n        \"https://www.blacktowhite.net/media/she-likes-to-blow-black-cocks.947140/full\",\n        \"https://www.blacktowhite.net/media/teasing-kings-around.947139/full\",\n        \"https://www.blacktowhite.net/media/waiting-a-caribbean-black-cock.947138/full\",\n    ),\n    \"#log\": \"username & password or authenticated cookies needed\",\n\n    \"album\"    : {\n        \"author\"     : \"westindiandick\",\n        \"author_id\"  : \"128528\",\n        \"author_slug\": \"westindiandick\",\n        \"author_url\" : \"https://www.blacktowhite.net/members/westindiandick.128528/\",\n        \"count\"      : 5,\n        \"date\"       : \"dt:2026-03-06 05:17:08\",\n        \"description\": \"\",\n        \"id\"         : \"47700\",\n        \"slug\"       : \"my-slutty-hotwife-gf-on-holidays-without-hubby-more-of-7000kms-december-2k25\",\n        \"title\"      : \"My Slutty Hotwife Gf on holidays without hubby...More of 7000Kms... December 2K25\",\n        \"url\"        : \"https://www.blacktowhite.net/media/albums/my-slutty-hotwife-gf-on-holidays-without-hubby-more-of-7000kms-december-2k25.47700/\",\n    },\n},\n\n{\n    \"#url\"     : \"https://www.blacktowhite.net/media/users/elodies_secret.1115361/\",\n    \"#category\": (\"xenforo\", \"blacktowhite\", \"media-user\"),\n    \"#class\"   : xenforo.XenforoMediaUserExtractor,\n    \"#archive\" : False,\n    \"#count\"   : 14,\n\n    \"author_id\"  : \"1115361\",\n    \"author_slug\": \"elodies_secret\",\n},\n\n)\n"
  },
  {
    "path": "test/results/blogger.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import blogger\n\n\n__tests__ = (\n{\n    \"#url\"     : \"blogger:http://www.julianbunker.com/2010/12/moon-rise.html\",\n    \"#category\": (\"blogger\", \"www.julianbunker.com\", \"post\"),\n    \"#class\"   : blogger.BloggerPostExtractor,\n},\n\n{\n    \"#url\"     : \"blogger:https://www.kefblog.com.ng/\",\n    \"#category\": (\"blogger\", \"www.kefblog.com.ng\", \"blog\"),\n    \"#class\"   : blogger.BloggerBlogExtractor,\n    \"#range\"   : \"1-25\",\n    \"#count\"   : 25,\n},\n\n{\n    \"#url\"     : \"blogger:http://www.julianbunker.com/search?q=400mm\",\n    \"#category\": (\"blogger\", \"www.julianbunker.com\", \"search\"),\n    \"#class\"   : blogger.BloggerSearchExtractor,\n},\n\n{\n    \"#url\"     : \"blogger:http://www.julianbunker.com/search/label/D%26D\",\n    \"#category\": (\"blogger\", \"www.julianbunker.com\", \"label\"),\n    \"#class\"   : blogger.BloggerLabelExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/blogspot.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import blogger\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://julianbphotography.blogspot.com/2010/12/moon-rise.html\",\n    \"#category\": (\"blogger\", \"blogspot\", \"post\"),\n    \"#class\"   : blogger.BloggerPostExtractor,\n    \"#results\" : \"https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEjH9WkPvLJq2moxKtyt3ieJZWSDFQwOi3PHRdlHVHEQHRwy-d86Jg6HWSMhxaa6EgvlXq-zDMmKM4kIPn27eJ9Hepk2X9e9HQhqwMfrT8RYTnFe65uexw7KSk5FdWHxRVp5crz3p_qph3Bj/s0/Icy-Moonrise---For-Web.jpg\",\n\n    \"blog\": {\n        \"date\"       : \"dt:2010-11-21 18:19:42\",\n        \"description\": \"\",\n        \"id\"         : \"5623928067739466034\",\n        \"kind\"       : \"blogger#blog\",\n        \"locale\"     : dict,\n        \"name\"       : \"Julian Bunker Photography\",\n        \"pages\"      : int,\n        \"posts\"      : int,\n        \"published\"  : \"2010-11-21T10:19:42-08:00\",\n        \"updated\"    : str,\n        \"url\"        : \"http://julianbphotography.blogspot.com/\",\n    },\n    \"post\": {\n        \"author\"   : \"Julian Bunker\",\n        \"content\"  : str,\n        \"date\"     : \"dt:2010-12-26 01:08:00\",\n        \"etag\"     : str,\n        \"id\"       : \"6955139236418998998\",\n        \"kind\"     : \"blogger#post\",\n        \"published\": \"2010-12-25T17:08:00-08:00\",\n        \"replies\"  : \"0\",\n        \"title\"    : \"Moon Rise\",\n        \"updated\"  : \"2011-12-06T05:21:24-08:00\",\n        \"url\"      : \"http://julianbphotography.blogspot.com/2010/12/moon-rise.html\",\n    },\n    \"extension\": \"jpg\",\n    \"filename\" : \"Icy-Moonrise---For-Web\",\n    \"num\"      : 1,\n    \"url\"      : \"https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEjH9WkPvLJq2moxKtyt3ieJZWSDFQwOi3PHRdlHVHEQHRwy-d86Jg6HWSMhxaa6EgvlXq-zDMmKM4kIPn27eJ9Hepk2X9e9HQhqwMfrT8RYTnFe65uexw7KSk5FdWHxRVp5crz3p_qph3Bj/s0/Icy-Moonrise---For-Web.jpg\",\n},\n\n{\n    \"#url\"     : \"https://hotgrannysomas.blogspot.com/2012/08/para-amantes-del-buen-sexo-anal-los.html\",\n    \"#comment\" : \"video\",\n    \"#category\": (\"blogger\", \"blogspot\", \"post\"),\n    \"#class\"   : blogger.BloggerPostExtractor,\n    \"#pattern\" : r\"https://.+\\.googlevideo\\.com/videoplayback\",\n},\n\n{\n    \"#url\"     : \"https://randomthingsthroughmyletterbox.blogspot.com/2022/01/bitter-flowers-by-gunnar-staalesen-blog.html\",\n    \"#comment\" : \"new image domain (#2204)\",\n    \"#category\": (\"blogger\", \"blogspot\", \"post\"),\n    \"#class\"   : blogger.BloggerPostExtractor,\n    \"#pattern\" : r\"https://blogger\\.googleusercontent\\.com/img/.+=s0$\",\n    \"#count\"   : 8,\n},\n\n{\n    \"#url\"     : \"https://julianbphotography.blogspot.com/\",\n    \"#category\": (\"blogger\", \"blogspot\", \"blog\"),\n    \"#class\"   : blogger.BloggerBlogExtractor,\n    \"#pattern\" : r\"https://blogger\\.googleusercontent\\.com/img/.+/s0/\",\n    \"#range\"   : \"1-25\",\n    \"#count\"   : 25,\n},\n\n{\n    \"#url\"     : \"https://julianbphotography.blogspot.com/search?q=400mm\",\n    \"#category\": (\"blogger\", \"blogspot\", \"search\"),\n    \"#class\"   : blogger.BloggerSearchExtractor,\n    \"#count\"   : \"< 10\",\n\n    \"query\": \"400mm\",\n},\n\n{\n    \"#url\"     : \"https://dmmagazine.blogspot.com/search/label/D%26D\",\n    \"#category\": (\"blogger\", \"blogspot\", \"label\"),\n    \"#class\"   : blogger.BloggerLabelExtractor,\n    \"#range\"   : \"1-25\",\n    \"#count\"   : 25,\n\n    \"label\": \"D&D\",\n},\n\n)\n"
  },
  {
    "path": "test/results/bluesky.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import bluesky\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://bsky.app/profile/bsky.app\",\n    \"#category\": (\"\", \"bluesky\", \"user\"),\n    \"#class\"   : bluesky.BlueskyUserExtractor,\n    \"#results\" : (\n        \"https://bsky.app/profile/bsky.app/media\",\n    ),\n},\n\n{\n    \"#url\"     : \"https://www.bsky.app/profile/bsky.app\",\n    \"#class\"   : bluesky.BlueskyUserExtractor,\n},\n\n{\n    \"#url\"     : \"https://main.bsky.dev/profile/bsky.app\",\n    \"#class\"   : bluesky.BlueskyUserExtractor,\n},\n\n{\n    \"#url\"     : \"https://bsky.app/profile/did:plc:z72i7hdynmk6r22z27h6tvur\",\n    \"#category\": (\"\", \"bluesky\", \"user\"),\n    \"#class\"   : bluesky.BlueskyUserExtractor,\n    \"#options\" : {\"include\": \"all\"},\n    \"#results\" : (\n        \"https://bsky.app/profile/did:plc:z72i7hdynmk6r22z27h6tvur/info\",\n        \"https://bsky.app/profile/did:plc:z72i7hdynmk6r22z27h6tvur/avatar\",\n        \"https://bsky.app/profile/did:plc:z72i7hdynmk6r22z27h6tvur/banner\",\n        \"https://bsky.app/profile/did:plc:z72i7hdynmk6r22z27h6tvur/posts\",\n        \"https://bsky.app/profile/did:plc:z72i7hdynmk6r22z27h6tvur/replies\",\n        \"https://bsky.app/profile/did:plc:z72i7hdynmk6r22z27h6tvur/media\",\n        \"https://bsky.app/profile/did:plc:z72i7hdynmk6r22z27h6tvur/video\",\n        \"https://bsky.app/profile/did:plc:z72i7hdynmk6r22z27h6tvur/likes\",\n    ),\n},\n\n{\n    \"#url\"     : \"https://bsky.app/profile/bsky.app\",\n    \"#class\"   : bluesky.BlueskyUserExtractor,\n    \"#options\" : {\"quoted\": True},\n    \"#results\" : \"https://bsky.app/profile/bsky.app/posts\",\n},\n\n{\n    \"#url\"     : \"https://bsky.app/profile/bsky.app/info\",\n    \"#class\"   : bluesky.BlueskyInfoExtractor,\n},\n\n{\n    \"#url\"     : \"https://bsky.app/profile/bsky.app/avatar\",\n    \"#category\": (\"\", \"bluesky\", \"avatar\"),\n    \"#class\"   : bluesky.BlueskyAvatarExtractor,\n    \"#results\" : \"https://puffball.us-east.host.bsky.network/xrpc/com.atproto.sync.getBlob?did=did:plc:z72i7hdynmk6r22z27h6tvur&cid=bafkreihagr2cmvl2jt4mgx3sppwe2it3fwolkrbtjrhcnwjk4jdijhsoze\",\n},\n\n{\n    \"#url\"     : \"https://bsky.app/profile/did:plc:z72i7hdynmk6r22z27h6tvur/banner\",\n    \"#category\": (\"\", \"bluesky\", \"background\"),\n    \"#class\"   : bluesky.BlueskyBackgroundExtractor,\n    \"#results\" : \"https://puffball.us-east.host.bsky.network/xrpc/com.atproto.sync.getBlob?did=did:plc:z72i7hdynmk6r22z27h6tvur&cid=bafkreichzyovokfzmymz36p5jibbjrhsur6n7hjnzxrpbt5jaydp2szvna\",\n},\n\n{\n    \"#url\"     : \"https://bsky.app/profile/bsky.app/posts\",\n    \"#category\": (\"\", \"bluesky\", \"posts\"),\n    \"#class\"   : bluesky.BlueskyPostsExtractor,\n    \"#range\"   : \"1-40\",\n    \"#count\"   : 40,\n},\n\n{\n    \"#url\"     : \"https://bsky.app/profile/bsky.app/replies\",\n    \"#category\": (\"\", \"bluesky\", \"replies\"),\n    \"#class\"   : bluesky.BlueskyRepliesExtractor,\n    \"#range\"   : \"1-40\",\n    \"#count\"   : 40,\n},\n\n{\n    \"#url\"     : \"https://bsky.app/profile/bsky.app/media\",\n    \"#category\": (\"\", \"bluesky\", \"media\"),\n    \"#class\"   : bluesky.BlueskyMediaExtractor,\n    \"#range\"   : \"1-40\",\n    \"#count\"   : 40,\n},\n\n{\n    \"#url\"     : \"https://bsky.app/profile/mikf.bsky.social/video\",\n    \"#category\": (\"\", \"bluesky\", \"video\"),\n    \"#class\"   : bluesky.BlueskyVideoExtractor,\n    \"#results\" : (\n        \"https://conocybe.us-west.host.bsky.network/xrpc/com.atproto.sync.getBlob?did=did:plc:cslxjqkeexku6elp5xowxkq7&cid=bafkreibmoobktxndnzauku65onoxu2tvvqswetezv76tqcwipktjs3cw3m\",\n        \"https://conocybe.us-west.host.bsky.network/xrpc/com.atproto.sync.getBlob?did=did:plc:cslxjqkeexku6elp5xowxkq7&cid=bafkreihq2nsfocrnlpx4nykb4szouqszxwmy3ucnk4k46nx5t6hjnxlti4\",\n    ),\n},\n\n{\n    \"#url\"     : \"https://bsky.app/profile/did:plc:jfhpnnst6flqway4eaeqzj2a/feed/for-science\",\n    \"#category\": (\"\", \"bluesky\", \"feed\"),\n    \"#class\"   : bluesky.BlueskyFeedExtractor,\n    \"#range\"   : \"1-40\",\n    \"#count\"   : 40,\n    \"#archive\" : False,\n},\n\n{\n    \"#url\"     : \"https://bsky.app/profile/bsky.app/follows\",\n    \"#category\": (\"\", \"bluesky\", \"following\"),\n    \"#class\"   : bluesky.BlueskyFollowingExtractor,\n    \"#results\" : (\n        \"https://bsky.app/profile/did:plc:eon2iu7v3x2ukgxkqaf7e5np\",\n        \"https://bsky.app/profile/did:plc:ewvi7nxzyoun6zhxrhs64oiz\",\n    ),\n},\n\n{\n    \"#url\"     : \"https://bsky.app/profile/bsky.app/likes\",\n    \"#category\": (\"\", \"bluesky\", \"likes\"),\n    \"#class\"   : bluesky.BlueskyLikesExtractor,\n    \"#auth\"    : False,\n    \"#range\"   : \"1-5\",\n    \"#count\"   : 5,\n},\n\n{\n    \"#url\"     : \"https://bsky.app/profile/mikf.bsky.social/likes\",\n    \"#class\"   : bluesky.BlueskyLikesExtractor,\n    \"#auth\"    : False,\n    \"#results\" : \"https://conocybe.us-west.host.bsky.network/xrpc/com.atproto.sync.getBlob?did=did:plc:cslxjqkeexku6elp5xowxkq7&cid=bafkreih2dn2xeyoayabgvpyutv5ldubcdxzfqipijasfzxyeez7fff5ymi\",\n},\n\n{\n    \"#url\"     : \"https://bsky.app/profile/mikf.bsky.social/likes\",\n    \"#class\"   : bluesky.BlueskyLikesExtractor,\n    \"#options\" : {\"endpoint\": \"getActorLikes\"},\n    \"#auth\"    : True,\n    \"#results\" : \"https://conocybe.us-west.host.bsky.network/xrpc/com.atproto.sync.getBlob?did=did:plc:cslxjqkeexku6elp5xowxkq7&cid=bafkreih2dn2xeyoayabgvpyutv5ldubcdxzfqipijasfzxyeez7fff5ymi\",\n},\n\n{\n    \"#url\"     : \"https://bsky.app/profile/mikf.bsky.social/likes\",\n    \"#class\"   : bluesky.BlueskyLikesExtractor,\n    \"#options\" : {\"endpoint\": \"getActorLikes\"},\n    \"#auth\"    : False,\n    \"#count\"   : 0,\n},\n\n{\n    \"#url\"     : \"https://bsky.app/profile/bsky.app/lists/abcdefghijklm\",\n    \"#category\": (\"\", \"bluesky\", \"list\"),\n    \"#class\"   : bluesky.BlueskyListExtractor,\n},\n\n{\n    \"#url\"     : \"https://bsky.app/search?q=nature\",\n    \"#category\": (\"\", \"bluesky\", \"search\"),\n    \"#class\"   : bluesky.BlueskySearchExtractor,\n    \"#range\"   : \"1-40\",\n    \"#count\"   : 40,\n    \"#archive\" : False,\n},\n\n{\n    \"#url\"     : \"https://bsky.app/hashtag/nature\",\n    \"#class\"   : bluesky.BlueskyHashtagExtractor,\n    \"#range\"   : \"1-40\",\n    \"#count\"   : 40,\n    \"#archive\" : False,\n},\n{\n    \"#url\"     : \"https://bsky.app/hashtag/top\",\n    \"#class\"   : bluesky.BlueskyHashtagExtractor,\n},\n{\n    \"#url\"     : \"https://bsky.app/hashtag/nature/latest\",\n    \"#class\"   : bluesky.BlueskyHashtagExtractor,\n},\n\n{\n    \"#url\"     : \"https://bsky.app/profile/bsky.app/post/3kh5rarr3gn2n\",\n    \"#category\": (\"\", \"bluesky\", \"post\"),\n    \"#class\"   : bluesky.BlueskyPostExtractor,\n    \"#options\"     : {\"metadata\": True},\n    \"#results\"     : \"https://puffball.us-east.host.bsky.network/xrpc/com.atproto.sync.getBlob?did=did:plc:z72i7hdynmk6r22z27h6tvur&cid=bafkreidypzoaybmfj5h7pnpiyct6ng5yae6ydp4czrm72ocg7ev6vbirri\",\n    \"#sha1_content\": \"ffcf25e7c511173a12de5276b85903309fcd8d14\",\n\n    \"author\": {\n        \"avatar\"     : \"https://cdn.bsky.app/img/avatar/plain/did:plc:z72i7hdynmk6r22z27h6tvur/bafkreihagr2cmvl2jt4mgx3sppwe2it3fwolkrbtjrhcnwjk4jdijhsoze@jpeg\",\n        \"did\"        : \"did:plc:z72i7hdynmk6r22z27h6tvur\",\n        \"displayName\": \"Bluesky\",\n        \"handle\"     : \"bsky.app\",\n        \"instance\"   : \"bsky.app\",\n        \"labels\"     : [],\n    },\n    \"cid\"        : \"bafyreihh7m6bfrwlcjfklwturmja7qfse5gte7lskpmgw76flivimbnoqm\",\n    \"count\"      : 1,\n    \"createdAt\"  : \"2023-12-22T18:58:32.715Z\",\n    \"date\"       : \"dt:2023-12-22 18:58:32\",\n    \"description\": \"The bluesky logo with the blue butterfly\",\n    \"extension\"  : \"jpeg\",\n    \"filename\"   : \"bafkreidypzoaybmfj5h7pnpiyct6ng5yae6ydp4czrm72ocg7ev6vbirri\",\n    \"height\"     : 630,\n    \"indexedAt\"  : \"2023-12-22T18:58:32.715Z\",\n    \"instance\"   : \"bsky.app\",\n    \"labels\"     : [],\n    \"likeCount\"  : int,\n    \"num\"        : 1,\n    \"post_id\"    : \"3kh5rarr3gn2n\",\n    \"replyCount\" : int,\n    \"repostCount\": int,\n    \"uri\"        : \"at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.post/3kh5rarr3gn2n\",\n    \"width\"      : 1200,\n    \"hashtags\"   : [],\n    \"mentions\"   : [],\n    \"uris\"       : [\"https://blueskyweb.xyz/blog/12-21-2023-butterfly\"],\n    \"user\"       : {\n        \"avatar\"        : str,\n        \"banner\"        : str,\n        \"description\"   : str,\n        \"did\"           : \"did:plc:z72i7hdynmk6r22z27h6tvur\",\n        \"displayName\"   : \"Bluesky\",\n        \"followersCount\": int,\n        \"followsCount\"  : int,\n        \"handle\"        : \"bsky.app\",\n        \"instance\"      : \"bsky.app\",\n        \"indexedAt\"     : str,\n        \"labels\"        : [],\n        \"postsCount\"    : int,\n    },\n},\n\n{\n    \"#url\"     : \"https://bsky.app/profile/mikf.bsky.social/post/3kkzc3xaf5m2w\",\n    \"#category\": (\"\", \"bluesky\", \"post\"),\n    \"#class\"   : bluesky.BlueskyPostExtractor,\n    \"#options\"     : {\"metadata\": \"facets\"},\n    \"#results\"     : \"https://conocybe.us-west.host.bsky.network/xrpc/com.atproto.sync.getBlob?did=did:plc:cslxjqkeexku6elp5xowxkq7&cid=bafkreib7ydpe3xxo4cq7nn32w7eqhcanfaanz6caepd2z4kzplxtx2ctgi\",\n    \"#sha1_content\": \"9cf5748f6d00aae83fbb3cc2c6eb3caa832b90f4\",\n\n    \"author\": {\n        \"did\"        : \"did:plc:cslxjqkeexku6elp5xowxkq7\",\n        \"displayName\": \"mikf\",\n        \"handle\"     : \"mikf.bsky.social\",\n        \"instance\"   : \"bsky.social\",\n        \"labels\"     : [],\n    },\n    \"cid\"        : \"bafyreihtck7clocti2qshaiounadof74pxqhz7gnvbstxujqzhlodigqru\",\n    \"count\"      : 1,\n    \"createdAt\"  : \"2024-02-09T21:57:31.917Z\",\n    \"date\"       : \"dt:2024-02-09 21:57:31\",\n    \"description\": \"reading lewd books\",\n    \"extension\"  : \"jpeg\",\n    \"filename\"   : \"bafkreib7ydpe3xxo4cq7nn32w7eqhcanfaanz6caepd2z4kzplxtx2ctgi\",\n    \"hashtags\"   : [\n        \"patchouli\",\n        \"patchy\",\n    ],\n    \"mentions\"   : [\n        \"did:plc:cslxjqkeexku6elp5xowxkq7\",\n    ],\n    \"uris\"       : [\n        \"https://seiga.nicovideo.jp/seiga/im5977527\",\n    ],\n    \"width\"      : 1024,\n    \"height\"     : 768,\n    \"langs\"      : [\"en\"],\n    \"likeCount\"  : int,\n    \"num\"        : 1,\n    \"post_id\"    : \"3kkzc3xaf5m2w\",\n    \"replyCount\" : int,\n    \"repostCount\": int,\n    \"text\"       : \"testing \\\"facets\\\"\\n\\nsource: seiga.nicovideo.jp/seiga/im5977...\\n#patchouli #patchy\\n@mikf.bsky.social\",\n    \"uri\"        : \"at://did:plc:cslxjqkeexku6elp5xowxkq7/app.bsky.feed.post/3kkzc3xaf5m2w\",\n},\n\n{\n    \"#url\"     : \"https://bsky.app/profile/go-guiltism.bsky.social/post/3klgth6lilt2l\",\n    \"#comment\" : \"different embed CID path\",\n    \"#category\": (\"\", \"bluesky\", \"post\"),\n    \"#class\"   : bluesky.BlueskyPostExtractor,\n    \"#results\" : \"https://amanita.us-east.host.bsky.network/xrpc/com.atproto.sync.getBlob?did=did:plc:owc2r2dsewj3hk73rtd746zh&cid=bafkreieuhplc7fpbvi3suvacaf2dqxzvuu4hgl5o6eifqb76tf3uopldmi\",\n},\n\n{\n    \"#url\"     : \"https://bsky.app/profile/mikf.bsky.social/post/3l46q5glfex27\",\n    \"#comment\" : \"video (#6183)\",\n    \"#category\": (\"\", \"bluesky\", \"post\"),\n    \"#class\"   : bluesky.BlueskyPostExtractor,\n    \"#results\" : \"https://conocybe.us-west.host.bsky.network/xrpc/com.atproto.sync.getBlob?did=did:plc:cslxjqkeexku6elp5xowxkq7&cid=bafkreihq2nsfocrnlpx4nykb4szouqszxwmy3ucnk4k46nx5t6hjnxlti4\",\n\n    \"description\": \"kirby and reimu dance\",\n    \"text\"       : \"video\",\n    \"width\"      : 1280,\n    \"height\"     : 720,\n    \"filename\"   : \"bafkreihq2nsfocrnlpx4nykb4szouqszxwmy3ucnk4k46nx5t6hjnxlti4\",\n    \"extension\"  : \"mp4\",\n},\n\n{\n    \"#url\"     : \"https://bsky.app/profile/mikf.bsky.social/post/3kmfodjotln2f\",\n    \"#comment\" : \"quote (#6183)\",\n    \"#class\"   : bluesky.BlueskyPostExtractor,\n    \"#options\" : {\"quoted\": True},\n    \"#results\" : \"https://lionsmane.us-east.host.bsky.network/xrpc/com.atproto.sync.getBlob?did=did:plc:eyhmjdxsnthqhvvszdejaocz&cid=bafkreib6eb7tfozksquveaj3z5msyx3hkniubrulxdys3eftthvmuzrtme\",\n\n    \"author\": {\n        \"associated\" : dict,\n        \"avatar\"     : \"https://cdn.bsky.app/img/avatar/plain/did:plc:eyhmjdxsnthqhvvszdejaocz/bafkreigjrftlw7tabtpie32saydttpnoi7276v252vnycr6zt6euef7vdi@jpeg\",\n        \"createdAt\"  : \"2024-01-11T00:27:37.404Z\",\n        \"did\"        : \"did:plc:eyhmjdxsnthqhvvszdejaocz\",\n        \"displayName\": \"フナ\",\n        \"handle\"     : \"ykfuna.bsky.social\",\n        \"labels\"     : list,\n    },\n    \"quote_by\": {\n        \"avatar\"     : \"https://cdn.bsky.app/img/avatar/plain/did:plc:cslxjqkeexku6elp5xowxkq7/bafkreic5jqkn5ohqhgsm6zzi7vnapuz54trojv3io4tfkrcyaprl4b2ztm@jpeg\",\n        \"createdAt\"  : \"2024-02-05T00:03:54.087Z\",\n        \"did\"        : \"did:plc:cslxjqkeexku6elp5xowxkq7\",\n        \"displayName\": \"mikf\",\n        \"handle\"     : \"mikf.bsky.social\",\n        \"labels\"     : list,\n    },\n    \"quote_id\": \"3kmfodjotln2f\",\n    \"post_id\" : \"3km4qy5y3jc2z\",\n},\n\n{\n    \"#url\"     : \"https://bsky.app/profile/mikf.bsky.social/post/3kmfp2qktil25\",\n    \"#comment\" : \"quote with media (#6183)\",\n    \"#class\"   : bluesky.BlueskyPostExtractor,\n    \"#options\" : {\"quoted\": True},\n    \"#results\" : (\n        \"https://conocybe.us-west.host.bsky.network/xrpc/com.atproto.sync.getBlob?did=did:plc:cslxjqkeexku6elp5xowxkq7&cid=bafkreiegcyremdrecmnpisci3a3nduc7lm3zdcl76z5o5rd4nstyolrxki\",\n        \"https://lionsmane.us-east.host.bsky.network/xrpc/com.atproto.sync.getBlob?did=did:plc:eyhmjdxsnthqhvvszdejaocz&cid=bafkreicojrnwiw5eqo3ko2q6duduyjaoyiqvdc25kuikcedlijtbgvlt5e\",\n\n    ),\n\n    \"text\"     : {\"quote with media\", \"\"},\n},\n\n{\n    \"#url\"     : \"https://bsky.app/profile/nytimes.com/post/3l7xvcjgdxg2g\",\n    \"#comment\" : \"instance metadata\",\n    \"#class\"   : bluesky.BlueskyPostExtractor,\n    \"#options\" : {\"metadata\": \"user\"},\n\n    \"instance\": \"bsky.app\",\n    \"author\": {\n        \"createdAt\"  : \"2023-06-05T18:50:31.498Z\",\n        \"did\"        : \"did:plc:eclio37ymobqex2ncko63h4r\",\n        \"displayName\": \"The New York Times\",\n        \"handle\"     : \"nytimes.com\",\n        \"instance\"   : \"nytimes.com\",\n    },\n    \"user\": {\n        \"avatar\"        : \"https://cdn.bsky.app/img/avatar/plain/did:plc:eclio37ymobqex2ncko63h4r/bafkreidvvqj5jymmpaeklwkpq6gi532el447mjy2yultuukypzqm5ohfju@jpeg\",\n        \"banner\"        : \"https://cdn.bsky.app/img/banner/plain/did:plc:eclio37ymobqex2ncko63h4r/bafkreidlzzmt7sy2n6imz5mg7siygb3cy4e526nvbjucczeu5cutqro5ni@jpeg\",\n        \"createdAt\"     : \"2023-06-05T18:50:31.498Z\",\n        \"description\"   : \"In-depth, independent reporting to better understand the world, now on Bluesky. News tips? Share them here: http://nyti.ms/2FVHq9v\",\n        \"did\"           : \"did:plc:eclio37ymobqex2ncko63h4r\",\n        \"displayName\"   : \"The New York Times\",\n        \"followersCount\": int,\n        \"followsCount\"  : int,\n        \"handle\"        : \"nytimes.com\",\n        \"instance\"      : \"nytimes.com\",\n        \"indexedAt\"     : \"iso:datetime\",\n        \"labels\"        : [],\n        \"postsCount\"    : int,\n    },\n},\n\n{\n    \"#url\"     : \"https://bsky.app/profile/stupidsaru.woke.cat/post/3l66wwwqw6u2w\",\n    \"#comment\" : \"instance metadata\",\n    \"#class\"   : bluesky.BlueskyPostExtractor,\n\n    \"author\": {\n        \"createdAt\": \"2023-08-31T23:28:42.305Z\",\n        \"did\"      : \"did:plc:b7s3pdcjk6qvxmu3n674hlgj\",\n        \"handle\"   : \"stupidsaru.woke.cat\",\n        \"instance\" : \"woke.cat\",\n    },\n},\n\n{\n    \"#url\"     : \"https://bsky.app/profile/alt.bun.how/post/3l7rdfxhyds2f\",\n    \"#comment\" : \"non-bsky PDS (#6406)\",\n    \"#class\"   : bluesky.BlueskyPostExtractor,\n    \"#results\"     : \"https://pds.bun.how/xrpc/com.atproto.sync.getBlob?did=did:plc:7x6rtuenkuvxq3zsvffp2ide&cid=bafkreielhgekjheckgjusx7x5hxkbrqryfdmzdwwp2zoxchovgnpzkxzae\",\n    \"#sha1_content\": \"1777956de0dc8cf0815c5c7eb574a24ce54a1d42\",\n\n    \"author\": {\n        \"createdAt\": \"2024-10-17T13:55:48.833Z\",\n        \"did\"      : \"did:plc:7x6rtuenkuvxq3zsvffp2ide\",\n        \"handle\"   : \"cinny.bun.how\",\n        \"instance\" : \"bun.how\",\n    },\n},\n\n{\n    \"#url\"     : \"https://cbsky.app/profile/bsky.app/post/3kh5rarr3gn2n\",\n    \"#category\": (\"\", \"bluesky\", \"post\"),\n    \"#class\"   : bluesky.BlueskyPostExtractor,\n},\n\n{\n    \"#url\"     : \"https://bskye.app/profile/bsky.app/post/3kh5rarr3gn2n\",\n    \"#category\": (\"\", \"bluesky\", \"post\"),\n    \"#class\"   : bluesky.BlueskyPostExtractor,\n},\n\n{\n    \"#url\"     : \"https://bskyx.app/profile/bsky.app/post/3kh5rarr3gn2n\",\n    \"#category\": (\"\", \"bluesky\", \"post\"),\n    \"#class\"   : bluesky.BlueskyPostExtractor,\n},\n\n{\n    \"#url\"     : \"https://bsyy.app/profile/bsky.app/post/3kh5rarr3gn2n\",\n    \"#category\": (\"\", \"bluesky\", \"post\"),\n    \"#class\"   : bluesky.BlueskyPostExtractor,\n},\n\n{\n    \"#url\"     : \"https://fxbsky.app/profile/bsky.app/post/3kh5rarr3gn2n\",\n    \"#category\": (\"\", \"bluesky\", \"post\"),\n    \"#class\"   : bluesky.BlueskyPostExtractor,\n},\n\n{\n    \"#url\"     : \"https://vxbsky.app/profile/bsky.app/post/3kh5rarr3gn2n\",\n    \"#category\": (\"\", \"bluesky\", \"post\"),\n    \"#class\"   : bluesky.BlueskyPostExtractor,\n},\n\n{\n    \"#url\"     : \"https://bsky.app/profile/jacksonlab.bsky.social/post/3m2ms33o6p52k\",\n    \"#comment\" : \"'external' embed - 'images': [], 'video': null\",\n    \"#class\"   : bluesky.BlueskyPostExtractor,\n    \"#count\"   : 0,\n},\n\n{\n    \"#url\"     : \"https://bsky.app/saved\",\n    \"#class\"   : bluesky.BlueskyBookmarkExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/booruvar.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import danbooru\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://booru.borvar.art/posts?tags=chibi&z=1\",\n    \"#category\": (\"Danbooru\", \"booruvar\", \"tag\"),\n    \"#class\"   : danbooru.DanbooruTagExtractor,\n    \"#pattern\" : r\"https://booru\\.borvar\\.art/data/original/[0-9a-f]{2}/[0-9a-f]{2}/[0-9a-f]{32}\\.\\w+\",\n    \"#count\"   : \">= 3\",\n},\n\n{\n    \"#url\"     : \"https://booru.borvar.art/pools/2\",\n    \"#category\": (\"Danbooru\", \"booruvar\", \"pool\"),\n    \"#class\"   : danbooru.DanbooruPoolExtractor,\n    \"#count\"   : 4,\n    \"#sha1_url\": \"77fa3559a3fc919f72611f4e3dd0f919d19d3e0d\",\n},\n\n{\n    \"#url\"     : \"https://booru.borvar.art/posts/1487\",\n    \"#category\": (\"Danbooru\", \"booruvar\", \"post\"),\n    \"#class\"   : danbooru.DanbooruPostExtractor,\n    \"#sha1_content\": \"91273ac1ea413a12be468841e2b5804656a50bff\",\n},\n\n{\n    \"#url\"     : \"https://booru.borvar.art/explore/posts/popular\",\n    \"#category\": (\"Danbooru\", \"booruvar\", \"popular\"),\n    \"#class\"   : danbooru.DanbooruPopularExtractor,\n},\n\n{\n    \"#url\"     : \"https://booru.borvar.art/posts/random?tags=chibi&z=1\",\n    \"#category\": (\"Danbooru\", \"booruvar\", \"random\"),\n    \"#class\"   : danbooru.DanbooruRandomExtractor,\n},\n\n{\n    \"#url\"     : \"https://booru.borvar.art/posts/random\",\n    \"#category\": (\"Danbooru\", \"booruvar\", \"random\"),\n    \"#class\"   : danbooru.DanbooruRandomExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/boosty.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import boosty\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://boosty.to/milshoo\",\n    \"#class\"   : boosty.BoostyUserExtractor,\n    \"#range\"   : \"1-40\",\n    \"#count\"   : 40,\n},\n\n{\n    \"#url\"     : \"https://boosty.to/milshoo?postsFrom=1706742000&postsTo=1709247599\",\n    \"#class\"   : boosty.BoostyUserExtractor,\n    \"#results\" : \"https://images.boosty.to/image/ff0d2006-3ee7-483d-a5fc-2a05b531742c?change_time=1707829201\",\n},\n\n{\n    \"#url\"     : \"https://boosty.to/milshoo/media/all\",\n    \"#class\"   : boosty.BoostyMediaExtractor,\n    \"#range\"   : \"1-40\",\n    \"#count\"   : 40,\n},\n\n{\n    \"#url\"     : \"https://boosty.to/milshoo/posts/4304d8f0-3f49-4f97-a3f3-9f064bc32b2f\",\n    \"#class\"   : boosty.BoostyPostExtractor,\n    \"#results\" : \"https://images.boosty.to/image/75f86086-fc67-4ed2-9365-2958d3d1a8f7?change_time=1711027786\",\n\n    \"count\"    : 1,\n    \"num\"      : 1,\n    \"extension\": \"\",\n    \"filename\" : \"75f86086-fc67-4ed2-9365-2958d3d1a8f7\",\n\n    \"_http_headers\": {\n        \"!Accept\": str,\n        \"Origin\" : \"https://www.boosty.to\",\n    },\n    \"file\": {\n        \"height\"   : 2048,\n        \"id\"       : \"75f86086-fc67-4ed2-9365-2958d3d1a8f7\",\n        \"rendition\": \"\",\n        \"size\"     : 1094903,\n        \"type\"     : \"image\",\n        \"url\"      : \"https://images.boosty.to/image/75f86086-fc67-4ed2-9365-2958d3d1a8f7?change_time=1711027786\",\n        \"width\"    : 2048,\n    },\n    \"user\": {\n        \"avatarUrl\": \"https://images.boosty.to/user/173542/avatar?change_time=1580888689\",\n        \"blogUrl\"  : \"milshoo\",\n        \"flags\"    : {\n            \"showPostDonations\": True,\n        },\n        \"hasAvatar\": True,\n        \"id\"       : 173542,\n        \"name\"     : \"Милшу\",\n    },\n    \"post\": {\n        \"comments\"   : dict,\n        \"content\"    : [\n            \"Привет! Это Милшу ) Я открываю комментарии в своём телеграм канале Милшу ( \",\n            \"https://t.me/milshoonya\",\n            \" ) и хочу, чтобы вы первые протестировали его работу :3\\nСсылку на вступление в чат оставлю здесь \",\n            \"https://t.me/+Z_5ph-XnIQU2YWMy\",\n            \"\\nТакже хотела напомнить, что мы собираем деньги на два арта от Ананаси: \",\n            \"https://boosty.to/milshoo/single-payment/donation/550562/target?share=target_link\",\n            \"\\nБуду очень благодарна за помощь :D  \",\n        ],\n        \"contentCounters\": list,\n        \"count\"      : dict,\n        \"createdAt\"  : 1711027834,\n        \"currencyPrices\": {\n            \"RUB\": 0,\n            \"USD\": 0,\n        },\n        \"date\"       : \"dt:2024-03-21 13:30:34\",\n        \"donations\"  : 0,\n        \"donators\"   : dict,\n        \"hasAccess\"  : True,\n        \"id\"         : \"4304d8f0-3f49-4f97-a3f3-9f064bc32b2f\",\n        \"int_id\"     : 5547124,\n        \"isBlocked\"  : False,\n        \"isCommentsDenied\": False,\n        \"isDeleted\"  : False,\n        \"isLiked\"    : False,\n        \"isPublished\": True,\n        \"isRecord\"   : False,\n        \"isWaitingVideo\": False,\n        \"links\"      : [\n            \"https://t.me/milshoonya\",\n            \"https://t.me/+Z_5ph-XnIQU2YWMy\",\n            \"https://boosty.to/milshoo/single-payment/donation/550562/target?share=target_link\",\n        ],\n        \"price\"      : 0,\n        \"publishTime\": 1711027834,\n        \"showViewsCounter\": False,\n        \"signedQuery\": \"\",\n        \"tags\"       : [],\n        \"teaser\"     : [],\n        \"title\"      : \"Открываю чат в телеге\",\n        \"updatedAt\"  : 1711027904\n    },\n},\n\n{\n    \"#url\"     : \"https://boosty.to/geekmedia/posts/31bb8fb6-83f1-404f-a597-f84bbe611d1d\",\n    \"#comment\" : \"video\",\n    \"#class\"   : boosty.BoostyPostExtractor,\n},\n\n{\n    \"#url\"     : \"https://boosty.to/xcang/posts/5d4d6f90-5d48-4442-a7e5-2164a858681d\",\n    \"#comment\" : \"audio\",\n    \"#class\"   : boosty.BoostyPostExtractor,\n},\n\n{\n    \"#url\"     : \"https://boosty.to/\",\n    \"#class\"   : boosty.BoostyFeedExtractor,\n    \"#auth\"    : True,\n    \"#range\"   : \"1-40\",\n    \"#count\"   : 40,\n},\n\n{\n    \"#url\"     : \"https://boosty.to/app/settings/subscriptions\",\n    \"#class\"   : boosty.BoostyFollowingExtractor,\n    \"#pattern\" : boosty.BoostyUserExtractor,\n    \"#auth\"    : True,\n},\n\n{\n    \"#url\"     : \"https://boosty.to/app/messages?dialogId=3598621\",\n    \"#class\"   : boosty.BoostyDirectMessagesExtractor,\n    \"#auth\"    : True,\n    \"#count\"   : 7,\n\n    \"count\"    : 1,\n    \"extension\": \"\",\n    \"file\"     : dict,\n    \"user\"     : dict,\n\n    \"post\": {\n        \"authorId\": int,\n        \"content\" : list,\n        \"date\"    : \"type:datetime\",\n        \"dialogId\": 3598621,\n        \"id\"      : int,\n    },\n},\n\n)\n"
  },
  {
    "path": "test/results/booth.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import booth\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://booth.pm/ja/items/4693741\",\n    \"#class\"   : booth.BoothItemExtractor,\n    \"#pattern\" : r\"https://booth.pximg.net/792d497b-6e82-4df3-86de-31577e10f476/i/4693741/[\\w-]{36}\\.(jpg|png)\",\n    \"#count\"   : 10,\n\n    \"!_fallback\"      : ...,\n    \"buyee_variations\": [],\n    \"count\"           : 10,\n    \"num\"             : range(1, 10),\n    \"date\"            : \"dt:2023-04-16 14:25:29\",\n    \"description\"     : \"\"\"※※英語版※※\n【踏切の音はもう聞こえない。の英訳ver.のダウンロード版になります。】\n【This is the downloadable version of the English translation ver.】\n\nGoto Hitori is 25 years old.\nShe loses all hope and tries to jump into a railroad crossing.\nBut at that moment, She is transported back in time to that era...?\n\nThe story is spun by the tag-team of manga and music,\nThe story of Bocchi-chan's inspiration and courage.\n\nThe set includes the manga book and a download card for the music (doujin music)!\nPlease enjoy the world of Bocchi ·the·rock! brought to you by \"Futari Bocchi no Solitude\".\n\nwano-Twitter\nhttps://twitter.com/wano49\n\nJapanese version\nhttps://www.melonbooks.co.jp/detail/detail.php?product_id=1872452\"\"\",\n    \"embeds\"          : [],\n    \"extension\"       : \"jpg\",\n    \"factory_description\": None,\n    \"filename\"        : str,\n    \"gift\"            : None,\n    \"id\"              : 4693741,\n    \"is_adult\"        : False,\n    \"is_buyee_possible\": False,\n    \"is_end_of_sale\"  : False,\n    \"is_placeholder\"  : False,\n    \"is_sold_out\"     : False,\n    \"name\"            : \"※英語版※ I can no longer hear the railway crossing.【Bocchi the rock!】\",\n    \"order\"           : None,\n    \"price\"           : \"¥ 700\",\n    \"published_at\"    : \"2023-04-16T23:25:29.000+09:00\",\n    \"purchase_limit\"  : None,\n    \"report_url\"      : \"https://wanoazayaka.booth.pm/items/4693741/report\",\n    \"shipping_info\"   : \"支払いから発送までの日数：4日以内\",\n    \"small_stock\"     : None,\n    \"sound\"           : None,\n    \"tracks\"          : None,\n    \"url\"             : str,\n    \"wish_list_url\"   : \"https://booth.pm/items/4693741/wish_list\",\n    \"wish_lists_count\": range(80, 120),\n    \"wished\"          : False,\n    \"tag_banners\"     : \"len:list:5\",\n    \"booth_category\"  : {\n        \"id\"    : 56,\n        \"name\"  : \"漫画・マンガ\",\n        \"url\"   : \"https://booth.pm/ja/browse/%E6%BC%AB%E7%94%BB%E3%83%BB%E3%83%9E%E3%83%B3%E3%82%AC\",\n        \"parent\": {\n            \"name\": \"漫画\",\n            \"url\" : \"https://booth.pm/ja/browse/%E6%BC%AB%E7%94%BB\",\n        },\n    },\n    \"share\"           : {\n        \"hashtags\": [\"booth_pm\"],\n        \"text\"    : \"※英語版※ I can no longer hear the railway crossing.【Bocchi the rock!】 | ふたりぼっちのSolitude\",\n    },\n    \"shop\"            : {\n        \"id\"           : 5742915,\n        \"uuid\"         : \"792d497b-6e82-4df3-86de-31577e10f476\",\n        \"name\"         : \"ふたりぼっちのSolitude\",\n        \"subdomain\"    : \"wanoazayaka\",\n        \"thumbnail_url\": \"https://booth.pximg.net/c/48x48/users/5742915/icon_image/1448e5d8-f93f-445e-8e1e-acb29aa45aa4_base_resized.jpg\",\n        \"url\"          : \"https://wanoazayaka.booth.pm/\",\n        \"verified\"     : False,\n    },\n    \"tag_combination\" : {\n        \"category\": \"漫画・マンガ\",\n        \"tag\"     : \"ぼっち・ざ・ろっく!\",\n        \"url\"     : \"https://booth.pm/ja/browse/%E6%BC%AB%E7%94%BB%E3%83%BB%E3%83%9E%E3%83%B3%E3%82%AC?tags%5B%5D=%E3%81%BC%E3%81%A3%E3%81%A1%E3%83%BB%E3%81%96%E3%83%BB%E3%82%8D%E3%81%A3%E3%81%8F%21\",\n    },\n    \"tags\"            : [\n        \"ぼっち・ざ・ろっく!\",\n        \"ぼっちざろっく\",\n        \"ぼっち・ざ・ろっく\",\n        \"Bocchi the Rock!\",\n        \"BocchiTheRock\",\n    ],\n    \"variations\"      : [{\n        \"buyee_html\"     : None,\n        \"downloadable\"   : None,\n        \"factory_image_url\": None,\n        \"has_download_code\": False,\n        \"id\"             : 7869860,\n        \"is_anshin_booth_pack\": False,\n        \"is_empty_allocatable_stock_with_preorder\": False,\n        \"is_empty_stock\" : False,\n        \"is_factory_item\": False,\n        \"is_mailbin\"     : False,\n        \"is_waiting_on_arrival\": False,\n        \"name\"           : None,\n        \"order_url\"      : None,\n        \"price\"          : 700,\n        \"small_stock\"    : None,\n        \"status\"         : \"addable_to_cart\",\n        \"type\"           : \"digital\",\n    }],\n},\n\n{\n    \"#url\"     : \"https://caramel-crunch.booth.pm/items/7236173?utm_source=pixiv&utm_medium=popboard&utm_campaign=popboard\",\n    \"#class\"   : booth.BoothItemExtractor,\n    \"#results\" : (\n        \"https://booth.pximg.net/74488d0d-e533-443c-82ce-fa961d5cbaf0/i/7236173/131bf61c-0534-4af3-9408-f19f08cb3622.jpg\",\n        \"https://booth.pximg.net/74488d0d-e533-443c-82ce-fa961d5cbaf0/i/7236173/fb65233a-7a93-4219-ba9f-b63e11329fda.jpg\",\n        \"https://booth.pximg.net/74488d0d-e533-443c-82ce-fa961d5cbaf0/i/7236173/e18c16a0-b285-4cd8-aacc-6b3c4f4c6ce3.jpeg\",\n    ),\n\n    \"!_fallback\"      : ...,\n    \"count\"           : 3,\n    \"date\"            : \"dt:2025-07-28 07:00:43\",\n    \"description\"     : \"\"\"C106新作おっぱいマウスパッドです\nコミケ開始時間に合わせてカート開放します\n■お届け9月中旬頃～予定\n印刷：熱転写\n素材：表面/SuperSmooth Fabric　裏面/PUゲル\n\n乳首パーツ付き\nブリスターパック封入\n\n納品済みの為数量限定です。\n数がなくなり次第終了となります。\n\n当日コミケにも持ち込みます。\n2日目,東7ホール Ａ26ab CARAMEL CRUNCH!\"\"\",\n    \"id\"              : 7236173,\n    \"is_adult\"        : True,\n    \"is_buyee_possible\": False,\n    \"is_end_of_sale\"  : False,\n    \"is_placeholder\"  : False,\n    \"is_sold_out\"     : False,\n    \"name\"            : \"こ〇ちゃんおっぱいマウスパッド(乳首パーツ付き)\",\n    \"price\"           : \"¥ 6,500\",\n    \"published_at\"    : \"2025-07-28T16:00:43.000+09:00\",\n    \"purchase_limit\"  : 1,\n    \"shipping_info\"   : \"支払いから発送までの日数：7日以内\",\n    \"booth_category\"  : {\n        \"id\"    : 171,\n        \"name\"  : \"マウスパッド\",\n        \"url\"   : \"https://booth.pm/ja/browse/%E3%83%9E%E3%82%A6%E3%82%B9%E3%83%91%E3%83%83%E3%83%89\",\n        \"parent\": {\n            \"name\": \"グッズ\",\n            \"url\" : \"https://booth.pm/ja/browse/%E3%82%B0%E3%83%83%E3%82%BA\",\n        },\n    },\n    \"shop\"            : {\n        \"id\"           : 49832,\n        \"uuid\"         : \"74488d0d-e533-443c-82ce-fa961d5cbaf0\",\n        \"name\"         : \"ＣＡＲＡＭＥＬ　ＣＲＵＮＣＨ！\",\n        \"subdomain\"    : \"caramel-crunch\",\n        \"thumbnail_url\": \"https://booth.pximg.net/c/48x48/users/49832/icon_image/a240e313-6a0f-4155-8310-a0d6abb299e6_base_resized.jpg\",\n        \"url\"          : \"https://caramel-crunch.booth.pm/\",\n        \"verified\"     : False,\n    },\n    \"tag_combination\" : {\n        \"category\": \"マウスパッド\",\n        \"tag\"     : \"おっぱいマウスパッド\",\n        \"url\"     : \"https://booth.pm/ja/browse/%E3%83%9E%E3%82%A6%E3%82%B9%E3%83%91%E3%83%83%E3%83%89?tags%5B%5D=%E3%81%8A%E3%81%A3%E3%81%B1%E3%81%84%E3%83%9E%E3%82%A6%E3%82%B9%E3%83%91%E3%83%83%E3%83%89\",\n    },\n    \"tags\"            : [\n        \"おっぱいマウスパッド\",\n        \"C106\",\n        \"c106新作\",\n    ],\n},\n\n{\n    \"#url\"     : \"https://caramel-crunch.booth.pm/items/7236173\",\n    \"#class\"   : booth.BoothItemExtractor,\n    \"#options\" : {\"strategy\": \"fallback\"},\n    \"#results\" : (\n        \"https://booth.pximg.net/74488d0d-e533-443c-82ce-fa961d5cbaf0/i/7236173/131bf61c-0534-4af3-9408-f19f08cb3622.jpg\",\n        \"https://booth.pximg.net/74488d0d-e533-443c-82ce-fa961d5cbaf0/i/7236173/fb65233a-7a93-4219-ba9f-b63e11329fda.jpg\",\n        \"https://booth.pximg.net/74488d0d-e533-443c-82ce-fa961d5cbaf0/i/7236173/e18c16a0-b285-4cd8-aacc-6b3c4f4c6ce3.jpg\",\n    ),\n\n    \"_fallback\": \"len:3\",\n},\n\n{\n    \"#url\"     : \"https://booth.pm/zh-cn/items/1895090\",\n    \"#comment\" : \"URL with language code\",\n    \"#class\"   : booth.BoothItemExtractor,\n},\n\n{\n    \"#url\"     : \"https://wanoazayaka.booth.pm/\",\n    \"#class\"   : booth.BoothShopExtractor,\n    \"#results\" : (\n        \"https://wanoazayaka.booth.pm/items/4972816\",\n        \"https://wanoazayaka.booth.pm/items/4855567\",\n        \"https://wanoazayaka.booth.pm/items/4693741\",\n    ),\n\n    \"event\"         : None,\n    \"id\"            : int,\n    \"is_adult\"      : False,\n    \"is_end_of_sale\": False,\n    \"is_placeholder\": False,\n    \"is_sold_out\"   : False,\n    \"is_vrchat\"     : False,\n    \"minimum_stock\" : None,\n    \"music\"         : None,\n    \"name\"          : str,\n    \"price\"         : \"700 JPY\",\n    \"url\"           : r\"re:https://booth.pm/en/items/\\d+\",\n    \"shop_item_url\" : r\"re:https://wanoazayaka.booth.pm/items/\\d+\",\n    \"wish_list_url\" : r\"re:https://wanoazayaka.booth.pm/items/\\d+/wish_list\",\n    \"thumbnail_image_urls\": list,\n    \"shop\"          : {\n        \"name\"         : \"ふたりぼっちのSolitude\",\n        \"thumbnail_url\": \"https://booth.pximg.net/c/48x48/users/5742915/icon_image/1448e5d8-f93f-445e-8e1e-acb29aa45aa4_base_resized.jpg\",\n        \"url\"          : \"https://wanoazayaka.booth.pm/\",\n        \"verified\"     : False,\n    },\n    \"tracking_data\" : {\n        \"product_brand\"   : \"wanoazayaka\",\n        \"product_category\": 56,\n        \"product_event\"   : None,\n        \"product_id\"      : int,\n        \"product_name\"    : str,\n        \"product_price\"   : 700,\n        \"tracking\"        : \"impression_item\",\n    },\n},\n\n{\n    \"#url\"     : \"https://caramel-crunch.booth.pm/items\",\n    \"#class\"   : booth.BoothShopExtractor,\n    \"#pattern\" : booth.BoothItemExtractor.pattern,\n    \"#count\"   : range(90, 120),\n\n    \"shop\": {\n        \"name\"         : \"ＣＡＲＡＭＥＬ　ＣＲＵＮＣＨ！\",\n        \"thumbnail_url\": \"https://booth.pximg.net/c/48x48/users/49832/icon_image/a240e313-6a0f-4155-8310-a0d6abb299e6_base_resized.jpg\",\n        \"url\"          : \"https://caramel-crunch.booth.pm/\",\n        \"verified\"     : False,\n    },\n},\n\n{\n    \"#url\"     : \"https://booth.pm/en/browse/Audio%20Goods?adult=only&max_price=3000\",\n    \"#class\"   : booth.BoothCategoryExtractor,\n    \"#pattern\" : booth.BoothItemExtractor.pattern,\n    \"#range\"   : \"1-100\",\n    \"#count\"   : 100,\n},\n\n{\n    \"#url\"     : \"https://booth.pm/zh-cn/browse/Books%20(Other)\",\n    \"#class\"   : booth.BoothCategoryExtractor,\n    \"#pattern\" : booth.BoothItemExtractor.pattern,\n    \"#range\"   : \"1-100\",\n    \"#count\"   : 100,\n},\n\n)\n"
  },
  {
    "path": "test/results/bulbapedia.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import wikimedia\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://bulbapedia.bulbagarden.net/wiki/Jet\",\n    \"#category\": (\"wikimedia\", \"bulbapedia\", \"article\"),\n    \"#class\"   : wikimedia.WikimediaArticleExtractor,\n    \"#pattern\" : r\"https://archives\\.bulbagarden\\.net/media/upload/\\w+/\\w+/[^/?#]+\",\n    \"#count\"   : range(8, 30),\n},\n\n{\n    \"#url\"     : \"https://archives.bulbagarden.net/wiki/File:0460Abomasnow-Mega.png\",\n    \"#category\": (\"wikimedia\", \"bulbapedia\", \"file\"),\n    \"#class\"   : wikimedia.WikimediaArticleExtractor,\n    \"#pattern\" : r\"https://archives\\.bulbagarden\\.net/media/upload/\\w+/\\w+/[^/?#]+\",\n    \"#count\"   : range(8, 12),\n    \"#archive\" : False,\n},\n\n)\n"
  },
  {
    "path": "test/results/bunkr.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import bunkr\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://bunkr.sk/a/Lktg9Keq\",\n    \"#category\": (\"lolisafe\", \"bunkr\", \"album\"),\n    \"#class\"   : bunkr.BunkrAlbumExtractor,\n    \"#results\"     : \"\"\"https://brg-bk.cdn.gigachad-cdn.ru/test-テスト-\"&>-QjgneIQv.png\"\"\",\n    \"#sha1_content\": (\n        \"0c8768055e4e20e7c7259608b67799171b691140\",\n        \"961b25d85b5f5bd18cbe3e847ac55925f14d0286\",\n    ),\n\n    \"album_id\"   : \"Lktg9Keq\",\n    \"album_name\" : \"test テスト \\\"&>\",\n    \"album_size\" : \"182 bytes\",\n    \"count\"      : 1,\n    \"extension\"  : \"png\",\n    \"file\"       : str,\n    \"filename\"   : \"test-テスト-\\\"&>-QjgneIQv\",\n    \"id\"         : \"QjgneIQv\",\n    \"id_url\"     : \"1044478\",\n    \"name\"       : \"test-テスト-\\\"&>\",\n    \"slug\"       : \"test-テスト-\\\"&>-QjgneIQv.png\",\n    \"num\"        : 1,\n},\n\n{\n    \"#url\"     : \"https://bunkr.is/a/iXTTc1o2\",\n    \"#category\": (\"lolisafe\", \"bunkr\", \"album\"),\n    \"#class\"   : bunkr.BunkrAlbumExtractor,\n    \"#results\"     : (\n        \"https://mlk-bk.cdn.gigachad-cdn.ru/image-sZrQUeOx.jpg\",\n    ),\n    \"#sha1_content\": (\n        \"55998743751dfe008d0e95605114fcbfa7dc4de8\",\n        \"caf7c3d3439d94e83b3c24ddaf5a3a48aa057519\",\n    ),\n\n    \"album_id\"   : \"iXTTc1o2\",\n    \"album_name\" : \"test2\",\n    \"album_size\" : \"534.61 KB\",\n    \"count\"      : 1,\n    \"filename\"   : r\"image-sZrQUeOx\",\n    \"id\"         : r\"sZrQUeOx\",\n    \"name\"       : r\"image\",\n    \"num\"        : 1,\n},\n\n{\n    \"#url\"     : \"https://bunkr.cat/a/j1G29CnD\",\n    \"#comment\" : \"cdn12 .ru TLD (#4147)\",\n    \"#category\": (\"lolisafe\", \"bunkr\", \"album\"),\n    \"#class\"   : bunkr.BunkrAlbumExtractor,\n    \"#pattern\" : r\"https://(i-)?meatballs.bunkr.ru/\\w+\",\n    \"#count\"   : 4,\n},\n\n{\n    \"#url\"     : \"https://bunkr.cr/a/Gm931jJz\",\n    \"#comment\" : \"empty 'id', duplicate archive IDs (#6935)\",\n    \"#category\": (\"lolisafe\", \"bunkr\", \"album\"),\n    \"#class\"   : bunkr.BunkrAlbumExtractor,\n    \"#count\"   : 2,\n\n    \"id\"    : \"\",\n    \"id_url\": {\"43478756\", \"43478551\"},\n    \"slug\"  : {\"UPKDHBf0CvrCe\", \"zQgSePr1f4HZ2\"},\n    \"uuid\"  : \"iso:uuid\",\n},\n\n{\n    \"#url\"     : \"https://bunkr.ph/a/Lktg9Keq\",\n    \"#class\"   : bunkr.BunkrAlbumExtractor,\n},\n\n{\n    \"#url\"     : \"https://bunkr.ps/a/Lktg9Keq\",\n    \"#class\"   : bunkr.BunkrAlbumExtractor,\n},\n\n{\n    \"#url\"     : \"https://bunkr.pk/a/Lktg9Keq\",\n    \"#class\"   : bunkr.BunkrAlbumExtractor,\n},\n\n{\n    \"#url\"     : \"https://bunkr.ax/a/Lktg9Keq\",\n    \"#category\": (\"lolisafe\", \"bunkr\", \"album\"),\n    \"#class\"   : bunkr.BunkrAlbumExtractor,\n},\n\n{\n    \"#url\"     : \"https://bunkrrr.org/a/Lktg9Keq\",\n    \"#category\": (\"lolisafe\", \"bunkr\", \"album\"),\n    \"#class\"   : bunkr.BunkrAlbumExtractor,\n},\n\n{\n    \"#url\"     : \"https://bunkr.ci/a/Lktg9Keq\",\n    \"#category\": (\"lolisafe\", \"bunkr\", \"album\"),\n    \"#class\"   : bunkr.BunkrAlbumExtractor,\n},\n\n{\n    \"#url\"     : \"https://bunkr.cr/a/Lktg9Keq\",\n    \"#category\": (\"lolisafe\", \"bunkr\", \"album\"),\n    \"#class\"   : bunkr.BunkrAlbumExtractor,\n},\n\n{\n    \"#url\"     : \"https://bunkr.fi/a/Lktg9Keq\",\n    \"#category\": (\"lolisafe\", \"bunkr\", \"album\"),\n    \"#class\"   : bunkr.BunkrAlbumExtractor,\n},\n\n{\n    \"#url\"     : \"https://bunkr.si/a/Lktg9Keq\",\n    \"#category\": (\"lolisafe\", \"bunkr\", \"album\"),\n    \"#class\"   : bunkr.BunkrAlbumExtractor,\n},\n\n{\n    \"#url\"     : \"https://bunkr.ac/a/Lktg9Keq\",\n    \"#category\": (\"lolisafe\", \"bunkr\", \"album\"),\n    \"#class\"   : bunkr.BunkrAlbumExtractor,\n},\n\n{\n    \"#url\"     : \"https://bunkr.media/a/Lktg9Keq\",\n    \"#category\": (\"lolisafe\", \"bunkr\", \"album\"),\n    \"#class\"   : bunkr.BunkrAlbumExtractor,\n},\n\n{\n    \"#url\"     : \"https://bunkr.site/a/Lktg9Keq\",\n    \"#category\": (\"lolisafe\", \"bunkr\", \"album\"),\n    \"#class\"   : bunkr.BunkrAlbumExtractor,\n},\n\n{\n    \"#url\"     : \"https://bunkr.ws/a/Lktg9Keq\",\n    \"#category\": (\"lolisafe\", \"bunkr\", \"album\"),\n    \"#class\"   : bunkr.BunkrAlbumExtractor,\n},\n\n{\n    \"#url\"     : \"https://bunkrr.ru/a/Lktg9Keq\",\n    \"#category\": (\"lolisafe\", \"bunkr\", \"album\"),\n    \"#class\"   : bunkr.BunkrAlbumExtractor,\n},\n\n{\n    \"#url\"     : \"https://bunkrr.su/a/Lktg9Keq\",\n    \"#category\": (\"lolisafe\", \"bunkr\", \"album\"),\n    \"#class\"   : bunkr.BunkrAlbumExtractor,\n},\n\n{\n    \"#url\"     : \"https://bunkr.la/a/Lktg9Keq\",\n    \"#category\": (\"lolisafe\", \"bunkr\", \"album\"),\n    \"#class\"   : bunkr.BunkrAlbumExtractor,\n},\n\n{\n    \"#url\"     : \"https://bunkr.su/a/Lktg9Keq\",\n    \"#category\": (\"lolisafe\", \"bunkr\", \"album\"),\n    \"#class\"   : bunkr.BunkrAlbumExtractor,\n},\n\n{\n    \"#url\"     : \"https://bunkr.ru/a/Lktg9Keq\",\n    \"#category\": (\"lolisafe\", \"bunkr\", \"album\"),\n    \"#class\"   : bunkr.BunkrAlbumExtractor,\n},\n\n{\n    \"#url\"     : \"https://bunkr.is/a/Lktg9Keq\",\n    \"#category\": (\"lolisafe\", \"bunkr\", \"album\"),\n    \"#class\"   : bunkr.BunkrAlbumExtractor,\n},\n\n{\n    \"#url\"     : \"https://bunkr.to/a/Lktg9Keq\",\n    \"#category\": (\"lolisafe\", \"bunkr\", \"album\"),\n    \"#class\"   : bunkr.BunkrAlbumExtractor,\n},\n\n{\n    \"#url\"     : \"bunkr:http://example.org/a/Lktg9Keq\",\n    \"#category\": (\"lolisafe\", \"bunkr\", \"album\"),\n    \"#class\"   : bunkr.BunkrAlbumExtractor,\n},\n\n{\n    \"#url\"     : \"https://bunkr.cr/a/z5Xt6NqH\",\n    \"#comment\" : \"filenames (#8150)\",\n    \"#category\": (\"lolisafe\", \"bunkr\", \"album\"),\n    \"#class\"   : bunkr.BunkrAlbumExtractor,\n    \"#results\" : (\n        \"https://beer.bunkr.ru/acba241d-c1e8-40c3-aa73-94daf75fcd13.jpg\",\n        \"https://cake.bunkr.ru/e997f757-61dc-45be-bd61-b6998d813beb.jpg\",\n        \"https://cake.bunkr.ru/72f1e20b-72a5-43b0-8ab2-472016e1d767.mp4\",\n    ),\n\n    \"album_id\"  : \"z5Xt6NqH\",\n    \"album_name\": \"filename\",\n    \"album_size\": \"1.82 MB\",\n    \"count\"     : 3,\n    \"date\"      : \"type:datetime\",\n    \"extension\" : {\"jpg\", \"mp4\"},\n    \"file\"      : str,\n    \"id\"        : \"\",\n    \"name\"      : str,\n    \"num\"       : range(1, 3),\n    \"id_url\"    : {\"53118207\", \"53118010\", \"53117871\"},\n    \"size\"      : {490885, 727670, 687238},\n    \"slug\"      : {\"Nzt1ID7lsgwR4\", \"Bu0e2k6gOB5di\", \"PwrDbEgQODSls\"},\n    \"filename\"  : {\n        \"'\\\"'\",\n        \"😃\",\n        \"\"\"filename: !\"#$%&\\'()*+,-.0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\\\]\"\"\",\n    },\n    \"uuid\"      : {\n        \"acba241d-c1e8-40c3-aa73-94daf75fcd13\",\n        \"e997f757-61dc-45be-bd61-b6998d813beb\",\n        \"72f1e20b-72a5-43b0-8ab2-472016e1d767\",\n    },\n},\n\n{\n    \"#url\"     : \"https://bunkr.black/i/image-sZrQUeOx.jpg\",\n    \"#category\": (\"lolisafe\", \"bunkr\", \"media\"),\n    \"#class\"   : bunkr.BunkrMediaExtractor,\n    \"#results\"     : \"https://mlk-bk.cdn.gigachad-cdn.ru/image-sZrQUeOx.jpg\",\n    \"#sha1_content\": (\n        \"55998743751dfe008d0e95605114fcbfa7dc4de8\",\n        \"caf7c3d3439d94e83b3c24ddaf5a3a48aa057519\",\n    ),\n\n    \"count\"    : 1,\n    \"extension\": \"jpg\",\n    \"file\"     : \"https://mlk-bk.cdn.gigachad-cdn.ru/image-sZrQUeOx.jpg\",\n    \"filename\" : \"image-sZrQUeOx\",\n    \"id\"       : \"sZrQUeOx\",\n    \"name\"     : \"image\",\n},\n\n{\n    \"#url\"     : \"https://bunkr.cr/f/image-sZrQUeOx.jpg\",\n    \"#comment\" : \"/f/ URL\",\n    \"#category\": (\"lolisafe\", \"bunkr\", \"media\"),\n    \"#class\"   : bunkr.BunkrMediaExtractor,\n    \"#results\" : \"https://mlk-bk.cdn.gigachad-cdn.ru/image-sZrQUeOx.jpg\",\n},\n\n{\n    \"#url\"     : \"https://bunkrrr.org/d/dJuETSzKLrUps\",\n    \"#category\": (\"lolisafe\", \"bunkr\", \"media\"),\n    \"#class\"   : bunkr.BunkrMediaExtractor,\n    \"#results\"     : \"https://brg-bk.cdn.gigachad-cdn.ru/file-r5fmwjdd.zip\",\n    \"#sha1_content\": \"102ddd7894fe39b3843098fc51f972a0af938f45\",\n\n    \"count\"    : 1,\n    \"extension\": \"zip\",\n    \"file\"     : \"https://brg-bk.cdn.gigachad-cdn.ru/file-r5fmwjdd.zip\",\n    \"filename\" : \"file-r5fmwjdd\",\n    \"id\"       : \"r5fmwjdd\",\n    \"id_url\"   : \"38792076\",\n    \"name\"     : \"file\",\n},\n\n{\n    \"#url\"     : \"https://bunkr.ph/v/rEeTUL8MXR17A\",\n    \"#comment\" : \"redirect to '/f/rEeTUL8MXR17A' (#6790)\",\n    \"#category\": (\"lolisafe\", \"bunkr\", \"media\"),\n    \"#class\"   : bunkr.BunkrMediaExtractor,\n    \"#results\" : \"https://c.bunkr-cache.se/hAVFkYK1bLbSaaKq/27-03-2024-Rp-0FfrropA.mp4\",\n},\n\n{\n    \"#url\"     : \"https://bunkr.site/f/wYGCKbGhSvuAW\",\n    \"#comment\" : \"correct 'name' from HTML (#6790)\",\n    \"#category\": (\"lolisafe\", \"bunkr\", \"media\"),\n    \"#class\"   : bunkr.BunkrMediaExtractor,\n    \"#results\" : \"https://c.bunkr-cache.se/QlXezBjk2fCVVobM/80ca5405-8b8d-4f9f-8167-8b046bb9dc67.mp4\",\n\n    \"id\"       : \"\",\n    \"id_url\"   : \"41913002\",\n    \"slug\"     : \"wYGCKbGhSvuAW\",\n    \"uuid\"     : \"80ca5405-8b8d-4f9f-8167-8b046bb9dc67\",\n    \"name\"     : \"0hwndshtfmj7hcbut1nd4_source\",\n    \"filename\" : \"0hwndshtfmj7hcbut1nd4_source\",\n    \"extension\": \"mp4\",\n},\n\n{\n    \"#url\"     : \"https://bunkr.site/f/JEn5iQgYVYJfi\",\n    \"#comment\" : \"file gone --- 403 error for main 'brg-bk.cdn.gigachad-cdn.ru' URL (#6732 #6972)\",\n    \"#category\": (\"lolisafe\", \"bunkr\", \"media\"),\n    \"#class\"   : bunkr.BunkrMediaExtractor,\n    \"#results\"     : \"https://brg-bk.cdn.gigachad-cdn.ru/IMG_47272f2c698d257fd22f4300ae98ec35929b-iEYVkLPQ.jpg\",\n    \"#sha1_content\": \"f1c839743563828b250e48d485933a735a508527\",\n\n    \"_http_headers\": {\n        \"Referer\": \"https://get.bunkrr.su/file/29682239\",\n    },\n    \"extension\": \"jpg\",\n    \"filename\" : \"IMG_47272f2c698d257fd22f4300ae98ec35929b-iEYVkLPQ\",\n    \"id\"       : \"iEYVkLPQ\",\n    \"id_url\"   : \"29682239\",\n    \"name\"     : \"IMG_47272f2c698d257fd22f4300ae98ec35929b\",\n},\n\n{\n    \"#url\"     : \"https://bunkr.pk/f/Nzt1ID7lsgwR4\",\n    \"#category\": (\"lolisafe\", \"bunkr\", \"media\"),\n    \"#class\"   : bunkr.BunkrMediaExtractor,\n    \"#results\" : \"https://beer.bunkr.ru/acba241d-c1e8-40c3-aa73-94daf75fcd13.jpg\",\n\n    \"filename\"   : \"'\\\"'\",\n    \"id\"         : \"\",\n    \"id_url\"     : \"53118207\",\n    \"name\"       : \"'\\\"'\",\n    \"slug\"       : \"Nzt1ID7lsgwR4\",\n    \"uuid\"       : \"acba241d-c1e8-40c3-aa73-94daf75fcd13\",\n},\n\n{\n    \"#url\"     : \"https://bunkr.cr/f/mX1DBQooiUOJ9\",\n    \"#comment\" : \"'album_...' metadata from '/f/' URL (#8405)\",\n    \"#category\": (\"lolisafe\", \"bunkr\", \"media\"),\n    \"#class\"   : bunkr.BunkrMediaExtractor,\n    \"#results\" : \"https://rum.bunkr.ru/edf721b7-618b-4214-9305-845e1d210437.png\",\n\n    \"album_id\"  : \"MwY4XLNV\",\n    \"album_name\": \"foo & bar\",\n    \"album_size\": \"3.54 MB\",\n    \"count\"     : 1,\n    \"extension\" : \"png\",\n    \"filename\"  : \"danbooru_10113035_fe864be2aa86487e5b08c768be78b787\",\n    \"id\"        : \"\",\n    \"id_url\"    : \"54661720\",\n    \"name\"      : \"danbooru_10113035_fe864be2aa86487e5b08c768be78b787\",\n    \"num\"       : 1,\n    \"slug\"      : \"mX1DBQooiUOJ9\",\n    \"uuid\"      : \"edf721b7-618b-4214-9305-845e1d210437\",\n},\n\n)\n"
  },
  {
    "path": "test/results/catbox.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import catbox\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://catbox.moe/c/1igcbe\",\n    \"#category\": (\"\", \"catbox\", \"album\"),\n    \"#class\"   : catbox.CatboxAlbumExtractor,\n    \"#pattern\"     : r\"https://files\\.catbox\\.moe/\\w+\\.\\w{3}$\",\n    \"#count\"       : 3,\n    \"#sha1_url\"    : \"35866a88c29462814f103bc22ec031eaeb380f8a\",\n    \"#sha1_content\": \"70ddb9de3872e2d17cc27e48e6bf395e5c8c0b32\",\n\n    \"album_id\"   : \"1igcbe\",\n    \"album_name\" : \"test\",\n    \"date\"       : \"dt:2022-08-18 00:00:00\",\n    \"description\": \"album test &>\",\n},\n\n{\n    \"#url\"     : \"https://www.catbox.moe/c/cd90s1\",\n    \"#category\": (\"\", \"catbox\", \"album\"),\n    \"#class\"   : catbox.CatboxAlbumExtractor,\n},\n\n{\n    \"#url\"     : \"https://catbox.moe/c/w7tm47#\",\n    \"#category\": (\"\", \"catbox\", \"album\"),\n    \"#class\"   : catbox.CatboxAlbumExtractor,\n},\n\n{\n    \"#url\"     : \"https://files.catbox.moe/8ih3y7.png\",\n    \"#category\": (\"\", \"catbox\", \"file\"),\n    \"#class\"   : catbox.CatboxFileExtractor,\n    \"#pattern\"     : r\"^https://files\\.catbox\\.moe/8ih3y7\\.png$\",\n    \"#count\"       : 1,\n    \"#sha1_content\": \"0c8768055e4e20e7c7259608b67799171b691140\",\n},\n\n{\n    \"#url\"     : \"https://litter.catbox.moe/t8v3n9.png\",\n    \"#category\": (\"\", \"catbox\", \"file\"),\n    \"#class\"   : catbox.CatboxFileExtractor,\n},\n\n{\n    \"#url\"     : \"https://de.catbox.moe/bjdmz1.jpg\",\n    \"#category\": (\"\", \"catbox\", \"file\"),\n    \"#class\"   : catbox.CatboxFileExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/cavemanon.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import shimmie2\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://booru.cavemanon.xyz/index.php?q=post/list/Amber/1\",\n    \"#category\": (\"shimmie2\", \"cavemanon\", \"tag\"),\n    \"#class\"   : shimmie2.Shimmie2TagExtractor,\n    \"#pattern\" : r\"https://booru\\.cavemanon\\.xyz/index\\.php\\?q=image/\\d+\\.\\w+\",\n    \"#range\"   : \"1-100\",\n    \"#count\"   : 100,\n},\n\n{\n    \"#url\"     : \"https://booru.cavemanon.xyz/post/list/Amber/1\",\n    \"#category\": (\"shimmie2\", \"cavemanon\", \"tag\"),\n    \"#class\"   : shimmie2.Shimmie2TagExtractor,\n},\n\n{\n    \"#url\"     : \"https://booru.cavemanon.xyz/index.php?q=post/view/8335\",\n    \"#category\": (\"shimmie2\", \"cavemanon\", \"post\"),\n    \"#class\"   : shimmie2.Shimmie2PostExtractor,\n    \"#pattern\"     : r\"https://booru\\.cavemanon\\.xyz/index\\.php\\?q=image/8335\\.png\",\n    \"#sha1_content\": \"7158f7e4abbbf143bad5835eb93dbe4d68c1d4ab\",\n\n    \"extension\": \"png\",\n    \"file_url\" : \"https://booru.cavemanon.xyz/index.php?q=image/8335.png\",\n    \"filename\" : \"8335\",\n    \"height\"   : 460,\n    \"id\"       : 8335,\n    \"md5\"      : \"\",\n    \"size\"     : 0,\n    \"tags\"     : \"Color discord_emote Fang Food Pterodactyl transparent\",\n    \"width\"    : 459,\n},\n\n)\n"
  },
  {
    "path": "test/results/celebforum.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import xenforo\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://celebforum.to/threads/addison-vodka.84947/post-885855\",\n    \"#category\": (\"xenforo\", \"celebforum\", \"post\"),\n    \"#class\"   : xenforo.XenforoPostExtractor,\n    \"#results\" : (\n        \"https://celebforum.to/data/assets/videos/notregistered.mp4\",\n        \"https://celebforum.to/attachments/e2vurqbi2i-png.5293040/\",\n        \"https://celebforum.to/attachments/wxt4sxsity-png.5293043/\",\n        \"https://celebforum.to/attachments/echvvlmtcl-png.5293045/\",\n    ),\n\n    \"count\"       : 4,\n    \"extension\"   : {\"png\", \"mp4\"},\n    \"filename\"    : str,\n    \"num_external\": 0,\n    \"num_internal\": range(1, 4),\n    \"type\"        : {\"video\", \"inline\"},\n    \"post\"        : {\n        \"attachments\": \"\",\n        \"author\"     : \"kamikaze-770807\",\n        \"author_id\"  : \"post-88585\",\n        \"author_slug\": \"\",\n        \"author_url\" : \"/threads/addison-vodka.84947/post-885855\",\n        \"count\"      : 4,\n        \"date\"       : \"dt:2024-09-15 08:08:16\",\n        \"id\"         : \"885855\",\n        \"content\"    : str,\n    },\n    \"thread\"      : {\n        \"author\"     : str,\n        \"author_id\"  : \"\",\n        \"author_slug\": str,\n        \"author_url\" : \"\",\n        \"date\"       : \"dt:2024-01-29 19:56:27\",\n        \"id\"         : \"84947\",\n        \"posts\"      : int,\n        \"section\"    : \"Pornostars\",\n        \"tags\"       : (),\n        \"title\"      : \"Addison Vodka\",\n        \"url\"        : \"https://celebforum.to/threads/addison-vodka.84947/\",\n        \"views\"      : -1,\n    },\n},\n\n{\n    \"#url\"     : \"https://celebforum.to/threads/addison-vodka.84947/\",\n    \"#category\": (\"xenforo\", \"celebforum\", \"thread\"),\n    \"#class\"   : xenforo.XenforoThreadExtractor,\n    \"#count\"   : range(1000, 2000),\n\n    \"count\"       : int,\n    \"num\"         : int,\n    \"num_external\": int,\n    \"num_internal\": int,\n    \"type\"        : {\"external\", \"inline\", \"video\"},\n    \"post\"        : {\n        \"attachments\": str,\n        \"author\"     : str,\n        \"author_id\"  : str,\n        \"author_slug\": str,\n        \"author_url\" : str,\n        \"count\"      : int,\n        \"date\"       : \"type:datetime\",\n        \"id\"         : str,\n        \"content\"    : str\n    },\n    \"thread\"      : {\n        \"author\"     : str,\n        \"author_id\"  : \"\",\n        \"author_slug\": str,\n        \"author_url\" : \"\",\n        \"date\"       : \"dt:2024-01-29 19:56:27\",\n        \"id\"         : \"84947\",\n        \"posts\"      : int,\n        \"section\"    : \"Pornostars\",\n        \"tags\"       : (),\n        \"title\"      : \"Addison Vodka\",\n        \"url\"        : \"https://celebforum.to/threads/addison-vodka.84947/\",\n        \"views\"      : -1,\n    },\n},\n\n{\n    \"#url\"     : \"https://celebforum.to/forums/pornostars.13/\",\n    \"#category\": (\"xenforo\", \"celebforum\", \"forum\"),\n    \"#class\"   : xenforo.XenforoForumExtractor,\n    \"#pattern\" : xenforo.XenforoThreadExtractor.pattern,\n    \"#range\"   : \"1-50\",\n    \"#count\"   : 50,\n},\n\n{\n    \"#url\"     : \"https://celebforum.to/media/albums/5404/\",\n    \"#category\": (\"xenforo\", \"celebforum\", \"media-album\"),\n    \"#class\"   : xenforo.XenforoMediaAlbumExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/cfake.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import cfake\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://cfake.com/images/celebrity/Kaley_Cuoco/631/\",\n    \"#category\": (\"\", \"cfake\", \"celebrity\"),\n    \"#class\"   : cfake.CfakeCelebrityExtractor,\n    \"#pattern\" : r\"https://cfake\\.com/medias/photos/\\d{4}/[0-9a-f]+_cfake\\.jpg\",\n    \"#range\"   : \"1-20\",\n    \"#count\"   : 20,\n\n    \"type\"         : \"celebrity\",\n    \"type_id\"      : 631,\n    \"type_name\"    : \"Kaley Cuoco\",\n    \"page\"         : 1,\n    \"id\"           : int,\n    \"num\"          : int,\n    \"date\"         : str,\n    \"rating\"       : str,\n},\n\n{\n    \"#url\"     : \"https://cfake.com/images/celebrity/Kaley_Cuoco/631/p2\",\n    \"#comment\" : \"pagination test - page 2\",\n    \"#category\": (\"\", \"cfake\", \"celebrity\"),\n    \"#class\"   : cfake.CfakeCelebrityExtractor,\n    \"#pattern\" : r\"https://cfake\\.com/medias/photos/\\d{4}/[0-9a-f]+_cfake\\.jpg\",\n    \"#range\"   : \"1-5\",\n\n    \"type\"         : \"celebrity\",\n    \"type_id\"      : 631,\n    \"type_name\"    : \"Kaley Cuoco\",\n    \"page\"         : 2,\n},\n\n{\n    \"#url\"     : \"https://www.cfake.com/images/celebrity/Chloe_Grace_Moretz/6575/\",\n    \"#category\": (\"\", \"cfake\", \"celebrity\"),\n    \"#class\"   : cfake.CfakeCelebrityExtractor,\n},\n\n{\n    \"#url\"     : \"https://cfake.com/images/categories/Facial/25/\",\n    \"#category\": (\"\", \"cfake\", \"category\"),\n    \"#class\"   : cfake.CfakeCategoryExtractor,\n    \"#pattern\" : r\"https://cfake\\.com/medias/photos/\\d{4}/[0-9a-f]+_cfake\\.jpg\",\n    \"#range\"   : \"1-10\",\n    \"#count\"   : 10,\n\n    \"type\"        : \"category\",\n    \"type_id\"     : 25,\n    \"type_name\"   : \"Facial\",\n    \"page\"        : 1,\n    \"id\"          : int,\n    \"num\"         : int,\n},\n\n{\n    \"#url\"     : \"https://cfake.com/images/categories/Big_Tits/35/\",\n    \"#category\": (\"\", \"cfake\", \"category\"),\n    \"#class\"   : cfake.CfakeCategoryExtractor,\n},\n\n{\n    \"#url\"     : \"https://cfake.com/images/categories/Big_Tits/35/p2\",\n    \"#comment\" : \"category pagination test\",\n    \"#category\": (\"\", \"cfake\", \"category\"),\n    \"#class\"   : cfake.CfakeCategoryExtractor,\n},\n\n{\n    \"#url\"     : \"https://cfake.com/images/created/Spice_Girls_%28band%29/72/4\",\n    \"#category\": (\"\", \"cfake\", \"created\"),\n    \"#class\"   : cfake.CfakeCreatedExtractor,\n    \"#pattern\" : r\"https://cfake\\.com/medias/photos/\\d{4}/[0-9a-f]+_cfake\\.jpg\",\n    \"#range\"   : \"1-10\",\n    \"#count\"   : 10,\n\n    \"type\"       : \"created\",\n    \"type_id\"    : 72,\n    \"type_name\"  : \"Spice Girls (band)\",\n    \"sub_id\"     : 4,\n    \"page\"       : 1,\n    \"id\"         : int,\n    \"num\"        : int,\n},\n\n{\n    \"#url\"     : \"https://cfake.com/images/created/Brooklyn_Nine-Nine/4142/4\",\n    \"#category\": (\"\", \"cfake\", \"created\"),\n    \"#class\"   : cfake.CfakeCreatedExtractor,\n},\n\n{\n    \"#url\"     : \"https://cfake.com/images/created/Brooklyn_Nine-Nine/4142/4/p2\",\n    \"#comment\" : \"created pagination test\",\n    \"#category\": (\"\", \"cfake\", \"created\"),\n    \"#class\"   : cfake.CfakeCreatedExtractor,\n},\n\n{\n    \"#url\"     : \"https://cfake.com/images/country/Australia/12/5\",\n    \"#category\": (\"\", \"cfake\", \"country\"),\n    \"#class\"   : cfake.CfakeCountryExtractor,\n    \"#pattern\" : r\"https://cfake\\.com/medias/photos/\\d{4}/[0-9a-f]+_cfake\\.jpg\",\n    \"#range\"   : \"1-10\",\n    \"#count\"   : 10,\n\n    \"type\"       : \"country\",\n    \"type_id\"    : 12,\n    \"type_name\"  : \"Australia\",\n    \"sub_id\"     : 5,\n    \"page\"       : 1,\n    \"id\"         : int,\n    \"num\"        : int,\n},\n\n{\n    \"#url\"     : \"https://cfake.com/images/country/Mexico/139/5\",\n    \"#category\": (\"\", \"cfake\", \"country\"),\n    \"#class\"   : cfake.CfakeCountryExtractor,\n},\n\n{\n    \"#url\"     : \"https://cfake.com/images/country/Mexico/139/5/p3\",\n    \"#comment\" : \"country pagination test\",\n    \"#category\": (\"\", \"cfake\", \"country\"),\n    \"#class\"   : cfake.CfakeCountryExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/chelseacrew.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import shopify\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://chelseacrew.com/collections/flats\",\n    \"#category\": (\"shopify\", \"chelseacrew\", \"collection\"),\n    \"#class\"   : shopify.ShopifyCollectionExtractor,\n},\n\n{\n    \"#url\"     : \"https://chelseacrew.com/collections/flats/products/dora\",\n    \"#category\": (\"shopify\", \"chelseacrew\", \"product\"),\n    \"#class\"   : shopify.ShopifyProductExtractor,\n},\n\n{\n    \"#url\"     : \"https://chelseacrew.com/en-de/collections/bridalcrew/products/gloria\",\n    \"#category\": (\"shopify\", \"chelseacrew\", \"product\"),\n    \"#class\"   : shopify.ShopifyProductExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/cien.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import cien\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://ci-en.net/creator/7491/article/1194568\",\n    \"#category\": (\"\", \"cien\", \"article\"),\n    \"#class\"   : cien.CienArticleExtractor,\n    \"#pattern\" : r\"https://media\\.ci-en\\.jp/private/attachment/creator/00007491/c0c212a93027c8863bdb40668071c1525a4567f94baca13c17989045e5a3d81d/video-web\\.mp4\\?px-time=.+\",\n\n    \"author\": {\n        \"@type\" : \"Person\",\n        \"id\"    : 7491,\n        \"image\" : \"https://media.ci-en.jp/public/icon/creator/00007491/9601a2a224245156335aaa839fa408d52c32c87dae5787fc03f455b7fd1d3488/image-200-c.jpg\",\n        \"name\"  : \"やかろ\",\n        \"url\"   : \"https://ci-en.net/creator/7491\",\n        \"sameAs\": [\n            \"https://pokapoka0802.wixsite.com/tunousaginoie82\",\n            \"https://www.freem.ne.jp/brand/6001\",\n            \"https://store.steampowered.com/search/?developer=%E3%83%84%E3%83%8E%E3%82%A6%E3%82%B5%E3%82%AE%E3%81%AE%E5%AE%B6\",\n            \"https://plicy.net/User/87381\",\n            \"https://twitter.com/pokapoka0802\",\n        ],\n    },\n    \"articleBody\": str,\n    \"count\"       : 1,\n    \"date\"       : \"dt:2024-07-21 15:36:00\",\n    \"dateModified\" : \"2024-07-22T03:28:40+09:00\",\n    \"datePublished\": \"2024-07-22T00:36:00+09:00\",\n    \"description\": \"お知らせ 今回は雨のピリオードの解説をしたいと思うのですが、その前にいくつかお知らせがあります。 電話を使って謎を解いていくフリーゲーム 電話を通して、様々なキャラクターを会話をしていく、ノベルゲーム……\",\n    \"extension\"  : \"mp4\",\n    \"filename\"   : \"無題の動画 (1)\",\n    \"headline\"   : \"角兎図書館「雨のピリオード」No,16\",\n    \"image\"      : \"https://media.ci-en.jp/public/article_cover/creator/00007491/cb4062e8d885ab93e0d0fb3133265a7ad1056c906fd4ab81da509220620901e1/image-1280-c.jpg\",\n    \"keywords\"   : \"お知らせ,角兎図書館\",\n    \"mainEntityOfPage\": \"https://ci-en.net/creator/7491/article/1194568\",\n    \"name\"       : \"角兎図書館「雨のピリオード」No,16\",\n    \"num\"        : 1,\n    \"post_id\"    : 1194568,\n    \"type\"       : \"video\",\n    \"url\"        : str,\n},\n\n{\n    \"#url\"     : \"https://ci-en.dlsite.com/creator/25509/article/1172460\",\n    \"#category\": (\"\", \"cien\", \"article\"),\n    \"#class\"   : cien.CienArticleExtractor,\n    \"#options\" : {\"files\": \"download\"},\n    \"#pattern\" : r\"https://media\\.ci-en\\.jp/private/attachment/creator/00025509/7fd3c039d2277ba9541e82592aca6f6751f6c268404038ccbf1112bcf2f93357/upload/.+\\.zip\\?px-time=.+\",\n\n    \"filename\" : \"VP 1.05.4 Tim-v9 ENG rec v3\",\n    \"extension\": \"zip\",\n    \"type\"     : \"download\",\n},\n\n{\n    \"#url\"     : \"https://ci-en.net/creator/11962\",\n    \"#category\": (\"\", \"cien\", \"creator\"),\n    \"#class\"   : cien.CienCreatorExtractor,\n    \"#pattern\" : cien.CienArticleExtractor.pattern,\n    \"#count\"   : \"> 25\",\n},\n\n{\n    \"#url\"     : \"https://ci-en.net/mypage/recent\",\n    \"#category\": (\"\", \"cien\", \"recent\"),\n    \"#class\"   : cien.CienRecentExtractor,\n    \"#auth\"    : True,\n},\n\n{\n    \"#url\"     : \"https://ci-en.net/mypage/subscription/following\",\n    \"#category\": (\"\", \"cien\", \"following\"),\n    \"#class\"   : cien.CienFollowingExtractor,\n    \"#pattern\" : cien.CienCreatorExtractor.pattern,\n    \"#count\"   : \"> 3\",\n    \"#auth\"    : True,\n},\n\n{\n    \"#url\"     : \"https://ci-en.net/mypage/subscription\",\n    \"#category\": (\"\", \"cien\", \"following\"),\n    \"#class\"   : cien.CienFollowingExtractor,\n    \"#auth\"    : True,\n},\n\n)\n"
  },
  {
    "path": "test/results/civitai.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import civitai\nfrom gallery_dl import exception\n\n\n__tests__ = (\n{\n    \"#url\"  : \"https://civitai.com/models/703211/maid-classic\",\n    \"#class\": civitai.CivitaiModelExtractor,\n    \"#results\": (\n        \"https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/51ea6a54-762c-46cf-9588-726461193c96/original=true/00019-2944604798.png\",\n        \"https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/aaa474a8-5a4d-4003-819f-79df2935ad78/original=true/00020-1919126538.png\",\n        \"https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/1bb22783-1c29-405e-9d7e-7c98b5a53d65/original=true/00021-2415646212.png\",\n        \"https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/52b6efa7-801c-4901-90b4-fa3964d23480/original=true/00004-822988489.png\",\n        \"https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/c4d3bcd5-0e23-4f4e-9f34-d13b2f2bf14c/original=true/00005-1059918744.png\",\n        \"https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/68568d22-c4f3-45cb-ac32-82f1cedf968f/original=true/00006-3467286319.png\",\n        \"https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/5c4efa68-bb58-47c5-a716-98cd0f51f047/original=true/00013-4238863814.png\",\n        \"https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/69bf3279-df2c-4ec8-b795-479e9cd3db1b/original=true/00014-3150861441.png\",\n        \"https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/2dd1dc69-45a6-4beb-b36b-2e2bc65e3cda/original=true/00015-2885514572.png\",\n    ),\n\n    \"model\"  : {\n        \"description\": \"<p>The strength of Lora is recommended to be around 1.0.</p>\",\n        \"id\"         : 703211,\n        \"minor\"      : False,\n        \"name\"       : \"メイド　クラシック/maid classic\",\n        \"nsfwLevel\"  : 1,\n        \"type\"       : \"LORA\",\n    },\n    \"user\"   : {\n        \"image\"   : None,\n        \"username\": \"bolero537\"\n    },\n    \"file\"   : {\n        \"uuid\": str,\n    },\n    \"version\": dict,\n    \"num\"    : range(1, 3),\n},\n\n{\n    \"#url\"    : \"https://civitai.com/models/703211?modelVersionId=786644\",\n    \"#comment\": \"model version ID\",\n    \"#class\"  : civitai.CivitaiModelExtractor,\n    \"#results\": (\n        \"https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/52b6efa7-801c-4901-90b4-fa3964d23480/original=true/00004-822988489.png\",\n        \"https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/c4d3bcd5-0e23-4f4e-9f34-d13b2f2bf14c/original=true/00005-1059918744.png\",\n        \"https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/68568d22-c4f3-45cb-ac32-82f1cedf968f/original=true/00006-3467286319.png\",\n    ),\n\n    \"version\": {\n        \"baseModel\"   : \"Pony\",\n        \"createdAt\"   : \"2024-08-30T15:28:47.661Z\",\n        \"date\"        : \"dt:2024-08-30 15:28:47\",\n        \"files\"       : list,\n        \"id\"          : 786644,\n        \"name\"        : \"v1.0 pony\",\n    },\n    \"user\"   : {\n        \"image\"   : None,\n        \"username\": \"bolero537\"\n    },\n    \"file\"   : {\n        \"id\"  : {26887862, 26887856, 26887852},\n        \"uuid\": {\"52b6efa7-801c-4901-90b4-fa3964d23480\",\n                 \"c4d3bcd5-0e23-4f4e-9f34-d13b2f2bf14c\",\n                 \"68568d22-c4f3-45cb-ac32-82f1cedf968f\"},\n    },\n    \"model\"  : {\n        \"id\": 703211,\n    },\n    \"num\"    : range(1, 3),\n},\n\n{\n    \"#url\"  : \"https://civitai.com/images/26962948\",\n    \"#class\": civitai.CivitaiImageExtractor,\n    \"#options\"     : {\"quality\": \"w\", \"metadata\": True},\n    \"#results\"     : \"https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/69bf3279-df2c-4ec8-b795-479e9cd3db1b/w/00014-3150861441.png\",\n    \"#sha1_content\": \"a9a9d08f5fcdbc1e1eec7f203717f9df97b7a671\",\n\n    \"extension\": \"png\",\n    \"filename\" : \"00014-3150861441\",\n    \"file\": {\n        \"createdAt\": \"2024-08-31T01:11:47.021Z\",\n        \"date\"     : \"dt:2024-08-31 01:11:47\",\n        \"hash\"     : \"ULN0-w?b4nRjxGM{-;t7M_t7NGae~qRjMyt7\",\n        \"width\"    : 1152,\n        \"height\"   : 1536,\n        \"id\"       : 26962948,\n        \"nsfwLevel\": 1,\n        \"postId\"   : 6030721,\n        \"stats\"    : dict,\n        \"url\"      : \"69bf3279-df2c-4ec8-b795-479e9cd3db1b\",\n        \"uuid\"     : \"69bf3279-df2c-4ec8-b795-479e9cd3db1b\",\n    },\n    \"user\"     : {\n        \"username\": \"bolero537\",\n    },\n    \"generation\": {\n        \"canRemix\"  : True,\n        \"external\"  : None,\n        \"resources\" : list,\n        \"techniques\": [],\n        \"tools\"     : [],\n        \"meta\"      : {\n            \"Denoising strength\": \"0.4\",\n            \"Model\"         : \"boleromix_XL_V1.3\",\n            \"Model hash\"    : \"afaf521da2\",\n            \"Size\"          : \"1152x1536\",\n            \"Tiled Diffusion scale factor\": \"1.5\",\n            \"Tiled Diffusion upscaler\": \"R-ESRGAN 4x+ Anime6B\",\n            \"VAE\"           : \"sdxl_vae.safetensors\",\n            \"Version\"       : \"v1.7.0\",\n            \"cfgScale\"      : 7,\n            \"negativePrompt\": \"negativeXL_D,(worst quality,extra legs,extra arms,extra ears,bad fingers,extra fingers,bad anatomy, missing fingers, lowres,username, artist name, text,pubic hair,bar censor,censored,multipul angle,split view,realistic,3D:1)\",\n            \"prompt\"        : \"masterpiece,ultra-detailed,best quality,8K,illustration,cute face,clean skin ,shiny hair,girl,ultra-detailed-eyes,simple background, <lora:add-detail-xl:1> <lora:classic maid_XL_V1.0:1> maid, maid apron, maid headdress, long sleeves,tray,tea,cup,skirt lift\",\n            \"resources\"     : list,\n            \"sampler\"       : \"DPM++ 2M Karras\",\n            \"seed\"          : 3150861441,\n            \"steps\"         : 20,\n            \"hashes\"        : {\n                \"lora:add-detail-xl\": \"9c783c8ce46c\",\n                \"lora:classic maid_XL_V1.0\": \"e8f6e4297112\",\n                \"model\": \"afaf521da2\",\n                \"vae\": \"735e4c3a44\",\n            },\n            \"TI hashes\"     : {\n                \"negativeXL_D\": \"fff5d51ab655\",\n            },\n        },\n    },\n    \"post\": {\n        \"id\": 6030721,\n        \"nsfwLevel\": 1,\n        \"title\": \"メイド　クラシック/maid classic - v1.0 XL Showcase\",\n        \"detail\": None,\n        \"modelVersionId\": 788385,\n        \"modelVersion\": {\n            \"id\": 788385,\n        },\n        \"publishedAt\": \"2024-08-31T01:11:52.175Z\",\n        \"availability\": \"Public\",\n        \"tags\": [],\n        \"collectionId\": None,\n    },\n    \"tags[*]\": {\n        \"automated\"  : bool,\n        \"concrete\"   : bool,\n        \"downVotes\"  : int,\n        \"id\"         : int,\n        \"lastUpvote\" : None,\n        \"name\"       : str,\n        \"needsReview\": bool,\n        \"nsfwLevel\"  : 1,\n        \"score\"      : int,\n        \"type\"       : {\"Label\", \"UserGenerated\"},\n        \"upVotes\"    : int,\n    },\n    \"model\": {\n        \"id\": 703211,\n        \"name\": \"メイド　クラシック/maid classic\",\n        \"type\": \"LORA\",\n        \"status\": \"Published\",\n        \"publishedAt\": \"2024-08-30T15:38:14.770Z\",\n        \"nsfw\": False,\n        \"uploadType\": \"Created\",\n        \"availability\": \"Public\",\n    },\n    \"version\": {\n        \"id\": 788385,\n        \"name\": \"v1.0 XL\",\n        \"description\": None,\n        \"baseModel\": \"SDXL 1.0\",\n        \"baseModelType\": \"Standard\",\n        \"earlyAccessConfig\": None,\n        \"earlyAccessEndsAt\": None,\n        \"trainedWords\": [\n            \"maid, maid apron, maid headdress, long sleeves\",\n        ],\n        \"epochs\": None,\n        \"steps\": None,\n        \"clipSkip\": None,\n        \"status\": \"Published\",\n        \"createdAt\": \"2024-08-31T01:11:08.841Z\",\n        \"vaeId\": None,\n        \"trainingDetails\": None,\n        \"trainingStatus\": None,\n        \"uploadType\": \"Created\",\n        \"usageControl\": \"Download\",\n        \"requireAuth\": True,\n        \"settings\": {\n            \"strength\": 0.8,\n        },\n        \"recommendedResources\": [],\n        \"monetization\": None,\n        \"canGenerate\": True,\n        \"files\": None,\n    },\n},\n\n{\n    \"#url\"    : \"https://civitai.com/images/44789630\",\n    \"#comment\": \"video - 'post' metadata (#7548)\",\n    \"#class\"  : civitai.CivitaiImageExtractor,\n    \"#options\": {\"metadata\": \"post\"},\n    \"#results\": \"https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/6a09ec54-6de4-4af1-b11d-2d0d8a66d651/quality=100/copy_C6C532CE-EC47-4A52-9138-AEF1D7756F16.Mp4\",\n\n    \"extension\": \"mp4\",\n    \"filename\" : \"copy_C6C532CE-EC47-4A52-9138-AEF1D7756F16\",\n    \"file\"     : {\n        \"date\"     : \"dt:2024-12-10 19:19:14\",\n        \"hash\"     : \"U9D8%cIU03Rk02?F$$WE0gs,?GSg~B9ut6sl\",\n        \"width\"    : 1080,\n        \"height\"   : 1920,\n        \"id\"       : 44789630,\n        \"mimeType\" : \"video/mp4\",\n        \"nsfwLevel\": 2,\n        \"postId\"   : 10151863,\n        \"stats\"    : dict,\n        \"type\"     : \"video\",\n        \"url\"      : \"6a09ec54-6de4-4af1-b11d-2d0d8a66d651\",\n        \"uuid\"     : \"6a09ec54-6de4-4af1-b11d-2d0d8a66d651\",\n        \"metadata\" : {\n            \"audio\"   : True,\n            \"duration\": 15.033,\n            \"hash\"    : \"U9D8%cIU03Rk02?F$$WE0gs,?GSg~B9ut6sl\",\n            \"height\"  : 1920,\n            \"size\"    : 23984479,\n            \"width\"   : 1080,\n        },\n    },\n    \"post\": {\n        \"availability\": \"Public\",\n        \"collectionId\": None,\n        \"date\"        : \"dt:2024-12-10 19:20:51\",\n        \"detail\"      : None,\n        \"id\"          : 10151863,\n        \"modelVersion\": None,\n        \"modelVersionId\": None,\n        \"nsfwLevel\"   : 2,\n        \"publishedAt\" : \"2024-12-10T19:20:51.579Z\",\n        \"tags\"        : [],\n        \"title\"       : None,\n    },\n    \"user\"     : {\n        \"username\": \"jboogx_creative\",\n    },\n},\n\n{\n    \"#url\"  : \"https://civitai.com/images/74353746\",\n    \"#comment\": \"video, rated 'R', WebP download (#7502)\",\n    \"#class\": civitai.CivitaiImageExtractor,\n    \"#results\": \"https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/c7e3744b-8f0d-4124-94c1-75e2af00431d/quality=100/2025-04-25-23h40m21s_seed665048144_A man appears from off screen and spanks her butto_2.webm\",\n\n    \"extension\": \"webm\",\n    \"filename\" : \"2025-04-25-23h40m21s_seed665048144_A man appears from off screen and spanks her butto_2\",\n    \"file\"     : {\n        \"date\"     : \"dt:2025-05-05 12:27:28\",\n        \"hash\"     : \"UMCsEoRPivxY~VjuWBoenMWBx]WrxvV?xvbb\",\n        \"width\"    : 512,\n        \"height\"   : 752,\n        \"id\"       : 74353746,\n        \"mimeType\" : \"video/webm\",\n        \"nsfwLevel\": 4,\n        \"postId\"   : 16509805,\n        \"stats\"    : dict,\n        \"type\"     : \"video\",\n        \"url\"      : \"c7e3744b-8f0d-4124-94c1-75e2af00431d\",\n        \"uuid\"     : \"c7e3744b-8f0d-4124-94c1-75e2af00431d\",\n        \"metadata\" : {\n            \"audio\"   : False,\n            \"duration\": 5.016,\n            \"hash\"    : \"UMCsEoRPivxY~VjuWBoenMWBx]WrxvV?xvbb\",\n            \"height\"  : 752,\n            \"size\"    : 6011344,\n            \"skipScannedAtReassignment\": True,\n            \"width\"   : 512,\n        },\n    },\n    \"user\"     : {\n        \"id\"      : 4856161,\n        \"username\": \"VlrgRomNS\",\n    },\n},\n\n{\n    \"#url\"    : \"https://civitai.com/images/76635747\",\n    \"#comment\": \"no 'modelVersionId' (#7432)\",\n    \"#class\"  : civitai.CivitaiImageExtractor,\n    \"#options\": {\"metadata\": \"version\"},\n    \"#results\": \"https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/65c1a01c-2583-4495-b4e9-bdb94218004e/original=true/5b5b95f8-9923-4c27-b50a-c801c0311375-0.jpg\",\n\n    \"model\"  : None,\n    \"version\": None,\n},\n\n{\n    \"#url\"    : \"https://civitai.com/images/68947296\",\n    \"#comment\": \"rated R / nsfwlevel 4\",\n    \"#class\"  : civitai.CivitaiImageExtractor,\n    \"#results\": \"https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/2d1fbe1b-6038-479f-8c37-39d338198fb1/quality=100/received_687641707052140.mp4\",\n\n    \"file\": {\n        \"nsfwLevel\": 4,\n    },\n},\n\n{\n    \"#url\"    : \"https://civitai.com/images/68852050\",\n    \"#comment\": \"rated X / nsfwlevel 8\",\n    \"#class\"  : civitai.CivitaiImageExtractor,\n    \"#results\": \"https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/1107208c-14cc-46fd-848d-2efa14fa6180/original=true/QRQC7HE5DFW3QZ85R3MXQXY440.jpeg\",\n\n    \"file\": {\n        \"nsfwLevel\": 8,\n    },\n},\n\n{\n    \"#url\"    : \"https://civitai.com/images/68851932\",\n    \"#comment\": \"rated XXX / nsfwlevel 16\",\n    \"#class\"  : civitai.CivitaiImageExtractor,\n    \"#results\": \"https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/fdbaa27d-4278-496b-8209-21591e5dc6fe/original=true/Q8AE16QCMCYCCBX49PG8VVWWD0.jpeg\",\n\n    \"file\": {\n        \"nsfwLevel\": 16,\n    },\n},\n\n{\n    \"#url\"    : \"https://civitai.com/posts/6877551\",\n    \"#class\"  : civitai.CivitaiPostExtractor,\n    \"#options\": {\"metadata\": \"generation\"},\n    \"#results\": (\n        \"https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/6220fa0f-9037-4b1d-bfbd-a740a06eeb7c/original=true/30748752.png\",\n        \"https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/cd1edb7f-7b50-4da5-bf23-d38f24d8aef0/original=true/30748747.png\",\n        \"https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/cfd5b231-accd-49bd-8bde-370880f63aa6/original=true/30748733.png\",\n    ),\n\n    \"post\": {\n        \"id\"  : 6877551,\n        \"date\": \"dt:2024-09-22 12:54:15\",\n    },\n    \"file\": {\n        \"id\"  : {30748752, 30748747, 30748733},\n        \"date\": \"dt:2024-09-22 12:54:15\",\n        \"uuid\": {\"6220fa0f-9037-4b1d-bfbd-a740a06eeb7c\",\n                 \"cd1edb7f-7b50-4da5-bf23-d38f24d8aef0\",\n                 \"cfd5b231-accd-49bd-8bde-370880f63aa6\"},\n        \"generation\": {\n            \"resources\" : list,\n            \"techniques\": [],\n            \"tools\"     : [],\n            \"meta\"      : {\n                \"prompt\"        : str,\n                \"negativePrompt\": str,\n            },\n        },\n    },\n},\n\n{\n    \"#url\"    : \"https://civitai.com/posts/17021768\",\n    \"#comment\": \"no 'modelVersionId' (#7432)\",\n    \"#class\"  : civitai.CivitaiPostExtractor,\n    \"#options\": {\"metadata\": \"version\"},\n\n    \"model\"  : None,\n    \"version\": None,\n},\n\n{\n    \"#url\"     : \"https://civitai.com/posts/20403514\",\n    \"#comment\" : \"mixed image & video (#8053)\",\n    \"#class\"   : civitai.CivitaiPostExtractor,\n    \"#results\" : (\n        \"https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/62dbebbe-48e9-4232-b4da-33c70d19683d/original=true/91967659.png\",\n        \"https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/e5786ddc-29b3-4a69-aec9-fba4dc2c78b5/quality=100/91967639.webm\",\n    ),\n},\n\n{\n    \"#url\"  : \"https://civitai.com/tag/mecha\",\n    \"#class\": civitai.CivitaiTagExtractor,\n},\n\n{\n    \"#url\"  : \"https://civitai.com/images?tags=482\",\n    \"#class\": civitai.CivitaiImagesExtractor,\n},\n\n{\n    \"#url\"  : \"https://civitai.com/images?modelVersionId=786644\",\n    \"#class\": civitai.CivitaiImagesExtractor,\n},\n\n{\n    \"#url\"     : \"https://civitai.com/videos\",\n    \"#class\"   : civitai.CivitaiVideosExtractor,\n},\n\n{\n    \"#url\"     : \"https://civitai.com/videos?tags=5169\",\n    \"#class\"   : civitai.CivitaiVideosExtractor,\n},\n\n{\n    \"#url\"  : \"https://civitai.com/models\",\n    \"#class\": civitai.CivitaiModelsExtractor,\n},\n\n{\n    \"#url\"  : \"https://civitai.com/search/models?sortBy=models_v9&query=Voynich\",\n    \"#class\": civitai.CivitaiSearchModelsExtractor,\n    \"#results\": (\n        \"https://civitai.com/models/99868\",\n        \"https://civitai.com/models/341330\",\n        \"https://civitai.com/models/884509\",\n        \"https://civitai.com/models/1003064\",\n    ),\n},\n\n{\n    \"#url\"    : \"https://civitai.com/search/images?sortBy=images_v6&query=Voynich\",\n    \"#class\"  : civitai.CivitaiSearchImagesExtractor,\n    \"#options\": {\"nsfw\": False},\n    \"#count\"  : range(150, 200),\n    \"#archive\": False,\n},\n\n{\n    \"#url\"  : \"https://civitai.com/user/waomodder\",\n    \"#class\": civitai.CivitaiUserExtractor,\n    \"#results\": (\n        \"https://civitai.com/user/waomodder/images\",\n        \"https://civitai.com/user/waomodder/videos\",\n    ),\n},\n\n{\n    \"#url\"  : \"https://civitai.com/user/waomodder/models\",\n    \"#class\": civitai.CivitaiUserModelsExtractor,\n    \"#pattern\": civitai.CivitaiModelExtractor.pattern,\n    \"#count\"  : \">= 8\",\n},\n\n{\n    \"#url\"    : \"https://civitai.com/user/waomodder/models?tag=character&types=Checkpoint&types=TextualInversion&types=Hypernetwork&types=LORA&checkpointType=Trained&fileFormats=SafeTensor&fileFormats=PickleTensor\",\n    \"#comment\": \"various filters (#7138)\",\n    \"#class\"  : civitai.CivitaiUserModelsExtractor,\n    \"#results\": (\n        \"https://civitai.com/models/42166\",\n        \"https://civitai.com/models/79845\",\n        \"https://civitai.com/models/81424\",\n        \"https://civitai.com/models/75925\",\n        \"https://civitai.com/models/65818\",\n        \"https://civitai.com/models/64272\",\n    ),\n},\n\n{\n    \"#url\"  : \"https://civitai.com/user/waomodder/posts\",\n    \"#class\": civitai.CivitaiUserPostsExtractor,\n    \"#pattern\": r\"https://image\\.civitai\\.com/xG1nkqKTMzGDvpLrqFT7WA/[0-9a-f-]+/original=true/\\S+\\.(jpe?g|png)\",\n    \"#range\"  : \"1-50\",\n    \"#count\"  : 50,\n\n    \"file\": {\n        \"id\"  : int,\n        \"date\": \"type:datetime\",\n    },\n    \"post\": {\n        \"id\"  : int,\n        \"date\": \"type:datetime\",\n    },\n},\n\n{\n    \"#url\"      : \"https://civitai.com/user/jackietop515100/posts\",\n    \"#comment\"  : \"deleted user (#8299)\",\n    \"#class\"    : civitai.CivitaiUserPostsExtractor,\n    \"#options\"  : {\"timeout\": 5, \"retries\": 2},\n    \"#exception\": exception.HttpError,\n},\n\n{\n    \"#url\"  : \"https://civitai.com/user/waomodder/images\",\n    \"#class\": civitai.CivitaiUserImagesExtractor,\n    \"#pattern\": r\"https://image\\.civitai\\.com/xG1nkqKTMzGDvpLrqFT7WA/[0-9a-f-]+/original=true/\\S+\\.png\",\n    \"#range\"  : \"1-50\",\n    \"#count\"  : 50,\n},\n\n{\n    \"#url\"    : \"https://civitai.com/user/waomodder/images?tags=5132\",\n    \"#comment\": \"tags (#7138)\",\n    \"#class\"  : civitai.CivitaiUserImagesExtractor,\n    \"#results\": \"https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/8cc7c513-ba77-4444-a21f-7e3907d29a4e/original=true/982824.png\",\n},\n\n{\n    \"#url\"    : \"https://civitai.com/user/waomodder/images?sort=Most+Collected&period=AllTime&tags=6594&baseModels=Illustrious&baseModels=PixArt+a&baseModels=Other&baseModels=Pony&remixesOnly=false\",\n    \"#comment\": \"various filters (#7138)\",\n    \"#class\"  : civitai.CivitaiUserImagesExtractor,\n    \"#range\"  : \"1-3\",\n    \"#results\": (\n        \"https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/c44c116a-263b-457d-8fa8-cc3d7716a0aa/original=true/36800924.png\",\n        \"https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/0f6cf303-8b12-4401-914e-bff33371e9c6/original=true/36801099.png\",\n        \"https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/9a682316-e451-4b98-8873-cc6c2e2d39bb/original=true/36801079.png\",\n    ),\n},\n\n{\n    \"#url\"     : \"https://civitai.com/user/USER/images?section=reactions\",\n    \"#category\": (\"\", \"civitai\", \"reactions-images\"),\n    \"#class\"   : civitai.CivitaiUserImagesExtractor,\n    \"#auth\"    : True,\n    \"#results\" : (\n        \"https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/dd29c97a-1e95-4186-8df5-632736cbae79/original=true/00012-2489035818.png\",\n        \"https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/5c4efa68-bb58-47c5-a716-98cd0f51f047/original=true/00013-4238863814.png\",\n        \"https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/69bf3279-df2c-4ec8-b795-479e9cd3db1b/original=true/00014-3150861441.png\",\n    ),\n},\n\n{\n    \"#url\"     : \"https://civitai.com/user/USER/images?section=reactions\",\n    \"#category\": (\"\", \"civitai\", \"reactions-images\"),\n    \"#class\"   : civitai.CivitaiUserImagesExtractor,\n    \"#auth\"     : False,\n    \"#exception\": exception.AuthorizationError,\n},\n\n{\n    \"#url\"  : \"https://civitai.com/user/jboogx_creative/videos\",\n    \"#class\": civitai.CivitaiUserVideosExtractor,\n    \"#pattern\": r\"https://image\\.civitai\\.com/xG1nkqKTMzGDvpLrqFT7WA/[0-9a-f-]+/original=true/\\S+\\.mp4\",\n    \"#range\"  : \"1-50\",\n    \"#count\"  : 50,\n},\n\n{\n    \"#url\"     : \"https://civitai.com/user/USER/videos?section=reactions\",\n    \"#category\": (\"\", \"civitai\", \"reactions-videos\"),\n    \"#class\"   : civitai.CivitaiUserVideosExtractor,\n    \"#auth\"    : True,\n    \"#results\" : (\n        \"https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/6a09ec54-6de4-4af1-b11d-2d0d8a66d651/quality=100/copy_C6C532CE-EC47-4A52-9138-AEF1D7756F16.Mp4\",\n        \"https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/74cd3e71-7833-4e32-9724-b8d1702693be/quality=100/1_THANKSGIVING_CLAYMATION_TOPAZ.mp4\",\n    ),\n},\n\n{\n    \"#url\"     : \"https://civitai.com/user/USER/videos?section=reactions\",\n    \"#category\": (\"\", \"civitai\", \"reactions-videos\"),\n    \"#class\"   : civitai.CivitaiUserVideosExtractor,\n    \"#auth\"     : False,\n    \"#exception\": exception.AuthorizationError,\n},\n\n{\n    \"#url\"     : \"https://civitai.com/generate\",\n    \"#class\"   : civitai.CivitaiGeneratedExtractor,\n    \"#auth\"    : True,\n},\n\n{\n    \"#url\"     : \"https://civitai.com/collections/11035869\",\n    \"#class\"   : civitai.CivitaiCollectionExtractor,\n    \"#results\" : \"https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/9b9c64b4-60de-4a9c-becd-a386ecf3fa7a/original=true/DailyWorldMorphChallenge_Base_0003.png\",\n\n    \"filename\"       : \"DailyWorldMorphChallenge_Base_0003\",\n    \"extension\"      : \"png\",\n    \"url\"            : \"https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/9b9c64b4-60de-4a9c-becd-a386ecf3fa7a/original=true/DailyWorldMorphChallenge_Base_0003.png\",\n    \"collection\"     : {\n        \"availability\": \"Public\",\n        \"description\" : \"\",\n        \"id\"          : 11035869,\n        \"image\"       : None,\n        \"metadata\"    : {},\n        \"mode\"        : None,\n        \"name\"        : \"Trees, Bonsai, and so one\",\n        \"nsfw\"        : False,\n        \"nsfwLevel\"   : 1,\n        \"read\"        : \"Public\",\n        \"tags\"        : [],\n        \"type\"        : \"Image\",\n        \"userId\"      : 4831516,\n        \"write\"       : \"Private\",\n    },\n    \"file\"           : {\n        \"acceptableMinor\": False,\n        \"availability\"   : \"Public\",\n        \"blockedFor\"     : None,\n        \"cosmetic\"       : None,\n        \"createdAt\"      : \"2025-04-30T20:20:44.015Z\",\n        \"date\"           : \"dt:2025-04-30 20:20:44\",\n        \"hasMeta\"        : True,\n        \"hasPositivePrompt\": True,\n        \"hash\"           : \"UHEfvNWC?dof00oc4Uae$,ofV}WFxeWCxwWV\",\n        \"height\"         : 1152,\n        \"hideMeta\"       : False,\n        \"id\"             : 73339178,\n        \"index\"          : 1,\n        \"ingestion\"      : \"Scanned\",\n        \"metadata\"       : {\n            \"hash\"  : \"UHEfvNWC?dof00oc4Uae$,ofV}WFxeWCxwWV\",\n            \"height\": 1152,\n            \"size\"  : 1523677,\n            \"width\" : 896,\n        },\n        \"mimeType\"       : \"image/png\",\n        \"minor\"          : False,\n        \"modelVersionId\" : None,\n        \"modelVersionIds\": [],\n        \"modelVersionIdsManual\": [],\n        \"name\"           : \"DailyWorldMorphChallenge_Base_0003.png\",\n        \"needsReview\"    : None,\n        \"nsfwLevel\"      : 1,\n        \"onSite\"         : False,\n        \"poi\"            : False,\n        \"postId\"         : 16290779,\n        \"postTitle\"      : None,\n        \"publishedAt\"    : \"2025-04-30T20:23:40.409Z\",\n        \"reactions\"      : [],\n        \"remixOfId\"      : None,\n        \"scannedAt\"      : \"2025-04-30T20:20:48.072Z\",\n        \"sortAt\"         : \"2025-04-30T20:23:40.409Z\",\n        \"stats\"          : {\n            \"collectedCountAllTime\": 1,\n            \"commentCountAllTime\": 0,\n            \"cryCountAllTime\" : 1,\n            \"dislikeCountAllTime\": 0,\n            \"heartCountAllTime\": 1,\n            \"laughCountAllTime\": 0,\n            \"likeCountAllTime\": 5,\n            \"tippedAmountCountAllTime\": 0,\n            \"viewCountAllTime\": 0,\n        },\n        \"tagIds\"         : [\n            5248,\n            9143,\n            111839,\n            112019,\n            116352,\n            120250,\n            161904,\n            234268,\n        ],\n        \"tags\"           : None,\n        \"thumbnailUrl\"   : None,\n        \"type\"           : \"image\",\n        \"url\"            : \"9b9c64b4-60de-4a9c-becd-a386ecf3fa7a\",\n        \"uuid\"           : \"9b9c64b4-60de-4a9c-becd-a386ecf3fa7a\",\n        \"width\"          : 896,\n    },\n    \"user\"           : {\n        \"cosmetics\"     : list,\n        \"deletedAt\"     : None,\n        \"id\"            : 2624648,\n        \"image\"         : \"ce0f7d5e-cc4a-41e2-8587-75d823c85ce9\",\n        \"profilePicture\": None,\n        \"username\"      : \"AIArtsChannel\",\n    },\n    \"user_collection\": {\n        \"cosmetics\"     : [],\n        \"deletedAt\"     : None,\n        \"id\"            : 4831516,\n        \"image\"         : \"https://lh3.googleusercontent.com/a/ACg8ocKeClAsD6kmHOATnC4Li1PLYw9-J41LCaVHdzcLLGZi9ElNUQ=s96-c\",\n        \"profilePicture\": None,\n        \"username\"      : \"TettyCo\",\n    },\n},\n\n{\n    \"#url\"     : \"https://civitai.com/collections/11453135\",\n    \"#class\"   : civitai.CivitaiCollectionExtractor,\n    \"#count\"   : 12,\n\n    \"collection\"     : {\n        \"availability\": \"Public\",\n        \"description\" : \"\",\n        \"id\"          : 11453135,\n        \"image\"       : None,\n        \"metadata\"    : {},\n        \"mode\"        : None,\n        \"name\"        : \"Sakura Trees\",\n        \"nsfw\"        : False,\n        \"nsfwLevel\"   : 3,\n        \"read\"        : \"Public\",\n        \"tags\"        : [],\n        \"type\"        : \"Image\",\n        \"userId\"      : 8511981,\n        \"write\"       : \"Private\",\n    },\n},\n\n{\n    \"#url\"     : \"https://civitai.com/user/SakuraCherryBlossoms/collections\",\n    \"#class\"   : civitai.CivitaiUserCollectionsExtractor,\n    \"#results\" : (\n        \"https://civitai.com/collections/11462456\",\n        \"https://civitai.com/collections/11453431\",\n        \"https://civitai.com/collections/11453135\",\n        \"https://civitai.com/collections/11407164\",\n        \"https://civitai.com/collections/11405046\",\n        \"https://civitai.com/collections/11395523\",\n        \"https://civitai.com/collections/11395467\",\n    ),\n},\n\n)\n"
  },
  {
    "path": "test/results/comedywildlifephoto.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import comedywildlifephoto\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://www.comedywildlifephoto.com/gallery/finalists/2024_finalists.php\",\n    \"#class\"   : comedywildlifephoto.ComedywildlifephotoGalleryExtractor,\n    \"#pattern\" : r\"https://www\\.comedywildlifephoto\\.com/images/gallery/\\d/000017\\d\\d_p\\.webp\",\n    \"#count\"   : 44,\n\n    \"count\"      : 44,\n    \"num\"        : range(1, 44),\n    \"description\": \"<p>Here are the finalists from the 2024 Comedy Wildlife Photography Awards competition. Winners will be announced on the 10th of December 2024. Voting for the People's Choice Award runs from 26th September until 31st October.</p>\",\n    \"caption\"    : str,\n    \"filename\"   : str,\n    \"extension\"  : \"webp\",\n    \"width\"      : range(750, 1600),\n    \"height\"     : range(750, 1600),\n    \"section\"    : \"Gallery of Winners and Finalists\",\n    \"title\"      : \"2024 Finalists\",\n},\n\n{\n    \"#url\"     : \"https://www.comedywildlifephoto.com/gallery/finalists/2022_finalists.php\",\n    \"#comment\" : \"empty 'description'\",\n    \"#class\"   : comedywildlifephoto.ComedywildlifephotoGalleryExtractor,\n    \"#range\"   : \"4\",\n    \"#results\" : \"https://www.comedywildlifephoto.com/images/gallery/9/00001169_p.jpg\",\n\n    \"count\"      : 43,\n    \"num\"        : 4,\n    \"description\": \"\",\n    \"caption\"    : \"\",\n    \"filename\"   : \"00001169_p\",\n    \"extension\"  : \"jpg\",\n    \"width\"      : 1600,\n    \"height\"     : 900,\n    \"section\"    : \"Gallery of Winners and Finalists\",\n    \"title\"      : \"2022 Finalists\",\n},\n\n)\n"
  },
  {
    "path": "test/results/comick.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import comick\n\n\n__tests__ = (\n\n{\n    \"#url\"     : \"https://comick.io/comic/neko-no-oshigoto/L7TaJB4n-chapter-10.5-en\",\n    \"#class\"   : comick.ComickChapterExtractor,\n    \"#results\" : (\n        \"https://meo.comick.pictures/0-ThqKs6imRo5oK.webp\",\n        \"https://meo.comick.pictures/1-NHbu8c09NjBCv.webp\",\n        \"https://meo.comick.pictures/2-i88d48rBYweD0.webp\",\n        \"https://meo.comick.pictures/3-AKTIcb3vjy3Lf.webp\",\n        \"https://meo.comick.pictures/4-N8Vj2XVdYY4Pv.webp\",\n    ),\n\n    \"filename\" : str,\n    \"extension\": \"webp\",\n    \"width\"    : 1424,\n    \"height\"   : 2000,\n    \"size\"     : range(67460, 276608),\n    \"optimized\": {int, None},\n\n    \"volume\": 1,\n    \"chapter\": 10,\n    \"chapter_minor\": \".5\",\n    \"chapter_hid\": \"L7TaJB4n\",\n    \"chapter_id\": 4105343,\n    \"chapter_string\": \"L7TaJB4n-chapter-10.5-en\",\n    \"count\": 5,\n    \"page\" : range(1, 5),\n    \"date\": \"dt:2025-06-21 13:07:32\",\n    \"date_updated\": \"type:datetime\",\n    \"demographic\": \"Seinen\",\n    \"description\": \"Ever wondered what it would be like if your cat had to get a job? The cats in this book do every kind of occupation you can imagine, and they do it just the way they like. Feline chefs, dentists, wrestlers, detectives, opera singers and much more await in this hilarious full-color collection!\",\n    \"format\": \"Full Color\",\n    \"lang\": \"en\",\n    \"manga\": \"Neko no Oshigoto\",\n    \"manga_hid\": \"aHgHjCfY\",\n    \"manga_id\": 119004,\n    \"manga_slug\": \"neko-no-oshigoto\",\n    \"mature\": False,\n    \"origin\": \"ja\",\n    \"published\": 2023,\n    \"publisher\": (),\n    \"rank\": range(20_000, 40_000),\n    \"rating\": \"safe\",\n    \"score\": float,\n    \"status\": \"Ongoing\",\n    \"title\": \"\",\n\n    \"tags\"  : (),\n    \"artist\": [\"Pandania\"],\n    \"author\": [\"Pandania\"],\n    \"group\" : [\"Official\"],\n    \"theme\" : [\"Animals\"],\n\n    \"genre\" : [\n        \"Comedy\",\n        \"Slice of Life\",\n    ],\n    \"manga_titles\": [\n        \"ねこのおしごと\",\n        \"Cats With Jobs\",\n    ],\n    \"links\": {\n        \"al\"   : \"194211\",\n        \"amz\"  : \"https://www.amazon.co.jp/%E3%81%AD%E3%81%93%E3%81%AE%E3%81%8A%E3%81%97%E3%81%94%E3%81%A8-1-%E3%83%92%E3%83%BC%E3%83%AD%E3%83%BC%E3%82%BA%E3%82%B3%E3%83%9F%E3%83%83%E3%82%AF%E3%82%B9-%E3%82%8F%E3%81%84%E3%82%8B%E3%81%A9-%E3%81%B1%E3%82%93%E3%81%A0%E3%81%AB%E3%81%82/dp/4864681643\",\n        \"bw\"   : \"series/409260/list\",\n        \"ebj\"  : \"https://ebookjapan.yahoo.co.jp/books/754652/\",\n        \"engtl\": \"https://sevenseasentertainment.com/series/cats-with-jobs/\",\n    },\n},\n\n{\n    \"#url\"     : \"https://comick.io/comic/neko-no-oshigoto/L7TaJB4n-chapter-10.7-xy\",\n    \"#comment\" : \"redirect\",\n    \"#class\"   : comick.ComickChapterExtractor,\n    \"#pattern\" : r\"https://meo.comick.pictures/\\d-\\w+\\.webp\",\n\n    \"volume\"        : 1,\n    \"chapter\"       : 10,\n    \"chapter_minor\" : \".5\",\n    \"chapter_hid\"   : \"L7TaJB4n\",\n    \"chapter_id\"    : 4105343,\n    \"chapter_string\": \"L7TaJB4n-chapter-10.5-en\",\n},\n\n{\n    \"#url\"     : \"https://comick.io/comic/koko-jidai-ni-gomandatta-jou-sama-to-no-dousei-seikatsu-wa-igaito-igokochi-ga-warukunai/aPu5CgJA-chapter-3-vi\",\n    \"#class\"   : comick.ComickChapterExtractor,\n    \"#pattern\" : r\"https://meo.comick.pictures/\\d+-[\\w-]+\\.(jpg|png)\",\n    \"#count\"   : 20,\n\n    \"artist\": [\"Togawa Ritsu\"],\n    \"author\": [\"Dozaemon Misoneta\"],\n    \"volume\": 1,\n    \"chapter\": 3,\n    \"chapter_minor\": \"\",\n    \"chapter_hid\": \"aPu5CgJA\",\n    \"chapter_id\": 4043238,\n    \"chapter_string\": \"aPu5CgJA-chapter-3-vi\",\n    \"count\": 20,\n    \"date\": \"dt:2025-05-09 17:25:38\",\n    \"date_updated\": \"type:datetime\",\n    \"demographic\": \"Seinen\",\n    \"description\": str,\n    \"format\": \"Adaptation\",\n    \"genre\": [\n        \"Comedy\",\n        \"Drama\",\n        \"Mystery\",\n        \"Psychological\",\n        \"Romance\",\n    ],\n    \"group\": [\n        \"Dịch cho vui\",\n    ],\n    \"lang\": \"vi\",\n    \"links\": {\n        \"al\" : \"187656\",\n        \"amz\": \"https://www.amazon.co.jp/dp/B0F2SL834T\",\n        \"bw\" : \"series/520105\",\n        \"mal\": \"178605\",\n        \"raw\": \"https://manga.nicovideo.jp/comic/71123\",\n    },\n    \"manga\": \"Koko Jidai ni Gomandatta Jou sama to no Dousei Seikatsu wa Igaito Igokochi ga Warukunai\",\n    \"manga_hid\": \"oeb0Dydj\",\n    \"manga_id\": 114526,\n    \"manga_slug\": \"koko-jidai-ni-gomandatta-jou-sama-to-no-dousei-seikatsu-wa-igaito-igokochi-ga-warukunai\",\n    \"manga_titles\": \"len:list:4\",\n    \"mature\": True,\n    \"origin\": \"ja\",\n    \"published\": 2025,\n    \"publisher\": [\"Suiyoubi wa Mattari Dash X Comic\"],\n    \"rank\": range(100, 1000),\n    \"rating\": \"suggestive\",\n    \"score\": float,\n    \"status\": \"Ongoing\",\n    \"tags\" : list,\n    \"theme\": [\"School Life\"],\n    \"title\": \"Yamamoto \\\"Đuổi\\\" Hayashi Đi Ư!?\",\n},\n\n{\n    \"#url\"     : \"https://comick.io/comic/00-the-100-girlfriends-who-really-really-really-really-really-love-you/Zqu59ZKD\",\n    \"#comment\" : \"no chapter info (#7972)\",\n    \"#class\"   : comick.ComickChapterExtractor,\n    \"#pattern\" : r\"https://meo.comick.pictures/\\d+-[\\w-]+\\.(jpg|png)\",\n    \"#count\"   : 22,\n\n    \"artist\"        : [\"Nozawa Yukiko\"],\n    \"author\"        : [\"Nakamura Rikito\"],\n    \"chapter\"       : 0,\n    \"chapter_hid\"   : \"Zqu59ZKD\",\n    \"chapter_id\"    : 3437106,\n    \"chapter_minor\" : \"\",\n    \"chapter_string\": \"Zqu59ZKD\",\n    \"count\"         : 22,\n    \"date\"          : \"dt:2024-08-29 14:20:51\",\n    \"date_updated\"  : \"dt:2024-08-29 14:20:51\",\n    \"group\"         : [\"ENCHILADAS NO SEKAI\"],\n    \"lang\"          : \"es-419\",\n    \"manga\"         : \"The 100 Girlfriends Who Really, Really, Really, Really, Really Love You\",\n    \"manga_hid\"     : \"grNTmie1\",\n    \"manga_id\"      : 37955,\n    \"manga_slug\"    : \"00-the-100-girlfriends-who-really-really-really-really-really-love-you\",\n    \"published\"     : 2019,\n    \"publisher\"     : [\"Shueisha\"],\n    \"title\"         : \"MAI Y MOMOHA SE CONVIERTEN EN MAIDS CERTIFICADAS(TAL VEZ)\",\n    \"volume\"        : 0,\n},\n\n{\n    \"#url\"     : \"https://comick.io/comic/00-boku-no-hero-academia/0nJzK-volume-1-en\",\n    \"#comment\" : \"volume-only chapter (#8043)\",\n    \"#class\"   : comick.ComickChapterExtractor,\n    \"#pattern\" : r\"https://meo.comick.pictures/\\d+-[\\w-]+\\.(jpg|png)\",\n    \"#count\"   : 187,\n\n    \"manga\"         : \"Boku no Hero Academia\",\n    \"manga_hid\"     : \"q1hZ1dbv\",\n    \"manga_id\"      : 11359,\n    \"manga_slug\"    : \"00-boku-no-hero-academia\",\n    \"volume\"        : 1,\n    \"chapter\"       : 0,\n    \"chapter_hid\"   : \"0nJzK\",\n    \"chapter_id\"    : 2285787,\n    \"chapter_minor\" : \"\",\n    \"chapter_string\": \"0nJzK-volume-1-en\",\n    \"title\"         : \"\",\n    \"lang\"          : \"en\",\n    \"artist\"        : [\"Horikoshi Kouhei\"],\n    \"author\"        : [\"Horikoshi Kouhei\"],\n    \"group\"         : [\"Official\"],\n    \"count\"         : 187,\n    \"date\"          : \"dt:2022-10-08 06:07:50\",\n    \"date_updated\"  : \"dt:2025-01-01 18:56:24\",\n    \"demographic\"   : \"Shounen\",\n    \"extension\"     : {\"jpg\", \"png\"},\n    \"filename\"      : str,\n    \"width\"         : int,\n    \"height\"        : int,\n    \"mature\"        : True,\n    \"origin\"        : \"ja\",\n    \"published\"     : 2014,\n    \"rating\"        : \"safe\",\n    \"score\"         : float,\n    \"status\"        : \"Complete\",\n},\n\n{\n    \"#url\"     : \"https://comick.io/comic/00-fate-extra/eE2wOoqb-chapter-35-en\",\n    \"#comment\" : \"missing page data (#8054)\",\n    \"#class\"   : comick.ComickChapterExtractor,\n    \"#count\"   : 0,\n    \"#log\"     : \"eE2wOoqb-chapter-35-en: Broken Chapter (missing 'b2key' for all pages)\",\n},\n\n{\n    \"#url\"     : \"https://comick.io/comic/kobayashi-san-chi-no-maid-dragon\",\n    \"#comment\" : \"all chapters\",\n    \"#class\"   : comick.ComickMangaExtractor,\n    \"#pattern\" : comick.ComickChapterExtractor.pattern,\n    \"#count\"   : range(890, 1000),\n\n    \"volume\" : int,\n    \"chapter\": int,\n    \"chapter_minor\": str,\n    \"lang\"   : \"iso:639\",\n},\n\n{\n    \"#url\"     : \"https://comick.io/comic/kobayashi-san-chi-no-maid-dragon?lang=pt-br&group=Amadeus+Scans&chap-order=&date-order=1&page=3#chapter-header\",\n    \"#comment\" : \"query parameters\",\n    \"#class\"   : comick.ComickMangaExtractor,\n    \"#pattern\" : comick.ComickChapterExtractor.pattern,\n    \"#results\" : (\n        \"https://comick.io/comic/kobayashi-san-chi-no-maid-dragon/It8UGI_U-chapter-137-pt-br\",\n        \"https://comick.io/comic/kobayashi-san-chi-no-maid-dragon/YlHNac8_-chapter-138-pt-br\",\n        \"https://comick.io/comic/kobayashi-san-chi-no-maid-dragon/dnMuDUdy-chapter-139-pt-br\",\n        \"https://comick.io/comic/kobayashi-san-chi-no-maid-dragon/1oFGBeum-chapter-140-pt-br\",\n        \"https://comick.io/comic/kobayashi-san-chi-no-maid-dragon/_lIICVw3-chapter-141-pt-br\",\n    ),\n\n    \"lang\": \"pt-br\",\n},\n\n{\n    \"#url\"     : \"https://comick.io/comic/koko-jidai-ni-gomandatta-jou-sama-to-no-dousei-seikatsu-wa-igaito-igokochi-ga-warukunai?lang=vi\",\n    \"#comment\" : \"language filter\",\n    \"#class\"   : comick.ComickMangaExtractor,\n    \"#pattern\" : comick.ComickChapterExtractor.pattern,\n    \"#results\" : (\n        \"https://comick.io/comic/koko-jidai-ni-gomandatta-jou-sama-to-no-dousei-seikatsu-wa-igaito-igokochi-ga-warukunai/1ngqqSQg-chapter-1-vi\",\n        \"https://comick.io/comic/koko-jidai-ni-gomandatta-jou-sama-to-no-dousei-seikatsu-wa-igaito-igokochi-ga-warukunai/rSROPoui-chapter-2-vi\",\n        \"https://comick.io/comic/koko-jidai-ni-gomandatta-jou-sama-to-no-dousei-seikatsu-wa-igaito-igokochi-ga-warukunai/aPu5CgJA-chapter-3-vi\",\n        \"https://comick.io/comic/koko-jidai-ni-gomandatta-jou-sama-to-no-dousei-seikatsu-wa-igaito-igokochi-ga-warukunai/eQ26SPqi-chapter-4-vi\",\n        \"https://comick.io/comic/koko-jidai-ni-gomandatta-jou-sama-to-no-dousei-seikatsu-wa-igaito-igokochi-ga-warukunai/nhtKNBiS-chapter-5-vi\",\n        \"https://comick.io/comic/koko-jidai-ni-gomandatta-jou-sama-to-no-dousei-seikatsu-wa-igaito-igokochi-ga-warukunai/1ukj8pOy-chapter-6-vi\",\n    ),\n\n    \"lang\": \"vi\",\n},\n\n{\n    \"#url\"     : \"https://comick.io/comic/kobayashi-san-chi-no-maid-dragon\",\n    \"#comment\" : \"'lang' option (#7938)\",\n    \"#class\"   : comick.ComickMangaExtractor,\n    \"#options\" : {\"lang\": [\"fr\", \"id\"]},\n    \"#pattern\" : comick.ComickChapterExtractor.pattern,\n    \"#results\" : (\n        \"https://comick.io/comic/kobayashi-san-chi-no-maid-dragon/l0Mj1-chapter-1-id\",\n        \"https://comick.io/comic/kobayashi-san-chi-no-maid-dragon/xnkNn-chapter-1-fr\",\n        \"https://comick.io/comic/kobayashi-san-chi-no-maid-dragon/vw9Kn-chapter-2-id\",\n    ),\n\n    \"lang\": {\"fr\", \"id\"},\n},\n\n{\n    \"#url\"     : \"https://comick.io/comic/00-the-100-girlfriends-who-really-really-really-really-really-love-you?lang=es-419&chap-order=1&date-order=\",\n    \"#comment\" : \"chapter without chapter info (#7972)\",\n    \"#class\"   : comick.ComickMangaExtractor,\n    \"#range\"   : \"1-3\",\n    \"#results\" : (\n        \"https://comick.io/comic/00-the-100-girlfriends-who-really-really-really-really-really-love-you/Zqu59ZKD\",\n        \"https://comick.io/comic/00-the-100-girlfriends-who-really-really-really-really-really-love-you/y6kgR-chapter-1-es-419\",\n        \"https://comick.io/comic/00-the-100-girlfriends-who-really-really-really-really-really-love-you/wkMZr-chapter-1-es-419\",\n    ),\n\n    \"chapter\": {0, 1},\n    \"lang\"   : \"es-419\",\n},\n\n{\n    \"#url\"     : \"https://comick.io/comic/fate-type-redline?lang=en&group=BananaShiki\",\n    \"#comment\" : \"'group_name' is None for some chapters (#8045)\",\n    \"#class\"   : comick.ComickMangaExtractor,\n    \"#pattern\" : comick.ComickChapterExtractor.pattern,\n    \"#count\"   : range(50, 100),\n},\n\n{\n    \"#url\"     : \"https://comick.io/comic/q1hZ1dbv\",\n    \"#comment\" : \"volume-only 'chapters' (#8043)\",\n    \"#class\"   : comick.ComickMangaExtractor,\n    \"#range\"   : \"1-5\",\n    \"#results\" : (\n        \"https://comick.io/comic/00-boku-no-hero-academia/0nJzK-volume-1-en\",\n        \"https://comick.io/comic/00-boku-no-hero-academia/oBxML-volume-1-en\",\n        \"https://comick.io/comic/00-boku-no-hero-academia/lyq4r-volume-2-en\",\n        \"https://comick.io/comic/00-boku-no-hero-academia/wNJYr-volume-2-en\",\n        \"https://comick.io/comic/00-boku-no-hero-academia/nAv4E-volume-3-en\",\n    ),\n\n    \"volume\" : {1, 2, 3},\n    \"chapter\": 0,\n    \"chapter_minor\": \"\",\n    \"lang\"   : \"iso:639\",\n},\n\n{\n    \"#url\"     : \"https://comick.io/comic/neko-no-oshigoto/cover\",\n    \"#class\"   : comick.ComickCoversExtractor,\n    \"#results\" : (\n        \"https://meo.comick.pictures/l6wvkz.jpg\",\n        \"https://meo.comick.pictures/X8xRNp.jpg\",\n    ),\n\n    \"id\"          : {45687770, 45687771},\n    \"width\"       : {1053, 1055},\n    \"height\"      : 1500,\n    \"size\"        : int,\n    \"lang\"        : \"ja\",\n    \"extension\"   : \"jpg\",\n    \"manga\"       : \"Neko no Oshigoto\",\n    \"volume\"      : range(1, 2),\n    \"cover\"       : {\n        \"b2key\"      : str,\n        \"gpurl\"      : str,\n        \"h\"          : int,\n        \"id\"         : int,\n        \"is_primary\" : bool,\n        \"locale\"     : \"ja\",\n        \"md_comic_id\": int,\n        \"mdid\"       : \"iso:uuid\",\n        \"s\"          : int,\n        \"url\"        : \"iso:uuid\",\n        \"vol\"        : str,\n        \"w\"          : int,\n    },\n},\n\n{\n    \"#url\"     : \"https://comick.io/comic/01-sakamoto-days/cover\",\n    \"#class\"   : comick.ComickCoversExtractor,\n    \"#pattern\" : r\"https://meo\\.comick\\.pictures/\\w+\\.jpg\",\n    \"#count\"   : range(50, 80),\n\n    \"id\"    : int,\n    \"width\" : int,\n    \"height\": int,\n    \"volume\": range(1, 30),\n    \"lang\"  : {\"ja\", \"en\", \"fr\", \"pt-br\"},\n},\n\n)\n"
  },
  {
    "path": "test/results/comicvine.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import comicvine\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://comicvine.gamespot.com/jock/4040-5653/images/\",\n    \"#category\": (\"\", \"comicvine\", \"tag\"),\n    \"#class\"   : comicvine.ComicvineTagExtractor,\n    \"#pattern\" : r\"https://comicvine\\.gamespot\\.com/a/uploads/original/\\d+/\\d+/\\d+-.+\\.(jpe?g|png)\",\n    \"#count\"   : \">= 140\",\n},\n\n{\n    \"#url\"     : \"https://comicvine.gamespot.com/batman/4005-1699/images/?tag=Fan%20Art%20%26%20Cosplay\",\n    \"#category\": (\"\", \"comicvine\", \"tag\"),\n    \"#class\"   : comicvine.ComicvineTagExtractor,\n    \"#pattern\" : r\"https://comicvine\\.gamespot\\.com/a/uploads/original/\\d+/\\d+/\\d+-.+\",\n    \"#count\"   : \">= 400\",\n},\n\n)\n"
  },
  {
    "path": "test/results/coomer.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import kemono\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://coomer.st/onlyfans/user/alinity/post/125962203\",\n    \"#comment\" : \"coomer (#2100)\",\n    \"#category\": (\"\", \"coomer\", \"onlyfans\"),\n    \"#class\"   : kemono.KemonoPostExtractor,\n    \"#results\" : \"https://coomer.st/data/7d/3f/7d3fd9804583dc224968c0591163ec91794552b04f00a6c2f42a15b68231d5a8.jpg\",\n},\n\n{\n    \"#url\"     : \"https://coomer.su/onlyfans/user/alinity/post/125962203\",\n    \"#comment\" : \"legacy TLD\",\n    \"#category\": (\"\", \"coomer\", \"onlyfans\"),\n    \"#class\"   : kemono.KemonoPostExtractor,\n},\n\n{\n    \"#url\"     : \"https://coomer.party/onlyfans/user/alinity/post/125962203\",\n    \"#comment\" : \"legacy TLD\",\n    \"#category\": (\"\", \"coomer\", \"onlyfans\"),\n    \"#class\"   : kemono.KemonoPostExtractor,\n},\n\n{\n    \"#url\"     : \"https://coomer.party/onlyfans/user/alinity/post/125962203\",\n    \"#category\": (\"\", \"coomer\", \"onlyfans\"),\n    \"#class\"   : kemono.KemonoPostExtractor,\n    \"#results\" : \"https://coomer.st/data/7d/3f/7d3fd9804583dc224968c0591163ec91794552b04f00a6c2f42a15b68231d5a8.jpg\",\n},\n\n)\n"
  },
  {
    "path": "test/results/cyberdrop.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import cyberdrop\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://cyberdrop.cr/a/8uE0wQiK\",\n    \"#comment\" : \"\",\n    \"#category\": (\"lolisafe\", \"cyberdrop\", \"album\"),\n    \"#class\"   : cyberdrop.CyberdropAlbumExtractor,\n    \"#pattern\" : r\"https://k1-cd.cdn.gigachad-cdn.ru/api/file/d/d1R0vW80T4BRt\\?token=ey.+\",\n    \"#sha1_content\": \"0c8768055e4e20e7c7259608b67799171b691140\",\n\n    \"album_id\"     : \"8uE0wQiK\",\n    \"album_name\"   : \"\"\"test テスト \"&>\"\"\",\n    \"album_size\"   : 182,\n    \"auth_url\"     : \"https://api.cyberdrop.cr/api/file/auth/d1R0vW80T4BRt\",\n    \"count\"        : 1,\n    \"date\"         : \"dt:2023-11-26 00:00:00\",\n    \"description\"  : \"\"\"test テスト \"&>\"\"\",\n    \"extension\"    : \"png\",\n    \"filename\"     : \"test-ãã¹ã--22->-xsaMaIQA\",\n    \"id\"           : \"xsaMaIQA\",\n    \"name\"         : \"test-ãã¹ã--22->\",\n    \"num\"          : 1,\n    \"size\"         : 182,\n    \"slug\"         : \"d1R0vW80T4BRt\",\n    \"thumbnail_url\": \"https://api.cyberdrop.cr/api/proxy/thumb/d1R0vW80T4BRt\",\n    \"type\"         : \"image/png\",\n    \"url\"          : str,\n},\n\n{\n    \"#url\"     : \"https://cyberdrop.cr/a/HriMgbuf\",\n    \"#comment\" : \"\",\n    \"#category\": (\"lolisafe\", \"cyberdrop\", \"album\"),\n    \"#class\"   : cyberdrop.CyberdropAlbumExtractor,\n    \"#pattern\" : (\n        r\"https://k1-cd.cdn.gigachad-cdn.ru/api/file/d/rln0wNQSY5iuA\\?token=ey.+\",\n        r\"https://k1-cd.cdn.gigachad-cdn.ru/api/file/d/cxSKzcj7Wxrrd\\?token=ey.+\",\n        r\"https://k1-cd.cdn.gigachad-cdn.ru/api/file/d/urLPkBXGuNfEg\\?token=ey.+\",\n    ),\n\n    \"album_id\"     : \"HriMgbuf\",\n    \"album_name\"   : \"animations\",\n    \"album_size\"   : 1090519,\n    \"auth_url\"     : r\"re:https://api.cyberdrop.cr/api/file/auth/\\w+\",\n    \"count\"        : 3,\n    \"date\"         : \"dt:2023-11-26 00:00:00\",\n    \"description\"  : \"animated stuff\",\n    \"extension\"    : {\"gif\", \"webm\"},\n    \"filename\"     : str,\n    \"id\"           : str,\n    \"name\"         : str,\n    \"num\"          : range(1, 3),\n    \"size\"         : {798157, 143992, 147828},\n    \"slug\"         : str,\n    \"thumbnail_url\": r\"re:https://api.cyberdrop.cr/api/proxy/thumb/\\w+\",\n    \"type\"         : {\"image/gif\", \"video/webm\"},\n    \"url\"          : str,\n},\n\n{\n    \"#url\"     : \"https://cyberdrop.me/a/8uE0wQiK\",\n    \"#category\": (\"lolisafe\", \"cyberdrop\", \"album\"),\n    \"#class\"   : cyberdrop.CyberdropAlbumExtractor,\n},\n\n{\n    \"#url\"     : \"https://cyberdrop.cr/f/rln0wNQSY5iuA\",\n    \"#category\": (\"lolisafe\", \"cyberdrop\", \"media\"),\n    \"#class\"   : cyberdrop.CyberdropMediaExtractor,\n    \"#pattern\" : r\"https://k1-cd.cdn.gigachad-cdn.ru/api/file/d/rln0wNQSY5iuA\\?token=ey.+\",\n    \"#sha1_content\": \"a546bdbc07d07f8e2c53e49e99736d5206f4da23\",\n\n    \"album_id\"     : \"\",\n    \"album_name\"   : \"\",\n    \"album_size\"   : -1,\n    \"auth_url\"     : \"https://api.cyberdrop.cr/api/file/auth/rln0wNQSY5iuA\",\n    \"count\"        : 1,\n    \"description\"  : \"\",\n    \"extension\"    : \"gif\",\n    \"filename\"     : \"danbooru_133128_049ebb917bb57589bca19155271a4200-cxEAcXkc\",\n    \"id\"           : \"cxEAcXkc\",\n    \"name\"         : \"danbooru_133128_049ebb917bb57589bca19155271a4200\",\n    \"num\"          : 1,\n    \"size\"         : 143992,\n    \"slug\"         : \"rln0wNQSY5iuA\",\n    \"thumbnail_url\": \"https://api.cyberdrop.cr/api/proxy/thumb/rln0wNQSY5iuA\",\n    \"type\"         : \"image/gif\",\n    \"url\"          : r\"re:https://k1-cd.cdn.gigachad-cdn.ru/api/file/d/rln0wNQSY5iuA\\?token=ey.+\",\n},\n\n{\n    \"#url\"     : \"https://cyberdrop.me/f/lHYBt9VAluZf6\",\n    \"#category\": (\"lolisafe\", \"cyberdrop\", \"media\"),\n    \"#class\"   : cyberdrop.CyberdropMediaExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/cyberfile.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import cyberfile\nfrom gallery_dl import exception\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://cyberfile.me/bpfD\",\n    \"#class\"   : cyberfile.CyberfileFileExtractor,\n    \"#pattern\" : r\"https://p1.cyberfile.me/bpfD/Raindrops.mp4\\?download_token=[0-9a-f]{64}\",\n    \"#count\"   : 1,\n\n    \"date\"       : \"dt:2024-01-04 16:01:26\",\n    \"extension\"  : \"mp4\",\n    \"file_id\"    : \"bpfD\",\n    \"file_num\"   : 718677,\n    \"file_url\"   : str,\n    \"filename\"   : \"Raindrops\",\n    \"folder\"     : \"Videos\",\n    \"name\"       : \"Raindrops.mp4\",\n    \"size\"       : 3659530,\n    \"uploader\"   : \"barbarella\",\n    \"permissions\": [\n        \"View\",\n        \"Download\",\n    ],\n    \"tags\"       : [\n        \"raindrops\",\n        \"mp4\",\n    ],\n},\n\n{\n    \"#url\"     : \"https://cyberfile.me/7d79\",\n    \"#comment\" : \"password-protected\",\n    \"#class\"   : cyberfile.CyberfileFileExtractor,\n    \"#options\" : {\"password\": \"sample_pwd\"},\n    \"#pattern\" : r\"https://p1.cyberfile.me/7d79/Raindrops.mp4\\?download_token=[0-9a-f]{64}\",\n    \"#count\"   : 1,\n\n    \"date\"       : \"dt:2024-01-04 17:50:59\",\n    \"extension\"  : \"mp4\",\n    \"file_id\"    : \"7d79\",\n    \"file_num\"   : 718711,\n    \"file_url\"   : str,\n    \"filename\"   : \"Raindrops\",\n    \"folder\"     : \"Playlist Protected\",\n    \"name\"       : \"Raindrops.mp4\",\n    \"size\"       : 3659530,\n    \"tags\"       : [],\n    \"uploader\"   : \"barbarella\",\n    \"permissions\": [\n        \"View\",\n        \"Download\",\n    ],\n},\n\n{\n    \"#url\"     : \"https://cyberfile.me/7d79\",\n    \"#comment\" : \"password-protected\",\n    \"#class\"   : cyberfile.CyberfileFileExtractor,\n    \"#options\"  : {\"password\": \"abc\"},\n    \"#exception\": exception.AuthorizationError,\n},\n\n{\n    \"#url\"     : \"https://cyberfile.me/folder/82d0aab0853fdd13294171577081f4d8/Playlist\",\n    \"#class\"   : cyberfile.CyberfileFolderExtractor,\n    \"#results\" : (\n        \"https://cyberfile.me/7d76\",\n        \"https://cyberfile.me/7d77\",\n    ),\n\n    \"folder\"     : \"Playlist\",\n    \"folder_hash\": \"82d0aab0853fdd13294171577081f4d8\",\n    \"folder_num\" : 56050,\n},\n\n{\n    \"#url\"     : \"https://cyberfile.me/folder/1524a09fa9d773dcc88c841ed2e098c9/Playlist_Protected\",\n    \"#comment\" : \"password-protected\",\n    \"#class\"   : cyberfile.CyberfileFolderExtractor,\n    \"#options\" : {\"password\": \"sample_pwd\"},\n    \"#results\" : (\n        \"https://cyberfile.me/7d7a\",\n        \"https://cyberfile.me/7d79\",\n    ),\n\n    \"folder\"     : \"Playlist Protected\",\n    \"folder_hash\": \"1524a09fa9d773dcc88c841ed2e098c9\",\n    \"folder_num\" : 56051,\n},\n\n{\n    \"#url\"     : \"https://cyberfile.me/folder/1524a09fa9d773dcc88c841ed2e098c9/Playlist_Protected\",\n    \"#comment\" : \"password-protected\",\n    \"#class\"   : cyberfile.CyberfileFolderExtractor,\n    \"#options\"  : {\"password\": \"abc\"},\n    \"#exception\": exception.AuthorizationError,\n},\n\n{\n    \"#url\"     : \"https://cyberfile.me/folder/8b17bbfdf25fca19aa51176bd246c97c/Helena_Price_Onlyfans\",\n    \"#class\"   : cyberfile.CyberfileFolderExtractor,\n    \"#results\" : (\n        \"https://cyberfile.me/folder/c2cfdcfcf1a6e6e57de7bc948804b0fc/PICS\",\n        \"https://cyberfile.me/folder/bdc7c36e7d4dfdc3fb908a6d3fe1cae5/VIDEO\",\n    ),\n\n    \"folder\"     : \"Helena Price Onlyfans\",\n    \"folder_hash\": \"8b17bbfdf25fca19aa51176bd246c97c\",\n    \"folder_num\" : 18322,\n},\n\n{\n    \"#url\"     : \"https://cyberfile.me/shared/tao35avvfc\",\n    \"#class\"   : cyberfile.CyberfileSharedExtractor,\n},\n\n{\n    \"#url\"     : \"https://cyberfile.me/shared/l7zoinbctg\",\n    \"#class\"   : cyberfile.CyberfileSharedExtractor,\n    \"#results\" : (\n        \"https://cyberfile.me/gb3s\",\n        \"https://cyberfile.me/gb8m\"\n    ),\n},\n\n{\n    \"#url\"     : \"https://cyberfile.me/shared/wqpd9n0si5\",\n    \"#class\"   : cyberfile.CyberfileSharedExtractor,\n    \"#results\" : \"https://cyberfile.me/folder/9f611ebab76f363e4b818397c7828a73/CF_DSPRMTRS\",\n},\n\n)\n"
  },
  {
    "path": "test/results/danbooru.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import danbooru\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://danbooru.donmai.us/posts?tags=bonocho\",\n    \"#category\": (\"Danbooru\", \"danbooru\", \"tag\"),\n    \"#class\"   : danbooru.DanbooruTagExtractor,\n    \"#sha1_content\": \"b196fb9f1668109d7774a0a82efea3ffdda07746\",\n},\n\n{\n    \"#url\"     : \"https://danbooru.donmai.us/posts?tags=mushishi\",\n    \"#comment\" : \"test page transitions\",\n    \"#category\": (\"Danbooru\", \"danbooru\", \"tag\"),\n    \"#class\"   : danbooru.DanbooruTagExtractor,\n    \"#count\"   : \">= 300\",\n},\n\n{\n    \"#url\"     : \"https://danbooru.donmai.us/posts?tags=pixiv_id%3A1476533\",\n    \"#comment\" : \"'external' option (#1747)\",\n    \"#category\": (\"Danbooru\", \"danbooru\", \"tag\"),\n    \"#class\"   : danbooru.DanbooruTagExtractor,\n    \"#options\" : {\"external\": True},\n    \"#pattern\" : r\"https://i\\.pximg\\.net/img-original/img/2008/08/28/02/35/48/1476533_p0\\.jpg\",\n},\n\n{\n    \"#url\"     : \"https://hijiribe.donmai.us/posts?tags=bonocho\",\n    \"#category\": (\"Danbooru\", \"danbooru\", \"tag\"),\n    \"#class\"   : danbooru.DanbooruTagExtractor,\n},\n\n{\n    \"#url\"     : \"https://sonohara.donmai.us/posts?tags=bonocho\",\n    \"#category\": (\"Danbooru\", \"danbooru\", \"tag\"),\n    \"#class\"   : danbooru.DanbooruTagExtractor,\n},\n\n{\n    \"#url\"     : \"https://safebooru.donmai.us/posts?tags=bonocho\",\n    \"#category\": (\"Danbooru\", \"danbooru\", \"tag\"),\n    \"#class\"   : danbooru.DanbooruTagExtractor,\n},\n\n{\n    \"#url\"     : \"https://donmai.moe/posts?tags=bonocho\",\n    \"#category\": (\"Danbooru\", \"danbooru\", \"tag\"),\n    \"#class\"   : danbooru.DanbooruTagExtractor,\n},\n\n{\n    \"#url\"     : \"https://danbooru.donmai.us/posts?tags=parent%3A15750&z=5\",\n    \"#comment\" : \"'parent:…' results\",\n    \"#category\": (\"Danbooru\", \"danbooru\", \"tag\"),\n    \"#class\"   : danbooru.DanbooruTagExtractor,\n    \"#results\" : (\n        \"https://cdn.donmai.us/original/61/e4/61e447489b451dab5e521afd72f3401d.jpg\",\n        \"https://cdn.donmai.us/original/73/f6/73f689d6cf1f9c973d9a6dd0545f68b7.jpg\",\n        \"https://cdn.donmai.us/original/bb/a3/bba30cc7ff0f22614fffd6aa15a83c4f.jpg\",\n    ),\n},\n\n{\n    \"#url\"     : \"https://danbooru.donmai.us/pools/7659\",\n    \"#category\": (\"Danbooru\", \"danbooru\", \"pool\"),\n    \"#class\"   : danbooru.DanbooruPoolExtractor,\n    \"#auth\"    : False,\n    \"#sha1_content\": \"15226ba183579bc2cdd67260445b5c97959a3e82\",\n    \"#results\"     : (\n        \"https://cdn.donmai.us/original/d8/3d/d83df4af8c0fa6d6069f8d3cf0b7dd56.jpg\",\n        \"https://cdn.donmai.us/original/70/82/7082572fa74650f3c1f39152550cd724.jpg\",\n        \"https://cdn.donmai.us/original/7a/b5/7ab551d011b89155a743d81308de653a.jpg\",\n        \"https://cdn.donmai.us/original/fd/d3/fdd31d7feb62e2a90de950765aa556b9.jpg\",\n        \"https://cdn.donmai.us/original/e7/11/e71119c20e1f7f7be829c168786ba807.jpg\",\n        \"https://cdn.donmai.us/original/65/ae/65ae1865ebfe490a00039832cd0ce16a.jpg\",\n        \"https://cdn.donmai.us/original/c3/e8/c3e82e213e6fa83fbe9c00659114c2d6.jpg\",\n        \"https://cdn.donmai.us/original/58/64/586438b110b88aef5f2bf14c19e7102d.jpg\",\n    ),\n\n    \"num\" : range(1, 8),\n    \"pool\": {\n        \"category\"   : \"series\",\n        \"created_at\" : \"2013-11-08T21:44:54.588-05:00\",\n        \"description\": \"A comic by [[ken (koala)]] about a girl. Seems to be somewhat melancholy.\\r\\n\\r\\n(Also, this will probably be renamed if it gets translated, since the title doesn't make much sense as-is)\",\n        \"id\"         : 7659,\n        \"is_active\"  : False,\n        \"is_deleted\" : False,\n        \"name\"       : \"Original - ある○人の島 (Ken (Koala))\",\n        \"post_count\" : 8,\n        \"updated_at\" : \"2017-08-10T20:16:04.564-04:00\",\n    },\n},\n\n{\n    \"#url\"     : \"https://danbooru.donmai.us/pools/24413\",\n    \"#category\": (\"Danbooru\", \"danbooru\", \"pool\"),\n    \"#class\"   : danbooru.DanbooruPoolExtractor,\n    \"#options\" : {\"order-posts\": \"asc\"},\n    \"#results\" : (\n        \"https://cdn.donmai.us/original/c5/7b/c57b045fee282199277d0f94e298b9dc.jpg\",\n        \"https://cdn.donmai.us/original/44/6d/446d8b5db9b78694936408049745ee42.jpg\",\n        \"https://cdn.donmai.us/original/34/0c/340c721ceb7fce6892a234adf0bea811.jpg\",\n        \"https://cdn.donmai.us/original/47/c2/47c2c1ba1f7b83e0a487dbc7e722059f.jpg\",\n        \"https://cdn.donmai.us/original/30/3b/303bdefb719b54253aa7731cf11ef91f.jpg\",\n    ),\n},\n{\n    \"#url\"     : \"https://danbooru.donmai.us/pools/24413\",\n    \"#category\": (\"Danbooru\", \"danbooru\", \"pool\"),\n    \"#class\"   : danbooru.DanbooruPoolExtractor,\n    \"#options\" : {\"order-posts\": \"pool_desc\"},\n    \"#results\" : (\n        \"https://cdn.donmai.us/original/30/3b/303bdefb719b54253aa7731cf11ef91f.jpg\",\n        \"https://cdn.donmai.us/original/47/c2/47c2c1ba1f7b83e0a487dbc7e722059f.jpg\",\n        \"https://cdn.donmai.us/original/34/0c/340c721ceb7fce6892a234adf0bea811.jpg\",\n        \"https://cdn.donmai.us/original/44/6d/446d8b5db9b78694936408049745ee42.jpg\",\n        \"https://cdn.donmai.us/original/c5/7b/c57b045fee282199277d0f94e298b9dc.jpg\",\n    ),\n},\n{\n    \"#url\"     : \"https://danbooru.donmai.us/pools/24413\",\n    \"#category\": (\"Danbooru\", \"danbooru\", \"pool\"),\n    \"#class\"   : danbooru.DanbooruPoolExtractor,\n    \"#options\" : {\"order-posts\": \"id\"},\n    \"#results\" : (\n        \"https://cdn.donmai.us/original/30/3b/303bdefb719b54253aa7731cf11ef91f.jpg\",\n        \"https://cdn.donmai.us/original/44/6d/446d8b5db9b78694936408049745ee42.jpg\",\n        \"https://cdn.donmai.us/original/34/0c/340c721ceb7fce6892a234adf0bea811.jpg\",\n        \"https://cdn.donmai.us/original/47/c2/47c2c1ba1f7b83e0a487dbc7e722059f.jpg\",\n        \"https://cdn.donmai.us/original/c5/7b/c57b045fee282199277d0f94e298b9dc.jpg\",\n    ),\n},\n{\n    \"#url\"     : \"https://danbooru.donmai.us/pools/24413\",\n    \"#category\": (\"Danbooru\", \"danbooru\", \"pool\"),\n    \"#class\"   : danbooru.DanbooruPoolExtractor,\n    \"#options\" : {\"order-posts\": \"asc_id\"},\n    \"#results\" : (\n        \"https://cdn.donmai.us/original/c5/7b/c57b045fee282199277d0f94e298b9dc.jpg\",\n        \"https://cdn.donmai.us/original/47/c2/47c2c1ba1f7b83e0a487dbc7e722059f.jpg\",\n        \"https://cdn.donmai.us/original/34/0c/340c721ceb7fce6892a234adf0bea811.jpg\",\n        \"https://cdn.donmai.us/original/44/6d/446d8b5db9b78694936408049745ee42.jpg\",\n        \"https://cdn.donmai.us/original/30/3b/303bdefb719b54253aa7731cf11ef91f.jpg\",\n    ),\n},\n\n{\n    \"#url\"     : \"https://danbooru.donmai.us/pool/show/7659\",\n    \"#category\": (\"Danbooru\", \"danbooru\", \"pool\"),\n    \"#class\"   : danbooru.DanbooruPoolExtractor,\n},\n\n{\n    \"#url\"     : \"https://danbooru.donmai.us/favorite_groups/14\",\n    \"#category\": (\"Danbooru\", \"danbooru\", \"favgroup\"),\n    \"#class\"   : danbooru.DanbooruFavgroupExtractor,\n    \"#auth\"    : False,\n    \"#count\"   : 24,\n\n    \"favgroup\": {\n        \"created_at\": \"2015-06-29T13:59:10.808-04:00\",\n        \"creator_id\": 65304,\n        \"id\"        : 14,\n        \"is_public\" : True,\n        \"name\"      : \"Mecha\",\n        \"updated_at\": \"2019-07-07T07:21:50.677-04:00\",\n    },\n},\n\n{\n    \"#url\"     : \"https://danbooru.donmai.us/posts/294929\",\n    \"#category\": (\"Danbooru\", \"danbooru\", \"post\"),\n    \"#class\"   : danbooru.DanbooruPostExtractor,\n    \"#sha1_content\": \"5e255713cbf0a8e0801dc423563c34d896bb9229\",\n\n    \"approver_id\": None,\n    \"bit_flags\": 0,\n    \"created_at\": \"2008-08-12T00:46:05.385-04:00\",\n    \"date\": \"dt:2008-08-12 04:46:05\",\n    \"down_score\": 0,\n    \"extension\": \"jpg\",\n    \"fav_count\": range(9, 15),\n    \"file_ext\": \"jpg\",\n    \"file_size\": 358232,\n    \"file_url\": \"https://cdn.donmai.us/original/ac/8e/ac8e3b92ea328ce9cf7211e69c905bf9.jpg\",\n    \"filename\": \"ac8e3b92ea328ce9cf7211e69c905bf9\",\n    \"has_active_children\": False,\n    \"has_children\": False,\n    \"has_large\": True,\n    \"has_visible_children\": False,\n    \"id\": 294929,\n    \"image_height\": 687,\n    \"image_width\": 895,\n    \"is_banned\": False,\n    \"is_deleted\": False,\n    \"is_flagged\": False,\n    \"is_pending\": False,\n    \"large_file_url\": \"https://cdn.donmai.us/sample/ac/8e/sample-ac8e3b92ea328ce9cf7211e69c905bf9.jpg\",\n    \"last_comment_bumped_at\": None,\n    \"last_commented_at\": None,\n    \"last_noted_at\": None,\n    \"md5\": \"ac8e3b92ea328ce9cf7211e69c905bf9\",\n    \"media_asset\": dict,\n    \"parent_id\": None,\n    \"pixiv_id\": 1129835,\n    \"preview_file_url\": \"https://cdn.donmai.us/180x180/ac/8e/ac8e3b92ea328ce9cf7211e69c905bf9.jpg\",\n    \"rating\": \"s\",\n    \"score\": range(1, 5),\n    \"source\": \"https://i.pximg.net/img-original/img/2008/07/09/16/10/23/1129835_p0.jpg\",\n    \"subcategory\": \"post\",\n    \"tag_count\": range(32, 40),\n    \"tag_count_artist\": 1,\n    \"tag_count_character\": 3,\n    \"tag_count_copyright\": 3,\n    \"tag_count_general\": range(23, 30),\n    \"tag_count_meta\": 2,\n    \"tag_string\": \"2boys bat_(animal) batman batman_(series) black_bodysuit bodysuit bonocho brown_eyes card closed_mouth collared_shirt commentary_request copyright_name dc_comics expressionless facepaint glasgow_smile heath_ledger joker_(dc) male_focus multiple_boys outline outstretched_arm parted_lips photoshop_(medium) pink_shirt playing_card shirt sketch smile the_dark_knight upper_body white_outline wing_collar\",\n    \"tag_string_artist\": \"bonocho\",\n    \"tag_string_character\": \"batman heath_ledger joker_(dc)\",\n    \"tag_string_copyright\": \"batman_(series) dc_comics the_dark_knight\",\n    \"tag_string_general\": \"2boys bat_(animal) black_bodysuit bodysuit brown_eyes card closed_mouth collared_shirt copyright_name expressionless facepaint glasgow_smile male_focus multiple_boys outline outstretched_arm parted_lips pink_shirt playing_card shirt sketch smile upper_body white_outline wing_collar\",\n    \"tag_string_meta\": \"commentary_request photoshop_(medium)\",\n    \"tags\": [\n        \"2boys\",\n        \"bat_(animal)\",\n        \"batman\",\n        \"batman_(series)\",\n        \"black_bodysuit\",\n        \"bodysuit\",\n        \"bonocho\",\n        \"brown_eyes\",\n        \"card\",\n        \"closed_mouth\",\n        \"collared_shirt\",\n        \"commentary_request\",\n        \"copyright_name\",\n        \"dc_comics\",\n        \"expressionless\",\n        \"facepaint\",\n        \"glasgow_smile\",\n        \"heath_ledger\",\n        \"joker_(dc)\",\n        \"male_focus\",\n        \"multiple_boys\",\n        \"outline\",\n        \"outstretched_arm\",\n        \"parted_lips\",\n        \"photoshop_(medium)\",\n        \"pink_shirt\",\n        \"playing_card\",\n        \"shirt\",\n        \"sketch\",\n        \"smile\",\n        \"the_dark_knight\",\n        \"upper_body\",\n        \"white_outline\",\n        \"wing_collar\",\n    ],\n    \"tags_artist\": [\n        \"bonocho\",\n    ],\n    \"tags_character\": [\n        \"batman\",\n        \"heath_ledger\",\n        \"joker_(dc)\",\n    ],\n    \"tags_copyright\": [\n        \"batman_(series)\",\n        \"dc_comics\",\n        \"the_dark_knight\",\n    ],\n    \"tags_general\": [\n        \"2boys\",\n        \"bat_(animal)\",\n        \"black_bodysuit\",\n        \"bodysuit\",\n        \"brown_eyes\",\n        \"card\",\n        \"closed_mouth\",\n        \"collared_shirt\",\n        \"copyright_name\",\n        \"expressionless\",\n        \"facepaint\",\n        \"glasgow_smile\",\n        \"male_focus\",\n        \"multiple_boys\",\n        \"outline\",\n        \"outstretched_arm\",\n        \"parted_lips\",\n        \"pink_shirt\",\n        \"playing_card\",\n        \"shirt\",\n        \"sketch\",\n        \"smile\",\n        \"upper_body\",\n        \"white_outline\",\n        \"wing_collar\",\n    ],\n    \"tags_meta\": [\n        \"commentary_request\",\n        \"photoshop_(medium)\",\n    ],\n    \"up_score\": range(1, 5),\n    \"updated_at\": \"2024-03-24T13:25:30.456-04:00\",\n    \"uploader_id\": 67005,\n},\n\n{\n    \"#url\"     : \"https://danbooru.donmai.us/posts/3613024\",\n    \"#category\": (\"Danbooru\", \"danbooru\", \"post\"),\n    \"#class\"   : danbooru.DanbooruPostExtractor,\n    \"#options\" : {\"ugoira\": False},\n    \"#results\" : \"https://cdn.donmai.us/sample/5e/e5/sample-5ee54a2d95ed36376ec1d8f6ddbdece9.webm\",\n\n    \"!_ugoira_original\"  : ...,\n    \"!_ugoira_frame_data\": ...,\n},\n\n{\n    \"#url\"     : \"https://danbooru.donmai.us/posts/3613024\",\n    \"#category\": (\"Danbooru\", \"danbooru\", \"post\"),\n    \"#class\"   : danbooru.DanbooruPostExtractor,\n    \"#options\" : {\"ugoira\": True},\n    \"#results\" : \"https://cdn.donmai.us/original/5e/e5/5ee54a2d95ed36376ec1d8f6ddbdece9.zip\",\n\n    \"_ugoira_original\"     : False,\n    \"_ugoira_frame_data[*]\": {\n        \"file\" : r\"re:^0000\\d\\d\\.jpg$\",\n        \"delay\": int,\n    },\n},\n\n{\n    \"#url\"     : \"https://danbooru.donmai.us/post/show/294929\",\n    \"#category\": (\"Danbooru\", \"danbooru\", \"post\"),\n    \"#class\"   : danbooru.DanbooruPostExtractor,\n},\n\n{\n    \"#url\"     : \"https://danbooru.donmai.us/explore/posts/popular\",\n    \"#category\": (\"Danbooru\", \"danbooru\", \"popular\"),\n    \"#class\"   : danbooru.DanbooruPopularExtractor,\n},\n\n{\n    \"#url\"     : \"https://danbooru.donmai.us/explore/posts/popular?date=2013-06-06&scale=week\",\n    \"#category\": (\"Danbooru\", \"danbooru\", \"popular\"),\n    \"#class\"   : danbooru.DanbooruPopularExtractor,\n    \"#range\"   : \"1-120\",\n    \"#count\"   : 120,\n},\n\n{\n    \"#url\"     : \"https://danbooru.donmai.us/artists/288683\",\n    \"#category\": (\"Danbooru\", \"danbooru\", \"artist\"),\n    \"#class\"   : danbooru.DanbooruArtistExtractor,\n    \"#results\" : \"https://danbooru.donmai.us/posts?tags=kaori_%28vuoian_appxv%29\",\n\n    \"created_at\" : \"2022-05-12T16:00:40.852-04:00\",\n    \"updated_at\" : \"2022-05-12T22:10:51.917-04:00\",\n    \"group_name\" : \"\",\n    \"id\"         : 288683,\n    \"is_banned\"  : False,\n    \"is_deleted\" : False,\n    \"name\"       : \"kaori_(vuoian_appxv)\",\n    \"other_names\": [\n        \"香\",\n        \"vuoian_appxv\",\n    ],\n},\n\n{\n    \"#url\"     : \"https://danbooru.donmai.us/artists?commit=Search&search%5Bany_name_matches%5D=yu&search%5Border%5D=created_at\",\n    \"#category\": (\"Danbooru\", \"danbooru\", \"artist-search\"),\n    \"#class\"   : danbooru.DanbooruArtistSearchExtractor,\n    \"#pattern\" : danbooru.DanbooruTagExtractor.pattern,\n    \"#count\"   : \"> 50\",\n\n    \"created_at\" : str,\n    \"updated_at\" : str,\n    \"group_name\" : str,\n    \"id\"         : int,\n    \"is_banned\"  : bool,\n    \"is_deleted\" : bool,\n    \"name\"       : str,\n    \"other_names\": list,\n},\n\n{\n    \"#url\"     : \"https://danbooru.donmai.us/posts/random?tags=bonocho\",\n    \"#category\": (\"Danbooru\", \"danbooru\", \"random\"),\n    \"#class\"   : danbooru.DanbooruRandomExtractor,\n    \"#pattern\" : \"https://cdn.donmai.us/original/.+\",\n    \"#count\"   : 1,\n\n    \"search_tags\": \"bonocho\",\n},\n\n{\n    \"#url\"     : \"https://danbooru.donmai.us/posts/random\",\n    \"#category\": (\"Danbooru\", \"danbooru\", \"random\"),\n    \"#class\"   : danbooru.DanbooruRandomExtractor,\n    \"#pattern\" : \"https://cdn.donmai.us/original/.+\",\n    \"#count\"   : 1,\n\n    \"search_tags\": \"\",\n},\n\n{\n    \"#url\"     : \"https://danbooru.donmai.us/media_assets/35394309\",\n    \"#category\": (\"Danbooru\", \"danbooru\", \"media-asset\"),\n    \"#class\"   : danbooru.DanbooruMediaassetExtractor,\n    \"#results\" : \"https://cdn.donmai.us/original/4a/ae/4aae24ce57d92c1167910ab068c7514b.jpg\",\n\n    \"created_at\"     : \"2025-11-06T06:42:00.070-05:00\",\n    \"date\"           : \"dt:2025-11-06 11:42:00\",\n    \"duration\"       : None,\n    \"extension\"      : \"jpg\",\n    \"file_ext\"       : \"jpg\",\n    \"file_key\"       : \"PyWKmcv0U\",\n    \"file_size\"      : 416727,\n    \"file_url\"       : \"https://cdn.donmai.us/original/4a/ae/4aae24ce57d92c1167910ab068c7514b.jpg\",\n    \"filename\"       : \"4aae24ce57d92c1167910ab068c7514b\",\n    \"id\"             : 35394309,\n    \"image_height\"   : 2132,\n    \"image_width\"    : 1867,\n    \"is_public\"      : True,\n    \"md5\"            : \"4aae24ce57d92c1167910ab068c7514b\",\n    \"pixel_hash\"     : \"ecfbad5b0bf98b1e8faeef908aba8613\",\n    \"status\"         : \"active\",\n    \"tag_string\"     : \"\",\n    \"tag_string_artist\": \"\",\n    \"tag_string_character\": \"\",\n    \"tag_string_copyright\": \"\",\n    \"tag_string_general\": \"\",\n    \"tag_string_meta\": \"\",\n    \"tags\"           : (),\n    \"tags_artist\"    : (),\n    \"tags_character\" : (),\n    \"tags_copyright\" : (),\n    \"tags_general\"   : (),\n    \"tags_meta\"      : (),\n    \"updated_at\"     : \"2025-11-06T06:42:01.465-05:00\",\n    \"variants\"       : [\n        {\n            \"file_ext\": \"jpg\",\n            \"height\"  : 180,\n            \"type\"    : \"180x180\",\n            \"url\"     : \"https://cdn.donmai.us/180x180/4a/ae/4aae24ce57d92c1167910ab068c7514b.jpg\",\n            \"width\"   : 158,\n        },\n        {\n            \"file_ext\": \"jpg\",\n            \"height\"  : 360,\n            \"type\"    : \"360x360\",\n            \"url\"     : \"https://cdn.donmai.us/360x360/4a/ae/4aae24ce57d92c1167910ab068c7514b.jpg\",\n            \"width\"   : 315,\n        },\n        {\n            \"file_ext\": \"webp\",\n            \"height\"  : 720,\n            \"type\"    : \"720x720\",\n            \"url\"     : \"https://cdn.donmai.us/720x720/4a/ae/4aae24ce57d92c1167910ab068c7514b.webp\",\n            \"width\"   : 631,\n        },\n        {\n            \"file_ext\": \"jpg\",\n            \"height\"  : 971,\n            \"type\"    : \"sample\",\n            \"url\"     : \"https://cdn.donmai.us/sample/4a/ae/sample-4aae24ce57d92c1167910ab068c7514b.jpg\",\n            \"width\"   : 850,\n        },\n        {\n            \"file_ext\": \"jpg\",\n            \"height\"  : 2132,\n            \"type\"    : \"original\",\n            \"url\"     : \"https://cdn.donmai.us/original/4a/ae/4aae24ce57d92c1167910ab068c7514b.jpg\",\n            \"width\"   : 1867,\n        },\n    ],\n},\n\n)\n"
  },
  {
    "path": "test/results/dandadan.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import dandadan\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://dandadan.net/manga/dandadan-chapter-1/\",\n    \"#class\"   : dandadan.DandadanChapterExtractor,\n    \"#pattern\" : r\"https://cdn\\.readkakegurui\\.com/file/cdnpog/dandadan/chapter\\-1/\\d+\\.webp\",\n    \"#count\"   : 67,\n\n    \"chapter\"  : 1,\n    \"count\"    : 67,\n    \"page\"     : range(1, 67),\n    \"extension\": \"webp\",\n    \"lang\"     : \"en\",\n    \"manga\"    : \"Dandadan\",\n},\n\n{\n    \"#url\"     : \"https://dandadan.net/manga/dandadan-chapter-40-5-3/\",\n    \"#class\"   : dandadan.DandadanChapterExtractor,\n    \"#pattern\" : r\"https://cdn\\.readkakegurui\\.com/file/cdnpog/dandadan/chapter\\-40\\.5/\\d+\\.webp\",\n    \"#count\"   : 35,\n\n    \"chapter\"      : 40,\n    \"chapter_minor\": \".5\",\n},\n\n{\n    \"#url\"     : \"https://dandadan.net/manga/dandadan-chapter-203/\",\n    \"#class\"   : dandadan.DandadanChapterExtractor,\n    \"#pattern\" : r\"https://pic\\.readkakegurui\\.com/file/sancdn/dandadan/chapter\\-203/\\d+\\.webp\",\n    \"#count\"   : 22,\n\n    \"chapter\": 203,\n},\n\n{\n    \"#url\"     : \"https://dandadan.net/\",\n    \"#class\"   : dandadan.DandadanMangaExtractor,\n    \"#pattern\" : dandadan.DandadanChapterExtractor.pattern,\n    \"#count\"   : range(210, 300),\n},\n\n)\n"
  },
  {
    "path": "test/results/dankefuerslesen.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import dankefuerslesen\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://danke.moe/read/manga/awana-chan-wa-kyou-mo-shiawase/2/1/\",\n    \"#class\"   : dankefuerslesen.DankefuerslesenChapterExtractor,\n    \"#pattern\" : r\"https://danke.moe/media/manga/awana-chan-wa-kyou-mo-shiawase/chapters/0002_m9inbehz/69/\\d+\\.png\",\n    \"#count\"   : 22,\n\n    \"volume\"    : 1,\n    \"chapter\"   : 2,\n    \"chapter_minor\": \"\",\n    \"count\"     : 22,\n    \"page\"      : range(1, 22),\n    \"date\"      : \"dt:2025-02-09 19:03:08\",\n    \"extension\" : \"png\",\n    \"filename\"  : str,\n    \"group\"     : [\"Good Try Scans\"],\n    \"group_id\"  : 69,\n    \"lang\"      : None,\n    \"language\"  : None,\n    \"manga\"     : \"Awana-chan wa Kyou mo Shiawase\",\n    \"manga_slug\": \"awana-chan-wa-kyou-mo-shiawase\",\n    \"title\"     : \"Eat some ramen!\",\n    \"artist\"    : \"Tabayou\",\n    \"author\"    : \"Tabayou\",\n    \"description\": \"<p>A convenience store part-timer who can't seem to do anything right: Awana-chan. Today, yet again, she messed up over and over, and she can't even count how many times she was told off... How will Awana-chan find happiness in a situation like this, without anyone else to rely on!? This is a manga about creating your own happiness!!</p>\",\n},\n\n{\n    \"#url\"     : \"https://danke.moe/read/manga/awana-chan-wa-kyou-mo-shiawase/2/1/\",\n    \"#comment\" : \"ZIP archive download\",\n    \"#class\"   : dankefuerslesen.DankefuerslesenChapterExtractor,\n    \"#options\" : {\"zip\": True},\n    \"#results\" : \"https://danke.moe/api/download_chapter/awana-chan-wa-kyou-mo-shiawase/2/\",\n\n    \"count\"    : 0,\n    \"page\"     : 0,\n    \"extension\": \"zip\",\n},\n\n{\n    \"#url\"     : \"https://danke.moe/read/manga/raul-and-the-vampire/7-5/1/\",\n    \"#comment\" : \"minor chapter version\",\n    \"#class\"   : dankefuerslesen.DankefuerslesenChapterExtractor,\n    \"#pattern\" : r\"https://danke.moe/media/manga/raul-and-the-vampire/chapters/0009-5_efnaqvlj/56/\\d+\\.png\",\n    \"#count\"   : 20,\n\n    \"volume\"    : 1,\n    \"chapter\"   : 7,\n    \"chapter_minor\": \".5\",\n    \"count\"     : 20,\n    \"page\"      : range(1, 20),\n    \"date\"      : \"dt:2024-10-10 07:12:44\",\n    \"extension\" : \"png\",\n    \"filename\"  : str,\n    \"group\"     : [\"Danke fürs Lesen\", \"Senko-san's Abode\"],\n    \"group_id\"  : 56,\n    \"lang\"      : None,\n    \"language\"  : None,\n    \"manga\"     : \"Raul and The Vampire\",\n    \"manga_slug\": \"raul-and-the-vampire\",\n    \"title\"     : \"Volume 1 Extras\",\n    \"artist\"    : \"Sonoguchi Naka\",\n    \"author\"    : \"Sonoguchi Naka\",\n    \"description\": \"<a href=\\\"https://twitter.com/2525_25_25_25_\\\"><img src=\\\"https://i.imgur.com/dQCXZkU.png\\\" alt=\\\"twitter\\\"/>Artist's Twitter</a>\\r\\n<a href=\\\"https://www.pixiv.net/en/users/67164428\\\"><img src=\\\"https://i.imgur.com/oiVINmy.png\\\" alt=\\\"pixiv\\\"/>Artist's Pixiv</a>\",\n},\n\n{\n    \"#url\"     : \"https://danke.moe/read/series/awana-chan-wa-kyou-mo-shiawase/2/1/\",\n    \"#class\"   : dankefuerslesen.DankefuerslesenChapterExtractor,\n},\n\n{\n    \"#url\"     : \"https://danke.moe/reader/manga/awana-chan-wa-kyou-mo-shiawase/2/1/\",\n    \"#class\"   : dankefuerslesen.DankefuerslesenChapterExtractor,\n},\n\n{\n    \"#url\"     : \"https://danke.moe/reader/series/awana-chan-wa-kyou-mo-shiawase/2/1/\",\n    \"#class\"   : dankefuerslesen.DankefuerslesenChapterExtractor,\n},\n\n{\n    \"#url\"     : \"https://danke.moe/read/manga/awana-chan-wa-kyou-mo-shiawase/\",\n    \"#class\"   : dankefuerslesen.DankefuerslesenMangaExtractor,\n    \"#pattern\" : dankefuerslesen.DankefuerslesenChapterExtractor.pattern,\n    \"#results\" : (\n        \"https://danke.moe/read/manga/awana-chan-wa-kyou-mo-shiawase/1/1/\",\n        \"https://danke.moe/read/manga/awana-chan-wa-kyou-mo-shiawase/2/1/\",\n        \"https://danke.moe/read/manga/awana-chan-wa-kyou-mo-shiawase/3/1/\",\n        \"https://danke.moe/read/manga/awana-chan-wa-kyou-mo-shiawase/4/1/\",\n    ),\n\n    \"chapter\" : range(1, 4),\n    \"chapter_minor\": \"\",\n    \"lang\"    : None,\n    \"language\": None,\n},\n\n{\n    \"#url\"     : \"https://danke.moe/read/series/awana-chan-wa-kyou-mo-shiawase\",\n    \"#class\"   : dankefuerslesen.DankefuerslesenMangaExtractor,\n},\n\n{\n    \"#url\"     : \"https://danke.moe/reader/manga/awana-chan-wa-kyou-mo-shiawase\",\n    \"#class\"   : dankefuerslesen.DankefuerslesenMangaExtractor,\n},\n\n{\n    \"#url\"     : \"https://danke.moe/reader/series/awana-chan-wa-kyou-mo-shiawase\",\n    \"#class\"   : dankefuerslesen.DankefuerslesenMangaExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/derpibooru.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import philomena\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://derpibooru.org/images/1\",\n    \"#category\": (\"philomena\", \"derpibooru\", \"post\"),\n    \"#class\"   : philomena.PhilomenaPostExtractor,\n    \"#count\"       : 1,\n    \"#sha1_content\": \"88449eeb0c4fa5d3583d0b794f6bc1d70bf7f889\",\n\n    \"animated\"        : False,\n    \"aspect_ratio\"    : 1.0,\n    \"comment_count\"   : int,\n    \"created_at\"      : \"2012-01-02T03:12:33Z\",\n    \"date\"            : \"dt:2012-01-02 03:12:33\",\n    \"deletion_reason\" : None,\n    \"description\"     : \"\",\n    \"downvotes\"       : int,\n    \"duplicate_of\"    : None,\n    \"duration\"        : 0.04,\n    \"extension\"       : \"png\",\n    \"faves\"           : int,\n    \"first_seen_at\"   : \"2012-01-02T03:12:33Z\",\n    \"format\"          : \"png\",\n    \"height\"          : 900,\n    \"hidden_from_users\": False,\n    \"id\"              : 1,\n    \"mime_type\"       : \"image/png\",\n    \"name\"            : \"1__safe_fluttershy_solo_cloud_happy_flying_upvotes+galore_artist-colon-speccysy_get_sunshine\",\n    \"orig_sha512_hash\": None,\n    \"processed\"       : True,\n    \"representations\" : dict,\n    \"score\"           : int,\n    \"sha512_hash\"     : \"f16c98e2848c2f1bfff3985e8f1a54375cc49f78125391aeb80534ce011ead14e3e452a5c4bc98a66f56bdfcd07ef7800663b994f3f343c572da5ecc22a9660f\",\n    \"size\"            : 860914,\n    \"source_url\"      : \"https://web.archive.org/web/20110702164313/http://speccysy.deviantart.com:80/art/Afternoon-Flight-215193985\",\n    \"spoilered\"       : False,\n    \"tag_count\"       : int,\n    \"tag_ids\"         : list,\n    \"tags\"            : list,\n    \"thumbnails_generated\": True,\n    \"updated_at\"      : r\"re:\\d\\d\\d\\d-\\d\\d-\\d\\dT\\d\\d:\\d\\d:\\d\\dZ\",\n    \"uploader\"        : \"Clover the Clever\",\n    \"uploader_id\"     : 211188,\n    \"upvotes\"         : int,\n    \"view_url\"        : str,\n    \"width\"           : 900,\n    \"wilson_score\"    : float,\n},\n\n{\n    \"#url\"     : \"https://derpibooru.org/images/3334658\",\n    \"#comment\" : \"svg (#5643)\",\n    \"#category\": (\"philomena\", \"derpibooru\", \"post\"),\n    \"#class\"   : philomena.PhilomenaPostExtractor,\n    \"#results\"     : \"https://derpicdn.net/img/view/2024/4/1/3334658.svg\",\n    \"#sha1_content\": \"eec5adf02e2a4fe83b9211c0444d57dc03e21f50\",\n\n    \"extension\": \"svg\",\n    \"format\"   : \"svg\",\n},\n\n{\n    \"#url\"     : \"https://derpibooru.org/1\",\n    \"#category\": (\"philomena\", \"derpibooru\", \"post\"),\n    \"#class\"   : philomena.PhilomenaPostExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.derpibooru.org/1\",\n    \"#category\": (\"philomena\", \"derpibooru\", \"post\"),\n    \"#class\"   : philomena.PhilomenaPostExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.derpibooru.org/images/1\",\n    \"#category\": (\"philomena\", \"derpibooru\", \"post\"),\n    \"#class\"   : philomena.PhilomenaPostExtractor,\n},\n\n{\n    \"#url\"     : \"https://derpibooru.org/search?q=cute\",\n    \"#category\": (\"philomena\", \"derpibooru\", \"search\"),\n    \"#class\"   : philomena.PhilomenaSearchExtractor,\n    \"#range\"   : \"40-60\",\n    \"#count\"   : 21,\n},\n\n{\n    \"#url\"     : \"https://derpibooru.org/tags/cute\",\n    \"#category\": (\"philomena\", \"derpibooru\", \"search\"),\n    \"#class\"   : philomena.PhilomenaSearchExtractor,\n    \"#range\"   : \"40-60\",\n    \"#count\"   : 21,\n},\n\n{\n    \"#url\"     : \"https://derpibooru.org/tags/artist-colon--dash-_-fwslash--fwslash-%255Bkorroki%255D_aternak\",\n    \"#category\": (\"philomena\", \"derpibooru\", \"search\"),\n    \"#class\"   : philomena.PhilomenaSearchExtractor,\n    \"#count\"   : \">= 2\",\n},\n\n{\n    \"#url\"     : \"https://derpibooru.org/galleries/1\",\n    \"#category\": (\"philomena\", \"derpibooru\", \"gallery\"),\n    \"#class\"   : philomena.PhilomenaGalleryExtractor,\n    \"#pattern\" : r\"https://derpicdn\\.net/img/view/\\d+/\\d+/\\d+/\\d+[^/]+$\",\n\n    \"gallery\": {\n        \"description\"    : \"Indexes start at 1 :P\",\n        \"id\"             : 1,\n        \"spoiler_warning\": \"\",\n        \"thumbnail_id\"   : 1,\n        \"title\"          : \"The Very First Gallery\",\n        \"user\"           : \"DeliciousBlackInk\",\n        \"user_id\"        : 365446,\n    },\n},\n\n)\n"
  },
  {
    "path": "test/results/desktopography.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import desktopography\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://desktopography.net/\",\n    \"#category\": (\"\", \"desktopography\", \"site\"),\n    \"#class\"   : desktopography.DesktopographySiteExtractor,\n},\n\n{\n    \"#url\"     : \"https://desktopography.net/exhibition-2020/\",\n    \"#category\": (\"\", \"desktopography\", \"exhibition\"),\n    \"#class\"   : desktopography.DesktopographyExhibitionExtractor,\n},\n\n{\n    \"#url\"     : \"https://desktopography.net/portfolios/new-era/\",\n    \"#category\": (\"\", \"desktopography\", \"entry\"),\n    \"#class\"   : desktopography.DesktopographyEntryExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/desuarchive.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import foolfuuka\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://desuarchive.org/a/thread/159542679/\",\n    \"#category\": (\"foolfuuka\", \"desuarchive\", \"thread\"),\n    \"#class\"   : foolfuuka.FoolfuukaThreadExtractor,\n    \"#sha1_url\": \"e7d624aded15a069194e38dc731ec23217a422fb\",\n},\n\n{\n    \"#url\"     : \"https://desuarchive.org/a\",\n    \"#category\": (\"foolfuuka\", \"desuarchive\", \"board\"),\n    \"#class\"   : foolfuuka.FoolfuukaBoardExtractor,\n},\n\n{\n    \"#url\"     : \"https://desuarchive.org/a/\",\n    \"#category\": (\"foolfuuka\", \"desuarchive\", \"board\"),\n    \"#class\"   : foolfuuka.FoolfuukaBoardExtractor,\n},\n\n{\n    \"#url\"     : \"https://desuarchive.org/a/2\",\n    \"#category\": (\"foolfuuka\", \"desuarchive\", \"board\"),\n    \"#class\"   : foolfuuka.FoolfuukaBoardExtractor,\n},\n\n{\n    \"#url\"     : \"https://desuarchive.org/a/page/2\",\n    \"#category\": (\"foolfuuka\", \"desuarchive\", \"board\"),\n    \"#class\"   : foolfuuka.FoolfuukaBoardExtractor,\n    \"#pattern\" : foolfuuka.FoolfuukaThreadExtractor.pattern,\n    \"#count\"   : 10,\n},\n\n{\n    \"#url\"     : \"https://desuarchive.org/_/search/text/test/\",\n    \"#category\": (\"foolfuuka\", \"desuarchive\", \"search\"),\n    \"#class\"   : foolfuuka.FoolfuukaSearchExtractor,\n},\n\n{\n    \"#url\"     : \"https://desuarchive.org/a/gallery/5\",\n    \"#category\": (\"foolfuuka\", \"desuarchive\", \"gallery\"),\n    \"#class\"   : foolfuuka.FoolfuukaGalleryExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/deviantart.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import deviantart\nfrom gallery_dl import exception\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://www.deviantart.com/shimoda7\",\n    \"#category\": (\"\", \"deviantart\", \"user\"),\n    \"#class\"   : deviantart.DeviantartUserExtractor,\n    \"#results\" : \"https://www.deviantart.com/shimoda7/gallery\",\n},\n\n{\n    \"#url\"     : \"https://www.deviantart.com/shimoda7\",\n    \"#category\": (\"\", \"deviantart\", \"user\"),\n    \"#class\"   : deviantart.DeviantartUserExtractor,\n    \"#options\" : {\"include\": \"all\"},\n    \"#results\" : (\n        \"https://www.deviantart.com/shimoda7/avatar\",\n        \"https://www.deviantart.com/shimoda7/banner\",\n        \"https://www.deviantart.com/shimoda7/gallery\",\n        \"https://www.deviantart.com/shimoda7/gallery/scraps\",\n        \"https://www.deviantart.com/shimoda7/posts\",\n        \"https://www.deviantart.com/shimoda7/posts/statuses\",\n        \"https://www.deviantart.com/shimoda7/favourites\",\n    ),\n},\n\n{\n    \"#url\"     : \"https://shimoda7.deviantart.com/\",\n    \"#category\": (\"\", \"deviantart\", \"user\"),\n    \"#class\"   : deviantart.DeviantartUserExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.deviantart.com/shimoda7/gallery/\",\n    \"#category\": (\"\", \"deviantart\", \"gallery\"),\n    \"#class\"   : deviantart.DeviantartGalleryExtractor,\n    \"#pattern\" : r\"https://(images-)?wixmp-[^.]+\\.wixmp\\.com/f/.+/.+\\.(jpg|png)\\?token=.+\",\n    \"#count\"   : \">= 38\",\n\n    \"allows_comments\" : bool,\n    \"author\"          : {\n        \"type\"    : \"premium\",\n        \"usericon\": str,\n        \"userid\"  : \"9AE51FC7-0278-806C-3FFF-F4961ABF9E2B\",\n        \"username\": \"shimoda7\",\n    },\n    \"content\"         : {\n        \"filesize\"    : int,\n        \"height\"      : int,\n        \"src\"         : str,\n        \"transparency\": bool,\n        \"width\"       : int,\n    },\n    \"date\"            : \"type:datetime\",\n    \"deviationid\"     : str,\n    \"?download_filesize\": int,\n    \"extension\"       : str,\n    \"index\"           : int,\n    \"is_deleted\"      : bool,\n    \"is_downloadable\" : bool,\n    \"is_favourited\"   : bool,\n    \"is_mature\"       : bool,\n    \"preview\"         : {\n        \"height\"      : int,\n        \"src\"         : str,\n        \"transparency\": bool,\n        \"width\"       : int,\n    },\n    \"published_time\"  : int,\n    \"stats\"           : {\n        \"comments\"  : int,\n        \"favourites\": int,\n    },\n    \"target\"          : dict,\n    \"thumbs\"          : list,\n    \"title\"           : str,\n    \"url\"             : r\"re:https://www.deviantart.com/shimoda7/art/[^/]+-\\d+\",\n    \"username\"        : \"shimoda7\",\n},\n\n{\n    \"#url\"     : \"https://www.deviantart.com/shimoda7/gallery/\",\n    \"#comment\" : \"range/skip (#4557)\",\n    \"#category\": (\"\", \"deviantart\", \"gallery\"),\n    \"#class\"   : deviantart.DeviantartGalleryExtractor,\n    \"#options\" : {\"original\": False},\n    \"#pattern\" : r\"https://images-wixmp-[0-9a-f]+\\.wixmp\\.com/f/0e474835-ec35-4937-b647-b6830ed58bd1/d2idul-6158ded2-37ac-413d-802e-0689f0f020ad\\.jpg\\?token=[\\w.]+\",\n    \"#range\"   : \"38-\",\n    \"#count\"   : 1,\n},\n\n{\n    \"#url\"     : \"https://www.deviantart.com/AlloyRabbit/gallery\",\n    \"#comment\" : \"deactivated account\",\n    \"#category\": (\"\", \"deviantart\", \"gallery\"),\n    \"#class\"   : deviantart.DeviantartGalleryExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.deviantart.com/Shydude/gallery\",\n    \"#comment\" : \"deactivated account\",\n    \"#category\": (\"\", \"deviantart\", \"gallery\"),\n    \"#class\"   : deviantart.DeviantartGalleryExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.deviantart.com/zapor666/gallery\",\n    \"#comment\" : \"deactivated account\",\n    \"#category\": (\"\", \"deviantart\", \"gallery\"),\n    \"#class\"   : deviantart.DeviantartGalleryExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.deviantart.com/yakuzafc/gallery\",\n    \"#comment\" : \"group\",\n    \"#category\": (\"\", \"deviantart\", \"gallery\"),\n    \"#class\"   : deviantart.DeviantartGalleryExtractor,\n    \"#pattern\" : r\"https://www.deviantart.com/yakuzafc/gallery/\\w{8}-\\w{4}-\\w{4}-\\w{4}-\\w{12}/\",\n    \"#count\"   : \">= 15\",\n},\n\n{\n    \"#url\"      : \"https://www.deviantart.com/yakuzafc/gallery\",\n    \"#comment\"  : \"'group': 'skip' (#4630)\",\n    \"#category\" : (\"\", \"deviantart\", \"gallery\"),\n    \"#class\"    : deviantart.DeviantartGalleryExtractor,\n    \"#options\"  : {\"group\": \"skip\"},\n    \"#exception\": exception.AbortExtraction,\n    \"#count\"    : 0,\n},\n\n{\n    \"#url\"     : \"https://www.deviantart.com/justatest235723/gallery\",\n    \"#comment\" : \"'folders' option (#276)\",\n    \"#category\": (\"\", \"deviantart\", \"gallery\"),\n    \"#class\"   : deviantart.DeviantartGalleryExtractor,\n    \"#options\" : {\n        \"metadata\": 1,\n        \"folders\" : 1,\n        \"original\": 0,\n    },\n    \"#count\"   : 10,\n\n    \"description\": str,\n    \"folders\"    : list,\n    \"is_watching\": bool,\n    \"license\"    : str,\n    \"tags\"       : list,\n},\n\n{\n    \"#url\"     : \"https://www.deviantart.com/shimoda8/gallery/\",\n    \"#category\": (\"\", \"deviantart\", \"gallery\"),\n    \"#class\"   : deviantart.DeviantartGalleryExtractor,\n    \"#exception\": exception.NotFoundError,\n},\n\n{\n    \"#url\"     : \"https://www.deviantart.com/shimoda7/gallery\",\n    \"#category\": (\"\", \"deviantart\", \"gallery\"),\n    \"#class\"   : deviantart.DeviantartGalleryExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.deviantart.com/shimoda7/gallery/all\",\n    \"#category\": (\"\", \"deviantart\", \"gallery\"),\n    \"#class\"   : deviantart.DeviantartGalleryExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.deviantart.com/shimoda7/gallery/recommended-for-you\",\n    \"#category\": (\"\", \"deviantart\", \"gallery\"),\n    \"#class\"   : deviantart.DeviantartGalleryExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.deviantart.com/shimoda7/gallery/?catpath=/\",\n    \"#category\": (\"\", \"deviantart\", \"gallery\"),\n    \"#class\"   : deviantart.DeviantartGalleryExtractor,\n},\n\n{\n    \"#url\"     : \"https://shimoda7.deviantart.com/gallery/\",\n    \"#category\": (\"\", \"deviantart\", \"gallery\"),\n    \"#class\"   : deviantart.DeviantartGalleryExtractor,\n},\n\n{\n    \"#url\"     : \"https://shimoda7.deviantart.com/gallery/all/\",\n    \"#category\": (\"\", \"deviantart\", \"gallery\"),\n    \"#class\"   : deviantart.DeviantartGalleryExtractor,\n},\n\n{\n    \"#url\"     : \"https://shimoda7.deviantart.com/gallery/?catpath=/\",\n    \"#category\": (\"\", \"deviantart\", \"gallery\"),\n    \"#class\"   : deviantart.DeviantartGalleryExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.deviantart.com/yinnyyany/gallery/all?order=newest\",\n    \"#class\"   : deviantart.DeviantartGalleryExtractor,\n},\n\n{\n    \"#url\"     : \"https://deviantart.com/shimoda7/avatar\",\n    \"#category\": (\"\", \"deviantart\", \"avatar\"),\n    \"#class\"   : deviantart.DeviantartAvatarExtractor,\n    \"#results\"     : \"https://a.deviantart.net/avatars-big/s/h/shimoda7.jpg?4\",\n    \"#sha1_content\": \"abf2cc79b842315f2e54bfdd93bf794a0f612b6f\",\n\n    \"author\"         : {\n        \"type\"    : \"premium\",\n        \"usericon\": \"https://a.deviantart.net/avatars/s/h/shimoda7.jpg?4\",\n        \"userid\"  : \"9AE51FC7-0278-806C-3FFF-F4961ABF9E2B\",\n        \"username\": \"shimoda7\",\n    },\n    \"content\"        : {\n        \"src\": \"https://a.deviantart.net/avatars-big/s/h/shimoda7.jpg?4\"\n    },\n    \"da_category\"    : \"avatar\",\n    \"date\"           : \"dt:1970-01-01 00:00:00\",\n    \"extension\"      : \"jpg\",\n    \"filename\"       : \"avatar_by_shimoda7-d4\",\n    \"index\"          : 4,\n    \"index_base36\"   : \"4\",\n    \"is_deleted\"     : False,\n    \"is_downloadable\": False,\n    \"is_original\"    : True,\n    \"published_time\" : 0,\n    \"target\"         : {\n        \"extension\": \"jpg\",\n        \"filename\" : \"avatar_by_shimoda7-d4\",\n        \"src\"      : \"https://a.deviantart.net/avatars-big/s/h/shimoda7.jpg?4\"\n    },\n    \"title\"          : \"avatar\",\n    \"username\"       : \"shimoda7\",\n},\n\n{\n    \"#url\"     : \"https://deviantart.com/shimoda7/avatar\",\n    \"#comment\" : \"'formats' option\",\n    \"#category\": (\"\", \"deviantart\", \"avatar\"),\n    \"#class\"   : deviantart.DeviantartAvatarExtractor,\n    \"#archive\" : False,\n    \"#options\" : {\"formats\": [\"original.jpg\", \"big.jpg\", \"big.png\", \"big.gif\"]},\n    \"#results\" : (\n        \"https://a.deviantart.net/avatars-original/s/h/shimoda7.jpg?4\",\n        \"https://a.deviantart.net/avatars-big/s/h/shimoda7.jpg?4\",\n        \"https://a.deviantart.net/avatars-big/s/h/shimoda7.png?4\",\n        \"https://a.deviantart.net/avatars-big/s/h/shimoda7.gif?4\",\n    ),\n},\n\n{\n    \"#url\"     : \"https://deviantart.com/h3813067/avatar\",\n    \"#comment\" : \"default avatar (#5276)\",\n    \"#category\": (\"\", \"deviantart\", \"avatar\"),\n    \"#class\"   : deviantart.DeviantartAvatarExtractor,\n    \"#count\"   : 0,\n},\n\n{\n    \"#url\"     : \"https://deviantart.com/gdldev/banner\",\n    \"#category\": (\"\", \"deviantart\", \"background\"),\n    \"#class\"   : deviantart.DeviantartBackgroundExtractor,\n    \"#pattern\"     : r\"https://wixmp-\\w+\\.wixmp\\.com/f/b042e0ae-a7ff-420b-a41a-b35503427360/dgntyqc-3deebb65-04b4-4085-992a-aa0c0e7e225d\\.png\\?token=ey[\\w.-]+$\",\n    \"#sha1_content\": \"980eaa76ce515f1b6bef60dfadf26a5bbe9c583f\",\n\n    \"allows_comments\"  : True,\n    \"author\"           : {\n        \"type\"    : \"regular\",\n        \"usericon\": \"https://a.deviantart.net/avatars/g/d/gdldev.jpg?12\",\n        \"userid\"  : \"1A12BA26-33C2-AA0A-7678-0B6DFBA7AC8E\",\n        \"username\": \"gdldev\"\n    },\n    \"content\"          : {\n        \"filename\"    : \"banner_by_gdldev_dgntyqc.png\",\n        \"filesize\"    : 84510,\n        \"height\"      : 4000,\n        \"src\"         : r\"re:https://wixmp-\\w+\\.wixmp\\.com/f/b042e0ae-a7ff-420b-a41a-b35503427360/dgntyqc-3deebb65-04b4-4085-992a-aa0c0e7e225d\\.png\\?token=ey[\\w.-]+$\",\n        \"transparency\": False,\n        \"width\"       : 6400\n    },\n    \"date\"             : \"dt:2024-01-02 21:16:06\",\n    \"deviationid\"      : \"8C8D6B28-766A-DE21-7F7D-CE055C3BD50A\",\n    \"download_filesize\": 84510,\n    \"extension\"        : \"png\",\n    \"filename\"         : \"banner_by_gdldev-dgntyqc\",\n    \"index\"            : 1007488020,\n    \"index_base36\"     : \"gntyqc\",\n    \"is_blocked\"       : False,\n    \"is_deleted\"       : False,\n    \"is_downloadable\"  : True,\n    \"is_favourited\"    : False,\n    \"is_mature\"        : False,\n    \"is_original\"      : True,\n    \"is_published\"     : False,\n    \"preview\"          : dict,\n    \"printid\"          : None,\n    \"published_time\"   : 1704230166,\n    \"stats\"            : {\n        \"comments\"  : 0,\n        \"favourites\": 0,\n    },\n    \"target\"           : dict,\n    \"thumbs\"           : list,\n    \"title\"            : \"Banner\",\n    \"url\"              : \"https://www.deviantart.com/stash/0198jippkeys\",\n    \"username\"         : \"gdldev\",\n},\n\n{\n    \"#url\"     : \"https://www.deviantart.com/shimoda7/gallery/722019/Miscellaneous\",\n    \"#comment\" : \"user\",\n    \"#category\": (\"\", \"deviantart\", \"folder\"),\n    \"#class\"   : deviantart.DeviantartFolderExtractor,\n    \"#options\" : {\"original\": False},\n    \"#count\"   : 5,\n},\n\n{\n    \"#url\"     : \"https://www.deviantart.com/yakuzafc/gallery/37412168/Crafts\",\n    \"#comment\" : \"group\",\n    \"#category\": (\"\", \"deviantart\", \"folder\"),\n    \"#class\"   : deviantart.DeviantartFolderExtractor,\n    \"#options\" : {\"original\": False},\n    \"#count\"   : \">= 4\",\n},\n\n{\n    \"#url\"     : \"https://www.deviantart.com/shimoda7/gallery/B38E3C6A-2029-6B45-757B-3C8D3422AD1A/misc\",\n    \"#comment\" : \"uuid\",\n    \"#category\": (\"\", \"deviantart\", \"folder\"),\n    \"#class\"   : deviantart.DeviantartFolderExtractor,\n    \"#options\" : {\"original\": False},\n    \"#count\"   : 5,\n},\n\n{\n    \"#url\"     : \"https://www.deviantart.com/justatest235723/gallery/69302698/-test-b-c-d-e-f-\",\n    \"#comment\" : \"name starts with '_', special characters (#1451)\",\n    \"#category\": (\"\", \"deviantart\", \"folder\"),\n    \"#class\"   : deviantart.DeviantartFolderExtractor,\n    \"#options\" : {\"original\": False},\n    \"#count\"   : 1,\n},\n\n{\n    \"#url\"     : \"https://www.deviantart.com/avapithecus/gallery/71028779/drake-hero\",\n    \"#comment\" : \"main folder + subfolders\",\n    \"#category\": (\"\", \"deviantart\", \"folder\"),\n    \"#class\"   : deviantart.DeviantartFolderExtractor,\n    \"#options\" : {\"subfolders\": True, \"original\": False, \"image-range\": \"1\"},\n    \"#pattern\" : (\n        r\"https://www.deviantart.com/Avapithecus/gallery/6FCC57FA-F21D-14CC-5E0F-BB76479B6555/Folk Hero\",\n        r\"https://www.deviantart.com/Avapithecus/gallery/8D5E41B0-4BF5-649B-6620-B1D89C6D6BCE/Denizens of Suwarrow\",\n        r\"https://www.deviantart.com/Avapithecus/gallery/7FE4D499-E883-23D2-1659-1B64CA67358D/Beyond Suwarrow\",\n        r\"https://www.deviantart.com/Avapithecus/gallery/38AAB41C-F0F1-4DE9-6FB9-D3493CD77D01/The Drake Number\",\n        r\"https://images-wixmp-ed30a86b8c4ca887773594c2.wixmp.com/f/c5e7b050-4923-4473-b8c0-ca0bc1c1b1fe/dgqc5py-3371d62e-465f-4b17-bd23-5005517fc68d.jpg/v1/fill/.+\",\n    ),\n},\n\n{\n    \"#url\"     : \"https://www.deviantart.com/avapithecus/gallery/87003033/the-drake-number\",\n    \"#comment\" : \"subfolder\",\n    \"#category\": (\"\", \"deviantart\", \"folder\"),\n    \"#class\"   : deviantart.DeviantartFolderExtractor,\n    \"#options\" : {\"original\": False},\n    \"#pattern\" : (\n        r\"https://images-wixmp-ed30a86b8c4ca887773594c2.wixmp.com/f/c5e7b050-4923-4473-b8c0-ca0bc1c1b1fe/dfu7xyj-44d1a551-dbdc-4614-baee-82612fb044a6.jpg\\?token=ey.+\",\n        r\"https://images-wixmp-ed30a86b8c4ca887773594c2.wixmp.com/f/c5e7b050-4923-4473-b8c0-ca0bc1c1b1fe/deeoxic-932e966c-6d3b-473c-8053-ed7bad05813a.jpg/v1/fill/.+\",\n    ),\n},\n\n{\n    \"#url\"     : \"https://shimoda7.deviantart.com/gallery/722019/Miscellaneous\",\n    \"#category\": (\"\", \"deviantart\", \"folder\"),\n    \"#class\"   : deviantart.DeviantartFolderExtractor,\n},\n\n{\n    \"#url\"     : \"https://yakuzafc.deviantart.com/gallery/37412168/Crafts\",\n    \"#category\": (\"\", \"deviantart\", \"folder\"),\n    \"#class\"   : deviantart.DeviantartFolderExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.deviantart.com/stash/022c83odnaxc\",\n    \"#category\": (\"\", \"deviantart\", \"stash\"),\n    \"#class\"   : deviantart.DeviantartStashExtractor,\n    \"#pattern\"     : r\"https://wixmp-ed30a86b8c4ca887773594c2.wixmp.com/f/940f2d05-c5eb-4917-8192-7eb6a2d508c6/dcvdmbc-e506cdcf-3208-4c20-85ab-0bfa8a7bcb16.png\\?token=ey.+\",\n    \"#count\"       : 1,\n    \"#sha1_content\": \"057eb2f2861f6c8a96876b13cca1a4b7a408c11f\",\n\n    \"content\": {\n        \"filename\": \"01_by_justatest235723_dcvdmbc.png\",\n        \"filesize\": 380,\n        \"width\"   : 128,\n        \"height\"  : 128,\n        \"src\"     : r\"re:https://wixmp-ed30a86b8c4ca887773594c2.wixmp.com/f/940f2d05-c5eb-4917-8192-7eb6a2d508c6/dcvdmbc-e506cdcf-3208-4c20-85ab-0bfa8a7bcb16.png\\?token=ey.+\",\n    },\n    \"date\"          : \"dt:2018-12-26 14:49:27\",\n    \"deviationid\"   : \"A4A6AD52-8857-46EE-ABFE-86D49D4FF9D0\",\n    \"download_filesize\": 380,\n    \"extension\"     : \"png\",\n    \"filename\"      : \"01_by_justatest235723-dcvdmbc\",\n    \"index\"         : 778297656,\n    \"index_base36\"  : \"cvdmbc\",\n    \"published_time\": 1545835767,\n    \"stash_description\": \"\",\n    \"stash_id\"      : \"022c83odnaxc\",\n    \"stash_name\"    : \"\",\n    \"stash_folder\"  : 0,\n    \"stash_parent\"  : 0,\n    \"title\"         : \"01\",\n    \"url\"           : \"https://www.deviantart.com/stash/022c83odnaxc\",\n},\n\n{\n    \"#url\"     : \"https://sta.sh/022c83odnaxc\",\n    \"#category\": (\"\", \"deviantart\", \"stash\"),\n    \"#class\"   : deviantart.DeviantartStashExtractor,\n    \"#pattern\" : r\"https://wixmp-ed30a86b8c4ca887773594c2.wixmp.com/f/940f2d05-c5eb-4917-8192-7eb6a2d508c6/dcvdmbc-e506cdcf-3208-4c20-85ab-0bfa8a7bcb16.png\\?token=ey.+\",\n    \"#count\"   : 1,\n\n    \"date\"          : \"dt:2018-12-26 14:49:27\",\n    \"deviationid\"   : \"A4A6AD52-8857-46EE-ABFE-86D49D4FF9D0\",\n    \"download_filesize\": 380,\n    \"extension\"     : \"png\",\n    \"filename\"      : \"01_by_justatest235723-dcvdmbc\",\n    \"index\"         : 778297656,\n    \"index_base36\"  : \"cvdmbc\",\n    \"published_time\": 1545835767,\n    \"title\"         : \"01\",\n    \"url\"           : \"https://www.deviantart.com/stash/022c83odnaxc\",\n},\n\n{\n    \"#url\"     : \"https://sta.sh/21jf51j7pzl2\",\n    \"#comment\" : \"multiple stash items\",\n    \"#category\": (\"\", \"deviantart\", \"stash\"),\n    \"#class\"   : deviantart.DeviantartStashExtractor,\n    \"#options\" : {\"original\": False},\n    \"#pattern\" : (\n        r\"https://images-wixmp-ed30a86b8c4ca887773594c2.wixmp.com/f/940f2d05-c5eb-4917-8192-7eb6a2d508c6/dcvdmcb-b0178127-de9d-48e2-b95c-8627778b1c20.png\\?token=ey.+\",\n        r\"https://images-wixmp-ed30a86b8c4ca887773594c2.wixmp.com/f/940f2d05-c5eb-4917-8192-7eb6a2d508c6/dcvdmc6-a2402b2f-b469-42d2-99ca-a3464b3c5889.png\\?token=ey.+\",\n        r\"https://images-wixmp-ed30a86b8c4ca887773594c2.wixmp.com/f/940f2d05-c5eb-4917-8192-7eb6a2d508c6/dcvdmbz-adf92e46-0481-4c65-9e3b-e142a86a2d44.png\\?token=ey.+\",\n        r\"https://images-wixmp-ed30a86b8c4ca887773594c2.wixmp.com/f/940f2d05-c5eb-4917-8192-7eb6a2d508c6/dcvdmbx-08eb6ce5-c892-4f16-8676-668cd2276697.png\\?token=ey.+\",\n    ),\n\n    \"stash_description\": \"\"\"Sta.sh Description\\ntest-テスト-\"&>\"\"\",\n    \"stash_id\"    : {\"021rsajamktz\", \"099tbcst5u5\", \"02394fx5fagg\", \"01okox2yh0o2\"},\n    \"stash_name\"  : \"\"\"Sta.sh Title test-テスト-\"&>\"\"\",\n    \"stash_folder\": 7362377764221985,\n    \"stash_parent\": 0,\n},\n\n{\n    \"#url\"     : \"https://sta.sh/024t4coz16mi\",\n    \"#comment\" : \"downloadable, but no 'content' field (#307)\",\n    \"#category\": (\"\", \"deviantart\", \"stash\"),\n    \"#class\"   : deviantart.DeviantartStashExtractor,\n    \"#pattern\" : r\"https://wixmp-[^.]+\\.wixmp\\.com/f/.+/.+\\.rar\\?token=.+\",\n    \"#count\"   : 1,\n},\n\n{\n    \"#url\"     : \"https://sta.sh/215twi387vfj\",\n    \"#comment\" : \"mixed folders and images (#659)\",\n    \"#category\": (\"\", \"deviantart\", \"stash\"),\n    \"#class\"   : deviantart.DeviantartStashExtractor,\n    \"#options\" : {\"original\": False},\n    \"#count\"   : 4,\n\n    \"stash_description\": \"\",\n    \"stash_id\"    : {\"018dfylek5o1\", \"0vmi73y92tn\", \"02g2v51kb8y2\", \"06nnon5vucx\"},\n    \"stash_name\"  : {\"Sta.sh Uploads 147\", \"1\"},\n    \"stash_folder\": {7382365850253347, 2415594944160654},\n    \"stash_parent\": {0, 7382365850253347},\n},\n\n{\n    \"#url\"     : \"https://sta.sh/abcdefghijkl\",\n    \"#category\": (\"\", \"deviantart\", \"stash\"),\n    \"#class\"   : deviantart.DeviantartStashExtractor,\n    \"#count\"   : 0,\n},\n\n{\n    \"#url\"     : \"https://www.deviantart.com/h3813067/favourites/\",\n    \"#category\": (\"\", \"deviantart\", \"favorite\"),\n    \"#class\"   : deviantart.DeviantartFavoriteExtractor,\n    \"#options\" : {\n        \"metadata\": True,\n        \"flat\"    : False,\n    },\n    \"#count\"   : 1,\n},\n\n{\n    \"#url\"     : \"https://www.deviantart.com/h3813067/favourites/\",\n    \"#category\": (\"\", \"deviantart\", \"favorite\"),\n    \"#class\"   : deviantart.DeviantartFavoriteExtractor,\n    \"#sha1_content\": \"6a7c74dc823ebbd457bdd9b3c2838a6ee728091e\",\n},\n\n{\n    \"#url\"     : \"https://www.deviantart.com/h3813067/favourites/all\",\n    \"#category\": (\"\", \"deviantart\", \"favorite\"),\n    \"#class\"   : deviantart.DeviantartFavoriteExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.deviantart.com/h3813067/favourites/?catpath=/\",\n    \"#category\": (\"\", \"deviantart\", \"favorite\"),\n    \"#class\"   : deviantart.DeviantartFavoriteExtractor,\n},\n\n{\n    \"#url\"     : \"https://h3813067.deviantart.com/favourites/\",\n    \"#category\": (\"\", \"deviantart\", \"favorite\"),\n    \"#class\"   : deviantart.DeviantartFavoriteExtractor,\n},\n\n{\n    \"#url\"     : \"https://h3813067.deviantart.com/favourites/all\",\n    \"#category\": (\"\", \"deviantart\", \"favorite\"),\n    \"#class\"   : deviantart.DeviantartFavoriteExtractor,\n},\n\n{\n    \"#url\"     : \"https://h3813067.deviantart.com/favourites/?catpath=/\",\n    \"#category\": (\"\", \"deviantart\", \"favorite\"),\n    \"#class\"   : deviantart.DeviantartFavoriteExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.deviantart.com/pencilshadings/favourites/70595441/3D-Favorites\",\n    \"#category\": (\"\", \"deviantart\", \"collection\"),\n    \"#class\"   : deviantart.DeviantartCollectionExtractor,\n    \"#options\" : {\"original\": False},\n    \"#count\"   : \">= 15\",\n},\n\n{\n    \"#url\"     : \"https://www.deviantart.com/pencilshadings/favourites/F050486B-CB62-3C66-87FB-1105A7F6379F/3D Favorites\",\n    \"#category\": (\"\", \"deviantart\", \"collection\"),\n    \"#class\"   : deviantart.DeviantartCollectionExtractor,\n    \"#options\" : {\"original\": False},\n    \"#count\"   : \">= 15\",\n},\n\n{\n    \"#url\"     : \"https://pencilshadings.deviantart.com/favourites/70595441/3D-Favorites\",\n    \"#category\": (\"\", \"deviantart\", \"collection\"),\n    \"#class\"   : deviantart.DeviantartCollectionExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.deviantart.com/angrywhitewanker/posts/journals/\",\n    \"#category\": (\"\", \"deviantart\", \"journal\"),\n    \"#class\"   : deviantart.DeviantartJournalExtractor,\n    \"#sha1_url\": \"48aeed5631763d96f5391d2177ea72d9fdbee4e5\",\n},\n\n{\n    \"#url\"     : \"https://www.deviantart.com/angrywhitewanker/posts/journals/\",\n    \"#category\": (\"\", \"deviantart\", \"journal\"),\n    \"#class\"   : deviantart.DeviantartJournalExtractor,\n    \"#options\" : {\"journals\": \"text\"},\n    \"#sha1_url\": \"b2a8e74d275664b1a4acee0fca0a6fd33298571e\",\n},\n\n{\n    \"#url\"     : \"https://www.deviantart.com/angrywhitewanker/posts/journals/\",\n    \"#category\": (\"\", \"deviantart\", \"journal\"),\n    \"#class\"   : deviantart.DeviantartJournalExtractor,\n    \"#options\" : {\"journals\": \"none\"},\n    \"#count\"   : 0,\n},\n\n{\n    \"#url\"     : \"https://www.deviantart.com/shimoda7/posts/\",\n    \"#category\": (\"\", \"deviantart\", \"journal\"),\n    \"#class\"   : deviantart.DeviantartJournalExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.deviantart.com/shimoda7/journal/\",\n    \"#category\": (\"\", \"deviantart\", \"journal\"),\n    \"#class\"   : deviantart.DeviantartJournalExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.deviantart.com/shimoda7/journal/?catpath=/\",\n    \"#category\": (\"\", \"deviantart\", \"journal\"),\n    \"#class\"   : deviantart.DeviantartJournalExtractor,\n},\n\n{\n    \"#url\"     : \"https://shimoda7.deviantart.com/journal/\",\n    \"#category\": (\"\", \"deviantart\", \"journal\"),\n    \"#class\"   : deviantart.DeviantartJournalExtractor,\n},\n\n{\n    \"#url\"     : \"https://shimoda7.deviantart.com/journal/?catpath=/\",\n    \"#category\": (\"\", \"deviantart\", \"journal\"),\n    \"#class\"   : deviantart.DeviantartJournalExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.deviantart.com/t1na/posts/statuses\",\n    \"#category\": (\"\", \"deviantart\", \"status\"),\n    \"#class\"   : deviantart.DeviantartStatusExtractor,\n    \"#count\"   : 0,\n},\n\n{\n    \"#url\"     : \"https://www.deviantart.com/justgalym/posts/statuses\",\n    \"#category\": (\"\", \"deviantart\", \"status\"),\n    \"#class\"   : deviantart.DeviantartStatusExtractor,\n    \"#count\"   : 4,\n    \"#sha1_url\": \"62ee48ff3405c7714dca70abf42e8e39731012fc\",\n},\n\n{\n    \"#url\"     : \"https://www.deviantart.com/justgalym/posts/statuses\",\n    \"#category\": (\"\", \"deviantart\", \"status\"),\n    \"#class\"   : deviantart.DeviantartStatusExtractor,\n    \"#options\" : {\"journals\": \"none\"},\n    \"#pattern\" : r\"https://images-wixmp-\\w+\\.wixmp\\.com/intermediary/f/[^/]+/[^.]+\\.jpg\",\n    \"#count\"   : 1,\n},\n\n{\n    \"#url\"     : \"https://www.deviantart.com/vanillaghosties/posts/statuses\",\n    \"#comment\" : \"shared sta.sh item\",\n    \"#category\": (\"\", \"deviantart\", \"status\"),\n    \"#class\"   : deviantart.DeviantartStatusExtractor,\n    \"#options\" : {\n        \"journals\": \"none\",\n        \"original\": False,\n    },\n    \"#range\"   : \"5-\",\n    \"#count\"   : 1,\n\n    \"index\"       : int,\n    \"index_base36\": r\"re:^[0-9a-z]+$\",\n    \"url\"         : r\"re:^https://www.deviantart.com/stash/\\w+\",\n},\n\n{\n    \"#url\"     : \"https://www.deviantart.com/AndrejSKalin/posts/statuses\",\n    \"#comment\" : \"'deleted' deviations in 'items'\",\n    \"#category\": (\"\", \"deviantart\", \"status\"),\n    \"#class\"   : deviantart.DeviantartStatusExtractor,\n    \"#options\" : {\n        \"journals\"    : \"none\",\n        \"original\"    : 0,\n        \"image-filter\": \"deviationid[:8] == '147C8B03'\",\n    },\n    \"#count\"   : 2,\n    \"#archive\" : False,\n\n    \"deviationid\": \"147C8B03-7D34-AE93-9241-FA3C6DBBC655\",\n},\n\n{\n    \"#url\"     : \"https://www.deviantart.com/justgalym/posts/statuses\",\n    \"#category\": (\"\", \"deviantart\", \"status\"),\n    \"#class\"   : deviantart.DeviantartStatusExtractor,\n    \"#options\" : {\"journals\": \"text\"},\n    \"#sha1_url\": \"10a336bdee7b9692919461443a7dde44d495818c\",\n},\n\n{\n    \"#url\"     : \"https://www.deviantart.com/tag/nature\",\n    \"#category\": (\"\", \"deviantart\", \"tag\"),\n    \"#class\"   : deviantart.DeviantartTagExtractor,\n    \"#options\" : {\"original\": False},\n    \"#range\"   : \"1-30\",\n    \"#count\"   : 30,\n},\n\n{\n    \"#url\"     : \"https://www.deviantart.com/watch/deviations\",\n    \"#category\": (\"\", \"deviantart\", \"watch\"),\n    \"#class\"   : deviantart.DeviantartWatchExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.deviantart.com/notifications/watch\",\n    \"#category\": (\"\", \"deviantart\", \"watch\"),\n    \"#class\"   : deviantart.DeviantartWatchExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.deviantart.com/watch/posts\",\n    \"#category\": (\"\", \"deviantart\", \"watch-posts\"),\n    \"#class\"   : deviantart.DeviantartWatchPostsExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.deviantart.com/shimoda7/art/For-the-sake-10073852\",\n    \"#category\": (\"\", \"deviantart\", \"deviation\"),\n    \"#class\"   : deviantart.DeviantartDeviationExtractor,\n    \"#options\"     : {\"original\": 0},\n    \"#sha1_content\": \"6a7c74dc823ebbd457bdd9b3c2838a6ee728091e\",\n},\n\n{\n    \"#url\"     : \"https://www.deviantart.com/shimoda7/art/For-the-sake-10073852\",\n    \"#category\": (\"\", \"deviantart\", \"deviation\"),\n    \"#class\"   : deviantart.DeviantartDeviationExtractor,\n    \"#options\" : {\"metadata\": \"submission,camera,stats\"},\n\n    \"can_post_comment\": False,\n    \"description\"     : str,\n    \"is_watching\"     : False,\n    \"license\"         : \"No License\",\n    \"stats\": {\n        \"comments\"       : int,\n        \"downloads\"      : int,\n        \"downloads_today\": int,\n        \"favourites\"     : int,\n        \"views\"          : int,\n        \"views_today\"    : int,\n    },\n    \"submission\": {\n        \"category\"      : \"traditional/drawings/other\",\n        \"creation_time\" : \"2004-08-25T02:44:08-0700\",\n        \"file_size\"     : \"133 KB\",\n        \"resolution\"    : \"710x510\",\n        \"submitted_with\": {\n            \"app\": \"Unknown App\",\n            \"url\": \"\"\n        },\n    },\n    \"tags\": [],\n},\n\n{\n    \"#url\"     : \"https://www.deviantart.com/zzz/art/zzz-1234567890\",\n    \"#category\": (\"\", \"deviantart\", \"deviation\"),\n    \"#class\"   : deviantart.DeviantartDeviationExtractor,\n    \"#exception\": exception.NotFoundError,\n},\n\n{\n    \"#url\"    : \"https://www.deviantart.com/justatest235723/art/archive-1103129101\",\n    \"#comment\": \"ZIP archive + preview image (#3782)\",\n    \"#class\"  : deviantart.DeviantartDeviationExtractor,\n    \"#options\": {\"previews\": True},\n    \"#pattern\": [\n        r\"/f/940f2d05-c5eb-4917-8192-7eb6a2d508c6/di8rvv1-afe65948-16e1-4eca-b08d-9e6aaa9ed344\\.zip\",\n        r\"/i/940f2d05-c5eb-4917-8192-7eb6a2d508c6/di8rvv1-bb9d891f-4374-4203-acd3-aea34b29a6a1\\.png\",\n    ],\n},\n\n{\n    \"#url\"     : \"https://www.deviantart.com/myria-moon/art/Aime-Moi-261986576\",\n    \"#category\": (\"\", \"deviantart\", \"deviation\"),\n    \"#class\"   : deviantart.DeviantartDeviationExtractor,\n    \"#options\" : {\"comments\": True},\n    \"#pattern\" : r\"https://wixmp-[^.]+\\.wixmp\\.com/f/.+/.+\\.jpg\\?token=.+\",\n\n    \"comments\": \"len:44\",\n},\n\n{\n    \"#url\"     : \"https://www.deviantart.com/justatest235723/art/Blue-811519058\",\n    \"#comment\" : \"nested comments (#4653)\",\n    \"#category\": (\"\", \"deviantart\", \"deviation\"),\n    \"#class\"   : deviantart.DeviantartDeviationExtractor,\n    \"#options\" : {\n        \"original\": False,\n        \"comments\": True,\n    },\n\n    \"comments\": \"len:20\",\n},\n\n{\n    \"#url\"     : \"https://www.deviantart.com/justatest235723/art/Blue-811519058\",\n    \"#comment\" : \"comment avatars (#4995)\",\n    \"#category\": (\"\", \"deviantart\", \"deviation\"),\n    \"#class\"   : deviantart.DeviantartDeviationExtractor,\n    \"#options\" : {\n        \"original\"        : False,\n        \"comments-avatars\": True,\n    },\n    \"#range\"   : \"5-\",\n    \"#pattern\" : r\"^https://www\\.deviantart\\.com/justatest235723/avatar/$\",\n    \"#count\"   : 16,\n},\n\n{\n    \"#url\"     : \"https://www.deviantart.com/citizenfresh/art/Hverarond-789295466\",\n    \"#comment\" : \"wixmp URL rewrite /intermediary/\",\n    \"#category\": (\"\", \"deviantart\", \"deviation\"),\n    \"#class\"   : deviantart.DeviantartDeviationExtractor,\n    \"#results\" : \"https://images-wixmp-ed30a86b8c4ca887773594c2.wixmp.com/intermediary/f/4deb0f1a-cdef-444e-b194-c8d6b3f7e933/dd1xca2-7f835e62-6fd3-4b99-92c7-2bfd4e1b296f.jpg\",\n\n    \"is_downloadable\": False,\n    \"is_original\"    : False,\n},\n\n{\n    \"#url\"     : \"https://www.deviantart.com/skatergators/art/COM-Moni-781571783\",\n    \"#comment\" : \"GIF (#242)\",\n    \"#category\": (\"\", \"deviantart\", \"deviation\"),\n    \"#class\"   : deviantart.DeviantartDeviationExtractor,\n    \"#pattern\" : r\"https://wixmp-\\w+\\.wixmp\\.com/f/03fd2413-efe9-4e5c-8734-2b72605b3fbb/dcxbsnb-1bbf0b38-42af-4070-8878-f30961955bec\\.gif\\?token=ey...\",\n},\n\n{\n    \"#url\"     : \"https://www.deviantart.com/yuumei/art/Flash-Comic-214724929\",\n    \"#comment\" : \"Flash animation with GIF preview (#1731)\",\n    \"#category\": (\"\", \"deviantart\", \"deviation\"),\n    \"#class\"   : deviantart.DeviantartDeviationExtractor,\n    \"#pattern\" : r\"https://wixmp-[^.]+\\.wixmp\\.com/f/.+/.+\\.swf\\?token=.+\",\n\n    \"filename\" : \"flash_comic_tutorial_by_yuumei-d3juatd\",\n    \"extension\": \"swf\",\n},\n\n{\n    \"#url\"     : \"https://www.deviantart.com/justatest235723/art/video-1103119114\",\n    \"#comment\" : \"video\",\n    \"#class\"   : deviantart.DeviantartDeviationExtractor,\n    \"#pattern\" : r\"/f/940f2d05-c5eb-4917-8192-7eb6a2d508c6/di8ro5m-e2a5bdf0-daee-4e18-bede-fbfc394d6c65\\.mp4\\?token=ey\",\n\n    \"filename\" : \"video_63aebdd4bc0323da460796b9a2ac8522_by_justatest235723-di8ro5m\",\n    \"extension\": \"mp4\",\n},\n\n{\n    \"#url\"     : \"https://www.deviantart.com/uotapo/art/INANAKI-Memo-590297498\",\n    \"#comment\" : \"sta.sh URLs from description (#302)\",\n    \"#category\": (\"\", \"deviantart\", \"deviation\"),\n    \"#class\"   : deviantart.DeviantartDeviationExtractor,\n    \"#options\" : {\n        \"extra\"   : 1,\n        \"original\": 0,\n    },\n    \"#pattern\" : deviantart.DeviantartStashExtractor.pattern,\n    \"#range\"   : \"2-\",\n    \"#count\"   : 4,\n},\n\n{\n    \"#url\"     : \"https://www.deviantart.com/cimar-wildehopps/art/Honorary-Vixen-859809305\",\n    \"#comment\" : \"sta.sh URL from deviation['text_content']['body']['features']\",\n    \"#category\": (\"\", \"deviantart\", \"deviation\"),\n    \"#class\"   : deviantart.DeviantartDeviationExtractor,\n    \"#options\" : {\"extra\": 1},\n    \"#pattern\" : r\"\"\"text:<!DOCTYPE html>\n|(?:https?://)?sta\\.sh/([a-z0-9]+)\"\"\",\n    \"#count\"   : 2,\n},\n\n{\n    \"#url\"     : \"https://www.deviantart.com/shimoda7/journal/ARTility-583755752\",\n    \"#comment\" : \"journal\",\n    \"#category\": (\"\", \"deviantart\", \"deviation\"),\n    \"#class\"   : deviantart.DeviantartDeviationExtractor,\n    \"#pattern\" : \"\"\"text:<!DOCTYPE html>\\n\"\"\",\n    \"#sha1_url\": \"37302947642d1e53392ef8ee9b3f473a3c578e7c\",\n},\n\n{\n    \"#url\"     : \"https://www.deviantart.com/gliitchlord/art/brashstrokes-812942668\",\n    \"#comment\" : \"journal-like post with isJournal == False (#419)\",\n    \"#category\": (\"\", \"deviantart\", \"deviation\"),\n    \"#class\"   : deviantart.DeviantartDeviationExtractor,\n    \"#pattern\" : \"\"\"text:<!DOCTYPE html>\\n\"\"\",\n    \"#sha1_url\": \"8ca1dc8df53d3707c778d08a604f9ad9ddba7469\",\n},\n\n{\n    \"#url\"     : \"https://www.deviantart.com/stash/09z3557z648\",\n    \"#comment\" : \"sta.sh journal (#6207)\",\n    \"#class\"   : deviantart.DeviantartStashExtractor,\n    \"#pattern\" : \"\"\"text:<!DOCTYPE html>\\n\"\"\",\n},\n\n{\n    \"#url\"     : \"https://www.deviantart.com/starvinglunatic/art/Against-the-world-chapter-1-50968347\",\n    \"#comment\" : \"literature (#6254)\",\n    \"#class\"   : deviantart.DeviantartDeviationExtractor,\n    \"#pattern\" : \"\"\"text:<!DOCTYPE html>\\n\"\"\",\n},\n\n\n{\n    \"#url\"     : \"https://www.deviantart.com/neotypical/art/985226590\",\n    \"#comment\" : \"subscription locked (#4567)\",\n    \"#category\": (\"\", \"deviantart\", \"deviation\"),\n    \"#class\"   : deviantart.DeviantartDeviationExtractor,\n    \"#count\"   : 0,\n    \"#exception\": exception.NotFoundError,\n},\n\n{\n    \"#url\"     : \"https://www.deviantart.com/colibriworkshop/art/Crimson-Pandaren-Phoenix-World-of-Warcraft-630984457\",\n    \"#comment\" : \"'png' option (#4846)\",\n    \"#category\": (\"\", \"deviantart\", \"deviation\"),\n    \"#class\"   : deviantart.DeviantartDeviationExtractor,\n    \"#options\"     : {\"quality\": \"png\", \"intermediary\": False},\n    \"#sha1_content\": \"75fb92a820b154c061f7e1f9935260577b2365ec\",\n    \"#pattern\"     : r\"https://images-wixmp-ed30a86b8c4ca887773594c2.wixmp.com\"\n                     r\"/f/d86d1faa-37a8-4bcb-b421-53331885d763/dafo6q1-5c4c999a-019e-4845-8c29-6fab2d05c8e8\\.jpg\"\n                     r\"/v1/fill/w_1024,h_1297,q_75,strp\"\n                     r\"/crimson_pandaren_phoenix_world_of_warcraft_by_colibriworkshop_dafo6q1-fullview\\.png\"\n                     r\"\\?token=ey.+\",\n\n    \"extension\": \"png\",\n},\n\n{\n    \"#url\"     : \"https://www.deviantart.com/justatest235723/art/Numbers-1133021832\",\n    \"#comment\" : \"multiple images (#6653)\",\n    \"#category\": (\"\", \"deviantart\", \"deviation\"),\n    \"#class\"   : deviantart.DeviantartDeviationExtractor,\n    \"#archive\" : False,\n    \"#pattern\" : (\n        r\"https://wixmp-ed30a86b8c4ca887773594c2.wixmp.com/f/940f2d05-c5eb-4917-8192-7eb6a2d508c6/diqkl8o-235680f0-7746-485c-9022-6042ab1f4d50\\.png\\?token=ey.+\",\n        r\"https://images-wixmp-ed30a86b8c4ca887773594c2.wixmp.com/f/940f2d05-c5eb-4917-8192-7eb6a2d508c6/diqkl8o-a47549b4-427d-404d-9a39-64cc07c6b5fb\\.png\\?token=ey.+\",\n        r\"https://images-wixmp-ed30a86b8c4ca887773594c2.wixmp.com/f/940f2d05-c5eb-4917-8192-7eb6a2d508c6/diqkl8o-faac0af6-ef9b-4c49-82af-349ba9f4acf7\\.png\\?token=ey.+\",\n        r\"https://images-wixmp-ed30a86b8c4ca887773594c2.wixmp.com/f/940f2d05-c5eb-4917-8192-7eb6a2d508c6/diqkl8o-34396355-d67d-4069-987f-b80f25495635\\.png\\?token=ey.+\",\n    ),\n\n    \"index\"     : 1133021832,\n    \"index_file\": {0, 810469878, 810469899, 810469922},\n    \"count\"     : 4,\n    \"num\"       : range(1, 4),\n},\n\n{\n    \"#url\"     : \"https://www.deviantart.com/exusiasword/art/Zenith-and-Silpha-combo-1186254616\",\n    \"#comment\" : \"JSON escapes in 'additionalMedia' (#6653)\",\n    \"#category\": (\"\", \"deviantart\", \"deviation\"),\n    \"#class\"   : deviantart.DeviantartDeviationExtractor,\n    \"#archive\" : False,\n    \"#pattern\" : (\n        r\"https://wixmp-ed30a86b8c4ca887773594c2.wixmp.com/f/54e06808-f21d-4b8d-bd48-bdea03cf0af5/djm9jx4-23fc1032-ee0d-460b-ac52-fcdf5e871317\\.jpg\\?token=ey.+\",\n        r\"https://images-wixmp-ed30a86b8c4ca887773594c2.wixmp.com/f/54e06808-f21d-4b8d-bd48-bdea03cf0af5/djm9jx4-7710b540-c27c-41f2-ae84-9d050ec170bc\\.png\\?token=ey.+\",\n        r\"https://images-wixmp-ed30a86b8c4ca887773594c2.wixmp.com/f/54e06808-f21d-4b8d-bd48-bdea03cf0af5/djm9jx4-e04525ea-b781-451a-ae70-b66243417868\\.png\\?token=ey.+\",\n    ),\n},\n\n{\n    \"#url\"     : \"https://deviantart.com/view/904858796/\",\n    \"#comment\" : \"/view/ URLs\",\n    \"#category\": (\"\", \"deviantart\", \"deviation\"),\n    \"#class\"   : deviantart.DeviantartDeviationExtractor,\n    \"#sha1_content\": \"8770ec40ad1c1d60f6b602b16301d124f612948f\",\n},\n\n{\n    \"#url\"     : \"http://www.deviantart.com/view/890672057\",\n    \"#category\": (\"\", \"deviantart\", \"deviation\"),\n    \"#class\"   : deviantart.DeviantartDeviationExtractor,\n    \"#sha1_content\": \"1497e13d925caeb13a250cd666b779a640209236\",\n},\n\n{\n    \"#url\"     : \"https://www.deviantart.com/view/706871727\",\n    \"#category\": (\"\", \"deviantart\", \"deviation\"),\n    \"#class\"   : deviantart.DeviantartDeviationExtractor,\n    \"#sha1_content\": \"4d013515e72dec1e3977c82fd71ce4b15b8bd856\",\n},\n\n{\n    \"#url\"     : \"https://www.deviantart.com/view/1\",\n    \"#category\": (\"\", \"deviantart\", \"deviation\"),\n    \"#class\"   : deviantart.DeviantartDeviationExtractor,\n    \"#exception\": exception.NotFoundError,\n},\n\n{\n    \"#url\"     : \"https://www.deviantart.com/deviation/817215762\",\n    \"#comment\" : \"/deviation/ (#3558)\",\n    \"#category\": (\"\", \"deviantart\", \"deviation\"),\n    \"#class\"   : deviantart.DeviantartDeviationExtractor,\n},\n\n{\n    \"#url\"     : \"https://fav.me/ddijrpu\",\n    \"#comment\" : \"fav.me (#3558)\",\n    \"#category\": (\"\", \"deviantart\", \"deviation\"),\n    \"#class\"   : deviantart.DeviantartDeviationExtractor,\n    \"#count\"   : 1,\n},\n\n{\n    \"#url\"     : \"https://fav.me/dddd\",\n    \"#category\": (\"\", \"deviantart\", \"deviation\"),\n    \"#class\"   : deviantart.DeviantartDeviationExtractor,\n    \"#exception\": exception.NotFoundError,\n},\n\n{\n    \"#url\"     : \"https://shimoda7.deviantart.com/art/For-the-sake-of-a-memory-10073852\",\n    \"#comment\" : \"old-style URLs\",\n    \"#category\": (\"\", \"deviantart\", \"deviation\"),\n    \"#class\"   : deviantart.DeviantartDeviationExtractor,\n},\n\n{\n    \"#url\"     : \"https://myria-moon.deviantart.com/art/Aime-Moi-part-en-vadrouille-261986576\",\n    \"#category\": (\"\", \"deviantart\", \"deviation\"),\n    \"#class\"   : deviantart.DeviantartDeviationExtractor,\n},\n\n{\n    \"#url\"     : \"https://zzz.deviantart.com/art/zzz-1234567890\",\n    \"#category\": (\"\", \"deviantart\", \"deviation\"),\n    \"#class\"   : deviantart.DeviantartDeviationExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.deviantart.com/view.php?id=14864502\",\n    \"#comment\" : \"old /view/ URLs from the Wayback Machine\",\n    \"#category\": (\"\", \"deviantart\", \"deviation\"),\n    \"#class\"   : deviantart.DeviantartDeviationExtractor,\n},\n\n{\n    \"#url\"     : \"http://www.deviantart.com/view-full.php?id=100842\",\n    \"#category\": (\"\", \"deviantart\", \"deviation\"),\n    \"#class\"   : deviantart.DeviantartDeviationExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.fxdeviantart.com/zzz/art/zzz-1234567890\",\n    \"#category\": (\"\", \"deviantart\", \"deviation\"),\n    \"#class\"   : deviantart.DeviantartDeviationExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.fxdeviantart.com/view/1234567890\",\n    \"#category\": (\"\", \"deviantart\", \"deviation\"),\n    \"#class\"   : deviantart.DeviantartDeviationExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.deviantart.com/shimoda7/gallery/scraps\",\n    \"#category\": (\"\", \"deviantart\", \"scraps\"),\n    \"#class\"   : deviantart.DeviantartScrapsExtractor,\n    \"#count\"   : 12,\n},\n\n{\n    \"#url\"     : \"https://www.deviantart.com/chain-man/gallery/scraps\",\n    \"#comment\" : \"deactivated account\",\n    \"#category\": (\"\", \"deviantart\", \"scraps\"),\n    \"#class\"   : deviantart.DeviantartScrapsExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.deviantart.com/shimoda7/gallery/?catpath=scraps\",\n    \"#category\": (\"\", \"deviantart\", \"scraps\"),\n    \"#class\"   : deviantart.DeviantartScrapsExtractor,\n},\n\n{\n    \"#url\"     : \"https://shimoda7.deviantart.com/gallery/?catpath=scraps\",\n    \"#category\": (\"\", \"deviantart\", \"scraps\"),\n    \"#class\"   : deviantart.DeviantartScrapsExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.deviantart.com/search?q=tree\",\n    \"#category\": (\"\", \"deviantart\", \"search\"),\n    \"#class\"   : deviantart.DeviantartSearchExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.deviantart.com/search/deviations?order=popular-1-week\",\n    \"#category\": (\"\", \"deviantart\", \"search\"),\n    \"#class\"   : deviantart.DeviantartSearchExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.deviantart.com/shimoda7/gallery?q=memory\",\n    \"#category\": (\"\", \"deviantart\", \"gallery-search\"),\n    \"#class\"   : deviantart.DeviantartGallerySearchExtractor,\n    \"#options\"     : {\"original\": 0},\n    \"#sha1_content\": \"6a7c74dc823ebbd457bdd9b3c2838a6ee728091e\",\n},\n\n{\n    \"#url\"     : \"https://www.deviantart.com/shimoda7/gallery?q=memory&sort=popular\",\n    \"#category\": (\"\", \"deviantart\", \"gallery-search\"),\n    \"#class\"   : deviantart.DeviantartGallerySearchExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.deviantart.com/shimoda7/about#watching\",\n    \"#category\": (\"\", \"deviantart\", \"following\"),\n    \"#class\"   : deviantart.DeviantartFollowingExtractor,\n    \"#pattern\" : deviantart.DeviantartUserExtractor.pattern,\n    \"#range\"   : \"1-50\",\n    \"#count\"   : 50,\n},\n\n{\n    \"#url\"     : \"https://www.deviantart.com/shimoda7/watching\",\n    \"#category\": (\"\", \"deviantart\", \"following\"),\n    \"#class\"   : deviantart.DeviantartFollowingExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/directlink.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import directlink\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://en.wikipedia.org/static/images/project-logos/enwiki.png\",\n    \"#category\": (\"\", \"directlink\", \"wikipedia.org\"),\n    \"#class\"   : directlink.DirectlinkExtractor,\n    \"#sha1_url\"     : \"18c5d00077332e98e53be9fed2ee4be66154b88d\",\n    \"#sha1_metadata\": \"326ac83735d3a103ccd71f2aeea831f6d62e7836\",\n    \"#sha1_content\" : \"e6f58aaec8f31eb222f9e10fa9e9f64b79ae888c\",\n\n    \"category\"   : \"directlink\",\n    \"subcategory\": \"wikipedia.org\",\n    \"domain\"     : \"en.wikipedia.org\",\n    \"path\"       : \"static/images/project-logos\",\n    \"filename\"   : \"enwiki\",\n    \"extension\"  : \"png\",\n    \"query\"      : None,\n    \"fragment\"   : None,\n},\n\n{\n    \"#url\"     : \"https://example.org/file.webm\",\n    \"#comment\" : \"empty path\",\n    \"#category\": (\"\", \"directlink\", \"example.org\"),\n    \"#class\"   : directlink.DirectlinkExtractor,\n    \"#results\" : \"https://example.org/file.webm\",\n\n    \"domain\"   : \"example.org\",\n    \"path\"     : \"\",\n    \"filename\" : \"file\",\n    \"extension\": \"webm\",\n},\n\n{\n    \"#url\"     : \"https://example.org/path/to/file.webm?que=1?&ry=2/#fragment\",\n    \"#comment\" : \"more complex example\",\n    \"#category\": (\"\", \"directlink\", \"example.org\"),\n    \"#class\"   : directlink.DirectlinkExtractor,\n    \"#results\" : \"https://example.org/path/to/file.webm?que=1?&ry=2/#fragment\",\n\n    \"domain\"   : \"example.org\",\n    \"path\"     : \"path/to\",\n    \"filename\" : \"file\",\n    \"extension\": \"webm\",\n    \"query\"    : \"que=1?&ry=2/\",\n    \"fragment\" : \"fragment\",\n},\n\n{\n    \"#url\"     : \"https://example.org/%27%3C%23/%23%3E%27.jpg?key=%3C%26%3E\",\n    \"#comment\" : \"percent-encoded characters\",\n    \"#category\": (\"\", \"directlink\", \"example.org\"),\n    \"#class\"   : directlink.DirectlinkExtractor,\n    \"#results\" : \"https://example.org/%27%3C%23/%23%3E%27.jpg?key=%3C%26%3E\",\n\n    \"domain\"   : \"example.org\",\n    \"path\"     : \"'<#\",\n    \"filename\" : \"#>'\",\n    \"extension\": \"jpg\",\n    \"query\"    : \"key=<&>\",\n    \"fragment\" : None,\n},\n\n{\n    \"#url\"     : \"https://post-phinf.pstatic.net/MjAxOTA1MjlfMTQ4/MDAxNTU5MTI2NjcyNTkw.JUzkGb4V6dj9DXjLclrOoqR64uDxHFUO5KDriRdKpGwg.88mCtd4iT1NHlpVKSCaUpPmZPiDgT8hmQdQ5K_gYyu0g.JPEG/2.JPG\",\n    \"#comment\" : \"upper case file extension (#296)\",\n    \"#category\": (\"\", \"directlink\", \"pstatic.net\"),\n    \"#class\"   : directlink.DirectlinkExtractor,\n},\n\n{\n    \"#url\"     : \"https://räksmörgås.josefsson.org/raksmorgas.jpg\",\n    \"#comment\" : \"internationalized domain name\",\n    \"#category\": (\"\", \"directlink\", \"josefsson.org\"),\n    \"#class\"   : directlink.DirectlinkExtractor,\n    \"#results\" : \"https://räksmörgås.josefsson.org/raksmorgas.jpg\",\n\n    \"domain\"   : \"räksmörgås.josefsson.org\",\n    \"path\"     : \"\",\n    \"filename\" : \"raksmorgas\",\n    \"extension\": \"jpg\",\n    \"query\"    : None,\n    \"fragment\" : None,\n},\n\n{\n    \"#url\"     : \"https://example.org/file.gif\",\n    \"#category\": (\"\", \"directlink\", \"example.org\"),\n    \"#class\"   : directlink.DirectlinkExtractor,\n},\n{\n    \"#url\"     : \"https://example.org/file.bmp\",\n    \"#category\": (\"\", \"directlink\", \"example.org\"),\n    \"#class\"   : directlink.DirectlinkExtractor,\n},\n{\n    \"#url\"     : \"https://example.org/file.svg\",\n    \"#category\": (\"\", \"directlink\", \"example.org\"),\n    \"#class\"   : directlink.DirectlinkExtractor,\n},\n{\n    \"#url\"     : \"https://example.org/file.webp\",\n    \"#category\": (\"\", \"directlink\", \"example.org\"),\n    \"#class\"   : directlink.DirectlinkExtractor,\n},\n{\n    \"#url\"     : \"https://example.org/file.avif\",\n    \"#category\": (\"\", \"directlink\", \"example.org\"),\n    \"#class\"   : directlink.DirectlinkExtractor,\n},\n{\n    \"#url\"     : \"https://example.org/file.heic\",\n    \"#category\": (\"\", \"directlink\", \"example.org\"),\n    \"#class\"   : directlink.DirectlinkExtractor,\n},\n{\n    \"#url\"     : \"https://example.org/file.psd\",\n    \"#category\": (\"\", \"directlink\", \"example.org\"),\n    \"#class\"   : directlink.DirectlinkExtractor,\n},\n{\n    \"#url\"     : \"https://example.org/file.mp4\",\n    \"#category\": (\"\", \"directlink\", \"example.org\"),\n    \"#class\"   : directlink.DirectlinkExtractor,\n},\n{\n    \"#url\"     : \"https://example.org/file.m4v\",\n    \"#category\": (\"\", \"directlink\", \"example.org\"),\n    \"#class\"   : directlink.DirectlinkExtractor,\n},\n{\n    \"#url\"     : \"https://example.org/file.mov\",\n    \"#category\": (\"\", \"directlink\", \"example.org\"),\n    \"#class\"   : directlink.DirectlinkExtractor,\n},\n{\n    \"#url\"     : \"https://example.org/file.mkv\",\n    \"#category\": (\"\", \"directlink\", \"example.org\"),\n    \"#class\"   : directlink.DirectlinkExtractor,\n},\n{\n    \"#url\"     : \"https://example.org/file.ogg\",\n    \"#category\": (\"\", \"directlink\", \"example.org\"),\n    \"#class\"   : directlink.DirectlinkExtractor,\n},\n{\n    \"#url\"     : \"https://example.org/file.ogm\",\n    \"#category\": (\"\", \"directlink\", \"example.org\"),\n    \"#class\"   : directlink.DirectlinkExtractor,\n},\n{\n    \"#url\"     : \"https://example.org/file.ogv\",\n    \"#category\": (\"\", \"directlink\", \"example.org\"),\n    \"#class\"   : directlink.DirectlinkExtractor,\n},\n{\n    \"#url\"     : \"https://example.org/file.wav\",\n    \"#category\": (\"\", \"directlink\", \"example.org\"),\n    \"#class\"   : directlink.DirectlinkExtractor,\n},\n{\n    \"#url\"     : \"https://example.org/file.mp3\",\n    \"#category\": (\"\", \"directlink\", \"example.org\"),\n    \"#class\"   : directlink.DirectlinkExtractor,\n},\n{\n    \"#url\"     : \"https://example.org/file.opus\",\n    \"#category\": (\"\", \"directlink\", \"example.org\"),\n    \"#class\"   : directlink.DirectlinkExtractor,\n},\n{\n    \"#url\"     : \"https://example.org/file.zip\",\n    \"#category\": (\"\", \"directlink\", \"example.org\"),\n    \"#class\"   : directlink.DirectlinkExtractor,\n},\n{\n    \"#url\"     : \"https://example.org/file.rar\",\n    \"#category\": (\"\", \"directlink\", \"example.org\"),\n    \"#class\"   : directlink.DirectlinkExtractor,\n},\n{\n    \"#url\"     : \"https://example.org/file.7z\",\n    \"#category\": (\"\", \"directlink\", \"example.org\"),\n    \"#class\"   : directlink.DirectlinkExtractor,\n},\n{\n    \"#url\"     : \"https://example.org/file.pdf\",\n    \"#category\": (\"\", \"directlink\", \"example.org\"),\n    \"#class\"   : directlink.DirectlinkExtractor,\n},\n{\n    \"#url\"     : \"https://example.org/file.swf\",\n    \"#category\": (\"\", \"directlink\", \"example.org\"),\n    \"#class\"   : directlink.DirectlinkExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/discord.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import discord\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://discord.com/channels/302094807046684672/1306706528786583623/1306706528786583623\",\n    \"#category\": (\"\", \"discord\", \"message\"),\n    \"#class\"   : discord.DiscordMessageExtractor,\n},\n\n{\n    \"#url\"     : \"https://discord.com/channels/@me/1306706528786583623/1306706528786583623\",\n    \"#category\": (\"\", \"discord\", \"direct-message\"),\n    \"#class\"   : discord.DiscordDirectMessageExtractor,\n},\n\n{\n    \"#url\"     : \"https://discord.com/channels/302094807046684672/1306705919916249098\",\n    \"#category\": (\"\", \"discord\", \"channel\"),\n    \"#class\"   : discord.DiscordChannelExtractor,\n#    # access token & access to minecraft server required for this test (REMEMBER TO REMOVE TOKEN BEFORE COMMITTING)\n#    \"#range\"   : \"1-2\",\n#    \"#count\"   : 2,\n#    \"#options\" : {\"token\": \"\"},\n#\n#    \"#server\"       : \"MINECRAFT\",\n#    \"#server_id\"    : \"302094807046684672\",\n#    \"#server_files\" : list,\n#    \"#owner_id\"     : \"827254075857829920\",\n#    \"#channel\"      : str,\n#    \"#channel_id\"   : str,\n#    \"#channel_type\" : 11,\n#    \"#channel_topic\": str,\n#    \"#parent\"       : \"challenges\",\n#    \"#parent_id\"    : \"1306705919916249098\",\n#    \"#parent_type\"  : 15,\n#    \"#is_thread\"    : True,\n#\n#    \"author\"      : str,\n#    \"author_id\"   : str,\n#    \"author_files\": list,\n#    \"message\"     : str,\n#    \"message_id\"  : str,\n#    \"type\"        : str,\n#    \"date\"        : \"type:datetime\",\n#    \"files\"       : list,\n#    \"filename\"    : str,\n#    \"extension\"   : str,\n#    \"num\"         : int,\n},\n\n{\n    \"#url\"     : \"https://discord.com/channels/302094807046684672/1306705919916249098/threads/1306706528786583623\",\n    \"#category\": (\"\", \"discord\", \"channel\"),\n    \"#class\"   : discord.DiscordChannelExtractor,\n},\n\n{\n    \"#url\"     : \"https://discord.com/channels/302094807046684672\",\n    \"#category\": (\"\", \"discord\", \"server\"),\n    \"#class\"   : discord.DiscordServerExtractor,\n},\n\n{\n    \"#url\"     : \"https://discord.com/channels/@me/302094807046684672\",\n    \"#category\": (\"\", \"discord\", \"direct-messages\"),\n    \"#class\"   : discord.DiscordDirectMessagesExtractor,\n},\n\n{\n    \"#url\"     : \"https://discord.com/channels/403905762268545024/assets\",\n    \"#class\"   : discord.DiscordServerAssetsExtractor,\n    \"#auth\"    : \"token\",\n    \"#count\"   : range(380, 450),\n\n    \"name\"     : str,\n    \"filename\" : str,\n    \"extension\": \"png\",\n    \"id\"       : str,\n    \"label\"    : {\"\", \"emojis\", \"stickers\"},\n    \"owner_id\" : \"699203962691256400\",\n    \"server\"   : \"MangaDex\",\n    \"server_id\": \"403905762268545024\",\n    \"url\"      : str,\n},\n\n{\n    \"#url\"     : \"https://discord.com/channels/403905762268545024/assets/general\",\n    \"#class\"   : discord.DiscordServerAssetsExtractor,\n    \"#auth\"    : \"token\",\n    \"#count\"   : 3,\n\n    \"name\"     : {\"icon\", \"banner\", \"splash\"},\n    \"filename\" : {\"icon\", \"banner\", \"splash\"},\n    \"extension\": \"png\",\n    \"id\"       : str,\n    \"label\"    : \"general\",\n    \"owner_id\" : \"699203962691256400\",\n    \"server\"   : \"MangaDex\",\n    \"server_id\": \"403905762268545024\",\n    \"url\"      : str,\n},\n\n{\n    \"#url\"     : \"https://discord.com/channels/1067148002722062416/search?from=429235270664060948\",\n    \"#class\"   : discord.DiscordServerSearchExtractor,\n},\n\n{\n    \"#url\"     : \"https://discord.com/channels/1067148002722062416/search?has=file&has=link&mentions=429235270664060948\",\n    \"#class\"   : discord.DiscordServerSearchExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/drawfriends.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import gelbooru_v01\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://drawfriends.booru.org/index.php?page=post&s=list&tags=all\",\n    \"#category\": (\"gelbooru_v01\", \"drawfriends\", \"tag\"),\n    \"#class\"   : gelbooru_v01.GelbooruV01TagExtractor,\n},\n\n{\n    \"#url\"     : \"https://drawfriends.booru.org/index.php?page=favorites&s=view&id=1\",\n    \"#category\": (\"gelbooru_v01\", \"drawfriends\", \"favorite\"),\n    \"#class\"   : gelbooru_v01.GelbooruV01FavoriteExtractor,\n},\n\n{\n    \"#url\"     : \"https://drawfriends.booru.org/index.php?page=post&s=view&id=107474\",\n    \"#category\": (\"gelbooru_v01\", \"drawfriends\", \"post\"),\n    \"#class\"   : gelbooru_v01.GelbooruV01PostExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/dynastyscans.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import dynastyscans\n\n\n__tests__ = (\n{\n    \"#url\"     : \"http://dynasty-scans.com/chapters/hitoribocchi_no_oo_seikatsu_ch33\",\n    \"#category\": (\"\", \"dynastyscans\", \"chapter\"),\n    \"#class\"   : dynastyscans.DynastyscansChapterExtractor,\n    \"#sha1_url\"     : \"3cafa527fecec27a66f35e038c0c53e35d5e4317\",\n    \"#sha1_metadata\": \"7b134f2093813d45774cc68a3cd199ffce3e6fd3\",\n},\n\n{\n    \"#url\"     : \"http://dynasty-scans.com/chapters/new_game_the_spinoff_special_13\",\n    \"#category\": (\"\", \"dynastyscans\", \"chapter\"),\n    \"#class\"   : dynastyscans.DynastyscansChapterExtractor,\n    \"#sha1_url\"     : \"047fa6d58f90272883157a80fbf1e6f03ea5bbab\",\n    \"#sha1_metadata\": \"62dc42e9025c79bdd3e26e026a690f4c28548fd4\",\n},\n\n{\n    \"#url\"     : \"https://dynasty-scans.com/series/hitoribocchi_no_oo_seikatsu\",\n    \"#category\": (\"\", \"dynastyscans\", \"manga\"),\n    \"#class\"   : dynastyscans.DynastyscansMangaExtractor,\n    \"#pattern\" : dynastyscans.DynastyscansChapterExtractor.pattern,\n    \"#count\"   : \">= 100\",\n},\n\n{\n    \"#url\"     : \"https://dynasty-scans.com/images?with[]=4930&with[]=5211\",\n    \"#category\": (\"\", \"dynastyscans\", \"search\"),\n    \"#class\"   : dynastyscans.DynastyscansSearchExtractor,\n\n    \"#sha1_metadata\": \"67690b4e21f59746f112803cba4c4d81fcbb9dbd\",\n    \"#results\"      : (\n        \"https://dynasty-scans.com/system/images_images/000/032/932/full/66051624_p0.webp\",\n        \"https://dynasty-scans.com/system/images_images/000/021/368/full/KEIGI_32-1467964487873486851-img1.webp\",\n        \"https://dynasty-scans.com/system/images_images/000/004/596/full/tortoise.webp\",\n        \"https://dynasty-scans.com/system/images_images/000/003/206/full/1f01f72e19b98bf0083d323e3c28e4bf.webp\",\n        \"https://dynasty-scans.com/system/images_images/000/000/535/full/8564987.webp\",\n    ),\n},\n\n{\n    \"#url\"     : \"https://dynasty-scans.com/images\",\n    \"#category\": (\"\", \"dynastyscans\", \"search\"),\n    \"#class\"   : dynastyscans.DynastyscansSearchExtractor,\n    \"#range\"   : \"1\",\n    \"#count\"   : 1,\n},\n\n{\n    \"#url\"     : \"https://dynasty-scans.com/images/1245\",\n    \"#category\": (\"\", \"dynastyscans\", \"image\"),\n    \"#class\"   : dynastyscans.DynastyscansImageExtractor,\n    \"#sha1_url\"     : \"877054defac8ea2bbaeb632db176037668c73eea\",\n    \"#sha1_metadata\": \"9f6fd139c372203dcf7237e662a80963ab070cb0\",\n},\n\n{\n    \"#url\"     : \"https://dynasty-scans.com/anthologies/%C3%A9clair\",\n    \"#class\"   : dynastyscans.DynastyscansAnthologyExtractor,\n    \"#pattern\" : dynastyscans.DynastyscansChapterExtractor.pattern,\n    \"#options\" : {\"metadata\": True},\n    \"#count\"   : 8,\n\n    \"alert\": [\n        \"This manga has been licensed\",\n        \"Content licensed for English release has been removed from the reader. You can support the author by purchasing the title when it becomes available.\",\n    ],\n    \"anthology\"     : \"Éclair\",\n    \"author\"        : {\"Canno\", \"Kawanami Izumi\", \"Kagero\", \"Mekimeki Oukoku\", \"Itou Hachi\", \"Isaki Uta\", \"Nakatani Nio\", \"Kitao Taki\"},\n    \"date\"          : \"type:datetime\",\n    \"date_updated\"  : \"type:datetime\",\n    \"description\"   : \"<p>A compilation of one-shots from some of the best and most popular recent Yuri mangaka, including Canno (A Kiss and a White Lily for my Dearest Girl), Nakatani Nio (Bloom into you), Amano Shunita (Ayame 14), Itou Hachi (Isn't the Moon Beautiful?/Sayuri's Sister is an Angel) and many more.</p>\\n\\n<p>A must have for any collection, in my opinion, and a great chance to support all of the fabulous artists at once by buying yourself a copy! - Estherlea</p>\",\n    \"scanlator\"     : {\"Estherlea\", \"/u/ Scanlations\"},\n    \"status\"        : \"Licensed\",\n    \"title\"         : str,\n    \"tags\"          : list,\n},\n\n{\n    \"#url\"     : \"https://dynasty-scans.com/anthologies/aashi_to_watashi_gyaru_yuri_anthology\",\n    \"#class\"   : dynastyscans.DynastyscansAnthologyExtractor,\n    \"#results\" : \"https://dynasty-scans.com/chapters/dont_call_me_senpai\",\n\n    \"!alert\"        : (),\n    \"!description\"  : \"\"\"<p><a href=\"https://dynasty-scans.com/anthologies/aashi_to_watashi_gyaru_yuri_anthology_volume_2\">Volume 2</a></p>\"\"\",\n    \"!status\"       : \"\",\n    \"anthology\"     : \"Aashi to Watashi - Gyaru Yuri Anthology\",\n    \"author\"        : \"keyyan\",\n    \"date\"          : \"dt:2024-03-30 04:07:10\",\n    \"date_updated\"  : \"dt:2025-04-04 20:21:36\",\n    \"scanlator\"     : \"Arka\",\n    \"title\"         : '''Don't Call Me \"Senpai\"''',\n    \"tags\"          : [\n        \"big breasts\",\n        \"childhood friends\",\n        \"ecchi\",\n        \"gyaru\",\n        \"height gap\",\n        \"prequel\",\n        \"romance\",\n        \"school girl\",\n        \"yuri\",\n    ],\n},\n\n)\n"
  },
  {
    "path": "test/results/e621.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import e621\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://e621.net/posts?tags=anry\",\n    \"#category\": (\"E621\", \"e621\", \"tag\"),\n    \"#class\"   : e621.E621TagExtractor,\n    \"#options\"     : {\"metadata\": True},\n    \"#sha1_url\"    : \"8021e5ea28d47c474c1ffc9bd44863c4d45700ba\",\n    \"#sha1_content\": \"501d1e5d922da20ee8ff9806f5ed3ce3a684fd58\",\n},\n\n{\n    \"#url\"     : \"https://e621.net/post/index/1/anry\",\n    \"#category\": (\"E621\", \"e621\", \"tag\"),\n    \"#class\"   : e621.E621TagExtractor,\n},\n\n{\n    \"#url\"     : \"https://e621.net/post?tags=anry\",\n    \"#category\": (\"E621\", \"e621\", \"tag\"),\n    \"#class\"   : e621.E621TagExtractor,\n},\n\n{\n    \"#url\"     : \"https://e621.net/post?tags=\",\n    \"#category\": (\"E621\", \"e621\", \"tag\"),\n    \"#class\"   : e621.E621TagExtractor,\n},\n\n{\n    \"#url\"     : \"https://e621.net/pools/73\",\n    \"#category\": (\"E621\", \"e621\", \"pool\"),\n    \"#class\"   : e621.E621PoolExtractor,\n    \"#sha1_url\"    : \"1bd09a72715286a79eea3b7f09f51b3493eb579a\",\n    \"#sha1_content\": \"91abe5d5334425d9787811d7f06d34c77974cd22\",\n},\n\n{\n    \"#url\"     : \"https://e621.net/pool/show/73\",\n    \"#category\": (\"E621\", \"e621\", \"pool\"),\n    \"#class\"   : e621.E621PoolExtractor,\n},\n\n{\n    \"#url\"     : \"https://e621.net/posts/535\",\n    \"#category\": (\"E621\", \"e621\", \"post\"),\n    \"#class\"   : e621.E621PostExtractor,\n    \"#results\" : \"https://static1.e621.net/data/63/0b/630b624cc581023ef9d26fd89d37a410.jpg\",\n    \"#sha1_content\": \"66f46e96a893fba8e694c4e049b23c2acc9af462\",\n\n    \"approver_id\"     : None,\n    \"change_seq\"      : 59815157,\n    \"comment_count\"   : range(4, 8),\n    \"created_at\"      : \"iso:dt\",\n    \"date\"            : \"dt:2007-02-17 19:02:32\",\n    \"description\"     : \"\",\n    \"duration\"        : None,\n    \"extension\"       : \"jpg\",\n    \"fav_count\"       : range(200, 500),\n    \"filename\"        : \"630b624cc581023ef9d26fd89d37a410\",\n    \"has_notes\"       : False,\n    \"id\"              : 535,\n    \"is_favorited\"    : False,\n    \"locked_tags\"     : [],\n    \"pools\"           : [],\n    \"rating\"          : \"s\",\n    \"sources\"         : [\"https://www.anry.ru\"],\n    \"tags_artist\"     : [\"anry\"],\n    \"tags_character\"  : [],\n    \"tags_contributor\": [],\n    \"tags_copyright\"  : [],\n    \"tags_invalid\"    : [],\n    \"tags_lore\"       : [],\n    \"updated_at\"      : \"iso:dt\",\n    \"uploader_id\"     : 8,\n    \"uploader_name\"   : \"mellis\",\n    \"tags\"            : list,\n    \"tags_general\"    : list,\n    \"tags_meta\"       : [\n        \"1:1\",\n        \"english_text\",\n    ],\n    \"tags_species\"    : [\n        \"ambient_arthropod\",\n        \"ambient_insect\",\n        \"ambient_moth\",\n        \"arthropod\",\n        \"canid\",\n        \"canine\",\n        \"fennec_fox\",\n        \"fox\",\n        \"insect\",\n        \"lepidopteran\",\n        \"mammal\",\n        \"moth\",\n        \"true_fox\",\n    ],\n    \"file\"            : {\n        \"ext\"   : \"jpg\",\n        \"height\": 900,\n        \"md5\"   : \"630b624cc581023ef9d26fd89d37a410\",\n        \"size\"  : 155108,\n        \"url\"   : \"https://static1.e621.net/data/63/0b/630b624cc581023ef9d26fd89d37a410.jpg\",\n        \"width\" : 900,\n    },\n    \"flags\"           : {\n        \"deleted\"      : False,\n        \"flagged\"      : False,\n        \"note_locked\"  : False,\n        \"pending\"      : False,\n        \"rating_locked\": False,\n        \"status_locked\": False,\n    },\n    \"preview\"         : {\n        \"alt\"   : \"https://static1.e621.net/data/preview/63/0b/630b624cc581023ef9d26fd89d37a410.webp\",\n        \"height\": 256,\n        \"url\"   : \"https://static1.e621.net/data/preview/63/0b/630b624cc581023ef9d26fd89d37a410.jpg\",\n        \"width\" : 256,\n    },\n    \"relationships\"   : {\n        \"children\"    : [],\n        \"has_active_children\": False,\n        \"has_children\": False,\n        \"parent_id\"   : None,\n    },\n    \"sample\"          : {\n        \"alt\"       : \"https://static1.e621.net/data/sample/63/0b/630b624cc581023ef9d26fd89d37a410.webp\",\n        \"alternates\": {},\n        \"has\"       : True,\n        \"height\"    : 850,\n        \"url\"       : \"https://static1.e621.net/data/sample/63/0b/630b624cc581023ef9d26fd89d37a410.jpg\",\n        \"width\"     : 850,\n    },\n    \"score\"           : {\n        \"down\" : -8,\n        \"total\": 134,\n        \"up\"   : 141,\n    },\n},\n\n{\n    \"#url\"     : \"https://e621.net/posts/3181052\",\n    \"#category\": (\"E621\", \"e621\", \"post\"),\n    \"#class\"   : e621.E621PostExtractor,\n    \"#options\" : {\"metadata\": \"notes,pools\"},\n    \"#pattern\" : r\"https://static\\d\\.e621\\.net/data/c6/8c/c68cca0643890b615f75fb2719589bff\\.png\",\n\n    \"notes\": [\n        {\n            \"body\"        : \"Little Legends 2\",\n            \"created_at\"  : \"2022-05-16T13:58:38.877-04:00\",\n            \"creator_id\"  : 517450,\n            \"creator_name\": \"EeveeCuddler69\",\n            \"height\"      : 475,\n            \"id\"          : 321296,\n            \"is_active\"   : True,\n            \"post_id\"     : 3181052,\n            \"updated_at\"  : \"2022-05-16T13:59:02.050-04:00\",\n            \"version\"     : 3,\n            \"width\"       : 809,\n            \"x\"           : 83,\n            \"y\"           : 117,\n        },\n    ],\n    \"pools\": [\n        {\n            \"category\"    : \"series\",\n            \"created_at\"  : \"2022-02-17T00:29:22.669-05:00\",\n            \"creator_id\"  : 1077440,\n            \"creator_name\": \"Yeetus90\",\n            \"description\" : \"\"\"\\\n[quote]h2.【web再録】ぷち・れじぇんず2\n2015年の関西けもケット4で頒布した個人誌第2弾！\n～行方不明になった親友のビクティニを救うべく怪しげな館に単身乗り込んだミュウ。\nしかし彼女の前には強大な力を持つ館の主が立ちはだかる！果たして二人は無事脱出することができるのか！？～\n \\n\\\nこの頃の方が背景に力が入ってますねw\nあとジャローダの顔の模様思いっきり間違ってますがそこはご愛嬌ということで…[/quote]\n\n* \"Little Legends\":/pools/27971\n* Little Legends 2\n* \"Little Legends 3\":/pools/27481\\\n\"\"\",\n\n            \"id\"          : 27492,\n            \"is_active\"   : False,\n            \"name\"        : \"Little Legends 2\",\n            \"post_count\"  : 39,\n            \"post_ids\"    : list,\n            \"updated_at\"  : \"2025-01-07T22:01:40.319-05:00\",\n        },\n    ],\n},\n\n{\n    \"#url\"     : \"https://e621.net/post/show/535\",\n    \"#category\": (\"E621\", \"e621\", \"post\"),\n    \"#class\"   : e621.E621PostExtractor,\n},\n\n{\n    \"#url\"     : \"https://e621.net/p/312fs\",\n    \"#comment\" : \"'share' URL with base32 ID (#9168)\",\n    \"#category\": (\"E621\", \"e621\", \"post\"),\n    \"#class\"   : e621.E621PostExtractor,\n    \"#results\" : \"https://static1.e621.net/data/c6/8c/c68cca0643890b615f75fb2719589bff.png\",\n\n    \"date\"     : \"dt:2022-02-17 05:27:31\",\n    \"id\"       : 3181052,\n},\n\n{\n    \"#url\"     : \"https://e621.net/explore/posts/popular\",\n    \"#category\": (\"E621\", \"e621\", \"popular\"),\n    \"#class\"   : e621.E621PopularExtractor,\n},\n\n{\n    \"#url\"     : \"https://e621.net/explore/posts/popular?date=2019-06-01&scale=month\",\n    \"#category\": (\"E621\", \"e621\", \"popular\"),\n    \"#class\"   : e621.E621PopularExtractor,\n    \"#pattern\" : r\"https://static\\d.e621.net/data/../../[0-9a-f]+\",\n    \"#count\"   : \">= 70\",\n},\n\n{\n    \"#url\"     : \"https://e621.net/favorites\",\n    \"#category\": (\"E621\", \"e621\", \"favorite\"),\n    \"#class\"   : e621.E621FavoriteExtractor,\n},\n\n{\n    \"#url\"     : \"https://e621.net/favorites?page=1&user_id=460755\",\n    \"#category\": (\"E621\", \"e621\", \"favorite\"),\n    \"#class\"   : e621.E621FavoriteExtractor,\n    \"#pattern\" : r\"https://static\\d.e621.net/data/../../[0-9a-f]+\",\n    \"#count\"   : 15,\n},\n\n{\n    \"#url\"     : \"https://e621.cc/posts?tags=rating:safe\",\n    \"#category\": (\"E621\", \"e621\", \"tag\"),\n    \"#class\"   : e621.E621TagExtractor,\n},\n\n{\n    \"#url\"     : \"https://e621.cc/?tags=rating:safe\",\n    \"#category\": (\"E621\", \"e621\", \"frontend\"),\n    \"#class\"   : e621.E621FrontendExtractor,\n    \"#results\" : \"https://e621.net/posts?tags=rating:safe\",\n},\n\n{\n    \"#url\"     : \"https://e621.anthro.fr/?q=rating:safe\",\n    \"#category\": (\"E621\", \"e621\", \"frontend\"),\n    \"#class\"   : e621.E621FrontendExtractor,\n    \"#results\" : \"https://e621.net/posts?tags=rating:safe\",\n},\n\n{\n    \"#url\"     : \"https://e621.net/artists/54632\",\n    \"#category\": (\"E621\", \"e621\", \"artist\"),\n    \"#class\"   : e621.E621ArtistExtractor,\n    \"#results\" : \"https://e621.net/posts?tags=emsibap\",\n\n    \"created_at\"    : \"2021-05-01T11:28:56.483-04:00\",\n    \"creator_id\"    : 338533,\n    \"domains\"       : [[\n        \"furaffinity.net\",\n        3,\n    ]],\n    \"group_name\"    : \"\",\n    \"id\"            : 54632,\n    \"is_active\"     : True,\n    \"is_locked\"     : False,\n    \"linked_user_id\": None,\n    \"name\"          : \"emsibap\",\n    \"notes\"         : None,\n    \"other_names\"   : [],\n    \"updated_at\"    : \"2021-05-01T11:28:56.488-04:00\",\n    \"urls\"          : [{\n        \"artist_id\"     : 54632,\n        \"created_at\"    : \"2021-05-01T11:28:56.486-04:00\",\n        \"id\"            : 217681,\n        \"is_active\"     : True,\n        \"normalized_url\": \"http://www.furaffinity.net/user/emsibap/\",\n        \"updated_at\"    : \"2021-05-01T11:28:56.486-04:00\",\n        \"url\"           : \"https://www.furaffinity.net/user/emsibap\",\n    }],\n},\n\n{\n    \"#url\"     : \"https://e621.net/artists?search%5Bany_name_or_url_matches%5D=emsi\",\n    \"#category\": (\"E621\", \"e621\", \"artist-search\"),\n    \"#class\"   : e621.E621ArtistSearchExtractor,\n    \"#results\" : (\n        \"https://e621.net/posts?tags=loremsipsum\",\n        \"https://e621.net/posts?tags=demsigma\",\n        \"https://e621.net/posts?tags=emsibap\",\n        \"https://e621.net/posts?tags=aluminemsiren\",\n    ),\n},\n\n)\n"
  },
  {
    "path": "test/results/e6ai.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import e621\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://e6ai.net/posts?tags=anry\",\n    \"#category\": (\"E621\", \"e6ai\", \"tag\"),\n    \"#class\"   : e621.E621TagExtractor,\n},\n\n{\n    \"#url\"     : \"https://e6ai.net/post/index/1/anry\",\n    \"#category\": (\"E621\", \"e6ai\", \"tag\"),\n    \"#class\"   : e621.E621TagExtractor,\n},\n\n{\n    \"#url\"     : \"https://e6ai.net/post?tags=anry\",\n    \"#category\": (\"E621\", \"e6ai\", \"tag\"),\n    \"#class\"   : e621.E621TagExtractor,\n},\n\n{\n    \"#url\"     : \"https://e6ai.net/pools/3\",\n    \"#category\": (\"E621\", \"e6ai\", \"pool\"),\n    \"#class\"   : e621.E621PoolExtractor,\n    \"#sha1_url\": \"a6d1ad67a3fa9b9f73731d34d5f6f26f7e85855f\",\n},\n\n{\n    \"#url\"     : \"https://e6ai.net/pool/show/3\",\n    \"#category\": (\"E621\", \"e6ai\", \"pool\"),\n    \"#class\"   : e621.E621PoolExtractor,\n},\n\n{\n    \"#url\"     : \"https://e6ai.net/posts/23\",\n    \"#category\": (\"E621\", \"e6ai\", \"post\"),\n    \"#class\"   : e621.E621PostExtractor,\n    \"#sha1_url\"    : \"3c85a806b3d9eec861948af421fe0e8ad6b8f881\",\n    \"#sha1_content\": \"a05a484e4eb64637d56d751c02e659b4bc8ea5d5\",\n},\n\n{\n    \"#url\"     : \"https://e6ai.net/post/show/23\",\n    \"#category\": (\"E621\", \"e6ai\", \"post\"),\n    \"#class\"   : e621.E621PostExtractor,\n},\n\n{\n    \"#url\"     : \"https://e6ai.net/explore/posts/popular\",\n    \"#category\": (\"E621\", \"e6ai\", \"popular\"),\n    \"#class\"   : e621.E621PopularExtractor,\n},\n\n{\n    \"#url\"     : \"https://e6ai.net/favorites\",\n    \"#category\": (\"E621\", \"e6ai\", \"favorite\"),\n    \"#class\"   : e621.E621FavoriteExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/e926.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import e621\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://e926.net/posts?tags=anry\",\n    \"#category\": (\"E621\", \"e926\", \"tag\"),\n    \"#class\"   : e621.E621TagExtractor,\n    \"#sha1_url\"    : \"12198b275c62ffe2de67cca676c8e64de80c425d\",\n    \"#sha1_content\": \"501d1e5d922da20ee8ff9806f5ed3ce3a684fd58\",\n},\n\n{\n    \"#url\"     : \"https://e926.net/post/index/1/anry\",\n    \"#category\": (\"E621\", \"e926\", \"tag\"),\n    \"#class\"   : e621.E621TagExtractor,\n},\n\n{\n    \"#url\"     : \"https://e926.net/post?tags=anry\",\n    \"#category\": (\"E621\", \"e926\", \"tag\"),\n    \"#class\"   : e621.E621TagExtractor,\n},\n\n{\n    \"#url\"     : \"https://e926.net/pools/73\",\n    \"#category\": (\"E621\", \"e926\", \"pool\"),\n    \"#class\"   : e621.E621PoolExtractor,\n    \"#sha1_url\"    : \"6936f1b6a18c5c25bee7cad700088dbc2503481b\",\n    \"#sha1_content\": \"91abe5d5334425d9787811d7f06d34c77974cd22\",\n},\n\n{\n    \"#url\"     : \"https://e926.net/pool/show/73\",\n    \"#category\": (\"E621\", \"e926\", \"pool\"),\n    \"#class\"   : e621.E621PoolExtractor,\n},\n\n{\n    \"#url\"     : \"https://e926.net/posts/535\",\n    \"#category\": (\"E621\", \"e926\", \"post\"),\n    \"#class\"   : e621.E621PostExtractor,\n    \"#sha1_url\"    : \"17aec8ebd8fab098d321adcb62a2db59dab1f4bf\",\n    \"#sha1_content\": \"66f46e96a893fba8e694c4e049b23c2acc9af462\",\n},\n\n{\n    \"#url\"     : \"https://e926.net/post/show/535\",\n    \"#category\": (\"E621\", \"e926\", \"post\"),\n    \"#class\"   : e621.E621PostExtractor,\n},\n\n{\n    \"#url\"     : \"https://e926.net/explore/posts/popular\",\n    \"#category\": (\"E621\", \"e926\", \"popular\"),\n    \"#class\"   : e621.E621PopularExtractor,\n},\n\n{\n    \"#url\"     : \"https://e926.net/explore/posts/popular?date=2019-06-01&scale=month\",\n    \"#category\": (\"E621\", \"e926\", \"popular\"),\n    \"#class\"   : e621.E621PopularExtractor,\n    \"#pattern\" : r\"https://static\\d.e926.net/data/../../[0-9a-f]+\",\n    \"#count\"   : \">= 70\",\n},\n\n{\n    \"#url\"     : \"https://e926.net/favorites\",\n    \"#category\": (\"E621\", \"e926\", \"favorite\"),\n    \"#class\"   : e621.E621FavoriteExtractor,\n},\n\n{\n    \"#url\"     : \"https://e926.net/favorites?page=1&user_id=460755\",\n    \"#category\": (\"E621\", \"e926\", \"favorite\"),\n    \"#class\"   : e621.E621FavoriteExtractor,\n    \"#pattern\" : r\"https://static\\d.e926.net/data/../../[0-9a-f]+\",\n    \"#count\"   : 15,\n},\n\n)\n"
  },
  {
    "path": "test/results/endchan.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import lynxchan\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://endchan.org/yuri/res/33621.html\",\n    \"#category\": (\"lynxchan\", \"endchan\", \"thread\"),\n    \"#class\"   : lynxchan.LynxchanThreadExtractor,\n    \"#results\" : \"https://endchan.org/.media/358c089df4be990e9f7b636e1ce83d3e-imagejpeg.jpg\",\n},\n\n{\n    \"#url\"     : \"https://endchan.org/yuri/res/33621.html\",\n    \"#category\": (\"lynxchan\", \"endchan\", \"thread\"),\n    \"#class\"   : lynxchan.LynxchanThreadExtractor,\n},\n\n{\n    \"#url\"     : \"https://endchan.org/yuri/\",\n    \"#category\": (\"lynxchan\", \"endchan\", \"board\"),\n    \"#class\"   : lynxchan.LynxchanBoardExtractor,\n    \"#pattern\" : lynxchan.LynxchanThreadExtractor.pattern,\n    \"#count\"   : \">= 8\",\n},\n\n{\n    \"#url\"     : \"https://endchan.org/yuri/catalog.html\",\n    \"#category\": (\"lynxchan\", \"endchan\", \"board\"),\n    \"#class\"   : lynxchan.LynxchanBoardExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/eporner.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import eporner\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://www.eporner.com/gallery/mHNhErACQFE/NaughtyAmerica-Lolly-Dames-My-Wife-s-Hot-Friend-Big-Booty-Big-Tits-Lolly-Dames-Gets-Her-Pussy-Slammed-Hard/\",\n    \"#class\"   : eporner.EpornerGalleryExtractor,\n    \"#pattern\" : r\"https://static\\-eu\\-cdn\\.eporner\\.com/gallery/FE/CQ/mHNhErACQFE/\\d+\\-image\\-\\d+\\.jpg\",\n    \"#count\"   : 261,\n\n    \"count\"      : 261,\n    \"num\"        : range(1, 261),\n    \"description\": \"NaughtyAmerica Lolly Dames - My Wife's Hot Friend - Big Booty Big Tits Lolly Dames Gets Her Pussy Slammed Hard sexy gallery with 261 pics. Eporner is the largest hd porn source.\",\n    \"extension\"  : \"jpg\",\n    \"filename\"   : r\"re:^\\d+\\-image\\-\\d+$\",\n    \"gallery_id\" : \"mHNhErACQFE\",\n    \"id\"         : r\"re:^\\d+$\",\n    \"slug\"       : \"NaughtyAmerica-Lolly-Dames-My-Wife-s-Hot-Friend-Big-Booty-Big-Tits-Lolly-Dames-Gets-Her-Pussy-Slammed-Hard\",\n    \"title\"      : \"NaughtyAmerica Lolly Dames - My Wife's Hot Friend - Big Booty Big Tits Lolly Dames Gets Her Pussy Slammed Hard\",\n    \"tags\"       : [\n        \"cumshot\",\n        \"hardcore\",\n        \"blowjob\",\n        \"mature\",\n        \"housewives\",\n        \"big tits\",\n        \"blonde\",\n        \"big ass\",\n        \"milf\",\n    ],\n},\n\n{\n    \"#url\"     : \"https://www.eporner.com/gallery/mHNhErACQFE\",\n    \"#class\"   : eporner.EpornerGalleryExtractor,\n},\n\n{\n    \"#url\"     : \"https://eporner.com/gallery/mHNhErACQFE\",\n    \"#class\"   : eporner.EpornerGalleryExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/erome.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import erome\nfrom gallery_dl import exception\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://www.erome.com/a/NQgdlWvk\",\n    \"#category\": (\"\", \"erome\", \"album\"),\n    \"#class\"   : erome.EromeAlbumExtractor,\n    \"#pattern\" : r\"https://v\\d+\\.erome\\.com/\\d+/NQgdlWvk/j7jlzmYB_480p\\.mp4\",\n    \"#count\"   : 1,\n\n    \"album_id\": \"NQgdlWvk\",\n    \"date\"    : None,\n    \"count\"   : 1,\n    \"num\"     : 1,\n    \"title\"   : \"porn\",\n    \"user\"    : \"yYgWBZw8o8qsMzM\",\n},\n\n{\n    \"#url\"     : \"https://www.erome.com/a/TdbZ4ogi\",\n    \"#category\": (\"\", \"erome\", \"album\"),\n    \"#class\"   : erome.EromeAlbumExtractor,\n    \"#pattern\" : r\"https://s\\d+\\.erome\\.com/\\d+/TdbZ4ogi/\\w+\",\n    \"#count\"   : 6,\n\n    \"album_id\": \"TdbZ4ogi\",\n    \"date\"    : \"dt:2024-03-18 00:01:56\",\n    \"count\"   : 6,\n    \"num\"     : int,\n    \"title\"   : \"82e78cfbb461ad87198f927fcb1fda9a1efac9ff.\",\n    \"user\"    : \"yYgWBZw8o8qsMzM\",\n},\n\n{\n    \"#url\"     : \"https://www.erome.com/a/qlV5z90y\",\n    \"#comment\" : \"deleted album (#8665)\",\n    \"#class\"   : erome.EromeAlbumExtractor,\n    \"#exception\": exception.AbortExtraction,\n},\n\n{\n    \"#url\"     : \"https://www.erome.com/a/ACGo2Pmy\",\n    \"#comment\" : \"copyrighted album (#8665)\",\n    \"#class\"   : erome.EromeAlbumExtractor,\n    \"#exception\": exception.AbortExtraction,\n},\n\n{\n    \"#url\"     : \"https://www.erome.com/yYgWBZw8o8qsMzM\",\n    \"#category\": (\"\", \"erome\", \"user\"),\n    \"#class\"   : erome.EromeUserExtractor,\n    \"#pattern\" : erome.EromeAlbumExtractor.pattern,\n    \"#count\"   : 88,\n},\n\n{\n    \"#url\"     : \"https://www.erome.com/yYgWBZw8o8qsMzM?t=reposts\",\n    \"#class\"   : erome.EromeUserExtractor,\n    \"#count\"   : 0,\n},\n\n{\n    \"#url\"     : \"https://www.erome.com/john3884\",\n    \"#class\"   : erome.EromeUserExtractor,\n    \"#count\"   : 0,\n},\n\n{\n    \"#url\"     : \"https://www.erome.com/john3884\",\n    \"#class\"   : erome.EromeUserExtractor,\n    \"#options\" : {\"reposts\": True},\n    \"#results\" : (\n        \"https://www.erome.com/a/NQgdlWvk\",\n        \"https://www.erome.com/a/TdbZ4ogi\",\n    ),\n},\n\n{\n    \"#url\"     : \"https://www.erome.com/search?q=cute\",\n    \"#category\": (\"\", \"erome\", \"search\"),\n    \"#class\"   : erome.EromeSearchExtractor,\n    \"#pattern\" : erome.EromeAlbumExtractor.pattern,\n    \"#range\"   : \"1-25\",\n    \"#count\"   : 25,\n},\n\n)\n"
  },
  {
    "path": "test/results/everia.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import everia\n\n\n__tests__ = (\n{\n    \"#url\"  : \"https://everia.club/2024/09/23/mikacho-조미카-joapictures-someday/\",\n    \"#class\": everia.EveriaPostExtractor,\n    \"#count\": 32,\n\n    \"title\"        : \"Mikacho 조미카, JOApictures ‘Someday’\",\n    \"post_category\": \"Korea\",\n    \"tags\"         : [\"[JOApictures]\", \"Mikacho 조미카\"],\n},\n\n{\n    \"#url\"  : \"https://everia.club/2020/12/13/karin-fujiyoshi-%e8%97%a4%e5%90%89%e5%a4%8f%e9%88%b4-rina-matsuda-%e6%9d%be%e7%94%b0%e9%87%8c%e5%a5%88-ex-taishu-2020-no-11-ex%e5%a4%a7%e8%a1%86-2020%e5%b9%b411%e6%9c%88%e5%8f%b7/\",\n    \"#class\": everia.EveriaPostExtractor,\n    \"#count\": 21,\n\n    \"title\"        : \"Karin Fujiyoshi 藤吉夏鈴, Rina Matsuda 松田里奈, Ex-Taishu 2020 No.11 (EX大衆 2020年11月号)\",\n    \"post_category\": \"Uncategorized\",\n    \"tags\"         : [\n        \"Ex-Taishu EX大衆\",\n        \"Karin Fujiyoshi 藤吉夏鈴\",\n        \"Rina Matsuda 松田里奈\",\n        \"Sakurazaka46 櫻坂46\",\n    ],\n},\n\n{\n    \"#url\"  : \"https://everia.club/2019/03/26/moeka-yahagi-%e7%9f%a2%e4%bd%9c%e8%90%8c%e5%a4%8f-b-l-t-graph-2019%e5%b9%b403%e6%9c%88%e5%8f%b7-vol-41/\",\n    \"#class\": everia.EveriaPostExtractor,\n    \"#count\": 9,\n\n    \"title\"        : \"Moeka Yahagi 矢作萌夏, B.L.T Graph 2019年03月号 Vol.41\",\n    \"post_category\": \"Uncategorized\",\n    \"tags\"         : [\n        \"AKB48\",\n        \"B.L.T ビー・エル・ティー\",\n        \"Moeka Yahagi 矢作萌夏\",\n    ],\n},\n\n{\n    \"#url\"  : \"https://everia.club/2021/03/12/%E9%9B%AF%E5%A6%B9%E4%B8%8D%E8%AE%B2%E9%81%93%E7%90%86-dido-%E3%83%80%E3%82%A4%E3%83%89%E3%83%BC-azur-lane-%E7%A2%A7%E8%93%9D%E8%88%AA%E7%BA%BF/\",\n    \"#class\": everia.EveriaPostExtractor,\n    \"#pattern\": r\"https://1.bp.blogspot.com/-\\S+/\\S+/\\S+/\\S+/s0/(%\\w\\w|\\d|\\+)+\\.jpg\",\n    \"#count\"  : 17,\n\n    \"count\"    : 17,\n    \"num\"      : range(1, 17),\n    \"extension\": \"jpg\",\n    \"filename\" : r\"re:雯妹不讲道理\\+\\(\\d+\\)\",\n    \"title\"    : \"[雯妹不讲道理] Dido ダイドー (Azur Lane 碧蓝航线)\",\n    \"post_url\" : \"https://everia.club/2021/03/12/雯妹不讲道理-dido-ダイドー-azur-lane-碧蓝航线/\",\n    \"post_category\": \"Cosplay\",\n    \"tags\": [\n        \"Cosplay\",\n        \"雯妹不讲道理\",\n    ],\n},\n\n{\n    \"#url\"    : \"https://everia.club/tag/miku-tanaka-%e7%94%b0%e4%b8%ad%e7%be%8e%e4%b9%85/\",\n    \"#class\"  : everia.EveriaTagExtractor,\n    \"#pattern\": everia.EveriaPostExtractor.pattern,\n    \"#count\"  : \"> 50\",\n},\n\n{\n    \"#url\"    : \"https://everia.club/category/japan/\",\n    \"#class\"  : everia.EveriaCategoryExtractor,\n    \"#pattern\": everia.EveriaPostExtractor.pattern,\n    \"#range\"  : \"1-50\",\n    \"#count\"  : 50,\n},\n\n{\n    \"#url\"    : \"https://everia.club/2023/10/05/\",\n    \"#class\"  : everia.EveriaDateExtractor,\n    \"#pattern\": everia.EveriaPostExtractor.pattern,\n    \"#count\"  : 34,\n},\n\n{\n    \"#url\"    : \"https://everia.club/?s=saika\",\n    \"#class\"  : everia.EveriaSearchExtractor,\n    \"#pattern\": everia.EveriaPostExtractor.pattern,\n    \"#range\"  : \"1-15\",\n    \"#count\"  : 15,\n},\n\n)\n"
  },
  {
    "path": "test/results/exhentai.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import exhentai\nfrom gallery_dl import exception\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://exhentai.org/g/1200119/d55c44d3d0/\",\n    \"#category\": (\"\", \"exhentai\", \"gallery\"),\n    \"#class\"   : exhentai.ExhentaiGalleryExtractor,\n    \"#options\"     : {\"original\": False, \"tags\": True},\n    \"#sha1_content\": [\n        \"2c68cff8a7ca540a78c36fdbf5fbae0260484f87\",\n        \"e9891a4c017ed0bb734cd1efba5cd03f594d31ff\",\n    ],\n\n    \"cost\"        : int,\n    \"date\"        : \"dt:2018-03-18 20:14:00\",\n    \"eh_category\" : \"Non-H\",\n    \"expunged\"    : False,\n    \"favorites\"   : r\"re:^[12]\\d$\",\n    \"filecount\"   : \"4\",\n    \"filesize\"    : 1488978,\n    \"gid\"         : 1200119,\n    \"height\"      : int,\n    \"image_token\" : r\"re:[0-9a-f]{10}\",\n    \"lang\"        : \"ja\",\n    \"language\"    : \"Japanese\",\n    \"parent\"      : \"\",\n    \"rating\"      : r\"re:\\d\\.\\d+\",\n    \"size\"        : int,\n    \"tags\"        : [\n        \"parody:komi-san wa komyushou desu.\",\n        \"character:shouko komi\",\n        \"group:seventh lowlife\",\n        \"other:sample\",\n    ],\n    \"tags_parody\" : [\"komi-san wa komyushou desu.\"],\n    \"tags_character\": [\"shouko komi\"],\n    \"tags_group\"  : [\"seventh lowlife\"],\n    \"tags_other\"  : [\"sample\"],\n    \"thumb\"       : \"https://s.exhentai.org/t/ce/0a/ce0a5bcb583229a9b07c0f83bcb1630ab1350640-624622-736-1036-jpg_250.jpg\",\n    \"title\"       : \"C93 [Seventh_Lowlife] Komi-san ha Tokidoki Daitan desu (Komi-san wa Komyushou desu) [Sample]\",\n    \"title_jpn\"   : \"(C93) [Comiketjack (わ！)] 古見さんは、時々大胆です。 (古見さんは、コミュ症です。) [見本]\",\n    \"token\"       : \"d55c44d3d0\",\n    \"torrentcount\": \"0\",\n    \"uploader\"    : \"klorpa\",\n    \"width\"       : int,\n},\n\n{\n    \"#url\"     : \"https://exhentai.org/g/960461/4f0e369d82/\",\n    \"#category\": (\"\", \"exhentai\", \"gallery\"),\n    \"#class\"   : exhentai.ExhentaiGalleryExtractor,\n    \"#exception\": exception.NotFoundError,\n},\n\n{\n    \"#url\"     : \"http://exhentai.org/g/962698/7f02358e00/\",\n    \"#category\": (\"\", \"exhentai\", \"gallery\"),\n    \"#class\"   : exhentai.ExhentaiGalleryExtractor,\n    \"#exception\": exception.AuthorizationError,\n},\n\n{\n    \"#url\"     : \"https://exhentai.org/s/f68367b4c8/1200119-3\",\n    \"#category\": (\"\", \"exhentai\", \"gallery\"),\n    \"#class\"   : exhentai.ExhentaiGalleryExtractor,\n    \"#options\" : {\"original\": False},\n    \"#count\"   : 2,\n},\n\n{\n    \"#url\"     : \"https://e-hentai.org/s/f68367b4c8/1200119-3\",\n    \"#category\": (\"\", \"exhentai\", \"gallery\"),\n    \"#class\"   : exhentai.ExhentaiGalleryExtractor,\n    \"#options\" : {\"original\": False},\n    \"#count\"   : 2,\n},\n\n{\n    \"#url\"     : \"https://g.e-hentai.org/g/1200119/d55c44d3d0/\",\n    \"#category\": (\"\", \"exhentai\", \"gallery\"),\n    \"#class\"   : exhentai.ExhentaiGalleryExtractor,\n},\n\n{\n    \"#url\"     : \"https://exhentai.org/mpv/1200119/d55c44d3d0/\",\n    \"#class\"   : exhentai.ExhentaiGalleryExtractor,\n},\n\n{\n    \"#url\"     : \"https://exhentai.org/mpv/1200119/d55c44d3d0/#page3\",\n    \"#class\"   : exhentai.ExhentaiGalleryExtractor,\n},\n\n{\n    \"#url\"     : \"https://e-hentai.org/?f_search=touhou\",\n    \"#category\": (\"\", \"exhentai\", \"search\"),\n    \"#class\"   : exhentai.ExhentaiSearchExtractor,\n},\n\n{\n    \"#url\"     : \"https://exhentai.org/?f_cats=767&f_search=touhou\",\n    \"#category\": (\"\", \"exhentai\", \"search\"),\n    \"#class\"   : exhentai.ExhentaiSearchExtractor,\n},\n\n{\n    \"#url\"     : \"https://exhentai.org/tag/parody:touhou+project\",\n    \"#category\": (\"\", \"exhentai\", \"search\"),\n    \"#class\"   : exhentai.ExhentaiSearchExtractor,\n},\n\n{\n    \"#url\"     : \"https://exhentai.org/?f_doujinshi=0&f_manga=0&f_artistcg=0&f_gamecg=0&f_western=0&f_non-h=1&f_imageset=0&f_cosplay=0&f_asianporn=0&f_misc=0&f_search=touhou&f_apply=Apply+Filter\",\n    \"#category\": (\"\", \"exhentai\", \"search\"),\n    \"#class\"   : exhentai.ExhentaiSearchExtractor,\n    \"#pattern\" : exhentai.ExhentaiGalleryExtractor.pattern,\n    \"#auth\"    : True,\n    \"#range\"   : \"1-30\",\n    \"#count\"   : 30,\n\n    \"gallery_id\"   : int,\n    \"gallery_token\": r\"re:^[0-9a-f]{10}$\",\n},\n\n{\n    \"#url\"     : \"https://e-hentai.org/favorites.php\",\n    \"#category\": (\"\", \"exhentai\", \"favorite\"),\n    \"#class\"   : exhentai.ExhentaiFavoriteExtractor,\n    \"#auth\"    : True,\n    \"#results\" : \"https://e-hentai.org/g/1200119/d55c44d3d0/\",\n},\n\n{\n    \"#url\"     : \"https://exhentai.org/favorites.php?favcat=1&f_search=touhou&f_apply=Search+Favorites\",\n    \"#category\": (\"\", \"exhentai\", \"favorite\"),\n    \"#class\"   : exhentai.ExhentaiFavoriteExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/facebook.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import facebook\nfrom gallery_dl import exception\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://www.facebook.com/facebook\",\n    \"#class\"   : facebook.FacebookUserExtractor,\n    \"#results\" : \"https://www.facebook.com/facebook/photos\"\n},\n\n{\n    \"#url\"     : \"https://www.facebook.com/people/facebook/100064860875397/?sk=photos\",\n    \"#class\"   : facebook.FacebookUserExtractor,\n    \"#results\" : \"https://www.facebook.com/100064860875397/photos\"\n},\n\n{\n    \"#url\"     : \"https://www.facebook.com/profile.php?id=100064860875397\",\n    \"#class\"   : facebook.FacebookUserExtractor,\n    \"#results\" : \"https://www.facebook.com/100064860875397/photos\"\n},\n\n{\n    \"#url\"     : \"https://www.facebook.com/facebook\",\n    \"#class\"   : facebook.FacebookUserExtractor,\n    \"#options\" : {\"include\": \"all\"},\n    \"#results\" : [\n        \"https://www.facebook.com/facebook/info\",\n        \"https://www.facebook.com/facebook/avatar\",\n        \"https://www.facebook.com/facebook/photos\",\n        \"https://www.facebook.com/facebook/photos_albums\",\n    ],\n},\n\n{\n    \"#url\"     : \"https://www.facebook.com/permalink.php?story_fbid=pfbid034C2PVBhr311C2jo91sBMNwfvcBeLmspzTXLikp37aEqKsdh47mW7ZX8hcS3Ba8Uul&id=61573780995993&rdid=eV7e4pTWFxWb6Evx\",\n    \"#comment\" : \"post URL (#8679)\",\n    \"#class\"   : facebook.FacebookUserExtractor,\n    \"#fail\"    : True,\n},\n\n\n{\n    \"#url\"     : \"https://www.facebook.com/facebook/photos\",\n    \"#class\"   : facebook.FacebookPhotosExtractor,\n\n    \"#range\"   : \"1-3\",\n    \"#count\"   : 3,\n},\n\n{\n    \"#url\"     : \"https://www.facebook.com/100064860875397/photos\",\n    \"#class\"   : facebook.FacebookPhotosExtractor,\n\n    \"#range\"   : \"1-3\",\n    \"#count\"   : 3,\n},\n\n{\n    \"#url\"     : \"https://www.facebook.com/profile.php?id=100074229772340/photos\",\n    \"#comment\" : \"pfbid user ID (#7953)\",\n    \"#class\"   : facebook.FacebookPhotosExtractor,\n    \"#range\"   : \"1\",\n\n    \"user_id\"   : \"100074229772340\",\n    \"user_pfbid\": r\"re:pfbid\\w{64}\",\n},\n\n{\n    \"#url\"     : \"https://www.facebook.com/facebook/photos_by\",\n    \"#class\"   : facebook.FacebookPhotosExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.facebook.com/brando.cha.3/photos\",\n    \"#comment\" : \"empty '/photos' page / missing 'set_id' value (#7962)\",\n    \"#class\"   : facebook.FacebookPhotosExtractor,\n    \"#count\"   : 0,\n},\n\n{\n    \"#url\"      : \"https://www.facebook.com/Forgetmen0w/photos\",\n    \"#comment\"  : \"'This content isn't available right now'\",\n    \"#class\"    : facebook.FacebookPhotosExtractor,\n    \"#exception\": exception.AuthRequired,\n},\n\n{\n    \"#url\"     : \"https://www.facebook.com/facebook/avatar\",\n    \"#class\"   : facebook.FacebookAvatarExtractor,\n    \"#pattern\" : r\"https://scontent-[^7?#]+\\.fbcdn\\.net/v/t39.30808-6/380700650_10162533193146729_2379134611963304810_n.jpg?.+\",\n    \"#count\"   : 1,\n\n    \"caption\"  : \"\",\n    \"count\"    : 1,\n    \"date\"     : \"dt:2023-10-06 21:13:59\",\n    \"extension\": \"jpg\",\n    \"filename\" : str,\n    \"id\"       : \"736550615183628\",\n    \"num\"      : 1,\n    \"set_id\"   : \"a.736550601850296\",\n    \"type\"     : \"avatar\",\n    \"url\"      : str,\n    \"user_id\"  : \"100064860875397\",\n    \"username\" : \"Facebook\",\n},\n\n{\n    \"#url\"     : \"https://www.facebook.com/brando.cha.3/avatar\",\n    \"#comment\" : \"empty '/photos_of' page (#7962)\",\n    \"#class\"   : facebook.FacebookAvatarExtractor,\n    \"#count\"   : 1,\n\n    \"date\"      : \"dt:2020-01-23 17:54:22\",\n    \"id\"        : \"104622291093002\",\n    \"set_id\"    : \"a.104622317759666\",\n    \"type\"      : \"avatar\",\n    \"user_id\"   : \"100046356937542\",\n    \"user_pfbid\": r\"re:pfbid\\w{64}\",\n    \"username\"  : \"Throwaway Idk\",\n},\n\n{\n    \"#url\"     : \"https://www.facebook.com/media/set/?set=a.10152716010956729&type=3\",\n    \"#class\"   : facebook.FacebookSetExtractor,\n    \"#count\"   : 6,\n},\n\n{\n    \"#url\"     : \"https://www.facebook.com/joho.press.jp/posts/pfbid02mfFRpVkErLQxQ8cpD2f1hwXEVsFzK8kfNBKdK2Jndnx6AkmMQZuXhovwDgwvoDNil\",\n    \"#class\"   : facebook.FacebookSetExtractor,\n    \"#range\"   : \"1-3\",\n    \"#count\"   : 3,\n\n    \"set_id\"   : \"pcb.1160563418981189\",\n    \"user_id\"  : \"100050826247807\",\n    \"username\" : \"情報プレスα\",\n},\n\n{\n    \"#url\"     : \"https://www.facebook.com/photo/?fbid=10152716011076729&set=a.10152716010956729&setextract\",\n    \"#class\"   : facebook.FacebookSetExtractor,\n    \"#count\"   : 4,\n},\n\n{\n    \"#url\"     : \"https://www.facebook.com/media/set/?set=a.127331797422780&type=3\",\n    \"#comment\" : \"pfbid user ID; 'This content isn't available right now' profile\",\n    \"#class\"   : facebook.FacebookSetExtractor,\n    \"#metadata\": \"post\",\n    \"#range\"   : \"0\",\n\n    \"caption\"   : \"Amarte es mi hábito favorito\",\n    \"date\"      : \"dt:2025-05-03 03:42:52\",\n    \"set_id\"    : \"a.127331797422780\",\n    \"title\"     : \"Profile pictures\",\n    \"user_id\"   : \"100004378810826\",\n    \"user_pfbid\": r\"re:pfbid\\w{64}\",\n    \"username\"  : \"Angel Nava Santiago\",\n},\n\n{\n    \"#url\"     : \"https://www.facebook.com/photo.php?fbid=10165113568399554&set=t.100064860875397&type=3\",\n    \"#class\"   : facebook.FacebookPhotoExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.facebook.com/photo.php/?fbid=10165113568399554&set=t.100064860875397\",\n    \"#class\"   : facebook.FacebookPhotoExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.facebook.com/photo?fbid=10165113568399554&set=t.100064860875397\",\n    \"#class\"   : facebook.FacebookPhotoExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.facebook.com/photo/?fbid=10165113568399554&set=t.100064860875397&type=3\",\n    \"#class\"   : facebook.FacebookPhotoExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.facebook.com/photo/?fbid=10165113568399554&set=t.100064860875397&type=3&setextract\",\n    \"#class\"   : facebook.FacebookPhotoExtractor,\n    \"#fail\"    : \"'setextract' query parameter\",\n},\n\n{\n    \"#url\"     : \"https://www.facebook.com/photo/?fbid=10160743390456729\",\n    \"#class\"   : facebook.FacebookPhotoExtractor,\n    \"#count\"   : 1,\n\n    \"caption\"  : \"They were on a break... #FriendsReunion #MoreTogether\",\n    \"date\"     : \"dt:2021-05-27 21:55:19\",\n    \"filename\" : \"191053255_10160743390471729_9001965649022744000_n\",\n    \"extension\": \"jpg\",\n    \"id\"       : \"10160743390456729\",\n    \"set_id\"   : \"a.494827881728\",\n    \"url\"      : str,\n    \"user_id\"  : \"100064860875397\",\n    \"username\" : \"Facebook\",\n},\n\n{\n    \"#url\"     : \"https://www.facebook.com/photo/?fbs=home&fbid=10160743390456729\",\n    \"#class\"   : facebook.FacebookPhotoExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.facebook.com/Facebook/photos/a.10152716010956729/10152716011076729\",\n    \"#class\"   : facebook.FacebookPhotoExtractor,\n    \"#count\"   : 1,\n\n    \"caption\"  : \"\",\n    \"date\"     : \"dt:2014-05-03 00:44:47\",\n    \"filename\" : str,\n    \"extension\": \"png\",\n    \"id\"       : \"10152716011076729\",\n    \"set_id\"   : \"a.10152716010956729\",\n    \"url\"      : str,\n    \"user_id\"  : \"100064860875397\",\n    \"user_pfbid\": \"\",\n    \"username\" : \"Facebook\",\n},\n\n{\n    \"#url\"     : \"https://www.facebook.com/photo.php?fbid=1143447107814264&set=pb.100064469571787.-2207520000&type=3\",\n    \"#class\"   : facebook.FacebookPhotoExtractor,\n    \"#count\"   : 1,\n\n    \"caption\"  : \"Wanting to post a pic on Stories but it’s too small? 😡❌\\n\\nTry using Meta AI to make the pic fit your screen 😇✅\\n\\n(Available in most of the US)\",\n    \"date\"     : \"dt:2025-05-30 18:47:34\",\n    \"extension\": \"jpg\",\n    \"id\"       : \"1143447107814264\",\n    \"set_id\"   : \"a.596799269145720\",\n    \"user_id\"  : \"100064469571787\",\n    \"user_pfbid\": \"\",\n    \"username\" : \"Instagram\",\n},\n\n{\n    \"#url\"     : \"https://www.facebook.com/photo/?fbid=221820450302279\",\n    \"#comment\" : \"pfbid user ID (#7953)\",\n    \"#class\"   : facebook.FacebookPhotoExtractor,\n\n    \"date\"    : \"dt:2023-02-05 22:41:02\",\n    \"id\"      : \"221820450302279\",\n    \"set_id\"  : \"a.109762038174788\",\n    \"user_id\" : \"100074229772340\",\n    \"user_pfbid\": r\"re:pfbid\\w{64}\",\n    \"username\": \"Throwaway Kwon\",\n},\n\n{\n    \"#url\"     : \"https://www.facebook.com/photo.php?fbid=1156625586261770\",\n    \"#comment\" : \"surrogate pair in 'caption' data (#6599)\",\n    \"#class\"   : facebook.FacebookPhotoExtractor,\n\n    \"caption\"  : \"A century of innovation parked side by side.\\n\\n📸: Vocabutesla via X\",\n},\n\n{\n    \"#url\"     : \"https://www.facebook.com/photo.php?fbid=989340003138066&set=pb.100061862277212.-2207520000&type=3\",\n    \"#comment\" : \"no 'publish_time' (#7151)\",\n    \"#class\"   : facebook.FacebookPhotoExtractor,\n\n    \"date\"     : \"dt:2025-02-25 15:00:09\",\n},\n\n{\n    \"#url\"     : \"https://www.facebook.com/watch/?v=1165557851291824\",\n    \"#class\"   : facebook.FacebookVideoExtractor,\n    \"#count\"   : 1,\n\n    \"date\"     : \"dt:2024-04-19 17:25:48\",\n    \"filename\" : str,\n    \"id\"       : \"1165557851291824\",\n    \"url\"      : str,\n    \"user_id\"  : \"100064860875397\",\n    \"username\" : \"Facebook\",\n},\n\n{\n    \"#url\"     : \"https://www.facebook.com/100064860875397/videos/644342003942740\",\n    \"#class\"   : facebook.FacebookVideoExtractor,\n    \"#count\"   : 2,\n\n    \"filename\" : str,\n    \"extension\": {\"mp4\", \"m4a\"},\n    \"id\"       : \"644342003942740\",\n    \"url\"      : str,\n    \"user_id\"  : \"100064860875397\",\n    \"username\" : \"Facebook\",\n},\n\n{\n    \"#url\"     : \"https://www.facebook.com/facebook/photos_albums\",\n    \"#class\"   : facebook.FacebookAlbumsExtractor,\n    \"#pattern\" : facebook.FacebookSetExtractor.pattern,\n    \"#results\" : (\n        \"https://www.facebook.com/media/set/?set=a.736550598516963&type=3\",\n        \"https://www.facebook.com/media/set/?set=a.736550611850295&type=3\",\n        \"https://www.facebook.com/media/set/?set=a.1198986285606723&type=3\",\n        \"https://www.facebook.com/media/set/?set=a.1188430493328969&type=3\",\n        \"https://www.facebook.com/media/set/?set=a.1182920610546624&type=3\",\n        \"https://www.facebook.com/media/set/?set=a.1152503723588313&type=3\",\n        \"https://www.facebook.com/media/set/?set=a.912647394240615&type=3\",\n        \"https://www.facebook.com/media/set/?set=a.862611645910857&type=3\",\n    ),\n\n    \"id\"       : r\"re:\\d+\",\n    \"thumbnail\": {str, None},\n    \"title\"    : str,\n    \"url\"      : str,\n},\n\n{\n    \"#url\"     : \"https://www.facebook.com/facebook/photos_albums/Mobile uploads\",\n    \"#class\"   : facebook.FacebookAlbumsExtractor,\n    \"#results\" : (\n        \"https://www.facebook.com/media/set/?set=a.736550611850295&type=3\",\n    ),\n\n    \"id\"       : \"736550611850295\",\n    \"thumbnail\": str,\n    \"title\"    : \"Mobile uploads\",\n    \"url\"      : \"https://www.facebook.com/media/set/?set=a.736550611850295&type=3\",\n},\n\n{\n    \"#url\"     : \"https://www.facebook.com/instagram/info\",\n    \"#class\"   : facebook.FacebookInfoExtractor,\n    \"#metadata\": \"post\",\n\n    \"id\"            : \"100064469571787\",\n    \"name\"          : \"Instagram\",\n    \"username\"      : \"instagram\",\n    \"biography\"     : \"Discover what's new on Instagram 🔎✨\",\n    \"url\"           : \"https://www.facebook.com/instagram\",\n    \"set_id\"        : \"\",\n    \"!user_pfbid\"    : r\"re:pfbid\\w{64}\",\n},\n\n{\n    \"#url\"     : \"https://www.facebook.com/brando.cha.3/info\",\n    \"#class\"   : facebook.FacebookInfoExtractor,\n    \"#metadata\": \"post\",\n\n    \"id\"            : \"100046356937542\",\n    \"name\"          : \"Throwaway Idk\",\n    \"username\"      : \"brando.cha.3\",\n    \"biography\"     : \"\",\n    \"url\"           : \"https://www.facebook.com/brando.cha.3\",\n    \"alternate_name\": \"\",\n    \"profile_video\" : None,\n    \"set_id\"        : \"\",\n    \"user_pfbid\"    : r\"re:pfbid\\w{64}\",\n    \"profilePhoto\"  : {\n        \"id\"          : \"104622291093002\",\n        \"url\"         : \"https://www.facebook.com/photo/?fbid=104622291093002&set=a.104622317759666\",\n        \"viewer_image\": {\n            \"height\": 1947,\n            \"width\" : 1928,\n        },\n    },\n    \"profile_tabs\"  : [\n        {\n            \"id\"      : \"YXBwX3NlY3Rpb246MTAwMDQ2MzU2OTM3NTQyOjIzNTYzMTgzNDk=\",\n            \"name\"    : \"Friends\",\n            \"tracking\": \"friends\",\n            \"url\"     : \"https://www.facebook.com/brando.cha.3/friends\",\n        },\n        {\n            \"id\"      : \"YXBwX3NlY3Rpb246MTAwMDQ2MzU2OTM3NTQyOjIzMDUyNzI3MzI=\",\n            \"name\"    : \"Photos\",\n            \"tracking\": \"photos\",\n            \"url\"     : \"https://www.facebook.com/brando.cha.3/photos\",\n        },\n        {\n            \"id\"      : \"YXBwX3NlY3Rpb246MTAwMDQ2MzU2OTM3NTQyOjE1NjA2NTMzMDQxNzQ1MTQ=\",\n            \"name\"    : \"Videos\",\n            \"tracking\": \"user_videos\",\n            \"url\"     : \"https://www.facebook.com/brando.cha.3/videos\",\n        },\n    ],\n},\n\n{\n    \"#url\"     : \"https://www.facebook.com/Forgetmen0w/info\",\n    \"#comment\" : \"'biography' fallback (#8233)\",\n    \"#class\"   : facebook.FacebookInfoExtractor,\n    \"#auth\"    : True,\n    \"#metadata\": \"post\",\n\n    \"biography\": \"G ❤️\",\n},\n\n)\n"
  },
  {
    "path": "test/results/fanbox.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import fanbox\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://xub.fanbox.cc\",\n    \"#category\": (\"\", \"fanbox\", \"creator\"),\n    \"#class\"   : fanbox.FanboxCreatorExtractor,\n    \"#options\" : {\"fee-max\": 0},\n    \"#range\"   : \"1-15\",\n    \"#count\"   : \">= 15\",\n\n    \"creatorId\": \"xub\",\n    \"tags\"     : list,\n    \"title\"    : str,\n},\n\n{\n    \"#url\"     : \"https://xub.fanbox.cc/posts\",\n    \"#category\": (\"\", \"fanbox\", \"creator\"),\n    \"#class\"   : fanbox.FanboxCreatorExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.fanbox.cc/@xub/\",\n    \"#category\": (\"\", \"fanbox\", \"creator\"),\n    \"#class\"   : fanbox.FanboxCreatorExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.fanbox.cc/@xub/posts\",\n    \"#category\": (\"\", \"fanbox\", \"creator\"),\n    \"#class\"   : fanbox.FanboxCreatorExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.fanbox.cc/@xub/posts/1910054\",\n    \"#category\": (\"\", \"fanbox\", \"post\"),\n    \"#class\"   : fanbox.FanboxPostExtractor,\n    \"#count\"   : 3,\n\n    \"title\"          : \"えま★おうがすと\",\n    \"tags\"           : list,\n    \"hasAdultContent\": True,\n    \"isCoverImage\"   : False,\n},\n\n{\n    \"#url\"     : \"https://nekoworks.fanbox.cc/posts/915\",\n    \"#comment\" : \"entry post type, image embedded in html of the post\",\n    \"#category\": (\"\", \"fanbox\", \"post\"),\n    \"#class\"   : fanbox.FanboxPostExtractor,\n    \"#count\"   : 2,\n\n    \"title\"          : \"【SAYORI FAN CLUB】お届け内容\",\n    \"tags\"           : list,\n    \"html\"           : str,\n    \"hasAdultContent\": True,\n},\n\n{\n    \"#url\"     : \"https://steelwire.fanbox.cc/posts/285502\",\n    \"#comment\" : \"article post type, imageMap, 2 twitter embeds, fanbox embed\",\n    \"#category\": (\"\", \"fanbox\", \"post\"),\n    \"#class\"   : fanbox.FanboxPostExtractor,\n    \"#options\" : {\"embeds\": True},\n    \"#count\"   : 8,\n\n    \"title\"          : \"イラスト+SS｜【全体公開版】義足の探鉱夫男子が義足を見せてくれるだけ \",\n    \"tags\"           : list,\n    \"articleBody\"    : dict,\n    \"hasAdultContent\": True,\n},\n\n{\n    \"#url\"     : \"https://www.fanbox.cc/@official-en/posts/4326303\",\n    \"#comment\" : \"'content' metadata (#3020)\",\n    \"#category\": (\"\", \"fanbox\", \"post\"),\n    \"#class\"   : fanbox.FanboxPostExtractor,\n\n    \"content\": r\"re:(?s)^Greetings from FANBOX.\\n \\nAs of Monday, September 5th, 2022, we are happy to announce the start of the FANBOX hashtag event #MySetupTour ! \\nAbout the event\\nTo join this event .+ \\nPlease check this page for further details regarding the Privacy & Terms.\\nhttps://fanbox.pixiv.help/.+/10184952456601\\n\\n\\nThank you for your continued support of FANBOX.$\",\n},\n\n{\n    \"#url\"     : \"https://official-en.fanbox.cc/posts/7022572\",\n    \"#comment\" : \"'plan' and 'user' metadata (#4921)\",\n    \"#category\": (\"\", \"fanbox\", \"post\"),\n    \"#class\"   : fanbox.FanboxPostExtractor,\n    \"#options\" : {\"metadata\": True},\n\n    \"plan\": {\n        \"coverImageUrl\"  : \"\",\n        \"creatorId\"      : \"official-en\",\n        \"description\"    : \"\",\n        \"fee\"            : 0,\n        \"hasAdultContent\": None,\n        \"id\"             : \"\",\n        \"paymentMethod\"  : None,\n        \"title\"          : \"\",\n    },\n    \"user\": {\n        \"coverImageUrl\"     : \"https://pixiv.pximg.net/c/1620x580_90_a2_g5/fanbox/public/images/creator/74349833/cover/n9mX8q4tUXHXXj7sK1RPWyUu.jpeg\",\n        \"creatorId\"         : \"official-en\",\n        \"description\"       : \"re:This is the official English pixivFANBOX account!.+\",\n        \"hasAdultContent\"   : False,\n        \"hasBoothShop\"      : False,\n        \"iconUrl\"           : \"https://pixiv.pximg.net/c/160x160_90_a2_g5/fanbox/public/images/user/74349833/icon/oJH0OoGoSixLrJXlnneNvC95.jpeg\",\n        \"isAcceptingRequest\": False,\n        \"isFollowed\"        : False,\n        \"isStopped\"         : False,\n        \"isSupported\"       : False,\n        \"name\"              : \"pixivFANBOX English\",\n        \"profileItems\"      : [],\n        \"profileLinks\"      : [\n            \"https://twitter.com/pixivfanbox\",\n        ],\n        \"userId\"            : \"74349833\",\n    },\n},\n\n{\n    \"#url\"     : \"https://saki9184.fanbox.cc/posts/7754760\",\n    \"#comment\" : \"missing plan for exact 'feeRequired' value (#5759)\",\n    \"#category\": (\"\", \"fanbox\", \"post\"),\n    \"#class\"   : fanbox.FanboxPostExtractor,\n    \"#options\" : {\"metadata\": \"plan\"},\n\n    \"feeRequired\": 300,\n    \"plan\"       : {\n        \"creatorId\": \"saki9184\",\n        \"fee\"      : 350,\n        \"id\"       : \"414274\",\n        \"title\"    : \"涼宮ハルヒの同人部\",\n    },\n},\n\n{\n    \"#url\"     : \"https://mochirong.fanbox.cc/posts/3746116\",\n    \"#comment\" : \"imageMap file order (#2718); comments metadata (#6287)\",\n    \"#category\": (\"\", \"fanbox\", \"post\"),\n    \"#class\"   : fanbox.FanboxPostExtractor,\n    \"#options\" : {\"metadata\": \"comments\"},\n    \"#sha1_url\": \"c92ddd06f2efc4a5fe30ec67e21544f79a5c4062\",\n    \"#results\" : (\n        \"https://pixiv.pximg.net/fanbox/public/images/post/3746116/cover/6h5w7F1MJWLeED6ODfHo6ZYQ.jpeg\",\n        \"https://downloads.fanbox.cc/images/post/3746116/ouTz7XZIeVD3FBOzoLhJ3ZTA.jpeg\",\n        \"https://downloads.fanbox.cc/images/post/3746116/hBs9bXEg6HvbqWT8QLD9g5ne.jpeg\",\n        \"https://downloads.fanbox.cc/images/post/3746116/C93E7db3C3sBqbDw6gQoZBMz.jpeg\",\n    ),\n\n    \"archives\": (),\n    \"comments\": \"len:4\",\n},\n\n{\n    \"#url\"     : \"https://mochirong.fanbox.cc/posts/9809662\",\n    \"#comment\" : \"'archives' metadata (#7454)\",\n    \"#class\"   : fanbox.FanboxPostExtractor,\n    \"#results\" : (\n        \"https://downloads.fanbox.cc/images/post/9809662/TUeXGybLxGVmzzrP8o3fhn27.jpeg\",\n        \"https://downloads.fanbox.cc/images/post/9809662/qt5fZBGxErXDAgBf2qgUZ1O8.jpeg\",\n        \"https://downloads.fanbox.cc/images/post/9809662/NvA7M0tIMGjA3sQxBqvdmwBm.jpeg\",\n        \"https://downloads.fanbox.cc/images/post/9809662/189bCj577YGtiBT7qCxVQJjK.jpeg\",\n        \"https://downloads.fanbox.cc/images/post/9809662/pYeCpfJYbojdj2VlyAwnh1oM.jpeg\",\n        \"https://downloads.fanbox.cc/files/post/9809662/8amsYwtWPtwcBVF4JnALM1ec.zip\",\n        \"https://downloads.fanbox.cc/files/post/9809662/6uhhqHYD4UvzVGx2I0QyLaiG.zip\",\n    ),\n\n    \"archives\": [\n        {\n            \"extension\": \"zip\",\n            \"id\"       : \"8amsYwtWPtwcBVF4JnALM1ec\",\n            \"name\"     : \"brushes\",\n            \"size\"     : 1087777,\n            \"url\"      : \"https://downloads.fanbox.cc/files/post/9809662/8amsYwtWPtwcBVF4JnALM1ec.zip\",\n        },\n        {\n            \"extension\": \"zip\",\n            \"id\"       : \"6uhhqHYD4UvzVGx2I0QyLaiG\",\n            \"name\"     : \"Manual\",\n            \"size\"     : 3222,\n            \"url\"      : \"https://downloads.fanbox.cc/files/post/9809662/6uhhqHYD4UvzVGx2I0QyLaiG.zip\",\n        },\n    ],\n},\n\n{\n    \"#url\"     : \"https://etlabsotwe.fanbox.cc/posts/11070192\",\n    \"#comment\" : \"(potentially?) missing 'publishedDatetime' (#8711)\",\n    \"#class\"   : fanbox.FanboxPostExtractor,\n    \"#results\" : \"https://pixiv.pximg.net/fanbox/public/images/post/11070192/cover/MGoWoTnJphuJSkD3owo3r7wG.jpeg\",\n\n    \"archives\"       : (),\n    \"creatorId\"      : \"etlabsotwe\",\n    \"date\"           : \"dt:2025-12-15 11:31:50\",\n    \"excerpt\"        : \"\",\n    \"extension\"      : \"jpeg\",\n    \"feeRequired\"    : 200,\n    \"fileUrl\"        : \"https://pixiv.pximg.net/fanbox/public/images/post/11070192/cover/MGoWoTnJphuJSkD3owo3r7wG.jpeg\",\n    \"filename\"       : \"MGoWoTnJphuJSkD3owo3r7wG\",\n    \"hasAdultContent\": True,\n    \"id\"             : \"11070192\",\n    \"imageForShare\"  : \"https://pixiv.pximg.net/c/1200x630_90_a2_g5/fanbox/public/images/post/11070192/cover/MGoWoTnJphuJSkD3owo3r7wG.jpeg\",\n    \"isCommentingRestricted\": False,\n    \"isCoverImage\"   : True,\n    \"isLiked\"        : False,\n    \"isPinned\"       : False,\n    \"isRestricted\"   : True,\n    \"likeCount\"      : int,\n    \"num\"            : 0,\n    \"publishedDatetime\": \"2025-12-15T20:31:50+09:00\",\n    \"tags\"           : [],\n    \"text\"           : None,\n    \"title\"          : \"Sketches 2025-12-15\",\n    \"type\"           : \"article\",\n    \"updatedDatetime\": \"iso:dt\",\n    \"user\"           : {\n        \"name\"   : \"ET\",\n        \"userId\" : \"74814193\",\n    },\n},\n\n{\n    \"#url\"     : \"https://fanbox.cc/\",\n    \"#category\": (\"\", \"fanbox\", \"home\"),\n    \"#class\"   : fanbox.FanboxHomeExtractor,\n    \"#auth\"    : True,\n    \"#range\"   : \"1-10\",\n    \"#count\"   : 10,\n},\n\n{\n    \"#url\"     : \"https://fanbox.cc/home/supporting\",\n    \"#category\": (\"\", \"fanbox\", \"supporting\"),\n    \"#class\"   : fanbox.FanboxSupportingExtractor,\n    \"#auth\"    : True,\n    \"#count\"   : 0,\n},\n\n{\n    \"#url\"     : \"https://www.pixiv.net/fanbox/creator/52336352\",\n    \"#category\": (\"\", \"fanbox\", \"redirect\"),\n    \"#class\"   : fanbox.FanboxRedirectExtractor,\n    \"#pattern\" : fanbox.FanboxCreatorExtractor.pattern,\n},\n\n{\n    \"#url\"     : \"https://gute-nacht-07.fanbox.cc/tags/%E3%81%BE%E3%81%A8%E3%82%81zip\",\n    \"#class\"   : fanbox.FanboxTagExtractor,\n    \"#range\"   : \"1-3\",\n    \"#results\" : (\n        \"https://pixiv.pximg.net/fanbox/public/images/post/6541606/cover/M7Xf4Q6ODSwVpeZH2XwWdUku.jpeg\",\n        \"https://pixiv.pximg.net/fanbox/public/images/post/6511517/cover/sFl5eRaGAZRXmknUH6kl59eM.jpeg\",\n        \"https://pixiv.pximg.net/fanbox/public/images/post/6449474/cover/3hnZUWv3aLVbPgatmJttmyPa.jpeg\",\n    ),\n\n    \"archives\"       : (),\n    \"commentCount\"   : int,\n    \"coverImageUrl\"  : str,\n    \"creatorId\"      : \"gute-nacht-07\",\n    \"date\"           : \"type:datetime\",\n    \"excerpt\"        : \"\",\n    \"extension\"      : \"jpeg\",\n    \"feeRequired\"    : 500,\n    \"hasAdultContent\": True,\n    \"isCoverImage\"   : True,\n    \"num\"            : 0,\n    \"search_tags\"    : \"まとめzip\",\n    \"tags\"           : [\"まとめzip\"],\n    \"type\"           : \"file\",\n},\n\n{\n    \"#url\"     : \"https://www.fanbox.cc/@ddks2923/tags/%E3%82%BD%E3%83%BC%E3%83%89%E3%82%A2%E3%83%BC%E3%83%88%E3%83%BB%E3%82%AA%E3%83%B3%E3%83%A9%E3%82%A4%E3%83%B3\",\n    \"#class\"   : fanbox.FanboxTagExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/fandom.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import wikimedia\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://www.fandom.com/wiki/Title\",\n    \"#comment\" : \"for scripts/supportedsites.py\",\n    \"#category\": (\"wikimedia\", \"fandom-www\", \"article\"),\n    \"#class\"   : wikimedia.WikimediaArticleExtractor,\n},\n\n{\n    \"#url\"     : \"https://mushishi.fandom.com/wiki/Yahagi\",\n    \"#category\": (\"wikimedia\", \"fandom-mushishi\", \"article\"),\n    \"#class\"   : wikimedia.WikimediaArticleExtractor,\n    \"#results\" : \"https://static.wikia.nocookie.net/mushi-shi/images/f/f8/Yahagi.png/revision/latest?cb=20150128052255&format=original\",\n\n    \"bitdepth\"      : 8,\n    \"canonicaltitle\": \"File:Yahagi.png\",\n    \"comment\"       : \"\",\n    \"commonmetadata\": {\n        \"ResolutionUnit\": 3,\n        \"XResolution\"   : \"3779/100\",\n        \"YResolution\"   : \"3779/100\",\n    },\n    \"date\"          : \"dt:2015-01-28 05:22:55\",\n    \"descriptionshorturl\": \"https://mushishi.fandom.com/index.php?curid=2595\",\n    \"descriptionurl\": \"https://mushishi.fandom.com/wiki/File:Yahagi.png\",\n    \"extension\"     : \"png\",\n    \"extmetadata\"   : {\n        \"DateTime\": {\n            \"source\": \"mediawiki-metadata\",\n            \"value\": \"2015-01-28T05:22:55Z\",\n        },\n        \"ObjectName\": {\n            \"source\": \"mediawiki-metadata\",\n            \"value\": \"Yahagi\",\n        },\n    },\n    \"filename\"      : \"Yahagi\",\n    \"height\"        : 410,\n    \"metadata\"      : {\n        \"bitDepth\"  : 8,\n        \"colorType\" : \"truecolour\",\n        \"duration\"  : 0,\n        \"frameCount\": 0,\n        \"loopCount\" : 1,\n        \"metadata\"  : [\n            {\n                \"name\" : \"XResolution\",\n                \"value\": \"3779/100\",\n            },\n            {\n                \"name\" : \"YResolution\",\n                \"value\": \"3779/100\",\n            },\n            {\n                \"name\" : \"ResolutionUnit\",\n                \"value\": 3,\n            },\n            {\n                \"name\" : \"_MW_PNG_VERSION\",\n                \"value\": 1,\n            },\n        ],\n    },\n    \"mime\"          : \"image/png\",\n    \"page\"          : \"Yahagi\",\n    \"sha1\"          : \"e3078a97976215323dbabb0c86b7acc55b512d16\",\n    \"size\"          : 429912,\n    \"timestamp\"     : \"2015-01-28T05:22:55Z\",\n    \"url\"           : \"https://static.wikia.nocookie.net/mushi-shi/images/f/f8/Yahagi.png/revision/latest?cb=20150128052255&format=original\",\n    \"user\"          : \"ITHYRIAL\",\n    \"userid\"        : 4637089,\n    \"width\"         : 728,\n},\n\n{\n    \"#url\"     : \"https://hearthstone.fandom.com/wiki/Flame_Juggler\",\n    \"#comment\" : \"empty 'metadata'\",\n    \"#category\": (\"wikimedia\", \"fandom-hearthstone\", \"article\"),\n    \"#class\"   : wikimedia.WikimediaArticleExtractor,\n\n    \"metadata\" : {},\n},\n\n{\n    \"#url\"     : \"https://hildatheseries.fandom.com/wiki/Burku\",\n    \"#comment\" : \"'.webp' file without 'format=original' (#5512)\",\n    \"#category\": (\"wikimedia\", \"fandom-hildatheseries\", \"article\"),\n    \"#class\"   : wikimedia.WikimediaArticleExtractor,\n    \"#options\" : {\"format\": \"\"},\n    \"#range\"   : \"1\",\n    \"#results\" : \"https://static.wikia.nocookie.net/hildatheseries/images/2/24/Burku.png/revision/latest?cb=20251010033752\",\n    \"#sha1_content\": \"36dce0e511fa8f6e1f834b92150126804fde971f\",\n},\n\n{\n    \"#url\"     : \"https://discogs.fandom.com/zh/wiki/File:CH-0430D2.jpg\",\n    \"#comment\" : \"non-English language prefix (#6370)\",\n    \"#category\": (\"wikimedia\", \"fandom-discogs\", \"file\"),\n    \"#class\"   : wikimedia.WikimediaArticleExtractor,\n    \"#results\" : \"https://static.wikia.nocookie.net/discogs/images/a/ab/CH-0430D2.jpg/revision/latest?cb=20241007150151&path-prefix=zh&format=original\",\n\n    \"lang\": \"zh\",\n},\n\n{\n    \"#url\"     : \"https://projectsekai.fandom.com/wiki/Project_SEKAI_Wiki\",\n    \"#category\": (\"wikimedia\", \"fandom-projectsekai\", \"article\"),\n    \"#class\"   : wikimedia.WikimediaArticleExtractor,\n},\n\n{\n    \"#url\"     : \"https://youtube.fandom.com/wiki/File:(500)_Montage_-_Reason_2_Die_Awakening\",\n    \"#comment\" : \"file without extension\",\n    \"#category\": (\"wikimedia\", \"fandom-youtube\", \"file\"),\n    \"#class\"   : wikimedia.WikimediaArticleExtractor,\n\n    \"extension\": \"\",\n    \"filename\" : \"(500) Montage - Reason 2 Die Awakening\",\n    \"page\"     : \"File:(500)_Montage_-_Reason_2_Die_Awakening\",\n    \"sha1\"     : \"6819869792d85927d60cc0a0cdc9e33dbd446731\",\n    \"size\"     : 81905,\n},\n\n{\n    \"#url\"     : \"https://youtube.fandom.com\",\n    \"#category\": (\"wikimedia\", \"fandom-youtube\", \"wiki\"),\n    \"#class\"   : wikimedia.WikimediaWikiExtractor,\n    \"#range\"   : \"1-20\",\n    \"#count\"   : 20,\n},\n\n)\n"
  },
  {
    "path": "test/results/fansly.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import fansly\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://fansly.com/post/819035448046268416\",\n    \"#comment\" : \"1 video\",\n    \"#class\"   : fansly.FanslyPostExtractor,\n},\n\n{\n    \"#url\"     : \"https://fansly.com/post/815337432600821760\",\n    \"#comment\" : \"4 images\",\n    \"#class\"   : fansly.FanslyPostExtractor,\n},\n\n{\n    \"#url\"     : \"https://fansly.com/post/800553913467023361\",\n    \"#comment\" : \"more than 5 files in an 'accountMediaBundles' entry\",\n    \"#class\"   : fansly.FanslyPostExtractor,\n    \"#auth\"    : True,\n    \"#count\"   : 6,\n},\n\n{\n    \"#url\"     : \"https://fansly.com/post/545313467469410305\",\n    \"#comment\" : \"'This post does not exist or has been deleted.'\",\n    \"#class\"   : fansly.FanslyPostExtractor,\n    \"#count\"   : 0,\n},\n\n{\n    \"#url\"     : \"https://fansly.com/post/543835794918354944\",\n    \"#comment\" : \"one locked image\",\n    \"#class\"   : fansly.FanslyPostExtractor,\n    \"#pattern\" : r\"https://cdn3.fansly.com/364164066794549248/542559086856646656.jpeg\\?.+\",\n    \"#count\"   : 1,\n    \"#auth\"    : False,\n    \"#log\"     : (\n        \"No 'token' provided\",\n        \"543835794918354944/542560754868432896: No format available\",\n    ),\n},\n\n{\n    \"#url\"     : \"https://fansly.com/post/451349524175138816\",\n    \"#comment\" : \"locked image + 2 locked videos\",\n    \"#class\"   : fansly.FanslyPostExtractor,\n    \"#count\"   : 0,\n    \"#auth\"    : False,\n    \"#log\"     : (\n        \"No 'token' provided\",\n        \"451349524175138816/451349523013316609: No format available\",\n        \"451349524175138816/451349523000729600: No format available\",\n        \"451349524175138816/451349523025899520: No format available\",\n    ),\n},\n\n{\n    \"#url\"     : \"https://fansly.com/post/831751284628414464\",\n    \"#comment\" : \"video - best format is non-manifest\",\n    \"#class\"   : fansly.FanslyPostExtractor,\n    \"#pattern\" : r\"https://cdn\\d+.fansly.com/788576864472932352/831751193247105025.mp4\\?.+\",\n\n    \"content\"        : \"off duty miyabi (⸝⸝> ω <⸝⸝)\",\n    \"date\"           : \"dt:2025-10-07 01:08:38\",\n    \"expiresAt\"      : None,\n    \"extension\"      : \"mp4\",\n    \"filename\"       : \"831751193247105025\",\n    \"id\"             : \"831751284628414464\",\n    \"file\"           : {\n        \"accountId\"     : \"788576864472932352\",\n        \"createdAt\"     : 1759799297,\n        \"date\"          : \"dt:2025-10-07 01:08:17\",\n        \"date_updated\"  : \"dt:2025-10-07 01:08:26\",\n        \"duration\"      : 12.376667,\n        \"flags\"         : 6,\n        \"format\"        : 2,\n        \"frameRate\"     : 30.05,\n        \"height\"        : 590,\n        \"id\"            : \"831751193247105025\",\n        \"location\"      : \"/788576864472932352/831751193247105025.mp4\",\n        \"mimetype\"      : \"video/mp4\",\n        \"originalHeight\": 590,\n        \"originalWidth\" : 786,\n        \"preview\"       : False,\n        \"resolutionMode\": 1,\n        \"status\"        : 1,\n        \"type\"          : \"video\",\n        \"updatedAt\"     : 1759799306,\n        \"variantHash\"   : {},\n        \"width\"         : 786,\n    },\n},\n\n{\n    \"#url\"     : \"https://fansly.com/post/527804734266941440\",\n    \"#comment\" : \"preview image (#8686)\",\n    \"#class\"   : fansly.FanslyPostExtractor,\n    \"#auth\"    : \"token\",\n    \"#pattern\" : r\"https://cdn3.fansly.com/509388488890658816/527804380229935104.jpeg\\?ngsw-bypass=true&Expires=\\d+.+\",\n\n    \"createdAt\"      : 1687332814,\n    \"date\"           : \"dt:2023-06-21 07:33:34\",\n    \"extension\"      : \"jpeg\",\n    \"filename\"       : \"527804380229935104\",\n    \"fypFlags\"       : 2,\n    \"id\"             : \"527804734266941440\",\n    \"file\"           : {\n        \"accountId\"     : \"509388488890658816\",\n        \"createdAt\"     : 1687332730,\n        \"date\"          : \"dt:2023-06-21 07:32:10\",\n        \"date_updated\"  : \"dt:2023-06-21 07:32:13\",\n        \"flags\"         : 394,\n        \"format\"        : 1,\n        \"height\"        : 1464,\n        \"id\"            : \"527804380229935104\",\n        \"location\"      : \"/509388488890658816/527804380229935104.jpeg\",\n        \"mimetype\"      : \"image/jpeg\",\n        \"preview\"       : True,\n        \"resolutionMode\": 2,\n        \"status\"        : 1,\n        \"type\"          : \"image\",\n        \"updatedAt\"     : 1687332733,\n        \"variantHash\"   : {},\n        \"width\"         : 1349,\n    },\n},\n\n{\n    \"#url\"     : \"https://fansly.com/Oliviaus/posts\",\n    \"#class\"   : fansly.FanslyCreatorPostsExtractor,\n},\n\n{\n    \"#url\"     : \"https://fansly.com/Oliviaus/posts/wall/785261459306196992\",\n    \"#class\"   : fansly.FanslyCreatorPostsExtractor,\n},\n\n{\n    \"#url\"     : \"https://fansly.com/Oliviaus/media\",\n    \"#class\"   : fansly.FanslyCreatorMediaExtractor,\n},\n\n{\n    \"#url\"     : \"https://fansly.com/Oliviaus/media/wall/785261459306196992\",\n    \"#class\"   : fansly.FanslyCreatorMediaExtractor,\n},\n\n{\n    \"#url\"     : \"https://fansly.com/VchiBan/media\",\n    \"#comment\" : \"posts without 'accountId' or 'contentId'\",\n    \"#class\"   : fansly.FanslyCreatorMediaExtractor,\n},\n\n{\n    \"#url\"     : \"https://fansly.com/home\",\n    \"#class\"   : fansly.FanslyHomeExtractor,\n},\n\n{\n    \"#url\"     : \"https://fansly.com/home/subscribed\",\n    \"#class\"   : fansly.FanslyHomeExtractor,\n},\n\n{\n    \"#url\"     : \"https://fansly.com/home/list/1234567890\",\n    \"#class\"   : fansly.FanslyHomeExtractor,\n},\n\n{\n    \"#url\"     : \"https://fansly.com/lists/1234567890\",\n    \"#class\"   : fansly.FanslyListExtractor,\n},\n\n{\n    \"#url\"     : \"https://fansly.com/lists\",\n    \"#class\"   : fansly.FanslyListsExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/fantia.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import fantia\n\n# These tests requires a free Fantia account with exactly one subscription: a free subscription to https://fantia.jp/fanclubs/6939\n\n__tests__ = (\n{\n    \"#url\"     : \"https://fantia.jp/fanclubs/6939\",\n    \"#category\": (\"\", \"fantia\", \"creator\"),\n    \"#class\"   : fantia.FantiaCreatorExtractor,\n    \"#range\"   : \"1-25\",\n    \"#count\"   : \">= 25\",\n\n    \"fanclub_user_id\": 52152,\n    \"tags\"           : list,\n    \"post_title\"     : str,\n},\n\n{\n    \"#url\"     : \"https://fantia.jp/posts/1166373\",\n    \"#category\": (\"\", \"fantia\", \"post\"),\n    \"#class\"   : fantia.FantiaPostExtractor,\n    \"#pattern\" : r\"https://(c\\.fantia\\.jp/uploads/post/file/1166373/|cc\\.fantia\\.jp/uploads/post_content_photo/file/732549[01]|fantia\\.jp/posts/1166373/album_image\\?)\",\n\n    \"blogpost_text\"   : r\"re:^$|This is a test.\\n\\n(This is a test.)?\\n\\n|Link to video:\\nhttps://www.youtube.com/watch\\?v=5SSdvNcAagI\\n\\nhtml img from another site:\\n\\n\\n\\n\\n\\n\",\n    \"comment\"         : \"\\n\\n\",\n    \"content_category\": r\"re:thumb|blog|photo_gallery\",\n    \"content_comment\" : str,\n    \"content_count\"   : 5,\n    \"content_filename\": r\"re:|\",\n    \"content_num\"     : range(1, 5),\n    \"content_title\"   : r\"re:Test (Blog Content \\d+|Image Gallery)|thumb\",\n    \"date\"            : \"dt:2022-03-09 16:46:12\",\n    \"fanclub_id\"      : 356320,\n    \"fanclub_name\"    : \"Test Fantia\",\n    \"fanclub_url\"     : \"https://fantia.jp/fanclubs/356320\",\n    \"fanclub_user_id\" : 7487131,\n    \"fanclub_user_name\": \"2022/03/08 15:13:52の名無し\",\n    \"file_url\"        : str,\n    \"filename\"        : str,\n    \"num\"             : int,\n    \"plan\"            : dict,\n    \"post_id\"         : 1166373,\n    \"post_title\"      : \"Test Fantia Post\",\n    \"post_url\"        : \"https://fantia.jp/posts/1166373\",\n    \"posted_at\"       : \"Thu, 10 Mar 2022 01:46:12 +0900\",\n    \"rating\"          : \"general\",\n    \"tags\"            : [],\n},\n\n{\n    \"#url\"     : \"https://fantia.jp/posts/508363\",\n    \"#category\": (\"\", \"fantia\", \"post\"),\n    \"#class\"   : fantia.FantiaPostExtractor,\n    \"#count\"   : 6,\n\n    \"post_title\": \"zunda逆バニーでおしりｺｯｼｮﾘ\",\n    \"tags\"      : list,\n    \"rating\"    : \"adult\",\n    \"post_id\"   : 508363,\n},\n\n{\n    \"#url\"     : \"https://fantia.jp/mypage/users/plans\",\n    \"#category\": (\"\", \"fantia\", \"supporting\"),\n    \"#class\"   : fantia.FantiaSupportingExtractor,\n    \"#auth\"    : \"cookies\",\n    \"#range\"   : \"1\",\n    \"#count\"   : 1,\n    \"#results\" : \"https://fantia.jp/fanclubs/6939\",\n},\n\n{\n    \"#url\"     : \"https://fantia.jp/mypage/users/plans?type=free\",\n    \"#category\": (\"\", \"fantia\", \"supporting\"),\n    \"#class\"   : fantia.FantiaSupportingExtractor,\n    \"#auth\"    : \"cookies\",\n    \"#range\"   : \"1\",\n    \"#count\"   : 1,\n    \"#results\" : \"https://fantia.jp/fanclubs/6939\",\n},\n\n{\n    \"#url\"     : \"https://fantia.jp/mypage/users/plans?type=not_free\",\n    \"#category\": (\"\", \"fantia\", \"supporting\"),\n    \"#class\"   : fantia.FantiaSupportingExtractor,\n    \"#auth\"    : \"cookies\",\n    \"#count\"   : 0,\n},\n\n)\n"
  },
  {
    "path": "test/results/fapachi.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import fapachi\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://fapachi.com/sonson/media/0082\",\n    \"#comment\" : \"NSFW\",\n    \"#category\": (\"\", \"fapachi\", \"post\"),\n    \"#class\"   : fapachi.FapachiPostExtractor,\n    \"#pattern\" : r\"https://fapachi\\.com/models/s/o/sonson/1/full/sonson_0082\\.jpeg\",\n\n    \"user\": \"sonson\",\n    \"id\"  : \"0082\",\n},\n\n{\n    \"#url\"     : \"https://fapachi.com/ferxiita/media/0159\",\n    \"#comment\" : \"NSFW\",\n    \"#category\": (\"\", \"fapachi\", \"post\"),\n    \"#class\"   : fapachi.FapachiPostExtractor,\n},\n\n{\n    \"#url\"     : \"https://fapachi.com/sonson\",\n    \"#category\": (\"\", \"fapachi\", \"user\"),\n    \"#class\"   : fapachi.FapachiUserExtractor,\n    \"#pattern\" : fapachi.FapachiPostExtractor.pattern,\n    \"#range\"   : \"1-50\",\n    \"#count\"   : 50,\n},\n\n{\n    \"#url\"     : \"https://fapachi.com/ferxiita/page/3\",\n    \"#category\": (\"\", \"fapachi\", \"user\"),\n    \"#class\"   : fapachi.FapachiUserExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/fapello.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import fapello\nfrom gallery_dl import exception\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://fapello.com/carrykey/530/\",\n    \"#category\": (\"\", \"fapello\", \"post\"),\n    \"#class\"   : fapello.FapelloPostExtractor,\n    \"#pattern\" : r\"https://fapello\\.com/content/c/a/carrykey/1000/carrykey_0530\\.jpg\",\n\n    \"model\"    : \"carrykey\",\n    \"id\"       : 530,\n    \"type\"     : \"photo\",\n    \"thumbnail\": \"\",\n},\n\n{\n    \"#url\"     : \"https://fapello.com/vladislava-661/693/\",\n    \"#category\": (\"\", \"fapello\", \"post\"),\n    \"#class\"   : fapello.FapelloPostExtractor,\n    \"#pattern\" : r\"https://cdn\\.fapello\\.com/content/v/l/vladislava-661/1000/vladislava-661_0693\\.mp4\",\n    \"#exception\": exception.NotFoundError,\n\n    \"model\"    : \"vladislava-661\",\n    \"id\"       : 693,\n    \"type\"     : \"video\",\n    \"thumbnail\": \"https://fapello.com/content/v/l/vladislava-661/1000/vladislava-661_0693.jpg\",\n},\n\n{\n    \"#url\"     : \"https://fapello.com/carrykey/000/\",\n    \"#category\": (\"\", \"fapello\", \"post\"),\n    \"#class\"   : fapello.FapelloPostExtractor,\n    \"#exception\": exception.NotFoundError,\n},\n\n{\n    \"#url\"     : \"https://fapello.su/grace-charis-gracecharisxo/2038266/\",\n    \"#category\": (\"\", \"fapello\", \"post\"),\n    \"#class\"   : fapello.FapelloPostExtractor,\n\n    \"model\"    : \"grace-charis-gracecharisxo\",\n    \"id\"       : 2038266,\n    \"type\"     : \"photo\",\n},\n\n{\n    \"#url\"     : \"https://fapello.com/hyoon/\",\n    \"#category\": (\"\", \"fapello\", \"model\"),\n    \"#class\"   : fapello.FapelloModelExtractor,\n    \"#pattern\" : fapello.FapelloPostExtractor.pattern,\n    \"#range\"   : \"1-50\",\n    \"#count\"   : 50,\n},\n\n{\n    \"#url\"     : \"https://fapello.com/kobaebeefboo/\",\n    \"#category\": (\"\", \"fapello\", \"model\"),\n    \"#class\"   : fapello.FapelloModelExtractor,\n},\n\n{\n    \"#url\"     : \"https://fapello.su/grace-charis-gracecharisxo/\",\n    \"#category\": (\"\", \"fapello\", \"model\"),\n    \"#class\"   : fapello.FapelloModelExtractor,\n    \"#pattern\" : fapello.FapelloPostExtractor.pattern,\n    \"#range\"   : \"1-50\",\n    \"#count\"   : 50,\n},\n\n{\n    \"#url\"     : \"https://fapello.com/top-likes/\",\n    \"#category\": (\"\", \"fapello\", \"path\"),\n    \"#class\"   : fapello.FapelloPathExtractor,\n    \"#pattern\" : fapello.FapelloModelExtractor.pattern,\n    \"#range\"   : \"1-10\",\n    \"#count\"   : 10,\n},\n\n{\n    \"#url\"     : \"https://fapello.su/top-likes/\",\n    \"#category\": (\"\", \"fapello\", \"path\"),\n    \"#class\"   : fapello.FapelloPathExtractor,\n    \"#pattern\" : fapello.FapelloModelExtractor.pattern,\n    \"#range\"   : \"1-10\",\n    \"#count\"   : 10,\n},\n\n{\n    \"#url\"     : \"https://fapello.com/videos/\",\n    \"#category\": (\"\", \"fapello\", \"path\"),\n    \"#class\"   : fapello.FapelloPathExtractor,\n    \"#pattern\" : fapello.FapelloPostExtractor.pattern,\n    \"#range\"   : \"1-10\",\n    \"#count\"   : 10,\n},\n\n{\n    \"#url\"     : \"https://fapello.com/top-followers/\",\n    \"#category\": (\"\", \"fapello\", \"path\"),\n    \"#class\"   : fapello.FapelloPathExtractor,\n},\n\n{\n    \"#url\"     : \"https://fapello.su/top-followers/\",\n    \"#category\": (\"\", \"fapello\", \"path\"),\n    \"#class\"   : fapello.FapelloPathExtractor,\n},\n\n{\n    \"#url\"     : \"https://fapello.com/trending/\",\n    \"#category\": (\"\", \"fapello\", \"path\"),\n    \"#class\"   : fapello.FapelloPathExtractor,\n},\n\n{\n    \"#url\"     : \"https://fapello.su/trending/\",\n    \"#category\": (\"\", \"fapello\", \"path\"),\n    \"#class\"   : fapello.FapelloPathExtractor,\n},\n\n{\n    \"#url\"     : \"https://fapello.com/popular_videos/twelve_hours/\",\n    \"#category\": (\"\", \"fapello\", \"path\"),\n    \"#class\"   : fapello.FapelloPathExtractor,\n},\n\n{\n    \"#url\"     : \"https://fapello.com/popular_videos/week/\",\n    \"#category\": (\"\", \"fapello\", \"path\"),\n    \"#class\"   : fapello.FapelloPathExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/fappic.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import imagehosts\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://fappic.com/98wxqcklyh8k/test.png\",\n    \"#category\": (\"imagehost\", \"fappic\", \"image\"),\n    \"#class\"   : imagehosts.FappicImageExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.fappic.com/98wxqcklyh8k/foo.bar\",\n    \"#category\": (\"imagehost\", \"fappic\", \"image\"),\n    \"#class\"   : imagehosts.FappicImageExtractor,\n    \"#pattern\"      : r\"https://img\\d+\\.fappic\\.com/img/\\w+/test\\.png\",\n    \"#sha1_metadata\": \"433b1d310b0ff12ad8a71ac7b9d8ba3f8cd1e898\",\n    \"#sha1_content\" : \"0c8768055e4e20e7c7259608b67799171b691140\",\n\n    \"filename\"   : \"test\",\n    \"extension\"  : \"png\",\n    \"token\"      : \"98wxqcklyh8k\",\n},\n\n{\n    \"#url\"     : \"https://www.fappic.com/i/00133/qi2nplzmwq7d_t.jpg\",\n    \"#comment\" : \"thumbnail URL (#8013)\",\n    \"#category\": (\"imagehost\", \"fappic\", \"image\"),\n    \"#class\"   : imagehosts.FappicImageExtractor,\n    \"#results\" : \"https://fappic.com/img/lahcoyienda2ftjqkmgdzxiaykeddwewwzw27nbuje/1X08WLv-4q6_0090.jpeg\",\n\n    \"filename\" : \"1X08WLv-4q6_0090\",\n    \"extension\": \"jpeg\",\n    \"token\"    : \"qi2nplzmwq7d\",\n},\n\n)\n"
  },
  {
    "path": "test/results/fashionnova.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import shopify\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://www.fashionnova.com/collections/mini-dresses\",\n    \"#category\": (\"shopify\", \"fashionnova\", \"collection\"),\n    \"#class\"   : shopify.ShopifyCollectionExtractor,\n    \"#range\"   : \"1-20\",\n    \"#count\"   : 20,\n},\n\n{\n    \"#url\"     : \"https://www.fashionnova.com/collections/mini-dresses/?page=1\",\n    \"#category\": (\"shopify\", \"fashionnova\", \"collection\"),\n    \"#class\"   : shopify.ShopifyCollectionExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.fashionnova.com/collections/mini-dresses#1\",\n    \"#category\": (\"shopify\", \"fashionnova\", \"collection\"),\n    \"#class\"   : shopify.ShopifyCollectionExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.fashionnova.com/products/all-my-life-legging-black\",\n    \"#category\": (\"shopify\", \"fashionnova\", \"product\"),\n    \"#class\"   : shopify.ShopifyProductExtractor,\n    \"#pattern\" : r\"https?://cdn\\d*\\.shopify\\.com/s/files/\",\n    \"#count\"   : 8,\n},\n\n{\n    \"#url\"     : \"https://www.fashionnova.com/collections/flats/products/name\",\n    \"#category\": (\"shopify\", \"fashionnova\", \"product\"),\n    \"#class\"   : shopify.ShopifyProductExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/fikfap.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import fikfap\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://fikfap.com/user/alinevs/post/1429486\",\n    \"#class\"   : fikfap.FikfapPostExtractor,\n    \"#pattern\" : r\"ytdl:https://vz-\\w+-178\\.b-cdn\\.net/bcdn_token=.+/playlist\\.m3u8\",\n\n    \"algorithm\"      : \"single-post\",\n    \"bunnyVideoId\"   : \"89218ae2-d79a-49a0-abcd-590fd70c9800\",\n    \"commentsCount\"  : int,\n    \"createdAt\"      : \"2025-10-21T00:49:00.306Z\",\n    \"date\"           : \"dt:2025-10-21 00:49:00\",\n    \"date_updated\"   : \"type:datetime\",\n    \"deletedAt\"      : None,\n    \"duration\"       : None,\n    \"explicitnessRating\": None,\n    \"extension\"      : \"mp4\",\n    \"filename\"       : \"⬇️check my FREE VIP OF ⬇️\",\n    \"inCollectionsCount\": range(20, 50),\n    \"isBunnyVideoReady\": True,\n    \"label\"          : \"⬇️check my FREE VIP OF ⬇️\",\n    \"likesCount\"     : range(300, 2000),\n    \"mediaId\"        : \"b821619e-96a1-49a3-a3f8-a8a3e8432a51\",\n    \"postId\"         : 1429486,\n    \"publishedAt\"    : \"2025-10-21T00:50:37.143Z\",\n    \"score\"          : range(300, 2000),\n    \"sexualOrientation\": \"STRAIGHT\",\n    \"tags\"           : [\"lesbian\"],\n    \"thumbnailStreamUrl\": str,\n    \"updatedAt\"      : \"iso:dt\",\n    \"uploadMethod\"   : \"USER_FILE\",\n    \"userId\"         : \"32f4c8d6-2409-4db8-9e66-d3b5ff0c1a98\",\n    \"videoStreamUrl\" : str,\n    \"viewsCount\"     : range(40_000, 200_000),\n    \"hashtags\"       : [{\n        \"createdAt\"      : \"2023-09-05T11:03:49.522Z\",\n        \"description\"    : \"Lesbian content only.\",\n        \"hashtagId\"      : \"287439f9-3210-42e2-98ea-2c7a86628845\",\n        \"isModerated\"    : True,\n        \"label\"          : \"lesbian\",\n        \"labelLower\"     : \"lesbian\",\n        \"lastCountUpdatedAt\": \"iso:dt\",\n        \"searchTags\"     : [],\n        \"thumbnailPostId\": 311180,\n        \"updatedAt\"      : \"iso:dt\",\n        \"sexualOrientations\": [\n            \"STRAIGHT\",\n            \"LESBIAN\",\n            \"TRANS\",\n            \"OTHER\",\n        ],\n    }],\n    \"author\"         : {\n        \"countCollections\": 1,\n        \"countIncomingFollows\": int,\n        \"countIncomingLikes\"  : int,\n        \"countOutgoingFollows\": int,\n        \"countOutgoingLikes\"  : int,\n        \"countPosts\"      : int,\n        \"countTotalViews\" : int,\n        \"createdAt\"       : \"2023-05-20T15:45:08.702Z\",\n        \"deletedAt\"       : None,\n        \"description\"     : \"Wanna to see  EXTRA? PLS FOLLOW!!⬇️👇\",\n        \"isAmbassador\"    : False,\n        \"isPartner\"       : True,\n        \"isSignedUp\"      : True,\n        \"isStaff\"         : False,\n        \"isVerified\"      : True,\n        \"lastSeenAt\"      : \"iso:dt\",\n        \"roles\"           : [],\n        \"thumbnailId\"     : \"b4b4f444-71d9-4dcd-91e4-0466ff5b9cdd\",\n        \"thumbnailUrl\"    : str,\n        \"updatedAt\"       : \"iso:dt\",\n        \"userId\"          : \"32f4c8d6-2409-4db8-9e66-d3b5ff0c1a98\",\n        \"username\"        : \"alinevs\",\n        \"profileLinks\"    : list,\n    },\n    \"linkDescription\": {\n        \"location\"     : \"POST_DESCRIPTION\",\n        \"profileLinkId\": \"92983607-7e6e-42c8-86f2-5a082f594435\",\n        \"type\"         : \"ONLYFANS\",\n        \"url\"          : \"https://onlyfans.com/alinevs/trial/kw2zxvoaieh5s7qx0gxynmxxp5ggi1tz\",\n        \"userId\"       : \"32f4c8d6-2409-4db8-9e66-d3b5ff0c1a98\",\n    },\n    \"linkSidebar\"    : {\n        \"location\"     : \"POST_SIDEBAR\",\n        \"profileLinkId\": \"6172dd36-4fa4-412b-bfc8-24c4c56c2b78\",\n        \"type\"         : \"OTHER\",\n        \"url\"          : \"https://onlyfans.com/alinevs/trial/kw2zxvoaieh5s7qx0gxynmxxp5ggi1tz\",\n        \"userId\"       : \"32f4c8d6-2409-4db8-9e66-d3b5ff0c1a98\",\n    },\n},\n\n{\n    \"#url\"     : \"https://fikfap.com/post/1429486\",\n    \"#class\"   : fikfap.FikfapPostExtractor,\n    \"#pattern\" : r\"ytdl:https://vz-\\w+-178\\.b-cdn\\.net/bcdn_token=.+/playlist\\.m3u8\",\n\n    \"bunnyVideoId\": \"89218ae2-d79a-49a0-abcd-590fd70c9800\",\n    \"date\"        : \"dt:2025-10-21 00:49:00\",\n    \"extension\"   : \"mp4\",\n    \"filename\"    : \"⬇️check my FREE VIP OF ⬇️\",\n    \"mediaId\"     : \"b821619e-96a1-49a3-a3f8-a8a3e8432a51\",\n    \"postId\"      : 1429486,\n    \"userId\"      : \"32f4c8d6-2409-4db8-9e66-d3b5ff0c1a98\",\n},\n\n{\n    \"#url\"     : \"https://fikfap.com/user/Hot-sauce-34/post/1026309\",\n    \"#class\"   : fikfap.FikfapPostExtractor,\n},\n\n{\n    \"#url\"     : \"https://fikfap.com/user/alinevs\",\n    \"#class\"   : fikfap.FikfapUserExtractor,\n    \"#range\"   : \"1-50\",\n    \"#pattern\" : r\"ytdl:https://vz-\\w+-\\w+\\.b\\-cdn\\.net/bcdn_token=.+/playlist\\.m3u8\",\n    \"#count\"   : 50,\n\n    \"algorithm\"      : \"user-posts\",\n    \"date\"           : \"type:datetime\",\n    \"date_updated\"   : \"type:datetime\",\n    \"extension\"      : \"mp4\",\n    \"filename\"       : str,\n    \"postId\"         : int,\n    \"tags\"           : list,\n    \"hashtags\"       : list,\n    \"author\"         : dict,\n    \"linkDescription\": dict,\n    \"linkSidebar\"    : dict,\n},\n\n{\n    \"#url\"     : \"https://fikfap.com/user/Hot-sauce-34\",\n    \"#comment\" : \"'-' in username\",\n    \"#class\"   : fikfap.FikfapUserExtractor,\n},\n\n{\n    \"#url\"     : \"https://fikfap.com/hash/outercourse\",\n    \"#class\"   : fikfap.FikfapHashtagExtractor,\n    \"#pattern\" : r\"ytdl:https://[^/]+\\.b\\-cdn\\.net/bcdn_token=.+/playlist\\.m3u8$\",\n    \"#count\"   : range(50, 100),\n\n    \"algorithm\": \"hashtag-posts\",\n    \"hashtag\"  : \"outercourse\",\n},\n\n{\n    \"#url\"     : \"https://fikfap.com/hash/foo-bar\",\n    \"#class\"   : fikfap.FikfapHashtagExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/filester.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import filester\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://filester.me/d/aPc9D5g\",\n    \"#class\"   : filester.FilesterFileExtractor,\n    \"#pattern\" : r\"https://cache[16].filester.me/d/37313437[0-9a-f]+\\.\\w{64}\\?download=true\",\n\n    \"date\"     : \"dt:2026-03-06 00:00:00\",\n    \"extension\": \"png\",\n    \"filename\" : \"\"\"test-テスト-\"&>\"\"\",\n    \"hash\"     : \"eb359cd8f02a7d6762f9863798297ff6a22569c5c87a9d38c55bdb3a3e26003f\",\n    \"id\"       : \"aPc9D5g\",\n    \"mime\"     : \"image/png\",\n    \"size\"     : \"182 bytes\",\n    \"uuid\"     : \"7147825c-5216-4d2a-b126-0e98e0b58d13\",\n},\n\n{\n    \"#url\"     : \"https://filester.me/f/1725bc5b793e8a4a\",\n    \"#class\"   : filester.FilesterFolderExtractor,\n    \"#pattern\" : (\n        r\"https://cache[16].filester.me/d/33343537[0-9a-f]+\\.\\w{64}\\?download=true\",\n        r\"https://cache[16].filester.me/d/30386462[0-9a-f]+\\.\\w{64}\\?download=true\",\n        r\"https://cache[16].filester.me/d/66663562[0-9a-f]+\\.\\w{64}\\?download=true\",\n        r\"https://cache[16].filester.me/d/63396533[0-9a-f]+\\.\\w{64}\\?download=true\",\n        r\"https://cache[16].filester.me/d/63343162[0-9a-f]+\\.\\w{64}\\?download=true\",\n        r\"https://cache[16].filester.me/d/39623935[0-9a-f]+\\.\\w{64}\\?download=true\",\n    ),\n\n    \"count\"      : 6,\n    \"num\"        : range(1, 6),\n    \"date\"       : \"dt:2026-03-06 00:00:00\",\n    \"extension\"  : {\"png\", \"mp4\"},\n    \"filename\"   : r\"re:\\d+_1\",\n    \"folder_date\": \"dt:2026-03-06 00:00:00\",\n    \"folder_id\"  : \"1725bc5b793e8a4a\",\n    \"folder_name\": '''\"&>''',\n    \"folder_size\": 194734,\n    \"folder_uuid\": \"34576704-dc5f-44a9-843c-ae0e5284309d\",\n    \"id\"         : r\"re:\\w+\",\n    \"size\"       : r\"re:\\d+\",\n    \"uuid\"       : \"iso:uuid\",\n},\n\n)\n"
  },
  {
    "path": "test/results/fireden.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import foolfuuka\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://boards.fireden.net/sci/thread/11264294/\",\n    \"#category\": (\"foolfuuka\", \"fireden\", \"thread\"),\n    \"#class\"   : foolfuuka.FoolfuukaThreadExtractor,\n    \"#sha1_url\": \"61cab625c95584a12a30049d054931d64f8d20aa\",\n},\n\n{\n    \"#url\"     : \"https://boards.fireden.net/sci/\",\n    \"#category\": (\"foolfuuka\", \"fireden\", \"board\"),\n    \"#class\"   : foolfuuka.FoolfuukaBoardExtractor,\n},\n\n{\n    \"#url\"     : \"https://boards.fireden.net/_/search/text/test/\",\n    \"#category\": (\"foolfuuka\", \"fireden\", \"search\"),\n    \"#class\"   : foolfuuka.FoolfuukaSearchExtractor,\n},\n\n{\n    \"#url\"     : \"https://boards.fireden.net/sci/gallery/6\",\n    \"#category\": (\"foolfuuka\", \"fireden\", \"gallery\"),\n    \"#class\"   : foolfuuka.FoolfuukaGalleryExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/fitnakedgirls.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import fitnakedgirls\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://fitnakedgirls.com/photos/gallery/sparksgowild-nude/\",\n    \"#comment\" : \"newer template with wp-block-image figures\",\n    \"#category\": (\"\", \"fitnakedgirls\", \"gallery\"),\n    \"#class\"   : fitnakedgirls.FitnakedgirlsGalleryExtractor,\n    \"#pattern\" : r\"https://fitnakedgirls\\.com/photos/wp-content/uploads/\\d+/\\d+/.+\\.(jpg|mp4)\",\n    \"#count\"   : range(60, 70),\n\n    \"gallery_id\"  : 419511,\n    \"gallery_slug\": \"sparksgowild-nude\",\n    \"model\"       : \"SparksGoWild\",\n    \"title\"       : \"SparksGoWild\",\n},\n\n{\n    \"#url\"     : \"https://fitnakedgirls.com/photos/gallery/mikayla-demaiter-mikayla_demaiter-nude-8-photos-2/\",\n    \"#comment\" : \"older template with size-large img tags\",\n    \"#category\": (\"\", \"fitnakedgirls\", \"gallery\"),\n    \"#class\"   : fitnakedgirls.FitnakedgirlsGalleryExtractor,\n    \"#pattern\" : r\"https://fitnakedgirls\\.com/photos/wp-content/uploads/\\d+/\\d+/.+\\.jpg\",\n    \"#count\"   : 8,\n\n    \"gallery_id\"  : 329550,\n    \"gallery_slug\": \"mikayla-demaiter-mikayla_demaiter-nude-8-photos-2\",\n    \"model\"       : \"Mikayla Demaiter (mikayla_demaiter)\",\n    \"title\"       : \"Mikayla Demaiter (mikayla_demaiter)\",\n},\n\n{\n    \"#url\"     : \"https://fitnakedgirls.com/photos/gallery/category/fit-naked-girls/\",\n    \"#category\": (\"\", \"fitnakedgirls\", \"category\"),\n    \"#class\"   : fitnakedgirls.FitnakedgirlsCategoryExtractor,\n    \"#pattern\" : fitnakedgirls.FitnakedgirlsGalleryExtractor.pattern,\n    \"#range\"   : \"1-10\",\n    \"#count\"   : 10,\n},\n\n{\n    \"#url\"     : \"https://fitnakedgirls.com/photos/gallery/tag/blonde/\",\n    \"#category\": (\"\", \"fitnakedgirls\", \"tag\"),\n    \"#class\"   : fitnakedgirls.FitnakedgirlsTagExtractor,\n    \"#pattern\" : fitnakedgirls.FitnakedgirlsGalleryExtractor.pattern,\n    \"#range\"   : \"1-10\",\n    \"#count\"   : 10,\n},\n\n{\n    \"#url\"     : \"https://fitnakedgirls.com/videos/2025/08/arikytsya-gym-sybian-riding-ppv-video/\",\n    \"#category\": (\"\", \"fitnakedgirls\", \"video\"),\n    \"#class\"   : fitnakedgirls.FitnakedgirlsVideoExtractor,\n    \"#pattern\" : r\"https://fitnakedgirls\\.com/videos/wp-content/uploads/.+\\.mp4\",\n    \"#count\"   : 1,\n\n    \"video_id\": 456559,\n    \"slug\"    : \"arikytsya-gym-sybian-riding-ppv-video\",\n    \"title\"   : \"Arikytsya Gym Sybian Riding PPV Video\",\n},\n\n{\n    \"#url\"     : \"https://fitnakedgirls.com/fitblog/haven-schulz-2/\",\n    \"#category\": (\"\", \"fitnakedgirls\", \"blog\"),\n    \"#class\"   : fitnakedgirls.FitnakedgirlsBlogExtractor,\n    \"#pattern\" : r\"https://fitnakedgirls\\.com/fitblog/wp-content/uploads/.+\\.(jpg|png)\",\n    \"#count\"   : 10,\n\n    \"post_id\": 165409,\n    \"slug\"   : \"haven-schulz-2\",\n    \"title\"  : \"Haven Schulz\",\n},\n\n)\n"
  },
  {
    "path": "test/results/flickr.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import flickr\nfrom gallery_dl import exception\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://www.flickr.com/photos/departingyyz/16089302239\",\n    \"#category\": (\"\", \"flickr\", \"image\"),\n    \"#class\"   : flickr.FlickrImageExtractor,\n    \"#options\" : {\n        \"contexts\": True,\n        \"exif\": True,\n        \"profile\": True,\n    },\n    \"#results\"     : \"https://live.staticflickr.com/7463/16089302239_de18cd8017_b_d.jpg\",\n    \"#pattern\"     : flickr.FlickrImageExtractor.pattern,\n    \"#sha1_content\": [\n        \"3133006c6d657fe54cf7d4c46b82abbcb0efaf9f\",\n        \"0821a28ee46386e85b02b67cf2720063440a228c\",\n    ],\n\n    \"camera\"     : \"Sony ILCE-7\",\n    \"comments\"   : int,\n    \"description\": str,\n    \"exif\"       : list,\n    \"extension\"  : \"jpg\",\n    \"filename\"   : \"16089302239_de18cd8017_b_d\",\n    \"id\"         : 16089302239,\n    \"height\"     : 683,\n    \"label\"      : \"Large\",\n    \"license\"    : \"0\",\n    \"license_name\": \"All Rights Reserved\",\n    \"media\"      : \"photo\",\n    \"pool\"       : list,\n    \"set\"        : list,\n    \"safety_level\": \"0\",\n    \"tags\"       : list,\n    \"url\"        : str,\n    \"views\"      : int,\n    \"width\"      : 1024,\n\n    \"user\": {\n        \"description\": str,\n        \"has_adfree\": 0,\n        \"has_free_educational_resources\": 0,\n        \"has_free_standard_shipping\": 0,\n        \"has_stats\": 0,\n        \"iconfarm\": 8,\n        \"iconserver\": \"7265\",\n        \"id\": \"59437997@N05\",\n        \"ispro\": 0,\n        \"location\": \"Canada\",\n        \"mobileurl\": \"https://www.flickr.com/photos/departingyyz/\",\n        \"nsid\": \"59437997@N05\",\n        \"path_alias\": \"departingyyz\",\n        \"photosurl\": \"https://www.flickr.com/photos/departingyyz/\",\n        \"profileurl\": \"https://www.flickr.com/people/departingyyz/\",\n        \"realname\": \"Joshua Paul Shefman\",\n        \"username\": \"departing(YYZ)\",\n        \"photos\": {\n            \"count\": int,\n            \"firstdate\": \"1297577284\",\n            \"firstdatetaken\": \"2008-07-07 18:31:47\",\n        },\n        \"timezone\": {\n            \"label\": \"Eastern Time (US & Canada)\",\n            \"offset\": \"-05:00\",\n            \"timezone\": 14,\n            \"timezone_id\": \"EST5EDT\",\n        },\n    },\n},\n\n{\n    \"#url\"     : \"https://secure.flickr.com/photos/departingyyz/16089302239\",\n    \"#category\": (\"\", \"flickr\", \"image\"),\n    \"#class\"   : flickr.FlickrImageExtractor,\n},\n\n{\n    \"#url\"     : \"https://m.flickr.com/photos/departingyyz/16089302239\",\n    \"#category\": (\"\", \"flickr\", \"image\"),\n    \"#class\"   : flickr.FlickrImageExtractor,\n},\n\n{\n    \"#url\"     : \"https://flickr.com/photos/departingyyz/16089302239\",\n    \"#category\": (\"\", \"flickr\", \"image\"),\n    \"#class\"   : flickr.FlickrImageExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.flickr.com/photos/eliasroviello/52713899383/\",\n    \"#comment\" : \"video\",\n    \"#class\"   : flickr.FlickrImageExtractor,\n    \"#pattern\" : r\"https://live.staticflickr\\.com/video/52713899383/51dfffef79/1080p\\.mp4\\?s=ey.+\",\n\n    \"media\": \"video\",\n},\n\n{\n    \"#url\"     : \"http://c2.staticflickr.com/2/1475/24531000464_9a7503ae68_b.jpg\",\n    \"#category\": (\"\", \"flickr\", \"image\"),\n    \"#class\"   : flickr.FlickrImageExtractor,\n    \"#pattern\" : flickr.FlickrImageExtractor.pattern,\n},\n\n{\n    \"#url\"     : \"https://farm2.static.flickr.com/1035/1188352415_cb139831d0.jpg\",\n    \"#category\": (\"\", \"flickr\", \"image\"),\n    \"#class\"   : flickr.FlickrImageExtractor,\n    \"#pattern\" : flickr.FlickrImageExtractor.pattern,\n},\n\n{\n    \"#url\"     : \"https://flic.kr/p/FPVo9U\",\n    \"#category\": (\"\", \"flickr\", \"image\"),\n    \"#class\"   : flickr.FlickrImageExtractor,\n    \"#pattern\" : flickr.FlickrImageExtractor.pattern,\n\n    \"id\"  : 26140204724,\n    \"date\": \"dt:2016-05-01 10:03:33\",\n    \"user\": {\n        \"location\": \"diebolsheim, france\",\n        \"nsid\": \"23965455@N05\",\n        \"path_alias\": \"sgu_\",\n        \"realname\": \"philippe baumgart\",\n        \"username\": \"philippe baumgart\",\n    },\n},\n\n{\n    \"#url\"     : \"https://www.flickr.com/photos/zzz/16089302238\",\n    \"#category\": (\"\", \"flickr\", \"image\"),\n    \"#class\"   : flickr.FlickrImageExtractor,\n    \"#exception\": exception.NotFoundError,\n},\n\n{\n    \"#url\"     : \"https://www.flickr.com/photos/shona_s/albums/72157633471741607\",\n    \"#category\": (\"\", \"flickr\", \"album\"),\n    \"#class\"   : flickr.FlickrAlbumExtractor,\n    \"#pattern\" : flickr.FlickrImageExtractor.pattern,\n    \"#count\"   : 6,\n},\n\n{\n    \"#url\"     : \"https://www.flickr.com/photos/shona_s/albums\",\n    \"#category\": (\"\", \"flickr\", \"album\"),\n    \"#class\"   : flickr.FlickrAlbumExtractor,\n    \"#pattern\" : flickr.FlickrAlbumExtractor.pattern,\n    \"#count\"   : 2,\n},\n\n{\n    \"#url\"     : \"https://secure.flickr.com/photos/shona_s/albums\",\n    \"#category\": (\"\", \"flickr\", \"album\"),\n    \"#class\"   : flickr.FlickrAlbumExtractor,\n},\n\n{\n    \"#url\"     : \"https://m.flickr.com/photos/shona_s/albums\",\n    \"#category\": (\"\", \"flickr\", \"album\"),\n    \"#class\"   : flickr.FlickrAlbumExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.flickr.com/photos/flickr/galleries/72157681572514792/\",\n    \"#category\": (\"\", \"flickr\", \"gallery\"),\n    \"#class\"   : flickr.FlickrGalleryExtractor,\n    \"#pattern\" : flickr.FlickrImageExtractor.pattern,\n    \"#count\"   : \">= 10\",\n},\n\n{\n    \"#url\"     : \"https://www.flickr.com/groups/bird_headshots/\",\n    \"#category\": (\"\", \"flickr\", \"group\"),\n    \"#class\"   : flickr.FlickrGroupExtractor,\n    \"#pattern\" : flickr.FlickrImageExtractor.pattern,\n    \"#count\"   : \"> 150\",\n},\n\n{\n    \"#url\"     : \"https://www.flickr.com/photos/shona_s/\",\n    \"#category\": (\"\", \"flickr\", \"user\"),\n    \"#class\"   : flickr.FlickrUserExtractor,\n    \"#pattern\" : flickr.FlickrImageExtractor.pattern,\n    \"#count\"   : 28,\n},\n\n{\n    \"#url\"     : \"https://www.flickr.com/photos/shona_s/favorites\",\n    \"#category\": (\"\", \"flickr\", \"favorite\"),\n    \"#class\"   : flickr.FlickrFavoriteExtractor,\n    \"#options\" : {\n        \"info\": True,\n        \"profile\": True,\n    },\n    \"#results\" : (\n        \"https://live.staticflickr.com/7322/8719105033_4a21140220_o_d.jpg\",\n        \"https://live.staticflickr.com/7376/8720226282_eae0faefd1_o_d.jpg\",\n        \"https://live.staticflickr.com/7460/8720245516_ab06f80353_o_d.jpg\",\n        \"https://live.staticflickr.com/8268/8705102120_64349ebac2_o_d.jpg\",\n    ),\n\n    \"dates\"       : dict,\n    \"license\"     : \"0\",\n    \"license_name\": \"All Rights Reserved\",\n    \"notes\"       : dict,\n    \"safety_level\": \"0\",\n    \"owner\": {\n        \"iconfarm\"  : int,\n        \"iconserver\": str,\n        \"location\"  : None,\n        \"nsid\"      : str,\n        \"path_alias\": None,\n        \"realname\"  : str,\n        \"username\"  : str,\n    },\n    \"user\": {\n        \"nsid\": \"95410434@N08\",\n        \"path_alias\": \"shona_s\",\n        \"username\": \"Shona_S\",\n\n        \"description\": \"\",\n        \"has_adfree\": 0,\n        \"has_free_educational_resources\": 0,\n        \"has_free_standard_shipping\": 0,\n        \"has_stats\": 0,\n        \"iconfarm\": 0,\n        \"iconserver\": \"0\",\n        \"id\": \"95410434@N08\",\n        \"ispro\": 0,\n        \"mobileurl\": \"https://www.flickr.com/photos/shona_s/\",\n        \"photosurl\": \"https://www.flickr.com/photos/shona_s/\",\n        \"profileurl\": \"https://www.flickr.com/people/shona_s/\",\n        \"photos\": {\n            \"count\": 28,\n            \"firstdate\": \"1367947187\",\n            \"firstdatetaken\": \"2012-09-21 19:35:39\",\n        },\n    },\n},\n\n{\n    \"#url\"     : \"https://flickr.com/search/?text=mountain\",\n    \"#category\": (\"\", \"flickr\", \"search\"),\n    \"#class\"   : flickr.FlickrSearchExtractor,\n    \"#range\"   : \"1-10\",\n    \"#count\"   : 10,\n    \"#pattern\" : r\"https://live\\.staticflickr\\.com/\\d+/.+\",\n\n    \"search\": {\n        \"text\": \"mountain\",\n    },\n},\n\n{\n    \"#url\"     : \"https://flickr.com/search/?text=tree%20cloud%20house&color_codes=4&styles=minimalism\",\n    \"#category\": (\"\", \"flickr\", \"search\"),\n    \"#class\"   : flickr.FlickrSearchExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/foriio.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import foriio\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://www.foriio.com/works/2081544\",\n    \"#comment\" : \"single image\",\n    \"#class\"   : foriio.ForiioWorkExtractor,\n    \"#results\" : \"https://foriio.imgix.net/store/d96eb4a34f7f69f1fb1e839abfc22cf6.jpg\",\n\n    \"accept_collaboration\": None,\n    \"author_id\"      : 234603,\n    \"category_list\"  : [],\n    \"count\"          : 1,\n    \"created_by_user_id\": \"\",\n    \"date\"           : \"dt:2025-12-14 05:13:15\",\n    \"description\"    : \"沖縄県のシーサーとハンバーガーをコンセプトに制作したミニキャラクター\",\n    \"extension\"      : \"jpg\",\n    \"favorite_id\"    : \"\",\n    \"file_id\"        : 4165644,\n    \"filename\"       : \"d96eb4a34f7f69f1fb1e839abfc22cf6\",\n    \"has_watermark\"  : True,\n    \"height\"         : 2048,\n    \"instagram_work\" : False,\n    \"is_draft\"       : False,\n    \"is_nsfw\"        : False,\n    \"is_schedule\"    : False,\n    \"notes_count\"    : 0,\n    \"num\"            : 1,\n    \"organization_id\": \"\",\n    \"own_roles\"      : [\"デザイン\"],\n    \"owner_id\"       : 234603,\n    \"pinned_work\"    : False,\n    \"published_at\"   : \"2025-12-14T05:13:15.506Z\",\n    \"reject_collaboration\": None,\n    \"schedule_time\"  : None,\n    \"thumbnail\"      : \"https://foriio.imgix.net/store/d96eb4a34f7f69f1fb1e839abfc22cf6.jpg?ixlib=rb-4.1.1&auto=compress&w=688&h=424&fit=crop&mark=https%3A%2F%2Fdyci7co52mbcc.cloudfront.net%2Fpublic%2FSample-%25282%2529_225233.png&mark-alpha=20&mark-rot=30&mark-tile=grid&mark-pad=40&s=358509b4edd91392e233e69f188179cb\",\n    \"thumbnail_id\"   : None,\n    \"title\"          : \"オリジナルSDキャラクター\",\n    \"type\"           : \"image\",\n    \"width\"          : 2048,\n    \"work_id\"        : 2081544,\n    \"creative_roles\" : list,\n    \"credits\"        : list,\n    \"author\"         : {\n        \"hidden_from_search_engines\": False,\n        \"id\"              : 234603,\n        \"is_pro\"          : True,\n        \"screen_name\"     : \"konoha-inc\",\n        \"status\"          : \"approved\",\n        \"support_director\": None,\n        \"profile\"         : dict,\n    },\n},\n\n{\n    \"#url\"     : \"https://www.foriio.com/works/16416\",\n    \"#comment\" : \"image post, multiple '.psd' files\",\n    \"#class\"   : foriio.ForiioWorkExtractor,\n    \"#pattern\" : r\"https://foriio\\.imgix\\.net/store/\\w+\\.psd\",\n    \"#count\"   : 10,\n\n    \"accept_collaboration\": None,\n    \"author_id\"      : 9213,\n    \"category_list\"  : [],\n    \"count\"          : 10,\n    \"created_by_user_id\": \"\",\n    \"date\"           : \"dt:2019-09-16 03:56:44\",\n    \"description\"    : \"\",\n    \"extension\"      : \"psd\",\n    \"favorite_id\"    : \"\",\n    \"file_id\"        : int,\n    \"filename\"       : \"hash:md5\",\n    \"has_watermark\"  : False,\n    \"height\"         : int,\n    \"instagram_work\" : False,\n    \"is_draft\"       : False,\n    \"is_nsfw\"        : False,\n    \"is_schedule\"    : None,\n    \"notes_count\"    : 0,\n    \"num\"            : range(1, 10),\n    \"organization_id\": \"\",\n    \"own_roles\"      : [\"イラストレーター\"],\n    \"owner_id\"       : 9213,\n    \"pinned_work\"    : False,\n    \"published_at\"   : \"2019-09-16T03:56:44.703Z\",\n    \"reject_collaboration\": None,\n    \"schedule_time\"  : None,\n    \"thumbnail\"      : \"https://foriio.imgix.net/store/f355fa6a7da645954867ecf7b9bbcbd7.psd?ixlib=rb-4.1.0&auto=compress&w=688&h=424&rect=27%2C0%2C679%2C425&s=06b06c79f9c5d4159d4648e36ae98c78\",\n    \"thumbnail_id\"   : 42091,\n    \"title\"          : \"Illust:01\",\n    \"type\"           : \"image\",\n    \"width\"          : int,\n    \"work_id\"        : 16416,\n},\n\n{\n    \"#url\"     : \"https://www.foriio.com/works/2267154\",\n    \"#comment\" : \"video post\",\n    \"#class\"   : foriio.ForiioWorkExtractor,\n    \"#results\" : (\n        \"ytdl:https://www.youtube.com/watch?v=PX23Yq9fSWg\",\n        \"ytdl:https://www.youtube.com/shorts/0zkhZ95h8F0\",\n    ),\n\n    \"author_id\"   : 310691,\n    \"count\"       : 2,\n    \"date\"        : \"dt:2026-03-16 17:48:22\",\n    \"extension\"   : \"mp4\",\n    \"file_id\"     : {\"PX23Yq9fSWg\", \"0zkhZ95h8F0\"},\n    \"owner_id\"    : 310691,\n    \"platform\"    : {\"youtube\", \"youtube_short\"},\n    \"published_at\": \"2026-03-16T17:48:22.972Z\",\n    \"thumbnail\"   : \"https://foriio.imgix.net/store/9XRWRG9dBrhZ_1773683652.jpg\",\n    \"title\"       : str,\n    \"type\"        : \"video\",\n    \"video_id\"    : {\"PX23Yq9fSWg\", \"0zkhZ95h8F0\"},\n    \"work_id\"     : 2267154,\n},\n\n{\n    \"#url\"     : \"https://www.foriio.com/works/2267160\",\n    \"#comment\" : \"webpage post\",\n    \"#class\"   : foriio.ForiioWorkExtractor,\n    \"#results\" : \"https://www.anisearch.de/\",\n\n    \"date\"   : \"dt:2026-03-16 17:50:38\",\n    \"domain\" : \"www.anisearch.de\",\n    \"image\"  : \"https://foriio.imgix.net/store/DphWF_ZrthxW_1773683597.webp\",\n    \"og_type\": \"website\",\n    \"type\"   : \"web_article\",\n    \"url\"    : \"https://www.anisearch.de/\",\n    \"work_id\": 2267160,\n},\n\n{\n    \"#url\"     : \"https://www.foriio.com/works/2267166\",\n    \"#comment\" : \"audio post\",\n    \"#class\"   : foriio.ForiioWorkExtractor,\n    \"#results\" : (\n        \"ytdl:http://soundcloud.com/ethmusic/lostin-powers-she-so-heavy\",\n        \"ytdl:https://soundcloud.com/the80m/the-following\",\n    ),\n\n    \"count\"    : 2,\n    \"date\"     : \"dt:2026-03-16 17:55:14\",\n    \"extension\": \"mp3\",\n    \"type\"     : \"sound\",\n    \"work_id\"  : 2267166,\n    \"metadata\" : dict,\n},\n\n{\n    \"#url\"     : \"https://www.foriio.com/works/2267161\",\n    \"#comment\" : \"copyright post\",\n    \"#class\"   : foriio.ForiioWorkExtractor,\n    \"#results\" : \"https://foriio.imgix.net/store/cw_image_YBe-KU5tfnEO_1773683697.png\",\n\n    \"author_id\"   : 310691,\n    \"date\"        : \"dt:2026-03-16 17:53:32\",\n    \"file_id\"     : 15246,\n    \"filename\"    : \"cw_image_YBe-KU5tfnEO_1773683697\",\n    \"type\"        : \"copy_writing\",\n    \"work_id\"     : 2267161,\n    \"copy_writing\": {\n        \"id\"   : 15246,\n        \"image\": \"https://dyci7co52mbcc.cloudfront.net/store/cw_image_YBe-KU5tfnEO_1773683697.png\",\n        \"title\": \"Copyright 2026 @gdldev\",\n    },\n},\n\n{\n    \"#url\"     : \"https://www.foriio.com/konoha-inc\",\n    \"#class\"   : foriio.ForiioUserExtractor,\n    \"#results\" : (\n        \"https://foriio.com/works/1868200\",\n        \"https://foriio.com/works/2050930\",\n        \"https://foriio.com/works/1998734\",\n        \"https://foriio.com/works/2093606\",\n        \"https://foriio.com/works/1867751\",\n        \"https://foriio.com/works/2081544\",\n    ),\n\n    \"author_id\"      : {18138, 234603},\n    \"category_list\"  : list,\n    \"has_watermark\"  : bool,\n    \"id\"             : int,\n    \"instagram_work\" : False,\n    \"is_nsfw\"        : False,\n    \"notes_count\"    : 0,\n    \"organization_id\": \"\",\n    \"owner_id\"       : {18138, 234603},\n    \"pin_order\"      : None,\n    \"pinned_work\"    : False,\n    \"published_at\"   : \"iso:8601\",\n    \"type\"           : {\"video\", \"image\"},\n},\n\n{\n    \"#url\"     : \"https://www.foriio.com/konoha-inc\",\n    \"#comment\" : \"'posts' option\",\n    \"#class\"   : foriio.ForiioUserExtractor,\n    \"#options\" : {\"posts\": \"image\"},\n    \"#results\" : \"https://foriio.com/works/2081544\",\n\n    \"author_id\"      : 234603,\n    \"category_list\"  : [\"イラスト\"],\n    \"has_watermark\"  : True,\n    \"id\"             : 2081544,\n    \"instagram_work\" : False,\n    \"is_nsfw\"        : False,\n    \"owner_id\"       : 234603,\n    \"published_at\"   : \"2025-12-14T05:13:15.506Z\",\n    \"thumbnail\"      : \"https://foriio.imgix.net/store/d96eb4a34f7f69f1fb1e839abfc22cf6.jpg?ixlib=rb-4.1.1&auto=compress&w=688&h=424&fit=crop&mark=https%3A%2F%2Fdyci7co52mbcc.cloudfront.net%2Fpublic%2FSample-%25282%2529_225233.png&mark-alpha=20&mark-rot=30&mark-tile=grid&mark-pad=40&s=358509b4edd91392e233e69f188179cb\",\n    \"title\"          : \"オリジナルSDキャラクター\",\n    \"type\"           : \"image\",\n    \"user_roles\"     : [\"デザイン\"],\n},\n\n)\n"
  },
  {
    "path": "test/results/furaffinity.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import furaffinity\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://www.furaffinity.net/gallery/mirlinthloth/\",\n    \"#category\": (\"\", \"furaffinity\", \"gallery\"),\n    \"#class\"   : furaffinity.FuraffinityGalleryExtractor,\n    \"#pattern\" : r\"https://d\\d?\\.f(uraffinity|acdn)\\.net/art/mirlinthloth/\\d+/\\d+.\\w+\\.\\w+\",\n    \"#range\"   : \"45-50\",\n    \"#count\"   : 6,\n},\n\n{\n    \"#url\"     : \"https://www.furaffinity.net/gallery/markrun15/folder/173240/Inanimate/?\",\n    \"#category\": (\"\", \"furaffinity\", \"folder\"),\n    \"#class\"   : furaffinity.FuraffinityFolderExtractor,\n    \"#range\"   : \"46-50\",\n    \"#urla\"    : (\n        \"https://d.furaffinity.net/art/markrun15/1598704240/1598704240.markrun15_20200829_dusknoir_flat3.jpg\",\n        \"https://d.furaffinity.net/art/markrun15/1598704109/1598704109.markrun15_20200829_dusknoir_flat1.jpg\",\n        \"https://d.furaffinity.net/art/markrun15/1588674514/1588674514.markrun15_20200504_cubemorgana.jpg\",\n        \"https://d.furaffinity.net/art/markrun15/1588501280/1588501280.markrun15_20200427_inanimate_animal3.jpg\",\n        \"https://d.furaffinity.net/art/markrun15/1588501161/1588501161.markrun15_20200427_inanimate_animal.jpg\",\n    ),\n\n    \"folder_id\"  : \"173240\",\n    \"folder_name\": \"Inanimate\",\n},\n\n{\n    \"#url\"     : \"https://www.furaffinity.net/scraps/mirlinthloth/\",\n    \"#category\": (\"\", \"furaffinity\", \"scraps\"),\n    \"#class\"   : furaffinity.FuraffinityScrapsExtractor,\n    \"#pattern\" : r\"https://d\\d?\\.f(uraffinity|acdn)\\.net/art/[^/]+(/stories)?/\\d+/\\d+.\\w+.\",\n    \"#count\"   : \">= 3\",\n},\n\n{\n    \"#url\"     : \"https://www.furaffinity.net/favorites/mirlinthloth/\",\n    \"#category\": (\"\", \"furaffinity\", \"favorite\"),\n    \"#class\"   : furaffinity.FuraffinityFavoriteExtractor,\n    \"#pattern\" : r\"https://d\\d?\\.f(uraffinity|acdn)\\.net/art/[^/]+/\\d+/\\d+.\\w+\\.\\w+\",\n    \"#range\"   : \"45-50\",\n    \"#count\"   : 6,\n\n    \"favorite_id\": int,\n},\n\n{\n    \"#url\"     : \"https://www.furaffinity.net/favorites/mirlinthloth/46682246/next?\",\n    \"#comment\" : \"custom start location\",\n    \"#class\"   : furaffinity.FuraffinityFavoriteExtractor,\n    \"#auth\"    : False,\n    \"#range\"   : \"1-3\",\n    \"#results\" : (\n        \"https://d.furaffinity.net/art/kacey/1263424668/1263424668.kacey_mine.jpg\",\n        \"https://d.furaffinity.net/art/leomon32/1254250660/1254250660.leomon32_high_in_the_sky.jpg\",\n        \"https://d.furaffinity.net/art/firefoxzero/1262442028/1262442028.firefoxzero_resolute_model_4.png\",\n    ),\n},\n\n{\n    \"#url\"     : \"https://www.furaffinity.net/search/?q=cute\",\n    \"#category\": (\"\", \"furaffinity\", \"search\"),\n    \"#class\"   : furaffinity.FuraffinitySearchExtractor,\n    \"#pattern\" : r\"https://d\\d?\\.f(uraffinity|acdn)\\.net/art/[^/]+/\\d+/\\d+.\\w+\\.\\w+\",\n    \"#range\"   : \"45-50\",\n    \"#count\"   : 6,\n},\n\n{\n    \"#url\"     : \"https://www.furaffinity.net/search/?q=leaf&range=1day\",\n    \"#comment\" : \"first page of search results (#2402)\",\n    \"#category\": (\"\", \"furaffinity\", \"search\"),\n    \"#class\"   : furaffinity.FuraffinitySearchExtractor,\n    \"#range\"   : \"1-3\",\n    \"#count\"   : 3,\n},\n\n{\n    \"#url\"     : \"https://www.furaffinity.net/view/21835115/\",\n    \"#category\": (\"\", \"furaffinity\", \"post\"),\n    \"#class\"   : furaffinity.FuraffinityPostExtractor,\n    \"#pattern\" : r\"https://d\\d*\\.f(uraffinity|acdn)\\.net/(download/)?art/mirlinthloth/music/1488278723/1480267446.mirlinthloth_dj_fennmink_-_bude_s_4_ever\\.mp3\",\n\n    \"artist\"     : \"mirlinthloth\",\n    \"artist_url\" : \"mirlinthloth\",\n    \"date\"       : \"dt:2016-11-27 17:24:06\",\n    \"description\": \"A Song made playing the game Cosmic DJ.\",\n    \"extension\"  : \"mp3\",\n    \"filename\"   : r\"re:\\d+\\.\\w+_dj_fennmink_-_bude_s_4_ever\",\n    \"id\"         : 21835115,\n    \"tags\"       : list,\n    \"title\"      : \"Bude's 4 Ever\",\n    \"url\"        : r\"re:https://d\\d?\\.f(uraffinity|acdn)\\.net/art\",\n    \"user\"       : \"mirlinthloth\",\n    \"views\"      : int,\n    \"favorites\"  : int,\n    \"comments\"   : int,\n    \"rating\"     : \"General\",\n    \"fa_category\": \"Music\",\n    \"theme\"      : \"All\",\n    \"species\"    : \"Unspecified / Any\",\n    \"gender\"     : \"Any\",\n    \"width\"      : 120,\n    \"height\"     : 120,\n    \"scraps\"     : False,\n},\n\n{\n    \"#url\"     : \"https://www.furaffinity.net/view/42166511/\",\n    \"#comment\" : \"'external' option (#1492)\",\n    \"#category\": (\"\", \"furaffinity\", \"post\"),\n    \"#class\"   : furaffinity.FuraffinityPostExtractor,\n    \"#options\" : {\"external\": True},\n    \"#pattern\" : r\"https://d\\d*\\.f(uraffinity|acdn)\\.net/|http://www\\.postybirb\\.com\",\n    \"#count\"   : 2,\n},\n\n{\n    \"#url\"     : \"https://www.furaffinity.net/view/45331225/\",\n    \"#comment\" : \"no tags (#2277)\",\n    \"#category\": (\"\", \"furaffinity\", \"post\"),\n    \"#class\"   : furaffinity.FuraffinityPostExtractor,\n\n    \"artist\"     : \"Kota_Remminders\",\n    \"artist_url\" : \"kotaremminders\",\n    \"date\"       : \"dt:2022-01-03 17:49:33\",\n    \"fa_category\": \"Adoptables\",\n    \"filename\"   : \"1641232173.kotaremminders_chidopts1\",\n    \"gender\"     : \"Any\",\n    \"height\"     : 905,\n    \"id\"         : 45331225,\n    \"rating\"     : \"General\",\n    \"species\"    : \"Unspecified / Any\",\n    \"tags\"       : [],\n    \"theme\"      : \"All\",\n    \"title\"      : \"REMINDER\",\n    \"width\"      : 1280,\n},\n\n{\n    \"#url\"     : \"https://www.furaffinity.net/view/22964019/\",\n    \"#comment\" : \"get thumbnails for posts (#1284)\",\n    \"#category\": (\"\", \"furaffinity\", \"post\"),\n    \"#class\"   : furaffinity.FuraffinityPostExtractor,\n\n    \"artist\"      : \"Dwale\",\n    \"artist_url\"  : \"dwale\",\n    \"date\"        : \"dt:2017-03-21 14:21:29\",\n    \"fa_category\" : \"Poetry\",\n    \"filename\"    : \"1490106089.dwale_poem_for_children\",\n    \"folders\"     : [],\n    \"height\"      : 50,\n    \"id\"          : 22964019,\n    \"rating\"      : \"General\",\n    \"title\"       : \"Poem for Children Wishing to Summon Evil Spirits\",\n    \"thumbnail\"   : \"https://t.furaffinity.net/22964019@600-1490106089.jpg\",\n    \"width\"       : 50,\n},\n\n{\n    \"#url\"     : \"https://www.furaffinity.net/view/34260156/\",\n    \"#comment\" : \"list gallery folders for image\",\n    \"#category\": (\"\", \"furaffinity\", \"post\"),\n    \"#class\"   : furaffinity.FuraffinityPostExtractor,\n\n    \"artist\"      : \"dbd\",\n    \"artist_url\"  : \"dbd\",\n    \"date\"        : \"dt:2019-12-17 22:52:01\",\n    \"fa_category\" : \"All\",\n    \"filename\"    : \"1576623121.dbd_patreoncustom-wdg13-web\",\n    \"folders\"     : [\"By Year - 2019\",\n                     \"Custom Character Folder - All Custom Characters\",\n                     \"Custom Character Folder - Other Ungulates\",\n                     \"Custom Character Folder - Female\",\n                     \"Custom Character Folder - Patreon Supported Custom Characters\"],\n    \"id\"          : 34260156,\n    \"rating\"      : \"General\",\n    \"title\"       : \"Patreon Custom Deer\",\n    \"thumbnail\"   : \"https://t.furaffinity.net/34260156@600-1576623121.jpg\",\n    \"width\"       : 488,\n    \"height\"      : 900,\n    \"scraps\"      : False,\n},\n\n{\n    \"#url\"     : \"https://www.furaffinity.net/view/4919026/\",\n    \"#comment\" : \"'scraps' metadata (#7015)\",\n    \"#category\": (\"\", \"furaffinity\", \"post\"),\n    \"#class\"   : furaffinity.FuraffinityPostExtractor,\n    \"#auth\"    : False,\n\n    \"id\"         : 4919026,\n    \"scraps\"     : True,\n    \"title\"      : \"Loth Color Test\",\n    \"rating\"     : \"General\",\n    \"theme\"      : \"Fantasy\",\n    \"species\"    : \"Dragon (Other)\",\n    \"gender\"     : \"Multiple characters\",\n    \"width\"      : 600,\n    \"height\"     : 777,\n    \"user\"       : \"mirlinthloth\",\n    \"date\"       : \"dt:2010-12-10 01:47:23\",\n    \"description\": \"I think this is the first coloring for Loth that I did, I loved the goofy expression so I kept it.\",\n    \"folders\": [\n        \"Mirlinth Loth\",\n        \"Akiric Works\",\n    ],\n},\n\n{\n    \"#url\"     : \"https://www.furaffinity.net/view/46163989/\",\n    \"#comment\" : \"display names (#7115 #7123)\",\n    \"#category\": (\"\", \"furaffinity\", \"post\"),\n    \"#class\"   : furaffinity.FuraffinityPostExtractor,\n\n    \"artist\"    : \"Pickra the magical feline\",\n    \"artist_url\": \"pickra\",\n    \"user\"      : \"pickra\",\n},\n\n{\n    \"#url\"     : \"https://www.furaffinity.net/view/57587562\",\n    \"#comment\" : \"login required\",\n    \"#category\": (\"\", \"furaffinity\", \"post\"),\n    \"#class\"   : furaffinity.FuraffinityPostExtractor,\n    \"#count\"   : 0,\n},\n\n{\n    \"#url\"     : \"https://furaffinity.net/view/21835115/\",\n    \"#category\": (\"\", \"furaffinity\", \"post\"),\n    \"#class\"   : furaffinity.FuraffinityPostExtractor,\n},\n\n{\n    \"#url\"     : \"https://fxfuraffinity.net/view/21835115/\",\n    \"#category\": (\"\", \"furaffinity\", \"post\"),\n    \"#class\"   : furaffinity.FuraffinityPostExtractor,\n},\n\n{\n    \"#url\"     : \"https://xfuraffinity.net/view/21835115/\",\n    \"#category\": (\"\", \"furaffinity\", \"post\"),\n    \"#class\"   : furaffinity.FuraffinityPostExtractor,\n},\n\n{\n    \"#url\"     : \"https://fxraffinity.net/view/21835115/\",\n    \"#category\": (\"\", \"furaffinity\", \"post\"),\n    \"#class\"   : furaffinity.FuraffinityPostExtractor,\n},\n\n{\n    \"#url\"     : \"https://sfw.furaffinity.net/view/21835115/\",\n    \"#category\": (\"\", \"furaffinity\", \"post\"),\n    \"#class\"   : furaffinity.FuraffinityPostExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.furaffinity.net/full/21835115/\",\n    \"#category\": (\"\", \"furaffinity\", \"post\"),\n    \"#class\"   : furaffinity.FuraffinityPostExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.furaffinity.net/user/mirlinthloth/\",\n    \"#category\": (\"\", \"furaffinity\", \"user\"),\n    \"#class\"   : furaffinity.FuraffinityUserExtractor,\n    \"#pattern\" : \"/gallery/mirlinthloth/$\",\n},\n\n{\n    \"#url\"     : \"https://www.furaffinity.net/user/mirlinthloth/\",\n    \"#category\": (\"\", \"furaffinity\", \"user\"),\n    \"#class\"   : furaffinity.FuraffinityUserExtractor,\n    \"#options\" : {\"include\": \"all\"},\n    \"#pattern\" : \"/(gallery|scraps|favorites)/mirlinthloth/$\",\n    \"#count\"   : 3,\n},\n\n{\n    \"#url\"     : \"https://www.furaffinity.net/watchlist/by/mirlinthloth/\",\n    \"#category\": (\"\", \"furaffinity\", \"following\"),\n    \"#class\"   : furaffinity.FuraffinityFollowingExtractor,\n    \"#pattern\" : furaffinity.FuraffinityUserExtractor.pattern,\n    \"#range\"   : \"176-225\",\n    \"#count\"   : 50,\n},\n\n{\n    \"#url\"     : \"https://www.furaffinity.net/msg/submissions\",\n    \"#category\": (\"\", \"furaffinity\", \"submissions\"),\n    \"#class\"   : furaffinity.FuraffinitySubmissionsExtractor,\n    \"#auth\"    : True,\n    \"#pattern\" : r\"https://d\\d?\\.f(uraffinity|acdn)\\.net/art/mirlinthloth/\\d+/\\d+.\\w+\\.\\w+\",\n    \"#range\"   : \"45-50\",\n    \"#count\"   : 6,\n},\n\n{\n    \"#url\"     : \"https://www.furaffinity.net/msg/submissions/new~56789000@48/\",\n    \"#category\": (\"\", \"furaffinity\", \"submissions\"),\n    \"#class\"   : furaffinity.FuraffinitySubmissionsExtractor,\n    \"#auth\"    : True,\n},\n\n)\n"
  },
  {
    "path": "test/results/furbooru.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import philomena\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://furbooru.org/images/1\",\n    \"#category\": (\"philomena\", \"furbooru\", \"post\"),\n    \"#class\"   : philomena.PhilomenaPostExtractor,\n    \"#sha1_content\": \"9eaa1e1b32fa0f16520912257dbefaff238d5fd2\",\n},\n\n{\n    \"#url\"     : \"https://furbooru.org/search?q=cute\",\n    \"#category\": (\"philomena\", \"furbooru\", \"search\"),\n    \"#class\"   : philomena.PhilomenaSearchExtractor,\n    \"#range\"   : \"40-60\",\n    \"#count\"   : 21,\n},\n\n{\n    \"#url\"     : \"https://furbooru.org/galleries/27\",\n    \"#category\": (\"philomena\", \"furbooru\", \"gallery\"),\n    \"#class\"   : philomena.PhilomenaGalleryExtractor,\n    \"#count\"   : \">= 13\",\n},\n\n)\n"
  },
  {
    "path": "test/results/furry34.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import furry34\n\n\n__tests__ = (\n{\n    \"#url\"    : \"https://furry34.com/post/541949\",\n    \"#comment\": \"image\",\n    \"#class\"  : furry34.Furry34PostExtractor,\n    \"#options\"     : {\"tags\": True},\n    \"#results\"     : \"https://furry34com.b-cdn.net/posts/541/541949/541949.pic.jpg\",\n    \"#sha1_content\": \"4880da04f7fb41b1760aad4c8297c9917aeeec53\",\n\n    \"created\"   : \"2024-09-20T19:49:47.443232Z\",\n    \"date\"      : \"dt:2024-09-20 19:49:47\",\n    \"extension\" : \"jpg\",\n    \"file_url\"  : \"https://furry34com.b-cdn.net/posts/541/541949/541949.pic.jpg\",\n    \"filename\"  : \"541949\",\n    \"format\"    : \"pic\",\n    \"format_id\" : \"10\",\n    \"id\"        : 541949,\n    \"likes\"     : 8,\n    \"posted\"    : \"2024-09-20T19:50:05.772166Z\",\n    \"status\"    : 2,\n    \"type\"      : 0,\n    \"uploaderId\": 2,\n    \"width\"     : 1300,\n    \"height\"    : 1920,\n\n    \"data\": {\n        \"sources\": [\n            \"https://x.com/EchoeDragon/status/1834316160252477741\",\n            \"https://pbs.twimg.com/media/GXTMHFkWYAA8wDj?format=jpg&name=orig\",\n        ],\n    },\n    \"tags\": [\n        \"echodragon\",\n        \"scp-1471\",\n        \"scp-1471-a\",\n        \"scp-1471-a (da.nilkaz)\",\n        \"scp foundation\",\n        \"canid\",\n        \"canine\",\n        \"malo\",\n        \"mammal\",\n        \"anthro\",\n        \"big breasts\",\n        \"black hair\",\n        \"breasts\",\n        \"cleavage\",\n        \"clothed\",\n        \"clothing\",\n        \"female\",\n        \"hair\",\n        \"orange jumpsuit\",\n        \"prison uniform\",\n        \"solo\",\n        \"tail\",\n        \"thick thighs\",\n        \"white eyes\",\n        \"3d (artwork)\",\n        \"digital media (artwork)\",\n        \"hi res\",\n    ],\n    \"tags_artist\": [\n        \"echodragon\",\n    ],\n    \"tags_character\": [\n        \"scp-1471\",\n        \"scp-1471-a\",\n        \"scp-1471-a (da.nilkaz)\",\n    ],\n    \"tags_copyright\": [\n        \"scp foundation\",\n    ],\n    \"tags_general\": [\n        \"canid\",\n        \"canine\",\n        \"malo\",\n        \"mammal\",\n        \"anthro\",\n        \"big breasts\",\n        \"black hair\",\n        \"breasts\",\n        \"cleavage\",\n        \"clothed\",\n        \"clothing\",\n        \"female\",\n        \"hair\",\n        \"orange jumpsuit\",\n        \"prison uniform\",\n        \"solo\",\n        \"tail\",\n        \"thick thighs\",\n        \"white eyes\",\n        \"3d (artwork)\",\n        \"digital media (artwork)\",\n        \"hi res\",\n    ],\n    \"uploader\": {\n        \"attributes\" : [\n            80,\n        ],\n        \"avatarModifyDate\": None,\n        \"created\"    : \"2021-07-04T15:01:03.110916Z\",\n        \"data\"       : None,\n        \"displayName\": \"agent.e621-uploader\",\n        \"emailVerified\": False,\n        \"id\"         : 2,\n        \"role\"       : 3,\n        \"userName\"   : \"agent.e621-uploader\",\n    },\n},\n\n{\n    \"#url\"    : \"https://furry34.com/post/605309\",\n    \"#comment\": \"video\",\n    \"#class\"  : furry34.Furry34PostExtractor,\n    \"#results\"     : \"https://furry34.com/posts/605/605309/605309.mov.mp4\",\n    \"#sha1_content\": \"914d00e2a6cfee73547bae266ec4b7aaee5aadf2\",\n\n    \"type\": 1,\n},\n\n{\n    \"#url\"  : \"https://furry34.com/tree\",\n    \"#class\": furry34.Furry34TagExtractor,\n    \"#pattern\": r\"https://(furry34\\.com|furry34com\\.b-cdn\\.net)/posts/\\d+/\\d+/\\d+\\.(pic\\.jpg|mov\\d*\\.mp4)\",\n    \"#range\"  : \"1-10\",\n    \"#count\"  : 10,\n},\n\n{\n    \"#url\"  : \"https://furry34.com/dariana_%2528quetzaly%2529%257Canimated?type=video\",\n    \"#class\": furry34.Furry34TagExtractor,\n    \"#pattern\": r\"https://(furry34\\.com|furry34com\\.b-cdn\\.net)/posts/\\d+/\\d+/\\d+\\.(pic\\.jpg|mov\\d*\\.mp4)\",\n    \"#count\"  : range(8, 20),\n\n    \"type\": 1,\n},\n\n{\n    \"#url\"  : \"https://furry34.com/playlists/view/8966\",\n    \"#class\": furry34.Furry34PlaylistExtractor,\n    \"#pattern\": r\"https://(furry34\\.com|furry34com\\.b-cdn\\.net)/posts/\\d+/\\d+/\\d+\\.mov(720)?\\.mp4\",\n    \"#count\"  : range(50, 75),\n},\n\n)\n"
  },
  {
    "path": "test/results/fuskator.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import fuskator\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://fuskator.com/thumbs/d0GnIzXrSKU/\",\n    \"#category\": (\"\", \"fuskator\", \"gallery\"),\n    \"#class\"   : fuskator.FuskatorGalleryExtractor,\n    \"#pattern\" : r\"https://i\\d+.fuskator.com/large/d0GnIzXrSKU/.+\\.jpg\",\n    \"#count\"   : 22,\n\n    \"gallery_id\"  : 473023,\n    \"gallery_hash\": \"d0GnIzXrSKU\",\n    \"title\"       : r\"re:Shaved Brunette Babe Maria Ryabushkina with \",\n    \"views\"       : int,\n    \"score\"       : float,\n    \"count\"       : 22,\n    \"tags\"        : list,\n},\n\n{\n    \"#url\"     : \"https://fuskator.com/expanded/gXpKzjgIidA/index.html\",\n    \"#category\": (\"\", \"fuskator\", \"gallery\"),\n    \"#class\"   : fuskator.FuskatorGalleryExtractor,\n},\n\n{\n    \"#url\"     : \"https://fuskator.com/search/red_swimsuit/\",\n    \"#category\": (\"\", \"fuskator\", \"search\"),\n    \"#class\"   : fuskator.FuskatorSearchExtractor,\n    \"#pattern\" : fuskator.FuskatorGalleryExtractor.pattern,\n    \"#count\"   : \">= 40\",\n},\n\n{\n    \"#url\"     : \"https://fuskator.com/page/3/swimsuit/quality/\",\n    \"#category\": (\"\", \"fuskator\", \"search\"),\n    \"#class\"   : fuskator.FuskatorSearchExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/gelbooru.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import gelbooru\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://gelbooru.com/index.php?page=post&s=list&tags=bonocho\",\n    \"#category\": (\"booru\", \"gelbooru\", \"tag\"),\n    \"#class\"   : gelbooru.GelbooruTagExtractor,\n    \"#count\"   : 5,\n},\n\n{\n    \"#url\"     : \"https://gelbooru.com/index.php?page=post&s=list&tags=all\",\n    \"#category\": (\"booru\", \"gelbooru\", \"tag\"),\n    \"#class\"   : gelbooru.GelbooruTagExtractor,\n    \"#range\"   : \"1-3\",\n    \"#count\"   : 3,\n},\n\n{\n    \"#url\"     : \"https://gelbooru.com/index.php?page=post&s=list&tags=\",\n    \"#category\": (\"booru\", \"gelbooru\", \"tag\"),\n    \"#class\"   : gelbooru.GelbooruTagExtractor,\n},\n\n{\n    \"#url\"     : \"https://gelbooru.com/index.php?page=post&s=list&tags=meiya_neon\",\n    \"#category\": (\"booru\", \"gelbooru\", \"tag\"),\n    \"#class\"   : gelbooru.GelbooruTagExtractor,\n    \"#pattern\" : r\"https://img\\d\\.gelbooru\\.com/images/../../[0-9a-f]{32}\\.jpg\",\n    \"#range\"   : \"196-204\",\n    \"#count\"   : 9,\n    \"#sha1_url\": \"75326d788049459aff46c537fe53d6ea31a2305e\",\n},\n\n{\n    \"#url\"     : \"https://gelbooru.com/index.php?page=post&s=list&tags=id:>=67800+id:<=68000\",\n    \"#comment\" : \"meta tags (#5478)\",\n    \"#category\": (\"booru\", \"gelbooru\", \"tag\"),\n    \"#class\"   : gelbooru.GelbooruTagExtractor,\n    \"#count\"   : range(180, 190),\n},\n\n{\n    \"#url\"     : \"https://gelbooru.com/index.php?page=post&s=list&tags=id:>=67800+id:<=68000+sort:id:asc\",\n    \"#comment\" : \"meta + sort tags (#5478)\",\n    \"#category\": (\"booru\", \"gelbooru\", \"tag\"),\n    \"#class\"   : gelbooru.GelbooruTagExtractor,\n    \"#count\"   : range(180, 190),\n},\n\n{\n    \"#url\"     : \"https://gelbooru.com/index.php?page=pool&s=show&id=761\",\n    \"#category\": (\"booru\", \"gelbooru\", \"pool\"),\n    \"#class\"   : gelbooru.GelbooruPoolExtractor,\n    \"#count\"   : 6,\n},\n\n{\n    \"#url\"     : \"https://gelbooru.com/index.php?page=favorites&s=view&id=1435674\",\n    \"#category\": (\"booru\", \"gelbooru\", \"favorite\"),\n    \"#class\"   : gelbooru.GelbooruFavoriteExtractor,\n    \"#results\" : (\n        \"https://img4.gelbooru.com/images/5d/30/5d30fc056ed8668616b3c440df9bac89.jpg\",\n        \"https://img4.gelbooru.com/images/4c/2d/4c2da867ed643acdadd8105177dcdaf0.png\",\n        \"https://img4.gelbooru.com/images/c8/26/c826f3cb90d9aaca8d0632a96bf4abe8.jpg\",\n        \"https://img4.gelbooru.com/images/c1/fe/c1fe59c0bc8ce955dd353544b1015d0c.jpg\",\n        \"https://img4.gelbooru.com/images/e6/6d/e66d8883c184f5d3b2591dfcdf0d007c.jpg\",\n    ),\n},\n\n{\n    \"#url\"     : \"https://gelbooru.com/index.php?page=favorites&s=view&id=1435674\",\n    \"#category\": (\"booru\", \"gelbooru\", \"favorite\"),\n    \"#class\"   : gelbooru.GelbooruFavoriteExtractor,\n    \"#options\" : {\"order-posts\": \"reverse\"},\n    \"#results\" : (\n        \"https://img4.gelbooru.com/images/e6/6d/e66d8883c184f5d3b2591dfcdf0d007c.jpg\",\n        \"https://img4.gelbooru.com/images/c1/fe/c1fe59c0bc8ce955dd353544b1015d0c.jpg\",\n        \"https://img4.gelbooru.com/images/c8/26/c826f3cb90d9aaca8d0632a96bf4abe8.jpg\",\n        \"https://img4.gelbooru.com/images/4c/2d/4c2da867ed643acdadd8105177dcdaf0.png\",\n        \"https://img4.gelbooru.com/images/5d/30/5d30fc056ed8668616b3c440df9bac89.jpg\",\n    ),\n},\n\n{\n    \"#url\"     : \"https://gelbooru.com/index.php?page=post&s=view&id=313638\",\n    \"#category\": (\"booru\", \"gelbooru\", \"post\"),\n    \"#class\"   : gelbooru.GelbooruPostExtractor,\n    \"#count\"       : 1,\n    \"#sha1_content\": \"5e255713cbf0a8e0801dc423563c34d896bb9229\",\n},\n\n{\n    \"#url\"     : \"https://gelbooru.com/index.php?page=post&s=view&id=313638\",\n    \"#category\": (\"booru\", \"gelbooru\", \"post\"),\n    \"#class\"   : gelbooru.GelbooruPostExtractor,\n},\n\n{\n    \"#url\"     : \"https://gelbooru.com/index.php?s=view&page=post&id=313638\",\n    \"#category\": (\"booru\", \"gelbooru\", \"post\"),\n    \"#class\"   : gelbooru.GelbooruPostExtractor,\n},\n\n{\n    \"#url\"     : \"https://gelbooru.com/index.php?page=post&id=313638&s=view\",\n    \"#category\": (\"booru\", \"gelbooru\", \"post\"),\n    \"#class\"   : gelbooru.GelbooruPostExtractor,\n},\n\n{\n    \"#url\"     : \"https://gelbooru.com/index.php?s=view&id=313638&page=post\",\n    \"#category\": (\"booru\", \"gelbooru\", \"post\"),\n    \"#class\"   : gelbooru.GelbooruPostExtractor,\n},\n\n{\n    \"#url\"     : \"https://gelbooru.com/index.php?id=313638&page=post&s=view\",\n    \"#category\": (\"booru\", \"gelbooru\", \"post\"),\n    \"#class\"   : gelbooru.GelbooruPostExtractor,\n},\n\n{\n    \"#url\"     : \"https://gelbooru.com/index.php?id=313638&s=view&page=post\",\n    \"#category\": (\"booru\", \"gelbooru\", \"post\"),\n    \"#class\"   : gelbooru.GelbooruPostExtractor,\n},\n\n{\n    \"#url\"     : \"https://gelbooru.com/index.php?page=post&s=view&id=6018318\",\n    \"#category\": (\"booru\", \"gelbooru\", \"post\"),\n    \"#class\"   : gelbooru.GelbooruPostExtractor,\n    \"#options\"     : {\"tags\": True},\n    \"#sha1_content\": \"977caf22f27c72a5d07ea4d4d9719acdab810991\",\n\n    \"tags_artist\"   : \"kirisaki_shuusei\",\n    \"tags_character\": str,\n    \"tags_copyright\": \"vocaloid\",\n    \"tags_general\"  : str,\n    \"tags_metadata\" : str,\n},\n\n{\n    \"#url\"     : \"https://gelbooru.com/index.php?page=post&s=view&id=5938076\",\n    \"#comment\" : \"video\",\n    \"#category\": (\"booru\", \"gelbooru\", \"post\"),\n    \"#class\"   : gelbooru.GelbooruPostExtractor,\n    \"#pattern\"     : r\"https://img\\d\\.gelbooru\\.com/images/22/61/226111273615049235b001b381707bd0\\.webm\",\n    \"#sha1_content\": \"6360452fa8c2f0c1137749e81471238564df832a\",\n},\n\n{\n    \"#url\"     : \"https://gelbooru.com/index.php?page=post&s=view&id=5997331\",\n    \"#comment\" : \"notes\",\n    \"#category\": (\"booru\", \"gelbooru\", \"post\"),\n    \"#class\"   : gelbooru.GelbooruPostExtractor,\n    \"#options\" : {\"notes\": True},\n\n    \"notes\": [\n        {\n            \"body\"  : \"Look over this way when you talk~\",\n            \"height\": 553,\n            \"width\" : 246,\n            \"x\"     : 35,\n            \"y\"     : 72,\n        },\n        {\n            \"body\"  : \"\"\"Hey~\nAre you listening~?\"\"\",\n            \"height\": 557,\n            \"width\" : 246,\n            \"x\"     : 1233,\n            \"y\"     : 109,\n        },\n    ],\n},\n\n{\n    \"#url\"     : \"https://gelbooru.com/redirect.php?s=Ly9nZWxib29ydS5jb20vaW5kZXgucGhwP3BhZ2U9cG9zdCZzPXZpZXcmaWQ9MTgzMDA0Ng==\",\n    \"#category\": (\"booru\", \"gelbooru\", \"redirect\"),\n    \"#class\"   : gelbooru.GelbooruRedirectExtractor,\n    \"#pattern\" : r\"https://gelbooru.com/index.php\\?page=post&s=view&id=1830046\",\n},\n\n)\n"
  },
  {
    "path": "test/results/generic.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import generic\n\n\n__tests__ = (\n{\n    \"#url\"     : \"generic:https://www.nongnu.org/lzip/\",\n    \"#category\": (\"\", \"generic\", \"www.nongnu.org\"),\n    \"#class\"   : generic.GenericExtractor,\n    \"#count\"       : 1,\n    \"#sha1_content\": \"40be5c77773d3e91db6e1c5df720ee30afb62368\",\n\n    \"description\": \"Lossless data compressor\",\n    \"imageurl\"   : \"https://www.nongnu.org/lzip/lzip.png\",\n    \"keywords\"   : \"lzip, clzip, plzip, lzlib, LZMA, bzip2, gzip, data compression, GNU, free software\",\n    \"pageurl\"    : \"https://www.nongnu.org/lzip/\",\n},\n\n{\n    \"#url\"     : \"generic:https://räksmörgås.josefsson.org/\",\n    \"#category\": (\"\", \"generic\", \"räksmörgås.josefsson.org\"),\n    \"#class\"   : generic.GenericExtractor,\n    \"#pattern\" : \"^https://räksmörgås.josefsson.org/\",\n    \"#count\"   : 2,\n},\n\n{\n    \"#url\"     : \"generic:https://en.wikipedia.org/Main_Page\",\n    \"#category\": (\"\", \"generic\", \"en.wikipedia.org\"),\n    \"#class\"   : generic.GenericExtractor,\n},\n\n{\n    \"#url\"     : \"generic:https://example.org/path/to/file?que=1?&ry=2/#fragment\",\n    \"#category\": (\"\", \"generic\", \"example.org\"),\n    \"#class\"   : generic.GenericExtractor,\n},\n\n{\n    \"#url\"     : \"generic:https://example.org/%27%3C%23/%23%3E%27.htm?key=%3C%26%3E\",\n    \"#category\": (\"\", \"generic\", \"example.org\"),\n    \"#class\"   : generic.GenericExtractor,\n},\n\n{\n    \"#url\"     : \"generic:https://en.wikipedia.org/Main_Page\",\n    \"#category\": (\"\", \"generic\", \"en.wikipedia.org\"),\n    \"#class\"   : generic.GenericExtractor,\n},\n\n{\n    \"#url\"     : \"generic:https://example.org/path/to/file?que=1?&ry=2/#fragment\",\n    \"#category\": (\"\", \"generic\", \"example.org\"),\n    \"#class\"   : generic.GenericExtractor,\n},\n\n{\n    \"#url\"     : \"generic:https://example.org/%27%3C%23/%23%3E%27.htm?key=%3C%26%3E\",\n    \"#category\": (\"\", \"generic\", \"example.org\"),\n    \"#class\"   : generic.GenericExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/girlsreleased.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import girlsreleased\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://girlsreleased.com/set/32332\",\n    \"#category\": (\"\", \"girlsreleased\", \"set\"),\n    \"#class\"   : girlsreleased.GirlsreleasedSetExtractor,\n    \"#pattern\" : r\"https://imx\\.to/i/\\w+\",\n    \"#count\"   : 122,\n\n    \"id\"        : \"32332\",\n    \"title\"     : \"Monadiko\",\n    \"model\"     : [\"Mia Sollis\"],\n    \"site\"      : \"sexart.com\",\n    \"date\"      : \"dt:2014-07-27 07:57:19\",\n},\n\n{\n    \"#url\"     : \"https://girlsreleased.com/set/124943\",\n    \"#category\": (\"\", \"girlsreleased\", \"set\"),\n    \"#class\"   : girlsreleased.GirlsreleasedSetExtractor,\n    \"#pattern\" : r\"https://imx\\.to/i/\\w+\",\n    \"#count\"   : 79,\n\n    \"id\"        : \"124943\",\n    \"title\"     : \"124943\",\n    \"model\"     : [\"Iveta\"],\n    \"site\"      : \"errotica-archives.com\",\n    \"date\"      : \"dt:2022-02-21 14:08:32\",\n},\n\n{\n    \"#url\"     : \"https://girlsreleased.com/model/11545/Ariana%20Regent\",\n    \"#category\": (\"\", \"girlsreleased\", \"model\"),\n    \"#class\"   : girlsreleased.GirlsreleasedModelExtractor,\n    \"#results\" : (\n        \"https://girlsreleased.com/set/142691\",\n        \"https://girlsreleased.com/set/142690\",\n    ),\n},\n\n{\n    \"#url\"     : \"https://girlsreleased.com/site/amourangels.com\",\n    \"#category\": (\"\", \"girlsreleased\", \"site\"),\n    \"#class\"   : girlsreleased.GirlsreleasedSiteExtractor,\n},\n\n{\n    \"#url\"     : \"https://girlsreleased.com/site/femjoy.com/model/854/Gabi\",\n    \"#category\": (\"\", \"girlsreleased\", \"site\"),\n    \"#class\"   : girlsreleased.GirlsreleasedSiteExtractor,\n    \"#pattern\" : girlsreleased.GirlsreleasedSetExtractor.pattern,\n    \"#count\"   : 17,\n},\n\n)\n"
  },
  {
    "path": "test/results/girlswithmuscle.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import girlswithmuscle\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://www.girlswithmuscle.com/2526619/\",\n    \"#category\": (\"\", \"girlswithmuscle\", \"post\"),\n    \"#class\"   : girlswithmuscle.GirlswithmusclePostExtractor,\n    \"#results\" : \"https://www.girlswithmuscle.com/images/full/2526619.jpg\",\n\n    \"comments\" : [],\n    \"date\"     : \"dt:2025-05-21 20:01:03\",\n    \"extension\": \"jpg\",\n    \"filename\" : \"2526619\",\n    \"id\"       : \"2526619\",\n    \"is_favorite\": None,\n    \"model\"    : \"Vladislava Galagan\",\n    \"model_list\" : [\n        \"Vladislava Galagan\"\n    ],\n    \"score\"    : range(190, 250),\n    \"source_filename\": \"\",\n    \"type\"     : \"picture\",\n    \"uploader\" : \"mrt\",\n    \"tags\": [\n        \"delts/shoulders\",\n        \"abs\",\n        \"casual\",\n        \"triceps\",\n        \"traps\",\n        \"bikini/competition suit\",\n        \"white\",\n        \"figure/fitness\",\n        \"bodybuilder\",\n        \"slavic\",\n        \"women's physique\",\n        \"russian\",\n    ],\n},\n\n{\n    \"#url\"     : \"https://www.girlswithmuscle.com/images/?name=Harmony%20Doughty\",\n    \"#category\": (\"\", \"girlswithmuscle\", \"search\"),\n    \"#class\"   : girlswithmuscle.GirlswithmuscleSearchExtractor,\n    \"#pattern\" : girlswithmuscle.GirlswithmusclePostExtractor.pattern,\n    \"#count\"   : range(130, 150),\n},\n\n)\n"
  },
  {
    "path": "test/results/gofile.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import gofile\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://gofile.io/d/k6BomI\",\n    \"#category\": (\"\", \"gofile\", \"folder\"),\n    \"#class\"   : gofile.GofileFolderExtractor,\n    \"#pattern\" : r\"https://store\\d+\\.gofile\\.io/download/\\w{8}-\\w{4}-\\w{4}-\\w{4}-\\w{12}/test-%E3%83%86%E3%82%B9%E3%83%88-%2522%26!\\.png\",\n\n    \"createTime\"   : int,\n    \"directLink\"   : r\"re:https://store5.gofile.io/download/direct/.+\",\n    \"downloadCount\": int,\n    \"extension\"    : \"png\",\n    \"filename\"     : \"test-テスト-%22&!\",\n    \"folder\"       : {\n        \"childs\"            : [\n            \"b0367d79-b8ba-407f-8342-aaf8eb815443\",\n            \"7fd4a36a-c1dd-49ff-9223-d93f7d24093f\",\n        ],\n        \"code\"              : \"k6BomI\",\n        \"createTime\"        : 1654076165,\n        \"id\"                : \"fafb59f9-a7c7-4fea-a098-b29b8d97b03c\",\n        \"name\"              : \"root\",\n        \"public\"            : True,\n        \"totalDownloadCount\": int,\n        \"totalSize\"         : 182,\n        \"type\"              : \"folder\",\n    },\n    \"id\"           : r\"re:\\w{8}-\\w{4}-\\w{4}-\\w{4}-\\w{12}\",\n    \"link\"         : r\"re:https://store5.gofile.io/download/.+\\.png\",\n    \"md5\"          : r\"re:[0-9a-f]{32}\",\n    \"mimetype\"     : \"image/png\",\n    \"name\"         : \"test-テスト-%22&!.png\",\n    \"num\"          : int,\n    \"parentFolder\" : \"fafb59f9-a7c7-4fea-a098-b29b8d97b03c\",\n    \"serverChoosen\": \"store5\",\n    \"size\"         : 182,\n    \"thumbnail\"    : r\"re:https://store5.gofile.io/download/.+\\.png\",\n    \"type\"         : \"file\",\n},\n\n{\n    \"#url\"     : \"https://gofile.io/d/7fd4a36a-c1dd-49ff-9223-d93f7d24093f\",\n    \"#category\": (\"\", \"gofile\", \"folder\"),\n    \"#class\"   : gofile.GofileFolderExtractor,\n    \"#options\"     : {\"website-token\": None},\n    \"#sha1_content\": \"0c8768055e4e20e7c7259608b67799171b691140\",\n},\n\n)\n"
  },
  {
    "path": "test/results/gurochan.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import vichan\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://boards.guro.cx/art/res/7184.html#q7184\",\n    \"#category\": (\"vichan\", \"gurochan\", \"thread\"),\n    \"#class\"   : vichan.VichanThreadExtractor,\n    \"#pattern\" : r\"https://boards\\.guro\\.cx/art/src/\\d+\\.\\w+\",\n    \"#count\"   : range(50, 80),\n},\n\n{\n    \"#url\"     : \"https://boards.guro.cx/art/\",\n    \"#category\": (\"vichan\", \"gurochan\", \"board\"),\n    \"#class\"   : vichan.VichanBoardExtractor,\n    \"#pattern\" : vichan.VichanThreadExtractor.pattern,\n    \"#count\"   : range(500, 800),\n},\n\n)\n"
  },
  {
    "path": "test/results/hatenablog.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import hatenablog\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://cosmiclatte.hatenablog.com/entry/2020/05/28/003227\",\n    \"#category\": (\"\", \"hatenablog\", \"entry\"),\n    \"#class\"   : hatenablog.HatenablogEntryExtractor,\n    \"#count\"   : 20,\n},\n\n{\n    \"#url\"     : \"https://moko0908.hatenablog.jp/entry/2023/12/31/083846\",\n    \"#category\": (\"\", \"hatenablog\", \"entry\"),\n    \"#class\"   : hatenablog.HatenablogEntryExtractor,\n},\n\n{\n    \"#url\"     : \"https://p-shirokuma.hatenadiary.com/entry/20231227/1703685600\",\n    \"#category\": (\"\", \"hatenablog\", \"entry\"),\n    \"#class\"   : hatenablog.HatenablogEntryExtractor,\n},\n\n{\n    \"#url\"     : \"https://urakatahero.hateblo.jp/entry/2ndlife\",\n    \"#category\": (\"\", \"hatenablog\", \"entry\"),\n    \"#class\"   : hatenablog.HatenablogEntryExtractor,\n},\n\n{\n    \"#url\"     : \"hatenablog:https://blog.hyouhon.com/entry/2023/12/22/133549\",\n    \"#category\": (\"\", \"hatenablog\", \"entry\"),\n    \"#class\"   : hatenablog.HatenablogEntryExtractor,\n},\n\n{\n    \"#url\"     : \"https://cetriolo.hatenablog.com\",\n    \"#category\": (\"\", \"hatenablog\", \"home\"),\n    \"#class\"   : hatenablog.HatenablogHomeExtractor,\n    \"#range\"   : \"1-7\",\n    \"#count\"   : 7,\n},\n\n{\n    \"#url\"     : \"https://moko0908.hatenablog.jp/\",\n    \"#category\": (\"\", \"hatenablog\", \"home\"),\n    \"#class\"   : hatenablog.HatenablogHomeExtractor,\n},\n\n{\n    \"#url\"     : \"https://p-shirokuma.hatenadiary.com/\",\n    \"#category\": (\"\", \"hatenablog\", \"home\"),\n    \"#class\"   : hatenablog.HatenablogHomeExtractor,\n},\n\n{\n    \"#url\"     : \"https://urakatahero.hateblo.jp/\",\n    \"#category\": (\"\", \"hatenablog\", \"home\"),\n    \"#class\"   : hatenablog.HatenablogHomeExtractor,\n},\n\n{\n    \"#url\"     : \"hatenablog:https://blog.hyouhon.com/\",\n    \"#category\": (\"\", \"hatenablog\", \"home\"),\n    \"#class\"   : hatenablog.HatenablogHomeExtractor,\n},\n\n{\n    \"#url\"     : (\"https://8saki.hatenablog.com/archive/category/%E3%82%BB%E3\"\n                  \"%83%AB%E3%83%95%E3%82%B8%E3%82%A7%E3%83%AB%E3%83%8D%E3%82\"\n                  \"%A4%E3%83%AB\"),\n    \"#category\": (\"\", \"hatenablog\", \"archive\"),\n    \"#class\"   : hatenablog.HatenablogArchiveExtractor,\n    \"#range\"   : \"1-30\",\n    \"#count\"   : 30,\n},\n\n{\n    \"#url\"     : \"https://moko0908.hatenablog.jp/archive/2023\",\n    \"#category\": (\"\", \"hatenablog\", \"archive\"),\n    \"#class\"   : hatenablog.HatenablogArchiveExtractor,\n    \"#count\"   : range(10, 15),\n},\n\n{\n    \"#url\"     : \"https://p-shirokuma.hatenadiary.com/archive/2023/01\",\n    \"#category\": (\"\", \"hatenablog\", \"archive\"),\n    \"#class\"   : hatenablog.HatenablogArchiveExtractor,\n    \"#count\"   : 5,\n},\n\n{\n    \"#url\"     : \"https://urakatahero.hateblo.jp/archive\",\n    \"#category\": (\"\", \"hatenablog\", \"archive\"),\n    \"#class\"   : hatenablog.HatenablogArchiveExtractor,\n    \"#range\"   : \"1-30\",\n    \"#count\"   : 30,\n},\n\n{\n    \"#url\"     : \"hatenablog:https://blog.hyouhon.com/archive/2024/01/01\",\n    \"#category\": (\"\", \"hatenablog\", \"archive\"),\n    \"#class\"   : hatenablog.HatenablogArchiveExtractor,\n},\n\n{\n    \"#url\"     : \"hatenablog:https://blog.hyouhon.com/search?q=a\",\n    \"#category\": (\"\", \"hatenablog\", \"search\"),\n    \"#class\"   : hatenablog.HatenablogSearchExtractor,\n    \"#range\"   : \"1-30\",\n    \"#count\"   : 30,\n},\n\n{\n    \"#url\"     : \"https://cosmiclatte.hatenablog.com/search?q=a\",\n    \"#category\": (\"\", \"hatenablog\", \"search\"),\n    \"#class\"   : hatenablog.HatenablogSearchExtractor,\n},\n\n{\n    \"#url\"     : \"https://moko0908.hatenablog.jp/search?q=a\",\n    \"#category\": (\"\", \"hatenablog\", \"search\"),\n    \"#class\"   : hatenablog.HatenablogSearchExtractor,\n},\n\n{\n    \"#url\"     : \"https://p-shirokuma.hatenadiary.com/search?q=a\",\n    \"#category\": (\"\", \"hatenablog\", \"search\"),\n    \"#class\"   : hatenablog.HatenablogSearchExtractor,\n},\n\n{\n    \"#url\"     : \"https://urakatahero.hateblo.jp/search?q=a\",\n    \"#category\": (\"\", \"hatenablog\", \"search\"),\n    \"#class\"   : hatenablog.HatenablogSearchExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/hdoujin.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import hdoujin\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://hdoujin.org/g/119874/bd0a5217dfc6\",\n    \"#class\"   : hdoujin.HdoujinGalleryExtractor,\n},\n\n{\n    \"#url\"     : \"https://hdoujin.net/g/119874/bd0a5217dfc6\",\n    \"#class\"   : hdoujin.HdoujinGalleryExtractor,\n},\n\n{\n    \"#url\"     : \"https://hdoujin.org/browse?s=beach\",\n    \"#class\"   : hdoujin.HdoujinSearchExtractor,\n},\n\n{\n    \"#url\"     : \"https://hdoujin.org/tag/female:maid\",\n    \"#class\"   : hdoujin.HdoujinSearchExtractor,\n    \"#pattern\" : hdoujin.HdoujinGalleryExtractor.pattern,\n    \"#range\"   : \"1-80\",\n    \"#count\"   : 80,\n},\n\n{\n    \"#url\"     : \"https://hdoujin.org/favorites\",\n    \"#class\"   : hdoujin.HdoujinFavoriteExtractor,\n    \"#auth\"    : True,\n},\n\n)\n"
  },
  {
    "path": "test/results/hentai2read.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import hentai2read\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://hentai2read.com/amazon_elixir/1/\",\n    \"#category\": (\"\", \"hentai2read\", \"chapter\"),\n    \"#class\"   : hentai2read.Hentai2readChapterExtractor,\n    \"#sha1_url\"     : \"964b942cf492b3a129d2fe2608abfc475bc99e71\",\n    \"#sha1_metadata\": \"85645b02d34aa11b3deb6dadd7536863476e1bad\",\n},\n\n{\n    \"#url\"     : \"https://hentai2read.com/popuni_kei_joshi_panic/2.5/\",\n    \"#category\": (\"\", \"hentai2read\", \"chapter\"),\n    \"#class\"   : hentai2read.Hentai2readChapterExtractor,\n    \"#pattern\" : r\"https://hentaicdn\\.com/hentai/13088/2\\.5y/ccdn00\\d+\\.jpg\",\n    \"#count\"   : 36,\n\n    \"author\"       : \"Kurisu\",\n    \"chapter\"      : 2,\n    \"chapter_id\"   : 75152,\n    \"chapter_minor\": \".5\",\n    \"count\"        : 36,\n    \"lang\"         : \"en\",\n    \"language\"     : \"English\",\n    \"manga\"        : \"Popuni Kei Joshi Panic!\",\n    \"manga_id\"     : 13088,\n    \"page\"         : int,\n    \"title\"        : \"Popuni Kei Joshi Panic! 2.5\",\n    \"type\"         : \"Original\",\n},\n\n{\n    \"#url\"     : \"https://hentai2read.com/nozoki_ana/1/\",\n    \"#category\": (\"\", \"hentai2read\", \"chapter\"),\n    \"#class\"   : hentai2read.Hentai2readChapterExtractor,\n    \"#pattern\" : r\"https://hentaicdn\\.com/hentai/2720/1/hcdn00\\d+\\.jpg\",\n    \"#count\"   : 203,\n\n    \"author\"       : \"\",\n    \"chapter\"      : 1,\n    \"chapter_id\"   : 2965,\n    \"chapter_minor\": \"\",\n    \"count\"        : 203,\n    \"extension\"    : \"jpg\",\n    \"filename\"     : str,\n    \"lang\"         : \"en\",\n    \"language\"     : \"English\",\n    \"manga\"        : \"Nozoki Ana [Ecchi]\",\n    \"manga_id\"     : 2720,\n    \"page\"         : range(1, 203),\n    \"subcategory\"  : \"chapter\",\n    \"title\"        : \"Nozoki Ana 1\",\n    \"type\"         : \"Original\",\n\n},\n\n{\n    \"#url\"     : \"https://hentai2read.com/amazon_elixir/\",\n    \"#category\": (\"\", \"hentai2read\", \"manga\"),\n    \"#class\"   : hentai2read.Hentai2readMangaExtractor,\n    \"#sha1_url\"     : \"273073752d418ec887d7f7211e42b832e8c403ba\",\n    \"#sha1_metadata\": \"5c1b712258e78e120907121d3987c71f834d13e1\",\n},\n\n{\n    \"#url\"     : \"https://hentai2read.com/oshikage_riot/\",\n    \"#category\": (\"\", \"hentai2read\", \"manga\"),\n    \"#class\"   : hentai2read.Hentai2readMangaExtractor,\n    \"#sha1_url\"     : \"6595f920a3088a15c2819c502862d45f8eb6bea6\",\n    \"#sha1_metadata\": \"a2e9724acb221040d4b29bf9aa8cb75b2240d8af\",\n},\n\n{\n    \"#url\"     : \"https://hentai2read.com/popuni_kei_joshi_panic/\",\n    \"#category\": (\"\", \"hentai2read\", \"manga\"),\n    \"#class\"   : hentai2read.Hentai2readMangaExtractor,\n    \"#pattern\" : hentai2read.Hentai2readChapterExtractor.pattern,\n    \"#range\"   : \"2-3\",\n\n    \"chapter\"      : int,\n    \"chapter_id\"   : int,\n    \"chapter_minor\": \".5\",\n    \"lang\"         : \"en\",\n    \"language\"     : \"English\",\n    \"manga\"        : \"Popuni Kei Joshi Panic!\",\n    \"manga_id\"     : 13088,\n    \"title\"        : str,\n    \"type\"         : \"Original\",\n},\n\n)\n"
  },
  {
    "path": "test/results/hentaicosplays.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import hentaicosplays\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://hentai-cosplay-xxx.com/image/---devilism--tide-kurihara-/\",\n    \"#category\": (\"hentaicosplays\", \"hentaicosplay\", \"gallery\"),\n    \"#class\"   : hentaicosplays.HentaicosplaysGalleryExtractor,\n    \"#pattern\" : r\"https://static\\d?\\.hentai-cosplay-xxx\\.com/upload/\\d+/\\d+/\\d+/\\d+\\.jpg$\",\n\n    \"count\": 18,\n    \"site\" : \"hentai-cosplay-xxx\",\n    \"slug\" : \"---devilism--tide-kurihara-\",\n    \"title\": \"艦 こ れ-devilism の tide Kurihara 憂\",\n},\n\n{\n    \"#url\"     : \"https://hentai-cosplays.com/image/---devilism--tide-kurihara-/\",\n    \"#category\": (\"hentaicosplays\", \"hentaicosplay\", \"gallery\"),\n    \"#class\"   : hentaicosplays.HentaicosplaysGalleryExtractor,\n    \"#pattern\" : r\"https://static\\d?\\.hentai-cosplay-xxx\\.com/upload/\\d+/\\d+/\\d+/\\d+\\.jpg$\",\n\n    \"count\": 18,\n    \"site\" : \"hentai-cosplay-xxx\",\n    \"slug\" : \"---devilism--tide-kurihara-\",\n    \"title\": \"艦 こ れ-devilism の tide Kurihara 憂\",\n},\n\n{\n    \"#url\"     : \"https://fr.porn-image.com/image/enako-enako-24/\",\n    \"#category\": (\"hentaicosplays\", \"pornimage\", \"gallery\"),\n    \"#class\"   : hentaicosplays.HentaicosplaysGalleryExtractor,\n    \"#pattern\" : r\"https://static\\d?.porn-image.com/upload/\\d+/\\d+/\\d+/\\d+.jpg$\",\n\n    \"count\": 11,\n    \"site\" : \"porn-image\",\n    \"title\": str,\n},\n\n{\n    \"#url\"     : \"https://fr.porn-images-xxx.com/image/enako-enako-24/\",\n    \"#category\": (\"hentaicosplays\", \"pornimage\", \"gallery\"),\n    \"#class\"   : hentaicosplays.HentaicosplaysGalleryExtractor,\n},\n\n{\n    \"#url\"     : \"https://ja.hentai-img-xxx.com/image/hollow-cora-502/\",\n    \"#category\": (\"hentaicosplays\", \"hentaiimg\", \"gallery\"),\n    \"#class\"   : hentaicosplays.HentaicosplaysGalleryExtractor,\n    \"#pattern\" : r\"https://static\\d?.hentai-img-xxx.com/upload/\\d+/\\d+/\\d+/\\d+.jpg$\",\n\n    \"count\": 2,\n    \"site\" : \"hentai-img-xxx\",\n    \"title\": str,\n},\n\n{\n    \"#url\"     : \"https://ja.hentai-img.com/image/hollow-cora-502/\",\n    \"#category\": (\"hentaicosplays\", \"hentaiimg\", \"gallery\"),\n    \"#class\"   : hentaicosplays.HentaicosplaysGalleryExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/hentaienvy.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import imhentai\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://hentaienvy.com/gallery/12/\",\n    \"#category\": (\"IMHentai\", \"hentaienvy\", \"gallery\"),\n    \"#class\"   : imhentai.ImhentaiGalleryExtractor,\n    \"#pattern\" : r\"https://m1\\.hentaienvy\\.com/001/3x907ntq18/\\d+\\.jpg\",\n    \"#count\"   : 94,\n\n    \"count\"     : 94,\n    \"extension\" : \"jpg\",\n    \"filename\"  : str,\n    \"gallery_id\": 12,\n    \"lang\"      : \"en\",\n    \"num\"       : range(1, 94),\n    \"title\"     : \"(C67) [Studio Kimigabuchi (Kimimaru)] RE-TAKE 2 (Neon Genesis Evangelion) [English]\",\n    \"title_alt\" : \"\",\n    \"type\"      : \"doujinshi\",\n    \"width\"     : {835, 838, 841, 1200},\n    \"height\"    : {862, 865, 1200},\n\n    \"artist\":    [\n        \"kimimaru | entokkun\",\n    ],\n    \"character\": [\n        \"asuka langley soryu\",\n        \"gendo ikari\",\n        \"makoto hyuga\",\n        \"maya ibuki\",\n        \"misato katsuragi\",\n        \"rei ayanami\",\n        \"shigeru aoba\",\n        \"shinji ikari\",\n    ],\n    \"group\": [\n        \"studio kimigabuchi\",\n    ],\n    \"language\": [\n        \"english\",\n        \"translated\",\n    ],\n    \"parody\": [\n        \"neon genesis evangelion | shin seiki evangelion\",\n    ],\n    \"tags\": [\n        \"multi-work series\",\n        \"schoolboy uniform\",\n        \"schoolgirl uniform\",\n        \"sole female\",\n        \"sole male\",\n        \"story arc\",\n        \"twintails\",\n    ],\n},\n\n{\n    \"#url\"     : \"https://hentaienvy.com/gallery/1293743/\",\n    \"#category\": (\"IMHentai\", \"hentaienvy\", \"gallery\"),\n    \"#class\"   : imhentai.ImhentaiGalleryExtractor,\n    \"#pattern\" : r\"https://m9\\.hentaienvy\\.com/029/tk70aw8b4y/\\d+\\.webp\",\n    \"#count\"   : 25,\n\n    \"count\"     : 25,\n    \"num\"       : range(1, 25),\n    \"extension\" : \"webp\",\n    \"filename\"  : str,\n    \"gallery_id\": 1293743,\n    \"lang\"      : \"ru\",\n    \"title\"     : \"(C102) [Koniro Kajitsu (KonKa)] Konbucha wa Ikaga desu ka | Хотите немного чая из водорослей? (Blue Archive) [Russian] [graun]\",\n    \"title_alt\" : \"\",\n    \"type\"      : \"doujinshi\",\n    \"width\"     : 1280,\n    \"height\"    : range(1804, 1832),\n\n    \"artist\": [\n        \"konka\",\n    ],\n    \"character\": [\n        \"nagisa kirifuji\",\n        \"sensei\",\n    ],\n    \"group\": [\n        \"koniro kajitsu\",\n    ],\n    \"language\": [\n        \"russian\",\n        \"translated\",\n    ],\n    \"parody\": [\n        \"blue archive\",\n    ],\n    \"tags\": [\n        \"angel\",\n        \"defloration\",\n        \"halo\",\n        \"kissing\",\n        \"pantyhose\",\n        \"sole female\",\n        \"sole male\",\n        \"wings\",\n    ],\n},\n\n{\n    \"#url\"     : \"https://hentaienvy.com/gallery/1119432/\",\n    \"#comment\" : \"empty 'var g_th = $.parseJSON('');' (#8951)\",\n    \"#category\": (\"IMHentai\", \"hentaienvy\", \"gallery\"),\n    \"#class\"   : imhentai.ImhentaiGalleryExtractor,\n    \"#pattern\" : r\"https://m8.hentaienvy.com/026/3zf1yedx5m/\\d+\\.jpg\",\n    \"#count\"   : 188,\n    \"#log\"     : \"1119432: Missing image data\",\n\n    \"artist\"    : [],\n    \"character\" : [],\n    \"gallery_id\": 1119432,\n    \"group\"     : [],\n    \"lang\"      : \"ja\",\n    \"language\"  : [\"japanese\"],\n    \"parody\"    : [\"super mario brothers\"],\n    \"tags\"      : list,\n    \"title\"     : \"hentai girls\",\n    \"title_alt\" : \"\",\n    \"type\"      : \"western\",\n},\n\n{\n    \"#url\"     : \"https://hentaienvy.com/artist/asutora/\",\n    \"#category\": (\"IMHentai\", \"hentaienvy\", \"tag\"),\n    \"#class\"   : imhentai.ImhentaiTagExtractor,\n    \"#pattern\" : imhentai.ImhentaiGalleryExtractor.pattern,\n    \"#count\"   : range(45, 60),\n},\n\n{\n    \"#url\"     : \"https://hentaienvy.com/search/?s_key=asutora\",\n    \"#category\": (\"IMHentai\", \"hentaienvy\", \"search\"),\n    \"#class\"   : imhentai.ImhentaiSearchExtractor,\n    \"#pattern\" : imhentai.ImhentaiGalleryExtractor.pattern,\n    \"#count\"   : range(45, 60),\n},\n\n{\n    \"#url\"     : \"https://hentaienvy.com/advanced-search/?key=%2Btag%3A%22Monster+Girl%22+%2Bcharacter%3A%22Gardevoir%22&lt=1&m=1&d=1&w=1&i=1&a=1&g=1&en=1\",\n    \"#comment\" : \"'/advanced-search/' URL (#8507)\",\n    \"#category\": (\"IMHentai\", \"hentaienvy\", \"search\"),\n    \"#class\"   : imhentai.ImhentaiSearchExtractor,\n    \"#pattern\" : imhentai.ImhentaiGalleryExtractor.pattern,\n    \"#count\"   : range(185, 200),\n},\n\n)\n"
  },
  {
    "path": "test/results/hentaiera.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import imhentai\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://hentaiera.com/gallery/28/\",\n    \"#category\": (\"IMHentai\", \"hentaiera\", \"gallery\"),\n    \"#class\"   : imhentai.ImhentaiGalleryExtractor,\n    \"#pattern\" : r\"https://m1\\.hentaiera\\.com/001/knrxtga49v/\\d+\\.jpg\",\n    \"#count\"   : 25,\n\n    \"count\"     : 25,\n    \"extension\" : \"jpg\",\n    \"filename\"  :  r\"re:\\d+\",\n    \"gallery_id\": 28,\n    \"lang\"      : \"ja\",\n    \"num\"       : range(1, 25),\n    \"title\"     : \"(Shikei wa Iyadakara na) [Kujira Logic, TOYBOX (Kujiran, Kurikara)] Gensou-kyou Chichi Zukan - Kurenai (Touhou Project)\",\n    \"title_alt\" : \"(死刑はいやだからな) [くぢらろじっく, といぼっくす (くぢらん, くりから)] 幻想郷乳図鑑 - 紅 (東方Project)\",\n    \"type\"      : \"doujinshi\",\n    \"width\"     : {696, 701},\n    \"height\"    : {999, 1000},\n\n    \"artist\": [\n        \"kujiran\",\n        \"kurikara\",\n    ],\n    \"character\": [\n        \"hong meiling\",\n        \"koakuma\",\n        \"patchouli knowledge\",\n        \"remilia scarlet\",\n        \"sakuya izayoi\",\n    ],\n    \"group\": [\n        \"kujira logic\",\n        \"toybox\",\n    ],\n    \"language\": [\n        \"japanese\",\n    ],\n    \"parody\": [\n        \"touhou project\",\n    ],\n    \"tags\": [\n        \"big breasts\",\n        \"footjob\",\n        \"futanari\",\n        \"lolicon\",\n        \"maid\",\n        \"paizuri\",\n    ],\n},\n\n{\n    \"#url\"     : \"https://hentaiera.com/gallery/9319/\",\n    \"#category\": (\"IMHentai\", \"hentaiera\", \"gallery\"),\n    \"#class\"   : imhentai.ImhentaiGalleryExtractor,\n    \"#pattern\" : r\"https://m1\\.hentaiera\\.com/001/gkchsf3x5m/\\d+\\.jpg\",\n    \"#count\"   : 8,\n\n    \"count\"     : 8,\n    \"extension\" : \"jpg\",\n    \"filename\"  : r\"re:\\d+\",\n    \"gallery_id\": 9319,\n    \"lang\"      : \"ja\",\n    \"num\"       : range(1, 8),\n    \"title\"     : \"(C70) [UDON-YA (Kizuki Aruchu, ZAN)] Udonko CM70 Omake Hon (Various)\",\n    \"title_alt\" : \"(C70) [うどんや (鬼月あるちゅ、ZAN)] うどんこ CM70オマケ本 (よろず)\",\n    \"type\"      : \"doujinshi\",\n    \"width\"     : 1076,\n    \"height\"    : 1517,\n\n    \"artist\": [\n        \"kizuki aruchu\",\n        \"zan\",\n    ],\n    \"character\": [\n        \"mikuru asahina\",\n        \"reisen udongein inaba\",\n        \"tsuruya\",\n    ],\n    \"group\": [\n        \"udon-ya\",\n    ],\n    \"language\": [\n        \"japanese\",\n    ],\n    \"parody\": [\n        \"fate stay night\",\n        \"super robot wars | super robot taisen\",\n        \"the melancholy of haruhi suzumiya | suzumiya haruhi no yuuutsu\",\n    ],\n    \"tags\": [\n        \"big breasts\",\n        \"okaasan to issho\",\n        \"touhou kaeidzuka\",\n    ],\n},\n\n{\n    \"#url\"     : \"https://hentaiera.com/artist/kujiran/\",\n    \"#category\": (\"IMHentai\", \"hentaiera\", \"tag\"),\n    \"#class\"   : imhentai.ImhentaiTagExtractor,\n    \"#pattern\" : imhentai.ImhentaiGalleryExtractor.pattern,\n    \"#count\"   : range(120, 150),\n},\n\n{\n    \"#url\"     : \"https://hentaiera.com/search/?key=kujiran\",\n    \"#category\": (\"IMHentai\", \"hentaiera\", \"search\"),\n    \"#class\"   : imhentai.ImhentaiSearchExtractor,\n    \"#pattern\" : imhentai.ImhentaiGalleryExtractor.pattern,\n    \"#count\"   : range(120, 150),\n},\n\n)\n"
  },
  {
    "path": "test/results/hentaifoundry.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import hentaifoundry\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://www.hentai-foundry.com/user/Tenpura/profile\",\n    \"#category\": (\"\", \"hentaifoundry\", \"user\"),\n    \"#class\"   : hentaifoundry.HentaifoundryUserExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.hentai-foundry.com/pictures/user/Tenpura\",\n    \"#category\": (\"\", \"hentaifoundry\", \"pictures\"),\n    \"#class\"   : hentaifoundry.HentaifoundryPicturesExtractor,\n    \"#sha1_url\": \"ebbc981a85073745e3ca64a0f2ab31fab967fc28\",\n},\n\n{\n    \"#url\"     : \"https://www.hentai-foundry.com/pictures/user/Tenpura/page/3\",\n    \"#category\": (\"\", \"hentaifoundry\", \"pictures\"),\n    \"#class\"   : hentaifoundry.HentaifoundryPicturesExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.hentai-foundry.com/pictures/user/Ethevian/scraps\",\n    \"#category\": (\"\", \"hentaifoundry\", \"scraps\"),\n    \"#class\"   : hentaifoundry.HentaifoundryScrapsExtractor,\n    \"#pattern\" : r\"https://pictures\\.hentai-foundry\\.com/e/Ethevian/.+\",\n    \"#count\"   : \">= 10\",\n},\n\n{\n    \"#url\"     : \"https://www.hentai-foundry.com/pictures/user/Evulchibi/scraps/page/3\",\n    \"#category\": (\"\", \"hentaifoundry\", \"scraps\"),\n    \"#class\"   : hentaifoundry.HentaifoundryScrapsExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.hentai-foundry.com/user/Tenpura/faves/pictures\",\n    \"#category\": (\"\", \"hentaifoundry\", \"favorite\"),\n    \"#class\"   : hentaifoundry.HentaifoundryFavoriteExtractor,\n    \"#sha1_url\": \"56f9ae2e89fe855e9fe1da9b81e5ec6212b0320b\",\n},\n\n{\n    \"#url\"     : \"https://www.hentai-foundry.com/user/Tenpura/faves/pictures/page/3\",\n    \"#category\": (\"\", \"hentaifoundry\", \"favorite\"),\n    \"#class\"   : hentaifoundry.HentaifoundryFavoriteExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.hentai-foundry.com/pictures/tagged/kancolle\",\n    \"#category\": (\"\", \"hentaifoundry\", \"tag\"),\n    \"#class\"   : hentaifoundry.HentaifoundryTagExtractor,\n    \"#pattern\" : r\"https://pictures.hentai-foundry.com/[^/]/[^/?#]+/\\d+/\",\n    \"#range\"   : \"20-30\",\n\n    \"search_tags\": \"kancolle\",\n},\n\n{\n    \"#url\"     : \"https://www.hentai-foundry.com/pictures/recent/2018-09-20\",\n    \"#category\": (\"\", \"hentaifoundry\", \"recent\"),\n    \"#class\"   : hentaifoundry.HentaifoundryRecentExtractor,\n    \"#pattern\" : r\"https://pictures.hentai-foundry.com/[^/]/[^/?#]+/\\d+/\",\n    \"#range\"   : \"20-30\",\n\n    \"date\": \"2018-09-20\",\n},\n\n{\n    \"#url\"     : \"https://www.hentai-foundry.com/pictures/popular\",\n    \"#category\": (\"\", \"hentaifoundry\", \"popular\"),\n    \"#class\"   : hentaifoundry.HentaifoundryPopularExtractor,\n    \"#pattern\" : r\"https://pictures.hentai-foundry.com/[^/]/[^/?#]+/\\d+/\",\n    \"#range\"   : \"20-30\",\n},\n\n{\n    \"#url\"     : \"https://www.hentai-foundry.com/pictures/user/Tenpura/407501/shimakaze\",\n    \"#category\": (\"\", \"hentaifoundry\", \"image\"),\n    \"#class\"   : hentaifoundry.HentaifoundryImageExtractor,\n    \"#sha1_url\"    : \"fbf2fd74906738094e2575d2728e8dc3de18a8a3\",\n    \"#sha1_content\": \"91bf01497c39254b6dfb234a18e8f01629c77fd1\",\n\n    \"artist\"     : \"Tenpura\",\n    \"categories\" : [\"Anime & Manga\"],\n    \"date\"       : \"dt:2016-02-22 14:41:19\",\n    \"description\": \"Thank you!\",\n    \"height\"     : 700,\n    \"index\"      : 407501,\n    \"media\"      : \"Other digital art\",\n    \"ratings\"    : [\n        \"Sexual content\",\n        \"Contains female nudity\",\n    ],\n    \"score\"      : int,\n    \"tags\"       : [\n        \"collection\",\n        \"kancolle\",\n        \"kantai\",\n        \"shimakaze\",\n    ],\n    \"title\"      : \"shimakaze\",\n    \"user\"       : \"Tenpura\",\n    \"views\"      : int,\n    \"width\"      : 495,\n},\n\n{\n    \"#url\"     : \"https://www.hentai-foundry.com/pictures/user/Soloid/186714/Osaloop\",\n    \"#comment\" : \"SWF / rumble embed (#4641)\",\n    \"#category\": (\"\", \"hentaifoundry\", \"image\"),\n    \"#class\"   : hentaifoundry.HentaifoundryImageExtractor,\n    \"#results\" : \"https://pictures.hentai-foundry.com/s/Soloid/186714/Soloid-186714-Osaloop.swf\",\n\n    \"artist\"     : \"Soloid\",\n    \"categories\" : [\"Misc\"],\n    \"date\"       : \"dt:2013-02-07 17:25:54\",\n    \"description\": \"It took me ages.\\nI hope you'll like it.\\nSorry for the bad quality, I made it on after effect because Flash works like shit when you have 44 layers to animate, and the final ae SWF file is 55mo big.\",\n    \"extension\"  : \"swf\",\n    \"filename\"   : \"Soloid-186714-Osaloop\",\n    \"height\"     : 768,\n    \"index\"      : 186714,\n    \"media\"      : \"Digital drawing or painting\",\n    \"ratings\"    : [\n        \"Nudity\",\n        \"Sexual content\",\n        \"Contains female nudity\",\n    ],\n    \"score\"      : range(80, 120),\n    \"src\"        : \"https://pictures.hentai-foundry.com/s/Soloid/186714/Soloid-186714-Osaloop.swf\",\n    \"tags\"       : [\n        \"soloid\",\n    ],\n    \"title\"      : \"Osaloop\",\n    \"user\"       : \"Soloid\",\n    \"views\"      : range(45000, 60000),\n    \"width\"      : 613,\n},\n\n{\n    \"#url\"     : \"https://www.hentai-foundry.com/pictures/user/Soloid/186714/Osaloop\",\n    \"#comment\" : \"HTML 'description'\",\n    \"#class\"   : hentaifoundry.HentaifoundryImageExtractor,\n    \"#options\" : {\"descriptions\": \"html\"},\n    \"#results\" : \"https://pictures.hentai-foundry.com/s/Soloid/186714/Soloid-186714-Osaloop.swf\",\n\n    \"description\": \"\"\"\\\nIt took me ages.<br />\nI hope you&#039;ll like it.<br />\nSorry for the bad quality, I made it on after effect because Flash works like shit when you have 44 layers to animate, and the final ae SWF file is 55mo big.\\\n\"\"\",\n    \"extension\"  : \"swf\",\n    \"index\"      : 186714,\n    \"tags\"       : [\"soloid\"],\n    \"title\"      : \"Osaloop\",\n},\n\n{\n    \"#url\"     : \"http://www.hentai-foundry.com/pictures/user/Tenpura/407501/\",\n    \"#category\": (\"\", \"hentaifoundry\", \"image\"),\n    \"#class\"   : hentaifoundry.HentaifoundryImageExtractor,\n    \"#pattern\" : \"http://pictures.hentai-foundry.com/t/Tenpura/407501/\",\n},\n\n{\n    \"#url\"     : \"https://www.hentai-foundry.com/pictures/user/Tenpura/407501/\",\n    \"#category\": (\"\", \"hentaifoundry\", \"image\"),\n    \"#class\"   : hentaifoundry.HentaifoundryImageExtractor,\n},\n\n{\n    \"#url\"     : \"https://pictures.hentai-foundry.com/t/Tenpura/407501/Tenpura-407501-shimakaze.png\",\n    \"#category\": (\"\", \"hentaifoundry\", \"image\"),\n    \"#class\"   : hentaifoundry.HentaifoundryImageExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.hentai-foundry.com/stories/user/SnowWolf35\",\n    \"#category\": (\"\", \"hentaifoundry\", \"stories\"),\n    \"#class\"   : hentaifoundry.HentaifoundryStoriesExtractor,\n    \"#count\"   : \">= 35\",\n\n    \"author\"     : \"SnowWolf35\",\n    \"chapters\"   : int,\n    \"comments\"   : int,\n    \"date\"       : \"type:datetime\",\n    \"description\": str,\n    \"index\"      : int,\n    \"rating\"     : int,\n    \"ratings\"    : list,\n    \"status\"     : r\"re:(Inc|C)omplete\",\n    \"title\"      : str,\n    \"user\"       : \"SnowWolf35\",\n    \"views\"      : int,\n    \"words\"      : int,\n},\n\n{\n    \"#url\"     : \"https://www.hentai-foundry.com/stories/user/Likelymouse\",\n    \"#class\"   : hentaifoundry.HentaifoundryStoriesExtractor,\n    \"#range\"   : \"2\",\n    \"#results\" : \"https://www.hentai-foundry.com/stories/user/Likelymouse/77892/The-Sweater-and-Scarf-Public-Investigators-Part-1.pdf\",\n\n    \"author\"     : \"Likelymouse\",\n    \"categories\" : [\"Original\", \"Neko Shoujo & Kemonomimi\"],\n    \"chapters\"   : 11,\n    \"comments\"   : 0,\n    \"date\"       : \"dt:2025-10-04 00:00:00\",\n    \"extension\"  : \"pdf\",\n    \"filename\"   : \"The-Sweater-and-Scarf-Public-Investigators-Part-1\",\n    \"index\"      : 77892,\n    \"rating\"     : 0,\n    \"src\"        : \"https://www.hentai-foundry.com/stories/user/Likelymouse/77892/The-Sweater-and-Scarf-Public-Investigators-Part-1.pdf\",\n    \"status\"     : \"Complete\",\n    \"title\"      : \"The Sweater and Scarf Public Investigators, Part 1\",\n    \"user\"       : \"Likelymouse\",\n    \"views\"      : range(100, 10_000),\n    \"words\"      : 47031,\n    \"description\": \"\"\"\\\n<div style=\"text-align:center\"><a href=\"https://imgur.com/a/uRDss5c\"><img src=\"https://i.imgur.com/SCWI09e.jpeg\" alt=\"135x240\"/></a><br />\n<br />\n<a href=\"https://imgur.com/a/uRDss5c\">Cover Page (Made by me)</a></div><br />\nFollow 22 y/o Puffy Penelope as she descends into degeneracy, mastering her new found succubi powers.<br />\n<div style=\"text-align:left\"></div><br />\n<div style=\"text-align:left\">MAJOR Kinks include: Exhibitionism and voyeurism, all fours nudity, pet play, cum play, free use, and hyper-sexuality.</div><br />\n<div style=\"text-align:left\"></div><br />\n<br />\n<br />\n<div style=\"text-align:center\"></div>\\\n\"\"\",\n    \"ratings\"    : [\n        \"Nudity\",\n        \"Violence\",\n        \"Profanity\",\n        \"Sexual content\",\n        \"Contains male nudity\",\n        \"Contains female nudity\",\n        \"Non-consensual/Rape/Forced\",\n    ],\n},\n\n{\n    \"#url\"     : \"https://www.hentai-foundry.com/stories/user/SnowWolf35/26416/Overwatch-High-Chapter-Voting-Location\",\n    \"#category\": (\"\", \"hentaifoundry\", \"story\"),\n    \"#class\"   : hentaifoundry.HentaifoundryStoryExtractor,\n    \"#sha1_url\": \"5a67cfa8c3bf7634c8af8485dd07c1ea74ee0ae8\",\n\n    \"title\": \"Overwatch High Chapter Voting Location\",\n    \"categories\": [\"Games\", \"Overwatch\"],\n},\n\n)\n"
  },
  {
    "path": "test/results/hentaifox.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import imhentai\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://hentaifox.com/gallery/56622/\",\n    \"#category\": (\"IMHentai\", \"hentaifox\", \"gallery\"),\n    \"#class\"   : imhentai.ImhentaiGalleryExtractor,\n    \"#pattern\" : r\"https://i\\d*\\.hentaifox\\.com/\\d+/\\d+/\\d+\\.jpg\",\n    \"#count\"   : 24,\n\n    \"count\"     : 24,\n    \"extension\" : \"jpg\",\n    \"filename\"  : str,\n    \"gallery_id\": 56622,\n    \"width\"     : 1143,\n    \"height\"    : 1600,\n    \"lang\"      : \"en\",\n    \"num\"       : range(1, 24),\n    \"title\"     : \"TSF no F no Hon Sono 3 no B - Ch.1\",\n    \"title_alt\" : \"\",\n    \"type\"      : \"doujinshi\",\n\n    \"artist\"    : [\n        \"taniyaraku\",\n    ],\n    \"character\" : [],\n    \"group\"     : [\n        \"tsf no f\",\n    ],\n    \"language\"  : [\n        \"english\",\n        \"translated\",\n    ],\n    \"parody\"    : [\n        \"original\",\n    ],\n    \"tags\"      : [\n        \"breast expansion\",\n        \"clothed male nude female\",\n        \"fingering\",\n        \"full censorship\",\n        \"gender bender\",\n        \"glasses\",\n        \"mind break\",\n        \"sole female\",\n        \"sole male\",\n        \"transformation\",\n    ],\n},\n\n{\n    \"#url\"     : \"https://hentaifox.com/gallery/630/\",\n    \"#comment\" : \"'split_tag' element (#1378)\",\n    \"#category\": (\"IMHentai\", \"hentaifox\", \"gallery\"),\n    \"#class\"   : imhentai.ImhentaiGalleryExtractor,\n\n    \"artist\"    : [\n        \"beti\",\n        \"betty\",\n        \"magi\",\n        \"mimikaki\",\n    ],\n    \"character\": [\n        \"aerith gainsborough\",\n        \"tifa lockhart\",\n        \"yuffie kisaragi\",\n    ],\n    \"count\"     : 32,\n    \"gallery_id\": 630,\n    \"group\"     : [\"cu-little2\"],\n    \"parody\"    : [\n        \"darkstalkers | vampire\",\n        \"final fantasy vii\",\n    ],\n    \"tags\"      : [\n        \"femdom\",\n        \"fingering\",\n        \"masturbation\",\n        \"yuri\",\n    ],\n    \"title\"     : \"Cu-Little Bakanyaï½ž\",\n    \"type\"      : \"doujinshi\",\n},\n\n{\n    \"#url\"     : \"https://hentaifox.com/gallery/35261/\",\n    \"#comment\" : \"email-protected title (#4201)\",\n    \"#category\": (\"IMHentai\", \"hentaifox\", \"gallery\"),\n    \"#class\"   : imhentai.ImhentaiGalleryExtractor,\n\n    \"gallery_id\": 35261,\n    \"title\"     : \"ManageM@ster!\",\n    \"artist\"    : [\"haritama hiroki\"],\n    \"group\"     : [\"studio n.ball\"],\n},\n\n{\n    \"#url\"     : \"https://hentaifox.com/parody/touhou-project/\",\n    \"#category\": (\"IMHentai\", \"hentaifox\", \"tag\"),\n    \"#class\"   : imhentai.ImhentaiTagExtractor,\n},\n\n{\n    \"#url\"     : \"https://hentaifox.com/character/reimu-hakurei/\",\n    \"#category\": (\"IMHentai\", \"hentaifox\", \"tag\"),\n    \"#class\"   : imhentai.ImhentaiTagExtractor,\n},\n\n{\n    \"#url\"     : \"https://hentaifox.com/artist/distance/\",\n    \"#category\": (\"IMHentai\", \"hentaifox\", \"tag\"),\n    \"#class\"   : imhentai.ImhentaiTagExtractor,\n},\n\n{\n    \"#url\"     : \"https://hentaifox.com/group/v-slash/\",\n    \"#category\": (\"IMHentai\", \"hentaifox\", \"tag\"),\n    \"#class\"   : imhentai.ImhentaiTagExtractor,\n},\n\n{\n    \"#url\"     : \"https://hentaifox.com/tag/heterochromia/\",\n    \"#category\": (\"IMHentai\", \"hentaifox\", \"tag\"),\n    \"#class\"   : imhentai.ImhentaiTagExtractor,\n    \"#pattern\" : imhentai.ImhentaiGalleryExtractor.pattern,\n    \"#count\"   : range(180, 220),\n},\n\n{\n    \"#url\"     : \"https://hentaifox.com/search/?q=touhou+filming\",\n    \"#category\": (\"IMHentai\", \"hentaifox\", \"search\"),\n    \"#class\"   : imhentai.ImhentaiSearchExtractor,\n    \"#pattern\" : imhentai.ImhentaiGalleryExtractor.pattern,\n    \"#count\"   : range(20, 30),\n},\n\n{\n    \"#url\"     : \"https://hentaifox.com/search/touhou/\",\n    \"#category\": (\"IMHentai\", \"hentaifox\", \"search\"),\n    \"#class\"   : imhentai.ImhentaiSearchExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/hentaihand.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import hentaihand\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://hentaihand.com/en/comic/c75-takumi-na-muchi-choudenji-hou-no-aishi-kata-how-to-love-a-super-electromagnetic-gun-toaru-kagaku-no-railgun-english\",\n    \"#category\": (\"\", \"hentaihand\", \"gallery\"),\n    \"#class\"   : hentaihand.HentaihandGalleryExtractor,\n    \"#pattern\" : r\"https://cdn.hentaihand.com/.*/images/37387/\\d+.jpg$\",\n    \"#count\"   : 50,\n\n    \"artists\"      : [\"Takumi Na Muchi\"],\n    \"date\"         : \"dt:2014-06-28 00:00:00\",\n    \"gallery_id\"   : 37387,\n    \"lang\"         : \"en\",\n    \"language\"     : \"English\",\n    \"parodies\"     : [\"Toaru Kagaku No Railgun\"],\n    \"relationships\": list,\n    \"tags\"         : list,\n    \"title\"        : r\"re:\\(C75\\) \\[Takumi na Muchi\\] Choudenji Hou \",\n    \"title_alt\"    : r\"re:\\(C75\\) \\[たくみなむち\\] 超電磁砲のあいしかた\",\n    \"type\"         : \"Doujinshi\",\n},\n\n{\n    \"#url\"     : \"https://hentaihand.com/en/artist/takumi-na-muchi\",\n    \"#category\": (\"\", \"hentaihand\", \"tag\"),\n    \"#class\"   : hentaihand.HentaihandTagExtractor,\n    \"#pattern\" : hentaihand.HentaihandGalleryExtractor.pattern,\n    \"#count\"   : \">= 6\",\n},\n\n{\n    \"#url\"     : \"https://hentaihand.com/en/tag/full-color\",\n    \"#category\": (\"\", \"hentaihand\", \"tag\"),\n    \"#class\"   : hentaihand.HentaihandTagExtractor,\n},\n\n{\n    \"#url\"     : \"https://hentaihand.com/fr/language/japanese\",\n    \"#category\": (\"\", \"hentaihand\", \"tag\"),\n    \"#class\"   : hentaihand.HentaihandTagExtractor,\n},\n\n{\n    \"#url\"     : \"https://hentaihand.com/zh/category/manga\",\n    \"#category\": (\"\", \"hentaihand\", \"tag\"),\n    \"#class\"   : hentaihand.HentaihandTagExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/hentaihere.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import hentaihere\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://hentaihere.com/m/S13812/1/1/\",\n    \"#category\": (\"\", \"hentaihere\", \"chapter\"),\n    \"#class\"   : hentaihere.HentaihereChapterExtractor,\n    \"#sha1_url\"     : \"964b942cf492b3a129d2fe2608abfc475bc99e71\",\n    \"#sha1_metadata\": \"0207d20eea3a15d2a8d1496755bdfa49de7cfa9d\",\n},\n\n{\n    \"#url\"     : \"https://hentaihere.com/m/S23048/1.5/1/\",\n    \"#category\": (\"\", \"hentaihere\", \"chapter\"),\n    \"#class\"   : hentaihere.HentaihereChapterExtractor,\n    \"#pattern\" : r\"https://hentaicdn\\.com/hentai/23048/1\\.5/ccdn00\\d+\\.jpg\",\n    \"#count\"   : 32,\n\n    \"author\"       : \"Shinozuka Yuuji\",\n    \"chapter\"      : 1,\n    \"chapter_id\"   : 80186,\n    \"chapter_minor\": \".5\",\n    \"count\"        : 32,\n    \"lang\"         : \"en\",\n    \"language\"     : \"English\",\n    \"manga\"        : \"High School Slut's Love Consultation\",\n    \"manga_id\"     : 23048,\n    \"page\"         : int,\n    \"title\"        : \"High School Slut's Love Consultation + Girlfriend [Full Color]\",\n    \"type\"         : \"Original\",\n},\n\n{\n    \"#url\"     : \"https://hentaihere.com/m/S13812\",\n    \"#category\": (\"\", \"hentaihere\", \"manga\"),\n    \"#class\"   : hentaihere.HentaihereMangaExtractor,\n    \"#sha1_url\"     : \"d1ba6e28bb2162e844f8559c2b2725ba0a093559\",\n    \"#sha1_metadata\": \"5c1b712258e78e120907121d3987c71f834d13e1\",\n},\n\n{\n    \"#url\"     : \"https://hentaihere.com/m/S7608\",\n    \"#category\": (\"\", \"hentaihere\", \"manga\"),\n    \"#class\"   : hentaihere.HentaihereMangaExtractor,\n    \"#sha1_url\": \"6c5239758dc93f6b1b4175922836c10391b174f7\",\n\n    \"chapter\"      : int,\n    \"chapter_id\"   : int,\n    \"chapter_minor\": \"\",\n    \"lang\"         : \"en\",\n    \"language\"     : \"English\",\n    \"manga\"        : \"Oshikake Riot\",\n    \"manga_id\"     : 7608,\n    \"title\"        : r\"re:Oshikake Riot( \\d+)?\",\n    \"type\"         : \"Original\",\n},\n\n)\n"
  },
  {
    "path": "test/results/hentainexus.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import hentainexus\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://hentainexus.com/view/5688\",\n    \"#category\": (\"\", \"hentainexus\", \"gallery\"),\n    \"#class\"   : hentainexus.HentainexusGalleryExtractor,\n\n    \"artist\"     : \"Tsukiriran\",\n    \"book\"       : \"\",\n    \"circle\"     : \"\",\n    \"count\"      : 4,\n    \"cover\"      : str,\n    \"description\": \"The cherry blossom blooms for one final graduation memory. ❤\",\n    \"event\"      : \"\",\n    \"extension\"  : \"png\",\n    \"filename\"   : str,\n    \"gallery_id\" : 5688,\n    \"image\"      : str,\n    \"label\"      : str,\n    \"lang\"       : \"en\",\n    \"language\"   : \"English\",\n    \"magazine\"   : \"Comic Bavel 2018-08\",\n    \"num\"        : range(1, 4),\n    \"parody\"     : \"Original Work\",\n    \"publisher\"  : \"FAKKU\",\n    \"tags\"       : [\n        \"busty\",\n        \"color\",\n        \"creampie\",\n        \"exhibitionism\",\n        \"hentai\",\n        \"kimono\",\n        \"pubic hair\",\n        \"uncensored\",\n        \"unlimited\",\n        \"vanilla\",\n    ],\n    \"title\"      : \"Graduation!\",\n    \"title_conventional\": \"[Tsukiriran] Graduation! (Comic Bavel 2018-08)\",\n    \"type\"       : \"image\",\n    \"url_label\"  : str,\n},\n\n{\n    \"#url\"     : \"https://hentainexus.com/read/5688\",\n    \"#category\": (\"\", \"hentainexus\", \"gallery\"),\n    \"#class\"   : hentainexus.HentainexusGalleryExtractor,\n},\n\n{\n    \"#url\"     : \"https://hentainexus.com/view/715\",\n    \"#comment\" : \"combined left-right pages (#5827)\",\n    \"#category\": (\"\", \"hentainexus\", \"gallery\"),\n    \"#class\"   : hentainexus.HentainexusGalleryExtractor,\n    \"#count\"   : 2,\n},\n\n{\n    \"#url\"     : \"https://hentainexus.com/?q=tag:%22heart+pupils%22%20tag:group\",\n    \"#category\": (\"\", \"hentainexus\", \"search\"),\n    \"#class\"   : hentainexus.HentainexusSearchExtractor,\n    \"#pattern\" : hentainexus.HentainexusGalleryExtractor.pattern,\n    \"#range\"   : \"1-30\",\n    \"#count\"   : 30,\n},\n\n{\n    \"#url\"     : \"https://hentainexus.com/page/3?q=tag:%22heart+pupils%22\",\n    \"#category\": (\"\", \"hentainexus\", \"search\"),\n    \"#class\"   : hentainexus.HentainexusSearchExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/hentairox.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import imhentai\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://hentairox.com/gallery/25/\",\n    \"#category\": (\"IMHentai\", \"hentairox\", \"gallery\"),\n    \"#class\"   : imhentai.ImhentaiGalleryExtractor,\n    \"#pattern\" : r\"https://m1\\.hentairox\\.com/001/knrxtga49v/\\d+\\.jpg\",\n    \"#count\"   : 25,\n\n    \"count\"     : 25,\n    \"extension\" : \"jpg\",\n    \"filename\"  :  r\"re:\\d+\",\n    \"gallery_id\": 25,\n    \"lang\"      : \"ja\",\n    \"num\"       : range(1, 25),\n    \"title\"     : \"(Shikei wa Iyadakara na) [Kujira Logic, TOYBOX (Kujiran, Kurikara)] Gensou-kyou Chichi Zukan - Kurenai (Touhou Project)\",\n    \"title_alt\" : \"(死刑はいやだからな) [くぢらろじっく, といぼっくす (くぢらん, くりから)] 幻想郷乳図鑑 - 紅 (東方Project)\",\n    \"type\"      : \"doujinshi\",\n    \"width\"     : {696, 701},\n    \"height\"    : {999, 1000},\n\n    \"artist\": [\n        \"kujiran\",\n        \"kurikara\",\n    ],\n    \"character\": [\n        \"hong meiling\",\n        \"koakuma\",\n        \"patchouli knowledge\",\n        \"remilia scarlet\",\n        \"sakuya izayoi\",\n    ],\n    \"group\": [\n        \"kujira logic\",\n        \"toybox\",\n    ],\n    \"language\": [\n        \"japanese\",\n    ],\n    \"parody\": [\n        \"touhou project\",\n    ],\n    \"tags\": [\n        \"big breasts\",\n        \"footjob\",\n        \"futanari\",\n        \"lolicon\",\n        \"maid\",\n        \"paizuri\",\n    ],\n},\n\n{\n    \"#url\"    : \"https://hentairox.com/gallery/8526/\",\n    \"#category\": (\"IMHentai\", \"hentairox\", \"gallery\"),\n    \"#class\"   : imhentai.ImhentaiGalleryExtractor,\n    \"#pattern\": r\"https://m1\\.hentairox\\.com/001/gkchsf3x5m/\\d+\\.jpg\",\n    \"#count\"  : 8,\n\n    \"count\"     : 8,\n    \"extension\" : \"jpg\",\n    \"filename\"  : r\"re:\\d+\",\n    \"gallery_id\": 8526,\n    \"lang\"      : \"ja\",\n    \"num\"       : range(1, 8),\n    \"title\"     : \"(C70) [UDON-YA (Kizuki Aruchu, ZAN)] Udonko CM70 Omake Hon (Various)\",\n    \"title_alt\" : \"(C70) [うどんや (鬼月あるちゅ、ZAN)] うどんこ CM70オマケ本 (よろず)\",\n    \"type\"      : \"doujinshi\",\n    \"width\"     : 1076,\n    \"height\"    : 1517,\n\n    \"artist\": [\n        \"kizuki aruchu\",\n        \"zan\",\n    ],\n    \"character\": [\n        \"mikuru asahina\",\n        \"reisen udongein inaba\",\n        \"tsuruya\",\n    ],\n    \"group\": [\n        \"udon-ya\",\n    ],\n    \"language\": [\n        \"japanese\",\n    ],\n    \"parody\": [\n        \"fate stay night\",\n        \"super robot wars | super robot taisen\",\n        \"the melancholy of haruhi suzumiya | suzumiya haruhi no yuuutsu\",\n    ],\n    \"tags\": [\n        \"big breasts\",\n        \"okaasan to issho\",\n        \"touhou kaeidzuka\",\n    ],\n},\n\n{\n    \"#url\"     : \"https://hentairox.com/artist/kizuki-aruchu/\",\n    \"#category\": (\"IMHentai\", \"hentairox\", \"tag\"),\n    \"#class\"   : imhentai.ImhentaiTagExtractor,\n    \"#pattern\" : imhentai.ImhentaiGalleryExtractor.pattern,\n    \"#count\"   : range(140, 160),\n},\n\n{\n    \"#url\"     : \"https://hentairox.com/search/?key=aruchu\",\n    \"#category\": (\"IMHentai\", \"hentairox\", \"search\"),\n    \"#class\"   : imhentai.ImhentaiSearchExtractor,\n    \"#pattern\" : imhentai.ImhentaiGalleryExtractor.pattern,\n    \"#count\"   : range(140, 160),\n},\n\n)\n"
  },
  {
    "path": "test/results/hentaizap.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import imhentai\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://hentaizap.com/gallery/12/\",\n    \"#category\": (\"IMHentai\", \"hentaizap\", \"gallery\"),\n    \"#class\"   : imhentai.ImhentaiGalleryExtractor,\n    \"#pattern\" : r\"https://m1\\.hentaizap\\.com/001/3x907ntq18/\\d+\\.jpg\",\n    \"#count\"   : 94,\n\n    \"count\"     : 94,\n    \"extension\" : \"jpg\",\n    \"filename\"  : str,\n    \"gallery_id\": 12,\n    \"lang\"      : \"en\",\n    \"num\"       : range(1, 94),\n    \"title\"     : \"(C67) [Studio Kimigabuchi (Kimimaru)] RE-TAKE 2 (Neon Genesis Evangelion) [English]\",\n    \"title_alt\" : \"\",\n    \"type\"      : \"doujinshi\",\n    \"width\"     : {835, 838, 841, 1200},\n    \"height\"    : {862, 865, 1200},\n\n    \"artist\":    [\n        \"kimimaru | entokkun\",\n    ],\n    \"character\": [\n        \"asuka langley soryu\",\n        \"gendo ikari\",\n        \"makoto hyuga\",\n        \"maya ibuki\",\n        \"misato katsuragi\",\n        \"rei ayanami\",\n        \"shigeru aoba\",\n        \"shinji ikari\",\n    ],\n    \"group\": [\n        \"studio kimigabuchi\",\n    ],\n    \"language\": [\n        \"english\",\n        \"translated\",\n    ],\n    \"parody\": [\n        \"neon genesis evangelion | shin seiki evangelion\",\n    ],\n    \"tags\": [\n        \"multi-work series\",\n        \"schoolboy uniform\",\n        \"schoolgirl uniform\",\n        \"sole female\",\n        \"sole male\",\n        \"story arc\",\n        \"twintails\",\n    ],\n},\n\n{\n    \"#url\"     : \"https://hentaizap.com/gallery/1329498/\",\n    \"#category\": (\"IMHentai\", \"hentaizap\", \"gallery\"),\n    \"#class\"   : imhentai.ImhentaiGalleryExtractor,\n    \"#pattern\" : r\"https://m9\\.hentaizap\\.com/029/tk70aw8b4y/\\d+\\.webp\",\n    \"#count\"   : 25,\n\n    \"count\"     : 25,\n    \"num\"       : range(1, 25),\n    \"extension\" : \"webp\",\n    \"filename\"  : str,\n    \"gallery_id\": 1329498,\n    \"lang\"      : \"ru\",\n    \"title\"     : \"(C102) [Koniro Kajitsu (KonKa)] Konbucha wa Ikaga desu ka | Хотите немного чая из водорослей? (Blue Archive) [Russian] [graun]\",\n    \"title_alt\" : \"\",\n    \"type\"      : \"doujinshi\",\n    \"width\"     : 1280,\n    \"height\"    : range(1804, 1832),\n\n    \"artist\": [\n        \"konka\",\n    ],\n    \"character\": [\n        \"nagisa kirifuji\",\n        \"sensei\",\n    ],\n    \"group\": [\n        \"koniro kajitsu\",\n    ],\n    \"language\": [\n        \"russian\",\n        \"translated\",\n    ],\n    \"parody\": [\n        \"blue archive\",\n    ],\n    \"tags\": [\n        \"angel\",\n        \"defloration\",\n        \"halo\",\n        \"kissing\",\n        \"pantyhose\",\n        \"sole female\",\n        \"sole male\",\n        \"wings\",\n    ],\n},\n\n{\n    \"#url\"     : \"https://hentaizap.com/artist/asutora/\",\n    \"#category\": (\"IMHentai\", \"hentaizap\", \"tag\"),\n    \"#class\"   : imhentai.ImhentaiTagExtractor,\n    \"#pattern\" : imhentai.ImhentaiGalleryExtractor.pattern,\n    \"#count\"   : range(45, 50),\n},\n\n{\n    \"#url\"     : \"https://hentaizap.com/search/?key=asutora\",\n    \"#category\": (\"IMHentai\", \"hentaizap\", \"search\"),\n    \"#class\"   : imhentai.ImhentaiSearchExtractor,\n    \"#pattern\" : imhentai.ImhentaiGalleryExtractor.pattern,\n    \"#count\"   : range(45, 60),\n},\n\n)\n"
  },
  {
    "path": "test/results/hiperdex.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import hiperdex\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://hipertoon.com/manga/domestic-na-kanojo/154-5/\",\n    \"#category\": (\"\", \"hiperdex\", \"chapter\"),\n    \"#class\"   : hiperdex.HiperdexChapterExtractor,\n    \"#pattern\" : r\"https://(1st)?hiper(dex|toon)\\d?.(com|net|info|top)/wp-content/uploads/WP-manga/data/manga_\\w+/[0-9a-f]{32}/\\d+\\.webp\",\n    \"#count\"   : 9,\n\n    \"artist\"       : \"Sasuga Kei\",\n    \"author\"       : \"Sasuga Kei\",\n    \"chapter\"      : 154,\n    \"chapter_minor\": \".5\",\n    \"description\"  : \"Natsuo Fujii is in love with his teacher, Hina. Attempting to forget his feelings towards her, Natsuo goes to a mixer with his classmates where he meets an odd girl named Rui Tachibana. In a strange turn of events, Rui asks Natsuo to sneak out with her and do her a favor. To his surprise, their destination is Rui’s house—and her request is for him to have sex with her. There’s no love behind the act; she just wants to learn from the experience. Thinking that it might help him forget about Hina, Natsuo hesitantly agrees. After this unusual encounter Natsuo now faces a new problem. With his father remarrying, he ends up with a new pair of stepsisters; unfortunately, he knows these two girls all too well. He soon finds out his new siblings are none other than Hina and Rui! Now living with both the teacher he loves and the girl with whom he had his “first time,” Natsuo finds himself in an unexpected love triangle as he climbs ever closer towards adulthood.\",\n    \"genre\"        : list,\n    \"manga\"        : \"Domestic na Kanojo\",\n    \"release\"      : 2014,\n    \"score\"        : float,\n    \"type\"         : \"Manga\",\n},\n\n{\n    \"#url\"     : \"https://hiperdex.com/mangas/domestic-na-kanojo/154-5/\",\n    \"#category\": (\"\", \"hiperdex\", \"chapter\"),\n    \"#class\"   : hiperdex.HiperdexChapterExtractor,\n},\n\n{\n    \"#url\"     : \"https://1sthiperdex.com/manga/domestic-na-kanojo/154-5/\",\n    \"#category\": (\"\", \"hiperdex\", \"chapter\"),\n    \"#class\"   : hiperdex.HiperdexChapterExtractor,\n},\n\n{\n    \"#url\"     : \"https://hiperdex2.com/manga/domestic-na-kanojo/154-5/\",\n    \"#category\": (\"\", \"hiperdex\", \"chapter\"),\n    \"#class\"   : hiperdex.HiperdexChapterExtractor,\n},\n\n{\n    \"#url\"     : \"https://hiperdex.net/manga/domestic-na-kanojo/154-5/\",\n    \"#category\": (\"\", \"hiperdex\", \"chapter\"),\n    \"#class\"   : hiperdex.HiperdexChapterExtractor,\n},\n\n{\n    \"#url\"     : \"https://hiperdex.info/manga/domestic-na-kanojo/154-5/\",\n    \"#category\": (\"\", \"hiperdex\", \"chapter\"),\n    \"#class\"   : hiperdex.HiperdexChapterExtractor,\n},\n\n{\n    \"#url\"     : \"https://hiperdex.top/manga/domestic-na-kanojo/154-5/\",\n    \"#category\": (\"\", \"hiperdex\", \"chapter\"),\n    \"#class\"   : hiperdex.HiperdexChapterExtractor,\n},\n\n{\n    \"#url\"     : \"https://hiperdex.com/manga/1603231576-youre-not-that-special/\",\n    \"#category\": (\"\", \"hiperdex\", \"manga\"),\n    \"#class\"   : hiperdex.HiperdexMangaExtractor,\n    \"#pattern\" : hiperdex.HiperdexChapterExtractor.pattern,\n    \"#count\"   : 51,\n\n    \"artist\"       : \"Bolp\",\n    \"author\"       : \"Abyo4\",\n    \"chapter\"      : int,\n    \"chapter_minor\": \"\",\n    \"description\"  : r\"re:I didn’t think much of the creepy girl in \",\n    \"genre\"        : list,\n    \"manga\"        : \"You’re Not That Special!\",\n    \"release\"      : 2019,\n    \"score\"        : float,\n    \"status\"       : \"Completed\",\n    \"type\"         : \"Manhwa\",\n},\n\n{\n    \"#url\"     : \"https://hiperdex.com/manga/youre-not-that-special/\",\n    \"#category\": (\"\", \"hiperdex\", \"manga\"),\n    \"#class\"   : hiperdex.HiperdexMangaExtractor,\n},\n\n{\n    \"#url\"     : \"https://1sthiperdex.com/manga/youre-not-that-special/\",\n    \"#category\": (\"\", \"hiperdex\", \"manga\"),\n    \"#class\"   : hiperdex.HiperdexMangaExtractor,\n},\n\n{\n    \"#url\"     : \"https://hiperdex2.com/manga/youre-not-that-special/\",\n    \"#category\": (\"\", \"hiperdex\", \"manga\"),\n    \"#class\"   : hiperdex.HiperdexMangaExtractor,\n},\n\n{\n    \"#url\"     : \"https://hiperdex.net/manga/youre-not-that-special/\",\n    \"#category\": (\"\", \"hiperdex\", \"manga\"),\n    \"#class\"   : hiperdex.HiperdexMangaExtractor,\n},\n\n{\n    \"#url\"     : \"https://hiperdex.info/manga/youre-not-that-special/\",\n    \"#category\": (\"\", \"hiperdex\", \"manga\"),\n    \"#class\"   : hiperdex.HiperdexMangaExtractor,\n},\n\n{\n    \"#url\"     : \"https://1sthiperdex.com/manga-artist/beck-ho-an/\",\n    \"#category\": (\"\", \"hiperdex\", \"artist\"),\n    \"#class\"   : hiperdex.HiperdexArtistExtractor,\n},\n\n{\n    \"#url\"     : \"https://hiperdex.net/manga-artist/beck-ho-an/\",\n    \"#category\": (\"\", \"hiperdex\", \"artist\"),\n    \"#class\"   : hiperdex.HiperdexArtistExtractor,\n},\n\n{\n    \"#url\"     : \"https://hiperdex2.com/manga-artist/beck-ho-an/\",\n    \"#category\": (\"\", \"hiperdex\", \"artist\"),\n    \"#class\"   : hiperdex.HiperdexArtistExtractor,\n},\n\n{\n    \"#url\"     : \"https://hiperdex.info/manga-artist/beck-ho-an/\",\n    \"#category\": (\"\", \"hiperdex\", \"artist\"),\n    \"#class\"   : hiperdex.HiperdexArtistExtractor,\n},\n\n{\n    \"#url\"     : \"https://hiperdex.com/manga-author/viagra/\",\n    \"#category\": (\"\", \"hiperdex\", \"artist\"),\n    \"#class\"   : hiperdex.HiperdexArtistExtractor,\n    \"#pattern\" : hiperdex.HiperdexMangaExtractor.pattern,\n    \"#count\"   : \">= 6\",\n},\n\n)\n"
  },
  {
    "path": "test/results/hitomi.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import hitomi\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://hitomi.la/galleries/867789.html\",\n    \"#category\": (\"\", \"hitomi\", \"gallery\"),\n    \"#class\"   : hitomi.HitomiGalleryExtractor,\n    \"#pattern\" : r\"https://w[1-3]\\.gold-usergeneratedcontent\\.net/\\d+/\\d+/[0-9a-f]{64}\\.webp\",\n    \"#count\"   : 16,\n\n    \"artist\"    : [\"morris\"],\n    \"characters\": [],\n    \"count\"     : 16,\n    \"date\"      : \"dt:2015-10-27 19:20:00\",\n    \"extension\" : \"webp\",\n    \"extension_original\" : \"jpg\",\n    \"filename\"  : str,\n    \"gallery_id\": 867789,\n    \"group\"     : [],\n    \"lang\"      : \"en\",\n    \"language\"  : \"English\",\n    \"num\"       : range(1, 16),\n    \"parody\"    : [],\n    \"tags\"      : [\n        \"Cheating ♀\",\n        \"Drugs ♀\",\n        \"Drugs ♂\",\n        \"Incest\",\n        \"Milf ♀\",\n        \"Mother ♀\",\n        \"Sole Female ♀\",\n        \"Sole Male ♂\",\n        \"Uncensored\"\n    ],\n    \"title\"     : \"Amazon no Hiyaku | Amazon Elixir (decensored)\",\n    \"title_jpn\" : \"\",\n    \"type\"      : \"Manga\",\n},\n\n{\n    \"#url\"     : \"https://hitomi.la/galleries/1401410.html\",\n    \"#comment\" : \"download test\",\n    \"#category\": (\"\", \"hitomi\", \"gallery\"),\n    \"#class\"   : hitomi.HitomiGalleryExtractor,\n    \"#range\"       : \"1\",\n    \"#sha1_content\": \"d75d5a3d1302a48469016b20e53c26b714d17745\",\n},\n\n{\n    \"#url\"     : \"https://hitomi.la/galleries/733697.html\",\n    \"#comment\" : \"Game CG with scenes (#321)\",\n    \"#category\": (\"\", \"hitomi\", \"gallery\"),\n    \"#class\"   : hitomi.HitomiGalleryExtractor,\n    \"#count\"   : 210,\n},\n\n{\n    \"#url\"     : \"https://hitomi.la/galleries/1045954.html\",\n    \"#comment\" : \"fallback for galleries only available through /reader/ URLs\",\n    \"#category\": (\"\", \"hitomi\", \"gallery\"),\n    \"#class\"   : hitomi.HitomiGalleryExtractor,\n    \"#count\"   : 1413,\n},\n\n{\n    \"#url\"     : \"https://hitomi.la/cg/scathacha-sama-okuchi-ecchi-1291900.html\",\n    \"#comment\" : \"gallery with 'broken' redirect\",\n    \"#category\": (\"\", \"hitomi\", \"gallery\"),\n    \"#class\"   : hitomi.HitomiGalleryExtractor,\n},\n\n{\n    \"#url\"     : \"https://hitomi.la/cg/1615823.html\",\n    \"#comment\" : \"no tags\",\n    \"#category\": (\"\", \"hitomi\", \"gallery\"),\n    \"#class\"   : hitomi.HitomiGalleryExtractor,\n    \"#options\" : {\"format\": \"avif\"},\n    \"#pattern\" : r\"https://a[1-3]\\.gold-usergeneratedcontent\\.net/\\d+/\\d+/[0-9a-f]{64}\\.avif\",\n    \"#count\"   : 22,\n\n    \"artist\"    : [\"sorairo len\"],\n    \"characters\": [],\n    \"count\"     : 22,\n    \"date\"      : \"dt:2020-04-19 06:33:00\",\n    \"extension\" : \"avif\",\n    \"filename\"  : str,\n    \"gallery_id\": 1615823,\n    \"group\"     : [\"mofumofuen\"],\n    \"lang\"      : \"ja\",\n    \"language\"  : \"Japanese\",\n    \"num\"       : range(1, 22),\n    \"parody\"    : [\"original\"],\n    \"tags\"      : [\n        \"Blowjob ♀\",\n        \"Focus Blowjob ♀\",\n        \"Fox Girl ♀\",\n        \"Kemonomimi ♀\",\n        \"Loli ♀\",\n        \"Miko ♀\",\n        \"No Penetration\",\n        \"Unusual Pupils ♀\",\n        \"Variant Set\"\n    ],\n    \"title\"     : \"Kouko-sama ga Okuchi de Reiryoku Hokyuu\",\n    \"title_jpn\" : \"コウコ様がお口で霊力補給♡\",\n    \"type\"      : \"Artistcg\",\n},\n\n{\n    \"#url\"     : \"https://hitomi.la/manga/amazon-no-hiyaku-867789.html\",\n    \"#category\": (\"\", \"hitomi\", \"gallery\"),\n    \"#class\"   : hitomi.HitomiGalleryExtractor,\n},\n\n{\n    \"#url\"     : \"https://hitomi.la/manga/867789.html\",\n    \"#category\": (\"\", \"hitomi\", \"gallery\"),\n    \"#class\"   : hitomi.HitomiGalleryExtractor,\n},\n\n{\n    \"#url\"     : \"https://hitomi.la/doujinshi/867789.html\",\n    \"#category\": (\"\", \"hitomi\", \"gallery\"),\n    \"#class\"   : hitomi.HitomiGalleryExtractor,\n},\n\n{\n    \"#url\"     : \"https://hitomi.la/cg/867789.html\",\n    \"#category\": (\"\", \"hitomi\", \"gallery\"),\n    \"#class\"   : hitomi.HitomiGalleryExtractor,\n},\n\n{\n    \"#url\"     : \"https://hitomi.la/gamecg/867789.html\",\n    \"#category\": (\"\", \"hitomi\", \"gallery\"),\n    \"#class\"   : hitomi.HitomiGalleryExtractor,\n},\n\n{\n    \"#url\"     : \"https://hitomi.la/imageset/867789.html\",\n    \"#comment\" : \"/imageset/ gallery (#4756)\",\n    \"#category\": (\"\", \"hitomi\", \"gallery\"),\n    \"#class\"   : hitomi.HitomiGalleryExtractor,\n},\n\n{\n    \"#url\"     : \"https://hitomi.la/reader/867789.html\",\n    \"#category\": (\"\", \"hitomi\", \"gallery\"),\n    \"#class\"   : hitomi.HitomiGalleryExtractor,\n},\n\n{\n    \"#url\"     : \"https://hitomi.la/tag/screenshots-japanese.html\",\n    \"#category\": (\"\", \"hitomi\", \"tag\"),\n    \"#class\"   : hitomi.HitomiTagExtractor,\n    \"#pattern\" : hitomi.HitomiGalleryExtractor.pattern,\n    \"#count\"   : \">= 35\",\n\n    \"search_tags\": \"screenshots\",\n},\n\n{\n    \"#url\"     : \"https://hitomi.la/artist/a1-all-1.html\",\n    \"#category\": (\"\", \"hitomi\", \"tag\"),\n    \"#class\"   : hitomi.HitomiTagExtractor,\n},\n\n{\n    \"#url\"     : \"https://hitomi.la/group/initial%2Dg-all-1.html\",\n    \"#category\": (\"\", \"hitomi\", \"tag\"),\n    \"#class\"   : hitomi.HitomiTagExtractor,\n},\n\n{\n    \"#url\"     : \"https://hitomi.la/series/amnesia-all-1.html\",\n    \"#category\": (\"\", \"hitomi\", \"tag\"),\n    \"#class\"   : hitomi.HitomiTagExtractor,\n},\n\n{\n    \"#url\"     : \"https://hitomi.la/type/doujinshi-all-1.html\",\n    \"#category\": (\"\", \"hitomi\", \"tag\"),\n    \"#class\"   : hitomi.HitomiTagExtractor,\n},\n\n{\n    \"#url\"     : \"https://hitomi.la/character/a2-all-1.html\",\n    \"#category\": (\"\", \"hitomi\", \"tag\"),\n    \"#class\"   : hitomi.HitomiTagExtractor,\n},\n\n{\n    \"#url\"     : \"https://hitomi.la/index-japanese.html\",\n    \"#class\"   : hitomi.HitomiIndexExtractor,\n    \"#pattern\" : hitomi.HitomiGalleryExtractor.pattern,\n    \"#range\"   : \"1-150\",\n    \"#count\"   : 150,\n},\n\n{\n    \"#url\"     : \"https://hitomi.la/search.html?tag%3Ascreenshots%20language%3Ajapanese\",\n    \"#class\"   : hitomi.HitomiSearchExtractor,\n    \"#pattern\" : hitomi.HitomiGalleryExtractor.pattern,\n    \"#range\"   : \"1-150\",\n    \"#count\"   : 150,\n\n    \"search_tags\": \"tag:screenshots language:japanese\",\n},\n\n{\n    \"#url\"     : \"https://hitomi.la/search.html?female%3Asole_female%20language%3Ajapanese%20artist%3Asumiya\",\n    \"#class\"   : hitomi.HitomiSearchExtractor,\n    \"#pattern\" : hitomi.HitomiGalleryExtractor.pattern,\n    \"#count\"   : range(35, 50),\n\n    \"search_tags\": \"female:sole_female language:japanese artist:sumiya\",\n},\n\n{\n    \"#url\"     : \"https://hitomi.la/search.html?language%3Ajapanese%20-tag%3Aanimated%20group%3Aparadiddle#2\",\n    \"#comment\" : \"negative search tag (#7694)\",\n    \"#class\"   : hitomi.HitomiSearchExtractor,\n    \"#pattern\" : hitomi.HitomiGalleryExtractor.pattern,\n    \"#count\"   : 41,\n\n    \"search_tags\": \"language:japanese -tag:animated group:paradiddle\",\n},\n\n{\n    \"#url\"     : \"https://hitomi.la/search.html?group:initial_g\",\n    \"#class\"   : hitomi.HitomiSearchExtractor,\n},\n{\n    \"#url\"     : \"https://hitomi.la/search.html?series:amnesia\",\n    \"#class\"   : hitomi.HitomiSearchExtractor,\n},\n{\n    \"#url\"     : \"https://hitomi.la/search.html?type%3Adoujinshi\",\n    \"#class\"   : hitomi.HitomiSearchExtractor,\n},\n{\n    \"#url\"     : \"https://hitomi.la/search.html?character%3Aa2\",\n    \"#class\"   : hitomi.HitomiSearchExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/horne.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import nijie\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://horne.red/members.php?id=58000\",\n    \"#category\": (\"Nijie\", \"horne\", \"user\"),\n    \"#class\"   : nijie.NijieUserExtractor,\n    \"#results\" : (\n        \"https://horne.red/members_illust.php?id=58000\",\n        \"https://horne.red/members_dojin.php?id=58000\",\n    ),\n},\n\n{\n    \"#url\"     : \"https://horne.red/members_illust.php?id=58000\",\n    \"#category\": (\"Nijie\", \"horne\", \"illustration\"),\n    \"#class\"   : nijie.NijieIllustrationExtractor,\n    \"#pattern\" : r\"https://pic\\.nijie\\.net/__s4__/[0-9a-f]{184}\\.png\",\n    \"#range\"   : \"1-20\",\n    \"#count\"   : 20,\n\n    \"artist_id\"  : 58000,\n    \"artist_name\": \"のえるわ\",\n    \"date\"       : \"type:datetime\",\n    \"description\": str,\n    \"image_id\"   : int,\n    \"num\"        : int,\n    \"tags\"       : list,\n    \"title\"      : str,\n    \"url\"        : str,\n    \"user_id\"    : 58000,\n    \"user_name\"  : \"のえるわ\",\n},\n\n{\n    \"#url\"     : \"https://horne.red/members_dojin.php?id=58000\",\n    \"#category\": (\"Nijie\", \"horne\", \"doujin\"),\n    \"#class\"   : nijie.NijieDoujinExtractor,\n},\n\n{\n    \"#url\"     : \"https://horne.red/user_like_illust_view.php?id=58000\",\n    \"#category\": (\"Nijie\", \"horne\", \"favorite\"),\n    \"#class\"   : nijie.NijieFavoriteExtractor,\n    \"#range\"   : \"1-5\",\n    \"#count\"   : 5,\n\n    \"user_id\"  : 58000,\n    \"user_name\": \"のえるわ\",\n},\n\n{\n    \"#url\"     : \"https://horne.red/history_nuita.php?id=58000\",\n    \"#category\": (\"Nijie\", \"horne\", \"nuita\"),\n    \"#class\"   : nijie.NijieNuitaExtractor,\n},\n\n{\n    \"#url\"     : \"https://horne.red/like_user_view.php\",\n    \"#category\": (\"Nijie\", \"horne\", \"feed\"),\n    \"#class\"   : nijie.NijieFeedExtractor,\n},\n\n{\n    \"#url\"     : \"https://horne.red/like_my.php\",\n    \"#category\": (\"Nijie\", \"horne\", \"followed\"),\n    \"#class\"   : nijie.NijieFollowedExtractor,\n},\n\n{\n    \"#url\"     : \"https://horne.red/view.php?id=8708\",\n    \"#category\": (\"Nijie\", \"horne\", \"image\"),\n    \"#class\"   : nijie.NijieImageExtractor,\n    \"#results\" : \"https://pic.nijie.net/__s4__/d7e18cb679f35b388dc8b0b9f2edb178078469b8970ee099cd573a577bc3a84cf33a04581f20b1cbed44bcd86147348cccdf7ded3b974bfb5b2711c2afb27c67834a22bd7411aa43895c9f480bbbed7373d345c24ac55e36018ad065.png\",\n\n    \"artist_id\"  : 58000,\n    \"artist_name\": \"のえるわ\",\n    \"count\"      : 1,\n    \"date\"       : \"dt:2018-01-29 14:25:39\",\n    \"description\": \"前回とシチュがまるかぶり　\\r\\n竿野郎は塗るのだるかった\",\n    \"extension\"  : \"png\",\n    \"filename\"   : \"d7e18cb679f35b388dc8b0b9f2edb178078469b8970ee099cd573a577bc3a84cf33a04581f20b1cbed44bcd86147348cccdf7ded3b974bfb5b2711c2afb27c67834a22bd7411aa43895c9f480bbbed7373d345c24ac55e36018ad065\",\n    \"image_id\"   : 8708,\n    \"num\"        : 0,\n    \"tags\"       : [\n        \"男の娘\",\n        \"オリキャラ\",\n        \"うちのこ\",\n    ],\n    \"title\"      : \"うちのこえっち\",\n    \"url\"        : \"https://pic.nijie.net/__s4__/d7e18cb679f35b388dc8b0b9f2edb178078469b8970ee099cd573a577bc3a84cf33a04581f20b1cbed44bcd86147348cccdf7ded3b974bfb5b2711c2afb27c67834a22bd7411aa43895c9f480bbbed7373d345c24ac55e36018ad065.png\",\n    \"user_id\"    : 58000,\n    \"user_name\"  : \"のえるわ\",\n},\n\n{\n    \"#url\"     : \"https://horne.red/view.php?id=8716\",\n    \"#category\": (\"Nijie\", \"horne\", \"image\"),\n    \"#class\"   : nijie.NijieImageExtractor,\n    \"#results\" : (\n        \"https://pic.nijie.net/__s4__/d7b784b573f850628fcae5b4f7ede679085af22dba0caf6bb91622438289ddce9312ab6f2886b8d8ef104f8eb5908d57cc309ec0239464add9f9d8d17d83c39d92c680b083f107d63c68eef29938ebc937699fd50d51b1128404cc6e.png\",\n        \"https://pic.nijie.net/__s4__/d7b9dae222f25e388a9debb4ade7eb79b87af28c9650b1babe514902eb854bbd8cb295b379d5480ea83cad78fbc843b146785ba59f1279fa77eb14e33ca66778474afb5063ba36d9d527fa31f780fbf396b27bd9901b0e3565efce08.png\",\n        \"https://pic.nijie.net/__s4__/d7b889b276fb5a36dacde5bef7e8ea225e06c9904f18be00035c96721dd1ac6ab7217be29f8c6f58626aa9839835185f08474efd3822a3e298c74b9f2b3d0fc1cd5400b97b74512b0800eabbdcf8d4094be666ea3b6305a436cadd10.png\",\n        \"https://pic.nijie.net/__s4__/d7b6d9e173fc59368199ebbef6b9e728aea39dd452c9e7d83d78465e787922b5e3194ba54e58bbf753c33de2b3c9adf063b77b92e56a0b0c28c72e90d9f1d857562b87b2a836c083b5ff85709ebd9e93dd19774fa5a8d624133d4eda.png\",\n    ),\n\n    \"artist_id\"  : 58000,\n    \"artist_name\": \"のえるわ\",\n    \"count\"      : 4,\n    \"date\"       : \"dt:2018-02-04 14:47:24\",\n    \"description\": \"ノエル「そんなことしなくても、言ってくれたら咥えるのに・・・♡」\",\n    \"image_id\"   : 8716,\n    \"num\"        : range(0, 3),\n    \"tags\"       : [\n        \"男の娘\",\n        \"フェラ\",\n        \"オリキャラ\",\n        \"うちのこ\",\n    ],\n    \"title\"      : \"ノエル「いまどきそんな、恵方巻ネタなんてやらなくても・・・」\",\n    \"user_id\"    : 58000,\n    \"user_name\"  : \"のえるわ\",\n},\n\n)\n"
  },
  {
    "path": "test/results/hotleak.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import hotleak\nfrom gallery_dl import exception\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://hotleak.vip/kaiyakawaii/photo/1617145\",\n    \"#category\": (\"\", \"hotleak\", \"post\"),\n    \"#class\"   : hotleak.HotleakPostExtractor,\n    \"#results\" : \"https://image-cdn.hotleak.vip/storage/images/e98/18ad68/18ad68.webp\",\n\n    \"id\"       : 1617145,\n    \"creator\"  : \"kaiyakawaii\",\n    \"type\"     : \"photo\",\n    \"filename\" : \"18ad68\",\n    \"extension\": \"webp\",\n},\n\n{\n    \"#url\"     : \"https://hotleak.vip/lilmochidoll/video/1625538\",\n    \"#category\": (\"\", \"hotleak\", \"post\"),\n    \"#class\"   : hotleak.HotleakPostExtractor,\n    \"#pattern\" : r\"ytdl:https://cdn\\d+-leak\\.camhdxx\\.com/.+,\\d+/1661/1625538/index\\.m3u8\",\n\n    \"id\"       : 1625538,\n    \"creator\"  : \"lilmochidoll\",\n    \"type\"     : \"video\",\n    \"filename\" : \"index\",\n    \"extension\": \"mp4\",\n},\n\n{\n    \"#url\"     : \"https://hotleak.vip/kaiyakawaii\",\n    \"#category\": (\"\", \"hotleak\", \"creator\"),\n    \"#class\"   : hotleak.HotleakCreatorExtractor,\n    \"#range\"   : \"1-200\",\n    \"#count\"   : 200,\n},\n\n{\n    \"#url\"     : \"https://hotleak.vip/stellaviolet\",\n    \"#category\": (\"\", \"hotleak\", \"creator\"),\n    \"#class\"   : hotleak.HotleakCreatorExtractor,\n    \"#count\"   : \"> 600\",\n},\n\n{\n    \"#url\"     : \"https://hotleak.vip/doesnotexist\",\n    \"#category\": (\"\", \"hotleak\", \"creator\"),\n    \"#class\"   : hotleak.HotleakCreatorExtractor,\n    \"#exception\": exception.NotFoundError,\n},\n\n{\n    \"#url\"     : \"https://hotleak.vip/photos\",\n    \"#category\": (\"\", \"hotleak\", \"category\"),\n    \"#class\"   : hotleak.HotleakCategoryExtractor,\n    \"#pattern\" : hotleak.HotleakPostExtractor.pattern,\n    \"#range\"   : \"1-50\",\n    \"#count\"   : 50,\n},\n\n{\n    \"#url\"     : \"https://hotleak.vip/videos\",\n    \"#category\": (\"\", \"hotleak\", \"category\"),\n    \"#class\"   : hotleak.HotleakCategoryExtractor,\n},\n\n{\n    \"#url\"     : \"https://hotleak.vip/creators\",\n    \"#category\": (\"\", \"hotleak\", \"category\"),\n    \"#class\"   : hotleak.HotleakCategoryExtractor,\n    \"#pattern\" : hotleak.HotleakCreatorExtractor.pattern,\n    \"#range\"   : \"1-50\",\n    \"#count\"   : 50,\n},\n\n{\n    \"#url\"     : \"https://hotleak.vip/hot\",\n    \"#category\": (\"\", \"hotleak\", \"category\"),\n    \"#class\"   : hotleak.HotleakCategoryExtractor,\n},\n\n{\n    \"#url\"     : \"https://hotleak.vip/search?search=gallery-dl\",\n    \"#category\": (\"\", \"hotleak\", \"search\"),\n    \"#class\"   : hotleak.HotleakSearchExtractor,\n    \"#count\"   : 0,\n},\n\n{\n    \"#url\"     : \"https://hotleak.vip/search?search=hannah\",\n    \"#category\": (\"\", \"hotleak\", \"search\"),\n    \"#class\"   : hotleak.HotleakSearchExtractor,\n    \"#count\"   : \"> 30\",\n},\n\n)\n"
  },
  {
    "path": "test/results/hypnohub.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import gelbooru_v02\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://hypnohub.net/index.php?page=post&s=list&tags=gonoike_biwa\",\n    \"#category\": (\"gelbooru_v02\", \"hypnohub\", \"tag\"),\n    \"#class\"   : gelbooru_v02.GelbooruV02TagExtractor,\n    \"#sha1_url\": \"fe662b86d38c331fcac9c62af100167d404937dc\",\n},\n\n{\n    \"#url\"     : \"https://hypnohub.net/index.php?page=pool&s=show&id=61\",\n    \"#category\": (\"gelbooru_v02\", \"hypnohub\", \"pool\"),\n    \"#class\"   : gelbooru_v02.GelbooruV02PoolExtractor,\n    \"#count\"   : 3,\n    \"#sha1_url\": \"d314826280073441a2da609f70ee814d1f4b9407\",\n},\n\n{\n    \"#url\"     : \"https://hypnohub.net/index.php?page=favorites&s=view&id=43546\",\n    \"#category\": (\"gelbooru_v02\", \"hypnohub\", \"favorite\"),\n    \"#class\"   : gelbooru_v02.GelbooruV02FavoriteExtractor,\n    \"#count\"   : 3,\n},\n\n{\n    \"#url\"     : \"https://hypnohub.net/index.php?page=post&s=view&id=1439\",\n    \"#category\": (\"gelbooru_v02\", \"hypnohub\", \"post\"),\n    \"#class\"   : gelbooru_v02.GelbooruV02PostExtractor,\n    \"#options\"     : {\n        \"tags\" : True,\n        \"notes\": True,\n    },\n    \"#pattern\"     : r\"https://hypnohub\\.net/images/90/24/90245c3c5250c2a8173255d3923a010b\\.jpg\",\n    \"#sha1_content\": \"5987c5d2354f22e5fa9b7ee7ce4a6f7beb8b2b71\",\n\n    \"tags_artist\"   : \"brokenteapot\",\n    \"tags_character\": \"hsien-ko\",\n    \"tags_copyright\": \"capcom darkstalkers\",\n    \"tags_general\"  : str,\n    \"tags_metadata\" : \"dialogue text translated\",\n    \"notes\"         : [\n        {\n            \"body\"  : \"Master Master Master Master Master Master\",\n            \"height\": 83,\n            \"id\"    : 10577,\n            \"width\" : 129,\n            \"x\"     : 259,\n            \"y\"     : 20,\n        },\n        {\n            \"body\"  : \"Response Response Response Response Response Response\",\n            \"height\": 86,\n            \"id\"    : 10578,\n            \"width\" : 125,\n            \"x\"     : 126,\n            \"y\"     : 20,\n        },\n        {\n            \"body\"  : \"Obedience Obedience Obedience Obedience Obedience Obedience\",\n            \"height\": 80,\n            \"id\"    : 10579,\n            \"width\" : 98,\n            \"x\"     : 20,\n            \"y\"     : 20,\n        },\n    ],\n},\n\n)\n"
  },
  {
    "path": "test/results/idolcomplex.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import idolcomplex\nfrom gallery_dl import exception\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://www.idolcomplex.com/en/posts?tags=lyumos\",\n    \"#category\": (\"booru\", \"idolcomplex\", \"tag\"),\n    \"#class\"   : idolcomplex.IdolcomplexTagExtractor,\n    \"#pattern\" : r\"https://i[sv]\\.sankakucomplex\\.com/data/[^/]{2}/[^/]{2}/[^/]{32}\\.\\w+\\?e=\\d+&.+\",\n    \"#range\"   : \"18-22\",\n    \"#count\"   : 5,\n},\n\n{\n    \"#url\"     : \"https://www.idolcomplex.com/zh-CN/posts?tags=lyumos\",\n    \"#comment\" : \"locale code (ISO 639-1 + ISO 3166-1) (#8667)\",\n    \"#category\": (\"booru\", \"idolcomplex\", \"tag\"),\n    \"#class\"   : idolcomplex.IdolcomplexTagExtractor,\n},\n\n{\n    \"#url\"     : \"https://idolcomplex.com/posts?tags=lyumos\",\n    \"#category\": (\"booru\", \"idolcomplex\", \"tag\"),\n    \"#class\"   : idolcomplex.IdolcomplexTagExtractor,\n},\n\n{\n    \"#url\"     : \"https://idol.sankakucomplex.com/en/posts?tags=lyumos\",\n    \"#category\": (\"booru\", \"idolcomplex\", \"tag\"),\n    \"#class\"   : idolcomplex.IdolcomplexTagExtractor,\n},\n\n{\n    \"#url\"     : \"https://idol.sankakucomplex.com/posts/?tags=lyumos\",\n    \"#category\": (\"booru\", \"idolcomplex\", \"tag\"),\n    \"#class\"   : idolcomplex.IdolcomplexTagExtractor,\n},\n\n{\n    \"#url\"     : \"https://idol.sankakucomplex.com/en/?tags=lyumos\",\n    \"#category\": (\"booru\", \"idolcomplex\", \"tag\"),\n    \"#class\"   : idolcomplex.IdolcomplexTagExtractor,\n},\n\n{\n    \"#url\"     : \"https://idol.sankakucomplex.com/?tags=lyumos\",\n    \"#category\": (\"booru\", \"idolcomplex\", \"tag\"),\n    \"#class\"   : idolcomplex.IdolcomplexTagExtractor,\n},\n\n{\n    \"#url\"     : \"https://idol.sankakucomplex.com/?tags=lyumos+wreath&page=3&next=694215\",\n    \"#category\": (\"booru\", \"idolcomplex\", \"tag\"),\n    \"#class\"   : idolcomplex.IdolcomplexTagExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.idolcomplex.com/en/pools/e9PMwnwRBK3\",\n    \"#category\": (\"booru\", \"idolcomplex\", \"pool\"),\n    \"#class\"   : idolcomplex.IdolcomplexPoolExtractor,\n    \"#auth\"    : True,\n    \"#pattern\" : (\n        r\"https://iv.sankakucomplex.com/data/50/9e/509eccbba54a43cea6b275a65b93c51d\\.jpg\\?e=\\d+&expires=\\d+&m=.+\",\n        r\"https://iv.sankakucomplex.com/data/cf/ae/cfae655b594634126bddc10ba7965485\\.jpg\\?e=\\d+&expires=\\d+&m=.+\",\n        r\"https://iv.sankakucomplex.com/data/53/b3/53b3d915a79ac72747455f4d0e843fc0\\.jpg\\?e=\\d+&expires=\\d+&m=.+\",\n    ),\n},\n\n{\n    \"#url\"     : \"https://idol.sankakucomplex.com/en/pools/e9PMwnwRBK3\",\n    \"#category\": (\"booru\", \"idolcomplex\", \"pool\"),\n    \"#class\"   : idolcomplex.IdolcomplexPoolExtractor,\n},\n\n{\n    \"#url\"     : \"https://idol.sankakucomplex.com/en/pools/show/145\",\n    \"#category\": (\"booru\", \"idolcomplex\", \"pool\"),\n    \"#class\"   : idolcomplex.IdolcomplexPoolExtractor,\n},\n\n{\n    \"#url\"     : \"https://idol.sankakucomplex.com/pool/show/145\",\n    \"#category\": (\"booru\", \"idolcomplex\", \"pool\"),\n    \"#class\"   : idolcomplex.IdolcomplexPoolExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.idolcomplex.com/en/posts/vkr36qdOaZ4\",\n    \"#category\": (\"booru\", \"idolcomplex\", \"post\"),\n    \"#class\"   : idolcomplex.IdolcomplexPostExtractor,\n    \"#auth\"    : True,\n    \"#sha1_content\": \"694ec2491240787d75bf5d0c75d0082b53a85afd\",\n\n    \"audios\"          : [],\n    \"author\"          : {\n        \"avatar\"       : str,\n        \"avatar_rating\": \"q\",\n        \"display_name\" : \"kekal\",\n        \"id\"           : \"8YEa7e8RmD0\",\n        \"level\"        : 20,\n        \"name\"         : \"kekal\",\n    },\n    \"category\"        : \"idolcomplex\",\n    \"change\"          : 2121180,\n    \"comment_count\"   : None,\n    \"created_at\"      : 1511560888,\n    \"date\"            : \"dt:2017-11-24 22:01:28\",\n    \"extension\"       : \"jpg\",\n    \"fav_count\"       : range(90, 120),\n    \"file_ext\"        : \"jpg\",\n    \"file_size\"       : 97521,\n    \"file_type\"       : \"image/jpeg\",\n    \"file_url\"        : r\"re:https?://iv.sankakucomplex.com/data/50/9e/509eccbba54a43cea6b275a65b93c51d.jpg\\?e=\\d+&.+\",\n    \"filename\"        : \"509eccbba54a43cea6b275a65b93c51d\",\n    \"generation_directives\": None,\n    \"gif_preview_url\" : None,\n    \"has_children\"    : False,\n    \"has_comments\"    : False,\n    \"has_notes\"       : False,\n    \"height\"          : 683,\n    \"id\"              : \"vkr36qdOaZ4\",\n    \"in_visible_pool\" : True,\n    \"is_anonymous\"    : False,\n    \"is_favorited\"    : False,\n    \"is_note_locked\"  : False,\n    \"is_premium\"      : False,\n    \"is_rating_locked\": False,\n    \"is_restricted_anonymous_upload\": False,\n    \"is_status_locked\": False,\n    \"md5\"             : \"509eccbba54a43cea6b275a65b93c51d\",\n    \"parent_id\"       : None,\n    \"preview_height\"  : 400,\n    \"preview_url\"     : r\"re:https?://iv.sankakucomplex.com/data/preview/50/9e/509eccbba54a43cea6b275a65b93c51d.avif\\?e=\\d+&.+\",\n    \"preview_width\"   : 600,\n    \"rating\"          : \"s\",\n    \"reactions\"       : [],\n    \"redirect_to_signup\": False,\n    \"sample_height\"   : 683,\n    \"sample_url\"      : r\"re:https?://iv.sankakucomplex.com/data/50/9e/509eccbba54a43cea6b275a65b93c51d.jpg\\?e=\\d+&.+\",\n    \"sample_width\"    : 1024,\n    \"sequence\"        : None,\n    \"source\"          : \"removed\",\n    \"status\"          : \"active\",\n    \"subtitles\"       : [],\n    \"tag_string\"      : \"lyumos the_witcher shani_(the_witcher) cosplay waistcoat wreath female green_eyes non-asian red_hair 1girl 3:2_aspect_ratio tagme\",\n    \"tags\"            : [\n        \"lyumos\",\n        \"the_witcher\",\n        \"shani_(the_witcher)\",\n        \"cosplay\",\n        \"waistcoat\",\n        \"wreath\",\n        \"female\",\n        \"green_eyes\",\n        \"non-asian\",\n        \"red_hair\",\n        \"1girl\",\n        \"3:2_aspect_ratio\",\n        \"tagme\",\n    ],\n    \"total_score\"     : range(120, 150),\n    \"total_tags\"      : 13,\n    \"user_vote\"       : None,\n    \"video_duration\"  : None,\n    \"vote_count\"      : range(25, 50),\n    \"width\"           : 1024,\n},\n\n{\n    \"#url\"     : \"https://idol.sankakucomplex.com/en/posts/vkr36qdOaZ4\",\n    \"#category\": (\"booru\", \"idolcomplex\", \"post\"),\n    \"#class\"   : idolcomplex.IdolcomplexPostExtractor,\n},\n\n{\n    \"#url\"     : \"https://idol.sankakucomplex.com/en/posts/509eccbba54a43cea6b275a65b93c51d\",\n    \"#category\": (\"booru\", \"idolcomplex\", \"post\"),\n    \"#class\"   : idolcomplex.IdolcomplexPostExtractor,\n},\n\n{\n    \"#url\"     : \"https://idol.sankakucomplex.com/en/posts/show/509eccbba54a43cea6b275a65b93c51d\",\n    \"#category\": (\"booru\", \"idolcomplex\", \"post\"),\n    \"#class\"   : idolcomplex.IdolcomplexPostExtractor,\n},\n\n{\n    \"#url\"     : \"https://idol.sankakucomplex.com/posts/509eccbba54a43cea6b275a65b93c51d\",\n    \"#category\": (\"booru\", \"idolcomplex\", \"post\"),\n    \"#class\"   : idolcomplex.IdolcomplexPostExtractor,\n},\n\n{\n    \"#url\"     : \"https://idol.sankakucomplex.com/post/show/694215\",\n    \"#category\": (\"booru\", \"idolcomplex\", \"post\"),\n    \"#class\"   : idolcomplex.IdolcomplexPostExtractor,\n    \"#exception\": exception.AbortExtraction,\n    \"#sha1_content\": \"694ec2491240787d75bf5d0c75d0082b53a85afd\",\n\n    \"id\"            : \"vkr36qdOaZ4\",  # legacy ID: 694215\n    \"tags_character\": \"shani_(the_witcher)\",\n    \"tags_copyright\": \"the_witcher\",\n    \"tags_idol\"     : str,\n    \"tags_medium\"   : str,\n    \"tags_general\"  : str,\n},\n\n)\n"
  },
  {
    "path": "test/results/illusioncardsbooru.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import gelbooru_v01\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://illusioncards.booru.org/index.php?page=post&s=list&tags=koikatsu\",\n    \"#category\": (\"gelbooru_v01\", \"illusioncardsbooru\", \"tag\"),\n    \"#class\"   : gelbooru_v01.GelbooruV01TagExtractor,\n    \"#range\"   : \"1-25\",\n    \"#count\"   : 25,\n},\n\n{\n    \"#url\"     : \"https://illusioncards.booru.org/index.php?page=favorites&s=view&id=84887\",\n    \"#category\": (\"gelbooru_v01\", \"illusioncardsbooru\", \"favorite\"),\n    \"#class\"   : gelbooru_v01.GelbooruV01FavoriteExtractor,\n    \"#count\"   : 2,\n},\n\n{\n    \"#url\"     : \"https://illusioncards.booru.org/index.php?page=post&s=view&id=82746\",\n    \"#category\": (\"gelbooru_v01\", \"illusioncardsbooru\", \"post\"),\n    \"#class\"   : gelbooru_v01.GelbooruV01PostExtractor,\n    \"#sha1_url\"    : \"3f9cd2fadf78869b90bc5422f27b48f1af0e0909\",\n    \"#sha1_content\": \"159e60b92d05597bd1bb63510c2c3e4a4bada1dc\",\n},\n\n)\n"
  },
  {
    "path": "test/results/imagebam.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import imagebam\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://www.imagebam.com/gallery/adz2y0f9574bjpmonaismyrhtjgvey4o\",\n    \"#class\"   : imagebam.ImagebamGalleryExtractor,\n    \"#sha1_url\"     : \"76d976788ae2757ac81694736b07b72356f5c4c8\",\n    \"#sha1_metadata\": \"b048478b1bbba3072a7fa9fcc40630b3efad1f6c\",\n    \"#sha1_content\" : \"596e6bfa157f2c7169805d50075c2986549973a8\",\n},\n\n{\n    \"#url\"     : \"http://www.imagebam.com/gallery/op9dwcklwdrrguibnkoe7jxgvig30o5p\",\n    \"#class\"   : imagebam.ImagebamGalleryExtractor,\n    \"#count\"   : 107,\n    \"#sha1_url\": \"32ae6fe5dc3e4ca73ff6252e522d16473595d1d1\",\n},\n\n{\n    \"#url\"     : \"http://www.imagebam.com/gallery/gsl8teckymt4vbvx1stjkyk37j70va2c\",\n    \"#comment\" : \"'The page could not be found'\",\n    \"#class\"   : imagebam.ImagebamGalleryExtractor,\n    \"#exception\": \"NotFoundError\",\n},\n\n{\n    \"#url\"     : \"https://www.imagebam.com/view/GA3MT1\",\n    \"#comment\" : \"/view/ path (#2378)\",\n    \"#class\"   : imagebam.ImagebamGalleryExtractor,\n    \"#sha1_url\"     : \"35018ce1e00a2d2825a33d3cd37857edaf804919\",\n    \"#sha1_metadata\": \"3a9f98178f73694c527890c0d7ca9a92b46987ba\",\n},\n\n{\n    \"#url\"     : \"https://www.imagebam.com/image/94d56c502511890\",\n    \"#class\"   : imagebam.ImagebamImageExtractor,\n    \"#sha1_url\"     : \"5e9ba3b1451f8ded0ae3a1b84402888893915d4a\",\n    \"#sha1_metadata\": \"2a4380d4b57554ff793898c2d6ec60987c86d1a1\",\n    \"#sha1_content\" : \"0c8768055e4e20e7c7259608b67799171b691140\",\n},\n\n{\n    \"#url\"     : \"http://images3.imagebam.com/1d/8c/44/94d56c502511890.png\",\n    \"#class\"   : imagebam.ImagebamImageExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.imagebam.com/image/0850951366904951\",\n    \"#comment\" : \"NSFW (#1534)\",\n    \"#class\"   : imagebam.ImagebamImageExtractor,\n    \"#sha1_url\": \"d37297b17ed1615b4311c8ed511e50ce46e4c748\",\n},\n\n{\n    \"#url\"     : \"https://www.imagebam.com/view/ME8JOQP\",\n    \"#comment\" : \"/view/ path (#2378)\",\n    \"#class\"   : imagebam.ImagebamImageExtractor,\n    \"#sha1_url\"     : \"4dca72bbe61a0360185cf4ab2bed8265b49565b8\",\n    \"#sha1_metadata\": \"15a494c02fd30846b41b42a26117aedde30e4ceb\",\n    \"#sha1_content\" : \"f81008666b17a42d8834c4749b910e1dc10a6e83\",\n},\n\n{\n    \"#url\"     : \"https://www.imagebam.com/image/b728aa119132443\",\n    \"#comment\" : \"filename without extension (#8476)\",\n    \"#class\"   : imagebam.ImagebamImageExtractor,\n    \"#results\" : \"https://images3.imagebam.com/d2/7a/d9/b728aa119132443.jpg\",\n\n    \"extension\": \"\",\n    \"filename\" : \"34415_AlessandraAmbrosio_PhotoshootforVictoriasSecretwearingbikinislingerieonthebeachinMalibuCalifor\",\n    \"image_key\": \"b728aa119132443\",\n    \"url\"      : \"https://images3.imagebam.com/d2/7a/d9/b728aa119132443.jpg\",\n},\n\n{\n    \"#url\"      : \"https://www.imagebam.com/image/40a481151474621\",\n    \"#comment\"  : \"deleted image (#8890)\",\n    \"#class\"    : imagebam.ImagebamImageExtractor,\n    \"#exception\": \"NotFoundError\",\n},\n\n)\n"
  },
  {
    "path": "test/results/imagechest.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import imagechest\nfrom gallery_dl import exception\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://imgchest.com/p/3na7kr3by8d\",\n    \"#category\": (\"\", \"imagechest\", \"gallery\"),\n    \"#class\"   : imagechest.ImagechestGalleryExtractor,\n    \"#pattern\"     : r\"https://cdn\\.imgchest\\.com/files/\\w+\\.(jpg|png)\",\n    \"#count\"       : 3,\n    \"#sha1_url\"    : \"7328ca4ec2459378d725e3be19f661d2b045feda\",\n    \"#sha1_content\": \"076959e65be30249a2c651fbe6090dc30ba85193\",\n\n    \"count\"     : 3,\n    \"gallery_id\": \"3na7kr3by8d\",\n    \"num\"       : int,\n    \"title\"     : \"Wizardry - Video Game From The Mid 80's\",\n},\n\n{\n    \"#url\"     : \"https://imgchest.com/p/9p4n3q2z7nq\",\n    \"#comment\" : \"'Load More Files' button (#4028)\",\n    \"#category\": (\"\", \"imagechest\", \"gallery\"),\n    \"#class\"   : imagechest.ImagechestGalleryExtractor,\n    \"#pattern\" : r\"https://cdn\\.imgchest\\.com/files/\\w+\\.(jpg|png)\",\n    \"#count\"   : 52,\n    \"#sha1_url\": \"f5674e8ba79d336193c9f698708d9dcc10e78cc7\",\n},\n\n{\n    \"#url\"     : \"https://imgchest.com/p/xxxxxxxxxxx\",\n    \"#category\": (\"\", \"imagechest\", \"gallery\"),\n    \"#class\"   : imagechest.ImagechestGalleryExtractor,\n    \"#exception\": exception.NotFoundError,\n},\n\n{\n    \"#url\"     : \"https://imgchest.com/u/LunarLandr\",\n    \"#category\": (\"\", \"imagechest\", \"user\"),\n    \"#class\"   : imagechest.ImagechestUserExtractor,\n    \"#pattern\" : imagechest.ImagechestGalleryExtractor.pattern,\n    \"#count\"   : range(280, 290),\n},\n\n)\n"
  },
  {
    "path": "test/results/imagefap.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import imagefap\nfrom gallery_dl import exception\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://www.imagefap.com/gallery/7102714\",\n    \"#category\": (\"\", \"imagefap\", \"gallery\"),\n    \"#class\"   : imagefap.ImagefapGalleryExtractor,\n    \"#exception\": exception.HttpError,\n},\n\n{\n    \"#url\"     : \"https://www.imagefap.com/gallery/7876223\",\n    \"#category\": (\"\", \"imagefap\", \"gallery\"),\n    \"#class\"   : imagefap.ImagefapGalleryExtractor,\n    \"#pattern\" : r\"https://cdn[ch]?\\.imagefap\\.com/images/full/\\d+/\\d+/\\d+\\.jpg\",\n    \"#count\"   : 44,\n\n    \"categories\" : [\n        \"Asses\",\n        \"Softcore\",\n        \"Pornstars\",\n    ],\n    \"count\"      : 44,\n    \"description\": \"\",\n    \"gallery_id\" : 7876223,\n    \"image_id\"   : int,\n    \"num\"        : int,\n    \"tags\"       : [\n        \"big ass\",\n        \"panties\",\n        \"horny\",\n        \"pussy\",\n        \"exposed\",\n        \"outdoor\",\n    ],\n    \"title\"      : \"Kelsi Monroe in lingerie\",\n    \"uploader\"   : \"BdRachel\",\n},\n\n{\n    \"#url\"     : \"https://www.imagefap.com/gallery/6706356\",\n    \"#comment\" : \"description (#3905)\",\n    \"#category\": (\"\", \"imagefap\", \"gallery\"),\n    \"#class\"   : imagefap.ImagefapGalleryExtractor,\n    \"#range\"   : \"1\",\n\n    \"categories\" : [\n        \"Lesbian\",\n        \"Fetish\",\n        \"Animated GIFS\",\n    ],\n    \"count\"      : 75,\n    \"description\": \"A mixed collection of pics and gifs depicting lesbian femdom.\\n\\nAll images originally found on various Tumblr blogs and through the internet.\\n\\nObviously I don't own any of the images so if you do and you would like them removed please just let me know and I shall remove them straight away.\",\n    \"gallery_id\" : 6706356,\n    \"tags\"       : [\n        \"lesbian\",\n        \"femdom\",\n        \"lesbian femdom\",\n        \"lezdom\",\n        \"dominant women\",\n        \"submissive women\",\n    ],\n    \"title\"      : \"Lezdom, Lesbian Femdom, Lesbian Domination - 3\",\n    \"uploader\"   : \"pussysimon\",\n},\n\n{\n    \"#url\"     : \"https://www.imagefap.com/pictures/7102714\",\n    \"#category\": (\"\", \"imagefap\", \"gallery\"),\n    \"#class\"   : imagefap.ImagefapGalleryExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.imagefap.com/gallery.php?gid=7102714\",\n    \"#category\": (\"\", \"imagefap\", \"gallery\"),\n    \"#class\"   : imagefap.ImagefapGalleryExtractor,\n},\n\n{\n    \"#url\"     : \"https://beta.imagefap.com/gallery.php?gid=7102714\",\n    \"#category\": (\"\", \"imagefap\", \"gallery\"),\n    \"#class\"   : imagefap.ImagefapGalleryExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.imagefap.com/photo/1962981893\",\n    \"#category\": (\"\", \"imagefap\", \"image\"),\n    \"#class\"   : imagefap.ImagefapImageExtractor,\n    \"#pattern\" : r\"https://cdn[ch]?\\.imagefap\\.com/images/full/65/196/1962981893\\.jpg\",\n\n    \"date\"      : \"21/08/2014\",\n    \"gallery_id\": 7876223,\n    \"height\"    : 1600,\n    \"image_id\"  : 1962981893,\n    \"title\"     : \"Kelsi Monroe in lingerie\",\n    \"uploader\"  : \"BdRachel\",\n    \"width\"     : 1066,\n},\n\n{\n    \"#url\"     : \"https://beta.imagefap.com/photo/1962981893\",\n    \"#category\": (\"\", \"imagefap\", \"image\"),\n    \"#class\"   : imagefap.ImagefapImageExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.imagefap.com/organizer/409758\",\n    \"#category\": (\"\", \"imagefap\", \"folder\"),\n    \"#class\"   : imagefap.ImagefapFolderExtractor,\n    \"#pattern\" : r\"https://www\\.imagefap\\.com/gallery/7876223\",\n    \"#count\"   : 1,\n    \"#sha1_url\": \"37822523e6e4a56feb9dea35653760c86b44ff89\",\n},\n\n{\n    \"#url\"     : \"https://www.imagefap.com/organizer/613950/Grace-Stout\",\n    \"#category\": (\"\", \"imagefap\", \"folder\"),\n    \"#class\"   : imagefap.ImagefapFolderExtractor,\n    \"#pattern\" : imagefap.ImagefapGalleryExtractor.pattern,\n    \"#count\"   : 31,\n\n    \"title\": r\"re:Grace Stout .+\",\n},\n\n{\n    \"#url\"     : \"https://www.imagefap.com/usergallery.php?userid=1981976&folderid=409758\",\n    \"#category\": (\"\", \"imagefap\", \"folder\"),\n    \"#class\"   : imagefap.ImagefapFolderExtractor,\n    \"#results\" : \"https://www.imagefap.com/gallery/7876223\",\n\n    \"folder\"    : \"Softcore\",\n    \"gallery_id\": \"7876223\",\n    \"title\"     : \"Kelsi Monroe in lingerie\",\n},\n\n{\n    \"#url\"     : \"https://www.imagefap.com/usergallery.php?user=BdRachel&folderid=409758\",\n    \"#category\": (\"\", \"imagefap\", \"folder\"),\n    \"#class\"   : imagefap.ImagefapFolderExtractor,\n    \"#sha1_url\": \"37822523e6e4a56feb9dea35653760c86b44ff89\",\n},\n\n{\n    \"#url\"     : \"https://www.imagefap.com/profile/BdRachel/galleries?folderid=-1\",\n    \"#category\": (\"\", \"imagefap\", \"folder\"),\n    \"#class\"   : imagefap.ImagefapFolderExtractor,\n    \"#pattern\" : imagefap.ImagefapGalleryExtractor.pattern,\n    \"#range\"   : \"1-40\",\n\n    \"folder\": \"Uncategorized\",\n},\n\n{\n    \"#url\"     : \"https://www.imagefap.com/usergallery.php?userid=1981976&folderid=-1\",\n    \"#category\": (\"\", \"imagefap\", \"folder\"),\n    \"#class\"   : imagefap.ImagefapFolderExtractor,\n    \"#pattern\" : imagefap.ImagefapGalleryExtractor.pattern,\n    \"#range\"   : \"1-40\",\n},\n\n{\n    \"#url\"     : \"https://www.imagefap.com/usergallery.php?user=BdRachel&folderid=-1\",\n    \"#category\": (\"\", \"imagefap\", \"folder\"),\n    \"#class\"   : imagefap.ImagefapFolderExtractor,\n    \"#pattern\" : imagefap.ImagefapGalleryExtractor.pattern,\n    \"#range\"   : \"1-40\",\n},\n\n{\n    \"#url\"     : \"https://www.imagefap.com/profile/BdRachel\",\n    \"#category\": (\"\", \"imagefap\", \"user\"),\n    \"#class\"   : imagefap.ImagefapUserExtractor,\n    \"#pattern\" : imagefap.ImagefapFolderExtractor.pattern,\n    \"#count\"   : \">= 18\",\n},\n\n{\n    \"#url\"     : \"https://www.imagefap.com/usergallery.php?userid=1862791\",\n    \"#category\": (\"\", \"imagefap\", \"user\"),\n    \"#class\"   : imagefap.ImagefapUserExtractor,\n    \"#pattern\" : r\"https://www\\.imagefap\\.com/profile/LucyRae/galleries\\?folderid=-1\",\n    \"#count\"   : 1,\n},\n\n{\n    \"#url\"     : \"https://www.imagefap.com/profile/BdRachel/galleries\",\n    \"#category\": (\"\", \"imagefap\", \"user\"),\n    \"#class\"   : imagefap.ImagefapUserExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.imagefap.com/profile.php?user=BdRachel\",\n    \"#category\": (\"\", \"imagefap\", \"user\"),\n    \"#class\"   : imagefap.ImagefapUserExtractor,\n},\n\n{\n    \"#url\"     : \"https://beta.imagefap.com/profile.php?user=BdRachel\",\n    \"#category\": (\"\", \"imagefap\", \"user\"),\n    \"#class\"   : imagefap.ImagefapUserExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.imagefap.com/profile/brookdale\",\n    \"#comment\" : \"multiple pagea (#9016)\",\n    \"#class\"   : imagefap.ImagefapUserExtractor,\n    \"#pattern\" : imagefap.ImagefapFolderExtractor.pattern,\n    \"#range\"   : \"1-100\",\n    \"#count\"   : 100,\n},\n\n{\n    \"#url\"     : \"https://www.imagefap.com/profile/Tiffany_and_me\",\n    \"#comment\" : \"empty profile (#9034)\",\n    \"#class\"   : imagefap.ImagefapUserExtractor,\n    \"#count\"   : 0,\n},\n\n{\n    \"#url\"     : \"https://www.imagefap.com/profile/Tiffany_and_me/galleries?folderid=0\",\n    \"#class\"   : imagefap.ImagefapUserExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/imagepond.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import imagepond\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://www.imagepond.net/i/chv_317512\",\n    \"#category\": (\"chevereto\", \"imagepond\", \"file\"),\n    \"#class\"   : imagepond.ImagepondFileExtractor,\n    \"#results\" : \"https://media.imagepond.net/media/IMG_20250217_1606226b345a5dbd0e8971.jpg\",\n\n    \"album\"    : \"\",\n    \"album_id\" : \"\",\n    \"date\"     : \"dt:2025-02-17 00:00:00\",\n    \"extension\": \"jpg\",\n    \"filename\" : \"IMG_20250217_1606226b345a5dbd0e8971\",\n    \"height\"   : 976,\n    \"id\"       : \"chv_317512\",\n    \"mime\"     : \"image/jpg\",\n    \"title\"    : \"IMG_20250217_160622.jpg\",\n    \"type\"     : \"image\",\n    \"url\"      : \"https://media.imagepond.net/media/IMG_20250217_1606226b345a5dbd0e8971.jpg\",\n    \"user\"     : \"dariusbbb24\",\n    \"width\"    : 720,\n},\n\n{\n    \"#url\"     : \"https://imagepond.net/image/IMG-20250217-160622.TJNphg\",\n    \"#category\": (\"chevereto\", \"imagepond\", \"file\"),\n    \"#class\"   : imagepond.ImagepondFileExtractor,\n    \"#results\"     : \"https://media.imagepond.net/media/IMG_20250217_1606226b345a5dbd0e8971.jpg\",\n    \"#sha1_content\": \"ec7fac6b427f7af01038619208cd69478e91ddef\",\n\n    \"album\"    : \"\",\n    \"date\"     : \"dt:2025-02-17 00:00:00\",\n    \"extension\": \"jpg\",\n    \"filename\" : \"IMG_20250217_1606226b345a5dbd0e8971\",\n    \"id\"       : \"chv_317512\",\n    \"mime\"     : \"image/jpg\",\n    \"title\"    : \"IMG_20250217_160622.jpg\",\n    \"type\"     : \"image\",\n    \"url\"      : \"https://media.imagepond.net/media/IMG_20250217_1606226b345a5dbd0e8971.jpg\",\n    \"user\"     : \"dariusbbb24\",\n},\n\n{\n    \"#url\"     : \"https://www.imagepond.net/image/IMG-20250217-160622.TJNphg\",\n    \"#category\": (\"chevereto\", \"imagepond\", \"file\"),\n    \"#class\"   : imagepond.ImagepondFileExtractor,\n},\n\n{\n    \"#url\"     : \"https://imagepond.net/video/1000423939.zb8Fxy\",\n    \"#category\": (\"chevereto\", \"imagepond\", \"file\"),\n    \"#class\"   : imagepond.ImagepondFileExtractor,\n    \"#results\" : \"https://media.imagepond.net/media/100042393993a6bfa75fc505e9.mp4\",\n\n    \"album\"    : \"\",\n    \"album_id\" : \"\",\n    \"date\"     : \"dt:2025-08-29 00:00:00\",\n    \"duration\" : 7,\n    \"extension\": \"mp4\",\n    \"filename\" : \"100042393993a6bfa75fc505e9\",\n    \"height\"   : 1280,\n    \"id\"       : \"chv_787880\",\n    \"thumbnail\": \"https://media.imagepond.net/media/./100042393993a6bfa75fc505e9_thumb.jpg\",\n    \"title\"    : \"1000423939.mp4\",\n    \"type\"     : \"video\",\n    \"url\"      : \"https://media.imagepond.net/media/100042393993a6bfa75fc505e9.mp4\",\n    \"user\"     : \"christiankita\",\n    \"width\"    : 720,\n},\n\n{\n    \"#url\"     : \"https://www.imagepond.net/a/chv_2822\",\n    \"#category\": (\"chevereto\", \"imagepond\", \"album\"),\n    \"#class\"   : imagepond.ImagepondAlbumExtractor,\n    \"#pattern\" : imagepond.ImagepondFileExtractor.pattern,\n    \"#count\"   : 86,\n\n    \"album\": \"Aline Torres\",\n    \"count\": 86,\n    \"num\"  : range(1, 86),\n},\n\n{\n    \"#url\"     : \"https://imagepond.net/album/CDilP/?sort=date_desc&page=1\",\n    \"#category\": (\"chevereto\", \"imagepond\", \"album\"),\n    \"#class\"   : imagepond.ImagepondAlbumExtractor,\n},\n\n{\n    \"#url\"     : \"https://imagepond.net/dariusbbb24\",\n    \"#category\": (\"chevereto\", \"imagepond\", \"user\"),\n    \"#class\"   : imagepond.ImagepondUserExtractor,\n    \"#auth\"    : \"cookies\",\n    \"#range\"   : \"1-30\",\n    \"#count\"   : 30,\n},\n\n{\n    \"#url\"     : \"https://imagepond.net/ap000\",\n    \"#comment\" : \"username starting with 'a' (#8149)\",\n    \"#category\": (\"chevereto\", \"imagepond\", \"user\"),\n    \"#class\"   : imagepond.ImagepondUserExtractor,\n    \"#auth\"    : \"cookies\",\n},\n\n)\n"
  },
  {
    "path": "test/results/imagetwist.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import imagehosts\nfrom gallery_dl import exception\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://imagetwist.com/f1i2s4vhvbrq/test.png\",\n    \"#category\": (\"imagehost\", \"imagetwist\", \"image\"),\n    \"#class\"   : imagehosts.ImagetwistImageExtractor,\n    \"#sha1_url\"     : \"8d5e168c0bee30211f821c6f3b2116e419d42671\",\n    \"#sha1_content\" : \"0c8768055e4e20e7c7259608b67799171b691140\",\n\n    \"filename\" : \"test\",\n    \"extension\": \"png\",\n    \"token\"    : \"f1i2s4vhvbrq\",\n    \"post_url\" : \"https://imagetwist.com/f1i2s4vhvbrq\",\n},\n\n{\n    \"#url\"     : \"https://www.imagetwist.com/f1i2s4vhvbrq/test.png\",\n    \"#category\": (\"imagehost\", \"imagetwist\", \"image\"),\n    \"#class\"   : imagehosts.ImagetwistImageExtractor,\n},\n\n{\n    \"#url\"     : \"https://phun.imagetwist.com/f1i2s4vhvbrq/test.png\",\n    \"#category\": (\"imagehost\", \"imagetwist\", \"image\"),\n    \"#class\"   : imagehosts.ImagetwistImageExtractor,\n},\n\n{\n    \"#url\"     : \"https://imagehaha.com/f1i2s4vhvbrq/test.png\",\n    \"#category\": (\"imagehost\", \"imagetwist\", \"image\"),\n    \"#class\"   : imagehosts.ImagetwistImageExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.imagehaha.com/f1i2s4vhvbrq/test.png\",\n    \"#category\": (\"imagehost\", \"imagetwist\", \"image\"),\n    \"#class\"   : imagehosts.ImagetwistImageExtractor,\n},\n\n{\n    \"#url\"     : \"https://imagetwist.com/tynhxt4ay9rl/9g09tq0e2i1b.jpg\",\n    \"#comment\" : \"'Image not found' (#8415)\",\n    \"#category\": (\"imagehost\", \"imagetwist\", \"image\"),\n    \"#class\"   : imagehosts.ImagetwistImageExtractor,\n    \"#exception\": exception.NotFoundError,\n},\n\n{\n    \"#url\"     : \"https://imagetwist.com/p/gdldev/747223/digits\",\n    \"#category\": (\"imagehost\", \"imagetwist\", \"gallery\"),\n    \"#class\"   : imagehosts.ImagetwistGalleryExtractor,\n    \"#results\" : (\n        \"https://imagetwist.com/j6eu91sbl9bs\",\n        \"https://imagetwist.com/vx4oh119izyr\",\n        \"https://imagetwist.com/n3td3a6vzzed\",\n        \"https://imagetwist.com/8uz6lmg31nmc\",\n    ),\n\n    \"gallery_id\"   : \"747223\",\n    \"gallery_title\": \"digits\",\n},\n\n{\n    \"#url\"     : \"https://imagetwist.com/p/gdldev/806105/multi\",\n    \"#comment\" : \"multiple pages (#8826)\",\n    \"#category\": (\"imagehost\", \"imagetwist\", \"gallery\"),\n    \"#class\"   : imagehosts.ImagetwistGalleryExtractor,\n    \"#pattern\" : imagehosts.ImagetwistImageExtractor.pattern,\n    \"#count\"   : 100,\n\n    \"gallery_id\"   : \"806105\",\n    \"gallery_title\": \"multi\",\n},\n\n{\n    \"#url\"     : \"https://imagetwist.com/?op=user_public&per_page=40&fld_id=806105&usr_login=gdldev&page=2\",\n    \"#comment\" : \"'page=' URL (#8826)\",\n    \"#category\": (\"imagehost\", \"imagetwist\", \"gallery\"),\n    \"#class\"   : imagehosts.ImagetwistGalleryExtractor,\n    \"#pattern\" : imagehosts.ImagetwistImageExtractor.pattern,\n    \"#count\"   : 60,\n\n    \"gallery_id\"   : \"806105\",\n    \"gallery_title\": \"multi\",\n},\n\n)\n"
  },
  {
    "path": "test/results/imagevenue.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import imagehosts\nfrom gallery_dl import exception\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://www.imagevenue.com/ME13LS07\",\n    \"#category\": (\"imagehost\", \"imagevenue\", \"image\"),\n    \"#class\"   : imagehosts.ImagevenueImageExtractor,\n    \"#results\"      : \"https://cdn-images.imagevenue.com/10/ac/05/ME13LS07_o.png\",\n    \"#sha1_metadata\": \"ae15d6e3b2095f019eee84cd896700cd34b09c36\",\n    \"#sha1_content\" : \"cfaa8def53ed1a575e0c665c9d6d8cf2aac7a0ee\",\n\n    \"extension\": \"png\",\n    \"filename\" : \"test-テスト-\\\"&>\",\n    \"token\"    : \"ME13LS07\",\n},\n\n{\n    \"#url\"     : \"https://www.imagevenue.com/view/o?i=92518_13732377annakarina424200712535AM_122_486lo.jpg&h=img150&l=loc486\",\n    \"#category\": (\"imagehost\", \"imagevenue\", \"image\"),\n    \"#class\"   : imagehosts.ImagevenueImageExtractor,\n    \"#results\" : \"https://cdno-data.imagevenue.com/html.img150/upload2328/loc486/92518_13732377annakarina424200712535AM_122_486lo.jpg\",\n    \"#sha1_url\": \"8bf0254e29250d8f5026c0105bbdda3ee3d84980\",\n},\n\n{\n    \"#url\"     : \"http://img28116.imagevenue.com/img.php?image=th_52709_test_122_64lo.jpg\",\n    \"#category\": (\"imagehost\", \"imagevenue\", \"image\"),\n    \"#class\"   : imagehosts.ImagevenueImageExtractor,\n    \"#results\" : \"https://cdno-data.imagevenue.com/html.img8116/upload2328/loc64/th_52709_test_122_64lo.jpg\",\n    \"#sha1_url\": \"f98e3091df7f48a05fb60fbd86f789fc5ec56331\",\n},\n\n{\n    \"#url\"     : \"http://img159.imagevenue.com/img.php?image=73874_203_123_83lo.jpg\",\n    \"#comment\" : \"404 image (#7570)\",\n    \"#category\": (\"imagehost\", \"imagevenue\", \"image\"),\n    \"#class\"   : imagehosts.ImagevenueImageExtractor,\n    \"#results\"     : \"https://cdno-data.imagevenue.com/html.img159/upload2328/loc83/73874_203_123_83lo.jpg\",\n    \"#sha1_content\": \"da39a3ee5e6b4b0d3255bfef95601890afd80709\",\n},\n\n{\n    \"#url\"     : \"http://img42.imagevenue.com/img.php?loc=loc1003%E2%84%91=20377_Alessandra_Ambrosio_Celebrity_City_Arriving_Mokai_Nightclub_17_13_1003lo.jpg\",\n    \"#comment\" : \"dead link / '404 Image Unavailable' redirect (#8477)\",\n    \"#category\": (\"imagehost\", \"imagevenue\", \"image\"),\n    \"#class\"   : imagehosts.ImagevenueImageExtractor,\n    \"#exception\": exception.NotFoundError,\n},\n\n)\n"
  },
  {
    "path": "test/results/imgadult.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import imagehosts\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://imgadult.com/img-686b4f451af05.html\",\n    \"#class\"   : imagehosts.ImgadultImageExtractor,\n    \"#results\"      : \"https://imgadult.com/upload/big/2025/07/07/686b4f451af04.jpg\",\n    \"#sha1_content\" : \"6df0034ce96ad2347926037abe2dd0085ebc8d66\",\n\n    \"extension\": \"jpg\",\n    \"filename\" : \"1612\",\n    \"token\"    : \"686b4f451af05\",\n},\n\n)\n"
  },
  {
    "path": "test/results/imgbb.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import imgbb\nfrom gallery_dl import exception\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://ibb.co/album/i5PggF\",\n    \"#category\": (\"\", \"imgbb\", \"album\"),\n    \"#class\"   : imgbb.ImgbbAlbumExtractor,\n    \"#patten\"       : r\"https://i\\.ibb\\.co/\\w{7}/[\\w-]+\\.jpg\",\n    \"#count\"        : 91,\n    \"#sha1_url\"     : \"efe7e5a76531436e3b82c87e4ebd34c4dfeb484c\",\n\n    \"extension\": \"jpg\",\n    \"filename\" : str,\n    \"id\"       : r\"re:^\\w{7}$\",\n    \"title\"    : str,\n    \"url\"      : r\"re:https://i\\.ibb\\.co/\\w{7}/[\\w-]+\\.jpg\",\n    \"width\"    : range(501, 1034),\n    \"height\"   : range(335, 768),\n    \"size\"     : range(74758, 439037),\n    \"mime\"     : \"image/jpeg\",\n    \"album\"    : {\n        \"count\"      : 91,\n        \"description\": \"Brief description of this album\",\n        \"id\"         : \"cgJrwc\",\n        \"title\"      : \"British Scrap Book\",\n        \"url\"        : \"https://ibb.co/album/cgJrwc\",\n    },\n    \"user\"     : {\n        \"id\"        : \"GvFMGK\",\n        \"name\"      : \"Folkie\",\n        \"url\"       : \"https://folkie.imgbb.com/\",\n        \"url_albums\": \"https://folkie.imgbb.com/albums\",\n        \"username\"  : \"folkie\",\n    },\n},\n\n{\n    \"#url\"     : \"https://ibb.co/album/cgJrwc?sort=name_asc\",\n    \"#comment\" : \"'sort' query argument\",\n    \"#class\"   : imgbb.ImgbbAlbumExtractor,\n    \"#patten\"  : r\"https://i\\.ibb\\.co/\\w{7}/[\\w-]+\\.jpg\",\n    \"#count\"   : 91,\n    \"#sha1_url\": \"a1bf67f74a6644b360a989a887ff413fd4eab2a6\",\n},\n\n{\n    \"#url\"     : \"https://ibb.co/album/kYKpwF\",\n    \"#comment\" : \"no user data (#471)\",\n    \"#category\": (\"\", \"imgbb\", \"album\"),\n    \"#class\"   : imgbb.ImgbbAlbumExtractor,\n    \"#sha1_url\": \"ac0abcfcb89f4df6adc2f7e4ff872f3b03ef1bc7\",\n\n    \"displayname\": \"\",\n    \"user\"       : \"\",\n    \"user_id\"    : \"\",\n},\n\n{\n    \"#url\"     : \"https://ibb.co/album/hqgWrF\",\n    \"#comment\" : \"private / That page doesn't exist\",\n    \"#category\": (\"\", \"imgbb\", \"album\"),\n    \"#class\"   : imgbb.ImgbbAlbumExtractor,\n    \"#exception\": exception.HttpError,\n},\n\n{\n    \"#url\"     : \"https://ibb.co/album/jyYWqL\",\n    \"#class\"   : imgbb.ImgbbAlbumExtractor,\n    \"#results\" : \"https://i.ibb.co/1J4mTWzQ/test.png\",\n\n    \"extension\": \"png\",\n    \"filename\" : \"test\",\n    \"height\"   : 32,\n    \"id\"       : \"bRGKX9bm\",\n    \"mime\"     : \"image/png\",\n    \"name\"     : \"test.png\",\n    \"size\"     : 182,\n    \"title\"    : \"test\",\n    \"url\"      : \"https://i.ibb.co/1J4mTWzQ/test.png\",\n    \"width\"    : 64,\n    \"album\"    : {\n        \"count\"      : 1,\n        \"id\"         : \"jyYWqL\",\n        \"title\"      : \"test-テスト-\\\"&> Album\",\n        \"url\"        : \"https://ibb.co/album/jyYWqL\",\n        \"description\": \"\"\"test-テスト-\"&>\\nDescription\"\"\",\n    },\n    \"user\"     : {\n        \"id\"        : \"nrFBYw\",\n        \"name\"      : \"Gdldev\",\n        \"url\"       : \"https://gdldev.imgbb.com/\",\n        \"url_albums\": \"https://gdldev.imgbb.com/albums\",\n        \"username\"  : \"gdldev\",\n    },\n},\n\n{\n    \"#url\"     : \"https://folkie.imgbb.com\",\n    \"#class\"   : imgbb.ImgbbUserExtractor,\n    \"#auth\"    : True,\n    \"#patten\"  : r\"https://i\\.ibb\\.co/\\w{7}/[\\w-]+\\.jpg\",\n    \"#range\"   : \"1-80\",\n\n},\n\n{\n    \"#url\"     : \"https://folkie.imgbb.com\",\n    \"#class\"   : imgbb.ImgbbUserExtractor,\n    \"#auth\"     : False,\n    \"#exception\": exception.AuthRequired,\n},\n\n{\n    \"#url\"     : \"https://gdldev.imgbb.com/\",\n    \"#class\"   : imgbb.ImgbbUserExtractor,\n    \"#results\" : \"https://i.ibb.co/1J4mTWzQ/test.png\",\n\n    \"extension\": \"png\",\n    \"filename\" : \"test\",\n    \"height\"   : 32,\n    \"id\"       : \"bRGKX9bm\",\n    \"mime\"     : \"image/png\",\n    \"name\"     : \"test.png\",\n    \"size\"     : 182,\n    \"title\"    : \"test\",\n    \"url\"      : \"https://i.ibb.co/1J4mTWzQ/test.png\",\n    \"width\"    : 64,\n    \"user\"     : {\n        \"id\"        : \"nrFBYw\",\n        \"name\"      : \"Gdldev\",\n        \"url\"       : \"https://gdldev.imgbb.com/\",\n        \"url_albums\": \"https://gdldev.imgbb.com/albums\",\n        \"username\"  : \"gdldev\",\n    },\n},\n\n{\n    \"#url\"     : \"https://ibb.co/fUqh5b\",\n    \"#class\"   : imgbb.ImgbbImageExtractor,\n    \"#auth\"    : False,\n    \"#results\"     : \"https://i.ibb.co/g3kvx80/Arundel-Ireeman-5.jpg\",\n    \"#sha1_content\": \"c5a0965178a8b357acd8aa39660092918c63795e\",\n\n    \"id\"       : \"qdT0v8H\",\n    \"title\"    : \"Arundel Ireeman 5\",\n    \"url\"      : \"https://i.ibb.co/g3kvx80/Arundel-Ireeman-5.jpg\",\n    \"width\"    : 960,\n    \"height\"   : 719,\n    \"filename\" : \"Arundel-Ireeman-5\",\n    \"extension\": \"jpg\",\n    \"date\"     : \"dt:2017-09-29 13:50:25\",\n    \"album\"    : {\n        \"id\"   : \"cgJrwc\",\n        \"title\": \"British Scrap Book\",\n    },\n    \"user\"     : {\n        \"id\"        : \"GvFMGK\",\n        \"name\"      : \"Folkie\",\n        \"url\"       : \"https://folkie.imgbb.com/\",\n        \"url_albums\": \"https://folkie.imgbb.com/albums\",\n        \"username\"  : \"folkie\",\n    },\n},\n\n{\n    \"#url\"     : \"https://ibb.co/bRGKX9bm\",\n    \"#class\"   : imgbb.ImgbbImageExtractor,\n    \"#auth\"    : False,\n    \"#results\" : \"https://i.ibb.co/1J4mTWzQ/test.png\",\n    \"#sha1_content\": \"0c8768055e4e20e7c7259608b67799171b691140\",\n\n    \"date\"     : \"dt:2025-07-30 20:05:06\",\n    \"extension\": \"png\",\n    \"filename\" : \"test\",\n    \"height\"   : 32,\n    \"id\"       : \"bRGKX9bm\",\n    \"title\"    : \"test\",\n    \"url\"      : \"https://i.ibb.co/1J4mTWzQ/test.png\",\n    \"width\"    : 64,\n    \"album\"    : {\n        \"id\"   : \"jyYWqL\",\n        \"title\": \"test-テスト-\\\"&> Album\",\n    },\n    \"user\"     : {\n        \"id\"        : \"nrFBYw\",\n        \"name\"      : \"Gdldev\",\n        \"url\"       : \"https://gdldev.imgbb.com/\",\n        \"url_albums\": \"https://gdldev.imgbb.com/albums\",\n        \"username\"  : \"gdldev\",\n    },\n},\n\n)\n"
  },
  {
    "path": "test/results/imgbox.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import imgbox\nfrom gallery_dl import exception\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://imgbox.com/g/JaX5V5HX7g\",\n    \"#category\": (\"\", \"imgbox\", \"gallery\"),\n    \"#class\"   : imgbox.ImgboxGalleryExtractor,\n    \"#sha1_url\"     : \"da4f15b161461119ee78841d4b8e8d054d95f906\",\n    \"#sha1_metadata\": \"4b1e62820ac2c6205b7ad0b6322cc8e00dbe1b0c\",\n    \"#sha1_content\" : \"d20307dc8511ac24d688859c55abf2e2cc2dd3cc\",\n},\n\n{\n    \"#url\"     : \"https://imgbox.com/g/cUGEkRbdZZ\",\n    \"#category\": (\"\", \"imgbox\", \"gallery\"),\n    \"#class\"   : imgbox.ImgboxGalleryExtractor,\n    \"#sha1_url\"     : \"76506a3aab175c456910851f66227e90484ca9f7\",\n    \"#sha1_metadata\": \"fb0427b87983197849fb2887905e758f3e50cb6e\",\n},\n\n{\n    \"#url\"     : \"https://imgbox.com/g/JaX5V5HX7h\",\n    \"#category\": (\"\", \"imgbox\", \"gallery\"),\n    \"#class\"   : imgbox.ImgboxGalleryExtractor,\n    \"#exception\": exception.NotFoundError,\n},\n\n{\n    \"#url\"     : \"https://imgbox.com/qHhw7lpG\",\n    \"#category\": (\"\", \"imgbox\", \"image\"),\n    \"#class\"   : imgbox.ImgboxImageExtractor,\n    \"#results\" : \"https://images3.imgbox.com/6d/9a/qHhw7lpG_o.png\",\n\n    \"#sha1_url\"     : \"ee9cdea6c48ad0161c1b5f81f6b0c9110997038c\",\n    \"#sha1_metadata\": \"dfc72310026b45f3feb4f9cada20c79b2575e1af\",\n    \"#sha1_content\" : \"0c8768055e4e20e7c7259608b67799171b691140\",\n\n    \"extension\": \"png\",\n    \"filename\" : \"test-___-___\",\n    \"image_key\": \"qHhw7lpG\",\n    \"num\"      : None,\n},\n\n{\n    \"#url\"     : \"https://imgbox.com/qHhw7lpH\",\n    \"#category\": (\"\", \"imgbox\", \"image\"),\n    \"#class\"   : imgbox.ImgboxImageExtractor,\n    \"#exception\": exception.NotFoundError,\n},\n\n{\n    \"#url\"     : \"https://images3.imgbox.com/6d/9a/qHhw7lpG_o.png\",\n    \"#comment\" : \"direct link\",\n    \"#class\"   : imgbox.ImgboxImageExtractor,\n    \"#results\" : \"https://images3.imgbox.com/6d/9a/qHhw7lpG_o.png\",\n\n    \"extension\": \"png\",\n    \"filename\" : \"test-___-___\",\n    \"image_key\": \"qHhw7lpG\",\n    \"num\"      : None,\n},\n\n{\n    \"#url\"     : \"https://i.imgbox.com/ivEi0Dgm.jpg\",\n    \"#comment\" : \"direct link\",\n    \"#class\"   : imgbox.ImgboxImageExtractor,\n    \"#results\" : \"https://images3.imgbox.com/72/e9/ivEi0Dgm_o.jpg\",\n\n    \"extension\": \"jpg\",\n    \"filename\" : \"3998C2F200000578-3861564-image-a-25_1477114630790\",\n    \"image_key\": \"ivEi0Dgm\",\n    \"num\"      : \"1\",\n},\n\n)\n"
  },
  {
    "path": "test/results/imgclick.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import imagehosts\n\n\n__tests__ = (\n{\n    \"#url\"     : \"http://imgclick.net/4tbrre1oxew9/test-_-_.png.html\",\n    \"#category\": (\"imagehost\", \"imgclick\", \"image\"),\n    \"#class\"   : imagehosts.ImgclickImageExtractor,\n    \"#sha1_url\"     : \"140dcb250a325f2d26b2d918c18b8ac6a2a0f6ab\",\n    \"#sha1_metadata\": \"6895256143eab955622fc149aa367777a8815ba3\",\n    \"#sha1_content\" : \"0c8768055e4e20e7c7259608b67799171b691140\",\n},\n\n)\n"
  },
  {
    "path": "test/results/imgdrive.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import imagehosts\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://imgdrive.net/img-61ac0caeabf35.html\",\n    \"#category\": (\"imagehost\", \"imgdrive\", \"image\"),\n    \"#class\"   : imagehosts.ImgdriveImageExtractor,\n    \"#results\" : \"https://imgdrive.net/images/big/2021/12/05/61ac0caeabf33.JPG\",\n\n    \"extension\": \"jpg\",\n    \"filename\" : \"5yl_0001\",\n    \"token\"    : \"61ac0caeabf35\",\n},\n\n)\n"
  },
  {
    "path": "test/results/imglike.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import chevereto\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://imglike.com/image/EMT-Skills-Verification-by-EMSA.Lx6dT\",\n    \"#category\": (\"chevereto\", \"imglike\", \"file\"),\n    \"#class\"   : chevereto.CheveretoFileExtractor,\n    \"#results\" : \"https://imglike.com/images/2022/08/12/EMT-Skills-Verification-by-EMSA.gif\",\n\n    \"album\"    : \"\",\n    \"date\"     : \"dt:2022-08-12 06:36:10\",\n    \"extension\": \"gif\",\n    \"filename\" : \"EMT-Skills-Verification-by-EMSA\",\n    \"id\"       : \"Lx6dT\",\n    \"url\"      : \"https://imglike.com/images/2022/08/12/EMT-Skills-Verification-by-EMSA.gif\",\n    \"user\"     : \"albertthomas9\",\n},\n\n{\n    \"#url\"     : \"https://www.imglike.com/image/EMT-Skills-Verification-by-EMSA.Lx6dT\",\n    \"#category\": (\"chevereto\", \"imglike\", \"file\"),\n    \"#class\"   : chevereto.CheveretoFileExtractor,\n},\n\n{\n    \"#url\"     : \"https://imglike.com/albertthomas9\",\n    \"#category\": (\"chevereto\", \"imglike\", \"user\"),\n    \"#class\"   : chevereto.CheveretoUserExtractor,\n    \"#results\" : (\n        \"https://imglike.com/image/Palm-Desert-Resuscitation-Education-%28YourCPRMD.com%29.L1xHc\",\n        \"https://imglike.com/image/CNA-Programs-Near-Me-Anza.L1VES\",\n        \"https://imglike.com/image/EMT-Skills-Verification-by-EMSA.Lx6dT\",\n        \"https://imglike.com/image/American-Heart-Association-BLS.Lisl2\",\n    ),\n},\n\n{\n    \"#url\"     : \"https://imglike.com/album/Kara-Del-Toro-Naked.cG7l\",\n    \"#category\": (\"chevereto\", \"imglike\", \"album\"),\n    \"#class\"   : chevereto.CheveretoAlbumExtractor,\n    \"#results\" : (\n        \"https://imglike.com/image/Nude-Kara-Del-Tori-%286%29.LGXgd\",\n        \"https://imglike.com/image/Nude-Kara-Del-Tori-%285%29.LGxFT\",\n        \"https://imglike.com/image/Nude-Kara-Del-Tori-%283%29.LGB1z\",\n        \"https://imglike.com/image/Nude-Kara-Del-Tori-%284%29.LG6lR\",\n        \"https://imglike.com/image/Nude-Kara-Del-Tori-%282%29.LG4Jg\",\n        \"https://imglike.com/image/Nude-Kara-Del-Tori-%281%29.LGP7s\",\n    ),\n},\n\n{\n    \"#url\"     : \"https://imglike.com/category/Bursting-boobs\",\n    \"#category\": (\"chevereto\", \"imglike\", \"category\"),\n    \"#class\"   : chevereto.CheveretoCategoryExtractor,\n    \"#pattern\" : chevereto.CheveretoFileExtractor.pattern,\n    \"#range\"   : \"1-100\",\n    \"#count\"   : 100,\n},\n\n)\n"
  },
  {
    "path": "test/results/imgpile.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import imgpile\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://imgpile.com/p/bbjiXrl\",\n    \"#class\"   : imgpile.ImgpilePostExtractor,\n    \"#results\" : (\n        \"https://cdn.imgpile.com/f/BobTUou.jpg\",\n        \"https://cdn.imgpile.com/f/Wr9cQFK.jpg\",\n        \"https://cdn.imgpile.com/f/VevZbjw.png\",\n    ),\n\n    \"id\"       : {3518940, 3518941, 3518942},\n    \"id_slug\"  : {\"BobTUou\", \"Wr9cQFK\", \"VevZbjw\"},\n    \"count\"    : 3,\n    \"num\"      : range(1, 3),\n    \"filename\" : str,\n    \"extension\": {\"jpg\", \"png\"},\n    \"url\"      : r\"re:https://cdn.imgpile.com/f/\\w+\\.(jpg|png)\",\n    \"post\"     : {\n        \"author\" : \"zilla_64\",\n        \"count\"  : 3,\n        \"id\"     : 105411,\n        \"id_slug\": \"bbjiXrl\",\n        \"score\"  : range(-5, 5),\n        \"title\"  : \"Mecha-King Ghidorah scans\",\n        \"views\"  : range(8_300, 12_000),\n        \"tags\"   : [\n            \"text\",\n            \"description\",\n            \"Godzilla\",\n            \"battle\",\n            \"story\",\n            \"article\",\n            \"monsters\",\n            \"characters\",\n            \"device\",\n            \"time\",\n            \"space\",\n            \"mecha\",\n        ],\n    },\n},\n\n{\n    \"#url\"     : \"https://imgpile.com/u/zilla_64\",\n    \"#class\"   : imgpile.ImgpileUserExtractor,\n    \"#pattern\" : imgpile.ImgpilePostExtractor.pattern,\n    \"#count\"   : 16,\n},\n\n)\n"
  },
  {
    "path": "test/results/imgpv.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import imagehosts\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://imgpv.com/30zydqn6y1yk/4bi%20(1).jpg.html\",\n    \"#category\": (\"imagehost\", \"imgpv\", \"image\"),\n    \"#class\"   : imagehosts.ImgpvImageExtractor,\n    \"#pattern\" : r\"https://s1.imgpv.com/cgi-bin/dl.cgi/xyhr\\w+/4bi %26%2340%3B1%26%2341%3B.jpg\",\n\n    \"date\"     : \"dt:2025-12-16 14:59:51\",\n    \"extension\": \"jpg\",\n    \"filename\" : \"4bi (1)\",\n    \"post_url\" : \"https://imgpv.com/30zydqn6y1yk/4bi%20(1).jpg.html\",\n    \"token\"    : \"30zydqn6y1yk\",\n    \"user\"     : \"kris85\",\n},\n\n{\n    \"#url\"     : \"https://imgpv.com/4sizkvumyh8v/test-%E3%83%86%E3%82%B9%E3%83%88-%22%2526%3E.jpg.html\",\n    \"#category\": (\"imagehost\", \"imgpv\", \"image\"),\n    \"#class\"   : imagehosts.ImgpvImageExtractor,\n    \"#pattern\" : r\"https://s1.imgpv.com/cgi-bin/dl.cgi/hmbb\\w+/test-%E3%83%86%E3%82%B9%E3%83%88-%2522%26%26gt%3B.jpg\",\n    \"#sha1_content\": \"0c8768055e4e20e7c7259608b67799171b691140\",\n\n    \"date\"     : \"dt:2025-12-28 13:09:35\",\n    \"extension\": \"jpg\",\n    \"filename\" : \"test-テスト-%22&>\",\n    \"post_url\" : \"https://imgpv.com/4sizkvumyh8v/test-%E3%83%86%E3%82%B9%E3%83%88-%22%2526%3E.jpg.html\",\n    \"token\"    : \"4sizkvumyh8v\",\n},\n\n)\n"
  },
  {
    "path": "test/results/imgspice.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import imagehosts\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://imgspice.com/q1taxkhxprrn/58410038_cal022jsp_308191001.jpg.html\",\n    \"#category\": (\"imagehost\", \"imgspice\", \"image\"),\n    \"#class\"   : imagehosts.ImgspiceImageExtractor,\n    \"#results\"      : \"https://img30.imgspice.com/i/03792/q1taxkhxprrn.jpg\",\n    \"#sha1_content\" : \"f1de8e58a7c2ef747a206a38f96c5623b8a83edc\",\n\n    \"extension\": \"jpg\",\n    \"filename\" : \"58410038_cal022jsp_308191001\",\n    \"token\"    : \"q1taxkhxprrn\",\n},\n\n)\n"
  },
  {
    "path": "test/results/imgtaxi.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import imagehosts\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://imgtaxi.com/img-61c71cea940d6.html\",\n    \"#category\": (\"imagehost\", \"imgtaxi\", \"image\"),\n    \"#class\"   : imagehosts.ImgdriveImageExtractor,\n    \"#results\" : \"https://imgtaxi.com/images/big/2021/12/25/61c71cea940d5.jpg\",\n\n    \"filename\" : \"SLn_0001\",\n    \"extension\": \"jpg\",\n    \"token\"    : \"61c71cea940d6\",\n},\n\n)\n"
  },
  {
    "path": "test/results/imgth.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import imgth\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://imgth.com/gallery/37/wallpaper-anime\",\n    \"#category\": (\"\", \"imgth\", \"gallery\"),\n    \"#class\"   : imgth.ImgthGalleryExtractor,\n    \"#pattern\" : r\"https://imgth\\.com/images/2009/11/25/wallpaper-anime_\\w+\\.jpg\",\n    \"#sha1_url\": \"4ae1d281ca2b48952cf5cca57e9914402ad72748\",\n\n    \"count\"     : 12,\n    \"date\"      : \"dt:2009-11-25 18:21:00\",\n    \"extension\" : \"jpg\",\n    \"filename\"  : r\"re:wallpaper-anime_\\w+\",\n    \"gallery_id\": 37,\n    \"num\"       : int,\n    \"title\"     : \"Wallpaper anime\",\n    \"user\"      : \"celebrities\",\n},\n\n{\n    \"#url\"     : \"https://www.imgth.com/gallery/37/wallpaper-anime\",\n    \"#category\": (\"\", \"imgth\", \"gallery\"),\n    \"#class\"   : imgth.ImgthGalleryExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/imgur.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import imgur\nfrom gallery_dl import exception\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://imgur.com/21yMxCS\",\n    \"#category\": (\"\", \"imgur\", \"image\"),\n    \"#class\"   : imgur.ImgurImageExtractor,\n    \"#sha1_url\"    : \"6f2dcfb86815bdd72808c313e5f715610bc7b9b2\",\n    \"#sha1_content\": \"0c8768055e4e20e7c7259608b67799171b691140\",\n    \"#results\"     : \"https://i.imgur.com/21yMxCS.png\",\n\n    \"account_id\"    : 0,\n    \"comment_count\" : int,\n    \"cover_id\"      : \"21yMxCS\",\n    \"date\"          : \"dt:2016-11-10 14:24:35\",\n    \"description\"   : \"\",\n    \"downvote_count\": int,\n    \"duration\"      : 0,\n    \"ext\"           : \"png\",\n    \"favorite\"      : False,\n    \"favorite_count\": 0,\n    \"has_sound\"     : False,\n    \"height\"        : 32,\n    \"id\"            : \"21yMxCS\",\n    \"image_count\"   : 1,\n    \"in_most_viral\" : False,\n    \"is_ad\"         : False,\n    \"is_album\"      : False,\n    \"is_animated\"   : False,\n    \"is_looping\"    : False,\n    \"is_mature\"     : False,\n    \"is_pending\"    : False,\n    \"mime_type\"     : \"image/png\",\n    \"name\"          : \"test-テスト\",\n    \"point_count\"   : int,\n    \"privacy\"       : \"\",\n    \"score\"         : int,\n    \"size\"          : 182,\n    \"title\"         : \"Test\",\n    \"upvote_count\"  : int,\n    \"url\"           : \"https://i.imgur.com/21yMxCS.png\",\n    \"view_count\"    : int,\n    \"width\"         : 64,\n},\n\n{\n    \"#url\"     : \"http://imgur.com/0gybAXR\",\n    \"#comment\" : \"gifv/mp4 video\",\n    \"#category\": (\"\", \"imgur\", \"image\"),\n    \"#class\"   : imgur.ImgurImageExtractor,\n    \"#sha1_url\"    : \"a2220eb265a55b0c95e0d3d721ec7665460e3fd7\",\n    \"#sha1_content\": \"a3c080e43f58f55243ab830569ba02309d59abfc\",\n},\n\n{\n    \"#url\"     : \"https://imgur.com/XFfsmuC\",\n    \"#comment\" : \"missing title in API response (#467)\",\n    \"#category\": (\"\", \"imgur\", \"image\"),\n    \"#class\"   : imgur.ImgurImageExtractor,\n\n    \"title\": \"Tears are a natural response to irritants\",\n},\n\n{\n    \"#url\"     : \"https://imgur.com/1Nily2P\",\n    \"#comment\" : \"animated png\",\n    \"#category\": (\"\", \"imgur\", \"image\"),\n    \"#class\"   : imgur.ImgurImageExtractor,\n    \"#pattern\" : \"https://i.imgur.com/1Nily2P.png\",\n},\n\n{\n    \"#url\"     : \"https://imgur.com/zzzzzzz\",\n    \"#comment\" : \"not found\",\n    \"#category\": (\"\", \"imgur\", \"image\"),\n    \"#class\"   : imgur.ImgurImageExtractor,\n    \"#exception\": exception.HttpError,\n},\n\n{\n    \"#url\"     : \"https://imgur.com/test-21yMxCS\",\n    \"#comment\" : \"slug\",\n    \"#category\": (\"\", \"imgur\", \"image\"),\n    \"#class\"   : imgur.ImgurImageExtractor,\n},\n\n{\n    \"#url\"     : \"https://m.imgur.com/r/Celebs/iHJ7tsM\",\n    \"#category\": (\"\", \"imgur\", \"image\"),\n    \"#class\"   : imgur.ImgurImageExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.imgur.com/21yMxCS\",\n    \"#comment\" : \"www\",\n    \"#category\": (\"\", \"imgur\", \"image\"),\n    \"#class\"   : imgur.ImgurImageExtractor,\n},\n\n{\n    \"#url\"     : \"https://m.imgur.com/21yMxCS\",\n    \"#comment\" : \"mobile\",\n    \"#category\": (\"\", \"imgur\", \"image\"),\n    \"#class\"   : imgur.ImgurImageExtractor,\n},\n\n{\n    \"#url\"     : \"https://imgur.com/zxaY6\",\n    \"#comment\" : \"5 character key\",\n    \"#category\": (\"\", \"imgur\", \"image\"),\n    \"#class\"   : imgur.ImgurImageExtractor,\n},\n\n{\n    \"#url\"     : \"https://imgur.io/zxaY6\",\n    \"#comment\" : \".io\",\n    \"#category\": (\"\", \"imgur\", \"image\"),\n    \"#class\"   : imgur.ImgurImageExtractor,\n},\n\n{\n    \"#url\"     : \"https://i.imgur.com/21yMxCS.png\",\n    \"#comment\" : \"direct link\",\n    \"#category\": (\"\", \"imgur\", \"image\"),\n    \"#class\"   : imgur.ImgurImageExtractor,\n},\n\n{\n    \"#url\"     : \"https://i.imgur.io/21yMxCS.png\",\n    \"#comment\" : \"direct link .io\",\n    \"#category\": (\"\", \"imgur\", \"image\"),\n    \"#class\"   : imgur.ImgurImageExtractor,\n},\n\n{\n    \"#url\"     : \"https://i.imgur.com/21yMxCSh.png\",\n    \"#comment\" : \"direct link thumbnail\",\n    \"#category\": (\"\", \"imgur\", \"image\"),\n    \"#class\"   : imgur.ImgurImageExtractor,\n},\n\n{\n    \"#url\"     : \"https://i.imgur.com/zxaY6.gif\",\n    \"#comment\" : \"direct link (short)\",\n    \"#category\": (\"\", \"imgur\", \"image\"),\n    \"#class\"   : imgur.ImgurImageExtractor,\n},\n\n{\n    \"#url\"     : \"https://i.imgur.com/zxaY6s.gif\",\n    \"#comment\" : \"direct link (short; thumb)\",\n    \"#category\": (\"\", \"imgur\", \"image\"),\n    \"#class\"   : imgur.ImgurImageExtractor,\n},\n\n{\n    \"#url\"     : \"https://imgur.com/a/TcBmP\",\n    \"#category\": (\"\", \"imgur\", \"album\"),\n    \"#class\"   : imgur.ImgurAlbumExtractor,\n    \"#sha1_url\": \"ce3552f550a5b5316bd9c7ae02e21e39f30c0563\",\n    \"#results\" : (\n        \"https://i.imgur.com/693j2Kr.jpg\",\n        \"https://i.imgur.com/ZNalkAC.jpg\",\n        \"https://i.imgur.com/lMox9Ek.jpg\",\n        \"https://i.imgur.com/6PryGOv.jpg\",\n        \"https://i.imgur.com/ecasnH2.jpg\",\n        \"https://i.imgur.com/NlJDmFG.jpg\",\n        \"https://i.imgur.com/aCwKs8S.jpg\",\n        \"https://i.imgur.com/Oz4rpxo.jpg\",\n        \"https://i.imgur.com/hE93Xsn.jpg\",\n        \"https://i.imgur.com/A5uBLSx.jpg\",\n        \"https://i.imgur.com/zZghWiD.jpg\",\n        \"https://i.imgur.com/ALV4fYV.jpg\",\n        \"https://i.imgur.com/FDd90t9.jpg\",\n        \"https://i.imgur.com/Txw37NO.jpg\",\n        \"https://i.imgur.com/DcLw7Cl.jpg\",\n        \"https://i.imgur.com/a4VChy8.jpg\",\n        \"https://i.imgur.com/auCwCig.jpg\",\n        \"https://i.imgur.com/Z8VihIb.jpg\",\n        \"https://i.imgur.com/6WDRFne.jpg\",\n    ),\n\n    \"album\"      : {\n        \"account_id\"    : 0,\n        \"comment_count\" : int,\n        \"cover_id\"      : \"693j2Kr\",\n        \"date\"          : \"dt:2015-10-09 10:37:50\",\n        \"description\"   : \"\",\n        \"downvote_count\": 0,\n        \"favorite\"      : False,\n        \"favorite_count\": 0,\n        \"id\"            : \"TcBmP\",\n        \"image_count\"   : 19,\n        \"in_most_viral\" : False,\n        \"is_ad\"         : False,\n        \"is_album\"      : True,\n        \"is_mature\"     : False,\n        \"is_pending\"    : False,\n        \"privacy\"       : \"private\",\n        \"score\"         : int,\n        \"title\"         : \"138\",\n        \"upvote_count\"  : int,\n        \"url\"           : \"https://imgur.com/a/TcBmP\",\n        \"view_count\"    : int,\n        \"virality\"      : int,\n    },\n    \"account_id\" : 0,\n    \"count\"      : 19,\n    \"date\"       : \"type:datetime\",\n    \"description\": \"\",\n    \"ext\"        : \"jpg\",\n    \"has_sound\"  : False,\n    \"height\"     : int,\n    \"id\"         : str,\n    \"is_animated\": False,\n    \"is_looping\" : False,\n    \"mime_type\"  : \"image/jpeg\",\n    \"name\"       : str,\n    \"num\"        : int,\n    \"size\"       : int,\n    \"title\"      : str,\n    \"type\"       : \"image\",\n    \"updated_at\" : None,\n    \"url\"        : str,\n    \"width\"      : int,\n},\n\n{\n    \"#url\"     : \"https://imgur.com/a/eD9CT\",\n    \"#comment\" : \"large album\",\n    \"#category\": (\"\", \"imgur\", \"album\"),\n    \"#class\"   : imgur.ImgurAlbumExtractor,\n    \"#exception\": exception.HttpError,\n},\n\n{\n    \"#url\"     : \"https://imgur.com/a/RhJXhVT/all\",\n    \"#comment\" : \"7 character album hash\",\n    \"#category\": (\"\", \"imgur\", \"album\"),\n    \"#class\"   : imgur.ImgurAlbumExtractor,\n    \"#sha1_url\": \"695ef0c950023362a0163ee5041796300db76674\",\n},\n\n{\n    \"#url\"     : \"https://imgur.com/a/TcBmQ\",\n    \"#category\": (\"\", \"imgur\", \"album\"),\n    \"#class\"   : imgur.ImgurAlbumExtractor,\n    \"#exception\": exception.HttpError,\n},\n\n{\n    \"#url\"     : \"https://imgur.com/a/pjOnJA0\",\n    \"#comment\" : \"empty, no 'media' (#2557)\",\n    \"#category\": (\"\", \"imgur\", \"album\"),\n    \"#class\"   : imgur.ImgurAlbumExtractor,\n    \"#exception\": exception.HttpError,\n},\n\n{\n    \"#url\"     : \"https://imgur.com/a/138-TcBmP\",\n    \"#comment\" : \"slug\",\n    \"#category\": (\"\", \"imgur\", \"album\"),\n    \"#class\"   : imgur.ImgurAlbumExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.imgur.com/a/TcBmP\",\n    \"#comment\" : \"www\",\n    \"#category\": (\"\", \"imgur\", \"album\"),\n    \"#class\"   : imgur.ImgurAlbumExtractor,\n},\n\n{\n    \"#url\"     : \"https://imgur.io/a/TcBmP\",\n    \"#comment\" : \".io\",\n    \"#category\": (\"\", \"imgur\", \"album\"),\n    \"#class\"   : imgur.ImgurAlbumExtractor,\n},\n\n{\n    \"#url\"     : \"https://m.imgur.com/a/TcBmP\",\n    \"#comment\" : \"mobile\",\n    \"#category\": (\"\", \"imgur\", \"album\"),\n    \"#class\"   : imgur.ImgurAlbumExtractor,\n},\n\n{\n    \"#url\"     : \"https://imgur.com/gallery/zf2fIms\",\n    \"#comment\" : \"non-album gallery (#380)\",\n    \"#category\": (\"\", \"imgur\", \"gallery\"),\n    \"#class\"   : imgur.ImgurGalleryExtractor,\n    \"#pattern\" : \"https://imgur.com/zf2fIms\",\n},\n\n{\n    \"#url\"     : \"https://imgur.com/gallery/eD9CT\",\n    \"#category\": (\"\", \"imgur\", \"gallery\"),\n    \"#class\"   : imgur.ImgurGalleryExtractor,\n    \"#exception\": exception.HttpError,\n},\n\n{\n    \"#url\"     : \"https://imgur.com/gallery/guy-gets-out-of-car-during-long-traffic-jam-to-pet-dog-zf2fIms\",\n    \"#comment\" : \"slug\",\n    \"#category\": (\"\", \"imgur\", \"gallery\"),\n    \"#class\"   : imgur.ImgurGalleryExtractor,\n},\n\n{\n    \"#url\"     : \"https://imgur.com/t/unmuted/26sEhNr\",\n    \"#category\": (\"\", \"imgur\", \"gallery\"),\n    \"#class\"   : imgur.ImgurGalleryExtractor,\n},\n\n{\n    \"#url\"     : \"https://imgur.com/t/cat/qSB8NbN\",\n    \"#category\": (\"\", \"imgur\", \"gallery\"),\n    \"#class\"   : imgur.ImgurGalleryExtractor,\n},\n\n{\n    \"#url\"     : \"https://imgur.io/t/cat/qSB8NbN\",\n    \"#comment\" : \".io\",\n    \"#category\": (\"\", \"imgur\", \"gallery\"),\n    \"#class\"   : imgur.ImgurGalleryExtractor,\n},\n\n{\n    \"#url\"     : \"https://imgur.com/user/Miguenzo\",\n    \"#category\": (\"\", \"imgur\", \"user\"),\n    \"#class\"   : imgur.ImgurUserExtractor,\n    \"#pattern\" : r\"https://imgur\\.com(/a)?/\\w+$\",\n    \"#range\"   : \"1-100\",\n    \"#count\"   : 100,\n},\n\n{\n    \"#url\"     : \"https://imgur.com/user/Miguenzo/posts\",\n    \"#category\": (\"\", \"imgur\", \"user\"),\n    \"#class\"   : imgur.ImgurUserExtractor,\n},\n\n{\n    \"#url\"     : \"https://imgur.com/user/Miguenzo/submitted\",\n    \"#category\": (\"\", \"imgur\", \"user\"),\n    \"#class\"   : imgur.ImgurUserExtractor,\n},\n\n{\n    \"#url\"     : \"https://imgur.com/user/Miguenzo/favorites\",\n    \"#category\": (\"\", \"imgur\", \"favorite\"),\n    \"#class\"   : imgur.ImgurFavoriteExtractor,\n    \"#pattern\" : r\"https://imgur\\.com(/a)?/\\w+$\",\n    \"#range\"   : \"1-100\",\n    \"#count\"   : 100,\n},\n\n{\n    \"#url\"     : \"https://imgur.com/user/mikf1/favorites/folder/11896757/public\",\n    \"#category\": (\"\", \"imgur\", \"favorite-folder\"),\n    \"#class\"   : imgur.ImgurFavoriteFolderExtractor,\n    \"#pattern\" : r\"https://imgur\\.com(/a)?/\\w+$\",\n    \"#count\"   : 3,\n},\n\n{\n    \"#url\"     : \"https://imgur.com/user/mikf1/favorites/folder/11896741/private\",\n    \"#category\": (\"\", \"imgur\", \"favorite-folder\"),\n    \"#class\"   : imgur.ImgurFavoriteFolderExtractor,\n    \"#pattern\" : r\"https://imgur\\.com(/a)?/\\w+$\",\n    \"#count\"   : 5,\n},\n\n{\n    \"#url\"     : \"https://imgur.com/user/me\",\n    \"#class\"   : imgur.ImgurMeExtractor,\n    \"#auth\"    : True,\n    \"#pattern\" : r\"https://imgur\\.com(/a)?/\\w+$\",\n    \"#count\"   : 3,\n},\n\n{\n    \"#url\"     : \"https://imgur.com/user/me/hidden\",\n    \"#class\"   : imgur.ImgurMeExtractor,\n    \"#auth\"    : True,\n    \"#pattern\" : r\"https://imgur\\.com(/a)?/\\w+$\",\n    \"#count\"   : 2,\n},\n\n{\n    \"#url\"     : \"https://imgur.com/user/me/posts\",\n    \"#class\"   : imgur.ImgurMeExtractor,\n},\n\n{\n    \"#url\"     : \"https://imgur.com/user/me/posts/hidden\",\n    \"#class\"   : imgur.ImgurMeExtractor,\n},\n\n{\n    \"#url\"     : \"https://imgur.com/r/pics\",\n    \"#category\": (\"\", \"imgur\", \"subreddit\"),\n    \"#class\"   : imgur.ImgurSubredditExtractor,\n    \"#pattern\" : r\"https://imgur\\.com(/a)?/\\w+$\",\n    \"#range\"   : \"1-100\",\n    \"#count\"   : 100,\n},\n\n{\n    \"#url\"     : \"https://imgur.com/t/animals\",\n    \"#category\": (\"\", \"imgur\", \"tag\"),\n    \"#class\"   : imgur.ImgurTagExtractor,\n    \"#pattern\" : r\"https://imgur\\.com(/a)?/\\w+$\",\n    \"#range\"   : \"1-100\",\n    \"#count\"   : 100,\n},\n\n{\n    \"#url\"     : \"https://imgur.com/search?q=cute+cat\",\n    \"#category\": (\"\", \"imgur\", \"search\"),\n    \"#class\"   : imgur.ImgurSearchExtractor,\n    \"#pattern\" : r\"https://imgur\\.com(/a)?/\\w+$\",\n    \"#range\"   : \"1-100\",\n    \"#count\"   : 100,\n},\n\n)\n"
  },
  {
    "path": "test/results/imgwallet.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import imagehosts\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://imgwallet.com/img-62fd3045ceec6.html\",\n    \"#category\": (\"imagehost\", \"imgwallet\", \"image\"),\n    \"#class\"   : imagehosts.ImgdriveImageExtractor,\n    \"#results\" : \"https://imgwallet.com/images/big/2022/08/17/62fd3045ceec5.JPG\",\n\n    \"filename\" : \"S0ph (1)\",\n    \"extension\": \"jpg\",\n    \"token\"    : \"62fd3045ceec6\",\n},\n\n)\n"
  },
  {
    "path": "test/results/imhentai.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import imhentai\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://imhentai.xxx/gallery/12/\",\n    \"#category\": (\"IMHentai\", \"imhentai\", \"gallery\"),\n    \"#class\"   : imhentai.ImhentaiGalleryExtractor,\n    \"#pattern\" : r\"https://m1\\.imhentai\\.xxx/001/3x907ntq18/\\d+\\.jpg\",\n    \"#count\"   : 94,\n\n    \"count\"     : 94,\n    \"extension\" : \"jpg\",\n    \"filename\"  : str,\n    \"gallery_id\": 12,\n    \"lang\"      : \"en\",\n    \"num\"       : range(1, 94),\n    \"title\"     : \"(C67) [Studio Kimigabuchi (Kimimaru)] RE-TAKE 2 (Neon Genesis Evangelion) [English]\",\n    \"title_alt\" : \"(C67) [スタジオKIMIGABUCHI (きみまる)] RE-TAKE2 (新世紀エヴァンゲリオン) [英訳]\",\n    \"type\"      : \"doujinshi\",\n    \"width\"     : {835, 838, 841, 1200},\n    \"height\"    : {862, 865, 1200},\n\n    \"artist\":    [\n        \"kimimaru | entokkun\",\n    ],\n    \"character\": [\n        \"asuka langley soryu\",\n        \"gendo ikari\",\n        \"makoto hyuga\",\n        \"maya ibuki\",\n        \"misato katsuragi\",\n        \"rei ayanami\",\n        \"shigeru aoba\",\n        \"shinji ikari\",\n    ],\n    \"group\": [\n        \"studio kimigabuchi\",\n    ],\n    \"language\": [\n        \"english\",\n        \"translated\",\n    ],\n    \"parody\": [\n        \"neon genesis evangelion | shin seiki evangelion\",\n    ],\n    \"tags\": [\n        \"multi-work series\",\n        \"schoolboy uniform\",\n        \"schoolgirl uniform\",\n        \"sole female\",\n        \"sole male\",\n        \"story arc\",\n        \"twintails\",\n    ],\n},\n\n{\n    \"#url\"     : \"https://imhentai.xxx/gallery/1396508/\",\n    \"#category\": (\"IMHentai\", \"imhentai\", \"gallery\"),\n    \"#class\"   : imhentai.ImhentaiGalleryExtractor,\n    \"#pattern\" : r\"https://m9\\.imhentai\\.xxx/028/po9f4w3jzx/\\d+\\.webp\",\n    \"#count\"   : 34,\n\n    \"count\"     : 34,\n    \"extension\" : \"webp\",\n    \"filename\"  : str,\n    \"gallery_id\": 1396508,\n    \"lang\"      : \"ko\",\n    \"num\"       : range(1, 34),\n    \"title\"     : \"[Beruennea (Skylader)] Tada no Kouhai ni Natta Kimi | 그냥 후배가 돼 버린 너 [Korean] [Digital]\",\n    \"title_alt\" : \"[ベルエンネーア (すかいれーだー)] ただの後輩になった君 [韓国翻訳] [DL版]\",\n    \"type\"      : \"doujinshi\",\n    \"width\"     : 1280,\n    \"height\"    : {1790, 1791},\n\n    \"artist\": [\n        \"skylader\",\n    ],\n    \"character\": [],\n    \"group\": [\n        \"beruennea\",\n    ],\n    \"language\": [\n        \"korean\",\n        \"translated\",\n    ],\n    \"parody\": [\n        \"original\",\n    ],\n    \"tags\": [\n        \"ahegao\",\n        \"big ass\",\n        \"big breasts\",\n        \"big nipples\",\n        \"big penis\",\n        \"bike shorts\",\n        \"blowjob\",\n        \"gokkun\",\n        \"hairy\",\n        \"huge breasts\",\n        \"mosaic censorship\",\n        \"muscle\",\n        \"nakadashi\",\n        \"netorare\",\n        \"schoolgirl uniform\",\n        \"tanlines\",\n    ],\n},\n\n{\n    \"#url\"     : \"https://imhentai.xxx/artist/asutora/\",\n    \"#category\": (\"IMHentai\", \"imhentai\", \"tag\"),\n    \"#class\"   : imhentai.ImhentaiTagExtractor,\n    \"#pattern\" : imhentai.ImhentaiGalleryExtractor.pattern,\n    \"#count\"   : range(45, 50),\n},\n\n{\n    \"#url\"     : \"https://imhentai.xxx/search/?lt=1&pp=0&m=1&d=1&w=1&i=1&a=1&g=1&key=asutora&apply=Search&en=1&jp=1&es=1&fr=1&kr=1&de=1&ru=1&dl=0&tr=0\",\n    \"#category\": (\"IMHentai\", \"imhentai\", \"search\"),\n    \"#class\"   : imhentai.ImhentaiSearchExtractor,\n    \"#pattern\" : imhentai.ImhentaiGalleryExtractor.pattern,\n    \"#count\"   : range(45, 60),\n},\n\n)\n"
  },
  {
    "path": "test/results/imxto.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import imagehosts\nfrom gallery_dl import exception\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://imx.to/i/1qdeva\",\n    \"#comment\" : \"new-style URL\",\n    \"#category\": (\"imagehost\", \"imxto\", \"image\"),\n    \"#class\"   : imagehosts.ImxtoImageExtractor,\n    \"#results\"     : \"https://image.imx.to/u/i/2018/04/09/1qdeva.png\",\n    \"#sha1_content\": \"0c8768055e4e20e7c7259608b67799171b691140\",\n\n    \"filename\" : \"test-ãƒ†ã‚¹ãƒˆ\",\n    \"extension\": \"png\",\n    \"post_url\" : \"https://imx.to/i/1qdeva\",\n    \"size\"  : 18,\n    \"width\" : 64,\n    \"height\": 32,\n    \"token\" : \"1qdeva\",\n    \"hash\"  : \"94d56c599223c59f3feb71ea603484d1\",\n},\n\n{\n    \"#url\"     : \"https://imx.to/img-57a2050547b97.html\",\n    \"#comment\" : \"old-style URL\",\n    \"#category\": (\"imagehost\", \"imxto\", \"image\"),\n    \"#class\"   : imagehosts.ImxtoImageExtractor,\n    \"#results\"     : \"https://image.imx.to/u/i/2016/08/03/57a2050547b60.jpg\",\n    \"#sha1_content\": \"54592f2635674c25677c6872db3709d343cdf92f\",\n\n    \"filename\" : \"test\",\n    \"extension\": \"jpg\",\n    \"post_url\" : \"https://imx.to/img-57a2050547b97.html\",\n    \"size\"  : 5284,\n    \"width\" : 320,\n    \"height\": 160,\n    \"token\" : \"57a2050547b97\",\n    \"hash\"  : \"40da6aaa7b8c42b18ef74309bbc713fc\",\n},\n\n{\n    \"#url\"     : \"https://img.yt/img-57a2050547b97.html\",\n    \"#comment\" : \"img.yt domain\",\n    \"#category\": (\"imagehost\", \"imxto\", \"image\"),\n    \"#class\"   : imagehosts.ImxtoImageExtractor,\n    \"#results\" : \"https://image.imx.to/u/i/2016/08/03/57a2050547b60.jpg\",\n\n    \"filename\" : \"test\",\n    \"extension\": \"jpg\",\n    \"post_url\" : \"https://imx.to/img-57a2050547b97.html\",\n    \"size\"  : 5284,\n    \"width\" : 320,\n    \"height\": 160,\n    \"token\" : \"57a2050547b97\",\n    \"hash\"  : \"40da6aaa7b8c42b18ef74309bbc713fc\",\n},\n\n{\n    \"#url\"     : \"https://imx.to/img-57a2050547b98.html\",\n    \"#category\": (\"imagehost\", \"imxto\", \"image\"),\n    \"#class\"   : imagehosts.ImxtoImageExtractor,\n    \"#exception\": exception.NotFoundError,\n},\n\n{\n    \"#url\"     : \"https://imx.to/g/ozdy\",\n    \"#category\": (\"imagehost\", \"imxto\", \"gallery\"),\n    \"#class\"   : imagehosts.ImxtoGalleryExtractor,\n    \"#pattern\" : imagehosts.ImxtoImageExtractor.pattern,\n    \"#count\"   : 40,\n\n    \"title\": \"untitled gallery\",\n},\n\n{\n    \"#url\"     : \"https://imx.to/g/mgun\",\n    \"#comment\" : \"multiple pages (#8282)\",\n    \"#category\": (\"imagehost\", \"imxto\", \"gallery\"),\n    \"#class\"   : imagehosts.ImxtoGalleryExtractor,\n    \"#pattern\" : imagehosts.ImxtoImageExtractor.pattern,\n    \"#count\"   : 1037,\n\n    \"title\": \"freckledspirit\",\n},\n\n)\n"
  },
  {
    "path": "test/results/inkbunny.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import inkbunny\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://inkbunny.net/soina\",\n    \"#category\": (\"\", \"inkbunny\", \"user\"),\n    \"#class\"   : inkbunny.InkbunnyUserExtractor,\n    \"#pattern\" : r\"https://[\\w.]+\\.metapix\\.net/files/full/\\d+/\\d+_soina_.+\",\n    \"#range\"   : \"20-50\",\n\n    \"date\"           : \"type:datetime\",\n    \"deleted\"        : bool,\n    \"file_id\"        : r\"re:[0-9]+\",\n    \"filename\"       : r\"re:[0-9]+_soina_\\w+\",\n    \"full_file_md5\"  : r\"re:[0-9a-f]{32}\",\n    \"mimetype\"       : str,\n    \"submission_id\"  : r\"re:[0-9]+\",\n    \"user_id\"        : \"20969\",\n    \"comments_count\" : r\"re:[0-9]+\",\n    \"favorite\"       : bool,\n    \"favorites_count\": r\"re:[0-9]+\",\n    \"friends_only\"   : bool,\n    \"guest_block\"    : bool,\n    \"hidden\"         : bool,\n    \"pagecount\"      : r\"re:[0-9]+\",\n    \"pools\"          : list,\n    \"pools_count\"    : int,\n    \"public\"         : bool,\n    \"rating_id\"      : r\"re:[0-9]+\",\n    \"rating_name\"    : str,\n    \"ratings\"        : list,\n    \"scraps\"         : bool,\n    \"tags\"           : list,\n    \"title\"          : str,\n    \"type_name\"      : str,\n    \"username\"       : \"soina\",\n    \"views\"          : str,\n},\n\n{\n    \"#url\"     : \"https://inkbunny.net/gallery/soina\",\n    \"#category\": (\"\", \"inkbunny\", \"gallery\"),\n    \"#class\"   : inkbunny.InkbunnyUserExtractor,\n    \"#range\"   : \"1-25\",\n\n    \"scraps\": False,\n},\n\n{\n    \"#url\"     : \"https://inkbunny.net/scraps/soina\",\n    \"#category\": (\"\", \"inkbunny\", \"scraps\"),\n    \"#class\"   : inkbunny.InkbunnyUserExtractor,\n    \"#range\"   : \"1-25\",\n\n    \"scraps\": True,\n},\n\n{\n    \"#url\"     : \"https://inkbunny.net/poolview_process.php?pool_id=28985\",\n    \"#category\": (\"\", \"inkbunny\", \"pool\"),\n    \"#class\"   : inkbunny.InkbunnyPoolExtractor,\n    \"#count\"   : 9,\n\n    \"pool_id\": \"28985\",\n},\n\n{\n    \"#url\"     : \"https://inkbunny.net/submissionsviewall.php?rid=ffffffffff&mode=pool&pool_id=28985&page=1&orderby=pool_order&random=no\",\n    \"#category\": (\"\", \"inkbunny\", \"pool\"),\n    \"#class\"   : inkbunny.InkbunnyPoolExtractor,\n},\n\n{\n    \"#url\"     : \"https://inkbunny.net/submissionsviewall.php?mode=pool&pool_id=28985\",\n    \"#category\": (\"\", \"inkbunny\", \"pool\"),\n    \"#class\"   : inkbunny.InkbunnyPoolExtractor,\n},\n\n{\n    \"#url\"     : \"https://inkbunny.net/userfavorites_process.php?favs_user_id=20969\",\n    \"#category\": (\"\", \"inkbunny\", \"favorite\"),\n    \"#class\"   : inkbunny.InkbunnyFavoriteExtractor,\n    \"#pattern\" : r\"https://[\\w.]+\\.metapix\\.net/files/full/\\d+/\\d+_\\w+_.+\",\n    \"#range\"   : \"20-50\",\n\n    \"favs_user_id\" : \"20969\",\n    \"favs_username\": \"soina\",\n},\n\n{\n    \"#url\"     : \"https://inkbunny.net/submissionsviewall.php?rid=ffffffffff&mode=userfavs&random=no&orderby=fav_datetime&page=1&user_id=20969\",\n    \"#category\": (\"\", \"inkbunny\", \"favorite\"),\n    \"#class\"   : inkbunny.InkbunnyFavoriteExtractor,\n},\n\n{\n    \"#url\"     : \"https://inkbunny.net/submissionsviewall.php?mode=userfavs&user_id=20969\",\n    \"#category\": (\"\", \"inkbunny\", \"favorite\"),\n    \"#class\"   : inkbunny.InkbunnyFavoriteExtractor,\n},\n\n{\n    \"#url\"     : \"https://inkbunny.net/submissionsviewall.php?rid=ffffffffff&mode=unreadsubs&page=1&orderby=unread_datetime\",\n    \"#category\": (\"\", \"inkbunny\", \"unread\"),\n    \"#class\"   : inkbunny.InkbunnyUnreadExtractor,\n},\n\n{\n    \"#url\"     : \"https://inkbunny.net/submissionsviewall.php?mode=unreadsubs\",\n    \"#category\": (\"\", \"inkbunny\", \"unread\"),\n    \"#class\"   : inkbunny.InkbunnyUnreadExtractor,\n},\n\n{\n    \"#url\"     : \"https://inkbunny.net/submissionsviewall.php?rid=ffffffffff&mode=search&page=1&orderby=create_datetime&text=cute&stringtype=and&keywords=yes&title=yes&description=no&artist=&favsby=&type=&days=&keyword_id=&user_id=&random=&md5=\",\n    \"#category\": (\"\", \"inkbunny\", \"search\"),\n    \"#class\"   : inkbunny.InkbunnySearchExtractor,\n    \"#range\"   : \"1-10\",\n    \"#count\"   : 10,\n\n    \"search\": {\n        \"rid\"        : \"ffffffffff\",\n        \"mode\"       : \"search\",\n        \"page\"       : \"1\",\n        \"orderby\"    : \"create_datetime\",\n        \"text\"       : \"cute\",\n        \"stringtype\" : \"and\",\n        \"keywords\"   : \"yes\",\n        \"title\"      : \"yes\",\n        \"description\": \"no\",\n    },\n},\n\n{\n    \"#url\"     : \"https://inkbunny.net/submissionsviewall.php?mode=search\",\n    \"#category\": (\"\", \"inkbunny\", \"search\"),\n    \"#class\"   : inkbunny.InkbunnySearchExtractor,\n},\n\n{\n    \"#url\"     : \"https://inkbunny.net/watchlist_process.php?mode=watching&user_id=20969\",\n    \"#category\": (\"\", \"inkbunny\", \"following\"),\n    \"#class\"   : inkbunny.InkbunnyFollowingExtractor,\n    \"#pattern\" : inkbunny.InkbunnyUserExtractor.pattern,\n    \"#count\"   : \">= 90\",\n},\n\n{\n    \"#url\"     : \"https://inkbunny.net/usersviewall.php?rid=ffffffffff&mode=watching&page=1&user_id=20969&orderby=added&namesonly=\",\n    \"#category\": (\"\", \"inkbunny\", \"following\"),\n    \"#class\"   : inkbunny.InkbunnyFollowingExtractor,\n},\n\n{\n    \"#url\"     : \"https://inkbunny.net/usersviewall.php?mode=watching&user_id=20969\",\n    \"#category\": (\"\", \"inkbunny\", \"following\"),\n    \"#class\"   : inkbunny.InkbunnyFollowingExtractor,\n},\n\n{\n    \"#url\"     : \"https://inkbunny.net/s/1829715\",\n    \"#category\": (\"\", \"inkbunny\", \"post\"),\n    \"#class\"   : inkbunny.InkbunnyPostExtractor,\n    \"#pattern\"     : r\"https://[\\w.]+\\.metapix\\.net/files/full/2626/2626843_soina_dscn2296\\.jpg\",\n    \"#sha1_content\": \"cf69d8dddf0822a12b4eef1f4b2258bd600b36c8\",\n},\n\n{\n    \"#url\"     : \"https://inkbunny.net/s/2044094\",\n    \"#category\": (\"\", \"inkbunny\", \"post\"),\n    \"#class\"   : inkbunny.InkbunnyPostExtractor,\n    \"#count\"   : 4,\n},\n\n)\n"
  },
  {
    "path": "test/results/instagram.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import instagram\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://www.instagram.com/instagram/\",\n    \"#category\": (\"\", \"instagram\", \"user\"),\n    \"#class\"   : instagram.InstagramUserExtractor,\n    \"#auth\"    : False,\n    \"#options\" : {\"include\": \"all\"},\n    \"#results\" : (\n        \"https://www.instagram.com/instagram/info/\",\n        \"https://www.instagram.com/instagram/avatar/\",\n        \"https://www.instagram.com/stories/instagram/\",\n        \"https://www.instagram.com/instagram/highlights/\",\n        \"https://www.instagram.com/instagram/posts/\",\n        \"https://www.instagram.com/instagram/reels/\",\n        \"https://www.instagram.com/instagram/tagged/\",\n    ),\n},\n\n{\n    \"#url\"     : \"https://www.instagram.com/instagram/?hl=en\",\n    \"#category\": (\"\", \"instagram\", \"user\"),\n    \"#class\"   : instagram.InstagramUserExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.instagram.com/id:25025320/\",\n    \"#category\": (\"\", \"instagram\", \"user\"),\n    \"#class\"   : instagram.InstagramUserExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.instagram.com/instagram/posts/\",\n    \"#category\": (\"\", \"instagram\", \"posts\"),\n    \"#class\"   : instagram.InstagramPostsExtractor,\n    \"#range\"   : \"1-16\",\n    \"#count\"   : \">= 16\",\n},\n\n{\n    \"#url\"     : \"https://www.instagram.com/instagram/reels/\",\n    \"#category\": (\"\", \"instagram\", \"reels\"),\n    \"#class\"   : instagram.InstagramReelsExtractor,\n    \"#range\"   : \"40-60\",\n    \"#count\"   : \">= 20\",\n},\n\n{\n    \"#url\"     : \"https://www.instagram.com/instagram/tagged/\",\n    \"#category\": (\"\", \"instagram\", \"tagged\"),\n    \"#class\"   : instagram.InstagramTaggedExtractor,\n    \"#range\"   : \"1-16\",\n    \"#count\"   : \">= 16\",\n\n    \"tagged_owner_id\" : \"25025320\",\n    \"tagged_username\" : \"instagram\",\n    \"tagged_full_name\": \"Instagram\",\n},\n\n{\n    \"#url\"     : \"https://www.instagram.com/kadakaofficial/guide/knit-i-need-collection/18131821684305217/\",\n    \"#category\": (\"\", \"instagram\", \"guide\"),\n    \"#class\"   : instagram.InstagramGuideExtractor,\n    \"#range\"   : \"1-16\",\n    \"#count\"   : \">= 16\",\n},\n\n{\n    \"#url\"     : \"https://www.instagram.com/instagram/saved/\",\n    \"#category\": (\"\", \"instagram\", \"saved\"),\n    \"#class\"   : instagram.InstagramSavedExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.instagram.com/instagram/saved/all-posts/\",\n    \"#category\": (\"\", \"instagram\", \"saved\"),\n    \"#class\"   : instagram.InstagramSavedExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.instagram.com/instagram/saved/collection_name/123456789/\",\n    \"#category\": (\"\", \"instagram\", \"collection\"),\n    \"#class\"   : instagram.InstagramCollectionExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.instagram.com/stories/instagram/\",\n    \"#category\": (\"\", \"instagram\", \"stories\"),\n    \"#class\"   : instagram.InstagramStoriesExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.instagram.com/stories/highlights/18042509488170095/\",\n    \"#category\": (\"\", \"instagram\", \"highlights\"),\n    \"#class\"   : instagram.InstagramStoriesExtractor,\n},\n\n{\n    \"#url\"     : \"https://instagram.com/stories/geekmig/2724343156064789461\",\n    \"#category\": (\"\", \"instagram\", \"stories\"),\n    \"#class\"   : instagram.InstagramStoriesExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.instagram.com/stories/me/\",\n    \"#class\"   : instagram.InstagramStoriesTrayExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.instagram.com/s/aGlnaGxpZ2h0OjE4MDQyNTA5NDg4MTcwMDk1\",\n    \"#category\": (\"\", \"instagram\", \"highlights\"),\n    \"#class\"   : instagram.InstagramStoriesExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.instagram.com/s/aGlnaGxpZ2h0OjE4MDQyNTA5NDg4MTcwMDk1?story_media_id=2724343156064789461\",\n    \"#category\": (\"\", \"instagram\", \"highlights\"),\n    \"#class\"   : instagram.InstagramStoriesExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.instagram.com/instagram/highlights\",\n    \"#category\": (\"\", \"instagram\", \"highlights\"),\n    \"#class\"   : instagram.InstagramHighlightsExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.instagram.com/instagram/following\",\n    \"#category\": (\"\", \"instagram\", \"following\"),\n    \"#class\"   : instagram.InstagramFollowingExtractor,\n    \"#range\"   : \"1-16\",\n    \"#count\"   : \">= 16\",\n},\n\n{\n    \"#url\"     : \"https://www.instagram.com/explore/tags/instagram/\",\n    \"#category\": (\"\", \"instagram\", \"tag\"),\n    \"#class\"   : instagram.InstagramTagExtractor,\n    \"#range\"   : \"1-16\",\n    \"#count\"   : \">= 16\",\n},\n\n{\n    \"#url\"     : \"https://www.instagram.com/instagram/info\",\n    \"#category\": (\"\", \"instagram\", \"info\"),\n    \"#class\"   : instagram.InstagramInfoExtractor,\n    \"#auth\"    : False,\n},\n\n{\n    \"#url\"     : \"https://www.instagram.com/instagram/avatar\",\n    \"#category\": (\"\", \"instagram\", \"avatar\"),\n    \"#class\"   : instagram.InstagramAvatarExtractor,\n    \"#pattern\" : r\"https://instagram\\.[\\w.-]+\\.fbcdn\\.net/v/t51\\.2885-19/281440578_1088265838702675_6233856337905829714_n\\.jpg\",\n},\n\n{\n    \"#url\"     : \"https://www.instagram.com/p/BqvsDleB3lV/\",\n    \"#comment\" : \"GraphImage\",\n    \"#category\": (\"\", \"instagram\", \"post\"),\n    \"#class\"   : instagram.InstagramPostExtractor,\n    \"#pattern\" : r\"https://[^/]+\\.(cdninstagram\\.com|fbcdn\\.net)/v(p/[0-9a-f]+/[0-9A-F]+)?/t51.2885-15/e35/44877605_725955034447492_3123079845831750529_n.jpg\",\n\n    \"date\"          : \"dt:2018-11-29 01:04:04\",\n    \"description\"   : str,\n    \"height\"        : int,\n    \"likes\"         : int,\n    \"location_id\"   : \"214424288\",\n    \"location_slug\" : \"hong-kong\",\n    \"location_url\"  : r\"re:/explore/locations/214424288/hong-kong/\",\n    \"media_id\"      : \"1922949326347663701\",\n    \"shortcode\"     : \"BqvsDleB3lV\",\n    \"post_id\"       : \"1922949326347663701\",\n    \"post_shortcode\": \"BqvsDleB3lV\",\n    \"post_url\"      : \"https://www.instagram.com/p/BqvsDleB3lV/\",\n    \"tags\"          : [\"#WHPsquares\"],\n    \"typename\"      : \"GraphImage\",\n    \"username\"      : \"instagram\",\n    \"width\"         : int,\n},\n\n{\n    \"#url\"     : \"https://www.instagram.com/p/BoHk1haB5tM/\",\n    \"#comment\" : \"GraphSidecar\",\n    \"#category\": (\"\", \"instagram\", \"post\"),\n    \"#class\"   : instagram.InstagramPostExtractor,\n    \"#count\"   : 5,\n\n    \"sidecar_media_id\": \"1875629777499953996\",\n    \"sidecar_shortcode\": \"BoHk1haB5tM\",\n    \"post_id\"         : \"1875629777499953996\",\n    \"post_shortcode\"  : \"BoHk1haB5tM\",\n    \"post_url\"        : \"https://www.instagram.com/p/BoHk1haB5tM/\",\n    \"num\"             : int,\n    \"likes\"           : int,\n    \"username\"        : \"instagram\",\n},\n\n{\n    \"#url\"     : \"https://www.instagram.com/p/Bqxp0VSBgJg/\",\n    \"#comment\" : \"GraphVideo\",\n    \"#category\": (\"\", \"instagram\", \"post\"),\n    \"#class\"   : instagram.InstagramPostExtractor,\n    \"#pattern\" : r\"/46840863_726311431074534_7805566102611403091_n\\.mp4\",\n\n    \"date\"       : \"dt:2018-11-29 19:23:58\",\n    \"description\": str,\n    \"height\"     : int,\n    \"likes\"      : int,\n    \"media_id\"   : \"1923502432034620000\",\n    \"post_url\"   : \"https://www.instagram.com/p/Bqxp0VSBgJg/\",\n    \"shortcode\"  : \"Bqxp0VSBgJg\",\n    \"tags\"       : [\"#ASMR\"],\n    \"typename\"   : \"GraphVideo\",\n    \"username\"   : \"instagram\",\n    \"width\"      : int,\n},\n\n{\n    \"#url\"     : \"https://www.instagram.com/tv/BkQjCfsBIzi/\",\n    \"#comment\" : \"GraphVideo (IGTV)\",\n    \"#category\": (\"\", \"instagram\", \"post\"),\n    \"#class\"   : instagram.InstagramPostExtractor,\n    \"#pattern\" : r\"/10000000_597132547321814_702169244961988209_n\\.mp4\",\n\n    \"date\"       : \"dt:2018-06-20 19:51:32\",\n    \"description\": str,\n    \"height\"     : int,\n    \"likes\"      : int,\n    \"media_id\"   : \"1806097553666903266\",\n    \"post_url\"   : \"https://www.instagram.com/p/BkQjCfsBIzi/\",\n    \"shortcode\"  : \"BkQjCfsBIzi\",\n    \"typename\"   : \"GraphVideo\",\n    \"username\"   : \"instagram\",\n    \"width\"      : int,\n},\n\n{\n    \"#url\"     : \"https://www.instagram.com/p/BtOvDOfhvRr/\",\n    \"#comment\" : \"GraphSidecar with 2 embedded GraphVideo objects\",\n    \"#category\": (\"\", \"instagram\", \"post\"),\n    \"#class\"   : instagram.InstagramPostExtractor,\n    \"#count\"   : 2,\n\n    \"post_url\"        : \"https://www.instagram.com/p/BtOvDOfhvRr/\",\n    \"sidecar_media_id\": \"1967717017113261163\",\n    \"sidecar_shortcode\": \"BtOvDOfhvRr\",\n    \"video_url\"       : str,\n},\n\n{\n    \"#url\"     : \"https://www.instagram.com/p/B_2lf3qAd3y/\",\n    \"#comment\" : \"GraphImage with tagged user\",\n    \"#category\": (\"\", \"instagram\", \"post\"),\n    \"#class\"   : instagram.InstagramPostExtractor,\n\n    \"tagged_users\": [{\n    \"id\"       : \"1246468638\",\n    \"username\" : \"kaaymbl\",\n    \"full_name\": \"Call Me Kay\",\n}],\n},\n\n{\n    \"#url\"     : \"https://www.instagram.com/dm/p/CW042g7B9CY/\",\n    \"#comment\" : \"URL with username (#2085)\",\n    \"#category\": (\"\", \"instagram\", \"post\"),\n    \"#class\"   : instagram.InstagramPostExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.instagram.com/reel/CDg_6Y1pxWu/\",\n    \"#category\": (\"\", \"instagram\", \"reel\"),\n    \"#class\"   : instagram.InstagramPostExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.instagram.com/reels/CDg_6Y1pxWu/\",\n    \"#category\": (\"\", \"instagram\", \"reel\"),\n    \"#class\"   : instagram.InstagramPostExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.instagram.com/share/BACiUUUYQV\",\n    \"#category\": (\"\", \"instagram\", \"post\"),\n    \"#class\"   : instagram.InstagramPostExtractor,\n    \"shortcode\"  : \"C6q-XdvsU5v\",\n},\n\n{\n    \"#url\"     : \"https://www.instagram.com/share/p/BACiUUUYQV\",\n    \"#category\": (\"\", \"instagram\", \"post\"),\n    \"#class\"   : instagram.InstagramPostExtractor,\n    \"shortcode\"  : \"C6q-XdvsU5v\",\n},\n\n{\n    \"#url\"     : \"https://www.instagram.com/share/reel/BARSSL4rTu\",\n    \"#category\": (\"\", \"instagram\", \"reel\"),\n    \"#class\"   : instagram.InstagramPostExtractor,\n    \"shortcode\"  : \"DHbVbT4Jx0c\",\n}\n\n)\n"
  },
  {
    "path": "test/results/issuu.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import issuu\nfrom gallery_dl import exception\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://issuu.com/issuu/docs/motions-1-2019/\",\n    \"#class\"   : issuu.IssuuPublicationExtractor,\n    \"#pattern\" : r\"https://image.isu.pub/190916155301-\\w+/jpg/page_\\d+.jpg\",\n    \"#count\"   : 36,\n\n    \"document\" : {\n        \"access\"       : \"PUBLIC\",\n        \"contentRating\": {\n            \"isAdsafe\"  : True,\n            \"isExplicit\": False,\n            \"isReviewed\": True,\n        },\n        \"date\"         : \"dt:2019-09-16 00:00:00\",\n        \"description\"  : r\"re:Motions, the brand new publication by I\",\n        \"documentName\" : \"motions-1-2019\",\n        \"pageCount\"    : 36,\n        \"publicationId\": \"d99ec95935f15091b040cb8060f05510\",\n        \"title\"        : \"Motions by Issuu - Issue 1\",\n        \"username\"     : \"issuu\",\n    },\n    \"extension\": \"jpg\",\n    \"filename\" : r\"re:page_\\d+\",\n    \"num\"      : int,\n},\n\n{\n    \"#url\"      : \"https://issuu.com/foodhome1955/docs/fh_winter2025-issuu-011625\",\n    \"#comment\"  : \"HTML escapes\",\n    \"#class\"    : issuu.IssuuPublicationExtractor,\n    \"#exception\": exception.NotFoundError,\n    \"#count\"    : 84,\n\n    \"document\": {\n        \"access\"          : \"PUBLIC\",\n        \"date\"            : \"dt:2025-01-17 00:00:00\",\n        \"description\"     : \"Santa Barbara's Lifestyle Magazine\",\n        \"documentName\"    : \"fh_winter2025-issuu-011625\",\n        \"isDocumentGated\" : False,\n        \"originalPublishDateInISOString\": \"2025-01-17T00:00:00.000Z\",\n        \"pageCount\"       : 84,\n        \"publicationId\"   : \"b89e35d4bd2201c7ecd871160fe000fa\",\n        \"revisionId\"      : \"250117005419\",\n        \"title\"           : \"Food & Home Winter 2025\",\n        \"username\"        : \"foodhome1955\",\n        \"contentRating\"   : {\n            \"isAdsafe\"    : True,\n            \"isExplicit\"  : False,\n            \"isReviewed\"  : True,\n        },\n        \"path\"            : {\n            \"documentName\": \"fh_winter2025-issuu-011625\",\n            \"type\"        : \"user\",\n            \"username\"    : \"foodhome1955\",\n        },\n    },\n},\n\n{\n    \"#url\"     : \"https://issuu.com/issuu\",\n    \"#class\"   : issuu.IssuuUserExtractor,\n    \"#pattern\" : issuu.IssuuPublicationExtractor.pattern,\n    \"#count\"   : range(100, 150),\n},\n\n{\n    \"#url\"     : \"https://issuu.com/issuu/3\",\n    \"#class\"   : issuu.IssuuUserExtractor,\n    \"#count\"   : range(4, 40),\n},\n\n)\n"
  },
  {
    "path": "test/results/itaku.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import itaku\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://itaku.ee/profile/piku\",\n    \"#class\"   : itaku.ItakuUserExtractor,\n    \"#results\" : (\n        \"https://itaku.ee/profile/piku/gallery\",\n    ),\n},\n\n{\n    \"#url\"     : \"https://itaku.ee/profile/piku\",\n    \"#class\"   : itaku.ItakuUserExtractor,\n    \"#options\" : {\"include\": \"all\"},\n    \"#results\" : (\n        \"https://itaku.ee/profile/piku/gallery\",\n        \"https://itaku.ee/profile/piku/posts\",\n        \"https://itaku.ee/profile/piku/followers\",\n        \"https://itaku.ee/profile/piku/following\",\n        \"https://itaku.ee/profile/piku/stars\",\n    ),\n},\n\n{\n    \"#url\"     : \"https://itaku.ee/profile/piku/gallery\",\n    \"#class\"   : itaku.ItakuGalleryExtractor,\n    \"#pattern\" : r\"https://itaku\\.ee/api/media/gallery_imgs/[^/?#]+\\.(jpg|png|gif)\",\n    \"#range\"   : \"1-10\",\n    \"#count\"   : 10,\n},\n\n{\n    \"#url\"     : \"https://itaku.ee/profile/piku/gallery/7391\",\n    \"#comment\" : \"gallery section (#6951)\",\n    \"#class\"   : itaku.ItakuGalleryExtractor,\n    \"#results\" : (\n        \"https://itaku.ee/api/media/gallery_imgs/misty-psyduck_IWbYdwT.png\",\n        \"https://itaku.ee/api/media/gallery_imgs/bea_alpha_N0YGfeT.png\",\n    ),\n\n    \"sections\" : [\"Fanart/Pokemon\"],\n},\n\n{\n    \"#url\"     : \"https://itaku.ee/profile/piku/gallery/7391\",\n    \"#comment\" : \"'order' option\",\n    \"#class\"   : itaku.ItakuGalleryExtractor,\n    \"#options\" : {\"order\": \"reverse\"},\n    \"#results\" : (\n        \"https://itaku.ee/api/media/gallery_imgs/bea_alpha_N0YGfeT.png\",\n        \"https://itaku.ee/api/media/gallery_imgs/misty-psyduck_IWbYdwT.png\",\n    ),\n\n    \"sections\" : [\"Fanart/Pokemon\"],\n},\n\n{\n    \"#url\"     : \"https://itaku.ee/profile/piku/posts\",\n    \"#class\"   : itaku.ItakuPostsExtractor,\n    \"#results\" : (\n        \"https://itaku.ee/api/media/gallery_imgs/220415_xEFUVR6.png\",\n        \"https://itaku.ee/api/media/gallery_imgs/220308_J0mgJ24.png\",\n        \"https://itaku.ee/api/media/gallery_imgs/220511_rdGpatf.png\",\n        \"https://itaku.ee/api/media/gallery_imgs/220420b_4Lrk6gB.png\",\n    ),\n\n    \"id\"   : {23762, 16422},\n    \"count\": {3, 1},\n    \"num\"  : range(1, 3),\n    \"date\" : \"type:datetime\",\n    \"title\": {\"Maids\", \"\"},\n},\n\n{\n    \"#url\"     : \"https://itaku.ee/profile/starluxioad/posts/2008\",\n    \"#comment\" : \"posts folder\",\n    \"#class\"   : itaku.ItakuPostsExtractor,\n    \"#count\"   : 12,\n\n    \"id\"   : {160779, 160163, 151859, 151851, 150443},\n    \"count\": {2, 3},\n    \"num\"  : range(1, 3),\n    \"date\" : \"type:datetime\",\n    \"title\": str,\n},\n\n{\n    \"#url\"     : \"https://itaku.ee/profile/piku/stars\",\n    \"#class\"   : itaku.ItakuStarsExtractor,\n    \"#pattern\" : r\"https://itaku\\.ee/api/media/gallery_imgs/[^/?#]+\\.(jpg|png|gif)\",\n    \"#range\"   : \"1-10\",\n    \"#count\"   : 10,\n},\n\n{\n    \"#url\"     : \"https://itaku.ee/profile/piku/followers\",\n    \"#class\"   : itaku.ItakuFollowersExtractor,\n    \"#pattern\" : itaku.ItakuUserExtractor.pattern,\n    \"#range\"   : \"1-60\",\n    \"#count\"   : 60,\n},\n\n{\n    \"#url\"     : \"https://itaku.ee/profile/piku/following\",\n    \"#class\"   : itaku.ItakuFollowingExtractor,\n    \"#pattern\" : itaku.ItakuUserExtractor.pattern,\n    \"#range\"   : \"1-60\",\n    \"#count\"   : 60,\n},\n\n{\n    \"#url\"     : \"https://itaku.ee/profile/USER/bookmarks/image/13712\",\n    \"#class\"   : itaku.ItakuBookmarksExtractor,\n    \"#results\" : (\n        \"https://itaku.ee/api/media/gallery_imgs/220511_rdGpatf.png\",\n        \"https://itaku.ee/api/media/gallery_imgs/220504_oUNIAFT.png\",\n        \"https://itaku.ee/api/media/gallery_vids/sleepy_af_OY5GHWw.mp4\",\n    ),\n},\n\n{\n    \"#url\"     : \"https://itaku.ee/profile/USER/bookmarks/user/11069\",\n    \"#class\"   : itaku.ItakuBookmarksExtractor,\n    \"#results\" : (\n        \"https://itaku.ee/profile/deliciousorange\",\n        \"https://itaku.ee/profile/piku\",\n    ),\n},\n\n{\n    \"#url\"     : \"https://itaku.ee/images/100471\",\n    \"#class\"   : itaku.ItakuImageExtractor,\n    \"#results\" : \"https://itaku.ee/api/media/gallery_imgs/220504_oUNIAFT.png\",\n\n    \"already_pinned\"  : None,\n    \"blacklisted\"     : {\n        \"blacklisted_tags\": [],\n        \"is_blacklisted\"  : False,\n    },\n    \"can_reshare\"     : True,\n    \"date\"            : \"dt:2022-05-05 19:21:17\",\n    \"date_added\"      : \"2022-05-05T19:21:17.674148Z\",\n    \"date_edited\"     : \"2022-05-25T14:37:46.220612Z\",\n    \"description\"     : \"sketch from drawpile\",\n    \"extension\"       : \"png\",\n    \"filename\"        : \"220504_oUNIAFT\",\n    \"hotness_score\"   : float,\n    \"id\"              : 100471,\n    \"image\"           : \"https://itaku.ee/api/media/gallery_imgs/220504_oUNIAFT.png\",\n    \"image_xl\"        : \"https://itaku.ee/api/media/gallery_imgs/220504_oUNIAFT/lg.jpg\",\n    \"liked_by_you\"    : False,\n    \"maturity_rating\" : \"SFW\",\n    \"num_comments\"    : int,\n    \"num_likes\"       : int,\n    \"num_reshares\"    : int,\n    \"obj_tags\"        : 136446,\n    \"owner\"           : 16775,\n    \"owner_avatar\"    : \"https://itaku.ee/api/media/profile_pics/av2022r_vKYVywc/md.jpg\",\n    \"owner_displayname\": \"Piku\",\n    \"owner_username\"  : \"piku\",\n    \"reshared_by_you\" : False,\n    \"sections\"        : [\"Fanart/Miku\"],\n    \"tags\"            : list,\n    \"tags_character\"  : [\"hatsune_miku\"],\n    \"tags_copyright\"  : [\"vocaloid\"],\n    \"tags_general\"    : [\n        \"female\",\n        \"green_eyes\",\n        \"twintails\",\n        \"green_hair\",\n        \"gloves\",\n        \"flag\",\n        \"racing_miku\",\n    ],\n    \"title\"           : \"Racing Miku 2022 Ver.\",\n    \"too_mature\"      : False,\n    \"uncompressed_filesize\": \"0.62\",\n    \"video\"           : None,\n    \"visibility\"      : \"PUBLIC\",\n},\n\n{\n    \"#url\"     : \"https://itaku.ee/images/19465\",\n    \"#comment\" : \"video\",\n    \"#class\"   : itaku.ItakuImageExtractor,\n    \"#results\" : \"https://itaku.ee/api/media/gallery_vids/sleepy_af_OY5GHWw.mp4\",\n},\n\n{\n    \"#url\"     : \"https://itaku.ee/posts/16422\",\n    \"#class\"   : itaku.ItakuPostExtractor,\n    \"#results\" : \"https://itaku.ee/api/media/gallery_imgs/220420b_4Lrk6gB.png\",\n\n    \"already_pinned\" : None,\n    \"can_reshare\"    : True,\n    \"category\"       : \"itaku\",\n    \"content\"        : \"\",\n    \"content_warning\": \"\",\n    \"count\"          : 1,\n    \"created_by_images\": True,\n    \"date\"           : \"dt:2022-04-26 16:06:30\",\n    \"date_added\"     : \"2022-04-26T16:06:30.352389Z\",\n    \"date_edited\"    : \"2022-05-10T21:32:44.017311Z\",\n    \"extension\"      : \"png\",\n    \"file\"           : {\n        \"already_pinned\" : None,\n        \"animated\"       : False,\n        \"blacklisted\"    : {\n            \"blacklisted_tags\": [],\n            \"is_blacklisted\"  : False,\n        },\n        \"bookmarked_by_you\": False,\n        \"content_warning\": \"\",\n        \"date\"           : \"dt:2022-04-26 16:06:28\",\n        \"date_added\"     : \"2022-04-26T16:06:28.272442Z\",\n        \"date_edited\"    : \"2022-06-30T09:43:58.816192Z\",\n        \"id\"             : 77775,\n        \"image\"          : \"https://itaku.ee/api/media/gallery_imgs/220420b_4Lrk6gB.png\",\n        \"image_lg\"       : \"https://itaku.ee/api/media/gallery_imgs/220420b_4Lrk6gB/lg.jpg\",\n        \"image_xl\"       : \"https://itaku.ee/api/media/gallery_imgs/220420b_4Lrk6gB/lg.jpg\",\n        \"is_thumbnail_for_video\": False,\n        \"liked_by_you\"   : False,\n        \"maturity_rating\": \"SFW\",\n        \"num_comments\"   : 0,\n        \"num_likes\"      : range(60, 90),\n        \"num_reshares\"   : 0,\n        \"owner\"          : 16775,\n        \"owner_displayname\": \"Piku\",\n        \"show_content_warning\": False,\n        \"title\"          : \"Felicia\",\n        \"too_mature\"     : False,\n        \"visibility\"     : \"PUBLIC\",\n    },\n    \"filename\"       : \"220420b_4Lrk6gB\",\n    \"folders\"        : [],\n    \"id\"             : 16422,\n    \"liked_by_you\"   : False,\n    \"maturity_rating\": \"SFW\",\n    \"num\"            : 1,\n    \"num_comments\"   : 0,\n    \"num_images\"     : 1,\n    \"num_likes\"      : range(40, 70),\n    \"num_reshares\"   : 0,\n    \"obj_tags\"       : 99052,\n    \"owner\"          : 16775,\n    \"owner_avatar\"   : \"https://itaku.ee/api/media/profile_pics/av2022r_vKYVywc/md.jpg\",\n    \"owner_displayname\": \"Piku\",\n    \"owner_username\" : \"piku\",\n    \"poll\"           : None,\n    \"reshared_by_you\": False,\n    \"subcategory\"    : \"post\",\n    \"tags\"           : [],\n    \"title\"          : \"\",\n    \"too_mature\"     : False,\n    \"visibility\"     : \"PUBLIC\",\n},\n\n{\n    \"#url\"     : \"https://itaku.ee/home/images?tags=cute\",\n    \"#comment\" : \"simple search\",\n    \"#class\"   : itaku.ItakuSearchExtractor,\n    \"#range\"   : \"1-10\",\n    \"#count\"   : 10,\n},\n\n{\n    \"#url\"     : \"https://itaku.ee/home/images?maturity_rating=SFW&date_range=&ordering=-date_added&text=hello&is_video=true\",\n    \"#comment\" : \"search for videos\",\n    \"#class\"   : itaku.ItakuSearchExtractor,\n    \"#count\"   : range(5, 50),\n},\n\n{\n    \"#url\"     : \"https://itaku.ee/home/images?tags=cute&tags=-cute&tags=~cute&maturity_rating=SFW&date_range=&ordering=-date_added\",\n    \"#comment\" : \"search with postive, negative, and optional tags\",\n    \"#class\"   : itaku.ItakuSearchExtractor,\n    \"#count\"   : 0,\n},\n)\n"
  },
  {
    "path": "test/results/itchio.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import itchio\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://sirtartarus.itch.io/a-craft-of-mine\",\n    \"#category\": (\"\", \"itchio\", \"game\"),\n    \"#class\"   : itchio.ItchioGameExtractor,\n    \"#pattern\" : r\"https://(dl.itch.zone|itchio-mirror.\\w+.r2.cloudflarestorage.com)/upload2/game/1983311/\\d+\\?\",\n    \"#count\"   : 3,\n\n    \"extension\": \"\",\n    \"filename\" : r\"re:\\d+\",\n    \"game\"     : {\n        \"id\"   : 1983311,\n        \"noun\" : \"game\",\n        \"title\": \"A Craft Of Mine\",\n        \"url\"  : \"https://sirtartarus.itch.io/a-craft-of-mine\",\n    },\n    \"user\"     : {\n        \"id\"  : 4060052,\n        \"name\": \"SirTartarus\",\n        \"url\" : \"https://sirtartarus.itch.io\",\n    },\n},\n\n)\n"
  },
  {
    "path": "test/results/iwara.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import iwara\n\n\n__tests__ = (\n{\n    \"#url\"          : \"https://www.iwara.tv/profile/user2426993\",\n    \"#class\"        : iwara.IwaraUserExtractor,\n    \"#results\"      : (\n        \"https://www.iwara.tv/profile/user2426993/images\",\n        \"https://www.iwara.tv/profile/user2426993/videos\",\n    ),\n},\n\n{\n    \"#url\"     : \"https://www.iwara.ai/profile/goldenapple\",\n    \"#class\"   : iwara.IwaraUserExtractor,\n    \"#results\" : (\n        \"https://www.iwara.ai/profile/goldenapple/images\",\n        \"https://www.iwara.ai/profile/goldenapple/videos\",\n    ),\n},\n\n{\n    \"#url\"          : \"https://www.iwara.tv/profile/user2426993/images\",\n    \"#class\"        : iwara.IwaraUserImagesExtractor,\n    \"#pattern\"      : r\"https://i.iwara.tv/image/original/.+\",\n\n    \"extension\": \"png\",\n    \"type\"     : \"image\",\n    \"count\"    : {1, 9},\n    \"num\"      : range(1, 9),\n},\n\n{\n    \"#url\"          : \"https://www.iwara.tv/profile/user2426993/videos\",\n    \"#class\"        : iwara.IwaraUserVideosExtractor,\n    \"#pattern\"      : r\"https://\\w+.iwara.tv/download\\?.+\",\n\n    \"extension\": \"mp4\",\n    \"type\"     : \"video\",\n    \"count\"    : 1,\n    \"num\"      : 1,\n},\n\n{\n    \"#url\"          : \"https://www.iwara.tv/profile/tyron82/playlists\",\n    \"#class\"        : iwara.IwaraUserPlaylistsExtractor,\n    \"#pattern\"      : iwara.IwaraPlaylistExtractor.pattern,\n    \"#count\"        : range(10, 20),\n\n    \"type\"          : \"playlist\",\n},\n\n{\n    \"#url\"          : \"https://www.iwara.tv/profile/tyron82/following\",\n    \"#class\"        : iwara.IwaraFollowingExtractor,\n    \"#pattern\"      : iwara.IwaraUserExtractor.pattern,\n    \"#range\"        : \"1-100\",\n    \"#count\"        : 100,\n\n    \"type\"          : \"user\",\n},\n\n{\n    \"#url\"          : \"https://www.iwara.tv/profile/tyron82/followers\",\n    \"#class\"        : iwara.IwaraFollowersExtractor,\n    \"#pattern\"      : iwara.IwaraUserExtractor.pattern,\n    \"#range\"        : \"1-100\",\n    \"#count\"        : 100,\n\n    \"type\"          : \"user\",\n},\n\n{\n    \"#url\"          : \"https://www.iwara.tv/playlist/01ea603a-4e70-4a36-bc28-dc717eebc2d7\",\n    \"#category\"     : (\"\", \"iwara\", \"playlist\"),\n    \"#class\"        : iwara.IwaraPlaylistExtractor,\n    \"#pattern\"      : r\"https://\\w+.iwara.tv/download\\?.+&filename=b7708020-f531-4eb4-bfd3-c62f3d17927e_Source.mp4&path=2024%2F05%2F12&.+\",\n    \"#count\"        : 1,\n\n    \"id\"            : \"OaoVL8nqijDjhB\",\n    \"title\"         : \"MMD.RuanMei's body modification\",\n    \"file_id\"       : \"b7708020-f531-4eb4-bfd3-c62f3d17927e\",\n    \"filename\"      : \"b7708020-f531-4eb4-bfd3-c62f3d17927e\",\n    \"extension\"     : \"mp4\",\n    \"mime\"          : \"video/mp4\",\n    \"size\"          : 225197782,\n    \"width\"         : None,\n    \"height\"        : None,\n    \"duration\"      : 654,\n    \"type\"          : \"video\",\n    \"user\"          : {\n        \"date\"       : \"dt:2020-05-15 09:59:32\",\n        \"description\": str,\n        \"id\"         : \"c9a08dd5-3cb5-4d7c-b9bb-9eb4c55eda14\",\n        \"name\"       : \"arisananades\",\n        \"nick\"       : \"Arisananades\",\n        \"premium\"    : False,\n        \"role\"       : \"user\",\n        \"status\"     : \"active\",\n    },\n},\n\n{\n    \"#url\"          : \"https://www.iwara.tv/favorites/videos\",\n    \"#class\"        : iwara.IwaraFavoriteExtractor,\n    \"#auth\"         : True,\n},\n\n{\n    \"#url\"          : \"https://www.iwara.tv/favorites/images\",\n    \"#class\"        : iwara.IwaraFavoriteExtractor,\n    \"#auth\"         : True,\n},\n\n{\n    \"#url\"          : \"https://www.iwara.tv/search?query=genshin%20tentacle&type=video\",\n    \"#category\"     : (\"\", \"iwara\", \"search\"),\n    \"#class\"        : iwara.IwaraSearchExtractor,\n    \"#count\"        : 5,\n\n    \"extension\"     : \"mp4\",\n    \"mime\"          : \"video/mp4\",\n    \"width\"         : None,\n    \"height\"        : None,\n    \"type\"          : \"video\",\n    \"user\": {\n        \"date\"       : \"dt:2022-01-12 17:08:38\",\n        \"description\": str,\n        \"id\"         : \"3ec40862-bcb6-4c2e-9f3b-6da3a00cc2d9\",\n        \"name\"       : \"nizipaco-kyu\",\n        \"nick\"       : \"Nizipaco - Kyu\",\n        \"premium\"    : False,\n        \"role\"       : \"user\",\n        \"status\"     : \"active\",\n    },\n},\n\n{\n    \"#url\"          : \"https://www.iwara.tv/search?query=genshin%20layla%20sex&type=image\",\n    \"#category\"     : (\"\", \"iwara\", \"search\"),\n    \"#class\"        : iwara.IwaraSearchExtractor,\n    \"#count\"        : range(40, 80),\n\n    \"duration\"      : None,\n    \"type\"          : \"image\",\n},\n\n{\n    \"#url\"          : \"https://www.iwara.tv/videos?tags=aether%2Ccitlali\",\n    \"#category\"     : (\"\", \"iwara\", \"tag\"),\n    \"#class\"        : iwara.IwaraTagExtractor,\n    \"#pattern\"      : r\"https://\\w+.iwara.tv/download\\?.+&filename=.+\",\n\n    \"user\"          : dict,\n    \"extension\"     : \"mp4\",\n    \"mime\"          : \"video/mp4\",\n    \"width\"         : None,\n    \"height\"        : None,\n    \"type\"          : \"video\",\n    \"search_tags\"   : \"aether,citlali\",\n    \"duration\"      : range(90, 600),\n},\n\n{\n    \"#url\"          : \"https://www.iwara.tv/images?tags=genshin_impact%2Ccitlali\",\n    \"#category\"     : (\"\", \"iwara\", \"tag\"),\n    \"#class\"        : iwara.IwaraTagExtractor,\n    \"#pattern\"      : r\"https://i.iwara.tv/image/original/[0-9a-f-]{36}/[0-9a-f-]{36}\\.(jpg|png|webm)\",\n\n    \"duration\"    : None,\n    \"extension\"   : {\"jpg\", \"png\", \"webm\"},\n    \"mime\"        : {\"image/jpeg\", \"image/png\", \"video/webm\"},\n    \"search_tags\" : \"genshin_impact,citlali\",\n    \"type\"        : \"image\",\n},\n\n{\n    \"#url\"        : \"https://www.iwara.tv/video/6QvQvzZnELJ9vv/bluearchive-rio\",\n    \"#category\"   : (\"\", \"iwara\", \"video\"),\n    \"#class\"      : iwara.IwaraVideoExtractor,\n    \"#pattern\"    : r\"https://\\w+.iwara.tv/download\\?.+\",\n    \"#count\"      : 1,\n\n    \"comments\"    : range(100, 200),\n    \"count\"       : 1,\n    \"date\"        : \"dt:2025-07-05 06:49:56\",\n    \"date_updated\": \"dt:2025-07-05 06:50:14\",\n    \"duration\"    : 107,\n    \"extension\"   : \"mp4\",\n    \"file_id\"     : \"7ba6e734-b9df-4588-88fc-4eef2bbf5c56\",\n    \"filename\"    : \"7ba6e734-b9df-4588-88fc-4eef2bbf5c56\",\n    \"height\"      : None,\n    \"id\"          : \"6QvQvzZnELJ9vv\",\n    \"likes\"       : range(8_000, 15_000),\n    \"mime\"        : \"video/mp4\",\n    \"num\"         : 1,\n    \"rating\"      : \"ecchi\",\n    \"size\"        : 86328642,\n    \"slug\"        : \"bluearchive-rio\",\n    \"title\"       : \"[BlueArchive / ブルアカ] Rio\",\n    \"type\"        : \"video\",\n    \"views\"       : range(200_000, 500_000),\n    \"width\"       : None,\n    \"description\" : \"\"\"\\\nYou can find FHD(1080p) and UHD(2160p) videos on my patreon page, so please check that out if you are interested.\n\nPatreon : https://www.patreon.com/croove\nTwitter : https://x.com/croove_nsfw\\\n\"\"\",\n    \"tags\"        : [\n        \"blender\",\n        \"blue_archive\",\n        \"tsukatsuki_rio\",\n    ],\n    \"user\"        : {\n        \"date\"       : \"dt:2022-04-01 01:55:59\",\n        \"id\"         : \"b3f86af1-874c-41f1-b62e-4e4b736ad3a4\",\n        \"name\"       : \"croove\",\n        \"nick\"       : \"crooveNSFW\",\n        \"premium\"    : False,\n        \"role\"       : \"user\",\n        \"status\"     : \"active\",\n        \"description\": \"\"\"\\\nYou can find FHD(1080p) and UHD(2160p) videos on my patreon page, so please check that out if you are interested.\n\nPatreon : https://www.patreon.com/croove\nTwitter : https://x.com/croove_nsfw\\\n\"\"\",\n    },\n},\n\n{\n    \"#url\"     : \"https://www.iwara.ai/video/GF56ILQxKThJnE/yeshunguang2\",\n    \"#class\"   : iwara.IwaraVideoExtractor,\n    \"#pattern\" : r\"https://\\w+\\.iwara\\.tv/download\\?hash=.+&filename=8dd8bbca-dabd-4fed-a295-fb69b1926459_Source.mp4&path=2026%2F03%2F13&expires=\\d+\",\n\n    \"comments\"    : int,\n    \"count\"       : 1,\n    \"num\"         : 1,\n    \"date\"        : \"dt:2026-03-13 12:38:30\",\n    \"date_updated\": \"type:datetime\",\n    \"duration\"    : 148,\n    \"extension\"   : \"mp4\",\n    \"file_id\"     : \"8dd8bbca-dabd-4fed-a295-fb69b1926459\",\n    \"filename\"    : \"8dd8bbca-dabd-4fed-a295-fb69b1926459\",\n    \"format\"      : \"Source\",\n    \"height\"      : 960,\n    \"id\"          : \"GF56ILQxKThJnE\",\n    \"likes\"       : int,\n    \"mime\"        : \"video/mp4\",\n    \"rating\"      : \"ecchi\",\n    \"size\"        : 80737302,\n    \"slug\"        : \"yeshunguang2\",\n    \"title\"       : \"叶瞬光YeShunguang葉瞬光💕2\",\n    \"type\"        : \"video\",\n    \"views\"       : int,\n    \"width\"       : 720,\n    \"description\" : \"\"\"\\\n赞助平台/Sponsorship platform → [Patreon](https://www.patreon.com/c/oldapple) | [UniFans](https://app.unifans.io/c/oldapple)（微/支）\n\n赞助可查看我制作的所有视频/Sponsor to view all my videos\\\n\"\"\",\n    \"tags\"        : [\n        \"ai\",\n        \"ai_generated\",\n        \"semi_realistic_style\",\n        \"ye_shunguang\",\n        \"zenless_zone_zero\",\n    ],\n    \"user\"        : {\n        \"date\"       : \"dt:2022-03-14 02:48:24\",\n        \"id\"         : \"a2b21572-415d-4c2a-ab1b-313dbd723edf\",\n        \"name\"       : \"goldenapple\",\n        \"nick\"       : \"Old Apple\",\n        \"premium\"    : True,\n        \"role\"       : \"user\",\n        \"status\"     : \"active\",\n    },\n},\n\n{\n    \"#url\"        : \"https://www.iwara.tv/image/5m3gLfcei6BQsL/sparkle\",\n    \"#category\"   : (\"\", \"iwara\", \"image\"),\n    \"#class\"      : iwara.IwaraImageExtractor,\n    \"#pattern\"    : r\"https://i.iwara.tv/image/original/[\\w-]{36}/[\\w-]{36}\\.png\",\n    \"#count\"      : 13,\n\n    \"comments\"    : int,\n    \"count\"       : 13,\n    \"date\"        : \"type:datetime\",\n    \"date_updated\": \"type:datetime\",\n    \"description\" : \"card from OoOoO & Rat\",\n    \"duration\"    : None,\n    \"extension\"   : \"png\",\n    \"file_id\"     : \"iso:uuid\",\n    \"filename\"    : \"iso:uuid\",\n    \"height\"      : int,\n    \"width\"       : int,\n    \"size\"        : int,\n    \"id\"          : \"5m3gLfcei6BQsL\",\n    \"likes\"       : int,\n    \"mime\"        : \"image/png\",\n    \"num\"         : range(1, 13),\n    \"rating\"      : \"ecchi\",\n    \"slug\"        : \"sparkle\",\n    \"title\"       : \"Sparkle\",\n    \"type\"        : \"image\",\n    \"views\"       : int,\n    \"tags\"        : [\n        \"koikatsu\",\n        \"sparkle\",\n    ],\n    \"user\"        : {\n        \"date\"       : \"dt:2025-07-04 19:17:20\",\n        \"description\": \"card from OoOoO & Rat\",\n        \"id\"         : \"771d2b29-5935-43d7-85e1-30abbf47ccad\",\n        \"name\"       : \"zcccz\",\n        \"nick\"       : \"zcccz\",\n        \"premium\"    : False,\n        \"role\"       : \"limited\",\n        \"status\"     : \"active\",\n    },\n},\n\n{\n    \"#url\"          : \"https://www.iwara.tv/image/PbYJb57QqwrFp0\",\n    \"#category\"     : (\"\", \"iwara\", \"image\"),\n    \"#class\"        : iwara.IwaraImageExtractor,\n    \"#results\"      : \"https://i.iwara.tv/image/original/0302deee-9cd5-4c1f-b931-04caf329c0c7/0302deee-9cd5-4c1f-b931-04caf329c0c7.png\",\n    \"#sha1_content\" : \"9fc2ae4d0d26d4b50c38ff2c5c235d33e8b56d1c\",\n\n    \"user\": {\n        \"id\"  : \"ef14099e-a6db-4325-9c67-51c0615985d5\",\n        \"name\": \"sanka\",\n        \"nick\": \"Cerodiers\",\n    },\n    \"id\"            : \"PbYJb57QqwrFp0\",\n    \"title\"         : \"还没做完\",\n    \"file_id\"       : \"0302deee-9cd5-4c1f-b931-04caf329c0c7\",\n    \"filename\"      : \"0302deee-9cd5-4c1f-b931-04caf329c0c7\",\n    \"extension\"     : \"png\",\n    \"mime\"          : \"image/png\",\n    \"size\"          : 3564514,\n    \"width\"         : 2560,\n    \"height\"        : 1440,\n    \"duration\"      : None,\n    \"type\"          : \"image\",\n    \"date\"          : \"dt:2025-07-04 03:15:37\",\n    \"date_updated\"  : \"dt:2025-07-04 03:15:53\",\n},\n\n{\n    \"#url\"     : \"https://www.iwara.tv/image/sjqkK5EobXucju/ellen-joe-dancing\",\n    \"#comment\" : \"WebM video with sound classified as 'image'\",\n    \"#class\"   : iwara.IwaraImageExtractor,\n    \"#results\" : \"https://i.iwara.tv/image/original/cf1686ac-9796-4213-bea3-71b6dcaac658/cf1686ac-9796-4213-bea3-71b6dcaac658.webm\",\n\n    \"date\"        : \"dt:2025-07-07 17:06:47\",\n    \"date_updated\": \"dt:2025-07-07 17:07:11\",\n    \"duration\"    : None,\n    \"extension\"   : \"webm\",\n    \"file_id\"     : \"cf1686ac-9796-4213-bea3-71b6dcaac658\",\n    \"filename\"    : \"cf1686ac-9796-4213-bea3-71b6dcaac658\",\n    \"width\"       : 1366,\n    \"height\"      : 768,\n    \"id\"          : \"sjqkK5EobXucju\",\n    \"mime\"        : \"video/webm\",\n    \"size\"        : 4747505,\n    \"subcategory\" : \"image\",\n    \"title\"       : \"Ellen Joe Dancing To Body Shaming\",\n    \"type\"        : \"image\",\n    \"user\": {\n        \"id\"  : \"f7625ea7-c1c8-416b-b929-a245892911a6\",\n        \"name\": \"marzcade\",\n        \"nick\": \"Marzcade\",\n    },\n},\n\n{\n    \"#url\"     : \"https://www.iwara.ai/image/CpU4ST8vhc93D3/hutao\",\n    \"#class\"   : iwara.IwaraImageExtractor,\n    \"#pattern\" : r\"https://i\\.iwara\\.tv/image/original/[\\w-]+/[\\w-]+\\.png\",\n    \"#count\"   : 12,\n},\n\n)\n"
  },
  {
    "path": "test/results/joyreactor.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import joyreactor\n\n\n__tests__ = (\n{\n    \"#url\"     : \"http://joyreactor.com/post/3721876\",\n    \"#comment\" : \"single image\",\n    \"#category\": (\"reactor\", \"joyreactor\", \"post\"),\n    \"#class\"   : joyreactor.JoyreactorPostExtractor,\n    \"#results\" : \"https://img2.joyreactor.com/pics/post/full/cartoon-painting-monster-4841316.jpeg\",\n\n    \"count\"    : 1,\n    \"num\"      : 1,\n    \"date\"     : \"dt:2018-11-18 20:31:00\",\n    \"extension\": \"jpeg\",\n    \"file_id\"  : \"4841316\",\n    \"file_url\" : \"https://img2.joyreactor.com/pics/post/full/cartoon-painting-monster-4841316.jpeg\",\n    \"filename\" : \"cartoon-painting-monster-4841316\",\n    \"post_id\"  : \"3721876\",\n    \"type\"     : \"image\",\n    \"user\"     : \"alcortje\",\n    \"user_id\"  : \"300202\",\n    \"content\"  : str,\n    \"tags\"     : [\n        \"cartoon\",\n        \"painting\",\n        \"monster\",\n        \"lake\",\n    ],\n},\n\n\n\n{\n    \"#url\"     : \"http://joyreactor.com/post/3713804\",\n    \"#comment\" : \"4 images\",\n    \"#category\": (\"reactor\", \"joyreactor\", \"post\"),\n    \"#class\"   : joyreactor.JoyreactorPostExtractor,\n    \"#results\" : (\n        \"https://img2.joyreactor.com/pics/post/full/movie-tv-godzilla-4827689.jpeg\",\n        \"https://img2.joyreactor.com/pics/post/full/movie-tv-godzilla-4827690.jpeg\",\n        \"https://img2.joyreactor.com/pics/post/full/movie-tv-godzilla-4827691.jpeg\",\n        \"https://img2.joyreactor.com/pics/post/full/movie-tv-godzilla-4827692.jpeg\",\n    ),\n\n    \"count\"    : 4,\n    \"num\"      : range(1, 4),\n    \"date\"     : \"dt:2018-11-12 18:17:00\",\n    \"extension\": \"jpeg\",\n    \"file_id\"  : r\"re:48276[89]\\d\",\n    \"file_url\" : r\"re:https://img2.joyreactor.com/pics/post/full/movie-tv-godzilla-48276\\d+\\.jpeg\",\n    \"filename\" : r\"re:movie-tv-godzilla-48276\\d+\",\n    \"post_id\"  : \"3713804\",\n    \"type\"     : \"image\",\n    \"user\"     : \"alcortje\",\n    \"user_id\"  : \"300202\",\n    \"content\"  : str,\n    \"tags\"     : [\n        \"movie\",\n        \"tv\",\n        \"godzilla\",\n        \"monsters\",\n        \"back drop\",\n        \"movie set\",\n    ],\n},\n\n{\n    \"#url\"     : \"http://joyreactor.com/post/3726210\",\n    \"#comment\" : \"video\",\n    \"#category\": (\"reactor\", \"joyreactor\", \"post\"),\n    \"#class\"   : joyreactor.JoyreactorPostExtractor,\n    \"#results\" : \"https://img2.joyreactor.com/pics/post/webm/hose-firefighters-cool-4848292.webm\",\n\n    \"count\"    : 1,\n    \"date\"     : \"dt:2018-11-22 09:08:00\",\n    \"extension\": \"webm\",\n    \"file_id\"  : \"4848292\",\n    \"file_url\" : \"https://img2.joyreactor.com/pics/post/webm/hose-firefighters-cool-4848292.webm\",\n    \"filename\" : \"hose-firefighters-cool-4848292\",\n    \"format\"   : \"webm\",\n    \"num\"      : 1,\n    \"post_id\"  : \"3726210\",\n    \"type\"     : \"video\",\n    \"user\"     : \"DeadWhale\",\n    \"user_id\"  : \"41786\",\n    \"tags\"     : [\n        \"hose\",\n        \"firefighters\",\n        \"cool\",\n        \"gif\",\n        \"gadget\",\n    ],\n},\n\n{\n    \"#url\"     : \"http://joyreactor.com/post/3726210\",\n    \"#comment\" : \"'format' option\",\n    \"#category\": (\"reactor\", \"joyreactor\", \"post\"),\n    \"#class\"   : joyreactor.JoyreactorPostExtractor,\n    \"#options\" : {\"format\": \"mp4,gif\"},\n    \"#results\" : (\n        \"https://img2.joyreactor.com/pics/post/mp4/hose-firefighters-cool-4848292.mp4\",\n        \"https://img2.joyreactor.com/pics/post/gif/hose-firefighters-cool-4848292.gif\",\n    ),\n\n    \"count\"    : 2,\n    \"format\"   : {\"mp4\", \"gif\"},\n    \"extension\": {\"mp4\", \"gif\"},\n    \"filename\" : \"hose-firefighters-cool-4848292\",\n    \"file_id\"  : \"4848292\",\n},\n\n{\n    \"#url\"     : \"http://joyreactor.com/post/3668724\",\n    \"#comment\" : \"youtube embed\",\n    \"#category\": (\"reactor\", \"joyreactor\", \"post\"),\n    \"#class\"   : joyreactor.JoyreactorPostExtractor,\n    \"#options\" : {\"embeds\": True},\n    \"#results\" : \"ytdl:https://www.youtube.com/embed/-hwv_v6ObnA?wmode=transparent&amp;rel=0\",\n\n    \"count\"    : 1,\n    \"date\"     : \"dt:2018-10-07 14:26:00\",\n    \"extension\": \"\",\n    \"file_id\"  : \"hwv_v6ObnA\",\n    \"file_url\" : \"ytdl:https://www.youtube.com/embed/-hwv_v6ObnA?wmode=transparent&amp;rel=0\",\n    \"filename\" : \"-hwv_v6ObnA\",\n    \"num\"      : 1,\n    \"post_id\"  : \"3668724\",\n    \"tags\"     : [],\n    \"type\"     : \"embed\",\n    \"user\"     : \"pux0073\",\n    \"user_id\"  : \"447567\",\n},\n\n{\n    \"#url\"     : \"http://joyreactor.com/post/3668724\",\n    \"#comment\" : \"youtube embed default\",\n    \"#category\": (\"reactor\", \"joyreactor\", \"post\"),\n    \"#class\"   : joyreactor.JoyreactorPostExtractor,\n    \"#count\"   : 0,\n},\n\n{\n    \"#url\"     : \"https://joyreactor.cc/post/6279901\",\n    \"#comment\" : \"'.cc' TLD\",\n    \"#category\": (\"reactor\", \"joyreactor\", \"post\"),\n    \"#class\"   : joyreactor.JoyreactorPostExtractor,\n    \"#results\" : \"https://img2.joyreactor.cc/pics/post/full/fasnakegod-Anime-Artist-artist-9294962.jpeg\",\n\n    \"date\"     : \"dt:2026-03-14 06:28:00\",\n    \"extension\": \"jpeg\",\n    \"file_id\"  : \"9294962\",\n    \"file_url\" : \"https://img2.joyreactor.cc/pics/post/full/fasnakegod-Anime-Artist-artist-9294962.jpeg\",\n    \"filename\" : \"fasnakegod-Anime-Artist-artist-9294962\",\n    \"post_id\"  : \"6279901\",\n    \"type\"     : \"image\",\n    \"user\"     : \"kotelnique\",\n    \"user_id\"  : \"1038132\",\n    \"tags\"     : [\n        \"fasnakegod\",\n        \"Anime Artist\",\n        \"artist\",\n        \"Shameimaru Aya\",\n        \"Touhou Project\",\n        \"Anime\",\n        \"фэндомы\",\n    ],\n},\n\n{\n    \"#url\"     : \"https://joyreactor.cc/tag/Touhou Project\",\n    \"#category\": (\"reactor\", \"joyreactor\", \"tag\"),\n    \"#class\"   : joyreactor.JoyreactorTagExtractor,\n    \"#pattern\" : r\"https://img\\d+\\.joyreactor\\.cc/pics/post/full/\\w+\",\n    \"#range\"   : \"1-50\",\n    \"#count\"   : 50,\n\n    \"date\"       : \"type:datetime\",\n    \"search_tags\": \"Touhou Project\",\n},\n\n{\n    \"#url\"     : \"https://joyreactor.com/tag/Dark%20Souls%202/top\",\n    \"#comment\" : \"'.com' TLD, 'top' results\",\n    \"#category\": (\"reactor\", \"joyreactor\", \"tag\"),\n    \"#class\"   : joyreactor.JoyreactorTagExtractor,\n    \"#count\"   : range(8, 20),\n\n    \"date\"       : \"type:datetime\",\n    \"search_tags\": \"Dark Souls 2\",\n},\n\n{\n    \"#url\"     : \"http://joyreactor.cc/search/Nature\",\n    \"#category\": (\"reactor\", \"joyreactor\", \"search\"),\n    \"#class\"   : joyreactor.JoyreactorSearchExtractor,\n    \"#pattern\" : r\"https://img\\d+\\.joyreactor\\.cc/pics/post/full/\\w+\",\n    \"#count\"   : range(8, 20),\n\n    \"search_tags\": \"Nature\",\n},\n\n{\n    \"#url\"     : \"http://joyreactor.cc/user/hemantic\",\n    \"#category\": (\"reactor\", \"joyreactor\", \"user\"),\n    \"#class\"   : joyreactor.JoyreactorUserExtractor,\n    \"#pattern\" : r\"https://img\\d+\\.joyreactor\\.cc/pics/post/full/\\w+\",\n    \"#range\"   : \"1-50\",\n    \"#count\"   : 11,\n\n    \"user\"     : \"hemantic\",\n    \"user_id\"  : \"78\",\n},\n\n{\n    \"#url\"     : \"http://joyreactor.com/user/Tacoman123\",\n    \"#category\": (\"reactor\", \"joyreactor\", \"user\"),\n    \"#class\"   : joyreactor.JoyreactorUserExtractor,\n    \"#pattern\" : r\"https://img\\d+\\.joyreactor\\.com/pics/post/full/\\w+\",\n    \"#range\"   : \"1-50\",\n    \"#count\"   : 21,\n\n    \"user\"     : \"Tacoman123\",\n    \"user_id\"  : \"189143\",\n},\n\n)\n"
  },
  {
    "path": "test/results/jpgfish.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import chevereto\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://jpg7.cr/img/funnymeme.LecXGS\",\n    \"#category\": (\"chevereto\", \"jpgfish\", \"file\"),\n    \"#class\"   : chevereto.CheveretoFileExtractor,\n    \"#results\"     : \"https://simp3.selti-delivery.ru/images/funnymeme.jpg\",\n    \"#sha1_content\": \"098e5e9b17ad634358426e0ffd1c93871474d13c\",\n\n    \"album\"    : \"\",\n    \"album_id\" : \"\",\n    \"album_slug\": \"\",\n    \"date\"     : \"dt:2022-06-05 03:24:25\",\n    \"extension\": \"jpg\",\n    \"filename\" : \"funnymeme\",\n    \"id\"       : \"LecXGS\",\n    \"title\"    : \"funnymeme\",\n    \"type\"     : \"article\",\n    \"url\"      : \"https://simp3.selti-delivery.ru/images/funnymeme.jpg\",\n    \"user\"     : \"exearco\",\n},\n\n{\n    \"#url\"     : \"https://jpg4.su/img/funnymeme.LecXGS\",\n    \"#category\": (\"chevereto\", \"jpgfish\", \"file\"),\n    \"#class\"   : chevereto.CheveretoFileExtractor,\n    \"#results\" : \"https://simp3.selti-delivery.ru/images/funnymeme.jpg\",\n\n    \"album\"    : \"\",\n    \"date\"     : \"dt:2022-06-05 03:24:25\",\n    \"extension\": \"jpg\",\n    \"filename\" : \"funnymeme\",\n    \"id\"       : \"LecXGS\",\n    \"url\"      : \"https://simp3.selti-delivery.ru/images/funnymeme.jpg\",\n    \"user\"     : \"exearco\",\n},\n\n{\n    \"#url\"     : \"https://jpg6.su/img/LecXGS/\",\n    \"#comment\" : \"image ID without name (#8307)\",\n    \"#category\": (\"chevereto\", \"jpgfish\", \"file\"),\n    \"#class\"   : chevereto.CheveretoFileExtractor,\n    \"#results\"     : \"https://simp3.selti-delivery.ru/images/funnymeme.jpg\",\n    \"#sha1_content\": \"098e5e9b17ad634358426e0ffd1c93871474d13c\",\n\n    \"album\"    : \"\",\n    \"date\"     : \"dt:2022-06-05 03:24:25\",\n    \"extension\": \"jpg\",\n    \"filename\" : \"funnymeme\",\n    \"id\"       : \"LecXGS\",\n    \"url\"      : str,\n    \"user\"     : \"exearco\",\n},\n\n{\n    \"#url\"     : \"https://jpg.church/img/auCruA\",\n    \"#category\": (\"chevereto\", \"jpgfish\", \"file\"),\n    \"#class\"   : chevereto.CheveretoFileExtractor,\n    \"#results\" : \"https://simp2.selti-delivery.ru/hannahowo_00457.jpg\",\n\n    \"album\"     : \"401-500\",\n    \"album_id\"  : \"atYaG\",\n    \"album_slug\": \"401-500\",\n    \"date\"      : \"dt:2022-03-23 13:50:52\",\n    \"id\"        : \"auCruA\",\n},\n\n{\n    \"#url\"     : \"https://jpg1.su/img/funnymeme.LecXGS\",\n    \"#category\": (\"chevereto\", \"jpgfish\", \"file\"),\n    \"#class\"   : chevereto.CheveretoFileExtractor,\n},\n\n{\n    \"#url\"     : \"https://jpeg.pet/img/funnymeme.LecXGS\",\n    \"#category\": (\"chevereto\", \"jpgfish\", \"file\"),\n    \"#class\"   : chevereto.CheveretoFileExtractor,\n},\n\n{\n    \"#url\"     : \"https://jpg.pet/img/funnymeme.LecXGS\",\n    \"#category\": (\"chevereto\", \"jpgfish\", \"file\"),\n    \"#class\"   : chevereto.CheveretoFileExtractor,\n},\n\n{\n    \"#url\"     : \"https://jpg.fishing/img/funnymeme.LecXGS\",\n    \"#category\": (\"chevereto\", \"jpgfish\", \"file\"),\n    \"#class\"   : chevereto.CheveretoFileExtractor,\n},\n\n{\n    \"#url\"     : \"https://jpg.fish/img/funnymeme.LecXGS\",\n    \"#category\": (\"chevereto\", \"jpgfish\", \"file\"),\n    \"#class\"   : chevereto.CheveretoFileExtractor,\n},\n\n{\n    \"#url\"     : \"https://jpg.church/img/funnymeme.LecXGS\",\n    \"#category\": (\"chevereto\", \"jpgfish\", \"file\"),\n    \"#class\"   : chevereto.CheveretoFileExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.jpg6.su/img/funnymeme.LecXGS\",\n    \"#category\": (\"chevereto\", \"jpgfish\", \"file\"),\n    \"#class\"   : chevereto.CheveretoFileExtractor,\n},\n\n{\n    \"#url\"     : \"https://jpg6.su/img/test.NLqFQHc\",\n    \"#category\": (\"chevereto\", \"jpgfish\", \"file\"),\n    \"#class\"   : chevereto.CheveretoFileExtractor,\n    \"#results\" : \"https://simp6.selti-delivery.ru/images3/test--2219f2531862b749d5.png\",\n\n    \"album\"     : \"\",\n    \"album_id\"  : \"\",\n    \"album_slug\": \"\",\n    \"date\"      : \"dt:2025-08-31 08:10:35\",\n    \"extension\" : \"png\",\n    \"filename\"  : \"test--2219f2531862b749d5\",\n    \"id\"        : \"NLqFQHc\",\n    \"title\"     : \"\"\"test テスト \"&>\"\"\",\n    \"url\"       : \"https://simp6.selti-delivery.ru/images3/test--2219f2531862b749d5.png\",\n    \"user\"      : \"gdldev\",\n},\n\n{\n    \"#url\"     : \"https://jpg1.su/album/CDilP/?sort=date_desc&page=1\",\n    \"#category\": (\"chevereto\", \"jpgfish\", \"album\"),\n    \"#class\"   : chevereto.CheveretoAlbumExtractor,\n    \"#count\"   : 2,\n\n    \"album\"     : \"funny meme album\",\n    \"album_id\"  : \"CDilP\",\n    \"album_slug\": \"funny-meme-album\",\n    \"count\"     : 2,\n    \"num\"       : range(1, 2),\n},\n\n{\n    \"#url\"     : \"https://jpg.fishing/a/gunggingnsk.N9OOI\",\n    \"#category\": (\"chevereto\", \"jpgfish\", \"album\"),\n    \"#class\"   : chevereto.CheveretoAlbumExtractor,\n    \"#count\"   : 114,\n\n    \"album\"     : \"Gunggingnsk OF\",\n    \"album_id\"  : \"N9OOI\",\n    \"album_slug\": \"gunggingnsk\",\n    \"count\"     : 114,\n    \"num\"       : range(1, 114),\n},\n\n{\n    \"#url\"     : \"https://jpg.fish/a/101-200.aNJ6A/\",\n    \"#category\": (\"chevereto\", \"jpgfish\", \"album\"),\n    \"#class\"   : chevereto.CheveretoAlbumExtractor,\n    \"#count\"   : 100,\n\n    \"album\"     : \"101-200\",\n    \"album_id\"  : \"aNJ6A\",\n    \"album_slug\": \"101-200\",\n    \"count\"     : 100,\n    \"num\"       : range(1, 100),\n},\n\n{\n    \"#url\"     : \"https://jpg.church/a/hannahowo.aNTdH/sub\",\n    \"#category\": (\"chevereto\", \"jpgfish\", \"album\"),\n    \"#class\"   : chevereto.CheveretoAlbumExtractor,\n    \"#count\"   : 606,\n\n    \"album\"     : \"re:([1-5]0)?1-[1-6]00\",\n    \"album_id\"  : str,\n    \"album_slug\": str,\n    \"count\"     : {100, 106},\n    \"num\"       : range(1, 106),\n},\n\n{\n    \"#url\"     : \"https://jpeg.pet/album/CDilP/?sort=date_desc&page=1\",\n    \"#category\": (\"chevereto\", \"jpgfish\", \"album\"),\n    \"#class\"   : chevereto.CheveretoAlbumExtractor,\n},\n\n{\n    \"#url\"     : \"https://jpg.pet/album/CDilP/?sort=date_desc&page=1\",\n    \"#category\": (\"chevereto\", \"jpgfish\", \"album\"),\n    \"#class\"   : chevereto.CheveretoAlbumExtractor,\n},\n\n{\n    \"#url\"     : \"https://jpg1.su/exearco\",\n    \"#category\": (\"chevereto\", \"jpgfish\", \"user\"),\n    \"#class\"   : chevereto.CheveretoUserExtractor,\n    \"#count\"   : 3,\n},\n\n{\n    \"#url\"     : \"https://jpg.church/exearco/albums\",\n    \"#category\": (\"chevereto\", \"jpgfish\", \"user\"),\n    \"#class\"   : chevereto.CheveretoUserExtractor,\n    \"#count\"   : 1,\n},\n\n{\n    \"#url\"     : \"https://jpeg.pet/exearco\",\n    \"#category\": (\"chevereto\", \"jpgfish\", \"user\"),\n    \"#class\"   : chevereto.CheveretoUserExtractor,\n},\n\n{\n    \"#url\"     : \"https://jpg.pet/exearco\",\n    \"#category\": (\"chevereto\", \"jpgfish\", \"user\"),\n    \"#class\"   : chevereto.CheveretoUserExtractor,\n},\n\n{\n    \"#url\"     : \"https://jpg.fishing/exearco\",\n    \"#category\": (\"chevereto\", \"jpgfish\", \"user\"),\n    \"#class\"   : chevereto.CheveretoUserExtractor,\n},\n\n{\n    \"#url\"     : \"https://jpg.fish/exearco\",\n    \"#category\": (\"chevereto\", \"jpgfish\", \"user\"),\n    \"#class\"   : chevereto.CheveretoUserExtractor,\n},\n\n{\n    \"#url\"     : \"https://jpg.church/exearco\",\n    \"#category\": (\"chevereto\", \"jpgfish\", \"user\"),\n    \"#class\"   : chevereto.CheveretoUserExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/kabeuchi.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import kabeuchi\nfrom gallery_dl import exception\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://kabe-uchiroom.com/mypage/?id=919865303848255493\",\n    \"#category\": (\"\", \"kabeuchi\", \"user\"),\n    \"#class\"   : kabeuchi.KabeuchiUserExtractor,\n    \"#pattern\" : r\"https://kabe-uchiroom\\.com/accounts/upfile/3/919865303848255493/\\w+\\.jpe?g\",\n    \"#count\"   : \">= 24\",\n},\n\n{\n    \"#url\"     : \"https://kabe-uchiroom.com/mypage/?id=123456789\",\n    \"#category\": (\"\", \"kabeuchi\", \"user\"),\n    \"#class\"   : kabeuchi.KabeuchiUserExtractor,\n    \"#exception\": exception.NotFoundError,\n},\n\n)\n"
  },
  {
    "path": "test/results/kaliscan.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import kaliscan\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://kaliscan.me/manga/2142-whats-wrong-with-secretary-kim/chapter-1\",\n    \"#class\"   : kaliscan.KaliscanChapterExtractor,\n    \"#pattern\" : r\"https://s\\d+\\.1stmggv\\d*\\.\\w+/.+\\.\\w+\",\n    \"#count\"   : 13,\n\n    \"author\"       : \"Jeong gyeong yun\",\n    \"chapter\"      : 1,\n    \"chapter_minor\": \"\",\n    \"chapter_id\"   : 68134,\n    \"count\"        : 13,\n    \"genres\"       : [\"Comedy\", \"Josei\", \"Manhwa\", \"Romance\", \"Webtoons\"],\n    \"lang\"         : \"en\",\n    \"language\"     : \"English\",\n    \"manga\"        : \"What's Wrong with Secretary Kim?\",\n    \"manga_id\"     : 2142,\n    \"manga_slug\"   : \"2142-whats-wrong-with-secretary-kim\",\n    \"status\"       : \"Completed\",\n},\n\n{\n    \"#url\"     : \"https://kaliscan.me/manga/2142-whats-wrong-with-secretary-kim/chapter-14.5\",\n    \"#class\"   : kaliscan.KaliscanChapterExtractor,\n\n    \"chapter\"      : 14,\n    \"chapter_minor\": \".5\",\n},\n\n{\n    \"#url\"     : \"https://kaliscan.me/manga/2142-whats-wrong-with-secretary-kim\",\n    \"#class\"   : kaliscan.KaliscanMangaExtractor,\n    \"#pattern\" : kaliscan.KaliscanChapterExtractor.pattern,\n    \"#count\"   : range(100, 200),\n\n    \"author\"   : \"Jeong gyeong yun\",\n    \"chapter\"  : int,\n    \"genres\"   : [\"Comedy\", \"Josei\", \"Manhwa\", \"Romance\", \"Webtoons\"],\n    \"lang\"     : \"en\",\n    \"manga\"    : \"What's Wrong with Secretary Kim?\",\n    \"manga_id\" : 2142,\n    \"status\"   : \"Completed\",\n},\n\n)\n"
  },
  {
    "path": "test/results/keenspot.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import keenspot\n\n\n__tests__ = (\n{\n    \"#url\"     : \"http://marksmen.keenspot.com/\",\n    \"#comment\" : \"link\",\n    \"#category\": (\"\", \"keenspot\", \"comic\"),\n    \"#class\"   : keenspot.KeenspotComicExtractor,\n    \"#range\"   : \"1-3\",\n    \"#sha1_url\": \"83bcf029103bf8bc865a1988afa4aaeb23709ba6\",\n},\n\n{\n    \"#url\"     : \"http://barkercomic.keenspot.com/\",\n    \"#comment\" : \"id\",\n    \"#category\": (\"\", \"keenspot\", \"comic\"),\n    \"#class\"   : keenspot.KeenspotComicExtractor,\n    \"#range\"   : \"1-3\",\n    \"#sha1_url\": \"c4080926db18d00bac641fdd708393b7d61379e6\",\n},\n\n{\n    \"#url\"     : \"http://crowscare.keenspot.com/\",\n    \"#comment\" : \"id v2\",\n    \"#category\": (\"\", \"keenspot\", \"comic\"),\n    \"#class\"   : keenspot.KeenspotComicExtractor,\n    \"#range\"   : \"1-3\",\n    \"#sha1_url\": \"a00e66a133dd39005777317da90cef921466fcaa\",\n},\n\n{\n    \"#url\"     : \"http://supernovas.keenspot.com/\",\n    \"#comment\" : \"ks\",\n    \"#category\": (\"\", \"keenspot\", \"comic\"),\n    \"#class\"   : keenspot.KeenspotComicExtractor,\n    \"#range\"   : \"1-3\",\n    \"#sha1_url\": \"de21b12887ef31ff82edccbc09d112e3885c3aab\",\n},\n\n{\n    \"#url\"     : \"http://twokinds.keenspot.com/comic/1066/\",\n    \"#category\": (\"\", \"keenspot\", \"comic\"),\n    \"#class\"   : keenspot.KeenspotComicExtractor,\n    \"#range\"   : \"1-3\",\n    \"#sha1_url\": \"6a784e11370abfb343dcad9adbb7718f9b7be350\",\n},\n\n)\n"
  },
  {
    "path": "test/results/kemono.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import kemono\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://kemono.cr/fanbox/user/6993449\",\n    \"#category\": (\"\", \"kemono\", \"fanbox\"),\n    \"#class\"   : kemono.KemonoUserExtractor,\n    \"#options\" : {\"endpoint\": \"legacy\"},\n    \"#range\"   : \"1-500\",\n    \"#count\"   : 500,\n\n    \"archives\"   : list,\n    \"attachments\": list,\n    \"count\"      : int,\n    \"num\"        : int,\n    \"date\"       : \"type:datetime\",\n    \"id\"         : str,\n    \"published\"  : str,\n    \"service\"    : \"fanbox\",\n    \"subcategory\": \"fanbox\",\n    \"substring\"  : str,\n    \"title\"      : str,\n    \"user\"       : \"6993449\",\n    \"username\"   : \"かえぬこ\",\n    \"file\"       : {\n        \"extension\": str,\n        \"filename\" : str,\n        \"hash\"     : \"len:str:64\",\n        \"name\"     : str,\n        \"path\"     : str,\n        \"type\"     : \"file\",\n        \"url\"      : str,\n    },\n},\n\n{\n    \"#url\"     : \"https://kemono.cr/fanbox/user/6993449\",\n    \"#comment\" : \"endpoint: legacy+ (#7438 #7450 #7462)\",\n    \"#category\": (\"\", \"kemono\", \"fanbox\"),\n    \"#class\"   : kemono.KemonoUserExtractor,\n    \"#options\" : {\"endpoint\": \"legacy+\"},\n    \"#range\"   : \"1-10\",\n\n    \"added\"      : {str, None},\n    \"archives\"   : [],\n    \"attachments\": list,\n    \"captions\"   : None,\n    \"content\"    : str,\n    \"count\"      : int,\n    \"num\"        : int,\n    \"date\"       : \"type:datetime\",\n    \"edited\"     : str,\n    \"embed\"      : dict,\n    \"id\"         : str,\n    \"poll\"       : None,\n    \"published\"  : str,\n    \"service\"    : \"fanbox\",\n    \"shared_file\": False,\n    \"subcategory\": \"fanbox\",\n    \"tags\"       : list,\n    \"title\"      : str,\n    \"user\"       : \"6993449\",\n    \"username\"   : \"かえぬこ\",\n    \"file\"       : {\n        \"hash\"   : \"len:str:64\",\n        \"name\"   : str,\n        \"path\"   : str,\n        \"type\"   : \"file\",\n        \"url\"    : str,\n    },\n},\n\n{\n    \"#url\"     : \"https://kemono.cr/patreon/user/881792?o=150\",\n    \"#comment\" : \"'max-posts' and 'endpoint' option, 'o' query parameter (#1674)\",\n    \"#category\": (\"\", \"kemono\", \"patreon\"),\n    \"#class\"   : kemono.KemonoUserExtractor,\n    \"#options\" : {\"max-posts\": 100, \"endpoint\": \"posts\"},\n    \"#count\"   : range(200, 400),\n\n    \"archives\"   : [],\n    \"attachments\": list,\n    \"count\"      : int,\n    \"num\"        : int,\n    \"id\"         : str,\n    \"date\"       : \"type:datetime\",\n    \"published\"  : str,\n    \"service\"    : \"patreon\",\n    \"subcategory\": \"patreon\",\n    \"title\"      : str,\n    \"user\"       : \"881792\",\n    \"username\"   : \"diives\",\n\n    \"!added\"      : {str, None},\n    \"!captions\"   : None,\n    \"!content\"    : str,\n    \"!edited\"     : {str, None},\n    \"!embed\"      : dict,\n    \"!poll\"       : None,\n    \"!shared_file\": False,\n    \"!tags\"       : {str, None},\n},\n\n{\n    \"#url\"     : \"https://kemono.cr/fanbox/user/6993449?q=お蔵入りになった\",\n    \"#comment\" : \"search / 'q' query parameter (#3385, #4057)\",\n    \"#category\": (\"\", \"kemono\", \"fanbox\"),\n    \"#class\"   : kemono.KemonoUserExtractor,\n    \"#results\" : (\n        \"https://kemono.cr/data/ef/7b/ef7b4398a2f4ada597421fd3c116cff86e85695911f7cd2a459b0e566b864e46.png\",\n        \"https://kemono.cr/data/73/e6/73e615f6645b9d1af6329448601673c9275f07fd11eb37670c97e307e29a9ee9.png\",\n    ),\n\n    \"id\": \"8779\",\n},\n\n{\n    \"#url\"     : \"https://kemono.cr/patreon/user/3161935?tag=pin-up\",\n    \"#comment\" : \"'tag' query parameter\",\n    \"#category\": (\"\", \"kemono\", \"patreon\"),\n    \"#class\"   : kemono.KemonoUserExtractor,\n    \"#results\" : (\n        \"https://kemono.cr/data/83/61/8361560887a09c7b828d326b3e1a2f0288673741569a09d74bcd01e602d20db1.png\",\n        \"https://kemono.cr/data/03/e6/03e62592c3b616b8906c1aaa130bd9ceaa24d7f601b31f90cc11956a57ca1d82.png\",\n        \"https://kemono.cr/data/83/0d/830d017873157d2e6544a0f23a47622ec1e91be09b5d7795eb22e32b3150c837.png\",\n        \"https://kemono.cr/data/6a/9b/6a9b6d93dcb86c24a48def1bb93ce2a9ad77393941f3469d87d39400433cf825.png\",\n        \"https://kemono.cr/data/96/43/9643ac03888f3b199f4e769242477b8d4d4f96025b10ab3f28affc3a1ae6bf52.jpg\",\n        \"https://kemono.cr/data/f7/a8/f7a87ccac5736f46190a53a2bb1ff3828230e90f480776759895fcba28375909.jpg\",\n        \"https://kemono.cr/data/b0/38/b03882c8b0ab3b1cf9fc658a2bb2f9ac6ad4f3449015311dcd2d7ee7f748db31.png\",\n    ),\n\n    \"tags\": list,\n},\n\n{\n    \"#url\"     : \"https://kemono.cr/subscribestar/user/alcorart\",\n    \"#category\": (\"\", \"kemono\", \"subscribestar\"),\n    \"#class\"   : kemono.KemonoUserExtractor,\n},\n\n{\n    \"#url\"     : \"https://kemono.cr/subscribestar/user/alcorart\",\n    \"#category\": (\"\", \"kemono\", \"subscribestar\"),\n    \"#class\"   : kemono.KemonoUserExtractor,\n},\n\n{\n    \"#url\"     : \"https://kemono.cr/fanbox/user/6993449/post/506575\",\n    \"#category\": (\"\", \"kemono\", \"fanbox\"),\n    \"#class\"   : kemono.KemonoPostExtractor,\n    \"#pattern\"     : r\"https://kemono.cr/data/21/0f/210f35388e28bbcf756db18dd516e2d82ce75[0-9a-f]+\\.jpg\",\n    \"#sha1_content\": \"900949cefc97ab8dc1979cc3664785aac5ba70dd\",\n\n    \"added\"      : None,\n    \"archives\"   : [],\n    \"content\"    : str,\n    \"count\"      : 1,\n    \"date\"       : \"dt:2019-08-10 17:09:04\",\n    \"edited\"     : None,\n    \"embed\"      : dict,\n    \"extension\"  : \"jpeg\",\n    \"filename\"   : \"P058kDFYus7DbqAkGlfWTlOr\",\n    \"hash\"       : \"210f35388e28bbcf756db18dd516e2d82ce758e0d32881eeee76d43e1716d382\",\n    \"id\"         : \"506575\",\n    \"num\"        : 1,\n    \"published\"  : \"2019-08-10T17:09:04\",\n    \"service\"    : \"fanbox\",\n    \"shared_file\": False,\n    \"subcategory\": \"fanbox\",\n    \"title\"      : \"c96取り置き\",\n    \"type\"       : \"file\",\n    \"user\"       : \"6993449\",\n},\n\n{\n    \"#url\"     : \"https://kemono.cr/fanbox/user/7356311/post/802343\",\n    \"#comment\" : \"inline image (#1286)\",\n    \"#category\": (\"\", \"kemono\", \"fanbox\"),\n    \"#class\"   : kemono.KemonoPostExtractor,\n    \"#pattern\" : r\"https://kemono\\.cr/data/47/b5/47b5c014ecdcfabdf2c85eec53f1133a76336997ae8596f332e97d956a460ad2\\.jpg\",\n\n    \"hash\": \"47b5c014ecdcfabdf2c85eec53f1133a76336997ae8596f332e97d956a460ad2\",\n},\n\n{\n    \"#url\"     : \"https://kemono.su/gumroad/user/3101696181060/post/tOWyf\",\n    \"#category\": (\"\", \"kemono\", \"gumroad\"),\n    \"#class\"   : kemono.KemonoPostExtractor,\n    \"#count\"   : 12,\n},\n\n{\n    \"#url\"     : \"https://kemono.party/gumroad/user/3252870377455/post/aJnAH\",\n    \"#comment\" : \"username (#1548, #1652)\",\n    \"#category\": (\"\", \"kemono\", \"gumroad\"),\n    \"#class\"   : kemono.KemonoPostExtractor,\n    \"#options\" : {\"metadata\": True},\n\n    \"username\": \"Kudalyn's Creations\",\n},\n\n{\n    \"#url\"     : \"https://kemono.cr/patreon/user/4158582/post/32099982\",\n    \"#comment\" : \"allow duplicates (#2440)\",\n    \"#category\": (\"\", \"kemono\", \"patreon\"),\n    \"#class\"   : kemono.KemonoPostExtractor,\n    \"#count\"   : 2,\n},\n\n{\n    \"#url\"     : \"https://kemono.cr/patreon/user/4158582/post/32099982\",\n    \"#comment\" : \"allow duplicates (#2440)\",\n    \"#category\": (\"\", \"kemono\", \"patreon\"),\n    \"#class\"   : kemono.KemonoPostExtractor,\n    \"#options\" : {\"duplicates\": True},\n    \"#count\"   : 3,\n},\n\n{\n    \"#url\"     : \"https://kemono.cr/patreon/user/3161935/post/23445732\",\n    \"#comment\" : \"comments (#2008)\",\n    \"#category\": (\"\", \"kemono\", \"patreon\"),\n    \"#class\"   : kemono.KemonoPostExtractor,\n    \"#options\" : {\"comments\": True},\n\n    \"comments\": \"len:12\",\n},\n\n{\n    \"#url\"     : \"https://kemono.cr/patreon/user/34134344/post/38129255\",\n    \"#comment\" : \"DMs (#2008); no comments\",\n    \"#category\": (\"\", \"kemono\", \"patreon\"),\n    \"#class\"   : kemono.KemonoPostExtractor,\n    \"#options\" : {\"dms\": True, \"comments\": True},\n\n    \"comments\": [],\n    \"dms\": [\n        {\n            \"added\"    : \"2021-07-31T02:47:51.327865\",\n            \"artist\"   : None,\n            \"content\"  : \"Hi! Thank you very much for supporting the work I did in May. Here's your reward pack! I hope you find something you enjoy in it. :)\\n\\nhttps://www.mediafire.com/file/n9ppjpip0r3f01v/Set13_tier_2.zip/file\",\n            \"embed\"    : {},\n            \"file\"     : {},\n            \"hash\"     : \"f8d4962fb7908614c9b7c8c0de1b5f8985f01b62a9b06d74d640c5b2bcedf758\",\n            \"published\": \"2021-06-09T03:28:51.431000\",\n            \"service\"  : \"patreon\",\n            \"user\"     : \"34134344\",\n        },\n    ],\n},\n\n{\n    \"#url\"     : \"https://kemono.cr/patreon/user/3161935/post/68231671\",\n    \"#comment\" : \"announcements\",\n    \"#category\": (\"\", \"kemono\", \"patreon\"),\n    \"#class\"   : kemono.KemonoPostExtractor,\n    \"#options\" : {\"announcements\": True},\n\n    \"announcements\": [\n        {\n            \"added\"    : \"2023-02-01T22:44:34.670719\",\n            \"content\"  : \"<div style=\\\"text-align: center;\\\"><strong>Thank you so much for the support!</strong><strong><br></strong>This Patreon is more of a tip jar for supporting what I make. I have to clarify that there are <strong>no exclusive Patreon animations</strong>&nbsp;because all are released for the public. You will get earlier access to WIPs. Direct downloads to my works are also available for $5 and $10 Tiers.</div>\",\n            \"hash\"     : \"815648d41c60d1d546437e475a0888fd4a77fd098b1ec61a3648ea6da30c1034\",\n            \"published\": None,\n            \"service\"  : \"patreon\",\n            \"user_id\"  : \"3161935\",\n        },\n    ],\n},\n\n{\n    \"#url\"     : \"https://kemono.cr/patreon/user/19623797/post/29035449\",\n    \"#comment\" : \"invalid file (#3510)\",\n    \"#category\": (\"\", \"kemono\", \"patreon\"),\n    \"#class\"   : kemono.KemonoPostExtractor,\n    \"#pattern\"     : r\"907ba78b4545338d3539683e63ecb51cf51c10adc9dabd86e92bd52339f298b9\\.txt\",\n    \"#sha1_content\": \"da39a3ee5e6b4b0d3255bfef95601890afd80709\",\n},\n\n{\n    \"#url\"     : \"https://kemono.cr/subscribestar/user/alcorart/post/184330\",\n    \"#category\": (\"\", \"kemono\", \"subscribestar\"),\n    \"#class\"   : kemono.KemonoPostExtractor,\n},\n\n{\n    \"#url\"     : \"https://kemono.cr/subscribestar/user/alcorart/post/184330\",\n    \"#category\": (\"\", \"kemono\", \"subscribestar\"),\n    \"#class\"   : kemono.KemonoPostExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.kemono.cr/subscribestar/user/alcorart/post/184330\",\n    \"#category\": (\"\", \"kemono\", \"subscribestar\"),\n    \"#class\"   : kemono.KemonoPostExtractor,\n},\n\n{\n    \"#url\"     : \"https://beta.kemono.cr/subscribestar/user/alcorart/post/184330\",\n    \"#category\": (\"\", \"kemono\", \"subscribestar\"),\n    \"#class\"   : kemono.KemonoPostExtractor,\n},\n\n{\n    \"#url\"     : \"https://kemono.cr/patreon/user/3161935/post/68231671/revision/142470\",\n    \"#comment\" : \"revisions (#4498)\",\n    \"#category\": (\"\", \"kemono\", \"patreon\"),\n    \"#class\"   : kemono.KemonoPostExtractor,\n    \"#results\" : \"https://kemono.cr/data/88/52/88521f71822dfa2f42df3beba319ea4fceda2a2d6dc59da0276a75238f743f86.jpg\",\n\n    \"file\": {\n        \"hash\": \"88521f71822dfa2f42df3beba319ea4fceda2a2d6dc59da0276a75238f743f86\",\n        \"name\": \"wip update.jpg\",\n        \"path\": \"/88/52/88521f71822dfa2f42df3beba319ea4fceda2a2d6dc59da0276a75238f743f86.jpg\",\n        \"type\": \"file\",\n    },\n    \"attachments\": [\n        {\n            \"hash\": \"88521f71822dfa2f42df3beba319ea4fceda2a2d6dc59da0276a75238f743f86\",\n            \"name\": \"wip update.jpg\",\n            \"path\": \"/88/52/88521f71822dfa2f42df3beba319ea4fceda2a2d6dc59da0276a75238f743f86.jpg\",\n            \"type\": \"attachment\",\n        },\n    ],\n    \"filename\"      : \"wip update\",\n    \"extension\"     : \"jpg\",\n    \"hash\"          : \"88521f71822dfa2f42df3beba319ea4fceda2a2d6dc59da0276a75238f743f86\",\n    \"revision_id\"   : 142470,\n    \"revision_index\": 2,\n    \"revision_count\": 11,\n    \"revision_hash\" : \"e0e93281495e151b11636c156e52bfe9234c2a40\",\n},\n\n{\n    \"#url\"     : \"https://kemono.cr/patreon/user/3161935/post/68231671\",\n    \"#comment\" : \"unique revisions (#5013)\",\n    \"#category\": (\"\", \"kemono\", \"patreon\"),\n    \"#class\"   : kemono.KemonoPostExtractor,\n    \"#options\" : {\"revisions\": \"unique\"},\n    \"#results\" : \"https://kemono.cr/data/e3/e6/e3e6287dbc0468dd2a9d28ed276ae86788907143acf2ba10ab886a3add4c436c.jpg\",\n    \"#archive\" : False,\n\n    \"filename\"      : \"wip update\",\n    \"hash\"          : {\n        \"88521f71822dfa2f42df3beba319ea4fceda2a2d6dc59da0276a75238f743f86\",\n        \"e3e6287dbc0468dd2a9d28ed276ae86788907143acf2ba10ab886a3add4c436c\",\n    },\n    \"revision_id\"   : {9277608, 10619155, 0},\n    \"revision_index\": {1, 2, 3},\n    \"revision_count\": 3,\n    \"revision_hash\" : {\n        \"eb2fa4385af730509a42f8f0424bd0b9a0e4bc21\",\n        \"a44ad7fa57ebc2473e861c1d7f11de721c809549\",\n        \"e0e93281495e151b11636c156e52bfe9234c2a40\",\n        \"bc5713195e14799da40c525381216c5a1a340b0f\",\n        \"9872bfb536a47cc69d95d2f195cd5c825808f089\",\n    },\n},\n\n{\n    \"#url\"     : \"https://kemono.cr/patreon/user/3161935/post/68231671/revisions\",\n    \"#comment\" : \"revisions (#4498)\",\n    \"#category\": (\"\", \"kemono\", \"patreon\"),\n    \"#class\"   : kemono.KemonoPostExtractor,\n    \"#pattern\" : r\"https://kemono\\.cr/data/88/52/88521f71822dfa2f42df3beba319ea4fceda2a2d6dc59da0276a75238f743f86\\.jpg\",\n    \"#count\"   : 11,\n    \"#archive\" : False,\n\n    \"revision_id\": range(134996, 10619155),\n    \"revision_index\": range(1, 11),\n    \"revision_count\": 11,\n    \"revision_hash\": {\n        \"9872bfb536a47cc69d95d2f195cd5c825808f089\",\n        \"e0e93281495e151b11636c156e52bfe9234c2a40\",\n        \"eb2fa4385af730509a42f8f0424bd0b9a0e4bc21\",\n    },\n},\n\n\n{\n    \"#url\"     : \"https://kemono.cr/patreon/user/3161935/post/68231671/revision/12345\",\n    \"#comment\" : \"revisions (#4498)\",\n    \"#category\": (\"\", \"kemono\", \"patreon\"),\n    \"#class\"   : kemono.KemonoPostExtractor,\n    \"#exception\": \"NotFoundError\",\n},\n\n{\n    \"#url\"     : \"https://kemono.cr/patreon/user/6298789/post/69764693\",\n    \"#comment\" : \"'published' metadata with extra microsecond data\",\n    \"#category\": (\"\", \"kemono\", \"patreon\"),\n    \"#class\"   : kemono.KemonoPostExtractor,\n\n    \"date\"     : \"dt:2022-07-29 21:12:11\",\n    \"published\": \"2022-07-29T21:12:11.483000\",\n},\n\n{\n    \"#url\"     : \"https://kemono.cr/gumroad/user/3267960360326/post/jwwag\",\n    \"#comment\" : \"empty 'file' with no 'path' (#5368)\",\n    \"#category\": (\"\", \"kemono\", \"gumroad\"),\n    \"#class\"   : kemono.KemonoPostExtractor,\n    \"#count\"   : 8,\n\n    \"type\"     : \"attachment\",\n},\n\n{\n    \"#url\"     : \"https://kemono.cr/fanbox/user/49494721/post/9457614\",\n    \"#comment\" : \"archives\",\n    \"#category\": (\"\", \"kemono\", \"fanbox\"),\n    \"#class\"   : kemono.KemonoPostExtractor,\n    \"#options\" : {\"archives\": True},\n    \"#range\"   : \"1-2\",\n\n    \"archives\": [\n        {\n            \"file\": {\n                \"added\": \"2025-03-03T02:11:28.153911\",\n                \"ctime\": \"2025-03-03T02:05:15.810201\",\n                \"ext\"  : \".zip\",\n                \"hash\" : \"c22c7e979355f633aaae4929b010816895a47ec37a9cfc25186a0952ec6e5774\",\n                \"id\"   : 190824068,\n                \"ihash\": None,\n                \"mime\" : \"application/zip\",\n                \"mtime\": \"2025-03-03T02:11:28.807462\",\n                \"size\" : 18634288,\n            },\n            \"file_list\": [\n                \"モナmp4形式まとめ/\",\n                \"モナmp4形式まとめ/Movie_1.mp4\",\n                \"モナmp4形式まとめ/Movie_2.mp4\",\n                \"モナmp4形式まとめ/Movie_3.mp4\",\n                \"モナmp4形式まとめ/Movie_4.mp4\",\n                \"モナmp4形式まとめ/Movie_5.mp4\",\n                \"モナmp4形式まとめ/Movie_End_3.mp4\",\n            ],\n            \"filename\": \"モナmp4形式まとめ\",\n            \"extension\": \"zip\",\n            \"hash\": \"c22c7e979355f633aaae4929b010816895a47ec37a9cfc25186a0952ec6e5774\",\n            \"name\": \"モナmp4形式まとめ.zip\",\n            \"password\": None,\n            \"path\": \"/c2/2c/c22c7e979355f633aaae4929b010816895a47ec37a9cfc25186a0952ec6e5774.zip\",\n            \"type\": \"archive\",\n            \"url\": \"https://kemono.cr/data/c2/2c/c22c7e979355f633aaae4929b010816895a47ec37a9cfc25186a0952ec6e5774.zip\",\n        },\n        {\n            \"file\": {\n                \"added\": \"2025-03-03T02:11:00.541142\",\n                \"ctime\": \"2025-03-03T02:04:56.754326\",\n                \"ext\"  : \".zip\",\n                \"hash\" : \"f7b4dedd9742aeb8da56dc6fe07deb7639880d0800ac0b7a6e91f64ff6b40178\",\n                \"id\"   : 190824029,\n                \"ihash\": None,\n                \"mime\" : \"application/zip\",\n                \"mtime\": \"2025-03-03T02:11:01.110281\",\n                \"size\" : 84738158,\n            },\n            \"file_list\": \"len:229\",\n            \"filename\": \"モナUnity\",\n            \"extension\": \"zip\",\n            \"hash\": \"f7b4dedd9742aeb8da56dc6fe07deb7639880d0800ac0b7a6e91f64ff6b40178\",\n            \"name\": \"モナUnity.zip\",\n            \"password\": None,\n            \"path\": \"/f7/b4/f7b4dedd9742aeb8da56dc6fe07deb7639880d0800ac0b7a6e91f64ff6b40178.zip\",\n            \"type\": \"archive\",\n            \"url\": \"https://kemono.cr/data/f7/b4/f7b4dedd9742aeb8da56dc6fe07deb7639880d0800ac0b7a6e91f64ff6b40178.zip\"\n        },\n    ],\n\n    \"title\": \"モナ（Live2Dアニメ）\",\n    \"type\": \"archive\",\n    \"user\": \"49494721\",\n    \"username\": \"soso\",\n    \"user_profile\": {\n        \"id\": \"49494721\",\n        \"indexed\": \"2021-04-02T23:50:57.138135\",\n        \"name\": \"soso\",\n        \"public_id\": \"soso\",\n        \"relation_id\": None,\n        \"service\": \"fanbox\",\n        \"updated\": \"iso:datetime\",\n    },\n    \"tags\": [\n        \"うごイラ\",\n        \"原神\",\n    ],\n},\n\n{\n    \"#url\"     : \"https://kemono.cr/fanbox/user/49494721/post/9457614\",\n    \"#comment\" : \"archives-format dict (#9104)\",\n    \"#category\": (\"\", \"kemono\", \"fanbox\"),\n    \"#class\"   : kemono.KemonoPostExtractor,\n    \"#options\" : {\"archives-format\": \"dict\"},\n    \"#range\"   : \"1-2\",\n\n    \"archives\": {\n        \"c22c7e979355f633aaae4929b010816895a47ec37a9cfc25186a0952ec6e5774\": dict,\n        \"f7b4dedd9742aeb8da56dc6fe07deb7639880d0800ac0b7a6e91f64ff6b40178\": dict,\n    },\n\n    \"title\": \"モナ（Live2Dアニメ）\",\n    \"type\": \"archive\",\n    \"user\": \"49494721\",\n},\n\n{\n    \"#url\"     : \"https://kemono.cr/boosty/user/felixf/post/d9d8d670-16be-4e06-8ff9-65b13e322ba8\",\n    \"#comment\" : r\"'\\' in file paths\",\n    \"#category\": (\"\", \"kemono\", \"boosty\"),\n    \"#class\"   : kemono.KemonoPostExtractor,\n    \"#results\" : (\n        \"https://kemono.cr/data/dd/35/dd35c43d8a93f1806f094d9331a17c5037ed5d93e0f30c28d3cca2056b400aa6.png\",\n        \"https://kemono.cr/data/25/48/254864eb2523ab48be8d3fb7ad21ab3a127d61736b76602f8421cde88700a174.png\",\n    ),\n\n    \"hash\": {\n        \"dd35c43d8a93f1806f094d9331a17c5037ed5d93e0f30c28d3cca2056b400aa6\",\n        \"254864eb2523ab48be8d3fb7ad21ab3a127d61736b76602f8421cde88700a174\",\n    },\n    \"path\": {\n        \"/dd/35/dd35c43d8a93f1806f094d9331a17c5037ed5d93e0f30c28d3cca2056b400aa6.png\",\n        \"/25/48/254864eb2523ab48be8d3fb7ad21ab3a127d61736b76602f8421cde88700a174.png\",\n    },\n},\n\n{\n    \"#url\"     : \"https://kemono.cr/patreon/user/108002999/post/136454591\",\n    \"#comment\" : \"'.zip' archive with '.bin' extension (#8156)\",\n    \"#category\": (\"\", \"kemono\", \"patreon\"),\n    \"#class\"   : kemono.KemonoPostExtractor,\n    \"#range\"   : \"0\",\n    \"#metadata\": \"post\",\n\n    \"archives\": [{\n        \"extension\": \"zip\",\n        \"filename\": \"#5 Kitagawa Marin\",\n        \"hash\": \"46cc99d4114906524fe52a6f772c51ab59ca1c3c0f6a8a0d3588a861b0d59ced\",\n        \"name\": \"#5 Kitagawa Marin.zip\",\n        \"path\": \"/46/cc/46cc99d4114906524fe52a6f772c51ab59ca1c3c0f6a8a0d3588a861b0d59ced.bin\",\n        \"type\": \"archive\",\n        \"url\": \"https://kemono.cr/data/46/cc/46cc99d4114906524fe52a6f772c51ab59ca1c3c0f6a8a0d3588a861b0d59ced.bin\"\n    }],\n},\n\n{\n    \"#url\"     : \"https://kemono.cr/patreon/user/34792417/post/137409895\",\n    \"#comment\" : \"user profile data unavailable (#8382)\",\n    \"#category\": (\"\", \"kemono\", \"patreon\"),\n    \"#class\"   : kemono.KemonoPostExtractor,\n    \"#results\" : (\n        \"https://kemono.cr/data/a9/87/a9874d7e1229396b0b2706fd7fa9949eac924e86256d84d077c10ecbace8bd17.bin\",\n        \"https://kemono.cr/data/a2/eb/a2eba02204086c789d59bc7112510aebf0428455ad1664153bfbb92eb8aa5643.jpg\",\n    ),\n\n    \"title\"       : \"Capella - Re:zero (20P)\",\n    \"user\"        : \"34792417\",\n    \"user_profile\": dict,\n    \"username\"    : \"Varas\",\n},\n\n{\n    \"#url\"     : \"https://kemono.cr/patreon/user/2570882/post/79311665\",\n    \"#comment\" : \"patreon file URL as 'name' / long 'extension' (#8491)\",\n    \"#category\": (\"\", \"kemono\", \"patreon\"),\n    \"#class\"   : kemono.KemonoPostExtractor,\n\n    \"name\"     : \"https://www.patreon.com/media-u/Z0FBQUFBQmpfWFNLWHpRakFlYjVNeWpuTlRuRnJBdHY3VVA2UmRhVHFpOFBHMW9QZUdVOHQ3b2pXSV9XMkJlaHFuN2JyVk5VNDBqdV9lZVRLR2NkUXUwSjgwdndDQlk3VzBCUXI5TW5iejlVWVZaUmJoTktIX3B5aGVCS3dUQk11a2hxajd4TUx2MFN2UHpKa0pfOWZQeS1UeDlzNEhpbG9pRzJsZE54MG5OcnZDOUllTGhyY01rNjVRaGgyaVFycjFSUUFIaV92OU9wdktuVjlMeFJNLXhYejdDNWZTVXZEc2l0TVZCR1A0YXM3RVMzbmsxSjh2ND0=#190833153_\",\n    \"filename\" : \"https://www.patreon.com/media-u/Z0FBQUFBQmpfWFNLWHpRakFlYjVNeWpuTlRuRnJBdHY3VVA2UmRhVHFpOFBHMW9QZUdVOHQ3b2pXSV9XMkJlaHFuN2JyVk5VNDBqdV9lZVRLR2NkUXUwSjgwdndDQlk3VzBCUXI5TW5iejlVWVZaUmJoTktIX3B5aGVCS3dUQk11a2hxajd4TUx2MFN2UHpKa0pfOWZQeS1UeDlzNEhpbG9pRzJsZE54MG5OcnZDOUllTGhyY01rNjVRaGgyaVFycjFSUUFIaV92OU9wdktuVjlMeFJNLXhYejdDNWZTVXZEc2l0TVZCR1A0YXM3RVMzbmsxSjh2ND0=#190833153_\",\n    \"extension\": \"jpg\",\n},\n\n{\n    \"#url\"     : \"https://kemono.cr/patreon/user/17152737/post/126135488/revisions/11162541\",\n    \"#comment\" : \"'str' values instead of 'dict' for 'file' & 'attachments' (#8929)\",\n    \"#category\": (\"\", \"kemono\", \"patreon\"),\n    \"#class\"   : kemono.KemonoPostExtractor,\n    \"#results\" : \"https://kemono.cr/data/b3/4d/b34d2ad89a59efa3746643c657310043bbada32751138aab7d23523fd1a5b765.png\",\n\n    \"revision_hash\" : \"758c2fe1d223a5d49093002ad162cef7e4ce5bb8\",\n    \"revision_id\"   : 11162541,\n    \"revision_index\": 14,\n    \"attachments\"   : [{\n        \"extension\": \"png\",\n        \"filename\" : \"448451741\",\n        \"hash\"     : \"b34d2ad89a59efa3746643c657310043bbada32751138aab7d23523fd1a5b765\",\n        \"name\"     : \"448451741.png\",\n        \"path\"     : \"/b3/4d/b34d2ad89a59efa3746643c657310043bbada32751138aab7d23523fd1a5b765.png\",\n        \"type\"     : \"attachment\",\n        \"url\"      : \"https://kemono.cr/data/b3/4d/b34d2ad89a59efa3746643c657310043bbada32751138aab7d23523fd1a5b765.png\",\n    }],\n    \"file\"          : {\n        \"hash\": \"b34d2ad89a59efa3746643c657310043bbada32751138aab7d23523fd1a5b765\",\n        \"name\": \"448451741.png\",\n        \"path\": \"/b3/4d/b34d2ad89a59efa3746643c657310043bbada32751138aab7d23523fd1a5b765.png\",\n        \"type\": \"file\",\n    },\n},\n\n{\n    \"#url\"     : \"https://kemono.cr/discord/server/488668827274444803/608504710906904576\",\n    \"#category\": (\"\", \"kemono\", \"discord\"),\n    \"#class\"   : kemono.KemonoDiscordExtractor,\n    \"#results\" : (\n        \"https://kemono.cr/data/6e/6a/6e6a4a048e6f3c047edac851d1f66eca4a4f0a823faa1d9395892378fcb700b1.png\",\n        \"https://kemono.cr/data/55/e1/55e1ddf540ded5e6651de65c059529d1f51451cde523ec103dc696f1cc3595a4.png\",\n        \"https://kemono.cr/data/9d/98/9d983fd163d5f5335c896c93b9f363198d6ca14a7e5bf0fa823aa86268732f85.png\",\n        \"https://kemono.cr/data/fb/54/fb54ff75f1c879b25bf031a55a1730002049337693443f1b57c08b07e35c452f.png\",\n    ),\n\n    \"channel\"      : \"finish-work\",\n    \"channel_id\"   : \"608504710906904576\",\n    \"channel_nsfw\" : False,\n    \"channel_topic\": None,\n    \"channel_type\" : 0,\n    \"server\"       : \"ABFMMD NSFW Server\",\n    \"server_id\"    : \"488668827274444803\",\n},\n\n{\n    \"#url\"     : \"https://kemono.cr/discord/server/488668827274444803/608504710906904576\",\n    \"#category\": (\"\", \"kemono\", \"discord\"),\n    \"#class\"   : kemono.KemonoDiscordExtractor,\n    \"#options\" : {\"order-posts\": \"reverse\"},\n    \"#results\" : (\n        \"https://kemono.cr/data/fb/54/fb54ff75f1c879b25bf031a55a1730002049337693443f1b57c08b07e35c452f.png\",\n        \"https://kemono.cr/data/9d/98/9d983fd163d5f5335c896c93b9f363198d6ca14a7e5bf0fa823aa86268732f85.png\",\n        \"https://kemono.cr/data/55/e1/55e1ddf540ded5e6651de65c059529d1f51451cde523ec103dc696f1cc3595a4.png\",\n        \"https://kemono.cr/data/6e/6a/6e6a4a048e6f3c047edac851d1f66eca4a4f0a823faa1d9395892378fcb700b1.png\",\n    ),\n\n    \"channel\"      : \"finish-work\",\n    \"channel_id\"   : \"608504710906904576\",\n    \"channel_nsfw\" : False,\n    \"channel_topic\": None,\n    \"channel_type\" : 0,\n    \"server\"       : \"ABFMMD NSFW Server\",\n    \"server_id\"    : \"488668827274444803\",\n},\n\n{\n    \"#url\"     : \"https://kemono.cr/discord/server/488668827274444803#608504710906904576\",\n    \"#category\": (\"\", \"kemono\", \"discord\"),\n    \"#class\"   : kemono.KemonoDiscordExtractor,\n    \"#count\"   : 4,\n\n    \"channel\"      : \"finish-work\",\n    \"channel_id\"   : \"608504710906904576\",\n    \"channel_nsfw\" : False,\n    \"channel_topic\": None,\n    \"channel_type\" : 0,\n    \"server\"       : \"ABFMMD NSFW Server\",\n    \"server_id\"    : \"488668827274444803\",\n},\n\n{\n    \"#url\"     : \"https://kemono.cr/discord/server/488668827274444803/channel/608504710906904576#finish-work\",\n    \"#category\": (\"\", \"kemono\", \"discord\"),\n    \"#class\"   : kemono.KemonoDiscordExtractor,\n    \"#count\"   : 4,\n\n    \"channel\"      : \"finish-work\",\n    \"channel_id\"   : \"608504710906904576\",\n    \"channel_nsfw\" : False,\n    \"channel_topic\": None,\n    \"channel_type\" : 0,\n    \"server\"       : \"ABFMMD NSFW Server\",\n    \"server_id\"    : \"488668827274444803\",\n    \"date\"         : \"type:datetime\",\n},\n\n{\n    \"#url\"     : \"https://kemono.cr/discord/server/818188637329031199/818343747275456522\",\n    \"#comment\" : \"pagination\",\n    \"#category\": (\"\", \"kemono\", \"discord\"),\n    \"#class\"   : kemono.KemonoDiscordExtractor,\n    \"#range\"   : \"1-250\",\n    \"#count\"   : 250,\n\n    \"channel\"      : \"wraith-sfw-gallery\",\n    \"channel_id\"   : \"818343747275456522\",\n    \"channel_nsfw\" : False,\n    \"channel_type\" : 0,\n    \"channel_topic\": None,\n    \"server\"       : \"The Ghost Zone\",\n    \"server_id\"    : \"818188637329031199\",\n},\n\n{\n    \"#url\"     : \"https://kemono.cr/discord/server/256559665620451329/channel/462437519519383555#\",\n    \"#category\": (\"\", \"kemono\", \"discord\"),\n    \"#class\"   : kemono.KemonoDiscordExtractor,\n    \"#pattern\" : r\"https://kemono\\.cr/data/(e3/77/e377e3525164559484ace2e64425b0cec1db08.*\\.png|51/45/51453640a5e0a4d23fbf57fb85390f9c5ec154.*\\.gif)\",\n    \"#count\"   : \">= 2\",\n\n    \"hash\": {\n        \"51453640a5e0a4d23fbf57fb85390f9c5ec15459af0bb5ba65a83781056b68e2\",\n        \"e377e3525164559484ace2e64425b0cec1db0863b9398682b90a9af006d87758\",\n    },\n},\n\n{\n    \"#url\"     : \"https://kemono.cr/discord/server/315262215055736843/channel/315262215055736843#general\",\n    \"#comment\" : \"'inline' files\",\n    \"#category\": (\"\", \"kemono\", \"discord\"),\n    \"#class\"   : kemono.KemonoDiscordExtractor,\n    \"#options\" : {\"image-filter\": \"type == 'inline'\"},\n    \"#pattern\" : r\"https://cdn\\.discordapp\\.com/attachments/\\d+/\\d+/.+$\",\n    \"#range\"   : \"1-5\",\n\n    \"hash\": \"\",\n},\n\n{\n    \"#url\"     : \"https://kemono.cr/discord/server/814339508694155294/815230464306446346\",\n    \"#comment\" : \"discord archives (#8898)\",\n    \"#class\"   : kemono.KemonoDiscordExtractor,\n    \"#options\" : {\n        \"archives\"   : True,\n        \"order-posts\": \"asc\",\n    },\n    \"#range\"   : \"1\",\n    \"#results\" : \"https://kemono.cr/data/ae/16/ae16db15cc15cc250db003964d6cd3cf2590863d925d96730871b6e75db3e69a.zip\",\n\n    \"added\"        : \"2021-10-24T09:11:03.268740\",\n    \"channel\"      : \"宝箱エリア\",\n    \"channel_id\"   : \"815230464306446346\",\n    \"channel_nsfw\" : True,\n    \"channel_topic\": None,\n    \"channel_type\" : 0,\n    \"content\"      : \"\",\n    \"count\"        : 1,\n    \"date\"         : \"dt:2021-02-28 03:17:21\",\n    \"edited\"       : None,\n    \"embeds\"       : [],\n    \"extension\"    : \"zip\",\n    \"filename\"     : \"Hachikuji_F\",\n    \"hash\"         : \"ae16db15cc15cc250db003964d6cd3cf2590863d925d96730871b6e75db3e69a\",\n    \"id\"           : \"815422363813675048\",\n    \"mentions\"     : [],\n    \"num\"          : 1,\n    \"parent_id\"    : None,\n    \"published\"    : \"2021-02-28T03:17:21.348000\",\n    \"revisions\"    : [],\n    \"seq\"          : 1,\n    \"server\"       : \"隠しエリア\",\n    \"server_id\"    : \"814339508694155294\",\n    \"type\"         : \"archive\",\n    \"archives\"     : [{\n        \"hash\"     : \"ae16db15cc15cc250db003964d6cd3cf2590863d925d96730871b6e75db3e69a\",\n        \"name\"     : \"Hachikuji_F.zip\",\n        \"password\" : \"894F\",\n        \"path\"     : \"/ae/16/ae16db15cc15cc250db003964d6cd3cf2590863d925d96730871b6e75db3e69a.zip\",\n        \"type\"     : \"attachment\",\n        \"file_list\": [\n            \"Hachikuji1.png\",\n            \"Hachikuji2.png\",\n            \"Hachikuji3.png\",\n            \"Hachikuji4.png\",\n            \"Hachikuji5.png\",\n            \"Hachikuji6.png\",\n            \"Hachikuji7.png\",\n            \"Hachikuji8.png\",\n        ],\n        \"file\"     : {\n            \"added\": \"2021-10-24T09:11:44.375913\",\n            \"ctime\": \"2021-10-24T09:11:44.318451\",\n            \"ext\"  : \".zip\",\n            \"flags\": \"00000011\",\n            \"hash\" : \"ae16db15cc15cc250db003964d6cd3cf2590863d925d96730871b6e75db3e69a\",\n            \"id\"   : 7886691,\n            \"ihash\": None,\n            \"mime\" : \"application/zip\",\n            \"mtime\": \"2021-10-24T09:11:44.318451\",\n            \"size\" : 12572064,\n        },\n    }],\n    \"attachments\"  : [{\n        \"hash\": \"ae16db15cc15cc250db003964d6cd3cf2590863d925d96730871b6e75db3e69a\",\n        \"name\": \"Hachikuji_F.zip\",\n        \"path\": \"/ae/16/ae16db15cc15cc250db003964d6cd3cf2590863d925d96730871b6e75db3e69a.zip\",\n        \"type\": \"attachment\",\n    }],\n    \"author\"       : {\n        \"avatar\"       : \"336bbb6864275a8ca05c3de4ec5d5984\",\n        \"discriminator\": \"6362\",\n        \"id\"           : \"798533277852893218\",\n        \"public_flags\" : 0,\n        \"username\"     : \"影おじ\",\n    },\n},\n\n{\n    \"#url\"     : \"https://kemono.cr/discord/server/814339508694155294/815230464306446346\",\n    \"#comment\" : \"discord archives-format (#9104)\",\n    \"#class\"   : kemono.KemonoDiscordExtractor,\n    \"#options\" : {\n        \"archives-format\": \"dict\",\n        \"order-posts\"    : \"asc\",\n    },\n    \"#range\"   : \"1\",\n    \"#results\" : \"https://kemono.cr/data/ae/16/ae16db15cc15cc250db003964d6cd3cf2590863d925d96730871b6e75db3e69a.zip\",\n\n    \"archives\": {\n        \"ae16db15cc15cc250db003964d6cd3cf2590863d925d96730871b6e75db3e69a\": dict,\n    },\n},\n\n{\n    \"#url\"     : \"https://kemono.cr/discord/server/488668827274444803\",\n    \"#category\": (\"\", \"kemono\", \"discord-server\"),\n    \"#class\"   : kemono.KemonoDiscordServerExtractor,\n    \"#pattern\" : kemono.KemonoDiscordExtractor.pattern,\n    \"#count\"   : 27,\n},\n\n{\n    \"#url\"     : \"https://kemono.cr/discord/server/488668827274444803/\",\n    \"#class\"   : kemono.KemonoDiscordServerExtractor,\n},\n\n{\n    \"#url\"     : \"https://kemono.cr/posts?q=foobar\",\n    \"#category\": (\"\", \"kemono\", \"posts\"),\n    \"#class\"   : kemono.KemonoPostsExtractor,\n    \"#count\"   : range(60, 100),\n},\n\n{\n    \"#url\"     : \"https://kemono.cr/favorites\",\n    \"#category\": (\"\", \"kemono\", \"favorite\"),\n    \"#class\"   : kemono.KemonoFavoriteExtractor,\n    \"#pattern\" : kemono.KemonoUserExtractor.pattern,\n    \"#auth\"    : True,\n    \"#results\" : (\n        \"https://kemono.cr/patreon/user/881792\",\n        \"https://kemono.cr/fanbox/user/6993449\",\n        \"https://kemono.cr/subscribestar/user/alcorart\",\n        \"https://kemono.cr/gumroad/user/shengtian\",\n    ),\n},\n\n{\n    \"#url\"     : \"https://kemono.cr/favorites?type=artist&sort=faved_seq&order=asc\",\n    \"#category\": (\"\", \"kemono\", \"favorite\"),\n    \"#class\"   : kemono.KemonoFavoriteExtractor,\n    \"#pattern\" : kemono.KemonoUserExtractor.pattern,\n    \"#auth\"    : True,\n    \"#results\" : (\n        \"https://kemono.cr/fanbox/user/6993449\",\n        \"https://kemono.cr/patreon/user/881792\",\n        \"https://kemono.cr/subscribestar/user/alcorart\",\n        \"https://kemono.cr/gumroad/user/shengtian\",\n    ),\n},\n\n{\n    \"#url\"     : \"https://kemono.cr/favorites?type=post\",\n    \"#category\": (\"\", \"kemono\", \"favorite\"),\n    \"#class\"   : kemono.KemonoFavoriteExtractor,\n    \"#pattern\" : kemono.KemonoPostExtractor.pattern,\n    \"#auth\"    : True,\n    \"#results\" : (\n        \"https://kemono.cr/subscribestar/user/alcorart/post/184329\",\n        \"https://kemono.cr/fanbox/user/6993449/post/23913\",\n        \"https://kemono.cr/patreon/user/881792/post/4769638\",\n    ),\n},\n\n{\n    \"#url\"     : \"https://kemono.cr/favorites?type=post&sort=published&order=asc\",\n    \"#category\": (\"\", \"kemono\", \"favorite\"),\n    \"#class\"   : kemono.KemonoFavoriteExtractor,\n    \"#pattern\" : kemono.KemonoPostExtractor.pattern,\n    \"#auth\"    : True,\n    \"#results\" : (\n        \"https://kemono.cr/patreon/user/881792/post/4769638\",\n        \"https://kemono.cr/fanbox/user/6993449/post/23913\",\n        \"https://kemono.cr/subscribestar/user/alcorart/post/184329\",\n    ),\n},\n\n{\n    \"#url\"     : \"https://kemono.cr/account/favorites/artists\",\n    \"#category\": (\"\", \"kemono\", \"favorite\"),\n    \"#class\"   : kemono.KemonoFavoriteExtractor,\n},\n\n{\n    \"#url\"     : \"https://kemono.cr/account/favorites/posts?sort_by=published&order=asc\",\n    \"#category\": (\"\", \"kemono\", \"favorite\"),\n    \"#class\"   : kemono.KemonoFavoriteExtractor,\n},\n\n{\n    \"#url\"     : \"https://kemono.cr/artists?q=aMSa\",\n    \"#category\": (\"\", \"kemono\", \"artists\"),\n    \"#class\"   : kemono.KemonoArtistsExtractor,\n    \"#pattern\" : kemono.KemonoUserExtractor.pattern,\n    \"#count\"   : range(15, 20),\n\n    \"favorited\": int,\n    \"id\"       : str,\n    \"indexed\"  : int,\n    \"name\"     : str,\n    \"service\"  : {\"patreon\", \"fanbox\"},\n    \"updated\"  : int,\n},\n\n{\n    \"#url\"     : \"https://kemono.cr/artists?q=Axe&service=discord&sort_by=name&order=asc\",\n    \"#category\": (\"\", \"kemono\", \"artists\"),\n    \"#class\"   : kemono.KemonoArtistsExtractor,\n    \"#pattern\" : kemono.KemonoDiscordServerExtractor.pattern,\n    \"#results\" : \"https://kemono.cr/discord/server/1168450323023663164\",\n\n    \"favorited\": range(40, 80),\n    \"id\"       : \"1168450323023663164\",\n    \"indexed\"  : 1710201675,\n    \"name\"     : \"Axel Colored Workshop\",\n    \"service\"  : \"discord\",\n    \"updated\"  : range(1740000000, 2000000000),\n},\n\n)\n"
  },
  {
    "path": "test/results/khinsider.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import khinsider\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://downloads.khinsider.com/game-soundtracks/album/horizon-riders-wii\",\n    \"#class\"   : khinsider.KhinsiderSoundtrackExtractor,\n    \"#pattern\" : r\"https?://(dl\\.|kappa\\.)?vgm(site|downloads)\\.com/soundtracks/horizon-riders-wii/[^/]+/Horizon%20Riders%20Wii%20-%20Full%20Soundtrack\\.mp3\",\n    \"#count\"   : 1,\n\n    \"album\"    : {\n        \"catalog\"  : \"\",\n        \"count\"    : 1,\n        \"date\"     : \"Sep 18th, 2016\",\n        \"developer\": \"Sabarasa\",\n        \"publisher\": \"Sabarasa\",\n        \"name\"     : \"Horizon Riders (WiiWare)\",\n        \"platform\" : [\"Wii\"],\n        \"size\"     : 26214400,\n        \"type\"     : \"Gamerip\",\n        \"uploader\" : \"\",\n        \"year\"     : \"2011\",\n\n    },\n    \"extension\": \"mp3\",\n    \"filename\" : \"Horizon Riders Wii - Full Soundtrack\",\n},\n\n{\n    \"#url\"  : \"https://downloads.khinsider.com/game-soundtracks/album/last-kingdom-goddess-of-victory-nikke-original-soundtrack-2024\",\n    \"#class\": khinsider.KhinsiderSoundtrackExtractor,\n    \"#range\": \"1\",\n\n    \"album\": {\n        \"catalog\"  : \"N/A\",\n        \"count\"    : 18,\n        \"date\"     : \"Dec 23rd, 2024\",\n        \"description\": r\"re:<p>Composer:.+\",\n        \"developer\": \"\",\n        \"name\"     : \"Last Kingdom (Goddess of Victory: NIKKE Original Soundtrack)\",\n        \"platform\" : [\"Android\", \"iOS\", \"Windows\"],\n        \"publisher\": \"LEVEL NINE\",\n        \"size\"     : 138412032,\n        \"type\"     : \"Soundtrack\",\n        \"uploader\" : \"ルナブレイズ\",\n        \"year\"     : \"2024\"\n    },\n    \"extension\": \"mp3\",\n    \"filename\" : str,\n    \"num\"      : int,\n    \"type\"     : \"track\",\n    \"url\"      : str,\n},\n\n{\n    \"#url\"  : \"https://downloads.khinsider.com/game-soundtracks/album/super-mario-64-soundtrack\",\n    \"#class\": khinsider.KhinsiderSoundtrackExtractor,\n    \"#options\": {\"covers\": True},\n    \"#range\"  : \"1-10\",\n    \"#results\": (\n        \"https://vgmsite.com/soundtracks/super-mario-64-soundtrack/00%20Front.jpg\",\n        \"https://vgmsite.com/soundtracks/super-mario-64-soundtrack/01%20Back.jpg\",\n        \"https://vgmsite.com/soundtracks/super-mario-64-soundtrack/02%20Booklet%20Front%20and%20Back.jpg\",\n        \"https://vgmsite.com/soundtracks/super-mario-64-soundtrack/03%20Booklet%20p%2001-02.jpg\",\n        \"https://vgmsite.com/soundtracks/super-mario-64-soundtrack/04%20Booklet%20p%2003-04.jpg\",\n        \"https://vgmsite.com/soundtracks/super-mario-64-soundtrack/05%20Booklet%20p%2005-06.jpg\",\n        \"https://vgmsite.com/soundtracks/super-mario-64-soundtrack/06%20Disc.jpg\",\n        \"https://vgmsite.com/soundtracks/super-mario-64-soundtrack/07%20Front%20digital.png\",\n        \"https://vgmsite.com/soundtracks/super-mario-64-soundtrack/08%20Obi.jpg\",\n        \"https://vgmsite.com/soundtracks/super-mario-64-soundtrack/09%20Tray.jpg\",\n    ),\n\n    \"extension\": {\"jpg\", \"png\"},\n    \"type\"     : \"cover\",\n    \"album\"    : {\n        \"catalog\"  : \"PCCG-00357\",\n        \"count\"    : 36,\n        \"date\"     : \"Jul 1st, 2024\",\n        \"description\": r\"re:<p>Composer: Koji Kondo\",\n        \"developer\": \"\",\n        \"name\"     : \"Super Mario 64 Original Soundtrack\",\n        \"platform\" : [\"N64\"],\n        \"publisher\": \"Nintendo\",\n        \"size\"     : 102760448,\n        \"type\"     : \"Soundtrack\",\n        \"uploader\" : \"HeroArts\",\n        \"year\"     : \"1996\",\n    },\n},\n\n)\n"
  },
  {
    "path": "test/results/kohlchan.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import lynxchan\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://kohlchan.net/a/res/4594.html\",\n    \"#category\": (\"lynxchan\", \"kohlchan\", \"thread\"),\n    \"#class\"   : lynxchan.LynxchanThreadExtractor,\n    \"#pattern\" : r\"https://kohlchan\\.net/\\.media/[0-9a-f]{64}(\\.\\w+)?$\",\n    \"#count\"   : \">= 80\",\n},\n\n{\n    \"#url\"     : \"https://kohlchan.net/a/\",\n    \"#category\": (\"lynxchan\", \"kohlchan\", \"board\"),\n    \"#class\"   : lynxchan.LynxchanBoardExtractor,\n    \"#pattern\" : lynxchan.LynxchanThreadExtractor.pattern,\n    \"#count\"   : \">= 100\",\n},\n\n{\n    \"#url\"     : \"https://kohlchan.net/a/2.html\",\n    \"#category\": (\"lynxchan\", \"kohlchan\", \"board\"),\n    \"#class\"   : lynxchan.LynxchanBoardExtractor,\n},\n\n{\n    \"#url\"     : \"https://kohlchan.net/a/catalog.html\",\n    \"#category\": (\"lynxchan\", \"kohlchan\", \"board\"),\n    \"#class\"   : lynxchan.LynxchanBoardExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/komikcast.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import komikcast\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://komikcast.li/chapter/apotheosis-chapter-02-2-bahasa-indonesia/\",\n    \"#class\"   : komikcast.KomikcastChapterExtractor,\n    \"#pattern\" : r\"https://svr?\\d+\\.imgkc\\d+\\.my\\.id/wp-content/img/A/Apotheosis/002-2/\\d{3}\\.jpg\",\n    \"#count\"   : 18,\n\n    \"chapter\"  : 2,\n    \"chapter_minor\": \".2\",\n    \"count\"    : 18,\n    \"extension\": \"jpg\",\n    \"filename\" : r\"re:0\\d{2}\",\n    \"lang\"     : \"id\",\n    \"language\" : \"Indonesian\",\n    \"manga\"    : \"Apotheosis\",\n    \"page\"     : range(1, 18),\n    \"title\"    : \"\",\n},\n\n{\n    \"#url\"     : \"https://komikcast02.com/chapter/apotheosis-chapter-02-2-bahasa-indonesia/\",\n    \"#class\"   : komikcast.KomikcastChapterExtractor,\n},\n\n{\n    \"#url\"     : \"https://komikcast.lol/chapter/apotheosis-chapter-02-2-bahasa-indonesia/\",\n    \"#class\"   : komikcast.KomikcastChapterExtractor,\n},\n\n{\n    \"#url\"     : \"https://komikcast.site/chapter/apotheosis-chapter-02-2-bahasa-indonesia/\",\n    \"#class\"   : komikcast.KomikcastChapterExtractor,\n},\n\n{\n    \"#url\"     : \"https://komikcast.me/chapter/apotheosis-chapter-02-2-bahasa-indonesia/\",\n    \"#class\"   : komikcast.KomikcastChapterExtractor,\n},\n\n{\n    \"#url\"     : \"https://komikcast.com/chapter/apotheosis-chapter-02-2-bahasa-indonesia/\",\n    \"#class\"   : komikcast.KomikcastChapterExtractor,\n},\n\n{\n    \"#url\"     : \"https://komikcast.cz/chapter/apotheosis-chapter-02-2-bahasa-indonesia/\",\n    \"#class\"   : komikcast.KomikcastChapterExtractor,\n},\n\n{\n    \"#url\"     : \"https://komikcast.la/chapter/apotheosis-chapter-02-2-bahasa-indonesia/\",\n    \"#class\"   : komikcast.KomikcastChapterExtractor,\n},\n\n{\n    \"#url\"     : \"https://komikcast.me/chapter/soul-land-ii-chapter-300-1-bahasa-indonesia/\",\n    \"#class\"   : komikcast.KomikcastChapterExtractor,\n    \"#pattern\" : r\"https://svr?\\d\\.imgkc\\d*\\.my\\.id/wp-content/img/S/Soul_Land_II/300\\.1/\\d\\d\\.jpg\",\n    \"#count\"   : 9,\n    \"#sha1_metadata\": \"cb646cfed3d45105bd645ab38b2e9f7d8c436436\",\n},\n\n{\n    \"#url\"     : \"https://komikcast.site/komik/090-eko-to-issho/\",\n    \"#class\"   : komikcast.KomikcastMangaExtractor,\n    \"#pattern\" : komikcast.KomikcastChapterExtractor.pattern,\n    \"#count\"   : 12,\n\n    \"author\" : \"Asakura Maru\",\n    \"chapter\": range(1, 12),\n    \"chapter_minor\": \"\",\n    \"genres\" : [\n        \"Comedy\",\n        \"Drama\",\n        \"Romance\",\n        \"School Life\",\n        \"Sci-Fi\",\n        \"Shounen\"\n    ],\n    \"manga\"  : \"090 Eko to Issho\",\n    \"type\"   : \"Manga\",\n},\n\n{\n    \"#url\"     : \"https://komikcast.me/tonari-no-kashiwagi-san/\",\n    \"#class\"   : komikcast.KomikcastMangaExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/konachan.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import moebooru\nfrom gallery_dl import exception\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://konachan.com/post/show/205189\",\n    \"#category\": (\"moebooru\", \"konachan\", \"post\"),\n    \"#class\"   : moebooru.MoebooruPostExtractor,\n    \"#options\"     : {\"tags\": True},\n    \"#sha1_content\": \"674e75a753df82f5ad80803f575818b8e46e4b65\",\n\n    \"tags_artist\"   : \"patata\",\n    \"tags_character\": \"clownpiece\",\n    \"tags_copyright\": \"touhou\",\n    \"tags_general\"  : str,\n},\n\n{\n    \"#url\"     : \"https://konachan.net/post/show/205189\",\n    \"#category\": (\"moebooru\", \"konachan\", \"post\"),\n    \"#class\"   : moebooru.MoebooruPostExtractor,\n},\n\n{\n    \"#url\"     : \"https://konachan.com/post?tags=patata\",\n    \"#category\": (\"moebooru\", \"konachan\", \"tag\"),\n    \"#class\"   : moebooru.MoebooruTagExtractor,\n    \"#sha1_content\": \"838cfb815e31f48160855435655ddf7bfc4ecb8d\",\n},\n\n{\n    \"#url\"     : \"https://konachan.com/post?tags=\",\n    \"#comment\" : \"empty 'tags' (#4354)\",\n    \"#category\": (\"moebooru\", \"konachan\", \"tag\"),\n    \"#class\"   : moebooru.MoebooruTagExtractor,\n},\n\n{\n    \"#url\"     : \"https://konachan.net/post?tags=patata\",\n    \"#category\": (\"moebooru\", \"konachan\", \"tag\"),\n    \"#class\"   : moebooru.MoebooruTagExtractor,\n},\n\n{\n    \"#url\"     : \"https://konachan.com/pool/show/95\",\n    \"#category\": (\"moebooru\", \"konachan\", \"pool\"),\n    \"#class\"   : moebooru.MoebooruPoolExtractor,\n    \"#sha1_content\": \"cf0546e38a93c2c510a478f8744e60687b7a8426\",\n},\n\n{\n    \"#url\"     : \"https://konachan.com/pool/show/95\",\n    \"#comment\" : \"'metadata' option (#4646)\",\n    \"#category\": (\"moebooru\", \"konachan\", \"pool\"),\n    \"#class\"   : moebooru.MoebooruPoolExtractor,\n    \"#options\"  : {\"metadata\": True},\n    \"#exception\": exception.HttpError,\n},\n\n{\n    \"#url\"     : \"https://konachan.net/pool/show/95\",\n    \"#category\": (\"moebooru\", \"konachan\", \"pool\"),\n    \"#class\"   : moebooru.MoebooruPoolExtractor,\n},\n\n{\n    \"#url\"     : \"https://konachan.com/post/popular_by_month?month=11&year=2010\",\n    \"#category\": (\"moebooru\", \"konachan\", \"popular\"),\n    \"#class\"   : moebooru.MoebooruPopularExtractor,\n    \"#count\"   : 20,\n},\n\n{\n    \"#url\"     : \"https://konachan.com/post/popular_recent\",\n    \"#category\": (\"moebooru\", \"konachan\", \"popular\"),\n    \"#class\"   : moebooru.MoebooruPopularExtractor,\n},\n\n{\n    \"#url\"     : \"https://konachan.net/post/popular_recent\",\n    \"#category\": (\"moebooru\", \"konachan\", \"popular\"),\n    \"#class\"   : moebooru.MoebooruPopularExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/koofr.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import koofr\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://k00.fr/cltf71jr\",\n    \"#class\"   : koofr.KoofrSharedExtractor,\n    \"#results\" : \"https://app.koofr.net/content/links/923b4f56-3aaf-49ee-95e3-d85c52b687b0/files/get/wsf-form-job-application-form.json?path=/&force\",\n    \"#sha1_content\": \"f65ccc63a99165ecb9ff2ab92302c25b245a904f\",\n\n    \"contentType\": \"application/json\",\n    \"extension\"  : \"json\",\n    \"filename\"   : \"wsf-form-job-application-form\",\n    \"hash\"       : \"99271125b819ee7907dc47ab723f6dc7\",\n    \"modified\"   : 1728623530078,\n    \"name\"       : \"wsf-form-job-application-form.json\",\n    \"size\"       : 18023,\n    \"tags\"       : {},\n    \"type\"       : \"file\",\n},\n\n{\n    \"#url\"     : \"https://app.koofr.net/links/923b4f56-3aaf-49ee-95e3-d85c52b687b0\",\n    \"#class\"   : koofr.KoofrSharedExtractor,\n    \"#results\" : \"https://app.koofr.net/content/links/923b4f56-3aaf-49ee-95e3-d85c52b687b0/files/get/wsf-form-job-application-form.json?path=/&force\",\n    \"#sha1_content\": \"f65ccc63a99165ecb9ff2ab92302c25b245a904f\",\n\n    \"contentType\": \"application/json\",\n    \"extension\"  : \"json\",\n    \"filename\"   : \"wsf-form-job-application-form\",\n    \"hash\"       : \"99271125b819ee7907dc47ab723f6dc7\",\n    \"modified\"   : 1728623530078,\n    \"name\"       : \"wsf-form-job-application-form.json\",\n    \"size\"       : 18023,\n    \"tags\"       : {},\n    \"type\"       : \"file\",\n},\n\n{\n    \"#url\"     : \"https://app.koofr.eu/links/923b4f56-3aaf-49ee-95e3-d85c52b687b0\",\n    \"#class\"   : koofr.KoofrSharedExtractor,\n},\n\n{\n    \"#url\"     : \"https://app.koofr.net/links/01deac62-f5d6-4d2b-7043-53b24cc0a038\",\n    \"#comment\" : \"individual files\",\n    \"#class\"   : koofr.KoofrSharedExtractor,\n    \"#options\" : {\"zip\": False},\n    \"#pattern\" : r\"https://app\\.koofr\\.net/content/links/01deac62\\-f5d6\\-4d2b\\-7043\\-53b24cc0a038/files/get/smw_msu1\\-\\d+\\.pcm\\?path=/smw_msu1\\-\\d+\\.pcm\",\n    \"#count\"   : 18,\n\n    \"contentType\": \"application/octet-stream\",\n    \"count\"      : 18,\n    \"num\"        : range(1, 18),\n    \"date\"       : \"type:datetime\",\n    \"extension\"  : \"pcm\",\n    \"filename\"   : r\"re:smw_msu1-\\d+\",\n    \"hash\"       : \"hash:md5\",\n    \"modified\"   : int,\n    \"name\"       : r\"re:smw_msu1-\\d+\\.pcm\",\n    \"size\"       : range(500_000, 20_000_000),\n    \"tags\"       : {},\n    \"type\"       : \"file\",\n    \"post\"       : {\n        \"!count\"     : 18,\n        \"date\"       : \"dt:2023-11-19 16:27:56\",\n        \"id\"         : \"01deac62-f5d6-4d2b-7043-53b24cc0a038\",\n        \"title\"      : \"Church of Kondo\",\n    },\n},\n\n{\n    \"#url\"     : \"https://app.koofr.net/links/01deac62-f5d6-4d2b-7043-53b24cc0a038\",\n    \"#comment\" : \".zip container\",\n    \"#class\"   : koofr.KoofrSharedExtractor,\n    \"#options\" : {\"recursive\": False},\n    \"#results\" : \"https://app.koofr.net/content/links/01deac62-f5d6-4d2b-7043-53b24cc0a038/files/get/Church of Kondo?path=/&force\",\n\n    \"contentType\": \"\",\n    \"count\"     : 1,\n    \"num\"       : 1,\n    \"date\"       : \"dt:2023-11-19 16:27:56\",\n    \"extension\"  : \"\",\n    \"filename\"   : \"Church of Kondo\",\n    \"modified\"   : 1700411276087,\n    \"name\"       : \"Church of Kondo\",\n    \"size\"       : 0,\n    \"tags\"       : {},\n    \"type\"       : \"dir\",\n    \"post\"       : {\n        \"!count\"     : 1,\n        \"date\"       : \"dt:2023-11-19 16:27:56\",\n        \"id\"         : \"01deac62-f5d6-4d2b-7043-53b24cc0a038\",\n        \"title\"      : \"Church of Kondo\",\n    },\n},\n\n{\n    \"#url\"     : \"https://app.koofr.net/links/7667d857-c639-4f38-93d1-c42394492a0c\",\n    \"#comment\" : \"recursive directories\",\n    \"#class\"   : koofr.KoofrSharedExtractor,\n    \"#pattern\" : r\"https://app\\.koofr\\.net/content/links/7667d857\\-c639\\-4f38\\-93d1\\-c42394492a0c/files/get/[\\w]\\.png\\?path=/.*\\w\\.png\",\n    \"#count\"   : 16,\n\n    \"contentType\": \"image/png\",\n    \"count\"     : 4,\n    \"num\"       : range(1, 4),\n    \"date\"       : \"type:datetime\",\n    \"extension\"  : \"png\",\n    \"filename\"   : r\"re:^[1-8a-d]$\",\n    \"hash\"       : \"hash:md5\",\n    \"modified\"   : range(1767688000000, 1767700000000),\n    \"name\"       : r\"re:^[1-8a-d]\\.png\",\n    \"path\"       : {(), (\"dir-l1-1\",), (\"dir-l1-2\",), (\"dir-l1-1\", \"dir-l2-1\")},\n    \"size\"       : range(200, 999),\n    \"tags\"       : {},\n    \"type\"       : \"file\",\n    \"post\"       : {\n        \"date\" : \"dt:2026-01-06 08:27:26\",\n        \"id\"   : \"7667d857-c639-4f38-93d1-c42394492a0c\",\n        \"title\": \"dir\",\n    },\n},\n\n)\n"
  },
  {
    "path": "test/results/leakgallery.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import leakgallery\nFILE_PATTERN = r\"https://cdn.leakgallery.com/content(-videos|\\d+)?/[\\w.-]+\\.\\w+\"\n\n\n__tests__ = (\n{\n    \"#url\"    : \"https://leakgallery.com/sophieraiin/12240\",\n    \"#class\"  : leakgallery.LeakgalleryPostExtractor,\n    \"#results\": \"https://cdn.leakgallery.com/content-videos/watermark_745_sophieraiin_241.mp4\",\n\n    \"id\"     : \"12240\",\n    \"creator\": \"sophieraiin\",\n},\n\n{\n    \"#url\"    : \"https://leakgallery.com/sophieraiin\",\n    \"#class\"  : leakgallery.LeakgalleryUserExtractor,\n    \"#pattern\": r\"https://cdn.leakgallery.com/content3/(compressed_)?watermark_[0-9a-f]+_sophieraiin_\\w+\\.(jpg|png|mp4|mov)\",\n    \"#range\"  : \"1-100\",\n    \"#count\"  : 100,\n\n    \"creator\": \"sophieraiin\",\n},\n\n{\n    \"#url\"    : \"https://leakgallery.com/trending-medias/Week\",\n    \"#class\"  : leakgallery.LeakgalleryTrendingExtractor,\n    \"#pattern\": FILE_PATTERN,\n    \"#range\"  : \"1-100\",\n    \"#count\"  : 100,\n},\n\n{\n    \"#url\"    : \"https://leakgallery.com/most-liked\",\n    \"#class\"  : leakgallery.LeakgalleryMostlikedExtractor,\n    \"#pattern\": FILE_PATTERN,\n    \"#range\"  : \"1-100\",\n    \"#count\"  : 100,\n},\n\n)\n"
  },
  {
    "path": "test/results/lensdump.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import lensdump\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://lensdump.com/a/1IhJr\",\n    \"#class\"   : lensdump.LensdumpAlbumExtractor,\n    \"#pattern\" : r\"https://[abcd]\\.l3n\\.co/(i/)?tq\\w{4}\\.png\",\n\n    \"extension\": \"png\",\n    \"name\"     : str,\n    \"num\"      : int,\n    \"title\"    : str,\n    \"url\"      : str,\n    \"width\"    : int,\n},\n\n{\n    \"#url\"     : \"https://lensdump.com/a/tA4lA\",\n    \"#comment\" : \"2 pages\",\n    \"#class\"   : lensdump.LensdumpAlbumExtractor,\n    \"#pattern\" : r\"https://[abcd]\\.l3n\\.co/(i/)?\\w{6}\\.(jpe?g|png)\",\n    \"#count\"   : range(80, 120),\n},\n\n{\n    \"#url\"     : \"https://lensdump.com/vstar925\",\n    \"#class\"   : lensdump.LensdumpAlbumsExtractor,\n    \"#results\" : (\n        \"https://lensdump.com/a/tX1uA\",\n        \"https://lensdump.com/a/R0gfK\",\n        \"https://lensdump.com/a/RSOMv\",\n        \"https://lensdump.com/a/9TbdT\",\n    ),\n},\n\n{\n    \"#url\"     : \"https://lensdump.com/vstar925/?sort=likes_desc&page=1\",\n    \"#comment\" : \"custom sort order\",\n    \"#class\"   : lensdump.LensdumpAlbumsExtractor,\n    \"#results\" : (\n        \"https://lensdump.com/a/9TbdT\",\n        \"https://lensdump.com/a/RSOMv\",\n        \"https://lensdump.com/a/R0gfK\",\n        \"https://lensdump.com/a/tX1uA\",\n    ),\n},\n\n{\n    \"#url\"     : \"https://lensdump.com/vstar925/albums\",\n    \"#class\"   : lensdump.LensdumpAlbumsExtractor,\n},\n\n{\n    \"#url\"     : \"https://lensdump.com/i/tyoAyM\",\n    \"#class\"   : lensdump.LensdumpImageExtractor,\n    \"#results\"     : \"https://c.l3n.co/tyoAyM.webp\",\n    \"#sha1_content\": \"1aa749ed2c0cf679ec8e1df60068edaf3875de46\",\n\n    \"date\"     : \"dt:2022-08-01 08:24:28\",\n    \"extension\": \"webp\",\n    \"filename\" : \"tyoAyM\",\n    \"height\"   : 400,\n    \"id\"       : \"tyoAyM\",\n    \"title\"    : \"MYOBI clovis bookcaseset\",\n    \"url\"      : \"https://c.l3n.co/tyoAyM.webp\",\n    \"width\"    : 620,\n},\n\n{\n    \"#url\"     : \"https://c.l3n.co/i/tyoAyM.webp\",\n    \"#class\"   : lensdump.LensdumpImageExtractor,\n    \"#results\" : \"https://c.l3n.co/tyoAyM.webp\",\n\n    \"date\"     : \"dt:2022-08-01 08:24:28\",\n    \"extension\": \"webp\",\n    \"filename\" : \"tyoAyM\",\n    \"height\"   : 400,\n    \"id\"       : \"tyoAyM\",\n    \"title\"    : \"MYOBI clovis bookcaseset\",\n    \"url\"      : \"https://c.l3n.co/tyoAyM.webp\",\n    \"width\"    : 620,\n},\n\n{\n    \"#url\"     : \"https://c.l3n.co/tyoAyM.webp\",\n    \"#comment\" : \"direct link without '/i/' (#8251)\",\n    \"#class\"   : lensdump.LensdumpImageExtractor,\n    \"#results\" : \"https://c.l3n.co/tyoAyM.webp\",\n\n    \"date\"     : \"dt:2022-08-01 08:24:28\",\n    \"extension\": \"webp\",\n    \"filename\" : \"tyoAyM\",\n    \"height\"   : 400,\n    \"id\"       : \"tyoAyM\",\n    \"title\"    : \"MYOBI clovis bookcaseset\",\n    \"url\"      : \"https://c.l3n.co/tyoAyM.webp\",\n    \"width\"    : 620,\n},\n\n{\n    \"#url\"     : \"https://i.lensdump.com/i/tyoAyM\",\n    \"#class\"   : lensdump.LensdumpImageExtractor,\n},\n\n{\n    \"#url\"     : \"https://i3.lensdump.com/i/tyoAyM\",\n    \"#class\"   : lensdump.LensdumpImageExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/lesbianenergy.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import misskey\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://lesbian.energy/@rerorero\",\n    \"#category\": (\"misskey\", \"lesbian.energy\", \"user\"),\n    \"#class\"   : misskey.MisskeyUserExtractor,\n    \"#pattern\" : r\"https://(lesbian.energy/files/\\w+|.+/media_attachments/files/.+)\",\n    \"#range\"   : \"1-50\",\n    \"#count\"   : 50,\n},\n\n{\n    \"#url\"     : \"https://lesbian.energy/@nano@mk.yopo.work\",\n    \"#category\": (\"misskey\", \"lesbian.energy\", \"user\"),\n    \"#class\"   : misskey.MisskeyUserExtractor,\n},\n\n{\n    \"#url\"     : \"https://lesbian.energy/notes/995ig09wqy\",\n    \"#category\": (\"misskey\", \"lesbian.energy\", \"note\"),\n    \"#class\"   : misskey.MisskeyNoteExtractor,\n    \"#count\"   : 1,\n},\n\n{\n    \"#url\"     : \"https://lesbian.energy/notes/96ynd9w5kc\",\n    \"#category\": (\"misskey\", \"lesbian.energy\", \"note\"),\n    \"#class\"   : misskey.MisskeyNoteExtractor,\n},\n\n{\n    \"#url\"     : \"https://lesbian.energy/my/favorites\",\n    \"#category\": (\"misskey\", \"lesbian.energy\", \"favorite\"),\n    \"#class\"   : misskey.MisskeyFavoriteExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/lexica.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import lexica\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://lexica.art/?q=tree\",\n    \"#category\": (\"\", \"lexica\", \"search\"),\n    \"#class\"   : lexica.LexicaSearchExtractor,\n    \"#pattern\" : r\"https://lexica-serve-encoded-images2\\.sharif\\.workers.dev/full_jpg/[0-9a-f-]{36}$\",\n    \"#range\"   : \"1-80\",\n    \"#count\"   : 80,\n\n    \"height\"         : int,\n    \"id\"             : str,\n    \"upscaled_height\": int,\n    \"upscaled_width\" : int,\n    \"userid\"         : str,\n    \"width\"          : int,\n    \"prompt\"         : {\n        \"c\"                : int,\n        \"grid\"             : bool,\n        \"height\"           : int,\n        \"id\"               : str,\n        \"images\"           : list,\n        \"initImage\"        : None,\n        \"initImageStrength\": None,\n        \"model\"            : \"lexica-aperture-v2\",\n        \"negativePrompt\"   : str,\n        \"prompt\"           : str,\n        \"seed\"             : str,\n        \"timestamp\"        : r\"re:\\d{4}-\\d\\d-\\d\\dT\\d\\d:\\d\\d:\\d\\d.\\d\\d\\dZ\",\n        \"width\"            : int,\n    },\n},\n\n)\n"
  },
  {
    "path": "test/results/lightbrd.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import nitter\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://lightbrd.com/supernaturepics/status/604341487988576256\",\n    \"#category\": (\"nitter\", \"lightbrd\", \"tweet\"),\n    \"#class\"   : nitter.NitterTweetExtractor,\n},\n\n{\n    \"#url\"     : \"https://lightbrd.com/supernaturepics\",\n    \"#category\": (\"nitter\", \"lightbrd\", \"tweets\"),\n    \"#class\"   : nitter.NitterTweetsExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/lightroom.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import lightroom\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://lightroom.adobe.com/shares/0c9cce2033f24d24975423fe616368bf\",\n    \"#category\": (\"\", \"lightroom\", \"gallery\"),\n    \"#class\"   : lightroom.LightroomGalleryExtractor,\n    \"#count\"   : \">= 55\",\n\n    \"title\": \"Sterne und Nachtphotos\",\n    \"user\" : \"Christian Schrang\",\n},\n\n{\n    \"#url\"     : \"https://lightroom.adobe.com/shares/7ba68ad5a97e48608d2e6c57e6082813\",\n    \"#category\": (\"\", \"lightroom\", \"gallery\"),\n    \"#class\"   : lightroom.LightroomGalleryExtractor,\n    \"#count\"   : \">= 180\",\n\n    \"title\": \"HEBFC Snr/Res v Brighton\",\n    \"user\" : \"\",\n},\n\n)\n"
  },
  {
    "path": "test/results/listal.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import listal\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://www.listal.com/viewimage/29620846\",\n    \"#class\"   : listal.ListalImageExtractor,\n    \"#results\" : \"https://ilarge.lisimg.com/image/29620846/1030full-jim-carrey.jpg\",\n\n    \"author\"    : \"sinaia16\",\n    \"author_url\": \"https://sinaia16.listal.com\",\n    \"date\"      : \"dt:2024-07-18 18:50:00\",\n    \"extension\" : \"jpg\",\n    \"filename\"  : \"1030full-jim-carrey\",\n    \"height\"    : 1037,\n    \"id\"        : \"29620846\",\n    \"title\"     : \"Jim Carrey\",\n    \"url\"       : \"https://ilarge.lisimg.com/image/29620846/1030full-jim-carrey.jpg\",\n    \"width\"     : 1030,\n},\n\n{\n    \"#url\"     : \"https://www.listal.com/jim-carrey/pictures\",\n    \"#class\"   : listal.ListalPeopleExtractor,\n    \"#pattern\" : r\"https://i\\w+\\.lisimg\\.com/image/\\d+/\\d+full-.+\\.jpg\",\n    \"#range\"   : \"1-10\",\n    \"#count\"   : 10,\n\n    \"author\"    : str,\n    \"author_url\": r\"re:https://\\w+.listal.com\",\n    \"date\"      : \"type:datetime\",\n    \"extension\" : \"jpg\",\n    \"filename\"  : str,\n    \"width\"     : range(200, 2000),\n    \"height\"    : range(200, 2000),\n    \"id\"        : r\"re:\\d+\",\n    \"title\"     : \"Jim Carrey\",\n    \"url\"       : r\"re:https://.+\",\n},\n\n)\n"
  },
  {
    "path": "test/results/livedoor.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import livedoor\n\n\n__tests__ = (\n{\n    \"#url\"     : \"http://blog.livedoor.jp/zatsu_ke/\",\n    \"#category\": (\"\", \"livedoor\", \"blog\"),\n    \"#class\"   : livedoor.LivedoorBlogExtractor,\n    \"#pattern\" : r\"https?://livedoor.blogimg.jp/\\w+/imgs/\\w/\\w/\\w+\\.\\w+\",\n    \"#range\"   : \"1-50\",\n    \"#count\"   : 50,\n    \"#archive\" : False,\n\n    \"post\"    : {\n        \"categories\" : tuple,\n        \"date\"       : \"type:datetime\",\n        \"description\": str,\n        \"id\"         : int,\n        \"tags\"       : list,\n        \"title\"      : str,\n        \"user\"       : \"zatsu_ke\",\n    },\n    \"filename\": str,\n    \"hash\"    : r\"re:\\w{4,}\",\n    \"num\"     : int,\n},\n\n{\n    \"#url\"     : \"http://blog.livedoor.jp/uotapo/\",\n    \"#category\": (\"\", \"livedoor\", \"blog\"),\n    \"#class\"   : livedoor.LivedoorBlogExtractor,\n    \"#range\"   : \"1-5\",\n    \"#count\"   : 5,\n},\n\n{\n    \"#url\"     : \"http://blog.livedoor.jp/zatsu_ke/archives/51493859.html\",\n    \"#category\": (\"\", \"livedoor\", \"post\"),\n    \"#class\"   : livedoor.LivedoorPostExtractor,\n    \"#sha1_url\"     : \"9ca3bbba62722c8155be79ad7fc47be409e4a7a2\",\n    \"#sha1_metadata\": \"1f5b558492e0734f638b760f70bfc0b65c5a97b9\",\n},\n\n{\n    \"#url\"     : \"http://blog.livedoor.jp/amaumauma/archives/7835811.html\",\n    \"#category\": (\"\", \"livedoor\", \"post\"),\n    \"#class\"   : livedoor.LivedoorPostExtractor,\n    \"#sha1_url\"     : \"204bbd6a9db4969c50e0923855aeede04f2e4a62\",\n    \"#sha1_metadata\": \"05821c7141360e6057ef2d382b046f28326a799d\",\n},\n\n{\n    \"#url\"     : \"http://blog.livedoor.jp/uotapo/archives/1050616939.html\",\n    \"#category\": (\"\", \"livedoor\", \"post\"),\n    \"#class\"   : livedoor.LivedoorPostExtractor,\n    \"#sha1_url\"     : \"4b5ab144b7309eb870d9c08f8853d1abee9946d2\",\n    \"#sha1_metadata\": \"84fbf6e4eef16675013d6333039a7cfcb22c2d50\",\n},\n\n)\n"
  },
  {
    "path": "test/results/lofter.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import lofter\n\n\n__tests__ = (\n{\n    \"#url\"  : \"https://gengar563.lofter.com/post/1e82da8c_1c98dae1b\",\n    \"#class\": lofter.LofterPostExtractor,\n    \"#results\": (\n        \"https://imglf3.lf127.net/img/S1d2QlVsWkJhSW1qcnpIS0ZSa3ZJQ1RxY0lYaU1UUE9tQ0NvUE9rVXFpOFFEVzMwbnQ4aEFnPT0.jpg\",\n        \"https://imglf3.lf127.net/img/S1d2QlVsWkJhSW1qcnpIS0ZSa3ZJRWlXYTRVOEpXTU9TSGt3TjBDQ0JFZVpZMEJtWjFneVNBPT0.png\",\n        \"https://imglf6.lf127.net/img/S1d2QlVsWkJhSW1qcnpIS0ZSa3ZJR1d3Y2VvbTNTQlIvdFU1WWlqZHEzbjI4MFVNZVdoN3VBPT0.png\",\n        \"https://imglf6.lf127.net/img/S1d2QlVsWkJhSW1qcnpIS0ZSa3ZJTi83NDRDUjNvd3hySGxEZFovd2hwbi9oaG9NQ1hOUkZ3PT0.png\",\n        \"https://imglf4.lf127.net/img/S1d2QlVsWkJhSW1qcnpIS0ZSa3ZJUFczb2RKSVlpMHJkNy9kc3BSQVQvQm5DNzB4eVhxay9nPT0.png\",\n        \"https://imglf4.lf127.net/img/S1d2QlVsWkJhSW1qcnpIS0ZSa3ZJSStJZE9RYnJURktHazdIVHNNMjQ5eFJldHVTQy9XbDB3PT0.png\",\n        \"https://imglf3.lf127.net/img/S1d2QlVsWkJhSW1qcnpIS0ZSa3ZJSzFCWFlnUWgzb01DcUdpT1lreG5yQjJVMkhGS09HNGR3PT0.png\",\n    ),\n\n    \"blog_name\": \"gengar563\",\n    \"content\"  : \"<p>发了三次发不出有毒……</p> \\n<p>二部运动au&nbsp;&nbsp;性转ac注意</p> \\n<p>失去耐心.jpg</p>\",\n    \"date\"     : \"dt:2020-06-04 12:51:42\",\n    \"id\"       : 7676472859,\n},\n\n{\n    \"#url\"    : \"https://wooden-brain.lofter.com/post/1e60de5b_1c9bf8efb\",\n    \"#comment\": \"video\",\n    \"#class\"  : lofter.LofterPostExtractor,\n    \"#results\": (\n        \"https://vodm2lzexwq.vod.126.net/vodm2lzexwq/Pc5jg1nL_3039990631_sd.mp4?resId=254486990bfa2cd7aa860229db639341_3039990631_1&sign=4j02HTHXqNfhaF%2B%2FO14Ny%2F9SMNZj%2FIjpJDCqXfYa4aM%3D\",\n    ),\n\n    \"blog_name\": \"wooden-brain\",\n    \"date\"     : \"dt:2020-06-24 11:01:59\",\n    \"id\"       : 7679741691,\n},\n\n{\n    \"#url\"  : \"https://gengar563.lofter.com/\",\n    \"#class\": lofter.LofterBlogPostsExtractor,\n    \"#range\": \"1-25\",\n    \"#count\": 25,\n\n    \"blog_name\": \"gengar563\",\n    \"date\"     : \"type:datetime\",\n    \"id\"       : int,\n},\n\n{\n    \"#url\"  : \"https://www.lofter.com/front/blog/home-page/gengar563\",\n    \"#class\": lofter.LofterBlogPostsExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/lolibooru.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import moebooru\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://lolibooru.moe/post/show/281305/\",\n    \"#category\": (\"moebooru\", \"lolibooru\", \"post\"),\n    \"#class\"   : moebooru.MoebooruPostExtractor,\n    \"#options\"     : {\"notes\": True},\n    \"#sha1_content\": \"a331430223ffc5b23c31649102e7d49f52489b57\",\n\n    \"notes\": list,\n},\n\n{\n    \"#url\"     : \"https://lolibooru.moe/post/show/287835\",\n    \"#category\": (\"moebooru\", \"lolibooru\", \"post\"),\n    \"#class\"   : moebooru.MoebooruPostExtractor,\n},\n\n{\n    \"#url\"     : \"https://lolibooru.moe/post?tags=ruu_%28tksymkw%29\",\n    \"#category\": (\"moebooru\", \"lolibooru\", \"tag\"),\n    \"#class\"   : moebooru.MoebooruTagExtractor,\n},\n\n{\n    \"#url\"     : \"https://lolibooru.moe/pool/show/239\",\n    \"#category\": (\"moebooru\", \"lolibooru\", \"pool\"),\n    \"#class\"   : moebooru.MoebooruPoolExtractor,\n},\n\n{\n    \"#url\"     : \"https://lolibooru.moe/post/popular_recent\",\n    \"#category\": (\"moebooru\", \"lolibooru\", \"popular\"),\n    \"#class\"   : moebooru.MoebooruPopularExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/loungeunderwear.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import shopify\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://loungeunderwear.com/collections/apparel\",\n    \"#category\": (\"shopify\", \"loungeunderwear\", \"collection\"),\n    \"#class\"   : shopify.ShopifyCollectionExtractor,\n},\n\n{\n    \"#url\"     : \"https://de.loungeunderwear.com/products/ribbed-crop-top-black\",\n    \"#category\": (\"shopify\", \"loungeunderwear\", \"product\"),\n    \"#class\"   : shopify.ShopifyProductExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/luscious.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import luscious\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://luscious.net/albums/okinami-no-koigokoro_277031/\",\n    \"#class\"   : luscious.LusciousAlbumExtractor,\n    #  \"#pattern\" : r\"https://storage\\.bhs\\.cloud\\.ovh\\.net/v1/AUTH_\\w+/images/NTRshouldbeillegal/277031/luscious_net_\\d+_\\d+\\.jpg$\",\n    \"#pattern\" : r\"https://cdni.luscious.net/NTRshouldbeillegal/277031/luscious_net_\\d+_\\d+.1680x0.jpg\\?md5=[\\w-]+&expires=\\d+$\",\n    \"#count\"   : 18,\n\n    \"album\"           : {\n        \"__typename\"                 : \"Album\",\n        \"audiences\"                  : list,\n        \"content\"                    : \"Hentai\",\n        \"cover\"                      : str,\n        \"created\"                    : 1479625853.237386,\n        \"created_by\"                 : \"Hive Mind\",\n        \"date\"                       : \"dt:2016-11-20 07:10:53\",\n        \"description\"                : \"Enjoy.\",\n        \"download_url\"               : \"/download/r/25/277031/?utm_source=partner&utm_medium=album_link&utm_campaign=gallery&utm_content=anonymous\",\n        \"genres\"                     : list,\n        \"id\"                         : 277031,\n        \"is_manga\"                   : True,\n        \"labels\"                     : list,\n        \"language\"                   : \"English\",\n        \"like_status\"                : \"none\",\n        \"modified\"                   : float,\n        \"permissions\"                : list,\n        \"rating\"                     : None,\n        \"slug\"                       : \"okinami-no-koigokoro\",\n        \"status\"                     : None,\n        \"tags\"                       : list,\n        \"title\"                      : \"Okinami no Koigokoro\",\n        \"url\"                        : \"/albums/okinami-no-koigokoro_277031/\",\n        \"marked_for_deletion\"        : False,\n        \"marked_for_processing\"      : False,\n        \"number_of_animated_pictures\": 0,\n        \"number_of_favorites\"        : int,\n        \"number_of_pictures\"         : 18,\n    },\n    \"aspect_ratio\"    : r\"re:\\d+:\\d+\",\n    \"category\"        : \"luscious\",\n    \"created\"         : float,\n    \"date\"            : \"type:datetime\",\n    \"height\"          : int,\n    \"id\"              : int,\n    \"is_animated\"     : False,\n    \"like_status\"     : \"none\",\n    \"position\"        : int,\n    \"resolution\"      : r\"re:\\d+x\\d+\",\n    \"status\"          : None,\n    \"tags\"            : list,\n    \"thumbnail\"       : str,\n    \"title\"           : str,\n    \"width\"           : int,\n    \"number_of_comments\": int,\n    \"number_of_favorites\": int,\n},\n\n{\n    \"#url\"     : \"https://luscious.net/albums/not-found_277035/\",\n    \"#class\"   : luscious.LusciousAlbumExtractor,\n    \"#exception\": \"NotFoundError\",\n},\n\n{\n    \"#url\"     : \"https://members.luscious.net/albums/login-required_323871/\",\n    \"#class\"   : luscious.LusciousAlbumExtractor,\n    \"#count\"   : 64,\n},\n\n{\n    \"#url\"     : \"https://www.luscious.net/albums/okinami_277031/\",\n    \"#class\"   : luscious.LusciousAlbumExtractor,\n},\n\n{\n    \"#url\"     : \"https://members.luscious.net/albums/okinami_277031/\",\n    \"#class\"   : luscious.LusciousAlbumExtractor,\n},\n\n{\n    \"#url\"     : \"https://luscious.net/pictures/c/video_game_manga/album/okinami-no-koigokoro_277031/sorted/position/id/16528978/@_1\",\n    \"#class\"   : luscious.LusciousAlbumExtractor,\n},\n\n{\n    \"#url\"     : \"https://members.luscious.net/albums/list/\",\n    \"#class\"   : luscious.LusciousSearchExtractor,\n},\n\n{\n    \"#url\"     : \"https://members.luscious.net/albums/list/?display=date_newest&language_ids=%2B1&tagged=+full_color&page=1\",\n    \"#class\"   : luscious.LusciousSearchExtractor,\n    \"#pattern\" : luscious.LusciousAlbumExtractor.pattern,\n    \"#range\"   : \"41-60\",\n    \"#count\"   : 20,\n},\n\n)\n"
  },
  {
    "path": "test/results/madokami.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import madokami\nfrom gallery_dl import exception\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://manga.madokami.al/Manga/K/K_/K___/K%20-%20Memory%20of%20Red\",\n    \"#class\"   : madokami.MadokamiMangaExtractor,\n    \"#auth\"    : True,\n    \"#results\" : (\n        \"https://manga.madokami.al/Manga/K/K_/K___/K%20-%20Memory%20of%20Red/K%20-%20Memory%20of%20Red%20v01%20c01-02%20%281%20of%202%29.zip\",\n        \"https://manga.madokami.al/Manga/K/K_/K___/K%20-%20Memory%20of%20Red/K%20-%20Memory%20of%20Red%20v01%20c03-05%20%282%20of%202%29.zip\",\n        \"https://manga.madokami.al/Manga/K/K_/K___/K%20-%20Memory%20of%20Red/K%20-%20Memory%20of%20Red%20v02%20c06-10.zip\",\n        \"https://manga.madokami.al/Manga/K/K_/K___/K%20-%20Memory%20of%20Red/K%20-%20Memory%20of%20Red%20v03%20c11-13%20%281%20of%202%29.zip\",\n        \"https://manga.madokami.al/Manga/K/K_/K___/K%20-%20Memory%20of%20Red/K%20-%20Memory%20of%20Red%20v03%20c14-15%20%282%20of%202%29.zip\",\n    ),\n\n    \"chapter\"       : {1, 3, 6, 11, 14},\n    \"chapter_end\"   : {2, 5, 10, 13, 15},\n    \"chapter_id\"    : range(57413, 57417),\n    \"chapter_string\": r\"re:v\\d\\d c\\d\\d-\\d\\d\",\n    \"complete\"      : True,\n    \"date\"          : \"type:datetime\",\n    \"extension\"     : \"zip\",\n    \"filename\"      : r\"re:K - Memory of Red .+\",\n    \"manga\"         : \"K - Memory of Red\",\n    \"path\"          : r\"re:/Manga/K/K_/K___/K%20-%20Memory%20of%20Red/K%20-%20Memory%20of%20Red%20.+\\.zip\",\n    \"size\"          : range(57_986_253, 82_732_646),\n    \"volume\"        : range(1, 3),\n    \"year\"          : 2012,\n    \"author\"        : [\n        \"GoHands\",\n        \"GoRA\",\n        \"KUROE Yui\",\n    ],\n    \"genre\"         : [\n        \"Action\",\n        \"Shoujo\",\n        \"Supernatural\",\n    ],\n    \"tags\"          : [\n        \"Bar/s\",\n        \"Bartender/s\",\n        \"Based on an Anime\",\n        \"Bishounen\",\n        \"Gang/s\",\n        \"Multiple Protagonists\",\n        \"Mysterious Protagonist\",\n        \"Skateboarding\",\n        \"Street Fighting\",\n        \"Stubborn Protagonist\",\n    ],\n},\n\n{\n    \"#url\"      : \"https://manga.madokami.al/Manga/K/K_/K___/K%20-%20Memory%20of%20Red\",\n    \"#comment\"  : \"no username & password\",\n    \"#class\"    : madokami.MadokamiMangaExtractor,\n    \"#auth\"     : False,\n    \"#exception\": exception.AuthRequired,\n},\n\n)\n"
  },
  {
    "path": "test/results/mangadex.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import mangadex\nfrom gallery_dl import exception\n\n\n__tests__ = (\n\n{\n    \"#url\"     : \"https://mangadex.org/chapter/f946ac53-0b71-4b5d-aeb2-7931b13c4aaa\",\n    \"#class\"   : mangadex.MangadexChapterExtractor,\n    \"#count\"   : 5,\n\n    \"artist\"       : [\"Oda Eiichirou\"],\n    \"author\"       : [\"Oda Eiichirou\"],\n    \"chapter\"      : 6,\n    \"chapter_id\"   : \"f946ac53-0b71-4b5d-aeb2-7931b13c4aaa\",\n    \"chapter_minor\": \"\",\n    \"count\"        : 5,\n    \"date\"         : \"dt:2018-02-28 10:42:50\",\n    \"demographic\"  : \"shounen\",\n    \"description\"  : \"One Piece Omake are short manga chapters originally published in the One Piece Log Books & Databooks.\",\n    \"extension\"    : {\"jpg\", \"png\"},\n    \"filename\"     : str,\n    \"group\"        : [\"KEFI\"],\n    \"lang\"         : \"en\",\n    \"manga\"        : \"One Piece Omake\",\n    \"manga_date\"   : \"dt:2018-06-29 17:22:51\",\n    \"manga_id\"     : \"487f1f04-75f3-4a2e-a4af-76e615e32585\",\n    \"origin\"       : \"ja\",\n    \"page\"         : range(1, 5),\n    \"rating\"       : \"safe\",\n    \"status\"       : \"ongoing\",\n    \"tags\"         : [\"Comedy\"],\n    \"title\"        : \"The 6th Log - Chopper Man\",\n    \"volume\"       : 0,\n    \"year\"         : None,\n    \"manga_titles\" : [\n        \"One Piece: Log Book Omake\",\n        \"One Piece: Mugiwara Theater\",\n        \"One Piece: Straw Hat Theater\",\n        \"One Piece: Strawhat Theater\",\n    ],\n    \"links\"        : {\n        \"al\" : \"44414\",\n        \"kt\" : \"24849\",\n        \"mal\": \"14414\",\n    },\n},\n\n{\n    \"#url\"     : \"https://mangadex.org/chapter/61a88817-9c29-4281-bdf1-77b3c1be9831\",\n    \"#comment\" : \"oneshot\",\n    \"#class\"   : mangadex.MangadexChapterExtractor,\n    \"#count\"   : 64,\n\n    \"artist\"       : [\"Arakawa Hiromu\"],\n    \"author\"       : [\"Arakawa Hiromu\"],\n    \"chapter\"      : 0,\n    \"chapter_id\"   : \"61a88817-9c29-4281-bdf1-77b3c1be9831\",\n    \"chapter_minor\": \"\",\n    \"count\"        : 64,\n    \"date\"         : \"dt:2018-03-05 14:36:10\",\n    \"demographic\"  : \"shounen\",\n    \"description\"  : \"A kunoichi, Henpukumaru, awakens in the mansion of her enemy. She is introduced to the future lord of the mansion, Chiyozuru. Chiyozuru is able to get the unemotional Henpukumaru to smile and react differently than she normally would. But then Henpukumaru's former allies attack one night…\",\n    \"extension\"    : {\"jpg\", \"png\"},\n    \"filename\"     : str,\n    \"group\"        : [\"Illuminati-Manga\"],\n    \"lang\"         : \"en\",\n    \"manga\"        : \"Souten no Koumori\",\n    \"manga_date\"   : \"dt:2018-03-19 10:36:00\",\n    \"manga_id\"     : \"f90c4398-8aad-4f51-8a1f-024ca09fdcbc\",\n    \"origin\"       : \"ja\",\n    \"page\"         : range(1, 64),\n    \"rating\"       : \"safe\",\n    \"status\"       : \"completed\",\n    \"title\"        : \"Oneshot\",\n    \"volume\"       : 0,\n    \"year\"         : 2006,\n    \"manga_titles\" : [\n        \"A Bat in Blue Sky\",\n        \"Sôten no Kômori\",\n        \"Soten no Komori\",\n        \"蒼天の蝙蝠\",\n    ],\n    \"tags\"         : [\n        \"Oneshot\",\n        \"Historical\",\n        \"Action\",\n        \"Martial Arts\",\n        \"Drama\",\n        \"Tragedy\",\n    ],\n    \"links\"        : {\n        \"al\" : \"30948\",\n        \"ap\" : \"souten-no-koumori\",\n        \"kt\" : \"2065\",\n        \"mal\": \"948\",\n        \"mu\" : \"opk9cgi\",\n    },\n},\n\n{\n    \"#url\"     : \"https://mangadex.org/chapter/74149a55-e7c4-44ea-8a37-98e879c1096f\",\n    \"#comment\" : \"MANGA Plus (#1154)\",\n    \"#class\"   : mangadex.MangadexChapterExtractor,\n    \"#exception\": exception.AbortExtraction,\n},\n\n{\n    \"#url\"     : \"https://mangadex.org/chapter/364728a4-6909-4164-9eea-6b56354f7c78\",\n    \"#comment\" : \"'externalUrl', but *was* still downloadable, now 404 (#2503)\",\n    \"#class\"   : mangadex.MangadexChapterExtractor,\n    \"#exception\": exception.AbortExtraction,\n},\n\n{\n    \"#url\"     : \"https://mangadex.org/chapter/f946ac53-0b71-4b5d-aeb2-7931b13c4aaa\",\n    \"#class\"   : mangadex.MangadexChapterExtractor,\n    \"#options\" : {\"data-saver\": True},\n    \"#results\" : (\n        \"https://cmdxd98sb0x3yprd.mangadex.network/data-saver/2780e594c3519e6858f76dfc018c8c1a/x1-d5962a0770b39faf73154b428be473752b4c379020916ecb5f0ffeac9639b6bf.jpg\",\n        \"https://cmdxd98sb0x3yprd.mangadex.network/data-saver/2780e594c3519e6858f76dfc018c8c1a/x2-c9fcaf38888e38c48ff3cff0e2b342f68b7aaed2ea9e2a2a5446dc49b6a4d86e.jpg\",\n        \"https://cmdxd98sb0x3yprd.mangadex.network/data-saver/2780e594c3519e6858f76dfc018c8c1a/x3-9ea5e06a4ba27b37dd66b75f1d267e3a6f8d21bb14a0163f669cf7f40ede315f.jpg\",\n        \"https://cmdxd98sb0x3yprd.mangadex.network/data-saver/2780e594c3519e6858f76dfc018c8c1a/x4-605c869a362a19d016d7fb777908c9336fec995965cb59853cb7f9b3e128f70e.jpg\",\n        \"https://cmdxd98sb0x3yprd.mangadex.network/data-saver/2780e594c3519e6858f76dfc018c8c1a/x5-dc40bd2b45d0ce26c7a401d74c2006a239f5839bc4f4a55893d035d6819627d7.jpg\",\n    ),\n},\n\n{\n    \"#url\"     : \"https://mangadex.org/title/f90c4398-8aad-4f51-8a1f-024ca09fdcbc\",\n    \"#comment\" : \"mutliple values for 'lang' (#4093)\",\n    \"#class\"   : mangadex.MangadexMangaExtractor,\n    \"#count\"   : \">= 5\",\n\n    \"manga\"        : \"Souten no Koumori\",\n    \"manga_id\"     : \"f90c4398-8aad-4f51-8a1f-024ca09fdcbc\",\n    \"title\"        : r\"re:One[Ss]hot\",\n    \"volume\"       : 0,\n    \"chapter\"      : 0,\n    \"chapter_minor\": \"\",\n    \"chapter_id\"   : str,\n    \"date\"         : \"type:datetime\",\n    \"lang\"         : \"iso:lang\",\n    \"artist\"       : [\"Arakawa Hiromu\"],\n    \"author\"       : [\"Arakawa Hiromu\"],\n    \"status\"       : \"completed\",\n    \"tags\"         : [\n        \"Oneshot\",\n        \"Historical\",\n        \"Action\",\n        \"Martial Arts\",\n        \"Drama\",\n        \"Tragedy\",\n    ],\n},\n\n{\n    \"#url\"     : \"https://mangadex.org/title/f90c4398-8aad-4f51-8a1f-024ca09fdcbc\",\n    \"#comment\" : \"mutliple values for 'lang' (#4093)\",\n    \"#class\"   : mangadex.MangadexMangaExtractor,\n    \"#options\" : {\"lang\": \"fr,it\"},\n    \"#results\" : (\n        \"https://mangadex.org/chapter/5f82bd70-2285-416e-8bcb-cd2487e09872\",\n        \"https://mangadex.org/chapter/e68583a7-12f5-431a-8559-9e667afc1a1a\",\n    ),\n\n    \"manga\"   : \"Souten no Koumori\",\n    \"lang\"    : {\"fr\", \"it\"},\n},\n\n{\n    \"#url\"     : \"https://mangadex.org/title/f90c4398-8aad-4f51-8a1f-024ca09fdcbc\",\n    \"#comment\" : \"'covers' results\",\n    \"#class\"   : mangadex.MangadexMangaExtractor,\n    \"#options\" : {\"covers\": True, \"lang\": \"fr\"},\n    \"#range\"   : \"1-2\",\n    \"#results\" : (\n        \"https://mangadex.org/title/f90c4398-8aad-4f51-8a1f-024ca09fdcbc?tab=art\",\n        \"https://mangadex.org/chapter/5f82bd70-2285-416e-8bcb-cd2487e09872\",\n    ),\n},\n\n{\n    \"#url\"     : \"https://mangadex.cc/manga/d0c88e3b-ea64-4e07-9841-c1d2ac982f4a/\",\n    \"#comment\" : \"removed\",\n    \"#class\"   : mangadex.MangadexMangaExtractor,\n    \"#options\" : {\"lang\": \"en\"},\n    \"#count\"   : 0,\n},\n\n{\n    \"#url\"     : \"https://mangadex.org/title/7c1e2742-a086-4fd3-a3be-701fd6cf0be9\",\n    \"#class\"   : mangadex.MangadexMangaExtractor,\n    \"#pattern\" : mangadex.MangadexChapterExtractor.pattern,\n    \"#count\"   : \">= 25\",\n},\n\n{\n    \"#url\"     : \"https://mangadex.org/title/584ef094-b2ab-40ce-962c-bce341fb9d10\",\n    \"#class\"   : mangadex.MangadexMangaExtractor,\n    \"#pattern\" : mangadex.MangadexChapterExtractor.pattern,\n    \"#count\"   : \">= 20\",\n},\n\n{\n    \"#url\"     : \"https://mangadex.org/title/2e73a1ac-bf26-4c04-84f6-d0a22fd37624/tomodachi-no-joshi-ni-josou-saserare-danshi\",\n    \"#comment\" : \"no 'description' (#8389)\",\n    \"#class\"   : mangadex.MangadexMangaExtractor,\n    \"#count\"   : 47,\n\n    \"description\": \"\",\n},\n\n{\n    \"#url\"     : \"https://mangadex.org/titles/feed\",\n    \"#class\"   : mangadex.MangadexFeedExtractor,\n    \"#auth\"    : True,\n},\n\n{\n    \"#url\"     : \"https://mangadex.org/title/feed\",\n    \"#class\"   : mangadex.MangadexFeedExtractor,\n    \"#auth\"    : True,\n},\n\n{\n    \"#url\"     : \"https://mangadex.org/titles/follows\",\n    \"#class\"   : mangadex.MangadexFollowingExtractor,\n    \"#auth\"    : True,\n    \"#results\" : (\n        \"https://mangadex.org/title/cad76ec6-ca22-42f6-96f8-eca164da6545\",\n        \"https://mangadex.org/title/7546ff2d-2310-47a4-b1f3-1a2561f20ce7\",\n    ),\n},\n\n{\n    \"#url\"     : \"https://mangadex.org/title/follows\",\n    \"#class\"   : mangadex.MangadexFollowingExtractor,\n    \"#auth\"    : True,\n},\n\n{\n    \"#url\"     : \"https://mangadex.org/list/3a0982c5-65aa-4de2-8a4a-2175be7383ab/test\",\n    \"#class\"   : mangadex.MangadexListExtractor,\n    \"#results\" : (\n        \"https://mangadex.org/title/cba4e5d6-67a0-47a0-b37a-c06e9bf25d93\",\n        \"https://mangadex.org/title/cad76ec6-ca22-42f6-96f8-eca164da6545\",\n    ),\n},\n\n{\n    \"#url\"     : \"https://mangadex.org/list/3a0982c5-65aa-4de2-8a4a-2175be7383ab/test?tab=titles\",\n    \"#class\"   : mangadex.MangadexListExtractor,\n},\n\n{\n    \"#url\"     : \"https://mangadex.org/list/3a0982c5-65aa-4de2-8a4a-2175be7383ab/test?tab=feed\",\n    \"#category\": (\"\", \"mangadex\", \"list-feed\"),\n    \"#class\"   : mangadex.MangadexListExtractor,\n    \"#results\" : (\n        \"https://mangadex.org/chapter/c765d6d5-5712-4360-be0b-0c8e0914fc94\",\n        \"https://mangadex.org/chapter/fa8a695d-260f-4dcc-95a3-1f30e66d6571\",\n        \"https://mangadex.org/chapter/788766b9-41c6-422e-97ba-552f03ba9655\",\n    ),\n},\n\n{\n    \"#url\"     : \"https://mangadex.org/author/7222d0d5-836c-4bf3-9174-72bceade8c87/kotoyama\",\n    \"#class\"   : mangadex.MangadexAuthorExtractor,\n    \"#pattern\" : mangadex.MangadexMangaExtractor.pattern,\n    \"#count\"   : 9,\n},\n\n{\n    \"#url\"     : \"https://mangadex.org/author/254efca2-0ac0-432c-a3a3-55b7e207e87d/flipflops\",\n    \"#class\"   : mangadex.MangadexAuthorExtractor,\n    \"#pattern\" : mangadex.MangadexMangaExtractor.pattern,\n    \"#options\" : {\"lang\": \"en\"},\n    \"#count\"   : \">= 15\",\n},\n\n{\n    \"#url\"     : \"https://mangadex.org/title/f90c4398-8aad-4f51-8a1f-024ca09fdcbc?tab=art\",\n    \"#class\"   : mangadex.MangadexCoversExtractor,\n    \"#results\" : \"https://mangadex.org/covers/f90c4398-8aad-4f51-8a1f-024ca09fdcbc/af3c1690-1e06-4432-909e-3e0f9ee01f68.jpg\",\n\n    \"artist\"      : [\"Arakawa Hiromu\"],\n    \"author\"      : [\"Arakawa Hiromu\"],\n    \"cover\"       : \"af3c1690-1e06-4432-909e-3e0f9ee01f68.jpg\",\n    \"cover_id\"    : \"af3c1690-1e06-4432-909e-3e0f9ee01f68\",\n    \"date\"        : \"dt:2021-05-24 17:19:13\",\n    \"date_updated\": \"dt:2021-05-24 17:19:13\",\n    \"extension\"   : {\"jpg\", \"png\"},\n    \"filename\"    : \"af3c1690-1e06-4432-909e-3e0f9ee01f68\",\n    \"lang\"        : \"ja\",\n    \"manga\"       : \"Souten no Koumori\",\n    \"manga_id\"    : \"f90c4398-8aad-4f51-8a1f-024ca09fdcbc\",\n    \"status\"      : \"completed\",\n    \"volume\"      : 0,\n    \"tags\"        : [\n        \"Oneshot\",\n        \"Historical\",\n        \"Action\",\n        \"Martial Arts\",\n        \"Drama\",\n        \"Tragedy\",\n    ],\n},\n\n{\n    \"#url\"     : \"https://mangadex.org/title/192aa767-2479-42c1-9780-8d65a2efd36a/gachiakuta?tab=art\",\n    \"#class\"   : mangadex.MangadexCoversExtractor,\n    \"#options\" : {\"lang\": [\"ja\", \"en\", \"fa\", \"zh\"]},\n    \"#pattern\" : r\"https://mangadex\\.org/covers/192aa767-2479-42c1-9780-8d65a2efd36a/[\\w-]+\\.jpg\",\n    \"#count\"   : 21,\n\n    \"artist\"      : [\"Urana Kei\"],\n    \"author\"      : [\"Urana Kei\"],\n    \"cover_id\"    : \"iso:uuid\",\n    \"date\"        : \"type:datetime\",\n    \"date_updated\": \"type:datetime\",\n    \"extension\"   : {\"jpg\", \"png\"},\n    \"filename\"    : str,\n    \"lang\"        : {\"ja\", \"fa\"},\n    \"manga\"       : \"Gachiakuta\",\n    \"manga_id\"    : \"192aa767-2479-42c1-9780-8d65a2efd36a\",\n    \"status\"      : \"ongoing\",\n    \"volume\"      : range(1, 20),\n    \"tags\"        : [\n        \"Monsters\",\n        \"Action\",\n        \"Comedy\",\n        \"Survival\",\n        \"Drama\",\n        \"Fantasy\",\n        \"Delinquents\",\n        \"Supernatural\",\n        \"Tragedy\",\n    ],\n},\n\n)\n"
  },
  {
    "path": "test/results/mangafire.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import mangafire\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://mangafire.to/read/moto-saikyou-yuusha-no-saishuushoku.qzq9j/en/chapter-4\",\n    \"#class\"   : mangafire.MangafireChapterExtractor,\n    \"#pattern\" : r\"https://20l\\.mfcdn2\\.xyz/mf/\\w+/h/p\\.jpg\",\n    \"#count\"   : 37,\n\n    \"chapter\"       : 4,\n    \"chapter_id\"    : 4276231,\n    \"chapter_minor\" : \"\",\n    \"chapter_string\": \"chapter-4\",\n    \"count\"         : 37,\n    \"page\"          : range(1, 37),\n    \"cover\"         : \"https://static.mfcdn.cc/1d8c/i/c/f7/3ff94f3785fab893354f9d0ca440b59f.jpg\",\n    \"description\"   : \"Dan was \\\"the strongest and bravest\\\" when he was young. But because of his lazy life, he is now a divorced, unemployed old man! He tries to earn money through quests for a living, but age has taken its toll on his strength and all that! He was approached by a silver-haired elf, a handsome man, and an S-rank adventurer... a young man of today’s generation!\",\n    \"extension\"     : \"jpg\",\n    \"filename\"      : \"p\",\n    \"lang\"          : \"en\",\n    \"manga\"         : \"Re-Employment of the Former Strongest Hero\",\n    \"manga_id\"      : \"qzq9j\",\n    \"manga_slug\"    : \"moto-saikyou-yuusha-no-saishuushoku\",\n    \"published\"     : \"Mar 31, 2022 to ?\",\n    \"publisher\"     : [\"Wild Hero's\"],\n    \"score\"         : float,\n    \"status\"        : \"Releasing\",\n    \"title\"         : \"The Former Strongest Hero Makes a Choice\",\n    \"type\"          : \"Manga\",\n    \"author\"        : [\n        \"Nobuto Hagio\",\n        \"Hironori Akutsu\",\n    ],\n    \"manga_titles\"  : [\n        \"Moto Saikyou Yuusha no Saishuushoku\",\n        \"Re-Employment of the Former Strongest Hero\",\n    ],\n    \"tags\"          : [\n        \"Action\",\n        \"Comedy\",\n        \"Adventure\",\n        \"Fantasy\",\n        \"Seinen\",\n    ],\n},\n\n{\n    \"#url\"     : \"https://mangafire.to/read/munou-wa-fuyou-to-iware-tokei-tsukai-no-boku-wa-shokuin-guild-kara-oidasareru-mo-dungeon-no-shinbu-de-shin-no-chikara-ni-kakusei-suru-the-comic.6wmv9/en/chapter-14.1\",\n    \"#class\"   : mangafire.MangafireChapterExtractor,\n    \"#pattern\" : r\"https://\\w+\\.mfcdn\\d\\.xyz/mf/\\w+/h/p\\.jpg\",\n    \"#count\"   : 13,\n\n    \"chapter\"       : 14,\n    \"chapter_id\"    : 4765506,\n    \"chapter_minor\" : \".1\",\n    \"chapter_string\": \"chapter-14.1\",\n    \"count\"         : 13,\n    \"page\"          : range(1, 13),\n    \"cover\"         : \"https://static.mfcdn.cc/58c3/i/4/80/a22d447a5e12f1df889632c9e8300cb3.jpg\",\n    \"description\"   : \"In a world where skills are supreme, Shikuro is given the incompetent skill of \\\"clock-user.\\\" One day, Shikuro, who is looked down upon by others, is accused of rape and sent to the frontier, where he is pushed down a hole that leads to a dungeon's deepest level. In the depths of his desperate situation, he thought to himself, \\\"If only I could use my clock-user ability to stop my stomach clock as well.\\\" Then the hunger would stop. This triggered the awakening of his skill \\\"clock-user.\\\" Eventually, he makes full use of his abilities and is recognized as an SSS-class adventurer.\",\n    \"extension\"     : \"jpg\",\n    \"filename\"      : \"p\",\n    \"lang\"          : \"en\",\n    \"manga\"         : \"As a Watchmaker, I Was Kicked Out of the Craftsman's Guild Because My Incompetence Was Deemed Unnecessary, But I Awakened to My True Power in the Depths of the Dungeon\",\n    \"manga_id\"      : \"6wmv9\",\n    \"manga_slug\"    : \"munou-wa-fuyou-to-iware-tokei-tsukai-no-boku-wa-shokuin-guild-kara-oidasareru-mo-dungeon-no-shinbu-de-shin-no-chikara-ni-kakusei-suru-the-comic\",\n    \"published\"     : \"Feb 27, 2023 to ?\",\n    \"publisher\"     : [\"Comic Ride\"],\n    \"score\"         : range(5, 10),\n    \"status\"        : \"Releasing\",\n    \"title\"         : \"Part 1 - Karim's Purpose\",\n    \"type\"          : \"Manga\",\n    \"author\"        : [\n        \"Kohaku Roumu\",\n        \"Misa Sarasa\",\n    ],\n    \"manga_titles\"  : [\n        \"Munou wa Fuyou to Iware \\\"Tokei Tsukai\\\" no Boku wa Shokuin Guild kara Oidasareru mo, Dungeon no Shinbu de Shin no Chikara ni Kakusei Suru THE COMIC\",\n        \"As a Watchmaker, I Was Kicked Out of the Craftsman's Guild Because My Incompetence Was Deemed Unnecessary, But I Awakened to My True Power in the Depths of the Dungeon\",\n    ],\n    \"tags\"          : [\n        \"Action\",\n        \"Adventure\",\n        \"Fantasy\",\n    ],\n},\n\n{\n    \"#url\"     : \"https://mangafire.to/read/munou-wa-fuyou-to-iware-tokei-tsukai-no-boku-wa-shokuin-guild-kara-oidasareru-mo-dungeon-no-shinbu-de-shin-no-chikara-ni-kakusei-suru-the-comic.6wmv9/en/volume-2\",\n    \"#comment\" : \"volume\",\n    \"#class\"   : mangafire.MangafireChapterExtractor,\n    \"#pattern\" : r\"https://\\w+\\.mfcdn\\d\\.xyz/mf/\\w+/h/p\\.jpg\",\n    \"#count\"   : 154,\n\n    \"volume\"        : 2,\n    \"volume_id\"     : 301787,\n    \"chapter\"       : 0,\n    \"chapter_id\"    : 301787,\n    \"chapter_minor\" : \"\",\n    \"chapter_string\": \"volume-2\",\n},\n\n{\n    \"#url\"     : \"https://mangafire.to/manga/my-noble-family-is-headed-for-ruin-so-i-may-as-well-study-magic-in-my-free-timee.xjj0w\",\n    \"#class\"   : mangafire.MangafireMangaExtractor,\n    \"#pattern\" : mangafire.MangafireChapterExtractor.pattern,\n    \"#count\"   : range(40, 60),\n\n    \"chapter\"       : range(1, 30),\n    \"chapter_id\"    : int,\n    \"chapter_minor\" : {\"\", \".1\", \".2\", \".3\", \".5\"},\n    \"chapter_string\": str,\n    \"cover\"         : \"https://static.mfcdn.cc/88d7/i/6/69/69367cb22f154599d524cd5f57ef7e56.jpg\",\n    \"description\"   : \"While sipping his evening beverage, a man was unexpectedly transported into the body of Liam, the fifth son of a nobleman from another world. Reveling in his newfound powers, he spent his days effortlessly mastering coveted magic, specializing in attribute-based spells, refining his abilities in summoning spirits, and acquiring impressive familiars. He eventually even conquered the most challenging of spells! Determined to secure his independence from his family's inevitable decline, he embraced the life of an adventurer. As time passed, surprisingly, instead of merely becoming one of the world's most skilled magicians, he effortlessly ascended the aristocratic hierarchy.\",\n    \"lang\"          : \"en\",\n    \"manga\"         : \"I Am a Noble About to Be Ruined, But Reached the Summit of Magic Because I Had a Lot of Free Time.\",\n    \"manga_id\"      : \"xjj0w\",\n    \"manga_slug\"    : \"my-noble-family-is-headed-for-ruin-so-i-may-as-well-study-magic-in-my-free-timee\",\n    \"published\"     : \"Feb 02, 2020 to ?\",\n    \"publisher\"     : [\"Comic Corona\"],\n    \"score\"         : float,\n    \"status\"        : \"Releasing\",\n    \"title\"         : str,\n    \"type\"          : \"Manga\",\n    \"author\"        : [\n        \"Nazuna Miki\",\n        \"Rio Akisaki\",\n    ],\n    \"manga_titles\"  : [\n        \"My Noble Family Is Headed for Ruin, so I May as Well Study Magic in My Free Time\",\n        \"I Am a Noble About to Be Ruined, But Reached the Summit of Magic Because I Had a Lot of Free Time.\",\n        \"Botsuraku Yotei no Kizoku dakedo, Hima Datta kara Mahou wo Kiwamete mita @COMIC\",\n        \"I'm a Noble on the Brink of Ruin, So I Might as Well Try Mastering Magic\",\n        \"Botsuraku Yotei no Kizoku dakedo, Hima datta kara Mahou wo Kiwamete Mita\",\n        \"没落予定の貴族だけど、暇だったから魔法を極めてみた\",\n    ],\n    \"tags\"          : [\n        \"Comedy\",\n        \"Drama\",\n        \"Isekai\",\n        \"Adventure\",\n        \"Fantasy\",\n        \"Slice of Life\",\n        \"Magic\",\n    ],\n},\n\n{\n    \"#url\"     : \"https://mangafire.to/manga/regressing-as-the-reincarnated-bastard-of-the-sword-clann.90vp0\",\n    \"#class\"   : mangafire.MangafireMangaExtractor,\n    \"#pattern\" : mangafire.MangafireChapterExtractor.pattern,\n    \"#count\"   : range(70, 120),\n\n    \"author\"        : (),\n    \"chapter\"       : int,\n    \"chapter_id\"    : int,\n    \"chapter_minor\" : \"\",\n    \"chapter_string\": str,\n    \"cover\"         : \"https://static.mfcdn.cc/2c9e/i/f/fd/de53985a6ffa2e06d0032d3abc00e87c.jpg\",\n    \"description\"   : \"The Great Northern Wall. The Dragon Knights. The Lords of the Winter Mountains. The Descendants of the Primordial Dragon. These are the various titles of the Grand Ducal Clan Ragnar, which boasted a rich, thousand year history. Theo Ragnar resolved himself to aiming for the throne of that very same clan. 'These cursed chains weren't something I could slip out of. They were meant to be broken with strength\",\n    \"lang\"          : \"en\",\n    \"manga\"         : \"Regressed life of the Sword Clan’s Ignoble Reincarnator\",\n    \"manga_id\"      : \"90vp0\",\n    \"manga_slug\"    : \"regressing-as-the-reincarnated-bastard-of-the-sword-clann\",\n    \"published\"     : \"2024 to ?\",\n    \"publisher\"     : (),\n    \"score\"         : range(8, 10),\n    \"status\"        : \"Releasing\",\n    \"title\"         : \"\",\n    \"type\"          : \"Manhwa\",\n    \"manga_titles\"  : [\n        \"Regressing as the Reincarnated Bastard of the Sword Clan\",\n        \"Regressed life of the Sword Clan’s Ignoble Reincarnator\",\n        \"How to Survive as the Bastard of the Regression Sword Clan\",\n        \"회귀검가의 서자가 사는 법\",\n    ],\n    \"tags\"          : [\n        \"Action\",\n        \"Comedy\",\n        \"Drama\",\n        \"Supernatural\",\n        \"Adventure\",\n        \"Fantasy\",\n    ],\n},\n\n)\n"
  },
  {
    "path": "test/results/mangafox.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import mangafox\n\n\n__tests__ = (\n{\n    \"#url\"     : \"http://fanfox.net/manga/kidou_keisatsu_patlabor/v05/c006.2/1.html\",\n    \"#category\": (\"\", \"mangafox\", \"chapter\"),\n    \"#class\"   : mangafox.MangafoxChapterExtractor,\n    \"#sha1_metadata\": \"5661dab258d42d09d98f194f7172fb9851a49766\",\n    \"#sha1_content\" : \"5c50c252dcf12ffecf68801f4db8a2167265f66c\",\n},\n\n{\n    \"#url\"     : \"http://mangafox.me/manga/kidou_keisatsu_patlabor/v05/c006.2/\",\n    \"#category\": (\"\", \"mangafox\", \"chapter\"),\n    \"#class\"   : mangafox.MangafoxChapterExtractor,\n},\n\n{\n    \"#url\"     : \"http://fanfox.net/manga/black_clover/vTBD/c295/1.html\",\n    \"#category\": (\"\", \"mangafox\", \"chapter\"),\n    \"#class\"   : mangafox.MangafoxChapterExtractor,\n},\n\n{\n    \"#url\"     : \"https://fanfox.net/manga/kanojo_mo_kanojo\",\n    \"#category\": (\"\", \"mangafox\", \"manga\"),\n    \"#class\"   : mangafox.MangafoxMangaExtractor,\n    \"#pattern\" : mangafox.MangafoxChapterExtractor.pattern,\n    \"#count\"   : \">=60\",\n\n    \"author\"        : \"HIROYUKI\",\n    \"chapter\"       : int,\n    \"chapter_minor\" : r\"re:^(\\.\\d+)?$\",\n    \"chapter_string\": r\"re:(v\\d+/)?c\\d+\",\n    \"date\"          : \"type:datetime\",\n    \"description\"   : \"High school boy Naoya gets a confession from Momi, a cute and friendly girl. However, Naoya already has a girlfriend, Seki... but Momi is too good a catch to let go. Momi and Nagoya's goal becomes clear: convince Seki to accept being an item with the two of them. Will she budge?\",\n    \"lang\"          : \"en\",\n    \"language\"      : \"English\",\n    \"manga\"         : \"Kanojo mo Kanojo\",\n    \"tags\"          : [\n        \"Comedy\",\n        \"Romance\",\n        \"School Life\",\n        \"Shounen\",\n    ],\n    \"volume\"        : int,\n},\n\n{\n    \"#url\"     : \"https://mangafox.me/manga/shangri_la_frontier\",\n    \"#category\": (\"\", \"mangafox\", \"manga\"),\n    \"#class\"   : mangafox.MangafoxMangaExtractor,\n    \"#pattern\" : mangafox.MangafoxChapterExtractor.pattern,\n    \"#count\"   : \">=45\",\n},\n\n{\n    \"#url\"     : \"https://m.fanfox.net/manga/sentai_daishikkaku\",\n    \"#category\": (\"\", \"mangafox\", \"manga\"),\n    \"#class\"   : mangafox.MangafoxMangaExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/mangafreak.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import mangafreak\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://ww2.mangafreak.me/Read1_Onepunch_Man_1\",\n    \"#class\"   : mangafreak.MangafreakChapterExtractor,\n    \"#pattern\" : r\"https://images\\.mangafreak\\.me/mangas/onepunch_man/onepunch_man_1/onepunch_man_1_\\d+\\.jpg\",\n    \"#count\"   : 24,\n\n    \"chapter\"      : 1,\n    \"chapter_minor\": \"\",\n    \"chapter_string\": \"1\",\n    \"lang\"         : \"en\",\n    \"language\"     : \"English\",\n    \"manga\"        : \"Onepunch Man\",\n    \"manga_slug\"   : \"Onepunch_Man\",\n},\n\n{\n    \"#url\"     : \"https://ww2.mangafreak.me/Read1_Onepunch_Man_167e\",\n    \"#class\"   : mangafreak.MangafreakChapterExtractor,\n\n    \"chapter\"      : 167,\n    \"chapter_minor\": \"e\",\n    \"chapter_string\": \"167e\",\n},\n\n{\n    \"#url\"     : \"https://ww2.mangafreak.me/Read1_Sss_Rank_Dungeon_De_Knife_Ippon_Tewatasare_Tsuihou_Sareta_Shiro_Madoushi_Yggdrasil_No_Noroi_Ni_Yori_Jakuten_De_Aru_Maryoku_Fusoku_Wo_Kokufuku_Shi_Sekai_Saikyou_E_To_Itaru_23c\",\n    \"#class\"   : mangafreak.MangafreakChapterExtractor,\n    \"#pattern\" : r\"https://images\\.mangafreak\\.me/mangas/sss_rank_dungeon_de_knife_ippon_tewatasare_tsuihou_sareta_shiro_madoushi_yggdrasil_no_noroi_ni_yori_jakuten_de_aru_maryoku_fusoku_wo_kokufuku_shi_sekai_saikyou_e_to_itaru/sss_rank_dungeon_de_knife_ippon_tewatasare_tsuihou_sareta_shiro_madoushi_yggdrasil_no_noroi_ni_yori_jakuten_de_aru_maryoku_fusoku_wo_kokufuku_shi_sekai_saikyou_e_to_itaru_23c/sss_rank_dungeon_de_knife_ippon_tewatasare_tsuihou_sareta_shiro_madoushi_yggdrasil_no_noroi_ni_yori_jakuten_de_aru_maryoku_fusoku_wo_kokufuku_shi_sekai_saikyou_e_to_itaru_23c_\\d+\\.jpg\",\n    \"#count\"   : 11,\n\n    \"chapter\"       : 23,\n    \"chapter_minor\" : \"c\",\n    \"chapter_string\": \"23c\",\n    \"count\"         : 11,\n    \"page\"          : range(1, 11),\n    \"filename\"      : str,\n    \"extension\"     : \"jpg\",\n    \"lang\"          : \"en\",\n    \"language\"      : \"English\",\n    \"manga\"         : \"Sss Rank Dungeon De Knife Ippon Tewatasare Tsuihou Sareta Shiro Madoushi Yggdrasil No Noroi Ni Yori Jakuten De Aru Maryoku Fusoku Wo Kokufuku Shi Sekai Saikyou E To Itaru\",\n    \"manga_slug\"    : \"Sss_Rank_Dungeon_De_Knife_Ippon_Tewatasare_Tsuihou_Sareta_Shiro_Madoushi_Yggdrasil_No_Noroi_Ni_Yori_Jakuten_De_Aru_Maryoku_Fusoku_Wo_Kokufuku_Shi_Sekai_Saikyou_E_To_Itaru\",\n    \"title\"         : \"\",\n},\n\n{\n    \"#url\"     : \"https://ww2.mangafreak.me/Read1_Tensei_Shitara_Slime_Datta_Ken_62\",\n    \"#class\"   : mangafreak.MangafreakChapterExtractor,\n    \"#count\"   : 19,\n\n    \"chapter\"  : 62,\n    \"count\"    : 19,\n    \"manga\"    : \"Tensei Shitara Slime Datta Ken\",\n    \"title\"    : \"To be a Monster or Human\",\n},\n\n{\n    \"#url\"     : \"https://ww2.mangafreak.me/Manga/Onepunch_Man\",\n    \"#class\"   : mangafreak.MangafreakMangaExtractor,\n    \"#pattern\" : mangafreak.MangafreakChapterExtractor.pattern,\n    \"#count\"   : range(150, 250),\n\n    \"lang\"       : \"en\",\n    \"language\"   : \"English\",\n    \"manga\"      : \"Onepunch-Man\",\n    \"manga_slug\" : \"Onepunch_Man\",\n    \"chapter\"    : int,\n},\n\n{\n    \"#url\"     : \"https://ww2.mangafreak.me/Manga/Sss_Rank_Dungeon_De_Knife_Ippon_Tewatasare_Tsuihou_Sareta_Shiro_Madoushi_Yggdrasil_No_Noroi_Ni_Yori_Jakuten_De_Aru_Maryoku_Fusoku_Wo_Kokufuku_Shi_Sekai_Saikyou_E_To_Itaru\",\n    \"#class\"   : mangafreak.MangafreakMangaExtractor,\n    \"#pattern\" : mangafreak.MangafreakChapterExtractor.pattern,\n    \"#count\"   : range(40, 80),\n\n    \"chapter\"       : int,\n    \"chapter_minor\" : {\"\", \"a\", \"b\", \"c\"},\n    \"chapter_string\": str,\n    \"lang\"          : \"en\",\n    \"language\"      : \"English\",\n    \"manga\"         : \"SSS Rank Dungeon de Knife Ippon Tewatasare Tsuihou Sareta Shiro Madoushi: Yggdrasil no Noroi ni yori Jakuten de aru Maryoku Fusoku wo Kokufuku-shi Sekai Saikyou e to Itaru\",\n    \"manga_slug\"    : \"Sss_Rank_Dungeon_De_Knife_Ippon_Tewatasare_Tsuihou_Sareta_Shiro_Madoushi_Yggdrasil_No_Noroi_Ni_Yori_Jakuten_De_Aru_Maryoku_Fusoku_Wo_Kokufuku_Shi_Sekai_Saikyou_E_To_Itaru\",\n    \"title\"         : str,\n},\n\n)\n"
  },
  {
    "path": "test/results/mangahere.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import mangahere\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://www.mangahere.cc/manga/dongguo_xiaojie/c004.2/\",\n    \"#category\": (\"\", \"mangahere\", \"chapter\"),\n    \"#class\"   : mangahere.MangahereChapterExtractor,\n    \"#sha1_metadata\": \"7c98d7b50a47e6757b089aa875a53aa970cac66f\",\n    \"#sha1_content\" : \"708d475f06893b88549cbd30df1e3f9428f2c884\",\n},\n\n{\n    \"#url\"     : \"https://www.mangahere.cc/manga/beastars/c196/1.html\",\n    \"#comment\" : \"URLs without HTTP scheme (#1070)\",\n    \"#category\": (\"\", \"mangahere\", \"chapter\"),\n    \"#class\"   : mangahere.MangahereChapterExtractor,\n    \"#pattern\" : \"https://zjcdn.mangahere.org/.*\",\n},\n\n{\n    \"#url\"     : \"http://www.mangahere.co/manga/dongguo_xiaojie/c003.2/\",\n    \"#category\": (\"\", \"mangahere\", \"chapter\"),\n    \"#class\"   : mangahere.MangahereChapterExtractor,\n},\n\n{\n    \"#url\"     : \"http://m.mangahere.co/manga/dongguo_xiaojie/c003.2/\",\n    \"#category\": (\"\", \"mangahere\", \"chapter\"),\n    \"#class\"   : mangahere.MangahereChapterExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.mangahere.cc/manga/aria/\",\n    \"#category\": (\"\", \"mangahere\", \"manga\"),\n    \"#class\"   : mangahere.MangahereMangaExtractor,\n    \"#count\"        : 71,\n    \"#sha1_url\"     : \"9c2e54ec42e9a87ad53096c328b33c90750af3e4\",\n    \"#sha1_metadata\": \"71503c682c5d0c277a50409a8c5fd78e871e3d69\",\n},\n\n{\n    \"#url\"     : \"https://www.mangahere.cc/manga/hiyokoi/#50\",\n    \"#category\": (\"\", \"mangahere\", \"manga\"),\n    \"#class\"   : mangahere.MangahereMangaExtractor,\n    \"#sha1_url\"     : \"654850570aa03825cd57e2ae2904af489602c523\",\n    \"#sha1_metadata\": \"c8084d89a9ea6cf40353093669f9601a39bf5ca2\",\n},\n\n{\n    \"#url\"     : \"http://www.mangahere.cc/manga/gunnm_mars_chronicle/\",\n    \"#comment\" : \"adult filter (#556)\",\n    \"#category\": (\"\", \"mangahere\", \"manga\"),\n    \"#class\"   : mangahere.MangahereMangaExtractor,\n    \"#pattern\" : mangahere.MangahereChapterExtractor.pattern,\n    \"#count\"   : \">= 50\",\n},\n\n{\n    \"#url\"     : \"https://www.mangahere.co/manga/aria/\",\n    \"#category\": (\"\", \"mangahere\", \"manga\"),\n    \"#class\"   : mangahere.MangahereMangaExtractor,\n},\n\n{\n    \"#url\"     : \"https://m.mangahere.co/manga/aria/\",\n    \"#category\": (\"\", \"mangahere\", \"manga\"),\n    \"#class\"   : mangahere.MangahereMangaExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/mangakakalot.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import manganelo\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://www.mangakakalot.gg/manga/danzai-sareta-akuyaku-reijou-wa-gyakkou-shite-kanpeki-na-akujo-wo-mezasu/chapter-4-5\",\n    \"#category\": (\"manganelo\", \"mangakakalot\", \"chapter\"),\n    \"#class\"   : manganelo.ManganeloChapterExtractor,\n    \"#pattern\" : r\"https://imgs-2.2xstorage.com/danzai-sareta-akuyaku-reijou-wa-gyakkou-shite-kanpeki-na-akujo-wo-mezasu/4\\.5/\\d+\\.webp\",\n    \"#count\"   : 24,\n\n    \"author\"       : \"NARAYAMA Bakufu\",\n    \"chapter\"      : 4,\n    \"chapter_id\"   : 6,\n    \"chapter_minor\": \".5\",\n    \"count\"        : 24,\n    \"date\"         : \"dt:2025-04-29 16:08:07\",\n    \"date_updated\" : \"dt:2025-04-29 16:08:07\",\n    \"extension\"    : \"webp\",\n    \"filename\"     : str,\n    \"lang\"         : \"en\",\n    \"language\"     : \"English\",\n    \"manga\"        : \"Danzai sareta Akuyaku Reijou wa, Gyakkou shite Kanpeki na Akujo wo Mezasu\",\n    \"manga_id\"     : 32842,\n    \"page\"         : range(1, 24),\n},\n\n{\n    \"#url\"     : \"https://mangakakalot.gg/manga/aria/chapter-60-2\",\n    \"#category\": (\"manganelo\", \"mangakakalot\", \"chapter\"),\n    \"#class\"   : manganelo.ManganeloChapterExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.mangakakalot.gg/manga/aria\",\n    \"#category\": (\"manganelo\", \"mangakakalot\", \"manga\"),\n    \"#class\"   : manganelo.ManganeloMangaExtractor,\n    \"#pattern\" : manganelo.ManganeloChapterExtractor.pattern,\n    \"#count\"   : 70,\n\n    \"author\"  : \"Amano Kozue\",\n    \"chapter\" : range(1, 60),\n    \"chapter_minor\": {\"\", \".1\", \".2\", \".5\"},\n    \"date\"    : \"type:datetime\",\n    \"date_updated\": \"dt:2024-10-30 10:20:58\",\n    \"lang\"    : \"en\",\n    \"language\": \"English\",\n    \"manga\"   : \"Aria\",\n    \"status\"  : \"Completed\",\n    \"title\"   : \"\",\n    \"tags\": [\n        \"Adventure\",\n        \"Comedy\",\n        \"Drama\",\n        \"Sci fi\",\n        \"Shounen\",\n        \"Slice of life\",\n    ],\n},\n\n{\n    \"#url\"     : \"https://mangakakalot.gg/manga/aria\",\n    \"#category\": (\"manganelo\", \"mangakakalot\", \"manga\"),\n    \"#class\"   : manganelo.ManganeloMangaExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/manganato.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import manganelo\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://www.manganato.gg/manga/danzai-sareta-akuyaku-reijou-wa-gyakkou-shite-kanpeki-na-akujo-wo-mezasu/chapter-4-5\",\n    \"#category\": (\"manganelo\", \"manganato\", \"chapter\"),\n    \"#class\"   : manganelo.ManganeloChapterExtractor,\n    \"#pattern\" : r\"https://imgs-2.2xstorage.com/danzai-sareta-akuyaku-reijou-wa-gyakkou-shite-kanpeki-na-akujo-wo-mezasu/4\\.5/\\d+\\.webp\",\n    \"#count\"   : 24,\n\n    \"author\"       : \"NARAYAMA Bakufu\",\n    \"chapter\"      : 4,\n    \"chapter_id\"   : 6,\n    \"chapter_minor\": \".5\",\n    \"count\"        : 24,\n    \"date\"         : \"\",\n    \"date_updated\" : \"\",\n    \"extension\"    : \"webp\",\n    \"filename\"     : str,\n    \"lang\"         : \"en\",\n    \"language\"     : \"English\",\n    \"manga\"        : \"Danzai sareta Akuyaku Reijou wa, Gyakkou shite Kanpeki na Akujo wo Mezasu\",\n    \"manga_id\"     : 32842,\n    \"page\"         : range(1, 24),\n},\n\n{\n    \"#url\"     : \"https://manganato.gg/manga/aria/chapter-60-2\",\n    \"#category\": (\"manganelo\", \"manganato\", \"chapter\"),\n    \"#class\"   : manganelo.ManganeloChapterExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.manganato.gg/manga/aria\",\n    \"#category\": (\"manganelo\", \"manganato\", \"manga\"),\n    \"#class\"   : manganelo.ManganeloMangaExtractor,\n    \"#pattern\" : manganelo.ManganeloChapterExtractor.pattern,\n    \"#count\"   : 70,\n\n    \"author\"  : \"Amano Kozue\",\n    \"chapter\" : range(1, 60),\n    \"chapter_minor\": {\"\", \".1\", \".2\", \".5\"},\n    \"date\"    : \"type:datetime\",\n    \"date_updated\": \"dt:2024-10-30 10:20:58\",\n    \"lang\"    : \"en\",\n    \"manga\"   : \"Aria\",\n    \"status\"  : \"Completed\",\n    \"tags\": [\n        \"Adventure\",\n        \"Comedy\",\n        \"Drama\",\n        \"Sci fi\",\n        \"Shounen\",\n        \"Slice of life\",\n    ],\n},\n\n{\n    \"#url\"     : \"https://manganato.gg/manga/aria\",\n    \"#category\": (\"manganelo\", \"manganato\", \"manga\"),\n    \"#class\"   : manganelo.ManganeloMangaExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/mangapark.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import mangapark\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://mangapark.net/title/114972-aria/6710214-en-ch.60.2\",\n    \"#category\": (\"\", \"mangapark\", \"chapter\"),\n    \"#class\"   : mangapark.MangaparkChapterExtractor,\n    \"#pattern\" : r\"https://[\\w-]+\\.mp\\w+\\.org/media/2002/e67/61e29278a583b9227964076e/\\d+_\\d+_\\d+_\\d+\\.jpeg\",\n    \"#count\"   : 70,\n\n    \"artist\"       : [\"amano kozue\"],\n    \"author\"       : [\"amano kozue\"],\n    \"chapter\"      : 60,\n    \"chapter_id\"   : 6710214,\n    \"chapter_minor\": \".2\",\n    \"count\"        : 70,\n    \"date\"         : \"dt:2022-01-15 09:25:03\",\n    \"extension\"    : \"jpeg\",\n    \"filename\"     : str,\n    \"genre\"        : [\n        \"adventure\",\n        \"comedy\",\n        \"drama\",\n        \"shounen\",\n        \"slice_of_life\",\n    ],\n    \"lang\"         : \"en\",\n    \"language\"     : \"English\",\n    \"manga\"        : \"Aria\",\n    \"manga_id\"     : 114972,\n    \"page\"         : int,\n    \"source\"       : \"Koala\",\n    \"title\"        : \"Special Navigation - Aquaria Ii\",\n    \"volume\"       : 12,\n},\n\n{\n    \"#url\"     : \"https://mangapark.net/comic/10426/aria/c60.2-en-i6712231\",\n    \"#comment\" : \"v3 URL\",\n    \"#class\"   : mangapark.MangaparkChapterExtractor,\n    \"#pattern\" : r\"https://[\\w-]+\\.mp\\w+\\.org/media/2001/a2e/61e2acf8062ec26ee5ef8e2a/\\d+_\\d+_\\d+_\\d+\\.jpeg\",\n    \"#count\"   : 70,\n\n    \"chapter_id\": 6712231,\n},\n\n{\n    \"#url\"     : \"https://mangapark.com/title/114972-aria/6710214-en-ch.60.2\",\n    \"#class\"   : mangapark.MangaparkChapterExtractor,\n},\n{\n    \"#url\"     : \"https://mangapark.org/title/114972-aria/6710214-en-ch.60.2\",\n    \"#class\"   : mangapark.MangaparkChapterExtractor,\n},\n{\n    \"#url\"     : \"https://mangapark.me/title/114972-aria/6710214-en-ch.60.2\",\n    \"#class\"   : mangapark.MangaparkChapterExtractor,\n},\n{\n    \"#url\"     : \"https://mangapark.io/title/114972-aria/6710214-en-ch.60.2\",\n    \"#class\"   : mangapark.MangaparkChapterExtractor,\n},\n{\n    \"#url\"     : \"https://mangapark.to/title/114972-aria/6710214-en-ch.60.2\",\n    \"#class\"   : mangapark.MangaparkChapterExtractor,\n},\n{\n    \"#url\"     : \"https://comicpark.org/title/114972-aria/6710214-en-ch.60.2\",\n    \"#class\"   : mangapark.MangaparkChapterExtractor,\n},\n{\n    \"#url\"     : \"https://comicpark.to/title/114972-aria/6710214-en-ch.60.2\",\n    \"#class\"   : mangapark.MangaparkChapterExtractor,\n},\n{\n    \"#url\"     : \"https://readpark.org/title/114972-aria/6710214-en-ch.60.2\",\n    \"#class\"   : mangapark.MangaparkChapterExtractor,\n},\n{\n    \"#url\"     : \"https://readpark.net/title/114972-aria/6710214-en-ch.60.2\",\n    \"#class\"   : mangapark.MangaparkChapterExtractor,\n},\n{\n    \"#url\"     : \"https://parkmanga.com/title/114972-aria/6710214-en-ch.60.2\",\n    \"#class\"   : mangapark.MangaparkChapterExtractor,\n},\n{\n    \"#url\"     : \"https://parkmanga.net/title/114972-aria/6710214-en-ch.60.2\",\n    \"#class\"   : mangapark.MangaparkChapterExtractor,\n},\n{\n    \"#url\"     : \"https://parkmanga.org/title/114972-aria/6710214-en-ch.60.2\",\n    \"#class\"   : mangapark.MangaparkChapterExtractor,\n},\n{\n    \"#url\"     : \"https://mpark.to/title/114972-aria/6710214-en-ch.60.2\",\n    \"#class\"   : mangapark.MangaparkChapterExtractor,\n},\n\n{\n    \"#url\"     : \"https://mangapark.net/title/114972-aria\",\n    \"#category\": (\"\", \"mangapark\", \"manga\"),\n    \"#class\"   : mangapark.MangaparkMangaExtractor,\n    \"#pattern\" : mangapark.MangaparkChapterExtractor.pattern,\n    \"#count\"   : 71,\n\n    \"chapter\"      : int,\n    \"chapter_id\"   : r\"re:\\d+\",\n    \"chapter_minor\": str,\n    \"date\"         : \"type:datetime\",\n    \"lang\"         : \"en\",\n    \"language\"     : \"English\",\n    \"manga_id\"     : 114972,\n    \"source\"       : \"Horse\",\n    \"source_id\"    : \"844\",\n    \"title\"        : str,\n    \"volume\"       : int,\n},\n\n{\n    \"#url\"     : \"https://mangapark.net/comic/10426/aria\",\n    \"#comment\" : \"v3 URL\",\n    \"#class\"   : mangapark.MangaparkMangaExtractor,\n    \"#pattern\" : mangapark.MangaparkChapterExtractor.pattern,\n    \"#count\"   : 74,\n\n    \"manga_id\" : 10426,\n},\n\n{\n    \"#url\"     : \"https://mangapark.net/title/10504-en-mushishi\",\n    \"#comment\" : \"'source' option\",\n    \"#skip\"    : \"not functional\",\n    \"#category\": (\"\", \"mangapark\", \"manga\"),\n    \"#class\"   : mangapark.MangaparkMangaExtractor,\n    \"#pattern\" : mangapark.MangaparkChapterExtractor.pattern,\n    \"#options\" : {\"source\": \"panda\"},\n    \"#count\"   : 70,\n\n    \"source\"   : \"Panda\",\n    \"source_id\": 15150116,\n},\n\n{\n    \"#url\"     : \"https://mangapark.com/title/114972-\",\n    \"#category\": (\"\", \"mangapark\", \"manga\"),\n    \"#class\"   : mangapark.MangaparkMangaExtractor,\n},\n\n{\n    \"#url\"     : \"https://mangapark.com/title/114972\",\n    \"#category\": (\"\", \"mangapark\", \"manga\"),\n    \"#class\"   : mangapark.MangaparkMangaExtractor,\n},\n\n{\n    \"#url\"     : \"https://mangapark.com/title/114972-aria\",\n    \"#category\": (\"\", \"mangapark\", \"manga\"),\n    \"#class\"   : mangapark.MangaparkMangaExtractor,\n},\n\n{\n    \"#url\"     : \"https://mangapark.org/title/114972-aria\",\n    \"#category\": (\"\", \"mangapark\", \"manga\"),\n    \"#class\"   : mangapark.MangaparkMangaExtractor,\n},\n\n{\n    \"#url\"     : \"https://mangapark.io/title/114972-aria\",\n    \"#category\": (\"\", \"mangapark\", \"manga\"),\n    \"#class\"   : mangapark.MangaparkMangaExtractor,\n},\n\n{\n    \"#url\"     : \"https://mangapark.me/title/114972-aria\",\n    \"#category\": (\"\", \"mangapark\", \"manga\"),\n    \"#class\"   : mangapark.MangaparkMangaExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/mangaread.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import mangaread\nfrom gallery_dl import exception\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://www.mangaread.org/manga/one-piece/chapter-1053-3/\",\n    \"#category\": (\"\", \"mangaread\", \"chapter\"),\n    \"#class\"   : mangaread.MangareadChapterExtractor,\n    \"#pattern\" : r\"https://www\\.mangaread\\.org/wp-content/uploads/WP-manga/data/manga_[^/]+/[^/]+/[^.]+\\.\\w+\",\n    \"#count\"   : 11,\n\n    \"manga\"        : \"One Piece\",\n    \"title\"        : \"\",\n    \"chapter\"      : 1053,\n    \"chapter_minor\": \".3\",\n    \"tags\"         : [\"Oda Eiichiro\"],\n    \"lang\"         : \"en\",\n    \"language\"     : \"English\",\n},\n\n{\n    \"#url\"     : \"https://www.mangaread.org/manga/one-piece/chapter-1000000/\",\n    \"#category\": (\"\", \"mangaread\", \"chapter\"),\n    \"#class\"   : mangaread.MangareadChapterExtractor,\n    \"#exception\": exception.NotFoundError,\n},\n\n{\n    \"#url\"     : \"https://www.mangaread.org/manga/kanan-sama-wa-akumade-choroi/chapter-10/\",\n    \"#category\": (\"\", \"mangaread\", \"chapter\"),\n    \"#class\"   : mangaread.MangareadChapterExtractor,\n    \"#pattern\" : r\"https://www\\.mangaread\\.org/wp-content/uploads/WP-manga/data/manga_[^/]+/[^/]+/[^.]+\\.\\w+\",\n    \"#count\"   : 9,\n\n    \"manga\"        : \"Kanan-sama wa Akumade Choroi\",\n    \"title\"        : \"\",\n    \"chapter\"      : 10,\n    \"chapter_minor\": \"\",\n    \"tags\"         : list,\n    \"lang\"         : \"en\",\n    \"language\"     : \"English\",\n},\n\n{\n    \"#url\"     : \"https://www.mangaread.org/manga/above-all-gods/chapter146-5/\",\n    \"#comment\" : \"^^ no whitespace\",\n    \"#category\": (\"\", \"mangaread\", \"chapter\"),\n    \"#class\"   : mangaread.MangareadChapterExtractor,\n    \"#pattern\" : r\"https://www\\.mangaread\\.org/wp-content/uploads/WP-manga/data/manga_[^/]+/[^/]+/[^.]+\\.\\w+\",\n    \"#count\"   : 6,\n\n    \"manga\"        : \"Above All Gods\",\n    \"title\"        : \"\",\n    \"chapter\"      : 146,\n    \"chapter_minor\": \".5\",\n    \"tags\"         : list,\n    \"lang\"         : \"en\",\n    \"language\"     : \"English\",\n},\n\n{\n    \"#url\"     : \"https://www.mangaread.org/manga/kanan-sama-wa-akumade-choroi\",\n    \"#category\": (\"\", \"mangaread\", \"manga\"),\n    \"#class\"   : mangaread.MangareadMangaExtractor,\n    \"#pattern\" : r\"https://www\\.mangaread\\.org/manga/kanan-sama-wa-akumade-choroi/chapter-\\d+([_-].+)?/\",\n    \"#count\"   : \">= 13\",\n\n    \"manga\"      : \"Kanan-sama wa Akumade Choroi\",\n    \"author\"     : [\"nonco\"],\n    \"artist\"     : [\"nonco\"],\n    \"type\"       : \"Manga\",\n    \"genres\"     : [\n        \"Comedy\",\n        \"Romance\",\n        \"Shounen\",\n        \"Supernatural\",\n    ],\n    \"rating\"     : float,\n    \"release\"    : 2022,\n    \"status\"     : \"OnGoing\",\n    \"lang\"       : \"en\",\n    \"language\"   : \"English\",\n    \"manga_alt\"  : list,\n    \"description\": str,\n},\n\n{\n    \"#url\"     : \"https://www.mangaread.org/manga/one-piece\",\n    \"#category\": (\"\", \"mangaread\", \"manga\"),\n    \"#class\"   : mangaread.MangareadMangaExtractor,\n    \"#pattern\" : r\"https://www\\.mangaread\\.org/manga/one-piece/chapter-\\d+(-.+)?/\",\n    \"#count\"   : \">= 1066\",\n\n    \"manga\"      : \"One Piece\",\n    \"author\"     : [\"Oda Eiichiro\"],\n    \"artist\"     : [\"Oda Eiichiro\"],\n    \"type\"       : \"Manga\",\n    \"genres\"     : list,\n    \"rating\"     : float,\n    \"release\"    : 1997,\n    \"status\"     : \"OnGoing\",\n    \"lang\"       : \"en\",\n    \"language\"   : \"English\",\n    \"manga_alt\"  : [\"One Piece\"],\n    \"description\": str,\n},\n\n{\n    \"#url\"     : \"https://www.mangaread.org/manga/doesnotexist\",\n    \"#category\": (\"\", \"mangaread\", \"manga\"),\n    \"#class\"   : mangaread.MangareadMangaExtractor,\n    \"#exception\": exception.NotFoundError,\n},\n\n)\n"
  },
  {
    "path": "test/results/mangareader.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import mangareader\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://mangareader.to/read/the-only-thing-i-must-do-in-an-all-girls-game-world-i-got-reincarnated-as-the-guy-stuck-between-yuri-couples-68609/en/chapter-2\",\n    \"#class\"   : mangareader.MangareaderChapterExtractor,\n    \"#pattern\" : r\"https://c-1\\.mreadercdn\\.com/_v1/\\d/\\d/\\w+\\.jpg\",\n    \"#count\"   : 46,\n\n    \"author\"        : [],\n    \"chapter\"       : 2,\n    \"chapter_id\"    : 2285279,\n    \"chapter_minor\" : \"\",\n    \"chapter_string\": \"2\",\n    \"chapter_url\"   : \"https://mangareader.to/read/the-only-thing-i-must-do-in-an-all-girls-game-world-i-got-reincarnated-as-the-guy-stuck-between-yuri-couples-68609/en/chapter-2\",\n    \"count\"         : 46,\n    \"description\"   : '''\"Everything for the Score\"—nicknamed \"Esco\"—is a renowned masterpiece in the world of yuri games. A boy who reincarnates into its world suddenly finds himself in the role of Sanjo Hiro, infamously known as \"the guy who gets in the way of yuri.\" Hiro is a nuisance character who ends up ruined no matter which route he's in. To avoid triggering his death flags, he begins to train and search for a safe path—but for some reason, heroine events keep unfolding around him as the central figure...!? \"I don’t want a harem romcom! Just give me yuri! Let me see the yuri!\"''',\n    \"extension\"     : \"jpg\",\n    \"filename\"      : \"hash:md5\",\n    \"lang\"          : \"en\",\n    \"manga\"         : \"The Only Thing I Must Do in an All-Girls Game World: I Got Reincarnated as the Guy Stuck Between Yuri Couples\",\n    \"manga_alt\"     : \"男子禁制ゲーム世界で俺がやるべき唯一のこと　百合の間に挟まる男として転生してしまいました\",\n    \"manga_id\"      : 68609,\n    \"manga_slug\"    : \"the-only-thing-i-must-do-in-an-all-girls-game-world-i-got-reincarnated-as-the-guy-stuck-between-yuri-couples\",\n    \"manga_url\"     : \"https://mangareader.to/the-only-thing-i-must-do-in-an-all-girls-game-world-i-got-reincarnated-as-the-guy-stuck-between-yuri-couples-68609\",\n    \"page\"          : range(1, 46),\n    \"published\"     : \"?\",\n    \"score\"         : 0.0,\n    \"status\"        : \"Publishing\",\n    \"title\"         : \"Chapter 2\",\n    \"type\"          : \"Manga\",\n    \"views\"         : range(300, 50_000),\n    \"tags\"          : [\n        \"Action\",\n        \"Comedy\",\n        \"Fantasy\",\n        \"Harem\",\n        \"Romance\",\n        \"School\",\n    ],\n},\n\n{\n    \"#url\"     : \"https://mangareader.to/read/komi-cant-communicate-287/fr/chapter-85.5\",\n    \"#class\"   : mangareader.MangareaderChapterExtractor,\n    \"#results\" : (\n        \"https://c-1.mreadercdn.com/_v1/1/1200/c9edb089648fbfca67eaab21fa9f4a61.jpeg\",\n        \"https://c-1.mreadercdn.com/_v1/0/1200/d7288b30936489b11e18e81125d28054.jpeg\",\n    ),\n\n    \"author\"        : [\"Oda, Tomohito\"],\n    \"chapter\"       : 85,\n    \"chapter_id\"    : 868842,\n    \"chapter_minor\" : \".5\",\n    \"chapter_string\": \"85.5\",\n    \"chapter_url\"   : \"https://mangareader.to/read/komi-cant-communicate-287/fr/chapter-85.5\",\n    \"count\"         : 2,\n    \"description\"   : \"\"\"It's Shouko Komi's first day at the prestigious Itan Private High School, and she has already risen to the status of the school's Madonna. With long black hair and a tall, graceful appearance, she captures the attention of anyone who comes across her. There's just one problem though—despite her popularity, Shouko is terrible at communicating with others.  Hitohito Tadano is your average high school boy. With his life motto of \"read the situation and make sure to stay away from trouble,\" he quickly finds that sitting next to Shouko has made him the enemy of everyone in his class! One day, knocked out by accident, Hitohito later wakes up to the sound of Shouko's \"meow.\" He lies that he heard nothing, causing Shouko to run away. But before she can escape, Hitohito surmises that Shouko is not able to talk to others easily—in fact, she has never been able to make a single friend. Hitohito resolves to help Shouko with her goal of making one hundred friends so that she can overcome her communication disorder.  [Written by MAL Rewrite]\"\"\",\n    \"extension\"     : \"jpeg\",\n    \"filename\"      : \"hash:md5\",\n    \"lang\"          : \"fr\",\n    \"manga\"         : \"Komi Can't Communicate\",\n    \"manga_alt\"     : \"古見さんは、コミュ症です。\",\n    \"manga_id\"      : 287,\n    \"manga_slug\"    : \"komi-cant-communicate\",\n    \"manga_url\"     : \"https://mangareader.to/komi-cant-communicate-287\",\n    \"page\"          : range(1, 2),\n    \"published\"     : \"May 18, 2016 to ?\",\n    \"score\"         : 8.28,\n    \"status\"        : \"Finished\",\n    \"title\"         : \"Souvenirs du festival culturel\",\n    \"type\"          : \"Manga\",\n    \"views\"         : range(2_000_000, 3_000_000),\n    \"tags\"          : [\n        \"Comedy\",\n        \"School\",\n        \"Shounen\",\n    ],\n},\n\n{\n    \"#url\"     : \"https://mangareader.to/read/komi-cant-communicate-287/ja/volume-6\",\n    \"#class\"   : mangareader.MangareaderChapterExtractor,\n    \"#count\"   : 198,\n\n    \"chapter\"       : 0,\n    \"chapter_id\"    : 21304,\n    \"chapter_minor\" : \"\",\n    \"chapter_string\": \"6\",\n    \"chapter_url\"   : \"https://mangareader.to/read/komi-cant-communicate-287/ja/volume-6\",\n    \"count\"         : 198,\n    \"lang\"          : \"ja\",\n    \"manga_id\"      : 287,\n    \"volume\"        : 6,\n    \"volume_cover\"  : \"https://img.mreadercdn.com/_m/200x300/100/c8/b9/c8b925c9d65fd68cc8d179eca560f895/c8b925c9d65fd68cc8d179eca560f895.jpeg\",\n},\n\n{\n    \"#url\"     : \"https://mangareader.to/mashle-magic-and-muscles-1616\",\n    \"#class\"   : mangareader.MangareaderMangaExtractor,\n    \"#pattern\" : mangareader.MangareaderChapterExtractor.pattern,\n    \"#options\" : {\"lang\": \"pt-br\"},\n    \"#count\"   : 162,\n\n    \"author\"        : [\"Koumoto, Hajime\"],\n    \"chapter\"       : int,\n    \"chapter_minor\" : \"\",\n    \"chapter_string\": str,\n    \"chapter_url\"   : r\"re:https://mangareader.to/read/mashle-magic-and-muscles-1616/pt-br/chapter-\\d+\",\n    \"description\"   : \"\"\"To everyone else in his magic-dominated world, the young and powerless Mash Vandead is a threat to the gene pool and must be purged. Living secretly in the forest, he spends every day training his body, building muscles strong enough to compete with magic itself! However, upon having his identity exposed and his peaceful life threatened, Mash begins his journey to becoming a \"Divine Visionary,\" a role so powerful that society would have no choice but to accept his existence.  And so, in order to maintain his peaceful life, the magicless Mash enrolls in the prestigious Easton Magic Academy, competing against the children of some of the most powerful and elite in the realm. Lacking the very skill needed to survive at Easton, magic, Mash appears to already be at a disadvantage to his fellow classmates. In order to achieve his goals, Mash will have to fight his way through every trial using his fists alone, overcoming magic with muscles, all for the illustrious title of Divine Visionary!  [Written by MAL Rewrite]\"\"\",\n    \"lang\"          : \"pt\",\n    \"manga\"         : \"Mashle: Magic and Muscles\",\n    \"manga_alt\"     : \"マッシュル-MASHLE-\",\n    \"manga_id\"      : 1616,\n    \"manga_slug\"    : \"mashle-magic-and-muscles\",\n    \"manga_url\"     : \"https://mangareader.to/mashle-magic-and-muscles-1616\",\n    \"published\"     : \"Jan 27, 2020 to Jul 2, 2023\",\n    \"score\"         : float,\n    \"status\"        : \"Finished\",\n    \"title\"         : str,\n    \"type\"          : \"Manga\",\n    \"views\"         : range(1_000_000, 3_000_000),\n    \"tags\"          : [\n        \"Action\",\n        \"Comedy\",\n        \"Magic\",\n        \"Parody\",\n        \"Shounen\",\n        \"Supernatural\",\n    ],\n},\n\n)\n"
  },
  {
    "path": "test/results/mangataro.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import mangataro\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://mangataro.org/read/majo-to-youhei/ch8-356833\",\n    \"#class\"   : mangataro.MangataroChapterExtractor,\n    \"#pattern\" : r\"https://(mangataro.yachts|bx1\\.mangapeak\\.me)/storage/chapters/b99b2860f0444d924c9446b4ecf1cdad/\\d+\\.webp\",\n    \"#count\"   : 22,\n\n    \"chapter\"      : 8,\n    \"chapter_minor\": \"\",\n    \"chapter_id\"   : 356833,\n    \"chapter_url\"  : \"https://mangataro.org/read/majo-to-youhei/ch8-356833\",\n    \"count\"        : 22,\n    \"page\"         : range(1, 22),\n    \"cover\"        : \"https://mangataro.org/content/media/301979l.jpg\",\n    \"date\"         : \"dt:2025-05-03 19:04:48\",\n    \"date_updated\" : \"dt:2025-05-03 19:04:53\",\n    \"description\"  : \"<p>Zig—a tall, broad-shouldered mercenary—participates in a witch hunt. Following a fierce duel with the deadly witch, he becomes privy to her desire. “I want you to protect me,” she requests, tired of having her life trivialized. Seeking a place to survive, the witch and the mercenary set their sights on an unknown continent!</p><p>(Source: Kodansha USA)</p></div><div class=\\\"mt-6 pt-6 border-t border-neutral-700/30\\\"><div class=\\\"flex items-center gap-2 mb-3\\\"> <svg class=\\\"w-4 h-4 text-neutral-400\\\" fill=\\\"none\\\" viewBox=\\\"0 0 24 24\\\" stroke=\\\"currentColor\\\"> <path stroke-linecap=\\\"round\\\" stroke-linejoin=\\\"round\\\" stroke-width=\\\"2\\\" d=\\\"M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z\\\" /> </svg><h4 class=\\\"text-sm font-medium text-neutral-100\\\">Background</h4></div><div class=\\\"max-w-none text-neutral-400 text-justify text-xs\\\"><p>Majo to Youhei has been published digitally as an English simulpub as The Witch and the Mercenary by Kodansha USA through K Manga.</p>\",\n    \"filename\"     : str,\n    \"extension\"    : \"webp\",\n    \"genre\"        : \"Manga\",\n    \"manga\"        : \"Majo to Youhei\",\n    \"manga_url\"    : \"https://mangataro.org/manga/majo-to-youhei\",\n    \"publisher\"    : \"Magazine pocket\",\n    \"status\"       : \"Ongoing\",\n    \"title\"        : \"It’s Just a Knife\",\n    \"author\"       : [\n        \"Miyagi\",\n        \"Makoto\",\n        \"Chouhoukiteki\",\n        \"Kaeru\",\n    ],\n    \"tags\"         : [\n        \"Action\",\n        \"Fantasy\",\n    ],\n},\n\n{\n    \"#url\"     : \"https://mangataro.org/read/sono-akuyaku-kizoku-mama-heroine-ga-sukisugiru-shinshi-na-doryoku-de-saikyou-to-nari-fuguu-na-oshi-chara-tasukemakuru/ch12-2-337633\",\n    \"#class\"   : mangataro.MangataroChapterExtractor,\n    \"#pattern\" : r\"https://(mangataro.yachts|bx1\\.mangapeak\\.me)/storage/chapters/200aa5d11c6ef1f049a2c68163c3a1d7/\\d+\\.webp\",\n    \"#count\"   : 13,\n\n    \"chapter\"      : 12,\n    \"chapter_minor\": \".2\",\n    \"chapter_id\"   : 337633,\n    \"chapter_url\"  : \"https://mangataro.org/read/sono-akuyaku-kizoku-mama-heroine-ga-sukisugiru-shinshi-na-doryoku-de-saikyou-to-nari-fuguu-na-oshi-chara-tasukemakuru/ch12-2-337633\",\n    \"count\"        : 13,\n    \"page\"         : range(1, 13),\n    \"cover\"        : \"https://mangataro.org/content/media/317553l.webp\",\n    \"date\"         : \"dt:2025-04-25 14:25:53\",\n    \"date_updated\" : \"dt:2025-04-25 14:25:57\",\n    \"description\"  : \"<p>The mom characters that appear in the game are all unfortunate sub-heroines who always get bad ending routes. “Why… Why does such a cute mom have to die?!” Even though the protagonist tried very hard to request a route where they get saved, all of his efforts were in vain, making him spend his days in frustration. Then, he suddenly gets reincarnated into the world of MamaFan on a certain day. …However, he gets reincarnated as a villainous aristocrat who’s got nothing but the worst routes. This guy, who’s crazy about mom characters—his fulfilling days of saving his beloved mom characters and his journey to create his harem full of plump women are about to begin!</p><p>(Source: Kadokawa, translated)</p>\",\n    \"filename\"     : str,\n    \"extension\"    : \"webp\",\n    \"genre\"        : \"Manga\",\n    \"manga\"        : \"The Villainous Noble Loves Mom Heroines Too Much: Becoming The Strongest With Sincere Effort To Save Misfortunate Fave Chars\",\n    \"manga_url\"    : \"https://mangataro.org/manga/sono-akuyaku-kizoku-mama-heroine-ga-sukisugiru-shinshi-na-doryoku-de-saikyou-to-nari-fuguu-na-oshi-chara-tasukemakuru\",\n    \"publisher\"    : \"Web Comic Apanta\",\n    \"status\"       : \"Ongoing\",\n    \"title\"        : \"\",\n    \"author\"       : [\n        \"Nozomi\",\n        \"Kota\",\n        \"Oomine\",\n    ],\n    \"tags\"         : [\n        \"Fantasy\",\n        \"Ecchi\",\n    ],\n},\n\n{\n    \"#url\"     : \"https://mangataro.org/manga/hinmin-choujin-kanenashi-kun\",\n    \"#class\"   : mangataro.MangataroMangaExtractor,\n    \"#pattern\" : mangataro.MangataroChapterExtractor.pattern,\n    \"#count\"   : 47,\n\n    \"author\"       : [\"Pageratta\"],\n    \"chapter\"      : int,\n    \"chapter_id\"   : int,\n    \"chapter_minor\": {\"\", \".5\", \".9\"},\n    \"cover\"        : \"https://mangataro.org/content/media/199106l.webp\",\n    \"description\"  : \"<p>The everyday life of a poor student, Kamenashi, as documented by his classmate who has a crush on him!</p>\",\n    \"genre\"        : \"Manga\",\n    \"manga\"        : \"Hinmin Choujin Kanenashi-kun\",\n    \"manga_url\"    : \"https://mangataro.org/manga/hinmin-choujin-kanenashi-kun\",\n    \"publisher\"    : \"Shounen Jump+\",\n    \"status\"       : \"Completed\",\n    \"tags\"         : [\n        \"Comedy\",\n        \"Romance\",\n    ],\n},\n\n{\n    \"#url\"     : \"https://mangataro.org/manga/lookism\",\n    \"#class\"   : mangataro.MangataroMangaExtractor,\n    \"#pattern\" : mangataro.MangataroChapterExtractor.pattern,\n    \"#count\"   : range(580, 800),\n\n    \"chapter\"      : range(1, 800),\n    \"chapter_id\"   : int,\n    \"chapter_minor\": {\"\", \".1\", \".5\"},\n    \"cover\"        : \"https://mangataro.org/content/media/208866l.webp\",\n    \"description\"  : \"<p>Park Hyung Suk has spent all 17 years of his life at the bottom of the food chain. Short, overweight, and unattractive, he is used to being bullied by his classmates and constantly discriminated against for his looks. In an attempt to escape his biggest bully, Lee Tae Sung, he decides to transfer to Seoul’s Jae Won High School, a vocational preparatory school notorious for its liberal education system and carefree students. Days before his transfer, Hyung Suk wakes to find that he is no longer in his usual chubby body, but is instead in a perfect body! Tall, handsome, and beautifully toned, Hyung Suk has become the ideal version of himself. The only problem is that his original body still lays beside him—and when one body falls asleep, he awakens in the other. Now possessing two extremely different bodies, Hyung Suk must learn to navigate his new and much more popular life at J High whilst also solving the mystery of where his second, almost superhuman, body came from. [Written by MAL Rewrite]</p></div><div class=\\\"mt-6 pt-6 border-t border-neutral-700/30\\\"><div class=\\\"flex items-center gap-2 mb-3\\\"> <svg class=\\\"w-4 h-4 text-neutral-400\\\" fill=\\\"none\\\" viewBox=\\\"0 0 24 24\\\" stroke=\\\"currentColor\\\"> <path stroke-linecap=\\\"round\\\" stroke-linejoin=\\\"round\\\" stroke-width=\\\"2\\\" d=\\\"M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z\\\" /> </svg><h4 class=\\\"text-sm font-medium text-neutral-100\\\">Background</h4></div><div class=\\\"max-w-none text-neutral-400 text-justify text-xs\\\"><p>Lookism is originally a webtoon which first volume was officially published in paperbook format by &Book (대원앤북) on May 25, 2017. The series has been serialized in English by LINE Webtoon since June 4, 2017.</p>\",\n    \"genre\"        : \"Manhwa\",\n    \"manga\"        : \"Lookism\",\n    \"manga_url\"    : \"https://mangataro.org/manga/lookism\",\n    \"publisher\"    : \"Naver Webtoon\",\n    \"status\"       : \"Ongoing\",\n    \"author\"       : [\n        \"Park\",\n        \"Tae-Jun\",\n    ],\n    \"tags\"         : [\n        \"Action\",\n        \"Comedy\",\n        \"Drama\",\n        \"Supernatural\",\n    ],\n},\n\n)\n"
  },
  {
    "path": "test/results/mangatown.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import mangatown\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://www.mangatown.com/manga/kimetsu_no_yaiba/c001/\",\n    \"#class\"   : mangatown.MangatownChapterExtractor,\n    \"#pattern\" : r\"https://zjcdn\\.mangahere\\.org/.*\",\n    \"#count\"   : \">= 20\",\n\n    \"chapter\"      : 1,\n    \"chapter_id\"   : 368511,\n    \"chapter_minor\": \"\",\n    \"count\"        : 55,\n    \"page\"         : range(1, 55),\n    \"extension\"    : \"jpg\",\n    \"filename\"     : str,\n    \"lang\"         : \"en\",\n    \"language\"     : \"English\",\n    \"manga\"        : \"Kimetsu no Yaiba\",\n    \"manga_id\"     : 21437,\n    \"volume\"       : 0,\n\n},\n\n{\n    \"#url\"     : \"https://www.mangatown.com/manga/kimetsu_no_yaiba/c001/1.html\",\n    \"#class\"   : mangatown.MangatownChapterExtractor,\n    \"#pattern\" : r\"https://zjcdn\\.mangahere\\.org/.*\",\n},\n\n{\n    \"#url\"     : \"http://www.mangatown.com/manga/kimetsu_no_yaiba/c001/\",\n    \"#class\"   : mangatown.MangatownChapterExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.mangatown.com/manga/kimetsu_no_yaiba/\",\n    \"#class\"   : mangatown.MangatownMangaExtractor,\n    \"#pattern\" : mangatown.MangatownChapterExtractor.pattern,\n    \"#count\"   : \">= 100\",\n\n    \"chapter\"  : int,\n    \"chapter_minor\": {\"\", \".5\", \".6\"},\n    \"date\"     : str,\n    \"lang\"     : \"en\",\n    \"language\" : \"English\",\n    \"manga\"    : \"Kimetsu no Yaiba\",\n    \"title\"    : str,\n\n},\n\n{\n    \"#url\"     : \"http://www.mangatown.com/manga/kimetsu_no_yaiba/\",\n    \"#class\"   : mangatown.MangatownMangaExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/mangoxo.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import mangoxo\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://www.mangoxo.com/album/lzVOv1Q9\",\n    \"#category\": (\"\", \"mangoxo\", \"album\"),\n    \"#class\"   : mangoxo.MangoxoAlbumExtractor,\n    \"#sha1_url\": \"ad921fe62663b06e7d73997f7d00646cab7bdd0d\",\n\n    \"channel\": {\n        \"id\"   : \"gaxO16d8\",\n        \"name\" : \"Phoenix\",\n        \"cover\": str,\n    },\n    \"album\"  : {\n        \"id\"         : \"lzVOv1Q9\",\n        \"name\"       : r\"re:池永康晟 Ikenaga Yasunari 透出古朴\",\n        \"date\"       : \"dt:2019-03-22 14:42:00\",\n        \"description\": str,\n    },\n    \"id\"     : int,\n    \"num\"    : int,\n    \"count\"  : 65,\n},\n\n{\n    \"#url\"     : \"https://www.mangoxo.com/phoenix/album\",\n    \"#category\": (\"\", \"mangoxo\", \"channel\"),\n    \"#class\"   : mangoxo.MangoxoChannelExtractor,\n    \"#pattern\" : mangoxo.MangoxoAlbumExtractor.pattern,\n    \"#range\"   : \"1-30\",\n    \"#count\"   : \"> 20\",\n},\n\n)\n"
  },
  {
    "path": "test/results/mariowiki.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import wikimedia\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://www.mariowiki.com/Rabbit\",\n    \"#category\": (\"wikimedia\", \"mariowiki\", \"article\"),\n    \"#class\"   : wikimedia.WikimediaArticleExtractor,\n    \"#pattern\" : r\"https://mario\\.wiki\\.gallery/images/.+\",\n    \"#count\"   : range(20, 50),\n},\n\n)\n"
  },
  {
    "path": "test/results/mastodon.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import mastodon\n\n\n__tests__ = (\n{\n    \"#url\"     : \"mastodon:https://donotsta.re/@elly/AcoUaA7EH1igiYKmFU\",\n    \"#comment\" : \"Akkoma - /:user/:status_id\",\n    \"#category\": (\"mastodon\", \"donotsta.re\", \"status\"),\n    \"#class\"   : mastodon.MastodonStatusExtractor,\n    \"#results\" : \"https://asdf.donotsta.re/media/917e7722dd30d510686ce9f3717a1f722dac96fd974b5af5ec2ccbc8cbd740c6.png\",\n\n    \"instance\": \"donotsta.re\",\n    \"instance_remote\": None,\n},\n\n{\n    \"#url\"     : \"mastodon:https://wanderingwires.net/@quarc/9qppkxzyd1ee3i9p\",\n    \"#comment\" : \"null moved account\",\n    \"#category\": (\"mastodon\", \"wanderingwires.net\", \"status\"),\n    \"#class\"   : mastodon.MastodonStatusExtractor,\n    \"#results\" : \"https://s3.wanderingwires.net/null/4377e826-72ab-4659-885c-fa12945eb207.png\",\n\n    \"instance\": \"wanderingwires.net\",\n    \"instance_remote\": None,\n},\n\n{\n    \"#url\"     : \"mastodon:https://woem.space/notice/Aswds12sVGsm55NS2S\",\n    \"#comment\" : \"Akkoma - /notice/:status_id\",\n    \"#category\": (\"mastodon\", \"woem.space\", \"status\"),\n    \"#class\"   : mastodon.MastodonStatusExtractor,\n    \"#results\" : \"https://nbg1.your-objectstorage.com/woem-space/261f4f482e1cb641db732dab91f0177b1f5ea0bcf008f4831c593ff718dff4fe.jpg\",\n\n    \"instance\" : \"woem.space\",\n    \"instance_remote\": None,\n},\n\n{\n    \"#url\"     : \"mastodon:https://labyrinth.zone/notice/Ai9Y2EijwN3gAil1nM\",\n    \"#comment\" : \"Akkoma - /notice/:status_id\",\n    \"#category\": (\"mastodon\", \"labyrinth.zone\", \"status\"),\n    \"#class\"   : mastodon.MastodonStatusExtractor,\n    \"#results\" : \"https://media.labyrinth.zone/media/96e10a9e3b0f24f63713d8a03e939eec7f9e636cdef57a14c389163f58e60947.png\",\n\n    \"instance\" : \"labyrinth.zone\",\n    \"instance_remote\": None,\n},\n\n{\n    \"#url\"     : \"mastodon:https://udongein.xyz/notice/Asl9hUpShUamlVAZiC\",\n    \"#comment\" : \"Pleroma - /notice/:status_id\",\n    \"#category\": (\"mastodon\", \"udongein.xyz\", \"status\"),\n    \"#class\"   : mastodon.MastodonStatusExtractor,\n    \"#results\" : \"https://statics.udongein.xyz/udongein/cc3c7a8b749cd88298fda6553e10f81f9c4de280f03ad107ed25a439e6be23eb.jpg?name=Husky_1743801357069_6QIL5OZLXK.jpg\",\n\n    \"instance\" : \"udongein.xyz\",\n    \"instance_remote\": None,\n},\n\n{\n    \"#url\"     : \"mastodon:https://freeradical.zone/@bitartbot/114477182939377350\",\n    \"#comment\" : \"Mastodon - /:user/:status_id\",\n    \"#category\": (\"mastodon\", \"freeradical.zone\", \"status\"),\n    \"#class\"   : mastodon.MastodonStatusExtractor,\n    \"#results\" : \"https://nfts.freeradical.zone/media_attachments/files/114/477/182/897/690/030/original/96700c8ae9a79651.png\",\n\n    \"instance\" : \"freeradical.zone\",\n    \"instance_remote\": None,\n},\n\n{\n    \"#url\"     : \"mastodon:https://labyrinth.zone/objects/ac523779-93d3-4315-ab8e-25b1665740cf\",\n    \"#comment\" : \"/objects/:uuid (#7497)\",\n    \"#category\": (\"mastodon\", \"labyrinth.zone\", \"status\"),\n    \"#class\"   : mastodon.MastodonStatusExtractor,\n    \"#results\" : \"https://media.labyrinth.zone/media/96e10a9e3b0f24f63713d8a03e939eec7f9e636cdef57a14c389163f58e60947.png\",\n\n    \"instance\" : \"labyrinth.zone\",\n    \"instance_remote\": None,\n},\n\n)\n"
  },
  {
    "path": "test/results/mastodonsocial.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import mastodon\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://mastodon.social/@jk\",\n    \"#category\": (\"mastodon\", \"mastodon.social\", \"user\"),\n    \"#class\"   : mastodon.MastodonUserExtractor,\n    \"#pattern\" : r\"https://files.mastodon.social/media_attachments/files/(\\d+/){3,}original/\\w+\",\n    \"#range\"   : \"1-60\",\n    \"#count\"   : 60,\n},\n\n{\n    \"#url\"     : \"https://mastodon.social/@ponapalt@ukadon.shillest.net\",\n    \"#category\": (\"mastodon\", \"mastodon.social\", \"user\"),\n    \"#class\"   : mastodon.MastodonUserExtractor,\n    \"#pattern\" : r\"https://files\\.mastodon\\.social/cache/media_attachments/files/.+/original/\\w{16}\\.\\w+$\",\n    \"#range\"   : \"1-10\",\n    \"#count\"   : 10,\n},\n\n{\n    \"#url\"     : \"https://mastodon.social/@gallerydl\",\n    \"#comment\" : \"reblogged/'boosted' posts (#4580)\",\n    \"#category\": (\"mastodon\", \"mastodon.social\", \"user\"),\n    \"#class\"   : mastodon.MastodonUserExtractor,\n    \"#options\" : {\"reblogs\": True},\n    \"#archive\" : False,\n    \"#results\": (\n        \"https://files.mastodon.social/media_attachments/files/111/330/852/486/713/967/original/2c25ade55a9d1af2.jpg\",\n        \"https://files.mastodon.social/media_attachments/files/111/331/603/082/304/823/original/e12cde371c88c1b0.png\",\n        \"https://files.mastodon.social/media_attachments/files/111/331/603/082/304/823/original/e12cde371c88c1b0.png\",\n    ),\n},\n\n{\n    \"#url\"     : \"https://mastodon.social/@id:10843\",\n    \"#category\": (\"mastodon\", \"mastodon.social\", \"user\"),\n    \"#class\"   : mastodon.MastodonUserExtractor,\n},\n\n{\n    \"#url\"     : \"https://mastodon.social/users/id:10843\",\n    \"#category\": (\"mastodon\", \"mastodon.social\", \"user\"),\n    \"#class\"   : mastodon.MastodonUserExtractor,\n},\n\n{\n    \"#url\"     : \"https://mastodon.social/users/jk\",\n    \"#category\": (\"mastodon\", \"mastodon.social\", \"user\"),\n    \"#class\"   : mastodon.MastodonUserExtractor,\n},\n\n{\n    \"#url\"     : \"https://mastodon.social/users/yoru_nine@pawoo.net\",\n    \"#category\": (\"mastodon\", \"mastodon.social\", \"user\"),\n    \"#class\"   : mastodon.MastodonUserExtractor,\n},\n\n{\n    \"#url\"     : \"https://mastodon.social/web/@jk\",\n    \"#category\": (\"mastodon\", \"mastodon.social\", \"user\"),\n    \"#class\"   : mastodon.MastodonUserExtractor,\n},\n\n{\n    \"#url\"     : \"https://mastodon.social/bookmarks\",\n    \"#category\": (\"mastodon\", \"mastodon.social\", \"bookmark\"),\n    \"#class\"   : mastodon.MastodonBookmarkExtractor,\n    \"#auth\"    : True,\n    \"#results\" : \"https://files.mastodon.social/media_attachments/files/111/331/603/082/304/823/original/e12cde371c88c1b0.png\",\n},\n\n{\n    \"#url\"     : \"https://mastodon.social/favourites\",\n    \"#category\": (\"mastodon\", \"mastodon.social\", \"favorite\"),\n    \"#class\"   : mastodon.MastodonFavoriteExtractor,\n    \"#auth\"    : True,\n    \"#results\" : \"https://files.mastodon.social/media_attachments/files/111/331/603/082/304/823/original/e12cde371c88c1b0.png\",\n},\n\n{\n    \"#url\"     : \"https://mastodon.social/lists/92653\",\n    \"#category\": (\"mastodon\", \"mastodon.social\", \"list\"),\n    \"#class\"   : mastodon.MastodonListExtractor,\n    \"#auth\"    : True,\n    \"#pattern\" : r\"https://files\\.mastodon\\.social/media_attachments/files/(\\d+/){3,}original/\\w+\",\n    \"#range\"   : \"1-10\",\n},\n\n{\n    \"#url\"     : \"https://mastodon.social/tags/mastodon\",\n    \"#category\": (\"mastodon\", \"mastodon.social\", \"hashtag\"),\n    \"#class\"   : mastodon.MastodonHashtagExtractor,\n    \"#pattern\" : r\"https://files\\.mastodon\\.social/media_attachments/files/(\\d+/){3,}original/\\w+\",\n    \"#range\"   : \"1-10\",\n},\n\n{\n    \"#url\"     : \"https://mastodon.social/@gallerydl/following\",\n    \"#category\": (\"mastodon\", \"mastodon.social\", \"following\"),\n    \"#class\"   : mastodon.MastodonFollowingExtractor,\n    \"#extractor\": False,\n    \"#results\"  : (\n        \"https://mastodon.ie/@RustyBertrand\",\n        \"https://ravenation.club/@soundwarrior20\",\n        \"https://mastodon.social/@0x4f\",\n        \"https://mastodon.social/@christianselig\",\n        \"https://saturation.social/@clive\",\n        \"https://mastodon.social/@sjvn\",\n    ),\n\n    \"acct\"          : str,\n    \"avatar\"        : r\"re:https://files.mastodon.social/.+\\.\\w+$\",\n    \"avatar_static\" : r\"re:https://files.mastodon.social/.+\\.\\w+$\",\n    \"bot\"           : False,\n    \"created_at\"    : r\"re:\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z\",\n    \"discoverable\"  : True,\n    \"display_name\"  : str,\n    \"emojis\"        : [],\n    \"fields\"        : list,\n    \"followers_count\": int,\n    \"following_count\": int,\n    \"group\"         : False,\n    \"header\"        : str,\n    \"header_static\" : str,\n    \"id\"            : r\"re:\\d+\",\n    \"last_status_at\": r\"re:\\d{4}-\\d{2}-\\d{2}\",\n    \"locked\"        : bool,\n    \"note\"          : str,\n    \"statuses_count\": int,\n    \"uri\"           : str,\n    \"url\"           : str,\n    \"username\"      : str,\n\n},\n\n{\n    \"#url\"     : \"https://mastodon.social/@0x4f/following\",\n    \"#category\": (\"mastodon\", \"mastodon.social\", \"following\"),\n    \"#class\"   : mastodon.MastodonFollowingExtractor,\n},\n\n{\n    \"#url\"     : \"https://mastodon.social/users/id:10843/following\",\n    \"#category\": (\"mastodon\", \"mastodon.social\", \"following\"),\n    \"#class\"   : mastodon.MastodonFollowingExtractor,\n},\n\n{\n    \"#url\"     : \"https://mastodon.social/@jk/103794036899778366\",\n    \"#category\": (\"mastodon\", \"mastodon.social\", \"status\"),\n    \"#class\"   : mastodon.MastodonStatusExtractor,\n    \"#count\"   : 4,\n\n    \"account\": {\n        \"acct\": \"jk\",\n    },\n    \"count\": 4,\n    \"num\"  : int,\n},\n\n{\n    \"#url\"     : \"https://mastodon.social/statuses/103794036899778366\",\n    \"#category\": (\"mastodon\", \"mastodon.social\", \"status\"),\n    \"#class\"   : mastodon.MastodonStatusExtractor,\n},\n\n{\n    \"#url\"     : \"https://mastodon.social/users/jk/statuses/103794036899778366\",\n    \"#category\": (\"mastodon\", \"mastodon.social\", \"status\"),\n    \"#class\"   : mastodon.MastodonStatusExtractor,\n},\n\n{\n    \"#url\"     : \"https://mastodon.social/@technewsbot@assortedflotsam.com/112360601113258881\",\n    \"#comment\" : \"card image\",\n    \"#category\": (\"mastodon\", \"mastodon.social\", \"status\"),\n    \"#class\"   : mastodon.MastodonStatusExtractor,\n    \"#options\" : {\"cards\": True},\n    \"#results\" : \"https://files.mastodon.social/cache/preview_cards/images/095/900/335/original/83f0b4a793c84123.jpg\",\n\n    \"media\": {\n        \"author_name\" : \"Tom Warren\",\n        \"author_url\"  : \"https://www.theverge.com/authors/tom-warren\",\n        \"blurhash\"    : \"UHBDWMCjVGM0k,XjnPM#0h+vkpb^RkjYSh$*\",\n        \"description\" : \"Microsoft’s big Xbox games showcase will take place on June 9th. It will include more games than last year and a special Call of Duty Direct will follow.\",\n        \"embed_url\"   : \"\",\n        \"height\"      : 628,\n        \"html\"        : \"\",\n        \"id\"          : \"card95900335\",\n        \"image\"       : \"https://files.mastodon.social/cache/preview_cards/images/095/900/335/original/83f0b4a793c84123.jpg\",\n        \"image_description\": \"The Xbox showcase illustration\",\n        \"language\"    : \"en\",\n        \"provider_name\": \"The Verge\",\n        \"provider_url\": \"\",\n        \"published_at\": \"2024-04-30T14:15:30.341Z\",\n        \"title\"       : \"The Xbox games showcase airs June 9th, followed by a Call of Duty Direct\",\n        \"type\"        : \"link\",\n        \"url\"         : \"https://files.mastodon.social/cache/preview_cards/images/095/900/335/original/83f0b4a793c84123.jpg\",\n        \"weburl\"      : \"https://www.theverge.com/2024/4/30/24145262/xbox-games-showcase-summer-2024-call-of-duty-direct\",\n        \"width\"       : 1200,\n    },\n\n},\n\n)\n"
  },
  {
    "path": "test/results/mediawiki.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import wikimedia\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://www.mediawiki.org/wiki/Help:Navigation\",\n    \"#category\": (\"wikimedia\", \"mediawiki\", \"help\"),\n    \"#class\"   : wikimedia.WikimediaArticleExtractor,\n    \"#results\" : (\n        \"https://upload.wikimedia.org/wikipedia/commons/0/07/Codex_icon_specialPages_color-progressive.svg?format=original\",\n        \"https://upload.wikimedia.org/wikipedia/commons/6/62/PD-icon.svg?format=original\",\n        \"https://upload.wikimedia.org/wikipedia/commons/0/0e/Vector_Sidebar.png?format=original\",\n        \"https://upload.wikimedia.org/wikipedia/commons/7/77/Vector_page_tabs.png?format=original\",\n        \"https://upload.wikimedia.org/wikipedia/commons/6/6e/Vector_user_links.png?format=original\",\n    ),\n},\n\n)\n"
  },
  {
    "path": "test/results/mgewiki.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import wikimedia\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://www.mgewiki.moe/index.php/Monster_Girl_Encyclopedia\",\n    \"#category\": (\"wikimedia\", \"mgewiki\", \"article\"),\n    \"#class\"   : wikimedia.WikimediaArticleExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/michaelscameras.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import shopify\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://michaels.com.au/collections/microphones\",\n    \"#category\": (\"shopify\", \"michaelscameras\", \"collection\"),\n    \"#class\"   : shopify.ShopifyCollectionExtractor,\n},\n\n{\n    \"#url\"     : \"https://michaels.com.au/collections/audio/products/boya-by-wm4-pro-k5-2-4ghz-mic-android-1-1-101281\",\n    \"#category\": (\"shopify\", \"michaelscameras\", \"product\"),\n    \"#class\"   : shopify.ShopifyProductExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/misskeyart.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import misskey\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://misskey.art/@mamad0r\",\n    \"#category\": (\"misskey\", \"misskey.art\", \"user\"),\n    \"#class\"   : misskey.MisskeyUserExtractor,\n    \"#options\" : {\"include\": \"all\"},\n    \"#results\" : (\n        \"https://misskey.art/@mamad0r/info\",\n        \"https://misskey.art/@mamad0r/avatar\",\n        \"https://misskey.art/@mamad0r/banner\",\n        \"https://misskey.art/@mamad0r/notes\",\n    ),\n},\n\n{\n    \"#url\"     : \"https://misskey.art/@mamad0r/info\",\n    \"#category\": (\"misskey\", \"misskey.art\", \"info\"),\n    \"#class\"   : misskey.MisskeyInfoExtractor,\n},\n\n{\n    \"#url\"     : \"https://misskey.art/@mamad0r/avatar\",\n    \"#category\": (\"misskey\", \"misskey.art\", \"avatar\"),\n    \"#class\"   : misskey.MisskeyAvatarExtractor,\n    \"#results\" : \"https://files.misskey.art//583b26e0-97bb-439a-bce9-0ef27e00cd5d.jpg\",\n    \"#sha1_content\": \"43a18e346cee05341da0cfa232c2754644473146\",\n\n    \"id\"       : \"avatar\",\n    \"file\"     : {\"id\": \"583b26e0-97bb-439a-bce9-0ef27e00cd5d\"},\n    \"user\"     : {\"id\": \"9d5fgmcxm1\"},\n},\n\n{\n    \"#url\"     : \"https://misskey.art/@risiy/banner\",\n    \"#category\": (\"misskey\", \"misskey.art\", \"background\"),\n    \"#class\"   : misskey.MisskeyBackgroundExtractor,\n    \"#results\" : \"https://files.misskey.art/f02b97ea-dc2a-4b5a-acf1-dfe360ba8bd8.png\",\n},\n\n{\n    \"#url\"     : \"https://misskey.art/@mamad0r/notes\",\n    \"#category\": (\"misskey\", \"misskey.art\", \"notes\"),\n    \"#class\"   : misskey.MisskeyNotesExtractor,\n    \"#pattern\" : r\"https://files\\.misskey\\.art/(webpublic-)?[\\w-]{36}\\.\\w+\",\n    \"#range\"   : \"1-50\",\n    \"#count\"   : 50,\n},\n\n{\n    \"#url\"     : \"https://misskey.art/@mamad0r/following\",\n    \"#category\": (\"misskey\", \"misskey.art\", \"following\"),\n    \"#class\"   : misskey.MisskeyFollowingExtractor,\n    \"#pattern\" : misskey.MisskeyUserExtractor.pattern,\n    \"#results\" : (\n        \"https://misskey.art/@tukushiA@misskey.io\",\n        \"https://misskey.art/@mamad0r@misskey.io\",\n        \"https://misskey.art/@shuumai@misskey.io\",\n    ),\n},\n\n{\n    \"#url\"     : \"https://misskey.art/notes/aaqoo4hsi6\",\n    \"#category\": (\"misskey\", \"misskey.art\", \"note\"),\n    \"#class\"   : misskey.MisskeyNoteExtractor,\n    \"#results\" : \"https://files.misskey.art/15694b3d-d157-4af5-84bc-5ff088ab3e8b.jpg\",\n},\n\n{\n    \"#url\"     : \"https://misskey.art/my/favorites\",\n    \"#category\": (\"misskey\", \"misskey.art\", \"favorite\"),\n    \"#class\"   : misskey.MisskeyFavoriteExtractor,\n},\n\n{\n    \"#url\"     : \"https://misskey.art/api/i/favorites\",\n    \"#category\": (\"misskey\", \"misskey.art\", \"favorite\"),\n    \"#class\"   : misskey.MisskeyFavoriteExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/misskeydesign.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import misskey\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://misskey.design/@machina_3D\",\n    \"#category\": (\"misskey\", \"misskey.design\", \"user\"),\n    \"#class\"   : misskey.MisskeyUserExtractor,\n    \"#options\" : {\"include\": \"all\"},\n    \"#results\" : (\n        \"https://misskey.design/@machina_3D/info\",\n        \"https://misskey.design/@machina_3D/avatar\",\n        \"https://misskey.design/@machina_3D/banner\",\n        \"https://misskey.design/@machina_3D/notes\",\n    ),\n},\n\n{\n    \"#url\"     : \"https://misskey.design/@machina_3D/notes\",\n    \"#category\": (\"misskey\", \"misskey.design\", \"notes\"),\n    \"#class\"   : misskey.MisskeyNotesExtractor,\n    \"#pattern\" : r\"https://file\\.misskey\\.design/post/(webpublic-)?[\\w-]{36}\\.\\w+\",\n    \"#range\"   : \"1-50\",\n    \"#count\"   : 50,\n},\n\n{\n    \"#url\"     : \"https://misskey.design/@machina_3D/avatar\",\n    \"#category\": (\"misskey\", \"misskey.design\", \"avatar\"),\n    \"#class\"   : misskey.MisskeyAvatarExtractor,\n    \"#results\" : \"https://file.misskey.design/post/f2cd76a4-e5e5-4c46-b45d-5cd28e0fdafe.png\",\n    \"#sha1_content\": \"5ffa5b43513ed2e77f26ef9d49a91bb0d3e76847\",\n\n    \"extension\": \"png\",\n    \"filename\" : \"f2cd76a4-e5e5-4c46-b45d-5cd28e0fdafe\",\n    \"id\"       : \"avatar\",\n    \"instance\" : \"misskey.design\",\n    \"file\"     : {\"id\": \"f2cd76a4-e5e5-4c46-b45d-5cd28e0fdafe\"},\n    \"user\"     : {\"id\": \"9bxksu5vqi\"},\n},\n\n{\n    \"#url\"     : \"https://misskey.design/@machina_3D/banner\",\n    \"#category\": (\"misskey\", \"misskey.design\", \"background\"),\n    \"#class\"   : misskey.MisskeyBackgroundExtractor,\n    \"#results\" : \"https://file.misskey.design/post/1ebf70d4-0175-454d-8d74-31829305582f.png\",\n    \"#sha1_content\": \"ba2d151e32114a894d342ed7408d970b9213e361\",\n\n    \"extension\": \"png\",\n    \"filename\" : \"1ebf70d4-0175-454d-8d74-31829305582f\",\n    \"id\"       : \"background\",\n    \"instance\" : \"misskey.design\",\n    \"file\"     : {\"id\": \"1ebf70d4-0175-454d-8d74-31829305582f\"},\n    \"user\"     : {\"id\": \"9bxksu5vqi\"},\n},\n\n{\n    \"#url\"     : \"https://misskey.design/@blooddj@pawoo.net/notes\",\n    \"#category\": (\"misskey\", \"misskey.design\", \"notes\"),\n    \"#class\"   : misskey.MisskeyNotesExtractor,\n    \"#count\"   : \"> 30\",\n},\n\n{\n    \"#url\"     : \"https://misskey.design/@kujyo_t/following\",\n    \"#category\": (\"misskey\", \"misskey.design\", \"following\"),\n    \"#class\"   : misskey.MisskeyFollowingExtractor,\n    \"#count\"    : \">= 250\",\n},\n\n{\n    \"#url\"     : \"https://misskey.design/notes/9jva1danjc\",\n    \"#category\": (\"misskey\", \"misskey.design\", \"note\"),\n    \"#class\"   : misskey.MisskeyNoteExtractor,\n    \"#results\" : \"https://file.misskey.design/post/a8d27901-24e1-42ab-b8a6-1e09c98c6f55.webp\",\n},\n\n{\n    \"#url\"     : \"https://misskey.design/my/favorites\",\n    \"#category\": (\"misskey\", \"misskey.design\", \"favorite\"),\n    \"#class\"   : misskey.MisskeyFavoriteExtractor,\n},\n\n{\n    \"#url\"     : \"https://misskey.design/api/i/favorites\",\n    \"#category\": (\"misskey\", \"misskey.design\", \"favorite\"),\n    \"#class\"   : misskey.MisskeyFavoriteExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/misskeyio.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import misskey\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://misskey.io/@lithla\",\n    \"#category\": (\"misskey\", \"misskey.io\", \"user\"),\n    \"#class\"   : misskey.MisskeyUserExtractor,\n    \"#options\" : {\"include\": \"all\"},\n    \"#results\" : (\n        \"https://misskey.io/@lithla/info\",\n        \"https://misskey.io/@lithla/avatar\",\n        \"https://misskey.io/@lithla/banner\",\n        \"https://misskey.io/@lithla/notes\",\n    ),\n},\n\n{\n    \"#url\"     : \"https://misskey.io/@lithla/notes\",\n    \"#category\": (\"misskey\", \"misskey.io\", \"notes\"),\n    \"#class\"   : misskey.MisskeyNotesExtractor,\n    \"#pattern\" : r\"https://(media.misskeyusercontent.(jp|com)|s\\d+\\.arkjp\\.net)/(misskey|io)/[\\w-]+\\.\\w+\",\n    \"#range\"   : \"1-50\",\n    \"#count\"   : 50,\n},\n\n{\n    \"#url\"     : \"https://misskey.io/@lithla/info\",\n    \"#category\": (\"misskey\", \"misskey.io\", \"info\"),\n    \"#class\"   : misskey.MisskeyInfoExtractor,\n},\n\n{\n    \"#url\"     : \"https://misskey.io/@lithla/avatar\",\n    \"#category\": (\"misskey\", \"misskey.io\", \"avatar\"),\n    \"#class\"   : misskey.MisskeyAvatarExtractor,\n    \"#results\" : \"https://media.misskeyusercontent.jp/io/d84e09f8-99b7-423a-9229-78ba65ab8a82.gif\",\n    \"#sha1_content\": \"375af4d302a4aef0bc8fc3f15b2c75ec952ac086\",\n\n    \"extension\": \"gif\",\n    \"filename\" : \"d84e09f8-99b7-423a-9229-78ba65ab8a82\",\n    \"id\"       : \"avatar\",\n    \"instance\" : \"misskey.io\",\n    \"file\"     : {\"id\": \"d84e09f8-99b7-423a-9229-78ba65ab8a82\"},\n    \"user\"     : {\"id\": \"9bhpt59w5k\"},\n},\n\n{\n    \"#url\"     : \"https://misskey.io/@lithla/banner\",\n    \"#category\": (\"misskey\", \"misskey.io\", \"background\"),\n    \"#class\"   : misskey.MisskeyBackgroundExtractor,\n    \"#results\" : \"https://media.misskeyusercontent.jp/io/ddea6f5f-9cde-42ff-8e0b-dafbfa9cca9b.png\",\n    \"#sha1_content\": \"02e1d33a7aa03d8e63baa82ea6d75c7d5de80112\",\n\n    \"extension\": \"png\",\n    \"filename\" : \"ddea6f5f-9cde-42ff-8e0b-dafbfa9cca9b\",\n    \"id\"       : \"background\",\n    \"instance\" : \"misskey.io\",\n    \"file\"     : {\"id\": \"ddea6f5f-9cde-42ff-8e0b-dafbfa9cca9b\"},\n    \"user\"     : {\"id\": \"9bhpt59w5k\"},\n},\n\n{\n    \"#url\"     : \"https://misskey.io/@blooddj@pawoo.net/notes\",\n    \"#category\": (\"misskey\", \"misskey.io\", \"notes\"),\n    \"#class\"   : misskey.MisskeyNotesExtractor,\n    \"#range\"   : \"1-50\",\n    \"#count\"   : 50,\n},\n\n{\n    \"#url\"     : \"https://misskey.io/@blooddj@pawoo.net/following\",\n    \"#category\": (\"misskey\", \"misskey.io\", \"following\"),\n    \"#class\"   : misskey.MisskeyFollowingExtractor,\n    \"#count\"    : \">= 6\",\n    \"#extractor\": False,\n},\n\n{\n    \"#url\"     : \"https://misskey.io/notes/9bhqfo835v\",\n    \"#category\": (\"misskey\", \"misskey.io\", \"note\"),\n    \"#class\"   : misskey.MisskeyNoteExtractor,\n    \"#results\" : (\n        \"https://media.misskeyusercontent.jp/misskey/1cbba095-5a19-4107-8e20-3efb0456dda4.png?sensitive=true\",\n        \"https://media.misskeyusercontent.jp/misskey/6baa558b-94ac-4bd2-a393-a52324a9d2d4.png?sensitive=true\",\n        \"https://media.misskeyusercontent.jp/misskey/14133ad0-ea40-4fed-b6e7-65d4cbe19b96.png?sensitive=true\",\n        \"https://media.misskeyusercontent.jp/misskey/e11164a2-9de5-4769-8c73-0ae44124b565.png?sensitive=true\",\n    ),\n},\n\n{\n    \"#url\"     : \"https://misskey.io/notes/9brq7z1re6\",\n    \"#category\": (\"misskey\", \"misskey.io\", \"note\"),\n    \"#class\"   : misskey.MisskeyNoteExtractor,\n},\n\n{\n    \"#url\"     : \"https://misskey.io/my/favorites\",\n    \"#category\": (\"misskey\", \"misskey.io\", \"favorite\"),\n    \"#class\"   : misskey.MisskeyFavoriteExtractor,\n},\n\n{\n    \"#url\"     : \"https://misskey.io/api/i/favorites\",\n    \"#category\": (\"misskey\", \"misskey.io\", \"favorite\"),\n    \"#class\"   : misskey.MisskeyFavoriteExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/mixdrop.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import mixdrop\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://mixdrop.ag/f/k0mmklw8axe09e\",\n    \"#class\"   : mixdrop.MixdropFileExtractor,\n    \"#pattern\" : r\"https://\\w+.mxcontent.net/v2/k0mmklw8axe09e.mp4\\?s=\\w+&e=\\d+&_t=\\d+\",\n    \"#count\"   : 1,\n\n    \"id\"       : \"k0mmklw8axe09e\",\n    \"title\"    : \"Leeds United vs Arsenal - 31012026\",\n    \"poster\"   : r\"re:https://\\w+.mxcontent.net/thumbs/k0mmklw8axe09e_5x5.jpg\",\n    \"filename\" : \"9ae814bc8aaacd419119cb1e8393c29c\",\n    \"extension\": \"mp4\",\n},\n\n{\n    \"#url\"     : \"https://m1xdrop.com/e/k0mmklw8axe09e\",\n    \"#class\"   : mixdrop.MixdropFileExtractor,\n},\n\n{\n    \"#url\"     : \"https://m1xdrop.com/f/k0mmklw8axe09e\",\n    \"#class\"   : mixdrop.MixdropFileExtractor,\n},\n\n{\n    \"#url\"     : \"https://m1xdrop.net/e/k0mmklw8axe09e\",\n    \"#class\"   : mixdrop.MixdropFileExtractor,\n},\n\n{\n    \"#url\"     : \"https://m1xdrop.net/f/k0mmklw8axe09e\",\n    \"#class\"   : mixdrop.MixdropFileExtractor,\n},\n\n{\n    \"#url\"     : \"https://mixdrop.top/e/k0mmklw8axe09e\",\n    \"#class\"   : mixdrop.MixdropFileExtractor,\n},\n\n{\n    \"#url\"     : \"https://mixdrop.top/f/k0mmklw8axe09e\",\n    \"#class\"   : mixdrop.MixdropFileExtractor,\n},\n\n{\n    \"#url\"     : \"https://mixdrop.ag/e/k0mmklw8axe09e\",\n    \"#class\"   : mixdrop.MixdropFileExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/modcloth.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import shopify\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://modcloth.com/collections/shoes\",\n    \"#category\": (\"shopify\", \"modcloth\", \"collection\"),\n    \"#class\"   : shopify.ShopifyCollectionExtractor,\n},\n\n{\n    \"#url\"     : \"https://modcloth.com/collections/shoes/products/heidii-brn\",\n    \"#category\": (\"shopify\", \"modcloth\", \"product\"),\n    \"#class\"   : shopify.ShopifyProductExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/motherless.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import motherless\nfrom gallery_dl import exception\n\n\n__tests__ = (\n{\n    \"#url\"  : \"https://motherless.com/B0168DB\",\n    \"#class\": motherless.MotherlessMediaExtractor,\n    \"#results\": \"https://cdn5-images.motherlessmedia.com/images/B0168DB.jpg\",\n    \"#sha1_content\": \"10629fc5dd7a9623af7dd57f1a322d0f24ac9acc\",\n\n    \"date\"     : \"dt:2013-03-29 00:00:00\",\n    \"extension\": \"jpg\",\n    \"favorites\": range(0, 10),\n    \"filename\" : \"B0168DB\",\n    \"group\"    : \"\",\n    \"id\"       : \"B0168DB\",\n    \"tags\"     : [\n        \"Lady J\",\n        \"outdoor\",\n        \"closeup. face\"\n    ],\n    \"title\"    : \"388652199_d6fc8a9515_o.jpg\",\n    \"type\"     : \"image\",\n    \"uploader\" : \"anonymous\",\n    \"url\"      : \"https://cdn5-images.motherlessmedia.com/images/B0168DB.jpg\",\n    \"views\"    : range(90, 200),\n\n},\n\n{\n    \"#url\"  : \"https://motherless.com/g/classic_porn/19D6C80\",\n    \"#class\": motherless.MotherlessMediaExtractor,\n    \"#results\": \"https://cdn5-images.motherlessmedia.com/images/19D6C80.gif\",\n\n    \"date\"     : \"dt:2021-05-11 00:00:00\",\n    \"extension\": \"gif\",\n    \"favorites\": range(10, 50),\n    \"filename\" : \"19D6C80\",\n    \"group\"    : \"classic_porn\",\n    \"id\"       : \"19D6C80\",\n    \"tags\"     : [],\n    \"title\"    : \"Kaffee 1\",\n    \"type\"     : \"image\",\n    \"uploader\" : \"KurtRitter\",\n    \"url\"      : \"https://cdn5-images.motherlessmedia.com/images/19D6C80.gif\",\n    \"views\"    : range(150000, 300000),\n},\n\n{\n    \"#url\"  : \"https://motherless.com/G444B6FA/46ABC1A\",\n    \"#class\": motherless.MotherlessMediaExtractor,\n    \"#results\": \"https://cdn5-images.motherlessmedia.com/images/46ABC1A.jpg\",\n\n    \"date\"      : \"dt:2017-11-24 00:00:00\",\n    \"extension\" : \"jpg\",\n    \"favorites\" : range(0, 100),\n    \"filename\"  : \"46ABC1A\",\n    \"gallery_id\": \"444B6FA\",\n    \"group\"     : \"\",\n    \"id\"        : \"46ABC1A\",\n    \"tags\"      : [\n        \"rope\",\n        \"bondage\",\n        \"bdsm\"\n    ],\n    \"title\"     : \"Some More Pix\",\n    \"type\"      : \"image\",\n    \"uploader\"  : \"FATBOY114\",\n    \"url\"       : \"https://cdn5-images.motherlessmedia.com/images/46ABC1A.jpg\",\n    \"views\"     : range(100, 2000),\n},\n\n{\n    \"#url\"     : \"https://motherless.com/8850983\",\n    \"#class\"   : motherless.MotherlessMediaExtractor,\n    \"#exception\": exception.NotFoundError,\n},\n\n{\n    \"#url\"  : \"https://motherless.com/G444B6FA\",\n    \"#class\": motherless.MotherlessGalleryExtractor,\n    \"#results\": (\n        \"https://motherless.com/GI444B6FA\",\n        \"https://motherless.com/GV444B6FA\",\n    ),\n},\n\n{\n    \"#url\"  : \"https://motherless.com/GI444B6FA\",\n    \"#class\": motherless.MotherlessGalleryExtractor,\n    \"#pattern\": r\"https://cdn5-images\\.motherlessmedia\\.com/images/[^/]+\\.(jpg|jpeg|png|gif)\",\n    \"#range\"  : \"1-100\",\n    \"#count\"  : range(5, 50),\n\n    \"count\"        : range(5, 50),\n    \"extension\"    : {\"jpg\", \"jpeg\", \"png\", \"gif\"},\n    \"filename\"     : str,\n    \"gallery_id\"   : \"444B6FA\",\n    \"id\"           : str,\n    \"num\"          : int,\n    \"thumbnail\"    : r\"re:https://cdn5-thumbs\\.motherlessmedia\\.com/thumbs/[^/]+\\.\\w+\",\n    \"title\"        : str,\n    \"type\"         : \"image\",\n    \"uploader\"     : \"WawaWeWa\",\n    \"url\"          : r\"re:https://cdn5-images\\.motherlessmedia\\.com/images/[^/]+\\.(jpg|jpeg|png|gif)\",\n},\n\n{\n    \"#url\"  : \"https://motherless.com/GV444B6FA\",\n    \"#class\": motherless.MotherlessGalleryExtractor,\n    \"#pattern\": r\"https://cdn5-videos\\.motherlessmedia\\.com/videos/[^/]+\\.mp4(?:\\?.*)?\",\n    \"#range\"  : \"1-100\",\n    \"#count\"  : range(20, 40),\n\n    \"count\"        : range(20, 100),\n    \"extension\"    : \"mp4\",\n    \"filename\"     : str,\n    \"gallery_id\"   : \"444B6FA\",\n    \"id\"           : str,\n    \"num\"          : int,\n    \"thumbnail\"    : r\"re:https://cdn5-thumbs\\.motherlessmedia\\.com/thumbs/[^/]+\\.\\w+\",\n    \"title\"        : str,\n    \"type\"         : \"video\",\n    \"uploader\"     : \"WawaWeWa\",\n    \"url\"          : r\"re:https://cdn5-videos.motherlessmedia.com/videos/[^/]+\\.mp4(?:\\?.*)?\",\n},\n\n{\n    \"#url\"     : \"https://motherless.com/GI466D59F\",\n    \"#class\"   : motherless.MotherlessGalleryExtractor,\n    \"#exception\": exception.NotFoundError,\n},\n\n{\n    \"#url\"  : \"https://motherless.com/g/bump___grind\",\n    \"#class\": motherless.MotherlessGroupExtractor,\n    \"#results\": (\n        \"https://motherless.com/gi/bump___grind\",\n        \"https://motherless.com/gv/bump___grind\",\n    ),\n},\n\n{\n    \"#url\"  : \"https://motherless.com/gi/bump___grind\",\n    \"#class\": motherless.MotherlessGroupExtractor,\n    \"#pattern\": r\"https://cdn5-images\\.motherlessmedia\\.com/images/[^/]+\\.(jpg|jpeg|png|gif)\",\n    \"#range\"  : \"1-100\",\n    \"#count\"  : 18,\n\n    \"count\"        : range(5, 50),\n    \"extension\"    : {\"jpg\", \"jpeg\", \"png\", \"gif\"},\n    \"filename\"     : str,\n    \"group_id\"     : \"bump___grind\",\n    \"group\"        : \"bump___grind\",\n    \"id\"           : str,\n    \"num\"          : int,\n    \"thumbnail\"    : r\"re:https://cdn5-thumbs\\.motherlessmedia\\.com/thumbs/[^/]+\\.\\w+\",\n    \"title\"        : str,\n    \"type\"         : \"image\",\n    \"url\"          : r\"re:https://cdn5-images\\.motherlessmedia\\.com/images/[^/]+\\.(jpg|jpeg|png|gif)\",\n},\n\n{\n    \"#url\"  : \"https://motherless.com/gv/bump___grind\",\n    \"#class\": motherless.MotherlessGroupExtractor,\n    \"#pattern\": r\"https://cdn5-videos\\.motherlessmedia\\.com/videos/[^/]+\\.mp4(?:\\?.*)?\",\n    \"#range\"  : \"1-100\",\n    \"#count\"  : 25,\n\n    \"count\"        : range(20, 100),\n    \"extension\"    : \"mp4\",\n    \"filename\"     : str,\n    \"group_id\"     : \"bump___grind\",\n    \"group\"        : \"bump___grind\",\n    \"id\"           : str,\n    \"num\"          : int,\n    \"thumbnail\"    : r\"re:https://cdn5-thumbs\\.motherlessmedia\\.com/thumbs/[^/]+\\.\\w+\",\n    \"title\"        : str,\n    \"type\"         : \"video\",\n    \"url\"          : r\"re:https://cdn5-videos.motherlessmedia.com/videos/[^/]+\\.mp4(?:\\?.*)?\",\n},\n\n)\n"
  },
  {
    "path": "test/results/myhentaigallery.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import myhentaigallery\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://myhentaigallery.com/g/16247\",\n    \"#category\": (\"\", \"myhentaigallery\", \"gallery\"),\n    \"#class\"   : myhentaigallery.MyhentaigalleryGalleryExtractor,\n    \"#pattern\" : r\"https://(cdn|images)\\.myhentaicomics\\.com/m\\w\\w/images/[^/]+/original/\\d+\\.jpg\",\n\n    \"artist\"    : list,\n    \"count\"     : 11,\n    \"gallery_id\": 16247,\n    \"group\"     : list,\n    \"parodies\"  : list,\n    \"tags\"      : [\"Giantess\"],\n    \"title\"     : \"Attack Of The 50ft Woman 1\",\n},\n\n{\n    \"#url\"     : \"https://myhentaigallery.com/gallery/thumbnails/16247\",\n    \"#category\": (\"\", \"myhentaigallery\", \"gallery\"),\n    \"#class\"   : myhentaigallery.MyhentaigalleryGalleryExtractor,\n},\n\n{\n    \"#url\"     : \"https://myhentaigallery.com/gallery/show/16247/1\",\n    \"#category\": (\"\", \"myhentaigallery\", \"gallery\"),\n    \"#class\"   : myhentaigallery.MyhentaigalleryGalleryExtractor,\n},\n\n{\n    \"#url\"     : \"https://myhentaigallery.com/g/artist/8084?sorting=favorite\",\n    \"#class\"   : myhentaigallery.MyhentaigalleryTagExtractor,\n    \"#pattern\" : myhentaigallery.MyhentaigalleryGalleryExtractor.pattern,\n    \"#count\"   : 18,\n},\n\n{\n    \"#url\"     : \"https://myhentaigallery.com/g/group/2\",\n    \"#class\"   : myhentaigallery.MyhentaigalleryTagExtractor,\n},\n\n{\n    \"#url\"     : \"https://myhentaigallery.com/g/parody/8239\",\n    \"#class\"   : myhentaigallery.MyhentaigalleryTagExtractor,\n},\n\n{\n    \"#url\"     : \"https://myhentaigallery.com/g/category/59\",\n    \"#class\"   : myhentaigallery.MyhentaigalleryTagExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/myportfolio.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import myportfolio\nfrom gallery_dl import exception\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://andrewling.myportfolio.com/volvo-xc-90-hybrid\",\n    \"#class\"   : myportfolio.MyportfolioGalleryExtractor,\n    \"#sha1_url\"     : \"acea0690c76db0e5cf267648cefd86e921bc3499\",\n    \"#sha1_metadata\": \"6ac6befe2ee0af921d24cf1dd4a4ed71be06db6d\",\n},\n\n{\n    \"#url\"     : \"https://andrewling.myportfolio.com/\",\n    \"#class\"   : myportfolio.MyportfolioGalleryExtractor,\n    \"#pattern\" : r\"https://andrewling\\.myportfolio\\.com/[^/?#+]+$\",\n    \"#count\"   : \">= 6\",\n},\n\n{\n    \"#url\"     : \"https://stevenilousphotography.myportfolio.com/society\",\n    \"#class\"   : myportfolio.MyportfolioGalleryExtractor,\n    \"#exception\": exception.NotFoundError,\n},\n\n{\n    \"#url\"     : \"myportfolio:https://tooco.com.ar/6-of-diamonds-paradise-bird\",\n    \"#comment\" : \"custom domain\",\n    \"#class\"   : myportfolio.MyportfolioGalleryExtractor,\n    \"#count\"   : 3,\n},\n\n{\n    \"#url\"     : \"myportfolio:https://tooco.com.ar/\",\n    \"#class\"   : myportfolio.MyportfolioGalleryExtractor,\n    \"#pattern\" : myportfolio.MyportfolioGalleryExtractor.pattern,\n    \"#count\"   : \">= 40\",\n},\n\n{\n    \"#url\"     : \"https://cdn.myportfolio.com/f33febb6-e0cb-4216-872f-b5b39bb2d451/74676fd6-d7e2-40ac-928f-9ea6f90a43b8.jpg?h=d3e8429d039657df820622c5e4d6c7ab\",\n    \"#comment\" : \"disallow 'cdn' subdomain\",\n    \"#class\"   : myportfolio.MyportfolioGalleryExtractor,\n    \"#fail\"    : True,\n},\n\n)\n"
  },
  {
    "path": "test/results/natomanga.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import manganelo\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://www.natomanga.com/manga/danzai-sareta-akuyaku-reijou-wa-gyakkou-shite-kanpeki-na-akujo-wo-mezasu/chapter-4-5\",\n    \"#category\": (\"manganelo\", \"natomanga\", \"chapter\"),\n    \"#class\"   : manganelo.ManganeloChapterExtractor,\n    \"#pattern\" : r\"https://imgs-2.2xstorage.com/danzai-sareta-akuyaku-reijou-wa-gyakkou-shite-kanpeki-na-akujo-wo-mezasu/4\\.5/\\d+\\.webp\",\n    \"#count\"   : 24,\n\n    \"author\"       : \"NARAYAMA Bakufu\",\n    \"chapter\"      : 4,\n    \"chapter_id\"   : 6,\n    \"chapter_minor\": \".5\",\n    \"count\"        : 24,\n    \"date\"         : \"dt:2025-04-29 16:08:07\",\n    \"date_updated\" : \"dt:2025-04-29 16:08:07\",\n    \"extension\"    : \"webp\",\n    \"filename\"     : str,\n    \"lang\"         : \"en\",\n    \"language\"     : \"English\",\n    \"manga\"        : \"Danzai sareta Akuyaku Reijou wa, Gyakkou shite Kanpeki na Akujo wo Mezasu\",\n    \"manga_id\"     : 32842,\n    \"page\"         : range(1, 24),\n},\n\n{\n    \"#url\"     : \"https://natomanga.com/manga/aria/chapter-60-2\",\n    \"#category\": (\"manganelo\", \"natomanga\", \"chapter\"),\n    \"#class\"   : manganelo.ManganeloChapterExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.natomanga.com/manga/aria\",\n    \"#category\": (\"manganelo\", \"natomanga\", \"manga\"),\n    \"#class\"   : manganelo.ManganeloMangaExtractor,\n    \"#pattern\" : manganelo.ManganeloChapterExtractor.pattern,\n    \"#count\"   : 70,\n\n    \"author\"  : \"Amano Kozue\",\n    \"chapter\" : range(1, 60),\n    \"chapter_minor\": {\"\", \".1\", \".2\", \".5\"},\n    \"date\"    : \"type:datetime\",\n    \"date_updated\": \"dt:2024-10-30 17:20:58\",\n    \"lang\"    : \"en\",\n    \"language\": \"English\",\n    \"manga\"   : \"Aria\",\n    \"status\"  : \"Completed\",\n    \"title\"   : \"\",\n    \"tags\": [\n        \"Adventure\",\n        \"Comedy\",\n        \"Drama\",\n        \"Sci fi\",\n        \"Shounen\",\n        \"Slice of life\",\n    ],\n},\n\n{\n    \"#url\"     : \"https://natomanga.com/manga/aria\",\n    \"#category\": (\"manganelo\", \"natomanga\", \"manga\"),\n    \"#class\"   : manganelo.ManganeloMangaExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/naverblog.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import naverblog\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://blog.naver.com/rlfqjxm0/221430673006\",\n    \"#class\"   : naverblog.NaverBlogPostExtractor,\n    \"#sha1_url\"     : \"6c694f3aced075ed5e9511f1e796d14cb26619cc\",\n\n    \"count\"      : 23,\n    \"num\"        : range(1, 23),\n    \"extension\"  : {\"jpg\", \"png\"},\n    \"filename\"   : str,\n    \"blog\"       : {\n        \"id\"  : \"rlfqjxm0\",\n        \"num\" : 43030507,\n        \"user\": \"에나\",\n    },\n    \"post\"       : {\n        \"date\"       : \"dt:2018-12-30 23:23:00\",\n        \"description\": \"-\",\n        \"num\"        : 221430673006,\n        \"title\"      : \"그림\",\n    },\n},\n\n{\n    \"#url\"     : \"https://blog.naver.com/PostView.nhn?blogId=rlfqjxm0&logNo=221430673006\",\n    \"#class\"   : naverblog.NaverBlogPostExtractor,\n    \"#sha1_url\"     : \"6c694f3aced075ed5e9511f1e796d14cb26619cc\",\n\n    \"count\"      : 23,\n    \"num\"        : range(1, 23),\n    \"extension\"  : {\"jpg\", \"png\"},\n    \"filename\"   : str,\n    \"blog\"       : {\n        \"id\"  : \"rlfqjxm0\",\n        \"num\" : 43030507,\n        \"user\": \"에나\",\n    },\n    \"post\"       : {\n        \"date\"       : \"dt:2018-12-30 23:23:00\",\n        \"description\": \"-\",\n        \"num\"        : 221430673006,\n        \"title\"      : \"그림\",\n    },\n},\n\n{\n    \"#url\"     : \"https://blog.naver.com/PostView.nhn?blogId=rlfqjxm0&logNo=70161391809\",\n    \"#comment\" : \"filenames in EUC-KR encoding (#5126)\",\n    \"#class\"   : naverblog.NaverBlogPostExtractor,\n    \"#results\": (\n        \"https://blogfiles.pstatic.net/20130305_23/ping9303_1362411028002Dpz9z_PNG/1_사본.png\",\n        \"https://blogfiles.pstatic.net/20130305_46/rlfqjxm0_1362473322580x33zi_PNG/오마갓합작.png\",\n    ),\n\n    \"blog\": {\n        \"id\"  : \"rlfqjxm0\",\n        \"num\" : 43030507,\n        \"user\": \"에나\",\n    },\n    \"post\": {\n        \"date\"       : \"dt:2013-03-05 17:48:00\",\n        \"description\": \" ◈     PROMOTER ：핑수 ˚ 아담 EDITOR：핑수   넵：이크：핑수...\",\n        \"num\"        : 70161391809,\n        \"title\"      : \"[공유] { 합작}  OH, MY GOD! ~ 아 또 무슨 종말을 한다 그래~\",\n    },\n    \"count\"    : 2,\n    \"num\"      : range(1, 2),\n    \"filename\" : r\"re:1_사본|오마갓합작\",\n    \"extension\": \"png\",\n},\n\n{\n    \"#url\"     : \"https://blog.naver.com/jws790103/223239681955\",\n    \"#comment\" : \"videos\",\n    \"#class\"   : naverblog.NaverBlogPostExtractor,\n    \"#pattern\" : (\n        r\"https://blogfiles.pstatic.net/MjAyMzA5MjVfMTMy/MDAxNjk1NjQ0MzI4OTE3.UxgvxTesk7Y88OWGvPMwQhbmCPp6mPA_C-5l5lJggyEg.B0DbxNEzz3DxRJtShiiBHDLzLQSCFDo_Bp6c-bcMDiog.JPEG.jws790103/20230925%EF%BC%BF080218.jpg\",\n        r\"https://blogfiles.pstatic.net/MjAyMzA5MjVfMjAz/MDAxNjk1NjQ0MzI4OTA5.Kd4VzqHhhrgby7rCA1iPdBX6f_k2DPEBnlRdOWD-kPgg.U0C1lmlKVMZMA4hhhs69nolZwCZ4Plme4KVbNfhezhkg.JPEG.jws790103/20230925%EF%BC%BF081103.jpg\",\n        r\"https://blogfiles.pstatic.net/MjAyMzA5MjVfMTg3/MDAxNjk1NjQ0MzI4OTk2.faiqny7Fl82Nnc3cJj85xa_MSBjYR3BStKeHw2bjYTwg.7Z8w0lDO9Uhjr8QTGwA0az_UZhN9haHocbYWgEyBO9gg.JPEG.jws790103/20230925%EF%BC%BF081141.jpg\",\n        r\"https://blogfiles.pstatic.net/MjAyMzA5MjVfMTIz/MDAxNjk1NjQ0MzI4OTIz.xkrCwJuYVtQID9td3XdEz8JHHrdN5UZzfOJ6nb1rW4Mg.d1FfbB8GONEej23X9Uc9uAP_oBwWnTbb9aFaBCrkfQEg.JPEG.jws790103/20230925%EF%BC%BF100506.jpg\",\n        r\"https://blogfiles.pstatic.net/MjAyMzA5MjVfMjI4/MDAxNjk1NjQ0MzI5Njg4.BHqs4eTTqOFfvYx7oZBCdeYXkQOkTFiTb8kWdC4JLeYg.8ytEDpmgyn79au0g1vGJhVxRPRVlKLF0gwQe4L0egFIg.JPEG.jws790103/20230925%EF%BC%BF100548.jpg\",\n        r\"https://a01-g-naver-vod.akamaized.net/blog/a/read/v2/VOD_ALPHA/blog_2023_10_18_2486/base_pathfinder_pf3448100_81cd756f-6cff-11ee-b67f-80615f0c46d6.mp4\\?__gda__=\\d+_\\w+&in_out_flag=1\",\n        r\"https://a01-g-naver-vod.akamaized.net/blog/a/read/v2/VOD_ALPHA/blog_2023_10_18_162/base_pathfinder_pf3448100_810b0fc9-6cff-11ee-8895-a0369ffde1ec.mp4\\?__gda__=\\d+_\\w+&in_out_flag=1\",\n    ),\n\n    \"blog\": {\n        \"id\"  : \"jws790103\",\n        \"num\" : 25591202,\n        \"user\": \"fm컴퍼니 짱\",\n    },\n    \"post\": {\n        \"date\"       : \"dt:2023-10-18 06:50:00\",\n        \"description\": \"체육행사 기획행사는 fm컴퍼니에서 함께 하겠습니다. 어린이집 연합회 마라톤 대회에 무대렌탈 장비를 대여...\",\n        \"num\"        : 223239681955,\n        \"title\"      : \"마라톤대회 무대설치 기획행사 무대설치 체육행사 무대설치완료 fm컴퍼니에서 함께 하였습니다.\",\n    },\n    \"extension\": {\"jpg\", \"mp4\"},\n    \"count\"    : 7,\n    \"num\"      : range(1, 7),\n},\n\n{\n    \"#url\"     : \"https://blog.naver.com/jws790103/223239681955\",\n    \"#comment\" : \"'videos' option\",\n    \"#class\"   : naverblog.NaverBlogPostExtractor,\n    \"#options\" : {\"videos\": False},\n    \"#results\": (\n        \"https://blogfiles.pstatic.net/MjAyMzA5MjVfMTMy/MDAxNjk1NjQ0MzI4OTE3.UxgvxTesk7Y88OWGvPMwQhbmCPp6mPA_C-5l5lJggyEg.B0DbxNEzz3DxRJtShiiBHDLzLQSCFDo_Bp6c-bcMDiog.JPEG.jws790103/20230925%EF%BC%BF080218.jpg\",\n        \"https://blogfiles.pstatic.net/MjAyMzA5MjVfMjAz/MDAxNjk1NjQ0MzI4OTA5.Kd4VzqHhhrgby7rCA1iPdBX6f_k2DPEBnlRdOWD-kPgg.U0C1lmlKVMZMA4hhhs69nolZwCZ4Plme4KVbNfhezhkg.JPEG.jws790103/20230925%EF%BC%BF081103.jpg\",\n        \"https://blogfiles.pstatic.net/MjAyMzA5MjVfMTg3/MDAxNjk1NjQ0MzI4OTk2.faiqny7Fl82Nnc3cJj85xa_MSBjYR3BStKeHw2bjYTwg.7Z8w0lDO9Uhjr8QTGwA0az_UZhN9haHocbYWgEyBO9gg.JPEG.jws790103/20230925%EF%BC%BF081141.jpg\",\n        \"https://blogfiles.pstatic.net/MjAyMzA5MjVfMTIz/MDAxNjk1NjQ0MzI4OTIz.xkrCwJuYVtQID9td3XdEz8JHHrdN5UZzfOJ6nb1rW4Mg.d1FfbB8GONEej23X9Uc9uAP_oBwWnTbb9aFaBCrkfQEg.JPEG.jws790103/20230925%EF%BC%BF100506.jpg\",\n        \"https://blogfiles.pstatic.net/MjAyMzA5MjVfMjI4/MDAxNjk1NjQ0MzI5Njg4.BHqs4eTTqOFfvYx7oZBCdeYXkQOkTFiTb8kWdC4JLeYg.8ytEDpmgyn79au0g1vGJhVxRPRVlKLF0gwQe4L0egFIg.JPEG.jws790103/20230925%EF%BC%BF100548.jpg\",\n    ),\n\n    \"extension\": \"jpg\",\n    \"count\"    : 5,\n    \"num\"      : range(1, 5),\n},\n\n{\n    \"#url\"     : \"https://blog.naver.com/PostView.naver?blogId=rlfqjxm0&logNo=221430673006\",\n    \"#class\"   : naverblog.NaverBlogPostExtractor,\n},\n\n{\n    \"#url\"     : \"https://blog.naver.com/fango5/224034048637\",\n    \"#comment\" : \"video with 'data-module-v2' (#8385)\",\n    \"#class\"   : naverblog.NaverBlogPostExtractor,\n    \"#pattern\" : (\n        r\"https://a01-g-naver-vod.akamaized.net/blog/a/read/v2/VOD_ALPHA/blog/BFECC5A8AE9558798A320806415BDC753038/pd/1759814126289/base_pathfinder_pf3448100_b2cbc7e0-a33c-11f0-8d5a-e4434b2a1c7c.mp4\\?__gda__=.+\",\n        r\"https://a01-g-naver-vod.akamaized.net/blog/a/read/v2/VOD_ALPHA/blog/93EBA48061F914D3F5B6641B6CCF0BCB9E54/pd/1759814149475/base_pathfinder_pf3448100_c9ce7003-a33c-11f0-a0c4-a0369ffbf8a4.mp4\\?__gda__=.+\",\n    ),\n\n    \"count\"         : 2,\n    \"duration\"      : {2678.123, 2949.056},\n    \"extension\"     : \"mp4\",\n    \"filename\"      : str,\n    \"id\"            : {\"9818E289D6E321DDA48D8BCD56F24A4E0B38\", \"BBE49425859BB8E21BD6346FF3FB11FE605A\"},\n    \"num\"           : range(1, 2),\n    \"p2pMetaUrl\"    : \"\",\n    \"p2pUrl\"        : \"\",\n    \"size\"          : {776289502, 909858818},\n    \"source\"        : str,\n    \"sourceFrom\"    : \"AM\",\n    \"type\"          : \"avc1\",\n    \"useP2P\"        : False,\n    \"bitrate\"       : {\n        \"audio\": float,\n        \"video\": float,\n    },\n    \"blog\"          : {\n        \"id\"  : \"fango5\",\n        \"num\" : 59788932,\n        \"user\": \"FUJII\",\n    },\n    \"encodingOption\": {\n        \"completeProgress\": \"100\",\n        \"height\"          : 1080,\n        \"id\"              : \"1080P_01\",\n        \"isEncodingComplete\": \"true\",\n        \"name\"            : \"1080p\",\n        \"profile\"         : \"HIGH\",\n        \"width\"           : 1920,\n    },\n    \"post\"          : {\n        \"date\"       : \"dt:2025-10-07 14:17:00\",\n        \"description\": \"전반 : MINI 여러분！ 부탁이 있어！ 후반: 헤드보이스 왕에게 나는 된다！\",\n        \"num\"        : 224034048637,\n        \"title\"      : \"250928 치도리의 오니렌챤 (千鳥の鬼レンチャン)\",\n    },\n},\n\n{\n    \"#url\"     : \"https://blog.naver.com/gukjung\",\n    \"#class\"   : naverblog.NaverBlogBlogExtractor,\n    \"#pattern\" : naverblog.NaverBlogPostExtractor.pattern,\n    \"#range\"   : \"1-12\",\n    \"#count\"   : 12,\n},\n\n{\n    \"#url\"     : \"https://blog.naver.com/PostList.nhn?blogId=gukjung\",\n    \"#class\"   : naverblog.NaverBlogBlogExtractor,\n    \"#pattern\" : naverblog.NaverBlogPostExtractor.pattern,\n    \"#range\"   : \"1-12\",\n    \"#count\"   : 12,\n},\n\n{\n    \"#url\"     : \"https://blog.naver.com/PostList.naver?blogId=gukjung\",\n    \"#class\"   : naverblog.NaverBlogBlogExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/naverchzzk.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import naverchzzk\n\n\n__tests__ = (\n\n{\n    \"#url\"    : \"https://chzzk.naver.com/f30b95fc9af53a75b781d7d3dd933892/community/detail/13393754\",\n    \"#class\"  : naverchzzk.NaverChzzkCommentExtractor,\n    \"#results\": (\n        \"https://nng-phinf.pstatic.net/MjAyNDA3MDlfNDgg/MDAxNzIwNTMzNzg2MDUx.0K9XrEW9CCSd2b7VdQHf8RGWkHAUsqEhNnLlleA11SUg.ZLx2V3gJPZR-kzrMY3E17wbu1ZmzYjitrEKmM_ykeWkg.PNG/tftyt.png\",\n    ),\n    \"#count\"  : 1,\n\n    \"id\"      : 13393754,\n    \"uid\"     : \"f30b95fc9af53a75b781d7d3dd933892\",\n    \"date\"    : \"dt:2024-07-09 23:03:07\",\n    \"num\"     : int,\n    \"user\"    : {\n        \"userNickname\": \"memoji\",\n        \"userRoleCode\": \"streamer\",\n    },\n    \"file\"     : {\n        \"attachType\": \"PHOTO\",\n        \"date\" : \"dt:2024-07-09 14:03:07\",\n        \"order\": int,\n        \"date_updated\": \"dt:2024-07-09 14:03:07\",\n    },\n},\n\n{\n    \"#url\"    : \"https://chzzk.naver.com/f30b95fc9af53a75b781d7d3dd933892/community/detail/20273040\",\n    \"#class\"  : naverchzzk.NaverChzzkCommentExtractor,\n    \"#results\": (\n        \"https://nng-phinf.pstatic.net/MjAyNTA2MTNfMTUw/MDAxNzQ5ODI1NjkyMzgx.8bsZ9moAfpuK3dqhHBxdd_CQdSuP5-MRrFgyJGDfdtEg.cs9HcI9BxBVXGUqJQhsUSGyOYvB3vj2itDB-arpvmokg.GIF/%EB%AC%BC%EC%9E%90%EB%AF%B8%EB%84%A4a.gif\",\n        \"https://nng-phinf.pstatic.net/MjAyNTA2MTNfMTAg/MDAxNzQ5ODI1NzA2NDk4.8PHxVU-4N8UE6mnDoDRhTMYoao9p0niz08DPQEqm2pog.C4KZL_RiK-jGlfKgoXJS5LdO3BDZUuPDCSsaqttE6Jwg.GIF/%EB%AC%BC%EC%9E%90%EB%AF%B8%EB%84%A4ab.gif\",\n        \"https://nng-phinf.pstatic.net/MjAyNTA2MTNfMjUz/MDAxNzQ5ODI1NzAzNTIw.ZODg1ok9tj0e9jQYgdAouwb_4MPX938QPWwNyhPdGs8g.wB3uMXpHObpljfoBcUTuemJfiYHTYuUT629BDIL18cog.GIF/%EB%AC%BC%EC%9E%90%EB%AF%B8%EB%84%A4b.gif\",\n    ),\n    \"#count\"  : 3,\n\n    \"id\"      : 20273040,\n    \"uid\"     : \"f30b95fc9af53a75b781d7d3dd933892\",\n    \"date\"    : \"dt:2025-06-13 23:42:18\",\n    \"content\" : \"https://mega.nz/file/DfoFgBAC#r5F_lbI4DUc2l5uuSlTMctMpk1I-qHC575ifLhYOWLI\\nhttps://mega.nz/file/LWAmkCwR#BML88rd6vRu2rKg3UwKIJzdreU86w0StAmw_7h0Nueo\\n\\n\",\n    \"num\"     : int,\n    \"user\"    : {\n        \"userNickname\": \"memoji\",\n        \"userRoleCode\": \"streamer\",\n    },\n    \"file\"      : {\n        \"attachType\": \"PHOTO\",\n        \"date\"  : \"dt:2025-06-13 14:42:18\",\n        \"width\" : int,\n        \"order\" : int,\n        \"height\": int,\n        \"extraJson\": \"{\\\"width\\\":900,\\\"height\\\":800}\",\n        \"date_updated\": \"dt:2025-06-13 14:42:18\",\n    },\n},\n\n{\n    \"#url\"  : \"https://chzzk.naver.com/f30b95fc9af53a75b781d7d3dd933892/community\",\n    \"#class\": naverchzzk.NaverChzzkCommunityExtractor,\n    \"#range\": \"1-50\",\n    \"#count\": 50,\n},\n\n{\n    \"#url\"    : \"https://chzzk.naver.com/f30b95fc9af53a75b781d7d3dd933892/community\",\n    \"#class\"  : naverchzzk.NaverChzzkCommunityExtractor,\n    \"#options\": {\"offset\": 50},\n    \"#range\"  : \"1-50\",\n    \"#count\"  : 50,\n},\n\n)\n"
  },
  {
    "path": "test/results/naverwebtoon.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import naverwebtoon\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://comic.naver.com/webtoon/detail?titleId=26458&no=1&weekday=tue\",\n    \"#class\"   : naverwebtoon.NaverWebtoonEpisodeExtractor,\n    \"#count\"       : 14,\n    \"#sha1_url\"    : \"47a956ba8c7a837213d5985f50c569fcff986f75\",\n    \"#sha1_content\": \"3806b6e8befbb1920048de9888dfce6220f69a60\",\n\n    \"author\"   : [\"김규삼\"],\n    \"artist\"   : [\"김규삼\"],\n    \"comic\"    : \"N의등대-눈의등대\",\n    \"count\"    : 14,\n    \"episode\"  : \"1\",\n    \"extension\": \"jpg\",\n    \"num\"      : int,\n    \"tags\"     : [\n        \"스릴러\",\n        \"완결무료\",\n        \"완결스릴러\",\n    ],\n    \"title\"    : \"n의 등대 - 눈의 등대 1화\",\n    \"title_id\" : \"26458\",\n},\n\n{\n    \"#url\"     : \"https://comic.naver.com/challenge/detail?titleId=765124&no=1\",\n    \"#class\"   : naverwebtoon.NaverWebtoonEpisodeExtractor,\n    \"#pattern\" : r\"https://image-comic\\.pstatic\\.net/user_contents_data/challenge_comic/2021/01/19/342586/upload_7149856273586337846\\.jpeg\",\n    \"#count\"   : 1,\n\n    \"author\"   : [\"kemi****\"],\n    \"artist\"   : [],\n    \"comic\"    : \"우니 모두의 이야기\",\n    \"count\"    : 1,\n    \"episode\"  : \"1\",\n    \"extension\": \"jpeg\",\n    \"filename\" : \"upload_7149856273586337846\",\n    \"num\"      : 1,\n    \"tags\"     : [\n        \"일상툰\",\n        \"우니모두의이야기\",\n        \"퇴사\",\n        \"입사\",\n        \"신입사원\",\n        \"사회초년생\",\n        \"회사원\",\n        \"20대\",\n    ],\n    \"title\"    : \"퇴사하다\",\n    \"title_id\" : \"765124\",\n},\n\n{\n    \"#url\"     : \"https://comic.naver.com/bestChallenge/detail?titleId=620732&no=334\",\n    \"#comment\" : \"empty tags (#5120)\",\n    \"#class\"   : naverwebtoon.NaverWebtoonEpisodeExtractor,\n    \"#count\"   : 9,\n\n    \"artist\"  : [],\n    \"author\"  : [\"안트로anthrokim\"],\n    \"comic\"   : \"백일몽화원\",\n    \"count\"   : 9,\n    \"episode\" : \"334\",\n    \"num\"     : range(1, 9),\n    \"tags\"    : [],\n    \"title\"   : \"321화... 성(省)\",\n    \"title_id\": \"620732\",\n},\n\n{\n    \"#url\"     : \"https://comic.naver.com/bestChallenge/detail.nhn?titleId=771467&no=3\",\n    \"#class\"   : naverwebtoon.NaverWebtoonEpisodeExtractor,\n    \"#pattern\" : r\"https://image-comic\\.pstatic\\.net/user_contents_data/challenge_comic/2021/04/28/345534/upload_3617293622396203109\\.jpeg\",\n    \"#count\"   : 1,\n},\n\n{\n    \"#url\"     : \"https://comic.naver.com/webtoon/list?titleId=22073\",\n    \"#class\"   : naverwebtoon.NaverWebtoonComicExtractor,\n    \"#pattern\" : naverwebtoon.NaverWebtoonEpisodeExtractor.pattern,\n    \"#count\"   : 32,\n},\n\n{\n    \"#url\"     : \"https://comic.naver.com/webtoon/list?titleId=765124\",\n    \"#comment\" : \"/webtoon/ path for 'challenge' comic (#5123)\",\n    \"#class\"   : naverwebtoon.NaverWebtoonComicExtractor,\n    \"#range\"   : \"1\",\n    \"#results\" : \"https://comic.naver.com/challenge/detail?titleId=765124&no=1\",\n},\n\n{\n    \"#url\"     : \"https://comic.naver.com/challenge/list?titleId=765124\",\n    \"#class\"   : naverwebtoon.NaverWebtoonComicExtractor,\n    \"#pattern\" : naverwebtoon.NaverWebtoonEpisodeExtractor.pattern,\n    \"#count\"   : 24,\n},\n\n{\n    \"#url\"     : \"https://comic.naver.com/bestChallenge/list.nhn?titleId=789786\",\n    \"#class\"   : naverwebtoon.NaverWebtoonComicExtractor,\n    \"#pattern\" : naverwebtoon.NaverWebtoonEpisodeExtractor.pattern,\n    \"#count\"   : 1,\n},\n\n)\n"
  },
  {
    "path": "test/results/nekohouse.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import nekohouse\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://nekohouse.su/fantia/user/319092/post/3163233\",\n    \"#class\"   : nekohouse.NekohousePostExtractor,\n    \"#results\" : (\n        \"https://nekohouse.su/data/b2/ca/b2ca86189cda7408d75c36d850ca6394c089786d46c6dd0c90b4a2e17e07774f.jpg\",\n        \"https://nekohouse.su/data/2e/cf/2ecfd1a04affa35c147bb43d626d6149c2c3f9a9fb7df1659a40c8de1b3e09e5.jpg\",\n        \"https://nekohouse.su/data/9a/ed/9aed4b879023b761882c7c11ce74a3ee51a22487e2c77df0bfabed7c5a73cbe5.jpg\",\n    ),\n\n    \"content\"  : \"エリー・マナ・マリア編のもの\\n\\n会場行った人以外よくわからないと思うので、\\nレポの体をなしてないですが…\",\n    \"count\"    : 3,\n    \"date\"     : \"dt:2024-12-12 09:34:36\",\n    \"extension\": \"jpg\",\n    \"filename\" : r\"re:^[0-9a-f]{64}$\",\n    \"hash\"     : r\"re:^[0-9a-f]{64}$\",\n    \"id\"       : {\"662005\", \"662006\", \"662007\"},\n    \"num\"      : range(1, 3),\n    \"post_id\"  : \"3163233\",\n    \"service\"  : \"fantia\",\n    \"title\"    : \"ルミナスバースデーイベ２\",\n    \"type\"     : \"file\",\n    \"url\"      : str,\n    \"user_id\"  : \"319092\",\n    \"username\" : \"島田フミカネ\",\n},\n\n{\n    \"#url\"     : \"https://nekohouse.su/fantia/user/19235/post/2621173\",\n    \"#comment\" : \"attachment / video\",\n    \"#class\"   : nekohouse.NekohousePostExtractor,\n    \"#range\"   : \"6\",\n    \"#results\" : (\n        \"https://nekohouse.su/data/f9/4c/f94ca55a329604bec63536828a36fd2b455aec03ffb3657e25c0b405d8484823.mp4\",\n    ),\n\n    \"content\"  : \"\",\n    \"count\"    : 6,\n    \"date\"     : \"dt:2024-03-15 12:09:48\",\n    \"extension\": \"mp4\",\n    \"filename\" : \"レミリアゲームver0.01\",\n    \"hash\"     : \"f94ca55a329604bec63536828a36fd2b455aec03ffb3657e25c0b405d8484823\",\n    \"id\"       : \"\",\n    \"num\"      : 6,\n    \"post_id\"  : \"2621173\",\n    \"service\"  : \"fantia\",\n    \"title\"    : \"ふたなりレミリア総受けエロゲーのお話\",\n    \"type\"     : \"attachment\",\n    \"url\"      : \"https://nekohouse.su/data/f9/4c/f94ca55a329604bec63536828a36fd2b455aec03ffb3657e25c0b405d8484823.mp4\",\n    \"user_id\"  : \"19235\",\n    \"username\" : \"なまこ大爆発\",\n},\n\n{\n    \"#url\"     : \"https://nekohouse.su/fantia/user/19235\",\n    \"#class\"   : nekohouse.NekohouseUserExtractor,\n    \"#pattern\" : r\"https://nekohouse\\.su/fantia/user/19235/post/\\d+\",\n    \"#count\"   : range(51, 100),\n},\n\n)\n"
  },
  {
    "path": "test/results/nelomanga.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import manganelo\nfrom gallery_dl import exception\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://www.nelomanga.net/manga/danzai-sareta-akuyaku-reijou-wa-gyakkou-shite-kanpeki-na-akujo-wo-mezasu/chapter-4-5\",\n    \"#category\": (\"manganelo\", \"nelomanga\", \"chapter\"),\n    \"#class\"   : manganelo.ManganeloChapterExtractor,\n    \"#pattern\" : r\"https://imgs-2.2xstorage.com/danzai-sareta-akuyaku-reijou-wa-gyakkou-shite-kanpeki-na-akujo-wo-mezasu/4\\.5/\\d+\\.webp\",\n    \"#count\"   : 24,\n\n    \"author\"       : \"NARAYAMA Bakufu\",\n    \"chapter\"      : 4,\n    \"chapter_id\"   : 6,\n    \"chapter_minor\": \".5\",\n    \"count\"        : 24,\n    \"date\"         : \"\",\n    \"date_updated\" : \"\",\n    \"extension\"    : \"webp\",\n    \"filename\"     : str,\n    \"lang\"         : \"en\",\n    \"language\"     : \"English\",\n    \"manga\"        : \"Danzai sareta Akuyaku Reijou wa, Gyakkou shite Kanpeki na Akujo wo Mezasu\",\n    \"manga_id\"     : 32842,\n    \"page\"         : range(1, 24),\n},\n\n{\n    \"#url\"     : \"https://nelomanga.net/manga/aria/chapter-60-2\",\n    \"#category\": (\"manganelo\", \"nelomanga\", \"chapter\"),\n    \"#class\"   : manganelo.ManganeloChapterExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.nelomanga.net/manga/aria\",\n    \"#category\": (\"manganelo\", \"nelomanga\", \"manga\"),\n    \"#class\"   : manganelo.ManganeloMangaExtractor,\n    \"#pattern\" : manganelo.ManganeloChapterExtractor.pattern,\n    \"#count\"   : 70,\n\n    \"author\"  : \"Amano Kozue\",\n    \"chapter\" : range(1, 60),\n    \"chapter_minor\": {\"\", \".1\", \".2\", \".5\"},\n    \"date\"    : \"type:datetime\",\n    \"date_updated\": \"dt:2024-10-30 17:20:58\",\n    \"lang\"    : \"en\",\n    \"language\": \"English\",\n    \"manga\"   : \"Aria\",\n    \"status\"  : \"Completed\",\n    \"title\"   : \"\",\n    \"tags\": [\n        \"Adventure\",\n        \"Comedy\",\n        \"Drama\",\n        \"Sci fi\",\n        \"Shounen\",\n        \"Slice of life\",\n    ],\n},\n\n{\n    \"#url\"     : \"https://nelomanga.net/manga/aria\",\n    \"#category\": (\"manganelo\", \"nelomanga\", \"manga\"),\n    \"#class\"   : manganelo.ManganeloMangaExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.nelomanga.net/bookmark\",\n    \"#category\": (\"manganelo\", \"nelomanga\", \"bookmark\"),\n    \"#class\"   : manganelo.ManganeloBookmarkExtractor,\n    \"#pattern\" : manganelo.ManganeloMangaExtractor.pattern,\n    \"#auth\"    : \"cookies\",\n    \"#count\"   : 23,\n},\n\n{\n    \"#url\"     : \"https://nelomanga.net/bookmark\",\n    \"#category\": (\"manganelo\", \"nelomanga\", \"bookmark\"),\n    \"#class\"   : manganelo.ManganeloBookmarkExtractor,\n    \"#auth\"     : False,\n    \"#exception\": exception.AuthRequired,\n},\n\n)\n"
  },
  {
    "path": "test/results/newgrounds.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import newgrounds\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://www.newgrounds.com/art/view/tomfulp/ryu-is-hawt\",\n    \"#category\": (\"\", \"newgrounds\", \"image\"),\n    \"#class\"   : newgrounds.NewgroundsImageExtractor,\n    \"#results\"     : \"https://art.ngfiles.com/images/1993000/1993615_4474_tomfulp_ryu-is-hawt.44f81090378ae9c257a5e46a8e17cc4d.gif?f1695674895\",\n    \"#sha1_content\": \"8f395e08333eb2457ba8d8b715238f8910221365\",\n\n    \"artist\"     : [\"tomfulp\"],\n    \"comment\"    : \"Consider this the bottom threshold for scouted artists.\\n\\nIn fact consider it BELOW the bottom threshold.\",\n    \"date\"       : \"dt:2009-06-04 14:44:05\",\n    \"description\": \"\",\n    \"favorites\"  : int,\n    \"filename\"   : \"1993615_4474_tomfulp_ryu-is-hawt.44f81090378ae9c257a5e46a8e17cc4d\",\n    \"height\"     : 476,\n    \"index\"      : 1993615,\n    \"rating\"     : \"e\",\n    \"score\"      : float,\n    \"slug\"       : \"ryu-is-hawt\",\n    \"tags\"       : [\n        \"ryu\",\n        \"streetfighter\",\n    ],\n    \"title\"      : \"Ryu is Hawt\",\n    \"type\"       : \"art\",\n    \"user\"       : \"tomfulp\",\n    \"width\"      : 447,\n},\n\n{\n    \"#url\"     : \"https://art.ngfiles.com/images/0/94_tomfulp_ryu-is-hawt.gif\",\n    \"#category\": (\"\", \"newgrounds\", \"image\"),\n    \"#class\"   : newgrounds.NewgroundsImageExtractor,\n    \"#results\" : \"https://art.ngfiles.com/images/1993000/1993615_4474_tomfulp_ryu-is-hawt.44f81090378ae9c257a5e46a8e17cc4d.gif?f1695674895\",\n\n    \"slug\" : \"ryu-is-hawt\",\n    \"title\": \"Ryu is Hawt\",\n},\n\n{\n    \"#url\"     : \"https://www.newgrounds.com/art/view/sailoryon/yon-dream-buster\",\n    \"#comment\" : \"embedded file in 'comments' (#1033)\",\n    \"#category\": (\"\", \"newgrounds\", \"image\"),\n    \"#class\"   : newgrounds.NewgroundsImageExtractor,\n    \"#results\" : (\n        \"https://art.ngfiles.com/images/1438000/1438673_sailoryon_yon-dream-buster.jpg?f1601058173\",\n        \"https://art.ngfiles.com/comments/172000/iu_172374_7112211.jpg\",\n    ),\n\n    \"slug\" : \"yon-dream-buster\",\n    \"title\": \"Yon Dream Buster!\",\n},\n\n{\n    \"#url\"     : \"https://www.newgrounds.com/art/view/zedrinbot/lewd-animation-tutorial\",\n    \"#comment\" : \"extra files in 'art-image-row' elements - WebP to GIF (#4642)\",\n    \"#category\": (\"\", \"newgrounds\", \"image\"),\n    \"#class\"   : newgrounds.NewgroundsImageExtractor,\n    \"#auth\"    : True,\n    \"#results\" : (\n        \"https://art.ngfiles.com/images/5091000/5091275_45067_zedrinbot_untitled-5091275.0a9d27ed2bc265a7e89478ed6ad6f86f.gif?f1696187399\",\n        \"https://art.ngfiles.com/images/5091000/5091275_45071_zedrinbot_untitled-5091275.6fdc62eaef43528fb1c9bda624d30a3d.gif?f1696187436\",\n        \"https://art.ngfiles.com/images/5091000/5091275_45070_zedrinbot_untitled-5091275.0d7334746374465bd448908b88d1f810.gif?f1696187434\",\n        \"https://art.ngfiles.com/images/5091000/5091275_45072_zedrinbot_untitled-5091275.6fdc62eaef43528fb1c9bda624d30a3d.gif?f1696187437\",\n        \"https://art.ngfiles.com/images/5091000/5091275_45073_zedrinbot_untitled-5091275.20aa05c1cd22fd058e8c68ce58f5a302.gif?f1696187437\",\n    ),\n},\n\n{\n    \"#url\"     : \"https://www.newgrounds.com/art/view/zedrinbot/nazrin-tanlines\",\n    \"#comment\" : \"extra files in 'art-image-row' elements - native PNG files (#4642)\",\n    \"#category\": (\"\", \"newgrounds\", \"image\"),\n    \"#class\"   : newgrounds.NewgroundsImageExtractor,\n    \"#auth\"    : True,\n    \"#results\" : (\n        \"https://art.ngfiles.com/images/5009000/5009916_14628_zedrinbot_nazrin-tanlines.265f7b6beec5855a349e2646e90cbc01.png?f1695698131\",\n        \"https://art.ngfiles.com/images/5009000/5009916_14632_zedrinbot_nazrin-tanlines.40bd62fbf5875806cda6b004b348114a.png?f1695727318\",\n        \"https://art.ngfiles.com/images/5009000/5009916_14634_zedrinbot_nazrin-tanlines.40bd62fbf5875806cda6b004b348114a.png?f1695727321\",\n        \"https://art.ngfiles.com/images/5009000/5009916_14633_zedrinbot_nazrin-tanlines.40bd62fbf5875806cda6b004b348114a.png?f1695727318\",\n        \"https://art.ngfiles.com/images/5009000/5009916_14635_zedrinbot_nazrin-tanlines.6a7aa4fd63e5f8077ad29314568246cc.png?f1695727321\",\n        \"https://art.ngfiles.com/images/5009000/5009916_14636_zedrinbot_nazrin-tanlines.6a7aa4fd63e5f8077ad29314568246cc.png?f1695727322\",\n    ),\n},\n\n{\n    \"#url\"     : \"https://www.newgrounds.com/art/view/bacun/kill-la-kill-10th-anniversary\",\n    \"#comment\" : \"extra files in 'imageData' block (#4642)\",\n    \"#category\": (\"\", \"newgrounds\", \"image\"),\n    \"#class\"   : newgrounds.NewgroundsImageExtractor,\n    \"#results\" : (\n        \"https://art.ngfiles.com/images/5127000/5127150_93307_bacun_kill-la-kill-10th-anniversary.61adfe309bec342f9db55fd44397235b.png?f1697310027\",\n        \"https://art.ngfiles.com/images/5127000/5127150_94250_bacun_kill-la-kill-10th-anniversary.64fdf525fa38c1ab34defac4b354bc7a.webp?f1697332147\",\n    ),\n},\n\n{\n    \"#url\"     : \"https://www.newgrounds.com/art/view/sockdotclip/trickin-treats\",\n    \"#comment\" : \"extra files in comment section as '<img src=' (#6253)\",\n    \"#class\"   : newgrounds.NewgroundsImageExtractor,\n    \"#results\" : (\n        \"https://art.ngfiles.com/images/2811000/2811344_sockdotclip_trickin-treats.png?f1667246310\",\n        \"https://art.ngfiles.com/comments/788000/iu_788899_10504416.webp\",\n        \"https://art.ngfiles.com/comments/788000/iu_788901_10504416.webp\",\n        \"https://art.ngfiles.com/comments/788000/iu_788900_10504416.webp\",\n        \"https://art.ngfiles.com/comments/788000/iu_788903_10504416.webp\",\n        \"https://art.ngfiles.com/comments/788000/iu_788902_10504416.webp\",\n    ),\n},\n\n{\n    \"#url\"     : \"https://www.newgrounds.com/art/view/kekiiro/red\",\n    \"#comment\" : \"'adult' rated (#2456)\",\n    \"#category\": (\"\", \"newgrounds\", \"image\"),\n    \"#class\"   : newgrounds.NewgroundsImageExtractor,\n    \"#options\" : {\"username\": None},\n    \"#count\"   : 0,\n},\n\n{\n    \"#url\"     : \"https://www.newgrounds.com/art/view/shamfoo/arigatou-ke-xue-zhe69\",\n    \"#comment\" : \"japanese title, ascii slug (#8064)\",\n    \"#class\"   : newgrounds.NewgroundsImageExtractor,\n    \"#results\" : \"https://art.ngfiles.com/images/878000/878868_shamfoo_arigatou-ke-xue-zhe69.png?f1555537672\",\n\n    \"slug\" : \"arigatou-ke-xue-zhe69\",\n    \"title\": \"ありがとう科学者69\",\n},\n\n{\n    \"#url\"     : \"https://www.newgrounds.com/portal/view/595355\",\n    \"#comment\" : \"video\",\n    \"#category\": (\"\", \"newgrounds\", \"media\"),\n    \"#class\"   : newgrounds.NewgroundsMediaExtractor,\n    \"#results\" : \"https://uploads.ungrounded.net/alternate/564000/564957_alternate_31.mp4?1359712249\",\n\n    \"artist\"     : [\n        \"kickinthehead\",\n        \"danpaladin\",\n        \"tomfulp\",\n    ],\n    \"comment\"    : r\"re:My fan trailer for Alien Hominid HD!\",\n    \"date\"       : \"dt:2013-02-01 09:50:49\",\n    \"description\": \"Fan trailer for Alien Hominid HD!\",\n    \"favorites\"  : int,\n    \"filename\"   : \"564957_alternate_31\",\n    \"index\"      : 595355,\n    \"rating\"     : \"e\",\n    \"score\"      : float,\n    \"tags\"       : [\n        \"alienhominid\",\n        \"trailer\",\n    ],\n    \"title\"      : \"Alien Hominid Fan Trailer\",\n    \"type\"       : \"movie\",\n    \"user\"       : \"kickinthehead\",\n},\n\n{\n    \"#url\"     : \"https://www.newgrounds.com/portal/view/595355\",\n    \"#category\": (\"\", \"newgrounds\", \"media\"),\n    \"#class\"   : newgrounds.NewgroundsMediaExtractor,\n    \"#options\" : {\"format\": [\"mkv\", \"mov\", 1080]},\n    \"#results\" : \"https://uploads.ungrounded.net/alternate/564000/564957_alternate_31.mkv?1359712249\",\n},\n\n{\n    \"#url\"     : \"https://www.newgrounds.com/portal/view/595355\",\n    \"#category\": (\"\", \"newgrounds\", \"media\"),\n    \"#class\"   : newgrounds.NewgroundsMediaExtractor,\n    \"#options\" : {\"format\": \"720p\"},\n    \"#results\" : \"https://uploads.ungrounded.net/alternate/564000/564957_alternate_31.720p.mp4?1359712249\",\n},\n\n{\n    \"#url\"     : \"https://www.newgrounds.com/audio/listen/609768\",\n    \"#comment\" : \"audio\",\n    \"#category\": (\"\", \"newgrounds\", \"media\"),\n    \"#class\"   : newgrounds.NewgroundsMediaExtractor,\n    \"#sha1_url\": \"f4c5490ae559a3b05e46821bb7ee834f93a43c95\",\n\n    \"artist\"     : [\n        \"zj\",\n        \"tomfulp\",\n    ],\n    \"comment\"    : \"\"\"\\\nRECORDED 12-09-2014\n\nFrom The ZJ \"Late Nite\" Report at the University of Cincinnati!\n\nZJ gets to interview Tom Fulp, the founder of Newgrounds.com and the programmer behind classic games like Alien Hominid and Castle Crashers. Lots of cool stuff is talked about on here like game design, finding a way to market yourself on the modern web, and what Tom would do in the zombie apocalypse. It's a barrel of fun, so shut up and listen to it!\n\nSee more ZJ Report:\n\nTwitter: @ZJReport\n\nFacebook: Facebook.com/ZJReport\n\nNOTE:\n\nIf this version of this interview offends your ears, there's a different one on Soundcloud. That original file was lost somehow, so I tried recreating it as best as I can, but I understand that there are still some differences...\n\nhttps://soundcloud.com/the-zj-late-nite-report/the-zj-late-nite-report-extra-tom-fulp-interview\n\nAlso wanna give a big shout-out to by by Zachary (Zachary.newgrounds.com) for providing the intro and outro music on this thing.\\\n\"\"\",\n    \"date\"       : \"dt:2015-02-23 19:31:59\",\n    \"description\": \"From The ZJ Report Show!\",\n    \"favorites\"  : int,\n    \"index\"      : 609768,\n    \"rating\"     : \"\",\n    \"score\"      : float,\n    \"tags\"       : [\n        \"fulp\",\n        \"interview\",\n        \"tom\",\n        \"zj\",\n    ],\n    \"title\"      : \"ZJ Interviews Tom Fulp!\",\n    \"type\"       : \"audio\",\n    \"user\"       : \"zj\",\n},\n\n{\n    \"#url\"     : \"https://www.newgrounds.com/portal/view/161181/format/flash\",\n    \"#comment\" : \"flash animation (#1257)\",\n    \"#category\": (\"\", \"newgrounds\", \"media\"),\n    \"#class\"   : newgrounds.NewgroundsMediaExtractor,\n    \"#results\" : \"https://uploads.ungrounded.net/161000/161181_ddautta_mask__550x281_.swf\",\n\n    \"type\": \"movie\",\n},\n\n{\n    \"#url\"     : \"https://www.newgrounds.com/portal/view/758545\",\n    \"#comment\" : \"video format selection (#1729)\",\n    \"#category\": (\"\", \"newgrounds\", \"media\"),\n    \"#class\"   : newgrounds.NewgroundsMediaExtractor,\n    \"#options\" : {\"format\": \"720p\"},\n    \"#pattern\" : r\"https://uploads\\.ungrounded\\.net/alternate/1482000/1482860_alternate_102516\\.720p\\.mp4\\?\\d+\",\n},\n\n{\n    \"#url\"     : \"https://www.newgrounds.com/portal/view/717744\",\n    \"#comment\" : \"'adult' rated (#2456)\",\n    \"#category\": (\"\", \"newgrounds\", \"media\"),\n    \"#class\"   : newgrounds.NewgroundsMediaExtractor,\n    \"#options\" : {\"username\": None},\n    \"#count\"   : 1,\n},\n\n{\n    \"#url\"     : \"https://www.newgrounds.com/portal/view/829032\",\n    \"#comment\" : \"flash game\",\n    \"#category\": (\"\", \"newgrounds\", \"media\"),\n    \"#class\"   : newgrounds.NewgroundsMediaExtractor,\n    \"#results\" : (\n        \"https://uploads.ungrounded.net/829000/829032_picovsbeardx.swf\",\n        \"https://uploads.ungrounded.net/tmp/img/521000/iu_521265_5431202.gif\",\n    ),\n\n    \"artist\"     : [\n        \"dungeonation\",\n        \"carpetbakery\",\n        \"animalspeakandrews\",\n        \"bill\",\n        \"chipollo\",\n    ],\n    \"comment\"    : r\"re:The children are expendable. Take out the \",\n    \"date\"       : \"dt:2022-01-10 23:00:57\",\n    \"description\": \"Bloodshed in The Big House that Blew...again!\",\n    \"favorites\"  : int,\n    \"index\"      : 829032,\n    \"post_url\"   : \"https://www.newgrounds.com/portal/view/829032\",\n    \"rating\"     : \"m\",\n    \"score\"      : float,\n    \"tags\"       : [\n        \"assassin\",\n        \"boyfriend\",\n        \"darnell\",\n        \"nene\",\n        \"pico\",\n        \"picos-school\",\n    ],\n    \"title\"      : \"PICO VS BEAR DX\",\n    \"type\"       : \"game\",\n    \"url\"        : \"https://uploads.ungrounded.net/829000/829032_picovsbeardx.swf\",\n},\n\n{\n    \"#url\"     : \"https://tomfulp.newgrounds.com/art\",\n    \"#class\"   : newgrounds.NewgroundsArtExtractor,\n    \"#pattern\" : r\"https://(art.ngfiles.com/images/\\d+|uploads.ungrounded.net/tmp/img/)\",\n    \"#count\"   : \">= 3\",\n},\n\n{\n    \"#url\"     : \"https://tomfulp.newgrounds.com/art/page/3\",\n    \"#class\"   : newgrounds.NewgroundsArtExtractor,\n},\n\n{\n    \"#url\"     : \"https://tomfulp.newgrounds.com/art?page=3\",\n    \"#class\"   : newgrounds.NewgroundsArtExtractor,\n},\n\n{\n    \"#url\"     : \"https://tomfulp.newgrounds.com/audio\",\n    \"#class\"   : newgrounds.NewgroundsAudioExtractor,\n    \"#pattern\" : r\"https://(audio\\.ngfiles\\.com/\\d+/\\d+_.+\\.mp3|uploads\\.ungrounded\\.net/.+\\.png)\",\n    \"#count\"   : \">= 10\",\n},\n\n{\n    \"#url\"     : \"https://tomfulp.newgrounds.com/audio?page=3\",\n    \"#class\"   : newgrounds.NewgroundsAudioExtractor,\n},\n\n{\n    \"#url\"     : \"https://tomfulp.newgrounds.com/movies\",\n    \"#class\"   : newgrounds.NewgroundsMoviesExtractor,\n    \"#pattern\" : r\"https://uploads.ungrounded.net(/alternate)?/\\d+/\\d+_.+\",\n    \"#range\"   : \"1-10\",\n    \"#count\"   : 10,\n},\n\n{\n    \"#url\"     : \"https://tomfulp.newgrounds.com/movies/?page=3\",\n    \"#class\"   : newgrounds.NewgroundsMoviesExtractor,\n},\n\n{\n    \"#url\"     : \"https://tomfulp.newgrounds.com/games\",\n    \"#class\"   : newgrounds.NewgroundsGamesExtractor,\n    \"#pattern\" : r\"https://(uploads.ungrounded.net(/alternate)?/(\\d+/\\d+_.+|tmp/.+)|img.ngfiles.com/)\",\n    \"#range\"   : \"1-10\",\n    \"#count\"   : 10,\n\n    \"type\": {\"archive\", \"game\"},\n},\n\n{\n    \"#url\"     : \"https://tomfulp.newgrounds.com/games?page=3\",\n    \"#class\"   : newgrounds.NewgroundsGamesExtractor,\n},\n\n{\n    \"#url\"     : \"https://tomfulp.newgrounds.com\",\n    \"#class\"   : newgrounds.NewgroundsUserExtractor,\n    \"#results\" : \"https://tomfulp.newgrounds.com/art\",\n},\n\n{\n    \"#url\"     : \"https://tomfulp.newgrounds.com\",\n    \"#class\"   : newgrounds.NewgroundsUserExtractor,\n    \"#options\" : {\"include\": \"all\"},\n    \"#results\" : (\n        \"https://tomfulp.newgrounds.com/art\",\n        \"https://tomfulp.newgrounds.com/audio\",\n        \"https://tomfulp.newgrounds.com/games\",\n        \"https://tomfulp.newgrounds.com/movies\",\n    ),\n},\n\n{\n    \"#url\"     : \"https://tomfulp.newgrounds.com/favorites/art\",\n    \"#class\"   : newgrounds.NewgroundsFavoriteExtractor,\n    \"#range\"   : \"1-10\",\n    \"#count\"   : \">= 10\",\n},\n\n{\n    \"#url\"     : \"https://tomfulp.newgrounds.com/favorites/art?page=3\",\n    \"#class\"   : newgrounds.NewgroundsFavoriteExtractor,\n},\n\n{\n    \"#url\"     : \"https://tomfulp.newgrounds.com/favorites/audio\",\n    \"#class\"   : newgrounds.NewgroundsFavoriteExtractor,\n},\n\n{\n    \"#url\"     : \"https://tomfulp.newgrounds.com/favorites/movies\",\n    \"#class\"   : newgrounds.NewgroundsFavoriteExtractor,\n},\n\n{\n    \"#url\"     : \"https://tomfulp.newgrounds.com/favorites/\",\n    \"#class\"   : newgrounds.NewgroundsFavoriteExtractor,\n},\n\n{\n    \"#url\"     : \"https://tomfulp.newgrounds.com/favorites/following\",\n    \"#class\"   : newgrounds.NewgroundsFollowingExtractor,\n    \"#pattern\" : newgrounds.NewgroundsUserExtractor.pattern,\n    \"#range\"   : \"76-125\",\n    \"#count\"   : 50,\n},\n\n{\n    \"#url\"     : \"https://tomfulp.newgrounds.com/favorites/following?page=3\",\n    \"#class\"   : newgrounds.NewgroundsFollowingExtractor,\n},\n\n\n{\n    \"#url\"     : \"https://www.newgrounds.com/search/conduct/art?terms=tree\",\n    \"#class\"   : newgrounds.NewgroundsSearchExtractor,\n    \"#pattern\" : newgrounds.NewgroundsImageExtractor.pattern,\n    \"#range\"   : \"1-10\",\n    \"#count\"   : 10,\n\n    \"search_tags\": \"tree\",\n},\n\n{\n    \"#url\"     : \"https://www.newgrounds.com/search/conduct/movies?terms=tree\",\n    \"#class\"   : newgrounds.NewgroundsSearchExtractor,\n    \"#pattern\" : r\"https://uploads.ungrounded.net(/alternate)?/\\d+/\\d+\",\n    \"#range\"   : \"1-10\",\n    \"#count\"   : 10,\n},\n\n{\n    \"#url\"     : \"https://www.newgrounds.com/search/conduct/audio?advanced=1&terms=tree+green+nature&match=tdtu&genre=5&suitabilities=e%2Cm\",\n    \"#class\"   : newgrounds.NewgroundsSearchExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/nhentai.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import nhentai\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://nhentai.net/g/147850/\",\n    \"#category\": (\"\", \"nhentai\", \"gallery\"),\n    \"#class\"   : nhentai.NhentaiGalleryExtractor,\n    \"#pattern\" : r\"https://i[1-4]\\.nhentai\\.net/galleries/867789/\\d+\\.jpg\",\n    \"#count\"   : 16,\n\n    \"title\"     : r\"re:\\[Morris\\] Amazon no Hiyaku \\| Amazon Elixir\",\n    \"title_en\"  : str,\n    \"title_ja\"  : str,\n    \"gallery_id\": 147850,\n    \"media_id\"  : 867789,\n    \"count\"     : 16,\n    \"date\"      : 1446050915,\n    \"scanlator\" : \"\",\n    \"artist\"    : [\"morris\"],\n    \"group\"     : list,\n    \"parody\"    : list,\n    \"characters\": list,\n    \"tags\"      : list,\n    \"type\"      : \"manga\",\n    \"lang\"      : \"en\",\n    \"language\"  : \"English\",\n    \"width\"     : int,\n    \"height\"    : int,\n},\n\n{\n    \"#url\"     : \"https://nhentai.net/g/538045/\",\n    \"#comment\" : \"webp (#6442)\",\n    \"#class\"   : nhentai.NhentaiGalleryExtractor,\n    \"#range\"   : \"4-7\",\n    \"#pattern\"    : (\n        r\"https://i\\d\\.nhentai.net/galleries/3115523/4\\.jpg\",\n        r\"https://i\\d\\.nhentai.net/galleries/3115523/5\\.webp\",\n        r\"https://i\\d\\.nhentai.net/galleries/3115523/6\\.webp\",\n        r\"https://i\\d\\.nhentai.net/galleries/3115523/7\\.jpg\",\n    ),\n},\n\n{\n    \"#url\"     : \"https://nhentai.net/tag/sole-female/\",\n    \"#category\": (\"\", \"nhentai\", \"tag\"),\n    \"#class\"   : nhentai.NhentaiTagExtractor,\n    \"#pattern\" : nhentai.NhentaiGalleryExtractor.pattern,\n    \"#range\"   : \"1-30\",\n    \"#count\"   : 30,\n\n    \"gallery_id\": int,\n},\n\n{\n    \"#url\"     : \"https://nhentai.net/artist/itou-life/\",\n    \"#category\": (\"\", \"nhentai\", \"tag\"),\n    \"#class\"   : nhentai.NhentaiTagExtractor,\n},\n\n{\n    \"#url\"     : \"https://nhentai.net/group/itou-life/\",\n    \"#category\": (\"\", \"nhentai\", \"tag\"),\n    \"#class\"   : nhentai.NhentaiTagExtractor,\n},\n\n{\n    \"#url\"     : \"https://nhentai.net/parody/touhou-project/\",\n    \"#category\": (\"\", \"nhentai\", \"tag\"),\n    \"#class\"   : nhentai.NhentaiTagExtractor,\n},\n\n{\n    \"#url\"     : \"https://nhentai.net/character/patchouli-knowledge/popular\",\n    \"#category\": (\"\", \"nhentai\", \"tag\"),\n    \"#class\"   : nhentai.NhentaiTagExtractor,\n},\n\n{\n    \"#url\"     : \"https://nhentai.net/category/doujinshi/popular-today\",\n    \"#category\": (\"\", \"nhentai\", \"tag\"),\n    \"#class\"   : nhentai.NhentaiTagExtractor,\n},\n\n{\n    \"#url\"     : \"https://nhentai.net/language/english/popular-week\",\n    \"#category\": (\"\", \"nhentai\", \"tag\"),\n    \"#class\"   : nhentai.NhentaiTagExtractor,\n},\n\n{\n    \"#url\"     : \"https://nhentai.net/search/?q=touhou\",\n    \"#category\": (\"\", \"nhentai\", \"search\"),\n    \"#class\"   : nhentai.NhentaiSearchExtractor,\n    \"#pattern\" : nhentai.NhentaiGalleryExtractor.pattern,\n    \"#range\"   : \"1-30\",\n    \"#count\"   : 30,\n},\n\n{\n    \"#url\"     : \"https://nhentai.net/favorites/\",\n    \"#category\": (\"\", \"nhentai\", \"favorite\"),\n    \"#class\"   : nhentai.NhentaiFavoriteExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/nijie.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import nijie\nfrom gallery_dl import exception\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://nijie.info/members.php?id=44\",\n    \"#category\": (\"Nijie\", \"nijie\", \"user\"),\n    \"#class\"   : nijie.NijieUserExtractor,\n    \"#results\" : (\n        \"https://nijie.info/members_illust.php?id=44\",\n        \"https://nijie.info/members_dojin.php?id=44\",\n    ),\n},\n\n{\n    \"#url\"     : \"https://nijie.info/members_illust.php?id=44\",\n    \"#category\": (\"Nijie\", \"nijie\", \"illustration\"),\n    \"#class\"   : nijie.NijieIllustrationExtractor,\n    \"#results\": (\n        \"https://pic.nijie.net/__s4__/d7b5d8b576a90f3688cfe6bfa6ebb678817ecb4c19f118e187e0f039c81b2e5d0b6c2edfe359d9f65b457507bf9ae807801a0ac27b9cf06ee94e1cb848c75d2f31353acc3197dff04f537d70a7cb6b8782f6f0635de3f3d522e57827.jpg\",\n        \"https://pic.nijie.net/__s4__/d7e2d9b124a95933ddc4e5baadbcb62b2a0ce7c6163dadac1d49c57e8da5d113398f3170da7835f03c897d9d6a9993d8fa40036a5209508611a305280cd684b33ca600f8160ee0d958b7c7f49972e4f4c650b8d58fff493a032d25f8.jpg\",\n    ),\n\n    \"artist_id\"  : 44,\n    \"artist_name\": \"ED\",\n    \"count\"      : 1,\n    \"date\"       : \"type:datetime\",\n    \"description\": str,\n    \"extension\"  : \"jpg\",\n    \"filename\"   : str,\n    \"image_id\"   : int,\n    \"num\"        : 0,\n    \"tags\"       : list,\n    \"title\"      : str,\n    \"url\"        : r\"re:https://pic.nijie.net/__s4__/[0-9a-f]{184}.jpg$\",\n    \"user_id\"    : 44,\n    \"user_name\"  : \"ED\",\n},\n\n{\n    \"#url\"     : \"https://nijie.info/members_illust.php?id=43\",\n    \"#category\": (\"Nijie\", \"nijie\", \"illustration\"),\n    \"#class\"   : nijie.NijieIllustrationExtractor,\n    \"#exception\": exception.NotFoundError,\n},\n\n{\n    \"#url\"     : \"https://nijie.info/members_dojin.php?id=6782\",\n    \"#category\": (\"Nijie\", \"nijie\", \"doujin\"),\n    \"#class\"   : nijie.NijieDoujinExtractor,\n    \"#count\"   : \">= 18\",\n\n    \"user_id\"  : 6782,\n    \"user_name\": \"ジョニー＠アビオン村\",\n},\n\n{\n    \"#url\"     : \"https://nijie.info/user_like_illust_view.php?id=44\",\n    \"#category\": (\"Nijie\", \"nijie\", \"favorite\"),\n    \"#class\"   : nijie.NijieFavoriteExtractor,\n    \"#count\"   : \">= 16\",\n\n    \"user_id\"  : 44,\n    \"user_name\": \"ED\",\n},\n\n{\n    \"#url\"     : \"https://nijie.info/history_nuita.php?id=728995\",\n    \"#category\": (\"Nijie\", \"nijie\", \"nuita\"),\n    \"#class\"   : nijie.NijieNuitaExtractor,\n    \"#range\"   : \"1-10\",\n    \"#count\"   : 10,\n\n    \"user_id\"  : 728995,\n    \"user_name\": \"莚\",\n},\n\n{\n    \"#url\"     : \"https://nijie.info/like_user_view.php\",\n    \"#category\": (\"Nijie\", \"nijie\", \"feed\"),\n    \"#class\"   : nijie.NijieFeedExtractor,\n    \"#range\"   : \"1-10\",\n    \"#count\"   : 10,\n},\n\n{\n    \"#url\"     : \"https://nijie.info/like_my.php\",\n    \"#category\": (\"Nijie\", \"nijie\", \"followed\"),\n    \"#class\"   : nijie.NijieFollowedExtractor,\n},\n\n{\n    \"#url\"     : \"https://nijie.info/view.php?id=70720\",\n    \"#category\": (\"Nijie\", \"nijie\", \"image\"),\n    \"#class\"   : nijie.NijieImageExtractor,\n    \"#results\"      : \"https://pic.nijie.net/__s4__/d7e2d9b124a95933ddc4e5baadbcb62b2a0ce7c6163dadac1d49c57e8da5d113398f3170da7835f03c897d9d6a9993d8fa40036a5209508611a305280cd684b33ca600f8160ee0d958b7c7f49972e4f4c650b8d58fff493a032d25f8.jpg\",\n    \"#sha1_content\" : \"d85e3ea896ed5e4da0bca2390ad310a4df716ca6\",\n\n    \"artist_id\"  : 44,\n    \"artist_name\": \"ED\",\n    \"count\"      : 1,\n    \"date\"       : \"dt:2014-01-18 19:58:21\",\n    \"description\": \"租絵にてお邪魔いたし候\\r\\n是非ともこの”おっぱい”をご高覧賜りたく馳せ参じた次第\\r\\n長文にて失礼仕る\\r\\n\\r\\nまず全景でありますが、首を右に傾けてみて頂きたい\\r\\nこの絵図は茶碗を眺めていた私が思わぬ美しさにて昇天したときのものを、筆をとり、したためたものである（トレースではない）\\r\\n筆は疾風の如く走り、半刻過ぎには私好みの”おっぱい”になっていたのである！\\r\\n次に細部をみて頂きたい\\r\\n絵図を正面から見直して頂くと、なんとはんなりと美しいお椀型をしたおっぱいであろうか　　右手から緩やかに生まれる曲線は左手に進むにつれて、穏やかな歪みを含み流れる　　これは所謂轆轤目であるが三重の紐でおっぱいをぐるぐると巻きつけた情景そのままであり、この歪みから茶碗の均整は崩れ、たぷんたぷんのおっぱいの重量感を醸し出している！\\r\\nさらに左手に進めば梅花皮（カイラギ）を孕んだ高大が現れる　今回は点線にて表現するが、その姿は乳首から母乳が噴出するが如く　或は精子をぶっかけられたが如く　白くとろっとした釉薬の凝固が素晴しい景色をつくりだしているのである！\\r\\n最後には極めつけ、すくっと螺旋を帯びながらそそり立つ兜巾（ときん）！この情景はまさしく乳首である！　　全体をふんわりと盛り上げさせる乳輪にちょこっと存在する乳頭はぺろりと舌で確かめ勃起させたくなる風情がある！\\r\\n\\r\\nこれを”おっぱい”と呼ばずなんと呼ぼうや！？\\r\\n\\r\\n興奮のあまり失礼致した\\r\\n御免\",\n    \"extension\"  : \"jpg\",\n    \"filename\"   : \"d7e2d9b124a95933ddc4e5baadbcb62b2a0ce7c6163dadac1d49c57e8da5d113398f3170da7835f03c897d9d6a9993d8fa40036a5209508611a305280cd684b33ca600f8160ee0d958b7c7f49972e4f4c650b8d58fff493a032d25f8\",\n    \"image_id\"   : 70720,\n    \"num\"        : 0,\n    \"tags\"       : [\"おっぱい\"],\n    \"title\"      : \"俺好高麗井戸茶碗　銘おっぱい\",\n    \"url\"        : \"https://pic.nijie.net/__s4__/d7e2d9b124a95933ddc4e5baadbcb62b2a0ce7c6163dadac1d49c57e8da5d113398f3170da7835f03c897d9d6a9993d8fa40036a5209508611a305280cd684b33ca600f8160ee0d958b7c7f49972e4f4c650b8d58fff493a032d25f8.jpg\",\n    \"user_id\"    : 44,\n    \"user_name\"  : \"ED\",\n},\n\n{\n    \"#url\"     : \"https://nijie.info/view.php?id=594044\",\n    \"#comment\" : \"404\",\n    \"#category\": (\"Nijie\", \"nijie\", \"image\"),\n    \"#class\"   : nijie.NijieImageExtractor,\n    \"#count\"   : 0,\n},\n\n{\n    \"#url\"     : \"https://nijie.info/view.php?id=37078\",\n    \"#comment\" : \"'view_side_dojin' thumbnails (#5049)\",\n    \"#category\": (\"Nijie\", \"nijie\", \"image\"),\n    \"#class\"   : nijie.NijieImageExtractor,\n    \"#results\" : \"https://pic.nijie.net/__s4__/d7b5d9b470fa0d368bcbb0bda4bae2797380288fb3a6f2d9aa1899530bbc0453912f2397ee9a37dbbd992b5bbde36f86061910e06961837c0ae7629006732ef8581a512a0e0b454ca050fa793340367fbef201687f1b8cdb2692898b.jpg\",\n},\n\n{\n    \"#url\"     : \"https://nijie.info/view.php?id=385585\",\n    \"#comment\" : \"video (#5707)\",\n    \"#category\": (\"Nijie\", \"nijie\", \"image\"),\n    \"#class\"   : nijie.NijieImageExtractor,\n    \"#results\" : (\n        \"https://pic.nijie.net/__s4__/d7b884ee71f90f358fcfe0bbf2e7b57ac1b72bd5df705d984997b3d47968e387b4d2e33c04faa793c9bb26f3f005eb5f875b440fcabb36f02d811203325ce520aacc1581c0ea361906bd6877031942a2a340d44ff9ba3d9c23950d44.mp4\",\n        \"https://pic.nijie.net/__s4__/d7e685e777ae0f308ecaeabda0e8e32c4a0873467846be7078716328f9b105bfc278d968db43d2707e577c63c231fb1a4999570823c460f18ee35b790f6e1c4a4c01a05a8c3260c2cd3b9f77810e5a1b2a22755f279f47cf86e75733.jpg\",\n        \"https://pic.nijie.net/__s4__/d7b38cee78f80c678acae3bef2b9e32c0fd196fdafaed57c041390ac33dd8f23b3236a48d9d41c6081ce7ca79840caa7deacc0120a0dc516210e2925c954d79c08e94bb9c0f7c572bd174336f64e6924721a2727078df5ad48e77b1b.jpg\",\n    ),\n},\n\n{\n    \"#url\"     : \"https://nijie.info/view.php?id=70724\",\n    \"#category\": (\"Nijie\", \"nijie\", \"image\"),\n    \"#class\"   : nijie.NijieImageExtractor,\n    \"#count\"   : 0,\n},\n\n{\n    \"#url\"     : \"https://nijie.info/view_popup.php?id=70720\",\n    \"#category\": (\"Nijie\", \"nijie\", \"image\"),\n    \"#class\"   : nijie.NijieImageExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/nitternet.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import nitter\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://nitter.net/supernaturepics/status/604341487988576256\",\n    \"#category\": (\"nitter\", \"nitter.net\", \"tweet\"),\n    \"#class\"   : nitter.NitterTweetExtractor,\n    \"#results\" : \"https://nitter.net/pic/orig/media%2FCGMNYZvW0AIVoom.jpg\",\n\n    \"comments\" : 18,\n    \"content\"  : \"Big Wedeene River, Canada\",\n    \"count\"    : 1,\n    \"date\"     : \"dt:2015-05-29 17:40:00\",\n    \"extension\": \"jpg\",\n    \"filename\" : \"CGMNYZvW0AIVoom\",\n    \"likes\"    : 513,\n    \"num\"      : 1,\n    \"quoted\"   : False,\n    \"quotes\"   : 0,\n    \"retweet\"  : False,\n    \"retweets\" : 455,\n    \"tweet_id\" : \"604341487988576256\",\n    \"url\"      : \"https://nitter.net/pic/orig/media%2FCGMNYZvW0AIVoom.jpg\",\n    \"author\"   : {\n        \"name\": \"supernaturepics\",\n        \"nick\": \"Nature Pictures\",\n    },\n    \"user\"     : {\n        \"name\": \"supernaturepics\",\n        \"nick\": \"Nature Pictures\",\n    },\n},\n\n{\n    \"#url\"     : \"https://nitter.net/supernaturepics\",\n    \"#category\": (\"nitter\", \"nitter.net\", \"tweets\"),\n    \"#class\"   : nitter.NitterTweetsExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/nitterspace.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import nitter\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://nitter.space/supernaturepics/status/604341487988576256\",\n    \"#category\": (\"nitter\", \"nitter.space\", \"tweet\"),\n    \"#class\"   : nitter.NitterTweetExtractor,\n},\n\n{\n    \"#url\"     : \"https://nitter.space/supernaturepics\",\n    \"#category\": (\"nitter\", \"nitter.space\", \"tweets\"),\n    \"#class\"   : nitter.NitterTweetsExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/nittertiekoetter.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import nitter\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://nitter.tiekoetter.com/supernaturepics/status/604341487988576256\",\n    \"#category\": (\"nitter\", \"nitter.tiekoetter\", \"tweet\"),\n    \"#class\"   : nitter.NitterTweetExtractor,\n},\n\n{\n    \"#url\"     : \"https://nitter.tiekoetter.com/supernaturepics\",\n    \"#category\": (\"nitter\", \"nitter.tiekoetter\", \"tweets\"),\n    \"#class\"   : nitter.NitterTweetsExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/noop.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import noop\n\n\n__tests__ = (\n{\n    \"#url\"     : \"noop\",\n    \"#class\"   : noop.NoopExtractor,\n    \"#results\" : (),\n    \"#count\"   : 0,\n},\n\n{\n    \"#url\"     : \"nop\",\n    \"#class\"   : noop.NoopExtractor,\n},\n\n{\n    \"#url\"     : \"NOOP\",\n    \"#class\"   : noop.NoopExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/nozomi.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import nozomi\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://nozomi.la/post/3649262.html\",\n    \"#category\": (\"\", \"nozomi\", \"post\"),\n    \"#class\"   : nozomi.NozomiPostExtractor,\n    \"#results\"     : \"https://w.gold-usergeneratedcontent.net/2/15/aaa9f7c632cde1e1a5baaff3fb6a6d857ec73df7fdc5cf5a358caf604bf73152.webp\",\n    \"#sha1_content\": \"6d62c4a7fea50c0a89d499603c4e7a2b4b9bffa8\",\n\n    \"artist\"   : [\"hammer (sunset beach)\"],\n    \"character\": [\"patchouli knowledge\"],\n    \"copyright\": [\"touhou\"],\n    \"dataid\"   : r\"re:aaa9f7c632cde1e1a5baaff3fb6a6d857ec73df7fdc5\",\n    \"date\"     : \"dt:2016-07-26 02:32:03\",\n    \"extension\": \"webp\",\n    \"filename\" : str,\n    \"height\"   : 768,\n    \"is_video\" : False,\n    \"postid\"   : 3649262,\n    \"tags\"     : list,\n    \"type\"     : \"jpg\",\n    \"url\"      : str,\n    \"width\"    : 1024,\n},\n\n{\n    \"#url\"     : \"https://nozomi.la/post/25588032.html\",\n    \"#comment\" : \"multiple images per post\",\n    \"#category\": (\"\", \"nozomi\", \"post\"),\n    \"#class\"   : nozomi.NozomiPostExtractor,\n    \"#results\" : (\n        \"https://w.gold-usergeneratedcontent.net/3/94/085e55e355808c03dedbe74fe44db1c07435e071952e8b925a3dfe5ec3278943.webp\",\n        \"https://w.gold-usergeneratedcontent.net/e/78/0fb5675f47e981650ab7a549cc8d90230ab0d249f35247258f6a7ceb81dd578e.webp\",\n        \"https://w.gold-usergeneratedcontent.net/3/68/f3cde060f8e9047171bebb70e62947375ef6bdc0160f2f37ea4d5d25ebfde683.webp\",\n        \"https://w.gold-usergeneratedcontent.net/e/41/888f1c268928adf77de609b50ade88a40f117b737cbaa1bdc264ccc2d074641e.webp\",\n        \"https://w.gold-usergeneratedcontent.net/6/c0/d035d2851a6e8b24473d1c575e3f3df1cbee5ad2b002758c3546439dc959bc06.webp\",\n        \"https://w.gold-usergeneratedcontent.net/b/b4/c527b2c6dde4124bdb8d7c0f061a03743aee36ccd2c8f707fd347674fc4e2b4b.webp\",\n        \"https://w.gold-usergeneratedcontent.net/9/3a/c8b6f23fc86669724373c89d436fbc33b47078a38457243d24e80e76ad7e43a9.webp\",\n    ),\n},\n\n{\n    \"#url\"     : \"https://nozomi.la/post/130309.html\",\n    \"#comment\" : \"empty 'date' (#1163)\",\n    \"#category\": (\"\", \"nozomi\", \"post\"),\n    \"#class\"   : nozomi.NozomiPostExtractor,\n\n    \"date\": None,\n},\n\n{\n    \"#url\"     : \"https://nozomi.la/post/1647.html\",\n    \"#comment\" : \"gif\",\n    \"#category\": (\"\", \"nozomi\", \"post\"),\n    \"#class\"   : nozomi.NozomiPostExtractor,\n    \"#results\"     : \"https://g.gold-usergeneratedcontent.net/a/f0/d1b06469e00d72e4f6346209c149db459d76b58a074416c260ed93cc31fa9f0a.gif\",\n    \"#sha1_content\": \"952efb78252bbc9fb56df2e8fafb68d5e6364181\",\n},\n\n{\n    \"#url\"     : \"https://nozomi.la/post/2269847.html\",\n    \"#comment\" : \"video\",\n    \"#category\": (\"\", \"nozomi\", \"post\"),\n    \"#class\"   : nozomi.NozomiPostExtractor,\n    \"#results\"     : \"https://v.gold-usergeneratedcontent.net/d/0e/ff88398862669783691b31519f2bea3a35c24b6e62e3ba2d89b4409e41c660ed.webm\",\n    \"#sha1_content\": \"57065e6c16da7b1c7098a63b36fb0c6c6f1b9bca\",\n},\n\n{\n    \"#url\"     : \"https://nozomi.la/\",\n    \"#category\": (\"\", \"nozomi\", \"index\"),\n    \"#class\"   : nozomi.NozomiIndexExtractor,\n},\n\n{\n    \"#url\"     : \"https://nozomi.la/index-2.html\",\n    \"#category\": (\"\", \"nozomi\", \"index\"),\n    \"#class\"   : nozomi.NozomiIndexExtractor,\n},\n\n{\n    \"#url\"     : \"https://nozomi.la/index-Popular-33.html\",\n    \"#category\": (\"\", \"nozomi\", \"index\"),\n    \"#class\"   : nozomi.NozomiIndexExtractor,\n},\n\n{\n    \"#url\"     : \"https://nozomi.la/tag/3:1_aspect_ratio-1.html\",\n    \"#category\": (\"\", \"nozomi\", \"tag\"),\n    \"#class\"   : nozomi.NozomiTagExtractor,\n    \"#pattern\" : r\"^https://[wgv]\\.gold-usergeneratedcontent\\.net/\\w/\\w\\w/\\w+\\.\\w+$\",\n    \"#range\"   : \"1-25\",\n    \"#count\"   : \">= 25\",\n},\n\n{\n    \"#url\"     : \"https://nozomi.la/search.html?q=hibiscus%203:4_ratio#1\",\n    \"#category\": (\"\", \"nozomi\", \"search\"),\n    \"#class\"   : nozomi.NozomiSearchExtractor,\n    \"#count\"   : range(5, 10),\n},\n\n{\n    \"#url\"     : \"https://nozomi.la/search.html?q=musume_janakute_mama_ga_sukinano!?\",\n    \"#comment\" : \"404 error due to unquoted '?' in search tag (#8328)\",\n    \"#class\"   : nozomi.NozomiSearchExtractor,\n    \"#range\"   : \"1-3\",\n    \"#count\"   : 3,\n\n    \"search_tags\": [\"musume_janakute_mama_ga_sukinano!?\"],\n},\n\n)\n"
  },
  {
    "path": "test/results/nozrip.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import shimmie2\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://noz.rip/booru/post/view/29\",\n    \"#category\": (\"shimmie2\", \"nozrip\", \"post\"),\n    \"#class\"   : shimmie2.Shimmie2PostExtractor,\n    \"#results\" : \"https://noz.rip/booru/_images/f33d9e0da3ba476f67ef18911e05876b/29%20-%20inkling%20series%3Asplatoon%20unknown_artist%20wat.png\",\n\n    \"id\"       : 29,\n    \"filename\" : \"29 - inkling series:splatoon unknown_artist wat\",\n    \"extension\": \"png\",\n    \"file_url\" : \"https://noz.rip/booru/_images/f33d9e0da3ba476f67ef18911e05876b/29%20-%20inkling%20series%3Asplatoon%20unknown_artist%20wat.png\",\n    \"width\"    : 798,\n    \"height\"   : 598,\n    \"md5\"      : \"f33d9e0da3ba476f67ef18911e05876b\",\n    \"size\"     : 0,\n    \"tags\"     : \"inkling series:splatoon unknown_artist wat\",\n},\n\n{\n    \"#url\"     : \"https://noz.rip/booru/post/list/inkling/1\",\n    \"#category\": (\"shimmie2\", \"nozrip\", \"tag\"),\n    \"#class\"   : shimmie2.Shimmie2TagExtractor,\n    \"#pattern\" : r\"https://noz\\.rip/booru/_images/[0-9a-f]{32}/\\d+.+\\.\\w+\",\n    \"#count\"   : range(130, 150),\n\n    \"id\"         : int,\n    \"filename\"   : str,\n    \"extension\"  : {\"jpeg\", \"jpg\", \"png\"},\n    \"file_url\"   : str,\n    \"width\"      : int,\n    \"height\"     : int,\n    \"size\"       : int,\n    \"md5\"        : \"len:str:32\",\n    \"search_tags\": \"inkling\",\n    \"tags\"       : str,\n},\n\n)\n"
  },
  {
    "path": "test/results/nsfwalbum.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import nsfwalbum\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://nsfwalbum.com/album/401611\",\n    \"#category\": (\"\", \"nsfwalbum\", \"album\"),\n    \"#class\"   : nsfwalbum.NsfwalbumAlbumExtractor,\n    \"#range\"        : \"1-5\",\n    \"#results\"      : (\n        \"https://img70.imgspice.com/i/05457/mio2bu5xbrxe.jpg\",\n        \"https://img70.imgspice.com/i/05457/zgpxa8kr4h1d.jpg\",\n        \"https://img70.imgspice.com/i/05457/3379nxsm9lx8.jpg\",\n        \"https://img70.imgspice.com/i/05457/pncrkhspuoa3.jpg\",\n        \"https://img70.imgspice.com/i/05457/128b2odt216a.jpg\",\n    ),\n\n    \"album_id\" : 401611,\n    \"extension\": \"jpg\",\n    \"filename\" : str,\n    \"height\"   : range(1365, 2048),\n    \"id\"       : int,\n    \"models\"   : [],\n    \"num\"      : range(1, 5),\n    \"studio\"   : \"Met-Art\",\n    \"title\"    : \"Met-Art - Katherine A - Difuza 25.05.2014 (134 photos)(4368 X 2912)\",\n    \"width\"    : range(1365, 2048),\n},\n\n)\n"
  },
  {
    "path": "test/results/nudostar.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import nudostar\n\n\n__tests__ = (\n{\n    \"#url\"    : \"https://nudostar.tv/models/eva-joys/\",\n    \"#class\"  : nudostar.NudostarModelExtractor,\n    \"#pattern\": r\"https://nudostar.tv/contents/e/v/eva-joys/1000/eva-joys_\\d{4}\\.jpg\",\n    \"#count\"  : range(50, 80),\n\n    \"count\"      : 54,\n    \"num\"        : range(1, 54),\n    \"extension\"  : \"jpg\",\n    \"filename\"   : r\"re:eva-joys_00\\d\\d\",\n    \"gallery_id\" : \"eva-joys\",\n    \"model\"      : \"eva_joy\",\n    \"model_slug\" : \"eva-joys\",\n    \"model_names\": [\n        \"eva_joy\",\n        \"eva_joys\",\n    ],\n},\n\n{\n    \"#url\"    : \"https://nl.nudostar.tv/models/eva-joys/\",\n    \"#class\"  : nudostar.NudostarModelExtractor,\n},\n\n{\n    \"#url\"    : \"https://nudostar.tv/models/thebigtittiecommittee/148/\",\n    \"#class\"  : nudostar.NudostarImageExtractor,\n    \"#results\": \"https://nudostar.tv/contents/t/h/thebigtittiecommittee/1000/thebigtittiecommittee_0148.jpg\",\n\n    \"extension\"  : \"jpg\",\n    \"filename\"   : \"thebigtittiecommittee_0148\",\n    \"gallery_id\" : \"thebigtittiecommittee\",\n    \"num\"        : 148,\n    \"url\"        : \"https://nudostar.tv/contents/t/h/thebigtittiecommittee/1000/thebigtittiecommittee_0148.jpg\",\n    \"model\"      : \"Hari Beavis\",\n    \"model_slug\" : \"thebigtittiecommittee\",\n    \"model_names\": [\n        \"Hari Beavis\",\n        \"Thebigtittiecommittee\",\n    ],\n},\n\n)\n"
  },
  {
    "path": "test/results/nudostarforum.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import xenforo\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://nudostar.com/forum/threads/tate-mcrae.109528/post-1919100\",\n    \"#category\": (\"xenforo\", \"nudostarforum\", \"post\"),\n    \"#class\"   : xenforo.XenforoPostExtractor,\n    \"#auth\"    : True,\n    \"#results\" : (\n        \"https://imagetwist.com/bvolb8129fnm/v1.jpg\",\n        \"https://imagetwist.com/9pddder15iow/v2.jpg\",\n        \"https://imagetwist.com/zzonmk0gqqdv/v3.jpg\",\n    ),\n\n    \"count\"       : 3,\n    \"type\"        : \"external\",\n    \"post\"        : {\n        \"attachments\": \"\",\n        \"author\"     : \"djokica\",\n        \"author_id\"  : \"3471965\",\n        \"author_url\" : \"/forum/members/djokica.3471965/\",\n        \"author_slug\": \"djokica\",\n        \"content\"    : \"\"\"<div class=\"bbWrapper\"><a href=\"https://imagetwist.com/bvolb8129fnm/v1.jpg\" target=\"_blank\" class=\"link link--external\" rel=\"nofollow noopener\"><img src=\"https://s10.imagetwist.com/th/73048/bvolb8129fnm.jpg\" data-url=\"https://s10.imagetwist.com/th/73048/bvolb8129fnm.jpg\" class=\"bbImage \" style=\"\" alt=\"\" title=\"\" /></a> <a href=\"https://imagetwist.com/9pddder15iow/v2.jpg\" target=\"_blank\" class=\"link link--external\" rel=\"nofollow noopener\"><img src=\"https://s10.imagetwist.com/th/73048/9pddder15iow.jpg\" data-url=\"https://s10.imagetwist.com/th/73048/9pddder15iow.jpg\" class=\"bbImage \" style=\"\" alt=\"\" title=\"\" /></a> <a href=\"https://imagetwist.com/zzonmk0gqqdv/v3.jpg\" target=\"_blank\" class=\"link link--external\" rel=\"nofollow noopener\"><img src=\"https://s10.imagetwist.com/th/73048/zzonmk0gqqdv.jpg\" data-url=\"https://s10.imagetwist.com/th/73048/zzonmk0gqqdv.jpg\" class=\"bbImage \" style=\"\" alt=\"\" title=\"\" /></a></div>\"\"\",\n        \"count\"      : 3,\n        \"date\"       : \"dt:2025-10-31 21:26:42\",\n        \"id\"         : \"1919100\",\n    },\n    \"thread\"      : {\n        \"author\"    : \"djokica\",\n        \"author_id\" : \"\",\n        \"author_url\": \"\",\n        \"date\"      : \"dt:2024-06-05 00:00:00\",\n        \"id\"        : \"109528\",\n        \"posts\"     : range(20, 80),\n        \"section\"   : \"Celebrity\",\n        \"tags\"      : (),\n        \"title\"     : \"Tate Mcrae\",\n        \"url\"       : \"https://nudostar.com/forum/threads/tate-mcrae.109528/\",\n        \"views\"     : -1,\n    },\n},\n\n{\n    \"#url\"     : \"https://nudostar.com/forum/threads/name.12345/post-67890\",\n    \"#category\": (\"xenforo\", \"nudostarforum\", \"post\"),\n    \"#class\"   : xenforo.XenforoPostExtractor,\n},\n\n{\n    \"#url\"     : \"https://nudostar.com/forum/threads/aspen-rae.106714/\",\n    \"#category\": (\"xenforo\", \"nudostarforum\", \"thread\"),\n    \"#class\"   : xenforo.XenforoThreadExtractor,\n},\n\n{\n    \"#url\"     : \"https://nudostar.com/forum/threads/aspen-rae.106714/page-2\",\n    \"#category\": (\"xenforo\", \"nudostarforum\", \"thread\"),\n    \"#class\"   : xenforo.XenforoThreadExtractor,\n},\n\n{\n    \"#url\"     : \"https://nudostar.com/forum/forums/celebrity.14/\",\n    \"#category\": (\"xenforo\", \"nudostarforum\", \"forum\"),\n    \"#class\"   : xenforo.XenforoForumExtractor,\n    \"#pattern\" : xenforo.XenforoThreadExtractor.pattern,\n    \"#range\"   : \"1-100\",\n    \"#count\"   : 100,\n},\n\n)\n"
  },
  {
    "path": "test/results/ohpolly.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import shopify\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://www.ohpolly.com/collections/dresses-mini-dresses\",\n    \"#category\": (\"shopify\", \"ohpolly\", \"collection\"),\n    \"#class\"   : shopify.ShopifyCollectionExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.ohpolly.com/products/edonia-ruched-triangle-cup-a-line-mini-dress-brown\",\n    \"#category\": (\"shopify\", \"ohpolly\", \"product\"),\n    \"#class\"   : shopify.ShopifyProductExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/okporn.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import okporn\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://ok.porn/albums/66141/\",\n    \"#class\"   : okporn.OkpornGalleryExtractor,\n    \"#pattern\" : r\"https://pics\\-storage\\-1\\.ok\\.porn/contents/albums/main/1920x1080/66000/66141/\\d+\\.jpg\",\n    \"#count\"   : 100,\n\n    \"count\"      : 100,\n    \"num\"        : range(1, 100),\n    \"description\": \"When Tommy Wood catches his stepmom Slimthick Vic parading her juicy ass in a thong, he takes a few pictures to show his dad. Vic begs him not to tell him but Tommy says the only way he’ll delete the pictures is if she gives him some naughty TLC.\",\n    \"extension\"  : \"jpg\",\n    \"filename\"   : r\"re:^\\d+$\",\n    \"gallery_id\" : 66141,\n    \"title\"      : \"Stepmom Likes To Flaunt It\",\n    \"tags\"       : [\n        \"milf\",\n        \"big tits\",\n        \"blowjob\",\n        \"oral\",\n        \"blonde\",\n        \"doggystyle\",\n        \"cowgirl\",\n        \"big cock\",\n        \"big ass\",\n        \"missionary\",\n        \"natural tits\",\n        \"side fuck\",\n        \"short hair\",\n        \"long legs\",\n        \"curvy\",\n        \"straight hair\",\n        \"perfect body\",\n        \"bedroom\",\n        \"Slimthick Vic\",\n    ],\n},\n\n)\n"
  },
  {
    "path": "test/results/omgmiamiswimwear.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import shopify\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://www.omgmiamiswimwear.com/collections/fajas\",\n    \"#category\": (\"shopify\", \"omgmiamiswimwear\", \"collection\"),\n    \"#class\"   : shopify.ShopifyCollectionExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.omgmiamiswimwear.com/products/snatch-me-waist-belt\",\n    \"#category\": (\"shopify\", \"omgmiamiswimwear\", \"product\"),\n    \"#class\"   : shopify.ShopifyProductExtractor,\n    \"#pattern\" : r\"https://cdn\\.shopify\\.com/s/files/1/1819/6171/\",\n    \"#count\"   : 3,\n},\n\n)\n"
  },
  {
    "path": "test/results/paheal.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import paheal\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://rule34.paheal.net/post/list/Ayane_Suzuki/1\",\n    \"#category\": (\"shimmie2\", \"paheal\", \"tag\"),\n    \"#class\"   : paheal.PahealTagExtractor,\n    \"#pattern\" : r\"https://[^.]+\\.paheal\\.net/_images/\\w+/\\d+%20-%20|https://r34i\\.paheal-cdn\\.net/[0-9a-f]{2}/[0-9a-f]{2}/[0-9a-f]{32}$\",\n    \"#count\"   : range(70, 200),\n\n    \"date\"     : \"type:datetime\",\n    \"extension\": {\"jpg\", \"png\"},\n    \"filename\" : r\"re:\\d+ - \\w+\",\n    \"duration\" : float,\n    \"height\"   : int,\n    \"id\"       : int,\n    \"md5\"      : \"hash:md5\",\n    \"search_tags\": \"Ayane_Suzuki\",\n    \"size\"     : int,\n    \"tags\"     : str,\n    \"width\"    : int,\n},\n\n{\n    \"#url\"     : \"https://rule34.paheal.net/post/list/Ayane_Suzuki/1\",\n    \"#category\": (\"shimmie2\", \"paheal\", \"tag\"),\n    \"#class\"   : paheal.PahealTagExtractor,\n    \"#options\" : {\"metadata\": True},\n    \"#range\"   : \"1\",\n\n    \"date\"       : \"dt:2018-01-07 07:04:05\",\n    \"duration\"   : 0.0,\n    \"extension\"  : \"jpg\",\n    \"filename\"   : \"2446128 - Ayane_Suzuki Idolmaster idolmaster_dearly_stars Zanzi\",\n    \"height\"     : 768,\n    \"id\"         : 2446128,\n    \"md5\"        : \"b0ceda9d860df1d15b60293a7eb465c1\",\n    \"search_tags\": \"Ayane_Suzuki\",\n    \"size\"       : 204800,\n    \"source\"     : \"https://www.pixiv.net/member_illust.php?mode=medium&illust_id=19957280\",\n    \"tags\"       : \"Ayane_Suzuki Idolmaster idolmaster_dearly_stars Zanzi\",\n    \"uploader\"   : \"XXXname\",\n    \"width\"      : 1024,\n},\n\n{\n    \"#url\"     : \"https://rule34.paheal.net/post/list/Ranma_1%2F2/1\",\n    \"#comment\" : \"percent-encoded character in tag (#7642)\",\n    \"#category\": (\"shimmie2\", \"paheal\", \"tag\"),\n    \"#class\"   : paheal.PahealTagExtractor,\n    \"#range\"   : \"1-200\",\n    \"#count\"   : 200,\n},\n\n{\n    \"#url\"     : \"https://rule34.paheal.net/post/list/non_existant_tag/1\",\n    \"#category\": (\"shimmie2\", \"paheal\", \"tag\"),\n    \"#class\"   : paheal.PahealTagExtractor,\n    \"#count\"   : 0,\n},\n\n{\n    \"#url\"     : \"https://rule34.paheal.net/post/list/tien vira/1\",\n    \"#comment\" : \"tag with only 1 result (#9186)\",\n    \"#category\": (\"shimmie2\", \"paheal\", \"tag\"),\n    \"#class\"   : paheal.PahealTagExtractor,\n    \"#results\" : \"https://r34i.paheal-cdn.net/fc/3b/fc3b8ff79b0332907edda690725036e8\",\n\n    \"date\"       : \"dt:2025-06-06 10:09:13\",\n    \"extension\"  : \"png\",\n    \"width\"      : 7972,\n    \"height\"     : 5154,\n    \"size\"       : 25165824,\n    \"id\"         : 6909357,\n    \"md5\"        : \"fc3b8ff79b0332907edda690725036e8\",\n    \"search_tags\": \"tien vira\",\n    \"source\"     : \"https://www.newgrounds.com/art/view/majinsfw/hypno-cruise-ship\",\n    \"uploader\"   : \"Deskjet23\",\n},\n\n{\n    \"#url\"     : \"https://rule34.paheal.net/post/view/481609\",\n    \"#category\": (\"shimmie2\", \"paheal\", \"post\"),\n    \"#class\"   : paheal.PahealPostExtractor,\n    \"#results\"     : \"https://r34i.paheal-cdn.net/bb/dc/bbdc1c33410c2cdce7556c7990be26b7\",\n    \"#sha1_content\": \"7b924bcf150b352ac75c9d281d061e174c851a11\",\n\n    \"date\"     : \"dt:2010-06-17 15:40:23\",\n    \"extension\": \"jpg\",\n    \"file_url\" : \"https://r34i.paheal-cdn.net/bb/dc/bbdc1c33410c2cdce7556c7990be26b7\",\n    \"filename\" : \"481609 - Ayumu_Kasuga Azumanga_Daioh inanimate Vuvuzela\",\n    \"height\"   : 660,\n    \"id\"       : 481609,\n    \"md5\"      : \"bbdc1c33410c2cdce7556c7990be26b7\",\n    \"size\"     : 157696,\n    \"source\"   : \"\",\n    \"tags\"     : \"Ayumu_Kasuga Azumanga_Daioh inanimate Vuvuzela\",\n    \"uploader\" : \"CaptainButtface\",\n    \"width\"    : 614,\n},\n\n{\n    \"#url\"     : \"https://rule34.paheal.net/post/view/488534\",\n    \"#category\": (\"shimmie2\", \"paheal\", \"post\"),\n    \"#class\"   : paheal.PahealPostExtractor,\n\n    \"date\"    : \"dt:2010-06-25 13:51:17\",\n    \"height\"  : 800,\n    \"md5\"     : \"b39edfe455a0381110c710d6ed2ef57d\",\n    \"size\"    : 758784,\n    \"source\"  : \"http://www.furaffinity.net/view/4057821/\",\n    \"tags\"    : \"inanimate thelost-dragon Vuvuzela\",\n    \"uploader\": \"leacheate_soup\",\n    \"width\"   : 1200,\n},\n\n{\n    \"#url\"     : \"https://rule34.paheal.net/post/view/3864982\",\n    \"#comment\" : \"video\",\n    \"#category\": (\"shimmie2\", \"paheal\", \"post\"),\n    \"#class\"   : paheal.PahealPostExtractor,\n    \"#results\" : \"https://r34i.paheal-cdn.net/76/29/7629fc0ff77e32637dde5bf4f992b2cb\",\n\n    \"date\"     : \"dt:2020-09-06 01:59:03\",\n    \"duration\" : 30.0,\n    \"extension\": \"webm\",\n    \"height\"   : 2500,\n    \"id\"       : 3864982,\n    \"md5\"      : \"7629fc0ff77e32637dde5bf4f992b2cb\",\n    \"size\"     : 18874368,\n    \"source\"   : \"https://twitter.com/VG_Worklog/status/1302407696294055936\",\n    \"tags\"     : \"animated Metal_Gear Metal_Gear_Solid_V Quiet Vg_erotica webm\",\n    \"uploader\" : \"justausername\",\n    \"width\"    : 1768,\n},\n\n{\n    \"#url\"     : \"https://rule34.paheal.net/post/view/7\",\n    \"#category\": (\"shimmie2\", \"paheal\", \"post\"),\n    \"#class\"   : paheal.PahealPostExtractor,\n    \"#count\"   : 0,\n},\n\n)\n"
  },
  {
    "path": "test/results/palanq.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import foolfuuka\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://archive.palanq.win/c/thread/4209598/\",\n    \"#category\": (\"foolfuuka\", \"palanq\", \"thread\"),\n    \"#class\"   : foolfuuka.FoolfuukaThreadExtractor,\n    \"#sha1_url\": \"1f9b5570d228f1f2991c827a6631030bc0e5933c\",\n},\n\n{\n    \"#url\"     : \"https://archive.palanq.win/c/\",\n    \"#category\": (\"foolfuuka\", \"palanq\", \"board\"),\n    \"#class\"   : foolfuuka.FoolfuukaBoardExtractor,\n},\n\n{\n    \"#url\"     : \"https://archive.palanq.win/_/search/text/test/\",\n    \"#category\": (\"foolfuuka\", \"palanq\", \"search\"),\n    \"#class\"   : foolfuuka.FoolfuukaSearchExtractor,\n},\n\n{\n    \"#url\"     : \"https://archive.palanq.win/c/gallery\",\n    \"#category\": (\"foolfuuka\", \"palanq\", \"gallery\"),\n    \"#class\"   : foolfuuka.FoolfuukaGalleryExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/patreon.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import patreon\nfrom gallery_dl import exception\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://www.patreon.com/koveliana\",\n    \"#class\"   : patreon.PatreonCreatorExtractor,\n    \"#range\"   : \"1-15\",\n    \"#count\"   : 15,\n\n    \"attachments\"  : list,\n    \"campaign\"     : dict,\n    \"comment_count\": int,\n    \"content\"      : str,\n    \"!content_json_string\": str,\n    \"creator\"      : dict,\n    \"date\"         : \"type:datetime\",\n    \"id\"           : int,\n    \"images\"       : list,\n    \"like_count\"   : int,\n    \"post_type\"    : str,\n    \"published_at\" : str,\n    \"title\"        : str,\n},\n\n{\n    \"#url\"     : \"https://www.patreon.com/koveliana/posts?filters[month]=2020-3\",\n    \"#class\"   : patreon.PatreonCreatorExtractor,\n    \"#count\"   : 1,\n\n    \"date\": \"dt:2020-03-30 21:21:44\",\n},\n\n{\n    \"#url\"     : \"https://www.patreon.com/kovelianot\",\n    \"#class\"   : patreon.PatreonCreatorExtractor,\n    \"#exception\": exception.NotFoundError,\n},\n\n{\n    \"#url\"     : \"https://www.patreon.com/cw/anythingelse\",\n    \"#comment\" : \"Next.js 13 - /cw/ URL\",\n    \"#class\"   : patreon.PatreonCreatorExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.patreon.com/c/koveliana\",\n    \"#class\"   : patreon.PatreonCreatorExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.patreon.com/user?u=2931440\",\n    \"#class\"   : patreon.PatreonCreatorExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.patreon.com/user/posts/?u=2931440\",\n    \"#class\"   : patreon.PatreonCreatorExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.patreon.com/user?c=369707\",\n    \"#class\"   : patreon.PatreonCreatorExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.patreon.com/profile/creators?u=2931440\",\n    \"#class\"   : patreon.PatreonCreatorExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.patreon.com/profile/creators?c=369707\",\n    \"#class\"   : patreon.PatreonCreatorExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.patreon.com/id:369707\",\n    \"#class\"   : patreon.PatreonCreatorExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.patreon.com/create\",\n    \"#class\"   : patreon.PatreonCreatorExtractor,\n    \"#fail\"    : True,\n},\n\n{\n    \"#url\"     : \"https://www.patreon.com/login\",\n    \"#class\"   : patreon.PatreonCreatorExtractor,\n    \"#fail\"    : True,\n},\n\n{\n    \"#url\"     : \"https://www.patreon.com/search?q=foobar\",\n    \"#class\"   : patreon.PatreonCreatorExtractor,\n    \"#fail\"    : True,\n},\n\n{\n    \"#url\"     : \"https://www.patreon.com/messages/?mode=user&tab=chats\",\n    \"#class\"   : patreon.PatreonCreatorExtractor,\n    \"#fail\"    : True,\n},\n\n{\n    \"#url\"     : \"https://www.patreon.com/home\",\n    \"#class\"   : patreon.PatreonUserExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.patreon.com/posts/precious-metal-23563293\",\n    \"#comment\" : \"postfile + attachments\",\n    \"#class\"   : patreon.PatreonPostExtractor,\n    \"#count\"   : 4,\n\n    \"content\"  : \"<p>+3 outfits ^_^</p>\",\n    \"file\"     : {dict, None},\n},\n\n{\n    \"#url\"     : \"https://www.patreon.com/posts/free-mari-8s-113049301\",\n    \"#comment\" : \"'This page has been removed' - postfile + attachments_media (#6241)\",\n    \"#class\"   : patreon.PatreonPostExtractor,\n    \"#exception\": exception.NotFoundError,\n},\n\n{\n    \"#url\"     : \"https://www.patreon.com/posts/56127163\",\n    \"#comment\" : \"account suspended\",\n    \"#class\"   : patreon.PatreonPostExtractor,\n    \"#count\"   : 0,\n},\n\n{\n    \"#url\"     : \"https://www.patreon.com/posts/free-post-12497641\",\n    \"#comment\" : \"tags (#1539)\",\n    \"#class\"   : patreon.PatreonPostExtractor,\n    \"#pattern\" : r\"https://c10.patreonusercontent.com/4/patreon-media/p/post/12497641/3d99f5f5b635428ca237fedf0f223f1a/eyJhIjoxLCJwIjoxfQ%3D%3D/1\\.JPG\\?.+\",\n\n    \"content\"  : \"<p>AWMedia brought his camera to our night out in LA </p><p><br/></p><p>took a few pics ✨</p><p>patrons comment below why you love pledging to my page! </p>\",\n    \"tags\"     : [\"AWMedia\"],\n    \"campaign\" : {\n        \"avatar_photo_image_urls\": dict,\n        \"avatar_photo_url\": \"https://c10.patreonusercontent.com/4/patreon-media/p/campaign/350434/cadc16f03fa1460f9185505b0a858c1b/eyJ3Ijo2MjB9/1.png?token-hash=tpUv_bM0-mEuUSizstb00UrVA-btPS5RyGSCWRx24oc%3D\",\n        \"creation_name\": \"creating Art Photography/Videography\",\n        \"currency\": \"USD\",\n        \"current_user_can_be_free_member\": True,\n        \"current_user_is_free_member\": False,\n        \"is_free_membership_paused\": False,\n        \"is_monthly\": True,\n        \"name\": \"ReedandWeep\",\n        \"offers_free_membership\": True,\n        \"offers_paid_membership\": True,\n        \"pay_per_name\": \"month\",\n        \"pledge_url\": \"https://www.patreon.com/checkout/Reedandweep\",\n        \"primary_theme_color\": None,\n        \"show_audio_post_download_links\": True,\n        \"show_free_membership_cta\": False,\n        \"url\": \"https://www.patreon.com/Reedandweep\",\n    },\n\n},\n\n{\n    \"#url\"     : \"https://www.patreon.com/posts/free-post-12497641\",\n    \"#comment\" : \"custom image format (#6569)\",\n    \"#class\"   : patreon.PatreonPostExtractor,\n    \"#options\" : {\"format-images\": \"thumbnail\"},\n    \"#pattern\"     : r\"https://c10.patreonusercontent.com/4/patreon-media/p/post/12497641/3d99f5f5b635428ca237fedf0f223f1a/eyJoIjozNjAsInciOjM2MH0%3D/1\\.JPG\\?.+\",\n    \"#sha1_content\": (\n        \"2967d7567d55debdfa59cfd27cd5edf89d9c3503\",\n        \"190e249295eeca1a8ffbcf1aece788b4f69bbb64\",\n    ),\n},\n\n{\n    \"#url\"     : \"https://www.patreon.com/posts/m3u8-94714289\",\n    \"#class\"   : patreon.PatreonPostExtractor,\n    \"#pattern\" : [\n        r\"https://c10\\.patreonusercontent\\.com/4/patreon-media/p/post/94714289/be3d8eb994ae44eca4baffcdc6dd25fc/eyJhIjoxLCJwIjoxfQ%3D%3D/1\\.png\",\n        r\"ytdl:https://stream\\.mux\\.com/NLrxTLdxyGStpOgapJAtB8uPGAaokEcj8YovML00y2DY\\.m3u8\\?token=ey\",\n    ],\n\n    \"content\": \"<p>The year 12,023 of the Human Era is nearing its end – and what a year it has been!  <br/>Thank you for being on this journey with us and sharing our passion for the universe and the world we live in. <br/>We hope you have a wonderful end of the year and an amazing 12,024. <br/>Much love from all of us at kurzgesagt ❤</p>\",\n},\n\n{\n    \"#url\"     : \"https://www.patreon.com/posts/closed-color-139862716\",\n    \"#class\"   : patreon.PatreonPostExtractor,\n\n    \"content\"  : \"\"\"<p><em>All slots are now filled — thank you so much for your support!</em> 🥰🙏✨</p><p></p><p>Hello everyone!</p><p>Thank you for waiting 🥰 Commissions are now open!</p><p>This time, I’m offering <strong>color sketch commissions</strong>:</p><ul><li><p>One character only</p></li><li><p>No background</p></li><li><p>A bit rougher finish compared to my usual works</p></li><li><p><strong>Pose cannot be revised</strong> (I will provide several pose options, and you may choose from them)</p></li></ul><p></p><p>💲 <strong>Pricing</strong></p><ul><li><p>Thigh-up: $250</p></li><li><p>Full-body: $300</p></li></ul><p></p><p>⏰ <strong>Slots</strong></p><p><s>Limited to 3 slots only (first come, first served)</s></p><p></p><p>📩 <strong>How to Apply</strong></p><p>If you would like to participate, please <strong>switch to the “Color Sketch Commission (Half-body)” plan</strong>.</p><ul><li><p>Half-body: $250</p></li><li><p>For Full-body, please add $50 (for a total of $300) when you join.</p></li></ul><p>After confirming your subscription, please <strong>contact me via Patreon message or Discord DM</strong>.<br/>After that, I will deliver the sketches and the finished illustration <strong>via Discord DM or Dropbox</strong>.</p><p>👉 <strong>Apply here:</strong><br/><a href=\"https://www.patreon.com/checkout/PI_Art314?rid=26956166\" target=\"_blank\"><s>https://www.patreon.com/checkout/PI_Art314?rid=26956166</s></a></p><p></p><p>💡 <strong>Note</strong></p><p>Once your commission is confirmed, you may <strong>cancel the plan afterwards</strong>.<br/>(There is no need to keep paying every month, so please don’t worry.)</p><p>Sexy themes are welcome within the following limits:</p><ul><li><p>❌ No depiction of nipples</p></li><li><p>❌ No genitals or sexual acts</p></li></ul><p></p><p>Other Notes</p><ul><li><p>Commercial use is prohibited.</p></li><li><p>Personal use, such as posting on social media, is permitted.</p></li><li><p>Illustrations created for commissions may be shared on social media and other platforms.</p></li><li><p>Commissioned illustrations may be further modified to create other works.</p></li></ul><p></p><p>I look forward to your requests! 🖌🥰🎨✨</p>\"\"\",\n},\n\n{\n    \"#url\"     : \"https://www.patreon.com/posts/143480584\",\n    \"#class\"   : patreon.PatreonPostExtractor,\n\n    \"content\"  : \"<h3>I’m late again, everyone — here are the WIPs for the <strong>second</strong> and <strong>third</strong> October rewards:<br/><strong>Ino Yamanaka</strong> and <strong>Kyoka Jiro</strong> (WIP)</h3><blockquote><p><em>As you can see, there isn’t much time left in November, and I’ll also be traveling with my family for a short trip at the end of the month. Because of that, the November rewards will be paused again, and I will pause the system’s billing for December.</em></p></blockquote><p><u>If you joined in </u><strong><u>November</u></strong><u>, once I finish all of the October rewards, I will upload them to the shop.</u><br/><u>(For a short period, the shop price will match the Patreon tier.)</u></p><p></p><p>I know my time management is terrible — thank you so much to everyone who continues to support me. 💖</p><p></p><p>--------------------------------------------------------------------------------</p><p></p><p>各位我來遲了，這是十月的第二個獎勵與第三個獎勵的WIP 山中井野和耳郎響香(wip)</p><p> *如各位所見，十一月所剩時間不多加上十一月底我要跟家人出去旅行一小段時間，因此十一月獎勵又將暫停一次，我會暫停系統十二月的收款。 如果你是十一月才加入，等我把十月獎勵都完成之後會一起上架至到商店。(上架短時間會與patreon價格相同) </p><p></p><p>我知道我的時間控管很差，謝謝每一位願意支持我的人。</p>\",\n},\n\n{\n    \"#url\"     : \"https://www.patreon.com/posts/not-found-123\",\n    \"#class\"   : patreon.PatreonPostExtractor,\n    \"#exception\": exception.NotFoundError,\n},\n\n{\n    \"#url\"     : \"https://www.patreon.com/collection/15764\",\n    \"#class\"   : patreon.PatreonCollectionExtractor,\n    \"#range\"   : \"1-3\",\n    \"#pattern\" : (\n        r\"https://c10.patreonusercontent.com/4/patreon-media/p/post/32798362/957d49296e4f48ef80718d0de98c15a4/eyJhIjoxLCJwIjoxfQ%3D%3D/2.jpg\\?token-hash=.+\",\n        r\"ytdl:https://stream.mux.com/h4DYqFU901qkkAwYmRWZPraVk5DvTJTlcSdhGV00006KBE.m3u8\\?token=ey.+\",\n        r\"https://c10.patreonusercontent.com/4/patreon-media/p/post/32798374/357b0133a476427a99169b4400ee03d4/eyJhIjoxLCJwIjoxfQ%3D%3D/2.jpg\\?token-hash=.+\",\n    ),\n\n    \"campaign\"        : {\n        \"currency\"        : \"USD\",\n        \"is_monthly\"      : True,\n        \"is_nsfw\"         : False,\n        \"name\"            : \"YaBoyRoshi\",\n    },\n    \"collection\"      : {\n        \"created_at\"     : \"2023-08-31T14:10:41.000+00:00\",\n        \"date\"           : \"dt:2023-08-31 14:10:41\",\n        \"default_layout\" : \"grid\",\n        \"description\"    : \"\",\n        \"edited_at\"      : \"2025-07-16T22:58:10.834+00:00\",\n        \"id\"             : \"15764\",\n        \"num_draft_posts\": 0,\n        \"num_posts\"      : 207,\n        \"num_posts_visible_for_creation\": 207,\n        \"num_scheduled_posts\": 8,\n        \"post_sort_type\" : \"custom\",\n        \"title\"          : \"JoJo's Bizarre Adventure\",\n        \"post_ids\"       : list,\n        \"thumbnail\"      : dict,\n    },\n    \"creator\"         : {\n        \"date\"      : \"dt:2018-10-17 05:45:19\",\n        \"first_name\": \"YaBoyRoshi\",\n        \"full_name\" : \"YaBoyRoshi\",\n        \"id\"        : \"14264111\",\n        \"vanity\"    : \"yaboyroshi\",\n    },\n},\n\n)\n"
  },
  {
    "path": "test/results/pawoo.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import mastodon\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://pawoo.net/@yoru_nine/\",\n    \"#category\": (\"mastodon\", \"pawoo\", \"user\"),\n    \"#class\"   : mastodon.MastodonUserExtractor,\n    \"#range\"   : \"1-60\",\n    \"#count\"   : 60,\n},\n\n{\n    \"#url\"     : \"https://pawoo.net/bookmarks\",\n    \"#category\": (\"mastodon\", \"pawoo\", \"bookmark\"),\n    \"#class\"   : mastodon.MastodonBookmarkExtractor,\n},\n\n{\n    \"#url\"     : \"https://pawoo.net/users/yoru_nine/following\",\n    \"#category\": (\"mastodon\", \"pawoo\", \"following\"),\n    \"#class\"   : mastodon.MastodonFollowingExtractor,\n},\n\n{\n    \"#url\"     : \"https://pawoo.net/@yoru_nine/105038878897832922\",\n    \"#category\": (\"mastodon\", \"pawoo\", \"status\"),\n    \"#class\"   : mastodon.MastodonStatusExtractor,\n    \"#sha1_content\": \"b52e807f8ab548d6f896b09218ece01eba83987a\",\n},\n\n)\n"
  },
  {
    "path": "test/results/pexels.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import pexels\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://www.pexels.com/search/garden/\",\n    \"#class\"   : pexels.PexelsSearchExtractor,\n    \"#pattern\" : r\"https://images\\.pexels\\.com/photos/\\d+/[\\w-]+\\.jpe?g\",\n    \"#range\"   : \"1-40\",\n    \"#count\"   : 40,\n\n    \"alt\"           : str,\n    \"aspect_ratio\"  : float,\n    \"collection_ids\": list,\n    \"colors\"        : list,\n    \"created_at\"    : str,\n    \"date\"          : \"type:datetime\",\n    \"description\"   : str,\n    \"extension\"     : \"jpg\",\n    \"feed_at\"       : str,\n    \"filename\"      : r\"re:pexels-[\\w-]+-\\d+\",\n    \"width\"         : int,\n    \"height\"        : int,\n    \"id\"            : int,\n    \"image\"         : dict,\n    \"license\"       : str,\n    \"liked\"         : False,\n    \"main_color\"    : \"len:3\",\n    \"pending\"       : False,\n    \"publish_at\"    : str,\n    \"published\"     : True,\n    \"reactions\"     : dict,\n    \"search_tags\"   : \"garden\",\n    \"slug\"          : str,\n    \"starred\"       : bool,\n    \"status\"        : \"approved\",\n    \"tags\"          : list,\n    \"title\"         : str,\n    \"type\"          : \"photo\",\n    \"updated_at\"    : str,\n    \"user\"          : {\n        \"avatar\"    : dict,\n        \"first_name\": str,\n        \"following\" : False,\n        \"hero\"      : bool,\n        \"id\"        : int,\n        \"last_name\" : {str, None},\n        \"location\"  : {str, None},\n        \"slug\"      : str,\n        \"username\"  : {str, None},\n    },\n},\n\n{\n    \"#url\"     : \"https://www.pexels.com/collections/summer-solstice-j2zdph3/\",\n    \"#class\"   : pexels.PexelsCollectionExtractor,\n    \"#pattern\" : r\"https://(images\\.pexels\\.com/photos/\\d+/[\\w-]+\\.jpe?g|www\\.pexels\\.com/download/video/\\d+/)\",\n    \"#range\"   : \"1-40\",\n    \"#count\"   : 40,\n\n    \"collection\"   : \"summer-solstice-j2zdph3\",\n    \"collection_id\": \"j2zdph3\",\n},\n\n{\n    \"#url\"     : \"https://www.pexels.com/@ehioma-osih-109764575\",\n    \"#class\"   : pexels.PexelsUserExtractor,\n    \"#pattern\" : r\"https://(images\\.pexels\\.com/photos/\\d+/[\\w-]+\\.jpe?g|www\\.pexels\\.com/download/video/\\d+/)\",\n    \"#range\"   : \"1-40\",\n    \"#count\"   : 40,\n\n    \"user\": {\n        \"id\": 109764575,\n    },\n},\n\n{\n    \"#url\"     : \"https://www.pexels.com/@azizico/\",\n    \"#comment\" : \"user URL without ID\",\n    \"#class\"   : pexels.PexelsUserExtractor,\n    \"#range\"   : \"1-10\",\n    \"#count\"   : 10,\n\n    \"user\": {\n        \"id\": 423972809,\n    },\n},\n\n{\n    \"#url\"     : \"https://www.pexels.com/@109764575\",\n    \"#comment\" : \"user URL with only ID\",\n    \"#class\"   : pexels.PexelsUserExtractor,\n    \"#range\"   : \"1-10\",\n    \"#count\"   : 10,\n\n    \"user\": {\n        \"id\": 109764575,\n    },\n},\n\n{\n    \"#url\"     : \"https://www.pexels.com/photo/sun-shining-between-the-trees-in-the-forest-onto-an-asphalt-road-17213600/\",\n    \"#class\"   : pexels.PexelsImageExtractor,\n    \"#results\" : \"https://images.pexels.com/photos/17213600/pexels-photo-17213600.jpeg\",\n},\n\n)\n"
  },
  {
    "path": "test/results/philomena.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import philomena\n\n\n__tests__ = (\n{\n    \"#url\"     : \"philomena:https://manebooru.art/307071\",\n    \"#comment\" : \"'view_url' yields 404 (#6922)\",\n    \"#category\": (\"philomena\", \"manebooru.art\", \"post\"),\n    \"#class\"   : philomena.PhilomenaPostExtractor,\n    \"#results\"     : \"https://static.manebooru.art/img/view/2020/10/27/307071.png\",\n    \"#sha1_content\": \"82c21bfb2675449893fa4b2546546f404019b3c8\",\n\n    \"date\"     : \"dt:2020-10-27 11:58:40\"\n},\n\n{\n    \"#url\"     : \"philomena:https://ponerpics.org/images/1\",\n    \"#category\": (\"philomena\", \"ponerpics.org\", \"post\"),\n    \"#class\"   : philomena.PhilomenaPostExtractor,\n    \"#results\" : \"https://ponerpics.org/img/view/2012/1/2/1.png\",\n\n    \"date\"     : \"dt:2012-01-02 03:12:33\"\n},\n\n)\n"
  },
  {
    "path": "test/results/pholder.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import pholder\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://pholder.com/r/lavaporn\",\n    \"#class\"   : pholder.PholderSubredditExtractor,\n    \"#range\"   : \"1-20\",\n    \"#count\"   : 20,\n\n    \"ad_allowlist\"  : None,\n    \"author\"        : str,\n    \"comment\"       : str,\n    \"date\"          : \"type:datetime\",\n    \"extension\"     : {\"jpg\", \"gif\"},\n    \"filename\"      : str,\n    \"gallery_id\"    : \"\",\n    \"width\"         : {int, None},\n    \"height\"        : {int, None},\n    \"id\"            : str,\n    \"is_gallery\"    : False,\n    \"network\"       : \"reddit\",\n    \"not_found\"     : False,\n    \"nsfw\"          : 0,\n    \"origin\"        : str,\n    \"profile\"       : r\"re:https://www.reddit.com/u/.+\",\n    \"submitted_utc\" : int,\n    \"subredditTitle\": \"lavaporn\",\n    \"tags\"          : {list, tuple, None},\n    \"thumbnails\"    : list,\n    \"title\"         : str,\n},\n\n{\n    \"#url\"     : \"https://pholder.com/u/automoderator\",\n    \"#class\"   : pholder.PholderUserExtractor,\n    \"#range\"   : \"1-20\",\n    \"#count\"   : \">= 20\",\n\n    \"author\": \"AutoModerator\",\n},\n\n{\n    \"#url\"     : \"https://pholder.com/search-text\",\n    \"#category\": (\"\", \"pholder\", \"search\"),\n    \"#class\"   : pholder.PholderSearchExtractor,\n    \"#range\"   : \"1-10\",\n    \"#count\"   : 10,\n},\n\n)\n"
  },
  {
    "path": "test/results/photovogue.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import photovogue\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://www.vogue.com/photovogue/photographers/221252\",\n    \"#category\": (\"\", \"photovogue\", \"user\"),\n    \"#class\"   : photovogue.PhotovogueUserExtractor,\n},\n\n{\n    \"#url\"     : \"https://vogue.com/photovogue/photographers/221252\",\n    \"#category\": (\"\", \"photovogue\", \"user\"),\n    \"#class\"   : photovogue.PhotovogueUserExtractor,\n    \"#pattern\" : \"https://images.vogue.it/Photovogue/[^/]+_gallery.jpg\",\n\n    \"date\"           : \"type:datetime\",\n    \"favorite_count\" : int,\n    \"favorited\"      : list,\n    \"id\"             : int,\n    \"image_id\"       : str,\n    \"is_favorite\"    : False,\n    \"orientation\"    : r\"re:portrait|landscape\",\n    \"photographer\"   : {\n        \"biography\"     : \"Born in 1995. Live in Bologna.\",\n        \"city\"          : \"Bologna\",\n        \"country_id\"    : 106,\n        \"favoritedCount\": int,\n        \"id\"            : 221252,\n        \"isGold\"        : bool,\n        \"isPro\"         : bool,\n        \"latitude\"      : str,\n        \"longitude\"     : str,\n        \"name\"          : \"Arianna Mattarozzi\",\n        \"user_id\"       : \"38cb0601-4a85-453c-b7dc-7650a037f2ab\",\n        \"websites\"      : list,\n    },\n    \"photographer_id\": 221252,\n    \"tags\"           : list,\n    \"title\"          : str,\n},\n\n)\n"
  },
  {
    "path": "test/results/picarto.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import picarto\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://picarto.tv/fnook/gallery/default/\",\n    \"#category\": (\"\", \"picarto\", \"gallery\"),\n    \"#class\"   : picarto.PicartoGalleryExtractor,\n    \"#pattern\" : r\"https://images\\.picarto\\.tv/gallery/\\d/\\d\\d/\\d+/artwork/[0-9a-f-]+/large-[0-9a-f]+\\.(jpg|png|gif)\",\n    \"#count\"   : \">= 7\",\n\n    \"date\": \"type:datetime\",\n},\n\n)\n"
  },
  {
    "path": "test/results/picazor.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import picazor\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://picazor.com/en/kailey-mae\",\n    \"#class\"   : picazor.PicazorUserExtractor,\n    \"#range\"   : \"1-50\",\n    \"#pattern\" : r\"https://picazor\\.com/uploads/.+\\.jpg\",\n    \"#count\"   : 50,\n\n    \"count\"    : 278,\n    \"num\"      : range(200, 278),\n    \"extension\": \"jpg\",\n    \"filename\" : str,\n    \"id\"       : \"re:[0-9a-f]+\",\n    \"mime\"     : \"photo\",\n    \"path\"     : str,\n    \"user\"     : \"kailey-mae\",\n    \"visible\"  : True,\n    \"subject\"  : {\n        \"id\" : \"66077ba2b64e3f3d178f39ac\",\n        \"uri\": \"kailey-mae\",\n    },\n},\n\n{\n    \"#url\"     : \"https://picazor.com/vi/badassnugget\",\n    \"#comment\" : \"non-'en' language code\",\n    \"#class\"   : picazor.PicazorUserExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/picstate.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import imagehosts\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://picstate.com/view/full/23416694_sfyue\",\n    \"#class\"   : imagehosts.PicstateImageExtractor,\n    \"#results\"      : \"https://picstate.com/file/23416694_sfyue/test-___-_22__.png\",\n    \"#sha1_content\" : \"0c8768055e4e20e7c7259608b67799171b691140\",\n\n    \"filename\"   : \"test-___-_22__\",\n    \"extension\"  : \"png\",\n    \"token\"      : \"23416694_sfyue\",\n},\n\n{\n    \"#url\"     : \"https://picstate.com/view/full/21812181_qxtpb\",\n    \"#class\"   : imagehosts.PicstateImageExtractor,\n    \"#results\"      : \"https://picstate.com/files/21812181_qxtpb/0.jpg\",\n    \"#sha1_content\" : \"bbb2b257049633f450660c88b75813c8c39612b1\",\n    \"#sha1_metadata\": \"68f331e95977d71cd7d615f259359ef3bbb8f163\",\n\n    \"filename\"   : \"0\",\n    \"extension\"  : \"jpg\",\n    \"token\"      : \"21812181_qxtpb\",\n},\n\n)\n"
  },
  {
    "path": "test/results/pictoa.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import pictoa\n\n\n__tests__ = (\n{\n    \"#url\"    : \"https://www.pictoa.com/albums/anna-kendrick-busty-in-a-strapless-red-dress-out-in-nyc-3229225.html\",\n    \"#class\"  : pictoa.PictoaAlbumExtractor,\n    \"#pattern\": pictoa.PictoaImageExtractor.pattern,\n    \"#count\"  : 16,\n\n    \"album_id\"   : \"3229225\",\n    \"album_title\": \"Anna Kendrick busty in a strapless red dress out in NYC\",\n    \"tags\"       : [\"Anna Kendrick\", \"Celebrity\"],\n},\n\n{\n    \"#url\"    : \"https://www.pictoa.com.de/albums/oscars-2020-red-carpet-4010403.html\",\n    \"#comment\": \"verify pagination works\",\n    \"#class\"  : pictoa.PictoaAlbumExtractor,\n    \"#pattern\": pictoa.PictoaImageExtractor.pattern,\n    \"#count\"  : 182,\n\n    \"album_id\"   : \"4010403\",\n    \"album_title\": \"Oscars 2020 Red Carpet\",\n    \"tags\"       : ['Celebrity', 'Red'],\n},\n\n{\n    \"#url\"    : \"https://it.pictoa.com/albums/carl-virkus-149024.html\",\n    \"#comment\": \"null tags\",\n    \"#class\"  : pictoa.PictoaAlbumExtractor,\n    \"#results\": \"https://www.pictoa.com/albums/carl-virkus-149024/2221031.html\",\n\n    \"album_id\"   : \"149024\",\n    \"album_title\": \"Carl Virkus\",\n    \"tags\"       : [],\n},\n\n{\n    \"#url\"  : \"https://www.pictoa.com/albums/anna-kendrick-showing-cleavage-at-the-56th-annual-grammy-awards-3233172/75206264.html\",\n    \"#class\": pictoa.PictoaImageExtractor,\n    \"#results\": \"https://s1.pictoa.com/media/galleries/168/930/168930594a8750dfd3e/3233172594a8759dcc3a.jpg\",\n\n    \"album_id\"   : \"3233172\",\n    \"album_title\": \"Anna Kendrick showing cleavage at the 56th Annual GRAMMY Awards\",\n    \"extension\"  : \"jpg\",\n    \"filename\"   : \"3233172594a8759dcc3a\",\n    \"id\"         : \"75206264\",\n    \"url\"        : \"https://s1.pictoa.com/media/galleries/168/930/168930594a8750dfd3e/3233172594a8759dcc3a.jpg\"\n},\n\n{\n    \"#url\"  : \"https://nl.pictoa.com/albums/kandi-barbour-3840809/94038192.html\",\n    \"#class\": pictoa.PictoaImageExtractor,\n    \"#results\"     : \"https://s2.pictoa.com/media/galleries/294/452/29445260009fa5b68e4/384080960009fb51e389.jpg\",\n    \"#sha1_content\": \"152595069016da89565eb3d8e73df835afd22e2c\",\n\n    \"album_id\"   : \"3840809\",\n    \"album_title\": \"Kandi Barbour\",\n    \"extension\"  : \"jpg\",\n    \"filename\"   : \"384080960009fb51e389\",\n    \"id\"         : \"94038192\",\n    \"url\"        : \"https://s2.pictoa.com/media/galleries/294/452/29445260009fa5b68e4/384080960009fb51e389.jpg\",\n},\n\n)\n"
  },
  {
    "path": "test/results/piczel.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import piczel\n\n\n__tests__ = (\n{\n    \"#url\"  : \"https://piczel.tv/gallery/Bikupan\",\n    \"#class\": piczel.PiczelUserExtractor,\n    \"#range\": \"1-100\",\n    \"#count\": \">= 100\",\n},\n\n{\n    \"#url\"  : \"https://piczel.tv/gallery/Lulena/1114\",\n    \"#class\": piczel.PiczelFolderExtractor,\n    \"#results\": (\n        \"https://piczel.tv/static/uploads/gallery_image/32920/image/11194/1544126403-Lulena.png\",\n        \"https://piczel.tv/static/uploads/gallery_image/32920/image/8008/1533616260-Lulena.png\",\n        \"https://piczel.tv/static/uploads/plain_image/32920/image/3761/3761-Lulena.png\",\n        \"https://piczel.tv/static/uploads/plain_image/32920/image/3762/3762-Lulena.png\",\n        \"https://piczel.tv/static/uploads/gallery_image/32920/image/7991/1533513024-Lulena.png\",\n        \"https://piczel.tv/static/uploads/gallery_image/32920/image/7806/1532236348-Lulena.png\",\n        \"https://piczel.tv/static/uploads/gallery_image/32920/image/7800/1532235785-Lulena.png\",\n    ),\n\n    \"folder_id\": 1114,\n},\n\n{\n    \"#url\"  : \"https://piczel.tv/gallery/image/7807\",\n    \"#class\": piczel.PiczelImageExtractor,\n    \"#results\"     : \"https://piczel.tv/static/uploads/gallery_image/32920/image/7807/1532236438-Lulena.png\",\n    \"#sha1_content\": \"df9a053a24234474a19bce2b7e27e0dec23bff87\",\n\n    \"count\"           : 1,\n    \"created_at\"      : \"2018-07-22T05:13:58.000Z\",\n    \"date\"            : \"dt:2018-07-22 05:13:58\",\n    \"description\"     : None,\n    \"extension\"       : \"png\",\n    \"favorites_count\" : int,\n    \"folder_id\"       : 1113,\n    \"id\"              : 7807,\n    \"is_flash\"        : False,\n    \"is_video\"        : False,\n    \"multi\"           : False,\n    \"nsfw\"            : False,\n    \"num\"             : 0,\n    \"password_protected\": False,\n    \"tags\"            : [\n        \"fanart\",\n        \"commission\",\n        \"altair\",\n        \"recreators\",\n    ],\n    \"title\"           : \"Altair\",\n    \"user\"            : dict,\n    \"views\"           : int,\n},\n\n{\n    \"#url\"    : \"https://piczel.tv/gallery/image/8008\",\n    \"#comment\": \"multi\",\n    \"#class\"  : piczel.PiczelImageExtractor,\n    \"#results\": (\n        \"https://piczel.tv/static/uploads/gallery_image/32920/image/8008/1533616260-Lulena.png\",\n        \"https://piczel.tv/static/uploads/plain_image/32920/image/3761/3761-Lulena.png\",\n        \"https://piczel.tv/static/uploads/plain_image/32920/image/3762/3762-Lulena.png\",\n    ),\n\n    \"count\"      : 3,\n    \"created_at\" : \"2018-08-07T04:31:00.000Z\",\n    \"curated\"    : False,\n    \"date\"       : \"dt:2018-08-07 04:31:00\",\n    \"description\": \"8/7/18\",\n    \"extension\"  : \"png\",\n    \"favorites_count\": range(3, 10),\n    \"folder_id\"  : 1114,\n    \"width\"      : None,\n    \"height\"     : None,\n    \"id\"         : 8008,\n    \"is_flash\"   : False,\n    \"is_video\"   : False,\n    \"multi\"      : True,\n    \"nsfw\"       : True,\n    \"num\"        : {0, 1, 2},\n    \"password_protected\"  : False,\n    \"published_at\"        : \"2018-08-07T04:31:00.000Z\",\n    \"rendered_description\": \"<p>8/7/18</p>\",\n    \"status\"     : \"published\",\n    \"thumbnail\"  : None,\n    \"title\"      : \"❤\",\n    \"views\"      : 314,\n    \"tags\"       : [\n        \"original\",\n        \"Orc\",\n        \"tanlines\",\n    ],\n    \"user\"       : {\n        \"follower_count\": range(15, 25),\n        \"id\"      : 32920,\n        \"premium?\": False,\n        \"role\"    : \"user\",\n        \"username\": \"Lulena\",\n    },\n},\n\n)\n"
  },
  {
    "path": "test/results/pidgiwiki.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import wikimedia\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://www.pidgi.net/wiki/File:Key_art_-_Fight_Knight.png\",\n    \"#category\": (\"wikimedia\", \"pidgiwiki\", \"file\"),\n    \"#class\"   : wikimedia.WikimediaArticleExtractor,\n    \"#results\" : \"https://cdn.pidgi.net/images/0/0c/Key_art_-_Fight_Knight.png?format=original\",\n},\n\n{\n    \"#url\"     : \"https://pidgi.net/wiki/File:Key_art_-_Fight_Knight.png\",\n    \"#category\": (\"wikimedia\", \"pidgiwiki\", \"file\"),\n    \"#class\"   : wikimedia.WikimediaArticleExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/pillowfort.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import pillowfort\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://www.pillowfort.social/posts/27510\",\n    \"#category\": (\"\", \"pillowfort\", \"post\"),\n    \"#class\"   : pillowfort.PillowfortPostExtractor,\n    \"#pattern\" : r\"https://img\\d+\\.pillowfort\\.social/posts/\\w+_out\\d+\\.png\",\n    \"#count\"   : 4,\n\n    \"avatar_url\"      : str,\n    \"col\"             : 0,\n    \"commentable\"     : True,\n    \"comments_count\"  : int,\n    \"community_id\"    : None,\n    \"content\"         : str,\n    \"count\"           : 4,\n    \"created_at\"      : str,\n    \"date\"            : \"type:datetime\",\n    \"deleted\"         : None,\n    \"deleted_at\"      : None,\n    \"deleted_by_mod\"  : None,\n    \"deleted_for_flag_id\": None,\n    \"embed_code\"      : None,\n    \"id\"              : int,\n    \"last_activity\"   : str,\n    \"last_activity_elapsed\": str,\n    \"last_edited_at\"  : str,\n    \"likes_count\"     : int,\n    \"media_type\"      : \"picture\",\n    \"nsfw\"            : False,\n    \"num\"             : range(1, 4),\n    \"original_post_id\": None,\n    \"original_post_user_id\": None,\n    \"picture_content_type\": None,\n    \"picture_file_name\": None,\n    \"picture_file_size\": None,\n    \"picture_updated_at\": None,\n    \"post_id\"         : 27510,\n    \"post_type\"       : \"picture\",\n    \"privacy\"         : \"public\",\n    \"reblog_copy_info\": list,\n    \"rebloggable\"     : True,\n    \"reblogged_from_post_id\": None,\n    \"reblogged_from_user_id\": None,\n    \"reblogs_count\"   : int,\n    \"row\"             : int,\n    \"small_image_url\" : None,\n    \"tags\"            : list,\n    \"time_elapsed\"    : str,\n    \"timestamp\"       : str,\n    \"title\"           : \"What is Pillowfort.social?\",\n    \"updated_at\"      : str,\n    \"url\"             : r\"re:https://img3.pillowfort.social/posts/.*\\.png\",\n    \"user_id\"         : 5,\n    \"username\"        : \"Staff\",\n},\n\n{\n    \"#url\"     : \"https://www.pillowfort.social/posts/1124584\",\n    \"#comment\" : \"'b2_lg_url' media URL (#4570)\",\n    \"#category\": (\"\", \"pillowfort\", \"post\"),\n    \"#class\"   : pillowfort.PillowfortPostExtractor,\n    \"#pattern\" : r\"https://img2\\.pillowfort\\.social/posts/c8e834bc09e6_Brandee\\.png\",\n    \"#count\"   : 1,\n\n    \"avatar_frame\"  : None,\n    \"avatar_id\"     : None,\n    \"avatar_url\"    : \"https://img3.pillowfort.social/avatars/000/037/139/original/437.jpg?1545015697\",\n    \"b2_lg_url\"     : \"https://img2.pillowfort.social/posts/c8e834bc09e6_Brandee.png\",\n    \"b2_sm_url\"     : \"https://img2.pillowfort.social/posts/c8e834bc09e6_Brandee_small.png\",\n    \"cached_tag_list\": \"art, digital art, mermaid, mermaids, underwater, seaweed, illustration, speed paint\",\n    \"col\"           : 0,\n    \"comm_screening_status\": \"not_applicable\",\n    \"commentable\"   : True,\n    \"comments_count\": 0,\n    \"community_id\"  : None,\n    \"concealed_comment_warning\": None,\n    \"content\"       : \"<p>Sea Bed</p>\",\n    \"count\"         : 1,\n    \"created_at\"    : r\"re:2020-02-.+\",\n    \"currentuser_default_avatar_url\": None,\n    \"currentuser_multi_avi\": None,\n    \"date\"          : \"dt:2020-02-29 17:09:03\",\n    \"deleted\"       : None,\n    \"deleted_at\"    : None,\n    \"deleted_by_mod\": None,\n    \"deleted_for_flag_id\": None,\n    \"embed_code\"    : None,\n    \"extension\"     : \"png\",\n    \"filename\"      : \"Brandee\",\n    \"hash\"          : \"c8e834bc09e6\",\n    \"id\"            : 720167,\n    \"last_activity\" : r\"re:2020-02-.+\",\n    \"last_activity_elapsed\": r\"re:\\d+ months\",\n    \"last_edited_at\": None,\n    \"likes_count\"   : 8,\n    \"media_type\"    : \"picture\",\n    \"nsfw\"          : False,\n    \"num\"           : 1,\n    \"original_post_id\": None,\n    \"original_post_user_id\": None,\n    \"pic_row_last\"  : 1,\n    \"picture_content_type\": None,\n    \"picture_file_name\": None,\n    \"picture_file_size\": None,\n    \"picture_updated_at\": None,\n    \"post_id\"       : 1124584,\n    \"post_type\"     : \"picture\",\n    \"privacy\"       : \"public\",\n    \"reblog_copy_info\": [],\n    \"rebloggable\"   : True,\n    \"reblogged_from_post_id\": None,\n    \"reblogged_from_user_id\": None,\n    \"reblogs_count\" : int,\n    \"row\"           : 1,\n    \"small_image_url\": None,\n    \"tag_list\"      : None,\n    \"tags\"          : [\n        \"art\",\n        \"digital art\",\n        \"mermaid\",\n        \"mermaids\",\n        \"underwater\",\n        \"seaweed\",\n        \"illustration\",\n        \"speed paint\",\n    ],\n    \"time_elapsed\"  : r\"re:\\d+ months\",\n    \"timestamp\"     : str,\n    \"title\"         : \"\",\n    \"updated_at\"    : r\"re:2020-02-.+\",\n    \"url\"           : \"\",\n    \"user_concealed\": None,\n    \"user_id\"       : 37201,\n    \"username\"      : \"Maclanahan\",\n},\n\n{\n    \"#url\"     : \"https://www.pillowfort.social/posts/1557500\",\n    \"#comment\" : \"'external' option\",\n    \"#category\": (\"\", \"pillowfort\", \"post\"),\n    \"#class\"   : pillowfort.PillowfortPostExtractor,\n    \"#options\" : {\n        \"external\": True,\n        \"inline\"  : False,\n    },\n    \"#pattern\" : r\"https://twitter\\.com/Aliciawitdaart/status/1282862493841457152\",\n},\n\n{\n    \"#url\"     : \"https://www.pillowfort.social/posts/1672518\",\n    \"#comment\" : \"'inline' option\",\n    \"#category\": (\"\", \"pillowfort\", \"post\"),\n    \"#class\"   : pillowfort.PillowfortPostExtractor,\n    \"#options\" : {\"inline\": True},\n    \"#count\"   : 3,\n},\n\n{\n    \"#url\"     : \"https://www.pillowfort.social/Pome\",\n    \"#category\": (\"\", \"pillowfort\", \"user\"),\n    \"#class\"   : pillowfort.PillowfortUserExtractor,\n    \"#pattern\" : r\"https://img\\d+\\.pillowfort\\.social/posts/\",\n    \"#range\"   : \"1-15\",\n    \"#count\"   : 15,\n},\n\n{\n    \"#url\"     : \"https://www.pillowfort.social/Staff/tagged/funding\",\n    \"#category\": (\"\", \"pillowfort\", \"user\"),\n    \"#class\"   : pillowfort.PillowfortUserExtractor,\n    \"#pattern\" : r\"https://img\\d+\\.pillowfort\\.social/posts/\",\n    \"#count\"   : range(30, 50),\n},\n\n)\n"
  },
  {
    "path": "test/results/pinterest.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import pinterest\nfrom gallery_dl import exception\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://www.pinterest.com/pin/858146903966145189/\",\n    \"#category\": (\"\", \"pinterest\", \"pin\"),\n    \"#class\"   : pinterest.PinterestPinExtractor,\n    \"#results\" : \"https://i.pinimg.com/originals/d4/f4/7f/d4f47fa2fce4c4c28475af5d94972904.jpg\",\n    \"#sha1_url\"    : \"afb3c26719e3a530bb0e871c480882a801a4e8a5\",\n    \"#sha1_content\": [\n        \"4c435a66f6bb82bb681db2ecc888f76cf6c5f9ca\",\n        \"d3e24bc9f7af585e8c23b9136956bd45a4d9b947\",\n    ],\n},\n\n{\n    \"#url\"     : \"https://www.pinterest.com/pin/422564377542934214/\",\n    \"#comment\" : \"video pin (#1189)\",\n    \"#class\"   : pinterest.PinterestPinExtractor,\n    \"#pattern\" : r\"https://v\\d*\\.pinimg\\.com/videos/mc/hls/d7/22/ff/d722ff00ab2352981b89974b37909de8.m3u8\",\n    \"#exception\": exception.NotFoundError,\n},\n\n{\n    \"#url\"     : \"https://jp.pinterest.com/pin/858146904010573850/\",\n    \"#comment\" : \"story pin with images\",\n    \"#class\"   : pinterest.PinterestPinExtractor,\n    \"#results\" : (\n        \"https://i.pinimg.com/originals/0f/b0/8c/0fb08c519067dd263a1fcfecea775450.jpg\",\n        \"https://i.pinimg.com/originals/2f/27/f3/2f27f3eb781b107ce58bf588c12a12b7.jpg\",\n        \"https://i.pinimg.com/originals/55/fd/df/55fddf8d26aa0d96071af52ac6a0c25f.jpg\",\n    ),\n},\n\n{\n    \"#url\"     : \"https://www.pinterest.com/pin/63824519713049795/\",\n    \"#comment\" : \"story pin with video (#6188)\",\n    \"#class\"   : pinterest.PinterestPinExtractor,\n    \"#results\" : \"ytdl:https://v1.pinimg.com/videos/iht/hls/7a/b0/cc/7ab0cc56dcbfc1508b8d650af7b0a593.m3u8\",\n\n    \"extension\"     : \"mp4\",\n    \"_ytdl_manifest\": \"hls\",\n},\n\n{\n    \"#url\"     : \"https://www.pinterest.com/pin/606508274845593025/\",\n    \"#comment\" : \"story pin with audio (#6188)\",\n    \"#class\"   : pinterest.PinterestPinExtractor,\n    \"#range\"   : \"2\",\n    \"#results\" : \"https://v1.pinimg.com/audios/mp3/5d/37/74/5d37749bde03855c1292f8869c8d9387.mp3\",\n\n    \"extension\": \"mp3\",\n},\n\n{\n    \"#url\"     : \"https://jp.pinterest.com/pin/851532242064221228/\",\n    \"#comment\" : \"story pin with text\",\n    \"#class\"   : pinterest.PinterestPinExtractor,\n    \"#range\"   : \"2\",\n    \"#results\" : \"text:Everskies character+outfits i made\",\n},\n\n{\n    \"#url\"     : \"https://www.pinterest.com/pin/1025272671423645004/\",\n    \"#comment\" : \"story pin with 'story_pin_static_sticker_block' blocks\",\n    \"#class\"   : pinterest.PinterestPinExtractor,\n    \"#results\" : \"https://i.pinimg.com/originals/70/ab/31/70ab31654b2329e2ec74a39adf7ee683.jpg\",\n},\n\n{\n    \"#url\"     : \"https://www.pinterest.com/pin/777856166916298367\",\n    \"#comment\" : \"story pin with 'story_pin_product_sticker_block' blocks (#7563)\",\n    \"#class\"   : pinterest.PinterestPinExtractor,\n    \"#results\" : \"https://i.pinimg.com/originals/3e/0a/2e/3e0a2e6c1173866c530c8ffe18d08b9f.jpg\",\n    \"#exception\": exception.NotFoundError,\n},\n\n{\n    \"#url\"     : \"https://pinterest.com/pin/725220346239561090/\",\n    \"#comment\" : \"stripped 'description' & 'closeup_unified_description' (#4335)\",\n    \"#class\"   : pinterest.PinterestPinExtractor,\n    \"#results\" : \"https://i.pinimg.com/originals/66/a3/9a/66a39a10c015df67b85481105fb3a81e.jpg\",\n\n    \"description\": \"\",\n    \"closeup_unified_description\": \"\",\n},\n\n{\n    \"#url\"     : \"https://www.pinterest.com/pin/858146903966145188/\",\n    \"#category\": (\"\", \"pinterest\", \"pin\"),\n    \"#class\"   : pinterest.PinterestPinExtractor,\n    \"#exception\": exception.NotFoundError,\n},\n\n{\n    \"#url\"     : \"https://www.pinterest.com/g1952849/test-/\",\n    \"#class\"   : pinterest.PinterestBoardExtractor,\n    \"#results\" : \"https://i.pinimg.com/originals/d4/f4/7f/d4f47fa2fce4c4c28475af5d94972904.jpg\",\n},\n\n{\n    \"#url\"     : \"https://www.pinterest.com/g1952849/stuff/\",\n    \"#comment\" : \"board with sections (#835)\",\n    \"#category\": (\"\", \"pinterest\", \"board\"),\n    \"#class\"   : pinterest.PinterestBoardExtractor,\n    \"#options\" : {\"sections\": True},\n    \"#count\"   : 4,\n},\n\n{\n    \"#url\"     : \"https://www.pinterest.jp/gdldev/bname/\",\n    \"#comment\" : \"board & section with /?# in name (#5104)\",\n    \"#category\": (\"\", \"pinterest\", \"board\"),\n    \"#class\"   : pinterest.PinterestBoardExtractor,\n    \"#options\" : {\"sections\": True},\n    \"#results\" : \"https://www.pinterest.jp/gdldev/bname/id:5345901183739414095\",\n},\n\n{\n    \"#url\"     : \"https://www.pinterest.de/g1952849/secret/\",\n    \"#comment\" : \"secret board (#1055)\",\n    \"#category\": (\"\", \"pinterest\", \"board\"),\n    \"#class\"   : pinterest.PinterestBoardExtractor,\n    \"#auth\"    : True,\n    \"#count\"   : 2,\n},\n\n{\n    \"#url\"     : \"https://www.pinterest.com/g1952848/test/\",\n    \"#category\": (\"\", \"pinterest\", \"board\"),\n    \"#class\"   : pinterest.PinterestBoardExtractor,\n    \"#exception\": exception.GalleryDLException,\n},\n\n{\n    \"#url\"     : \"https://www.pinterest.co.uk/hextra7519/based-animals/\",\n    \"#comment\" : \".co.uk TLD (#914)\",\n    \"#category\": (\"\", \"pinterest\", \"board\"),\n    \"#class\"   : pinterest.PinterestBoardExtractor,\n},\n\n{\n    \"#url\"     : \"https://ru.pinterest.com/tarvenck/%D0%B0%D1%82%D0%BC%D0%BE%D1%81%D1%84%D0%B5%D1%80%D0%BD%D1%8B%D0%B5/?invite_code=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa&sender=1111111111111111111\",\n    \"#comment\" : \"board URL with query string (#7805)\",\n    \"#class\"   : pinterest.PinterestBoardExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.pinterest.com/g1952849/\",\n    \"#category\": (\"\", \"pinterest\", \"user\"),\n    \"#class\"   : pinterest.PinterestUserExtractor,\n    \"#pattern\" : pinterest.PinterestBoardExtractor.pattern,\n    \"#count\"   : \">= 2\",\n},\n\n{\n    \"#url\"     : \"https://www.pinterest.com/g1952849/_saved/\",\n    \"#category\": (\"\", \"pinterest\", \"user\"),\n    \"#class\"   : pinterest.PinterestUserExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.pinterest.com/g1952849/pins/\",\n    \"#category\": (\"\", \"pinterest\", \"allpins\"),\n    \"#class\"   : pinterest.PinterestAllpinsExtractor,\n    \"#pattern\" : r\"https://i\\.pinimg\\.com/originals/[0-9a-f]{2}/[0-9a-f]{2}/[0-9a-f]{2}/[0-9a-f]{32}\\.\\w{3}\",\n    \"#count\"   : 9,\n},\n\n{\n    \"#url\"     : \"https://www.pinterest.de/digitalmomblog/_created/\",\n    \"#category\": (\"\", \"pinterest\", \"created\"),\n    \"#class\"   : pinterest.PinterestCreatedExtractor,\n    \"#pattern\" : r\"ytdl:|https://i\\.pinimg\\.com/originals/[0-9a-f]{2}/[0-9a-f]{2}/[0-9a-f]{2}/[0-9a-f]{32}\\.(jpg|png|webp)\",\n    \"#range\"   : \"1-10\",\n    \"#count\"   : 10,\n},\n\n{\n    \"#url\"     : \"https://www.pinterest.com/g1952849/stuff/section\",\n    \"#category\": (\"\", \"pinterest\", \"section\"),\n    \"#class\"   : pinterest.PinterestSectionExtractor,\n    \"#count\"   : 2,\n},\n\n{\n    \"#url\"     : \"https://www.pinterest.com/search/pins/?q=nature\",\n    \"#category\": (\"\", \"pinterest\", \"search\"),\n    \"#class\"   : pinterest.PinterestSearchExtractor,\n    \"#range\"   : \"1-50\",\n    \"#count\"   : \">= 50\",\n},\n\n{\n    \"#url\"     : \"https://www.pinterest.com/pin/858146903966145189/#related\",\n    \"#category\": (\"\", \"pinterest\", \"related-pin\"),\n    \"#class\"   : pinterest.PinterestRelatedPinExtractor,\n    \"#range\"   : \"31-70\",\n    \"#count\"   : 40,\n    \"#archive\" : False,\n},\n\n{\n    \"#url\"     : \"https://www.pinterest.com/g1952849/test-/#related\",\n    \"#category\": (\"\", \"pinterest\", \"related-board\"),\n    \"#class\"   : pinterest.PinterestRelatedBoardExtractor,\n    \"#range\"   : \"31-70\",\n    \"#count\"   : 40,\n    \"#archive\" : False,\n},\n\n{\n    \"#url\"     : \"https://pin.it/Hvt8hgT\",\n    \"#category\": (\"\", \"pinterest\", \"pinit\"),\n    \"#class\"   : pinterest.PinterestPinitExtractor,\n    \"#results\" : \"https://www.pinterest.com/pin/858146903966145191/sent/?sender=858147041405047427&invite_code=d7494ea7610c324ffc1ef6c20c4e29c4\",\n},\n\n{\n    \"#url\"     : \"https://pin.it/72hPRLLfr\",\n    \"#comment\" : \"board redirect (#7805)\",\n    \"#class\"   : pinterest.PinterestPinitExtractor,\n    \"#pattern\" : r\"https://www.pinterest.ru/tarvenck/%D0%B0%D1%82%D0%BC%D0%BE%D1%81%D1%84%D0%B5%D1%80%D0%BD%D1%8B%D0%B5/\\?invite_code=\\w+&sender=\\d+\",\n},\n\n{\n    \"#url\"     : \"https://pin.it/Hvt8hgS\",\n    \"#category\": (\"\", \"pinterest\", \"pinit\"),\n    \"#class\"   : pinterest.PinterestPinitExtractor,\n    \"#exception\": exception.NotFoundError,\n},\n\n)\n"
  },
  {
    "path": "test/results/pinupgirlclothing.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import shopify\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://pinupgirlclothing.com/collections/evening\",\n    \"#category\": (\"shopify\", \"pinupgirlclothing\", \"collection\"),\n    \"#class\"   : shopify.ShopifyCollectionExtractor,\n},\n\n{\n    \"#url\"     : \"https://pinupgirlclothing.com/collections/evening/products/clarice-coat-dress-in-olive-green-poly-crepe-laura-byrnes-design\",\n    \"#category\": (\"shopify\", \"pinupgirlclothing\", \"product\"),\n    \"#class\"   : shopify.ShopifyProductExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/pixeldrain.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import pixeldrain\n\n__tests__ = (\n{\n    \"#url\"     : \"https://pixeldrain.com/u/jW9E6s4h\",\n    \"#category\": (\"\", \"pixeldrain\", \"file\"),\n    \"#class\"   : pixeldrain.PixeldrainFileExtractor,\n    \"#results\"     : \"https://pixeldrain.com/api/file/jW9E6s4h?download\",\n    \"#sha1_content\": \"0c8768055e4e20e7c7259608b67799171b691140\",\n\n    \"abuse_reporter_name\" : \"\",\n    \"abuse_type\"          : \"\",\n    \"allow_video_player\"  : True,\n    \"availability\"        : \"\",\n    \"availability_message\": \"\",\n    \"bandwidth_used\"      : int,\n    \"bandwidth_used_paid\" : 0,\n    \"can_download\"        : True,\n    \"can_edit\"            : False,\n    \"date\"                : \"dt:2023-11-22 16:33:27\",\n    \"date_last_view\"      : r\"re:\\d+-\\d+-\\d+T\\d+:\\d+:\\d+\\.\\d+Z\",\n    \"date_upload\"         : \"2023-11-22T16:33:27.744Z\",\n    \"delete_after_date\"   : \"0001-01-01T00:00:00Z\",\n    \"delete_after_downloads\": 0,\n    \"download_speed_limit\": 0,\n    \"downloads\"           : int,\n    \"extension\"           : \"png\",\n    \"filename\"            : \"test-テスト-\\\"&>\",\n    \"hash_sha256\"         : \"eb359cd8f02a7d6762f9863798297ff6a22569c5c87a9d38c55bdb3a3e26003f\",\n    \"id\"                  : \"jW9E6s4h\",\n    \"mime_type\"           : \"image/png\",\n    \"name\"                : \"test-テスト-\\\"&>.png\",\n    \"show_ads\"            : True,\n    \"size\"                : 182,\n    \"success\"             : True,\n    \"thumbnail_href\"      : \"/file/jW9E6s4h/thumbnail\",\n    \"url\"                 : \"https://pixeldrain.com/api/file/jW9E6s4h?download\",\n    \"views\"               : int,\n},\n\n{\n    \"#url\"     : \"https://pixeldrain.com/u/yEK1n2Qc\",\n    \"#category\": (\"\", \"pixeldrain\", \"file\"),\n    \"#class\"   : pixeldrain.PixeldrainFileExtractor,\n    \"#results\"     : \"https://pixeldrain.com/api/file/yEK1n2Qc?download\",\n    \"#sha1_content\": \"08463261191d403de2133d829060050d8b04609f\",\n\n    \"date\"       : \"dt:2023-11-22 16:38:04\",\n    \"date_upload\": \"2023-11-22T16:38:04.928Z\",\n    \"extension\"  : \"txt\",\n    \"filename\"   : '\"&>',\n    \"hash_sha256\": \"4c1e2bbcbe1dea8b6f895f5cdd8461c37c561bce4f1b3556ba58392d95964294\",\n    \"id\"         : \"yEK1n2Qc\",\n    \"mime_type\"  : \"text/plain; charset=utf-8\",\n    \"name\"       : '\"&>.txt',\n    \"size\"       : 14,\n},\n\n{\n    \"#url\"     : \"https://pixeldrain.com/l/zQ7XpWfM\",\n    \"#category\": (\"\", \"pixeldrain\", \"album\"),\n    \"#class\"   : pixeldrain.PixeldrainAlbumExtractor,\n    \"#results\" : (\n        \"https://pixeldrain.com/api/file/yEK1n2Qc?download\",\n        \"https://pixeldrain.com/api/file/jW9E6s4h?download\",\n    ),\n\n    \"album\"      : {\n        \"can_edit\"    : False,\n        \"count\"       : 2,\n        \"date\"        : \"dt:2023-11-22 16:40:39\",\n        \"date_created\": \"2023-11-22T16:40:39.218Z\",\n        \"id\"          : \"zQ7XpWfM\",\n        \"success\"     : True,\n        \"title\"       : \"アルバム\",\n    },\n    \"date\"       : \"type:datetime\",\n    \"description\": \"\",\n    \"detail_href\": r\"re:/file/(yEK1n2Qc|jW9E6s4h)/info\",\n    \"hash_sha256\": r\"re:\\w{64}\",\n    \"id\"         : r\"re:yEK1n2Qc|jW9E6s4h\",\n    \"mime_type\"  : str,\n},\n\n{\n    \"#url\"     : \"https://pixeldrain.com/l/zQ7XpWfM#item=0\",\n    \"#category\": (\"\", \"pixeldrain\", \"album\"),\n    \"#class\"   : pixeldrain.PixeldrainAlbumExtractor,\n    \"#results\"     : \"https://pixeldrain.com/api/file/jW9E6s4h?download\",\n    \"#sha1_content\": \"0c8768055e4e20e7c7259608b67799171b691140\",\n},\n\n{\n    \"#url\"     : \"https://pixeldrain.com/d/8xz8hcYJ\",\n    \"#category\": (\"\", \"pixeldrain\", \"folder\"),\n    \"#class\"   : pixeldrain.PixeldrainFolderExtractor,\n    \"#results\"     : \"https://pixeldrain.com/api/filesystem/8xz8hcYJ?attach\",\n    \"#sha1_content\": \"edfea851cad717f5643cb94ac04b32335611acf2\",\n\n    \"date\"       : \"dt:2025-05-19 15:27:54\",\n    \"extension\"  : \"mp4\",\n    \"filename\"   : \"test\",\n    \"id\"         : \"8xz8hcYJ\",\n    \"mime_type\"  : \"video/mp4\",\n    \"name\"       : \"test.mp4\",\n    \"path\"       : \"/8xz8hcYJ\",\n    \"hash_sha256\": \"c6293d8359cb84723bbf8cf355da6cf1ef9c3e8b3d465110e91db485e53ada54\",\n    \"share_url\"  : \"https://pixeldrain.com/d/8xz8hcYJ\",\n    \"size\"       : 3026,\n    \"type\"       : \"file\",\n},\n\n{\n    \"#url\"     : \"https://pixeldrain.com/api/filesystem/8xz8hcYJ\",\n    \"#category\": (\"\", \"pixeldrain\", \"folder\"),\n    \"#class\"   : pixeldrain.PixeldrainFolderExtractor,\n    \"#results\"     : \"https://pixeldrain.com/api/filesystem/8xz8hcYJ?attach\",\n    \"#sha1_content\": \"edfea851cad717f5643cb94ac04b32335611acf2\",\n\n    \"date\"       : \"dt:2025-05-19 15:27:54\",\n    \"extension\"  : \"mp4\",\n    \"filename\"   : \"test\",\n    \"id\"         : \"8xz8hcYJ\",\n    \"mime_type\"  : \"video/mp4\",\n    \"name\"       : \"test.mp4\",\n    \"path\"       : \"/8xz8hcYJ\",\n    \"hash_sha256\": \"c6293d8359cb84723bbf8cf355da6cf1ef9c3e8b3d465110e91db485e53ada54\",\n    \"share_url\"  : \"https://pixeldrain.com/d/8xz8hcYJ\",\n    \"size\"       : 3026,\n    \"type\"       : \"file\",\n},\n\n{\n    \"#url\"     : \"https://pixeldrain.com/d/DkdR6QRh\",\n    \"#comment\" : \"dir with file\",\n    \"#category\": (\"\", \"pixeldrain\", \"folder\"),\n    \"#class\"   : pixeldrain.PixeldrainFolderExtractor,\n    \"#results\" : (\"https://pixeldrain.com/api/filesystem/DkdR6QRh/test.mp4?attach\"),\n\n    \"id\": \"DkdR6QRh\",\n},\n\n{\n    \"#url\"     : \"https://pixeldrain.com/d/STAcYjEh\",\n    \"#comment\" : \"dir with subdir\",\n    \"#category\": (\"\", \"pixeldrain\", \"folder\"),\n    \"#class\"   : pixeldrain.PixeldrainFolderExtractor,\n\n    \"id\": \"STAcYjEh\",\n},\n\n{\n    \"#url\"     : \"https://pixeldrain.com/d/qTnZkhCJ\",\n    \"#comment\" : \"dir with subdir and files\",\n    \"#category\": (\"\", \"pixeldrain\", \"folder\"),\n    \"#class\"   : pixeldrain.PixeldrainFolderExtractor,\n    \"#results\" : (\n        \"https://pixeldrain.com/api/filesystem/qTnZkhCJ/test1.mp4?attach\",\n        \"https://pixeldrain.com/api/filesystem/qTnZkhCJ/test2.mp4?attach\",\n    ),\n\n    \"id\": \"qTnZkhCJ\",\n},\n\n{\n    \"#url\"     : \"https://pixeldrain.com/d/qTnZkhCJ/subdir/test3.mp4\",\n    \"#comment\" : \"file in subdir\",\n    \"#category\": (\"\", \"pixeldrain\", \"folder\"),\n    \"#class\"   : pixeldrain.PixeldrainFolderExtractor,\n    \"#results\" : (\n        \"https://pixeldrain.com/api/filesystem/qTnZkhCJ/subdir/test3.mp4?attach\",\n    ),\n\n    \"date\"       : \"dt:2025-05-20 19:02:08\",\n    \"extension\"  : \"mp4\",\n    \"filename\"   : \"test3\",\n    \"hash_sha256\": \"c6293d8359cb84723bbf8cf355da6cf1ef9c3e8b3d465110e91db485e53ada54\",\n    \"id\"         : \"qTnZkhCJ\",\n    \"mime_type\"  : \"video/mp4\",\n    \"name\"       : \"test3.mp4\",\n    \"path\"       : \"/qTnZkhCJ/subdir/test3.mp4\",\n    \"share_url\"  : \"https://pixeldrain.com/d/qTnZkhCJ/subdir/test3.mp4\",\n    \"size\"       : 3026,\n    \"type\"       : \"file\",\n},\n\n)\n"
  },
  {
    "path": "test/results/pixhost.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import imagehosts\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://pixhost.to/show/190/130327671_test-.png\",\n    \"#category\": (\"imagehost\", \"pixhost\", \"image\"),\n    \"#class\"   : imagehosts.PixhostImageExtractor,\n    \"#sha1_url\"     : \"4e5470dcf6513944773044d40d883221bbc46cff\",\n    \"#sha1_content\" : \"0c8768055e4e20e7c7259608b67799171b691140\",\n\n    \"filename\" : \"130327671_test-\",\n    \"extension\": \"png\",\n    \"directory\": \"190\",\n    \"token\"    : \"130327671\",\n    \"post_url\" : \"https://pixhost.to/show/190/130327671_test-.png\",\n},\n\n{\n    \"#url\"     : \"https://pixhost.to/gallery/jSMFq\",\n    \"#category\": (\"imagehost\", \"pixhost\", \"gallery\"),\n    \"#class\"   : imagehosts.PixhostGalleryExtractor,\n    \"#pattern\" : imagehosts.PixhostImageExtractor.pattern,\n    \"#count\"   : 3,\n},\n\n)\n"
  },
  {
    "path": "test/results/pixiv.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import pixiv\nfrom gallery_dl import exception\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://www.pixiv.net/en/users/173530\",\n    \"#class\"   : pixiv.PixivUserExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.pixiv.net/en/users/173530\",\n    \"#class\"   : pixiv.PixivUserExtractor,\n    \"#options\" : {\"include\": \"all\"},\n    \"#results\" : (\n        \"https://www.pixiv.net/users/173530/avatar\",\n        \"https://www.pixiv.net/users/173530/background\",\n        \"https://www.pixiv.net/users/173530/artworks\",\n        \"https://www.pixiv.net/users/173530/bookmarks/artworks\",\n        \"https://www.pixiv.net/users/173530/bookmarks/novels\",\n        \"https://www.pixiv.net/users/173530/novels\",\n        \"https://sketch.pixiv.net/@del_shannon\",\n        \"https://www.pixiv.net/users/173530/bookmarks/novels\",\n        \"https://www.pixiv.net/users/173530/novels\",\n    ),\n},\n\n{\n    \"#url\"     : \"https://www.pixiv.net/u/173530\",\n    \"#class\"   : pixiv.PixivUserExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.pixiv.net/member.php?id=173530\",\n    \"#class\"   : pixiv.PixivUserExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.pixiv.net/mypage.php#id=173530\",\n    \"#class\"   : pixiv.PixivUserExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.pixiv.net/#id=173530\",\n    \"#class\"   : pixiv.PixivUserExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.pixiv.net/en/users/173530/artworks\",\n    \"#class\"   : pixiv.PixivArtworksExtractor,\n    \"#options\" : {\"metadata\": True},\n    \"#sha1_url\": \"852c31ad83b6840bacbce824d85f2a997889efb7\",\n\n    \"profile\": {\n        \"address_id\": 0,\n        \"background_image_url\": None,\n        \"birth\": \"\",\n        \"birth_day\": \"\",\n        \"birth_year\": 0,\n        \"country_code\": \"\",\n        \"gender\": \"male\",\n        \"is_premium\": False,\n        \"is_using_custom_profile_image\": True,\n        \"job\": \"\",\n        \"job_id\": 0,\n        \"pawoo_url\": None,\n        \"region\": \"\",\n        \"total_follow_users\": 16,\n        \"total_illust_bookmarks_public\": range(5, 20),\n        \"total_illust_series\": 0,\n        \"total_illusts\": 17,\n        \"total_manga\": 0,\n        \"total_mypixiv_users\": 0,\n        \"total_novel_series\": 0,\n        \"total_novels\": 0,\n        \"twitter_account\": \"\",\n        \"twitter_url\": None,\n        \"webpage\": None,\n    },\n    \"profile_publicity\": {\n        \"birth_day\": \"public\",\n        \"birth_year\": \"public\",\n        \"gender\": \"public\",\n        \"job\": \"public\",\n        \"pawoo\": True,\n        \"region\": \"public\",\n    },\n    \"user\": {\n        \"account\": \"del_shannon\",\n        \"comment\": \"基本　お絵かき掲示板で書いたものＵＰしております。\\r\\nVistaとの相性最悪で泣きそうな毎日です。\\r\\nメモリは大幅増で一般使用はサクサクなだけに・・・。orz\",\n        \"id\": 173530,\n        \"is_access_blocking_user\": False,\n        \"is_followed\": False,\n        \"name\": \"syuri\",\n        \"profile_image_urls\": {\n            \"medium\": \"https://i.pximg.net/user-profile/img/2008/06/17/01/28/01/171098_fc06efd15628e2ee252941ae5298b5ff_170.jpg\",\n        },\n    },\n    \"workspace\": {\n        \"chair\": \"\",\n        \"comment\": \"\",\n        \"desk\": \"\",\n        \"desktop\": \"\",\n        \"monitor\": \"\",\n        \"mouse\": \"\",\n        \"music\": \"古い陸軍行進曲「ジェッディン・デデン」\",\n        \"pc\": \"\",\n        \"printer\": \"\",\n        \"scanner\": \"\",\n        \"tablet\": \"わこむ\",\n        \"tool\": \"\",\n        \"workspace_image_url\": None,\n    },\n},\n\n{\n    \"#url\"     : \"https://www.pixiv.net/en/users/173530/artworks\",\n    \"#comment\" : \"Invalid PHPSESSID cookie\",\n    \"#class\"   : pixiv.PixivArtworksExtractor,\n    \"#options\" : {\"cookies\": {\"PHPSESSID\": \"12345_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}},\n    \"#log\"     : \"Invalid 'PHPSESSID' cookie\",\n    \"#sha1_url\": \"852c31ad83b6840bacbce824d85f2a997889efb7\",\n},\n\n{\n    \"#url\"     : \"https://www.pixiv.net/en/users/173530/artworks/%E6%89%8B%E3%81%B6%E3%82%8D\",\n    \"#comment\" : \"illusts with specific tag\",\n    \"#class\"   : pixiv.PixivArtworksExtractor,\n    \"#sha1_url\": \"25b1cd81153a8ff82eec440dd9f20a4a22079658\",\n},\n\n{\n    \"#url\"     : \"https://www.pixiv.net/member_illust.php?id=173530&tag=%E6%89%8B%E3%81%B6%E3%82%8D\",\n    \"#class\"   : pixiv.PixivArtworksExtractor,\n    \"#sha1_url\": \"25b1cd81153a8ff82eec440dd9f20a4a22079658\",\n},\n\n{\n    \"#url\"     : \"http://www.pixiv.net/member_illust.php?id=173531\",\n    \"#comment\" : \"deleted account\",\n    \"#class\"   : pixiv.PixivArtworksExtractor,\n    \"#options\"  : {\"metadata\": True},\n    \"#exception\": exception.NotFoundError,\n},\n\n{\n    \"#url\"     : \"https://www.pixiv.net/users/91306124/artworks\",\n    \"#comment\" : \"deleted account with a different error\",\n    \"#class\"   : pixiv.PixivArtworksExtractor,\n    \"#log\"     : \"'User has left pixiv or the user ID does not exist.'\",\n    \"#exception\": exception.NotFoundError,\n},\n\n{\n    \"#url\"     : \"https://www.pixiv.net/en/users/56514424/artworks\",\n    \"#comment\" : \"limit_sanity_level_360.png in artworks results (#5435, #6339)\",\n    \"#class\"   : pixiv.PixivArtworksExtractor,\n    \"#count\"   : \">= 39\",\n},\n\n{\n    \"#url\"     : \"https://www.pixiv.net/en/users/173530/manga\",\n    \"#class\"   : pixiv.PixivArtworksExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.pixiv.net/en/users/173530/illustrations\",\n    \"#class\"   : pixiv.PixivArtworksExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.pixiv.net/member_illust.php?id=173530\",\n    \"#class\"   : pixiv.PixivArtworksExtractor,\n},\n\n{\n    \"#url\"     : \"https://touch.pixiv.net/member_illust.php?id=173530\",\n    \"#class\"   : pixiv.PixivArtworksExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.phixiv.net/member_illust.php?id=173530\",\n    \"#class\"   : pixiv.PixivArtworksExtractor,\n},\n\n{\n    \"#url\"     : \"https://phixiv.net/en/users/56514424/artworks\",\n    \"#class\"   : pixiv.PixivArtworksExtractor,\n},\n\n{\n    \"#url\"      : \"https://www.pixiv.net/users/70060776/artworks\",\n    \"#comment\"  : \"suspended account (#7990)\",\n    \"#class\"    : pixiv.PixivArtworksExtractor,\n    \"#exception\": exception.NotFoundError,\n},\n\n{\n    \"#url\"     : \"https://www.pixiv.net/users/84930793/artworks\",\n    \"#comment\" : \"empty profile (#8066)\",\n    \"#class\"   : pixiv.PixivArtworksExtractor,\n    \"#count\"   : 0,\n},\n\n{\n    \"#url\"     : \"https://www.pixiv.net/en/users/173530/avatar\",\n    \"#class\"   : pixiv.PixivAvatarExtractor,\n    \"#options\" : {\n        \"metadata\"         : True,\n        \"metadata-bookmark\": True,\n        \"captions\"         : True,\n        \"comments\"         : True,\n    },\n    \"#sha1_content\": \"4e57544480cc2036ea9608103e8f024fa737fe66\",\n},\n\n{\n    \"#url\"     : \"https://www.pixiv.net/en/users/194921/background\",\n    \"#class\"   : pixiv.PixivBackgroundExtractor,\n    \"#options\" : {\n        \"metadata\"         : True,\n        \"metadata-bookmark\": True,\n        \"captions\"         : True,\n        \"comments\"         : True,\n    },\n    \"#pattern\" : r\"https://i\\.pximg\\.net/background/img/2021/01/30/16/12/02/194921_af1f71e557a42f499213d4b9eaccc0f8\\.jpg\",\n},\n\n{\n    \"#url\"     : \"https://pixiv.me/del_shannon\",\n    \"#class\"   : pixiv.PixivMeExtractor,\n    \"#sha1_url\": \"29c295ce75150177e6b0a09089a949804c708fbf\",\n},\n\n{\n    \"#url\"     : \"https://pixiv.me/del_shanno\",\n    \"#class\"   : pixiv.PixivMeExtractor,\n    \"#exception\": exception.NotFoundError,\n},\n\n{\n    \"#url\"     : \"https://www.pixiv.net/artworks/966412\",\n    \"#class\"   : pixiv.PixivWorkExtractor,\n    \"#sha1_url\"    : \"90c1715b07b0d1aad300bce256a0bc71f42540ba\",\n    \"#sha1_content\": \"69a8edfb717400d1c2e146ab2b30d2c235440c5a\",\n\n    \"count\"   : 1,\n    \"num\"     : 0,\n    \"date\"    : \"dt:2008-06-12 15:29:13\",\n    \"date_url\": \"dt:2008-06-12 15:29:13\",\n},\n\n{\n    \"#url\"     : \"http://www.pixiv.net/member_illust.php?mode=medium&illust_id=966411\",\n    \"#class\"   : pixiv.PixivWorkExtractor,\n    \"#exception\": exception.NotFoundError,\n},\n\n{\n    \"#url\"     : \"https://www.pixiv.net/member_illust.php?mode=medium&illust_id=66806629\",\n    \"#comment\" : \"ugoira\",\n    \"#class\"   : pixiv.PixivWorkExtractor,\n    \"#results\" : \"https://i.pximg.net/img-zip-ugoira/img/2018/01/15/13/24/48/66806629_ugoira1920x1080.zip\",\n\n    \"frames\"  : list,\n    \"count\"   : 1,\n    \"date\"    : \"dt:2018-01-14 15:06:08\",\n    \"date_url\": \"dt:2018-01-15 04:24:48\",\n},\n\n{\n    \"#url\"     : \"https://www.pixiv.net/artworks/101003492\",\n    \"#comment\" : \"ugoira - original '.png' frames (#6056)\",\n    \"#class\"   : pixiv.PixivWorkExtractor,\n    \"#options\" : {\"ugoira\": \"original\"},\n    \"#results\" : (\n        \"https://i.pximg.net/img-original/img/2022/09/04/23/54/19/101003492_ugoira0.png\",\n        \"https://i.pximg.net/img-original/img/2022/09/04/23/54/19/101003492_ugoira1.png\",\n        \"https://i.pximg.net/img-original/img/2022/09/04/23/54/19/101003492_ugoira2.png\",\n        \"https://i.pximg.net/img-original/img/2022/09/04/23/54/19/101003492_ugoira3.png\",\n        \"https://i.pximg.net/img-original/img/2022/09/04/23/54/19/101003492_ugoira4.png\",\n        \"https://i.pximg.net/img-original/img/2022/09/04/23/54/19/101003492_ugoira5.png\",\n    ),\n\n    \"frames\": list,\n    \"count\" : 6,\n},\n\n{\n    \"#url\"     : \"https://www.pixiv.net/en/artworks/108469527\",\n    \"#comment\" : \"ugoira - '.gif' frames\",\n    \"#class\"   : pixiv.PixivWorkExtractor,\n    \"#options\" : {\"ugoira\": \"original\"},\n    \"#pattern\" : r\"https://i\\.pximg\\.net/img\\-original/img/2023/05/27/02/23/48/108469527_ugoira\\d+\\.gif\",\n    \"#count\"   : 30,\n\n    \"frames\": list,\n    \"count\" : 30,\n},\n\n{\n    \"#url\"     : \"https://www.pixiv.net/artworks/966412\",\n    \"#comment\" : \"related works (#1237)\",\n    \"#class\"   : pixiv.PixivWorkExtractor,\n    \"#options\" : {\"related\": True},\n    \"#range\"   : \"1-10\",\n    \"#count\"   : \">= 10\",\n},\n\n{\n    \"#url\"     : \"https://www.pixiv.net/artworks/85960783\",\n    \"#comment\" : \"limit_sanity_level_360.png (#4327, #5180)\",\n    \"#class\"   : pixiv.PixivWorkExtractor,\n    \"#options\" : {\"sanity\": False},\n    \"#count\"   : 0,\n},\n\n{\n    \"#url\"     : \"https://www.pixiv.net/en/artworks/102932581\",\n    \"#comment\" : \"limit_sanity_level_360.png (#4327, #5180)\",\n    \"#class\"   : pixiv.PixivWorkExtractor,\n    \"#options\" : {\"sanity\": True, \"comments\": True},\n    \"#results\" : \"https://i.pximg.net/img-original/img/2022/11/20/00/00/49/102932581_p0.jpg\",\n\n    \"caption\"       : \"Meet a deer .\",\n    \"comment_access_control\": 0,\n    \"comments\"      : (),\n    \"count\"         : 1,\n    \"create_date\"   : \"2022-11-19T15:00:00+00:00\",\n    \"date\"          : \"dt:2022-11-19 15:00:00\",\n    \"date_url\"      : \"dt:2022-11-19 15:00:49\",\n    \"extension\"     : \"jpg\",\n    \"filename\"      : \"102932581_p0\",\n    \"height\"        : 3840,\n    \"id\"            : 102932581,\n    \"illust_ai_type\": 1,\n    \"illust_book_style\": 0,\n    \"is_bookmarked\" : False,\n    \"is_muted\"      : False,\n    \"num\"           : 0,\n    \"page_count\"    : 1,\n    \"rating\"        : \"General\",\n    \"restrict\"      : 0,\n    \"sanity_level\"  : 2,\n    \"series\"        : None,\n    \"suffix\"        : \"\",\n    \"title\"         : \"《 Bridge and Deer 》\",\n    \"tools\"         : [],\n    \"total_bookmarks\": range(1900, 3000),\n    \"total_comments\": range(3, 10),\n    \"total_view\"    : range(11000, 20000),\n    \"type\"          : \"illust\",\n    \"url\"           : \"https://i.pximg.net/img-original/img/2022/11/20/00/00/49/102932581_p0.jpg\",\n    \"visible\"       : False,\n    \"width\"         : 2160,\n    \"x_restrict\"    : 0,\n    \"image_urls\"    : {\n        \"mini\"    : \"https://i.pximg.net/c/48x48/custom-thumb/img/2022/11/20/00/00/49/102932581_p0_custom1200.jpg\",\n        \"original\": \"https://i.pximg.net/img-original/img/2022/11/20/00/00/49/102932581_p0.jpg\",\n        \"regular\" : \"https://i.pximg.net/img-master/img/2022/11/20/00/00/49/102932581_p0_master1200.jpg\",\n        \"small\"   : \"https://i.pximg.net/c/540x540_70/img-master/img/2022/11/20/00/00/49/102932581_p0_master1200.jpg\",\n        \"thumb\"   : \"https://i.pximg.net/c/250x250_80_a2/custom-thumb/img/2022/11/20/00/00/49/102932581_p0_custom1200.jpg\",\n    },\n    \"tags\"          : [\n        \"オリジナル\",\n        \"風景\",\n        \"イラスト\",\n        \"illustration\",\n        \"美しい\",\n        \"女の子\",\n        \"少女\",\n        \"deer\",\n        \"flower\",\n        \"spring\",\n    ],\n    \"user\"          : {\n        \"account\"    : \"805482263\",\n        \"id\"         : 7386235,\n        \"is_followed\": False,\n        \"name\"       : \"岛的鲸\",\n        \"profile_image_urls\": {},\n    },\n},\n\n{\n    \"#url\"     : \"https://www.pixiv.net/en/artworks/109487939\",\n    \"#comment\" : \"R-18 limit_sanity_level_360.png (#4327, #5180)\",\n    \"#class\"   : pixiv.PixivWorkExtractor,\n    \"#results\" : (\n        \"https://i.pximg.net/img-original/img/2023/07/01/00/06/28/109487939_p0.png\",\n        \"https://i.pximg.net/img-original/img/2023/07/01/00/06/28/109487939_p1.png\",\n        \"https://i.pximg.net/img-original/img/2023/07/01/00/06/28/109487939_p2.png\",\n        \"https://i.pximg.net/img-original/img/2023/07/01/00/06/28/109487939_p3.png\",\n    ),\n},\n\n{\n    \"#url\"     : \"https://www.pixiv.net/en/artworks/103841583\",\n    \"#comment\" : \"Ugoira limit_sanity_level_360.png (#4327 #6297 #7285)\",\n    \"#class\"   : pixiv.PixivWorkExtractor,\n    \"#auth\"    : True,\n    \"#results\" : \"https://i.pximg.net/img-zip-ugoira/img/2022/12/23/23/36/13/103841583_ugoira1920x1080.zip\",\n},\n\n{\n    \"#url\"     : \"https://www.pixiv.net/en/artworks/104582860\",\n    \"#comment\" : \"deleted limit_sanity_level_360.png work (#6339)\",\n    \"#class\"   : pixiv.PixivWorkExtractor,\n    \"#count\"   : 0,\n    \"#exception\": exception.NotFoundError,\n},\n\n{\n    \"#url\"     : \"https://www.pixiv.net/en/artworks/103983466\",\n    \"#comment\" : \"empty 'caption' in App API response (#4327, #5191)\",\n    \"#class\"   : pixiv.PixivWorkExtractor,\n    \"#options\" : {\"captions\": True},\n\n    \"caption\": r\"re:Either she doesn't know how to pose or she can't move with that much clothing on her, in any case she's very well dressed for a holiday trip around town. Lots of stuff to see and a perfect day to grab some sweet pastries at the bakery.<br />...\",\n},\n\n{\n    \"#url\"     : \"https://www.pixiv.net/artworks/56360615\",\n    \"#comment\" : \"fallback; 'original' version results in HTTP 500 error (#6762)\",\n    \"#class\"   : pixiv.PixivWorkExtractor,\n    \"#options\" : {\"retries\": 0},\n    \"#range\"   : \"4\",\n    \"#sha1_content\": \"aa119c27fec0a36bbd06e7491987acf5f1be6293\",\n},\n\n{\n    \"#url\"     : \"https://www.pixiv.net/artworks/56360615\",\n    \"#comment\" : \"limit_unviewable_s / unavailable without cookies (#7940)\",\n    \"#class\"   : pixiv.PixivWorkExtractor,\n    \"#count\"   : 11,\n},\n\n{\n    \"#url\"     : \"https://www.pixiv.net/en/artworks/966412\",\n    \"#class\"   : pixiv.PixivWorkExtractor,\n},\n\n{\n    \"#url\"     : \"http://www.pixiv.net/member_illust.php?mode=medium&illust_id=96641\",\n    \"#class\"   : pixiv.PixivWorkExtractor,\n},\n\n{\n    \"#url\"     : \"http://i1.pixiv.net/c/600x600/img-master/img/2008/06/13/00/29/13/966412_p0_master1200.jpg\",\n    \"#class\"   : pixiv.PixivWorkExtractor,\n},\n\n{\n    \"#url\"     : \"https://i.pximg.net/img-original/img/2017/04/25/07/33/29/62568267_p0.png\",\n    \"#class\"   : pixiv.PixivWorkExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.pixiv.net/i/966412\",\n    \"#class\"   : pixiv.PixivWorkExtractor,\n},\n\n{\n    \"#url\"     : \"http://img.pixiv.net/img/soundcross/42626136.jpg\",\n    \"#class\"   : pixiv.PixivWorkExtractor,\n},\n\n{\n    \"#url\"     : \"http://i2.pixiv.net/img76/img/snailrin/42672235.jpg\",\n    \"#class\"   : pixiv.PixivWorkExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.phixiv.net/en/artworks/966412\",\n    \"#class\"   : pixiv.PixivWorkExtractor,\n},\n\n{\n    \"#url\"     : \"https://phixiv.net/member_illust.php?mode=medium&illust_id=966412\",\n    \"#class\"   : pixiv.PixivWorkExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.pixiv.net/en/artworks/141762848\",\n    \"#comment\" : \"'hash' metadata\",\n    \"#class\"   : pixiv.PixivWorkExtractor,\n    \"#results\" : \"https://i.pximg.net/img-original/img/2026/03/01/07/35/25/141762848-757d4d64b92a41c496c04aa34ae56855_p0.jpg\",\n\n    \"caption\"  : \"\"\"🐰＜ Twitter [ <strong><a href=\"https://twitter.com/MityaStar\" target=\"_blank\">twitter/MityaStar</a></strong> ]<br />🐰＜ FANBOX [ <a href=\"https://shootingstar.fanbox.cc/\" target=\"_blank\">https://shootingstar.fanbox.cc/</a> ]<br />🐰＜ Skeb [ <a href=\"https://skeb.jp/@MityaStar\" target='_blank' rel='noopener noreferrer'>https://skeb.jp/@MityaStar</a> ]\"\"\",\n    \"date\"     : \"dt:2026-02-28 22:35:25\",\n    \"date_url\" : \"dt:2026-02-28 22:35:25\",\n    \"extension\": \"jpg\",\n    \"filename\" : \"141762848-757d4d64b92a41c496c04aa34ae56855_p0\",\n    \"hash\"     : \"757d4d64b92a41c496c04aa34ae56855\",\n    \"width\"    : 816,\n    \"height\"   : 1200,\n    \"id\"       : 141762848,\n    \"title\"    : \"シャロちゃん\",\n    \"tags\"     : [\n        \"ご注文はうさぎですか?\",\n        \"桐間紗路\",\n        \"シャロ\",\n        \"内田真礼\",\n    ],\n    \"user\"     : {\n        \"account\"    : \"witch_shootingstar\",\n        \"id\"         : 213128,\n        \"name\"       : \"みーちゃ\",\n    },\n},\n\n{\n    \"#url\"     : \"https://i.pximg.net/img-original/img/2026/03/01/07/35/25/141762848-757d4d64b92a41c496c04aa34ae56855_p0.jpg\",\n    \"#comment\" : \"'hash' metadata - direct link URL\",\n    \"#class\"   : pixiv.PixivWorkExtractor,\n    \"#results\" : \"https://i.pximg.net/img-original/img/2026/03/01/07/35/25/141762848-757d4d64b92a41c496c04aa34ae56855_p0.jpg\",\n\n    \"filename\" : \"141762848-757d4d64b92a41c496c04aa34ae56855_p0\",\n    \"hash\"     : \"757d4d64b92a41c496c04aa34ae56855\",\n    \"id\"       : 141762848,\n},\n\n{\n    \"#url\"     : \"https://www.pixiv.net/en/artworks/unlisted/eE3fTYaROT9IsZmep386\",\n    \"#class\"   : pixiv.PixivUnlistedExtractor,\n    \"#results\" : \"https://i.pximg.net/img-original/img/2020/10/15/00/46/12/85017704-149014193e4d3e23a6b8bd5e38b51ed4_p0.png\",\n\n    \"id\"         : 85017704,\n    \"id_unlisted\": \"eE3fTYaROT9IsZmep386\",\n},\n\n{\n    \"#url\"     : \"https://www.pixiv.net/en/users/173530/bookmarks/artworks\",\n    \"#class\"   : pixiv.PixivFavoriteExtractor,\n    \"#results\" : (\n        \"https://i.pximg.net/img-original/img/2025/06/25/02/06/58/131943241_p0.png\",\n        \"https://i.pximg.net/img-original/img/2025/07/02/03/22/51/132200601_p0.jpg\",\n        \"https://i.pximg.net/img-original/img/2008/10/31/17/54/01/2005108_p0.jpg\",\n        \"https://i.pximg.net/img-original/img/2008/09/27/12/22/40/1719386_p0.jpg\",\n        \"https://i.pximg.net/img-original/img/2008/04/15/01/43/46/669358_p0.jpg\",\n        \"https://i.pximg.net/img-original/img/2008/06/19/21/52/15/1005851_p0.jpg\",\n        \"https://i.pximg.net/img-original/img/2008/06/17/22/16/54/994965_p0.jpg\",\n    ),\n    \"#log\": (\n        (\"warning\", \"1679677: 'My pixiv' locked\"),\n    ),\n},\n\n{\n    \"#url\"     : \"https://www.pixiv.net/bookmark.php?id=173530\",\n    \"#class\"   : pixiv.PixivFavoriteExtractor,\n    \"#results\" : (\n        \"https://i.pximg.net/img-original/img/2025/06/25/02/06/58/131943241_p0.png\",\n        \"https://i.pximg.net/img-original/img/2025/07/02/03/22/51/132200601_p0.jpg\",\n        \"https://i.pximg.net/img-original/img/2008/10/31/17/54/01/2005108_p0.jpg\",\n        \"https://i.pximg.net/img-original/img/2008/09/27/12/22/40/1719386_p0.jpg\",\n        \"https://i.pximg.net/img-original/img/2008/04/15/01/43/46/669358_p0.jpg\",\n        \"https://i.pximg.net/img-original/img/2008/06/19/21/52/15/1005851_p0.jpg\",\n        \"https://i.pximg.net/img-original/img/2008/06/17/22/16/54/994965_p0.jpg\",\n    ),\n    \"#log\": (\n        (\"warning\", \"1679677: 'My pixiv' locked\"),\n    ),\n},\n\n{\n    \"#url\"     : \"https://www.pixiv.net/en/users/3137110/bookmarks/artworks/%E3%81%AF%E3%82%93%E3%82%82%E3%82%93\",\n    \"#comment\" : \"bookmarks with specific tag\",\n    \"#class\"   : pixiv.PixivFavoriteExtractor,\n    \"#sha1_url\": \"379b28275f786d946e01f721e54afe346c148a8c\",\n},\n\n{\n    \"#url\"     : \"https://www.pixiv.net/bookmark.php?id=3137110&tag=%E3%81%AF%E3%82%93%E3%82%82%E3%82%93&p=1\",\n    \"#comment\" : \"bookmarks with specific tag (legacy url)\",\n    \"#class\"   : pixiv.PixivFavoriteExtractor,\n    \"#sha1_url\": \"379b28275f786d946e01f721e54afe346c148a8c\",\n},\n\n{\n    \"#url\"     : \"https://www.pixiv.net/bookmark.php\",\n    \"#comment\" : \"own bookmarks\",\n    \"#category\": (\"\", \"pixiv\", \"bookmark\"),\n    \"#class\"   : pixiv.PixivFavoriteExtractor,\n    \"#options\" : {\"metadata-bookmark\": True},\n    \"#sha1_url\": \"90c1715b07b0d1aad300bce256a0bc71f42540ba\",\n\n    \"tags_bookmark\": [\n        \"47\",\n        \"hitman\",\n    ],\n},\n\n{\n    \"#url\"     : \"https://www.pixiv.net/bookmark.php?tag=foobar\",\n    \"#comment\" : \"own bookmarks with tag (#596)\",\n    \"#category\": (\"\", \"pixiv\", \"bookmark\"),\n    \"#class\"   : pixiv.PixivFavoriteExtractor,\n    \"#count\"   : 0,\n},\n\n{\n    \"#url\"     : \"https://www.pixiv.net/en/users/173530/following\",\n    \"#comment\" : \"followed users (#515)\",\n    \"#category\": (\"\", \"pixiv\", \"following\"),\n    \"#class\"   : pixiv.PixivFavoriteExtractor,\n    \"#pattern\" : pixiv.PixivUserExtractor.pattern,\n    \"#count\"   : \">= 12\",\n},\n\n{\n    \"#url\"     : \"https://www.pixiv.net/bookmark.php?id=173530&type=user\",\n    \"#comment\" : \"followed users (legacy url) (#515)\",\n    \"#category\": (\"\", \"pixiv\", \"following\"),\n    \"#class\"   : pixiv.PixivFavoriteExtractor,\n    \"#pattern\" : pixiv.PixivUserExtractor.pattern,\n    \"#count\"   : \">= 12\",\n},\n\n{\n    \"#url\"     : \"https://touch.pixiv.net/bookmark.php?id=173530\",\n    \"#comment\" : \"touch URLs\",\n    \"#class\"   : pixiv.PixivFavoriteExtractor,\n},\n\n{\n    \"#url\"     : \"https://touch.pixiv.net/bookmark.php\",\n    \"#category\": (\"\", \"pixiv\", \"bookmark\"),\n    \"#class\"   : pixiv.PixivFavoriteExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.pixiv.net/ranking.php?mode=daily&date=20170818\",\n    \"#class\"   : pixiv.PixivRankingExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.pixiv.net/ranking.php\",\n    \"#class\"   : pixiv.PixivRankingExtractor,\n    \"#options\" : {\"max-posts\": 10},\n\n    \"ranking\": {\n        \"date\": r\"re:\\d\\d\\d\\d-\\d\\d-\\d\\d\",\n        \"mode\": \"day\",\n        \"rank\": range(1, 10),\n    },\n},\n\n{\n    \"#url\"     : \"https://touch.pixiv.net/ranking.php\",\n    \"#class\"   : pixiv.PixivRankingExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.pixiv.net/ranking.php?mode=unknown\",\n    \"#class\"   : pixiv.PixivRankingExtractor,\n    \"#exception\": exception.AbortExtraction,\n},\n\n{\n    \"#url\"     : \"https://www.pixiv.net/en/tags/Original\",\n    \"#class\"   : pixiv.PixivSearchExtractor,\n    \"#range\"   : \"1-10\",\n    \"#count\"   : 10,\n},\n\n{\n    \"#url\"     : \"https://pixiv.net/en/tags/foo/artworks?order=week&s_mode=s_tag\",\n    \"#class\"   : pixiv.PixivSearchExtractor,\n    \"#exception\": exception.AbortExtraction,\n},\n\n{\n    \"#url\"     : \"https://pixiv.net/en/tags/foo/artworks?order=date&s_mode=tag\",\n    \"#class\"   : pixiv.PixivSearchExtractor,\n    \"#exception\": exception.AbortExtraction,\n},\n\n{\n    \"#url\"     : \"https://www.pixiv.net/search.php?s_mode=s_tag&name=Original\",\n    \"#class\"   : pixiv.PixivSearchExtractor,\n    \"#exception\": exception.AbortExtraction,\n},\n\n{\n    \"#url\"     : \"https://www.pixiv.net/en/tags/foo/artworks?order=date&s_mode=s_tag\",\n    \"#class\"   : pixiv.PixivSearchExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.pixiv.net/search.php?s_mode=s_tag&word=Original\",\n    \"#class\"   : pixiv.PixivSearchExtractor,\n},\n\n{\n    \"#url\"     : \"https://touch.pixiv.net/search.php?word=Original\",\n    \"#class\"   : pixiv.PixivSearchExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.pixiv.net/bookmark_new_illust.php\",\n    \"#class\"   : pixiv.PixivFollowedExtractor,\n},\n\n{\n    \"#url\"     : \"https://touch.pixiv.net/bookmark_new_illust.php\",\n    \"#class\"   : pixiv.PixivFollowedExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.pixivision.net/en/a/2791\",\n    \"#class\"   : pixiv.PixivPixivisionExtractor,\n},\n\n{\n    \"#url\"     : \"https://pixivision.net/a/2791\",\n    \"#class\"   : pixiv.PixivPixivisionExtractor,\n    \"#count\"   : 7,\n\n    \"pixivision_id\"   : \"2791\",\n    \"pixivision_title\": \"What's your favorite music? Editor’s picks featuring: “CD Covers”!\",\n},\n\n{\n    \"#url\"     : \"https://www.pixiv.net/user/10509347/series/21859\",\n    \"#class\"   : pixiv.PixivSeriesExtractor,\n    \"#range\"   : \"1-10\",\n    \"#count\"   : 10,\n\n    \"num_series\": int,\n    \"series\"    : {\n        \"create_date\": \"2017-10-22T14:07:42+09:00\",\n        \"width\" : 4250,\n        \"height\": 3009,\n        \"id\"    : 21859,\n        \"title\" : \"先輩がうざい後輩の話\",\n        \"total\" : range(100, 500),\n        \"user\"  : dict,\n        \"watchlist_added\": False,\n    },\n},\n\n{\n    \"#url\"     : \"https://sketch.pixiv.net/@nicoby\",\n    \"#class\"   : pixiv.PixivSketchExtractor,\n    \"#pattern\" : r\"https://img\\-sketch\\.pixiv\\.net/uploads/medium/file/\\d+/\\d+\\.(jpg|png)\",\n    \"#count\"   : \">= 35\",\n\n    \"date\": \"type:datetime\",\n},\n\n)\n"
  },
  {
    "path": "test/results/pixivnovel.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import pixiv\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://www.pixiv.net/novel/show.php?id=12101012\",\n    \"#class\"   : pixiv.PixivNovelNovelExtractor,\n    \"#count\"       : 1,\n    \"#sha1_content\": \"20f4a62f0e87ae2cb9f5a787b6c641bfa4eabf93\",\n\n    \"caption\"        : \"<br />第一印象から決めてました！<br /><br />素敵な表紙はいもこは妹さん(<strong><a href=\\\"pixiv://illusts/53802907\\\">illust/53802907</a></strong>)からお借りしました。<br /><br />たくさんのコメント、タグありがとうございます、本当に嬉しいです。お返事できていませんが、一つ一つ目を通させていただいてます。タイトルも込みで読んでくださってすごく嬉しいです。ありがとうございます……！！<br /><br />■12/19付けルキラン20位を頂きました…！大変混乱していますがすごく嬉しいです。ありがとうございます！　<br /><br />■2019/12/20デイリー15位、女子に人気8位をを頂きました…！？！？！？！？て、手が震える…。ありがとうございます…ひえええ。感謝してもしきれないです…！\",\n    \"create_date\"    : \"2019-12-19T23:14:36+09:00\",\n    \"date\"           : \"dt:2019-12-19 14:14:36\",\n    \"extension\"      : \"txt\",\n    \"id\"             : 12101012,\n    \"image_urls\"     : dict,\n    \"is_bookmarked\"  : False,\n    \"is_muted\"       : False,\n    \"is_mypixiv_only\": False,\n    \"is_original\"    : False,\n    \"is_x_restricted\": False,\n    \"novel_ai_type\"  : 0,\n    \"page_count\"     : 1,\n    \"rating\"         : \"General\",\n    \"restrict\"       : 0,\n    \"series\"         : {\n        \"id\"   : 1479656,\n        \"title\": \"一目惚れした彼らの話\",\n    },\n    \"tags\"           : [\n        \"鬼滅の夢\",\n        \"女主人公\",\n        \"煉獄杏寿郎\",\n        \"涙腺崩壊\",\n        \"なにこれすごい\",\n        \"来世で幸せになって欲しい\",\n        \"キメ学世界線できっと幸せになってる!!\",\n        \"あなたが神か!!\",\n        \"キメ学編を·····\",\n        \"鬼滅の夢小説10000users入り\",\n    ],\n    \"text_length\"    : 9569,\n    \"title\"          : \"本当は、一目惚れだった\",\n    \"total_bookmarks\": range(17900, 20000),\n    \"total_comments\" : range(200, 400),\n    \"total_view\"     : range(158000, 300000),\n    \"user\"           : {\n        \"account\": \"46_maru\",\n        \"id\"     : 888268,\n    },\n    \"visible\"        : True,\n    \"x_restrict\"     : 0,\n},\n\n{\n    \"#url\"     : \"https://www.pixiv.net/novel/show.php?id=16422450\",\n    \"#comment\" : \"embeds // covers (#5373)\",\n    \"#class\"   : pixiv.PixivNovelNovelExtractor,\n    \"#options\" : {\n        \"embeds\": True,\n        \"covers\": True,\n    },\n    \"#count\"   : 4,\n},\n\n{\n    \"#url\"     : \"https://www.pixiv.net/novel/show.php?id=12101012\",\n    \"#comment\" : \"full series\",\n    \"#class\"   : pixiv.PixivNovelNovelExtractor,\n    \"#options\" : {\"full-series\": True},\n    \"#count\"   : 2,\n},\n\n{\n    \"#url\"     : \"https://www.pixiv.net/n/19612040\",\n    \"#comment\" : \"short URL\",\n    \"#class\"   : pixiv.PixivNovelNovelExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.pixiv.net/en/users/77055466/novels\",\n    \"#class\"   : pixiv.PixivNovelUserExtractor,\n    \"#pattern\" : \"^text:\",\n    \"#range\"   : \"1-5\",\n    \"#count\"   : 5,\n},\n\n{\n    \"#url\"     : \"https://www.pixiv.net/novel/series/1479656\",\n    \"#class\"   : pixiv.PixivNovelSeriesExtractor,\n    \"#count\"       : 2,\n    \"#sha1_content\": \"243ce593333bbfe26e255e3372d9c9d8cea22d5b\",\n},\n\n{\n    \"#url\"     : \"https://www.pixiv.net/en/users/77055466/bookmarks/novels\",\n    \"#class\"   : pixiv.PixivNovelBookmarkExtractor,\n    \"#count\"       : 1,\n    \"#sha1_content\": \"7194e8faa876b2b536f185ee271a2b6e46c69089\",\n},\n\n{\n    \"#url\"     : \"https://www.pixiv.net/en/users/11/bookmarks/novels/TAG?rest=hide\",\n    \"#class\"   : pixiv.PixivNovelBookmarkExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/pixnet.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import pixnet\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://albertayu773.pixnet.net/album/photo/159443828\",\n    \"#category\": (\"\", \"pixnet\", \"image\"),\n    \"#class\"   : pixnet.PixnetImageExtractor,\n    \"#sha1_url\"     : \"156564c422138914c9fa5b42191677b45c414af4\",\n    \"#sha1_metadata\": \"19971bcd056dfef5593f4328a723a9602be0f087\",\n    \"#sha1_content\" : \"0e097bdf49e76dd9b9d57a016b08b16fa6a33280\",\n},\n\n{\n    \"#url\"     : \"https://albertayu773.pixnet.net/album/set/15078995\",\n    \"#category\": (\"\", \"pixnet\", \"set\"),\n    \"#class\"   : pixnet.PixnetSetExtractor,\n    \"#sha1_url\"     : \"6535712801af47af51110542f4938a7cef44557f\",\n    \"#sha1_metadata\": \"bf25d59e5b0959cb1f53e7fd2e2a25f2f67e5925\",\n},\n\n{\n    \"#url\"     : \"https://anrine910070.pixnet.net/album/set/5917493\",\n    \"#category\": (\"\", \"pixnet\", \"set\"),\n    \"#class\"   : pixnet.PixnetSetExtractor,\n    \"#sha1_url\"     : \"b3eb6431aea0bcf5003432a4a0f3a3232084fc13\",\n    \"#sha1_metadata\": \"bf7004faa1cea18cf9bd856f0955a69be51b1ec6\",\n},\n\n{\n    \"#url\"     : \"https://sky92100.pixnet.net/album/set/17492544\",\n    \"#comment\" : \"password-protected\",\n    \"#category\": (\"\", \"pixnet\", \"set\"),\n    \"#class\"   : pixnet.PixnetSetExtractor,\n    \"#count\"   : 0,\n},\n\n{\n    \"#url\"     : \"https://albertayu773.pixnet.net/album/folder/1405768\",\n    \"#category\": (\"\", \"pixnet\", \"folder\"),\n    \"#class\"   : pixnet.PixnetFolderExtractor,\n    \"#pattern\" : pixnet.PixnetSetExtractor.pattern,\n    \"#count\"   : \">= 15\",\n},\n\n{\n    \"#url\"     : \"https://albertayu773.pixnet.net/\",\n    \"#category\": (\"\", \"pixnet\", \"user\"),\n    \"#class\"   : pixnet.PixnetUserExtractor,\n},\n\n{\n    \"#url\"     : \"https://albertayu773.pixnet.net/blog\",\n    \"#category\": (\"\", \"pixnet\", \"user\"),\n    \"#class\"   : pixnet.PixnetUserExtractor,\n},\n\n{\n    \"#url\"     : \"https://albertayu773.pixnet.net/album\",\n    \"#category\": (\"\", \"pixnet\", \"user\"),\n    \"#class\"   : pixnet.PixnetUserExtractor,\n},\n\n{\n    \"#url\"     : \"https://albertayu773.pixnet.net/album/list\",\n    \"#category\": (\"\", \"pixnet\", \"user\"),\n    \"#class\"   : pixnet.PixnetUserExtractor,\n    \"#pattern\" : pixnet.PixnetFolderExtractor.pattern,\n    \"#count\"   : \">= 30\",\n},\n\n{\n    \"#url\"     : \"https://anrine910070.pixnet.net/album/list\",\n    \"#category\": (\"\", \"pixnet\", \"user\"),\n    \"#class\"   : pixnet.PixnetUserExtractor,\n    \"#pattern\" : pixnet.PixnetSetExtractor.pattern,\n    \"#count\"   : \">= 14\",\n},\n\n)\n"
  },
  {
    "path": "test/results/plurk.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import plurk\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://www.plurk.com/plurkapi\",\n    \"#category\": (\"\", \"plurk\", \"timeline\"),\n    \"#class\"   : plurk.PlurkTimelineExtractor,\n    \"#pattern\" : \"https?://.+\",\n    \"#count\"   : \">= 23\",\n},\n\n{\n    \"#url\"     : \"https://www.plurk.com/p/i701j1\",\n    \"#category\": (\"\", \"plurk\", \"post\"),\n    \"#class\"   : plurk.PlurkPostExtractor,\n    \"#count\"   : 3,\n    \"#sha1_url\": \"2115f208564591b8748525c2807a84596aaaaa5f\",\n},\n\n{\n    \"#url\"     : \"https://www.plurk.com/p/i701j1\",\n    \"#category\": (\"\", \"plurk\", \"post\"),\n    \"#class\"   : plurk.PlurkPostExtractor,\n    \"#options\" : {\"comments\": True},\n    \"#count\"   : \">= 210\",\n},\n\n)\n"
  },
  {
    "path": "test/results/poipiku.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import poipiku\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://poipiku.com/25049/\",\n    \"#class\"   : poipiku.PoipikuUserExtractor,\n    \"#pattern\" : r\"https://cdn.poipiku.com/\\d+/\\d+_\\w+\\.(jpe?g|png)\\?.+\",\n    \"#range\"   : \"1-10\",\n    \"#count\"   : 10,\n},\n\n{\n    \"#url\"     : \"https://poipiku.com/IllustListPcV.jsp?PG=1&ID=25049&KWD=\",\n    \"#class\"   : poipiku.PoipikuUserExtractor,\n},\n\n{\n    \"#url\"     : \"https://poipiku.com/25049/5864576.html\",\n    \"#class\"   : poipiku.PoipikuPostExtractor,\n    \"#pattern\" : r\"https://cdn.poipiku.com/000025049/005864576_EWN1Y65gQ\\.png\\?Expires=\\d+&Signature=.+&Key-Pair-Id=\\w+$\",\n\n    \"count\"        : 1,\n    \"description\"  : \"\",\n    \"original\"     : True,\n    \"extension\"    : \"png\",\n    \"filename\"     : \"005864576_EWN1Y65gQ\",\n    \"num\"          : 1,\n    \"post_category\": \"DOODLE\",\n    \"post_id\"      : \"5864576\",\n    \"user_id\"      : \"25049\",\n    \"user_name\"    : \"ユキウサギ\",\n},\n\n{\n    \"#url\"     : \"https://poipiku.com/25049/5864576.html\",\n    \"#class\"   : poipiku.PoipikuPostExtractor,\n    \"#auth\"    : False,\n    \"#results\" : \"https://cdn.poipiku.com/000025049/005864576_EWN1Y65gQ.png_640.jpg\",\n\n    \"count\"        : 1,\n    \"description\"  : \"\",\n    \"original\"     : False,\n    \"extension\"    : \"jpg\",\n    \"filename\"     : \"005864576_EWN1Y65gQ.png_640\",\n    \"num\"          : 1,\n    \"post_category\": \"DOODLE\",\n    \"post_id\"      : \"5864576\",\n    \"user_id\"      : \"25049\",\n    \"user_name\"    : \"ユキウサギ\",\n},\n\n{\n    \"#url\"     : \"https://poipiku.com/2166245/6411749.html\",\n    \"#class\"   : poipiku.PoipikuPostExtractor,\n    \"#pattern\" : r\"https://cdn.poipiku.com/002166245/006411749(_\\d+)?_\\w+.jpeg\\?Expires=\\d+&Signature=.+\",\n    \"#count\"   : 4,\n\n    \"count\"        : 4,\n    \"num\"          : range(1, 4),\n    \"description\"  : \"絵茶の産物ネタバレあるやつ\",\n    \"post_category\": \"SPOILER\",\n    \"post_id\"      : \"6411749\",\n    \"user_id\"      : \"2166245\",\n    \"user_name\"    : \"wadahito\",\n},\n\n{\n    \"#url\"     : \"https://poipiku.com/3572553/5776587.html\",\n    \"#comment\" : \"different warning button style\",\n    \"#class\"   : poipiku.PoipikuPostExtractor,\n    \"#pattern\" : r\"https://cdn.poipiku.com/003572553/005776587(_\\d+)?_\\w+.jpeg\\?Expires=\\d+&Signature=.+\",\n    \"#count\"   : 3,\n\n    \"count\"        : 3,\n    \"num\"          : range(1, 3),\n    \"description\"  : \"ORANGE OASISボスネタバレ<br />曲も大好き<br />2枚目以降はほとんど見えなかった1枚目背景のヒエログリフ小ネタです𓀀\",\n    \"post_category\": \"SPOILER\",\n    \"post_id\"      : \"5776587\",\n    \"user_id\"      : \"3572553\",\n    \"user_name\"    : \"nagakun\",\n},\n\n{\n    \"#url\"     : \"https://poipiku.com/1400760/5483268.html\",\n    \"#comment\" : \"Warning and no 'Show all' button (#6736)\",\n    \"#class\"   : poipiku.PoipikuPostExtractor,\n    \"#pattern\" : r\"https://cdn.poipiku.com/001400760/005483268_JdB7sAWpv.jpeg\\?Expires=\\d+&Signature=.+\",\n\n    \"count\"        : 1,\n    \"num\"          : 1,\n    \"description\"  : \"えち描く描く詐欺ずっとやってるのですこしかいてた<br />ほたしか写ってないよ\",\n    \"warning\"      : True,\n    \"post_category\": \"TRAINING\",\n    \"post_id\"      : \"5483268\",\n    \"user_id\"      : \"1400760\",\n    \"user_name\"    : \"onitsuraaaai\",\n},\n\n{\n    \"#url\"     : \"https://poipiku.com/12282220/12290661.html\",\n    \"#comment\" : \"Password Required ('yes')\",\n    \"#class\"   : poipiku.PoipikuPostExtractor,\n    \"#options\" : {\"password\": \"yes\"},\n    \"#pattern\" : (\n        r\"https://cdn.poipiku.com/012282220/012290661_cTNUS0cX9.png\\?.+\",\n        r\"https://cdn.poipiku.com/012282220/012290661_027772303_q9Yb5mdQO.png\\?.+\",\n        r\"https://cdn.poipiku.com/012282220/012290661_027772304_jTlthEwKf.jpeg\\?.+\",\n    ),\n\n    \"count\"        : 3,\n    \"num\"          : range(1, 3),\n    \"filename\"     : str,\n    \"extension\"    : {\"jpeg\", \"png\"},\n    \"description\"  : \"过去的🕶️Σ🕶️ 🔞<br />堆堆<br /><br />18↑ yes/no\",\n    \"original\"     : True,\n    \"password\"     : True,\n    \"post_category\": \"DOODLE\",\n    \"post_id\"      : \"12290661\",\n    \"user_id\"      : \"12282220\",\n    \"user_name\"    : \"FaratMo4\",\n},\n\n{\n    \"#url\"     : \"https://poipiku.com/3572553/5776587.html\",\n    \"#comment\" : \"SPOILER / Warning (warning.png)\",\n    \"#class\"   : poipiku.PoipikuPostExtractor,\n},\n\n{\n    \"#url\"     : \"https://poipiku.com/9117461/12291186.html\",\n    \"#comment\" : \"Follower Only (publish_follower.png)  + Password ('yes')\",\n    \"#class\"   : poipiku.PoipikuPostExtractor,\n},\n\n{\n    \"#url\"     : \"https://poipiku.com/11516189/12291094.html\",\n    \"#comment\" : \"Retweet Required (publish_t_rt.png) + Password\",\n    \"#class\"   : poipiku.PoipikuPostExtractor,\n},\n\n{\n    \"#url\"     : \"https://poipiku.com/542956/12293287.html\",\n    \"#comment\" : \"Sign-In Only (publish_login.png)\",\n    \"#class\"   : poipiku.PoipikuPostExtractor,\n    \"#auth\"    : False,\n},\n\n{\n    \"#url\"     : \"https://poipiku.com/11513074/12290032.html\",\n    \"#comment\" : \"Sign-In Only (publish_login.png) + Password\",\n    \"#class\"   : poipiku.PoipikuPostExtractor,\n    \"#metadata\": \"post\",\n    \"#auth\"    : False,\n\n    \"requires\": \"login\",\n    \"password\": True,\n},\n\n{\n\n    \"#url\"     : \"https://poipiku.com/2498939/12293054.html\",\n    \"#comment\" : \"Animated GIF\",\n    \"#class\"   : poipiku.PoipikuPostExtractor,\n    \"#auth\"    : False,\n    \"#result\"  : \"https://cdn.poipiku.com/002498939/012293054_RcNvVjZ85.gif_640.jpg\",\n    \"#sha1_content\": \"ac4726b93dc6d507188cfcb5193dd20bcf6c38b0\",\n},\n\n{\n\n    \"#url\"     : \"https://poipiku.com/11329926/11669296.html\",\n    \"#comment\" : \"Text Posts\",\n    \"#class\"   : poipiku.PoipikuPostExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/ponybooru.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import philomena\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://ponybooru.org/images/1\",\n    \"#category\": (\"philomena\", \"ponybooru\", \"post\"),\n    \"#class\"   : philomena.PhilomenaPostExtractor,\n    \"#sha1_content\": \"bca26f58fafd791fe07adcd2a28efd7751824605\",\n},\n\n{\n    \"#url\"     : \"https://www.ponybooru.org/images/1\",\n    \"#category\": (\"philomena\", \"ponybooru\", \"post\"),\n    \"#class\"   : philomena.PhilomenaPostExtractor,\n},\n\n{\n    \"#url\"     : \"https://ponybooru.org/search?q=cute\",\n    \"#category\": (\"philomena\", \"ponybooru\", \"search\"),\n    \"#class\"   : philomena.PhilomenaSearchExtractor,\n    \"#range\"   : \"40-60\",\n    \"#count\"   : 21,\n},\n\n{\n    \"#url\"     : \"https://ponybooru.org/galleries/27\",\n    \"#category\": (\"philomena\", \"ponybooru\", \"gallery\"),\n    \"#class\"   : philomena.PhilomenaGalleryExtractor,\n    \"#count\"   : \">= 24\",\n},\n\n)\n"
  },
  {
    "path": "test/results/poringa.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import poringa\n\n\n__tests__ = (\n{\n    \"#url\"     : \"http://www.poringa.net/posts/imagenes/3051081/Turrita-alto-ojete.html\",\n    \"#category\": (\"\", \"poringa\", \"post\"),\n    \"#class\"   : poringa.PoringaPostExtractor,\n    \"#count\"   : 26,\n\n    \"count\"    : 26,\n    \"num\"      : range(1, 26),\n    \"post_id\"  : \"3051081\",\n    \"title\"    : \"turrita alto ojete...\",\n    \"user\"     : \"vipower1top\",\n},\n\n{\n    \"#url\"     : \"http://www.poringa.net/posts/imagenes/3095554/Otra-culona-de-instagram.html\",\n    \"#category\": (\"\", \"poringa\", \"post\"),\n    \"#class\"   : poringa.PoringaPostExtractor,\n    \"#count\"   : 15,\n\n    \"count\"    : 15,\n    \"num\"      : range(1, 15),\n    \"post_id\"  : \"3095554\",\n    \"title\"    : \"Otra culona de instagram\",\n    \"user\"     : \"Expectro007\",\n},\n\n{\n    \"#url\"     : \"http://www.poringa.net/Expectro007\",\n    \"#category\": (\"\", \"poringa\", \"user\"),\n    \"#class\"   : poringa.PoringaUserExtractor,\n    \"#pattern\" : r\"https?://img-\\d+\\.poringa\\.net/poringa/img/././././././Expectro007/\\w{3}\\.(jpg|png|gif)\",\n    \"#count\"   : range(500, 600),\n},\n\n{\n    \"#url\"     : \"http://www.poringa.net/buscar/?&q=yuslopez\",\n    \"#category\": (\"\", \"poringa\", \"search\"),\n    \"#class\"   : poringa.PoringaSearchExtractor,\n    \"#pattern\" : r\"https?://img-\\d+\\.poringa\\.net/poringa/img/././././././\\w+/\\w{3}\\.(jpg|png|gif)\",\n    \"#range\"   : \"1-50\",\n    \"#count\"   : 50,\n},\n\n)\n"
  },
  {
    "path": "test/results/pornhub.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import pornhub\nfrom gallery_dl import exception\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://www.pornhub.com/album/19289801\",\n    \"#category\": (\"\", \"pornhub\", \"gallery\"),\n    \"#class\"   : pornhub.PornhubGalleryExtractor,\n    \"#pattern\" : r\"https://\\w+.phncdn.com/pics/albums/\\d+/\\d+/\\d+/\\d+/\",\n    \"#count\"   : \">= 300\",\n\n    \"id\"     : int,\n    \"num\"    : int,\n    \"score\"  : int,\n    \"views\"  : int,\n    \"caption\": str,\n    \"user\"   : \"Danika Mori\",\n    \"gallery\": {\n        \"id\"   : 19289801,\n        \"score\": int,\n        \"views\": int,\n        \"tags\" : list,\n        \"title\": \"Danika Mori Best Moments\",\n    },\n},\n\n{\n    \"#url\"     : \"https://www.pornhub.com/album/69606532\",\n    \"#comment\" : \"KeyError due to missing image entry (#6299)\",\n    \"#class\"   : pornhub.PornhubGalleryExtractor,\n    \"#log\"     : \"69606532: Unable to ensure correct file order\",\n    \"#count\"   : 6,\n},\n\n{\n    \"#url\"     : \"https://www.pornhub.com/album/69040172\",\n    \"#comment\" : \"404 Error Page Not Found\",\n    \"#category\": (\"\", \"pornhub\", \"gallery\"),\n    \"#class\"   : pornhub.PornhubGalleryExtractor,\n    \"#exception\": exception.HttpError,\n},\n\n{\n    \"#url\"     : \"https://www.pornhub.com/gif/43726891\",\n    \"#category\": (\"\", \"pornhub\", \"gif\"),\n    \"#class\"   : pornhub.PornhubGifExtractor,\n    \"#pattern\" : r\"https://\\w+\\.phncdn\\.com/pics/gifs/043/726/891/43726891a\\.webm\",\n\n    \"date\"     : \"dt:2023-04-20 00:00:00\",\n    \"extension\": \"webm\",\n    \"filename\" : \"43726891a\",\n    \"id\"       : \"43726891\",\n    \"tags\"     : [\n        \"sloppy deepthroat\",\n        \"perfect body\",\n        \"petite brunette\",\n        \"mouth fuck\",\n        \"big dick\",\n        \"natural big tits\",\n        \"deepthroat swallow\",\n        \"amateur couple\",\n        \"homemade\",\n        \"girls wanking boys\",\n        \"hardcore sex\",\n        \"babes 18 year\",\n    ],\n    \"timestamp\": \"5:07\",\n    \"title\"    : \"Intense sloppy blowjob of Danika Mori\",\n    \"url\"      : \"https://el.phncdn.com/pics/gifs/043/726/891/43726891a.webm\",\n    \"user\"     : \"Danika Mori\",\n    \"viewkey\"  : \"64367c8c78a4a\",\n},\n\n{\n    \"#url\"     : \"https://www.pornhub.com/pornstar/danika-mori\",\n    \"#category\": (\"\", \"pornhub\", \"user\"),\n    \"#class\"   : pornhub.PornhubUserExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.pornhub.com/pornstar/danika-mori/photos\",\n    \"#category\": (\"\", \"pornhub\", \"photos\"),\n    \"#class\"   : pornhub.PornhubPhotosExtractor,\n    \"#pattern\" : pornhub.PornhubGalleryExtractor.pattern,\n    \"#count\"   : \">= 6\",\n},\n\n{\n    \"#url\"     : \"https://www.pornhub.com/users/flyings0l0/photos/public\",\n    \"#category\": (\"\", \"pornhub\", \"photos\"),\n    \"#class\"   : pornhub.PornhubPhotosExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.pornhub.com/users/flyings0l0/photos/private\",\n    \"#category\": (\"\", \"pornhub\", \"photos\"),\n    \"#class\"   : pornhub.PornhubPhotosExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.pornhub.com/users/flyings0l0/photos/favorites\",\n    \"#category\": (\"\", \"pornhub\", \"photos\"),\n    \"#class\"   : pornhub.PornhubPhotosExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.pornhub.com/model/bossgirl/photos\",\n    \"#category\": (\"\", \"pornhub\", \"photos\"),\n    \"#class\"   : pornhub.PornhubPhotosExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.pornhub.com/pornstar/danika-mori/gifs\",\n    \"#category\": (\"\", \"pornhub\", \"gifs\"),\n    \"#class\"   : pornhub.PornhubGifsExtractor,\n    \"#pattern\" : pornhub.PornhubGifExtractor.pattern,\n    \"#count\"   : \">= 30\",\n},\n\n{\n    \"#url\"     : \"https://www.pornhub.com/users/flyings0l0/gifs\",\n    \"#category\": (\"\", \"pornhub\", \"gifs\"),\n    \"#class\"   : pornhub.PornhubGifsExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.pornhub.com/model/bossgirl/gifs/video\",\n    \"#category\": (\"\", \"pornhub\", \"gifs\"),\n    \"#class\"   : pornhub.PornhubGifsExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/pornpics.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import pornpics\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://www.pornpics.com/galleries/british-beauty-danielle-flashes-hot-breasts-ass-and-snatch-in-the-forest-62610699/\",\n    \"#category\": (\"\", \"pornpics\", \"gallery\"),\n    \"#class\"   : pornpics.PornpicsGalleryExtractor,\n    \"#pattern\" : r\"https://cdni\\.pornpics\\.com/1280/7/160/62610699/62610699_\\d+_[0-9a-f]{4}\\.jpg\",\n\n    \"categories\": [\n        \"Outdoor\",\n        \"Boots\",\n        \"MILF\",\n        \"Hairy\",\n        \"Sexy\",\n        \"Pussy\",\n        \"Spreading\",\n    ],\n    \"channel\"   : [\"FTV MILFs\"],\n    \"count\"     : 17,\n    \"gallery_id\": 62610699,\n    \"models\"    : [\"Danielle Maye\"],\n    \"num\"       : int,\n    \"slug\"      : \"british-beauty-danielle-flashes-hot-breasts-ass-and-snatch-in-the-forest\",\n    \"tags\"      : [\n        \"MILF Outdoor\",\n        \"Forest\",\n        \"Nature\",\n        \"Pussy Flash\",\n        \"Open Pussy\",\n        \"Hairy Pussy Spread\",\n        \"Thigh High Boots\",\n        \"Sexy MILF\",\n    ],\n    \"title\"     : \"British beauty Danielle flashes hot breasts, ass and snatch in the forest\",\n    \"views\"     : int,\n},\n\n{\n    \"#url\"     : \"https://pornpics.com/es/galleries/62610699\",\n    \"#category\": (\"\", \"pornpics\", \"gallery\"),\n    \"#class\"   : pornpics.PornpicsGalleryExtractor,\n\n    \"slug\": \"british-beauty-danielle-flashes-hot-breasts-ass-and-snatch-in-the-forest\",\n},\n\n{\n    \"#url\"     : \"https://www.pornpics.com/galleries/four-american-hotties-in-swimsuit-showing-off-their-sexy-booty-and-bare-legs-59500405/\",\n    \"#comment\" : \"more than one 'channel' (#5195)\",\n    \"#category\": (\"\", \"pornpics\", \"gallery\"),\n    \"#class\"   : pornpics.PornpicsGalleryExtractor,\n\n    \"count\"     : 16,\n    \"num\"       : range(1, 16),\n    \"gallery_id\": 59500405,\n    \"slug\"      : \"four-american-hotties-in-swimsuit-showing-off-their-sexy-booty-and-bare-legs\",\n    \"title\"     : \"Four American hotties in swimsuit showing off their sexy booty and bare legs\",\n    \"views\"     : range(50000, 100000),\n    \"models\"    : [\n        \"Kayla West\",\n        \"Layla Price\",\n        \"Marley Blaze\",\n        \"Mena Mason\",\n    ],\n    \"categories\": [\n        \"Outdoor\",\n        \"Pornstar\",\n        \"Brunette\",\n        \"Blonde\",\n    ],\n    \"channel\"   : [\n        \"Adult Time\",\n        \"Fame Digital\",\n    ],\n    \"tags\"      : [\n        \"Nudist\",\n        \"Nature\",\n    ],\n},\n\n{\n    \"#url\"     : \"https://www.pornpics.com/tags/summer-dress/\",\n    \"#category\": (\"\", \"pornpics\", \"tag\"),\n    \"#class\"   : pornpics.PornpicsTagExtractor,\n    \"#pattern\" : pornpics.PornpicsGalleryExtractor.pattern,\n    \"#range\"   : \"1-50\",\n    \"#count\"   : 50,\n},\n\n{\n    \"#url\"     : \"https://pornpics.com/fr/tags/summer-dress\",\n    \"#category\": (\"\", \"pornpics\", \"tag\"),\n    \"#class\"   : pornpics.PornpicsTagExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.pornpics.com/?q=nature\",\n    \"#category\": (\"\", \"pornpics\", \"search\"),\n    \"#class\"   : pornpics.PornpicsSearchExtractor,\n    \"#pattern\" : pornpics.PornpicsGalleryExtractor.pattern,\n    \"#range\"   : \"1-50\",\n    \"#count\"   : 50,\n},\n\n{\n    \"#url\"     : \"https://www.pornpics.com/channels/femjoy/\",\n    \"#category\": (\"\", \"pornpics\", \"search\"),\n    \"#class\"   : pornpics.PornpicsSearchExtractor,\n    \"#pattern\" : pornpics.PornpicsGalleryExtractor.pattern,\n    \"#range\"   : \"1-50\",\n    \"#count\"   : 50,\n},\n\n{\n    \"#url\"     : \"https://www.pornpics.com/pornstars/emma-brown/\",\n    \"#category\": (\"\", \"pornpics\", \"search\"),\n    \"#class\"   : pornpics.PornpicsSearchExtractor,\n    \"#pattern\" : pornpics.PornpicsGalleryExtractor.pattern,\n    \"#range\"   : \"1-50\",\n    \"#count\"   : 50,\n},\n\n{\n    \"#url\"     : \"https://pornpics.com/jp/?q=nature\",\n    \"#category\": (\"\", \"pornpics\", \"search\"),\n    \"#class\"   : pornpics.PornpicsSearchExtractor,\n},\n\n{\n    \"#url\"     : \"https://pornpics.com/it/channels/femjoy\",\n    \"#category\": (\"\", \"pornpics\", \"search\"),\n    \"#class\"   : pornpics.PornpicsSearchExtractor,\n},\n\n{\n    \"#url\"     : \"https://pornpics.com/pt/pornstars/emma-brown\",\n    \"#category\": (\"\", \"pornpics\", \"search\"),\n    \"#class\"   : pornpics.PornpicsSearchExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.pornpics.com/pornstars/paige-turnah/\",\n    \"#comment\" : \"results have less than 20 items per batch (#9022)\",\n    \"#class\"   : pornpics.PornpicsSearchExtractor,\n    \"#pattern\" : pornpics.PornpicsGalleryExtractor.pattern,\n    \"#range\"   : \"1-75\",\n    \"#count\"   : 75,\n},\n\n{\n    \"#url\"     : \"https://www.pornpics.com/ass/\",\n    \"#category\": (\"\", \"pornpics\", \"category\"),\n    \"#class\"   : pornpics.PornpicsCategoryExtractor,\n    \"#pattern\" : pornpics.PornpicsGalleryExtractor.pattern,\n    \"#range\"   : \"1-50\",\n    \"#count\"   : 50,\n},\n\n{\n    \"#url\"     : \"https://www.pornpics.com/milf/\",\n    \"#category\": (\"\", \"pornpics\", \"category\"),\n    \"#class\"   : pornpics.PornpicsCategoryExtractor,\n},\n\n{\n    \"#url\"     : \"https://pornpics.com/de/blonde\",\n    \"#category\": (\"\", \"pornpics\", \"category\"),\n    \"#class\"   : pornpics.PornpicsCategoryExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.pornpics.com/popular/\",\n    \"#category\": (\"\", \"pornpics\", \"listing\"),\n    \"#class\"   : pornpics.PornpicsListingExtractor,\n    \"#pattern\" : pornpics.PornpicsGalleryExtractor.pattern,\n    \"#range\"   : \"1-20\",\n    \"#count\"   : 20,\n},\n\n{\n    \"#url\"     : \"https://www.pornpics.com/recent/\",\n    \"#category\": (\"\", \"pornpics\", \"listing\"),\n    \"#class\"   : pornpics.PornpicsListingExtractor,\n},\n\n{\n    \"#url\"     : \"https://pornpics.com/fr/rating\",\n    \"#category\": (\"\", \"pornpics\", \"listing\"),\n    \"#class\"   : pornpics.PornpicsListingExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/pornreactor.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import reactor\n\n\n__tests__ = (\n{\n    \"#url\"     : \"http://pornreactor.cc/tag/RiceGnat\",\n    \"#category\": (\"reactor\", \"pornreactor\", \"tag\"),\n    \"#class\"   : reactor.ReactorTagExtractor,\n    \"#range\"   : \"1-25\",\n    \"#count\"   : \">= 25\",\n},\n\n{\n    \"#url\"     : \"http://fapreactor.com/tag/RiceGnat\",\n    \"#category\": (\"reactor\", \"pornreactor\", \"tag\"),\n    \"#class\"   : reactor.ReactorTagExtractor,\n},\n\n{\n    \"#url\"     : \"http://pornreactor.cc/search?q=ecchi+hentai\",\n    \"#category\": (\"reactor\", \"pornreactor\", \"search\"),\n    \"#class\"   : reactor.ReactorSearchExtractor,\n},\n\n{\n    \"#url\"     : \"http://fapreactor.com/search/ecchi+hentai\",\n    \"#category\": (\"reactor\", \"pornreactor\", \"search\"),\n    \"#class\"   : reactor.ReactorSearchExtractor,\n},\n\n{\n    \"#url\"     : \"http://pornreactor.cc/user/Disillusion\",\n    \"#category\": (\"reactor\", \"pornreactor\", \"user\"),\n    \"#class\"   : reactor.ReactorUserExtractor,\n    \"#range\"   : \"1-25\",\n    \"#count\"   : \">= 20\",\n},\n\n{\n    \"#url\"     : \"http://fapreactor.com/user/Disillusion\",\n    \"#category\": (\"reactor\", \"pornreactor\", \"user\"),\n    \"#class\"   : reactor.ReactorUserExtractor,\n},\n\n{\n    \"#url\"     : \"http://pornreactor.cc/post/863166\",\n    \"#category\": (\"reactor\", \"pornreactor\", \"post\"),\n    \"#class\"   : reactor.ReactorPostExtractor,\n    \"#sha1_url\"    : \"a09fb0577489e1f9564c25d0ad576f81b19c2ef3\",\n    \"#sha1_content\": \"ec6b0568bfb1803648744077da082d14de844340\",\n},\n\n{\n    \"#url\"     : \"http://fapreactor.com/post/863166\",\n    \"#category\": (\"reactor\", \"pornreactor\", \"post\"),\n    \"#class\"   : reactor.ReactorPostExtractor,\n    \"#sha1_url\": \"2a956ce0c90e8bc47b4392db4fa25ad1342f3e54\",\n},\n\n)\n"
  },
  {
    "path": "test/results/pornstarstube.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import pornstarstube\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://pornstars.tube/albums/40771/cleaning-leads-to-delicious-mess/\",\n    \"#class\"   : pornstarstube.PornstarstubeGalleryExtractor,\n    \"#pattern\" : r\"https://pics\\-storage\\-1\\.pornhat\\.com/contents/albums/main/1920x1080/40000/40771/\\d+\\.jpg\",\n    \"#count\"   : 100,\n\n    \"count\"      : 100,\n    \"num\"        : range(1, 100),\n    \"description\": \"When stepson Brad Sterling decides to help out his stepmom Cali Lee and do a deep clean of the kitchen, he wasn’t expecting her to be so grateful for it. Cali offers to reward him for all his hard work in a much devious way. Who knew that a little bit of cleaning would lead to such messy results!\",\n    \"extension\"  : \"jpg\",\n    \"filename\"   : r\"re:^\\d+$\",\n    \"gallery_id\" : 40771,\n    \"slug\"       : \"cleaning-leads-to-delicious-mess\",\n    \"title\"      : \"Cleaning Leads To Delicious Mess\",\n    \"tags\"       : [\n        \"blowjob\",\n        \"oral\",\n        \"brunette\",\n        \"teen (18+)\",\n        \"cowgirl\",\n        \"latina\",\n        \"missionary\",\n        \"handjob\",\n        \"babe\",\n        \"standing doggystyle\",\n        \"side fuck\",\n        \"reverse cowgirl\",\n        \"deep throat\",\n        \"kitchen\",\n        \"posing\",\n        \"stripping\",\n        \"firm ass\",\n        \"legs on shoulders\",\n        \"Cali Lee\",\n    ],\n},\n\n{\n    \"#url\"     : \"https://pornstars.tube/albums/40771/cleaning\",\n    \"#class\"   : pornstarstube.PornstarstubeGalleryExtractor,\n},\n\n{\n    \"#url\"     : \"https://pornstars.tube/albums/40771-O\",\n    \"#class\"   : pornstarstube.PornstarstubeGalleryExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/postimg.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import imagehosts\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://postimages.org/Wtn2b3hC\",\n    \"#category\": (\"imagehost\", \"postimg\", \"image\"),\n    \"#class\"   : imagehosts.PostimgImageExtractor,\n    \"#results\" : \"https://i.postimg.cc/PhJZt1Rw/test-tesuto.png?dl=1\",\n\n    \"filename\" : \"test-テスト-\\\"&>\",\n    \"extension\": \"png\",\n    \"token\"    : \"Wtn2b3hC\",\n    \"post_url\" : \"https://postimg.cc/Wtn2b3hC\",\n},\n\n{\n    \"#url\"     : \"https://www.postimages.org/Wtn2b3hC\",\n    \"#category\": (\"imagehost\", \"postimg\", \"image\"),\n    \"#class\"   : imagehosts.PostimgImageExtractor,\n},\n\n{\n    \"#url\"     : \"https://pixxxels.cc/Wtn2b3hC\",\n    \"#category\": (\"imagehost\", \"postimg\", \"image\"),\n    \"#class\"   : imagehosts.PostimgImageExtractor,\n},\n\n{\n    \"#url\"     : \"https://postimg.cc/Wtn2b3hC\",\n    \"#category\": (\"imagehost\", \"postimg\", \"image\"),\n    \"#class\"   : imagehosts.PostimgImageExtractor,\n    \"#results\"      : \"https://i.postimg.cc/PhJZt1Rw/test-tesuto.png?dl=1\",\n    \"#sha1_content\" : \"cfaa8def53ed1a575e0c665c9d6d8cf2aac7a0ee\",\n\n    \"filename\" : \"test-テスト-\\\"&>\",\n    \"extension\": \"png\",\n    \"token\"    : \"Wtn2b3hC\",\n    \"post_url\" : \"https://postimg.cc/Wtn2b3hC\",\n},\n\n{\n    \"#url\"     : \"http://postimg.org/image/5l1cogxcr/\",\n    \"#comment\" : \"no 'imagename' (#8505)\",\n    \"#category\": (\"imagehost\", \"postimg\", \"image\"),\n    \"#class\"   : imagehosts.PostimgImageExtractor,\n    \"#results\" : \"https://i.postimg.cc/08bm81zX/fashion-show-dream-angels-fantasy-bra-2014-adria.jpg?dl=1\",\n\n    \"extension\": \"jpg\",\n    \"filename\" : \"fashion-show-dream-angels-fantasy-bra-2014-adria\",\n    \"token\"    : \"5l1cogxcr\",\n},\n\n{\n    \"#url\"     : \"https://postimg.cc/Z9Y6srnT\",\n    \"#comment\" : \"'filename' (#9119)\",\n    \"#category\": (\"imagehost\", \"postimg\", \"image\"),\n    \"#class\"   : imagehosts.PostimgImageExtractor,\n    \"#results\" : \"https://i.postimg.cc/JRdFpY6K/Esue-Vyrisel3b.jpg?dl=1\",\n\n    \"extension\": \"jpg\",\n    \"filename\" : \"EsueVyrisel3b\",\n    \"post_url\" : \"https://postimg.cc/Z9Y6srnT\",\n    \"token\"    : \"Z9Y6srnT\",\n},\n\n{\n    \"#url\"     : \"https://postimg.cc/gallery/wxpDLgX\",\n    \"#category\": (\"imagehost\", \"postimg\", \"gallery\"),\n    \"#class\"   : imagehosts.PostimgGalleryExtractor,\n    \"#pattern\" : imagehosts.PostimgImageExtractor.pattern,\n    \"#count\"   : 22,\n\n    \"gallery_title\": \"My Gallery\",\n},\n\n{\n    \"#url\"     : \"https://postimg.cc/gallery/07vFSxB\",\n    \"#comment\" : \"multiple pages (#9119)\",\n    \"#category\": (\"imagehost\", \"postimg\", \"gallery\"),\n    \"#class\"   : imagehosts.PostimgGalleryExtractor,\n    \"#pattern\" : imagehosts.PostimgImageExtractor.pattern,\n    \"#range\"   : \"1-100\",\n    \"#count\"   : 100,\n\n    \"gallery_title\": \"Archive\",\n    \"extension\"    : {\"jpg\", \"png\"},\n    \"filename\"     : str,\n    \"width\"        : int,\n    \"height\"       : int,\n    \"thumbnail\"    : str,\n    \"token\"        : str,\n},\n\n)\n"
  },
  {
    "path": "test/results/raddle.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import postmill\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://raddle.me/\",\n    \"#category\": (\"postmill\", \"raddle\", \"home\"),\n    \"#class\"   : postmill.PostmillHomeExtractor,\n    \"#range\"   : \"1-25\",\n    \"#count\"   : 25,\n},\n\n{\n    \"#url\"     : \"https://raddle.me/f/traa\",\n    \"#category\": (\"postmill\", \"raddle\", \"forum\"),\n    \"#class\"   : postmill.PostmillForumExtractor,\n    \"#count\"   : 1,\n    \"#pattern\" : r\"^https://raddle\\.me/f/traa/156646/click-here-to-go-to-f-traaaaaaannnnnnnnnns$\",\n},\n\n{\n    \"#url\"     : \"https://raddle.me/user/Sam_the_enby/submissions\",\n    \"#category\": (\"postmill\", \"raddle\", \"usersubmissions\"),\n    \"#class\"   : postmill.PostmillUserSubmissionsExtractor,\n    \"#range\"   : \"1-25\",\n    \"#count\"   : 25,\n},\n\n{\n    \"#url\"     : \"https://raddle.me/tag/Trans\",\n    \"#category\": (\"postmill\", \"raddle\", \"tag\"),\n    \"#class\"   : postmill.PostmillTagExtractor,\n},\n\n{\n    \"#url\"     : \"https://raddle.me/search?q=tw\",\n    \"#category\": (\"postmill\", \"raddle\", \"search\"),\n    \"#class\"   : postmill.PostmillSearchExtractor,\n    \"#range\"   : \"1-50\",\n    \"#count\"   : 50,\n},\n\n{\n    \"#url\"     : \"https://raddle.me/160845\",\n    \"#category\": (\"postmill\", \"raddle\", \"shorturl\"),\n    \"#class\"   : postmill.PostmillShortURLExtractor,\n    \"#pattern\" : r\"^https://raddle\\.me/f/egg_irl/160845/egg_irl$\",\n},\n\n{\n    \"#url\"     : \"https://raddle.me/f/NonBinary/179017/scattered-thoughts-would-appreciate-advice-immensely-tw\",\n    \"#comment\" : \"Text post\",\n    \"#category\": (\"postmill\", \"raddle\", \"post\"),\n    \"#class\"   : postmill.PostmillPostExtractor,\n    \"#sha1_url\"    : \"99277f815820810d9d7e219d455f818601858378\",\n    \"#sha1_content\": \"7a1159e1e45f2ce8e2c8b5959f6d66b042776f3b\",\n    \"#count\"   : 1,\n},\n\n{\n    \"#url\"     : \"https://raddle.me/f/egg_irl/160845\",\n    \"#comment\" : \"Image post\",\n    \"#category\": (\"postmill\", \"raddle\", \"post\"),\n    \"#class\"   : postmill.PostmillPostExtractor,\n    \"#sha1_content\": \"431e938082c2b59c44888a83cfc711cd1f0e910a\",\n    \"#results\"     : \"https://uploads-cdn.raddle.me/submission_images/30f4cf7d235d40c1daebf6dc2e58bef2a80bec2b5b2dab10f2021ea8e3f29e11.png\",\n},\n\n{\n    \"#url\"     : \"https://raddle.me/f/trans/177042/tw-vent-nsfw-suicide-i-lost-no-nut-november-tw-trauma\",\n    \"#comment\" : \"Image + text post (with text enabled)\",\n    \"#category\": (\"postmill\", \"raddle\", \"post\"),\n    \"#class\"   : postmill.PostmillPostExtractor,\n    \"#options\" : {\"save-link-post-body\": True},\n    \"#pattern\" : r\"^(text:[\\s\\S]+|https://(uploads-cdn\\.)?raddle\\.me/submission_images/[0-9a-f]+\\.png)$\",\n    \"#count\"   : 2,\n},\n\n{\n    \"#url\"     : \"https://raddle.me/f/videos/179541/raisins-and-sprite\",\n    \"#comment\" : \"Link post\",\n    \"#category\": (\"postmill\", \"raddle\", \"post\"),\n    \"#class\"   : postmill.PostmillPostExtractor,\n    \"#results\" : \"https://m.youtube.com/watch?v=RFJCA5zcZxI\",\n    \"#count\"   : 1,\n},\n\n{\n    \"#url\"     : \"https://raddle.me/f/Anime/150698/neo-tokyo-1987-link-to-the-english-dub-version-last-link\",\n    \"#comment\" : \"Link + text post (with text disabled)\",\n    \"#category\": (\"postmill\", \"raddle\", \"post\"),\n    \"#class\"   : postmill.PostmillPostExtractor,\n    \"#pattern\" : r\"^https://fantasyanime\\.com/anime/neo-tokyo-dub$\",\n    \"#count\"   : 1,\n},\n\n{\n    \"#url\"     : \"https://raddle.me/f/egg_irl/166855/4th-wall-breaking-please-let-this-be-a-flair-egg-irl\",\n    \"#comment\" : \"Post with multiple flairs\",\n    \"#category\": (\"postmill\", \"raddle\", \"post\"),\n    \"#class\"   : postmill.PostmillPostExtractor,\n    \"flair\"    : [\"Gender non-specific\", \"4th wall breaking\"],\n},\n\n)\n"
  },
  {
    "path": "test/results/raidlondon.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import shopify\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://www.raidlondon.com/collections/flats\",\n    \"#category\": (\"shopify\", \"raidlondon\", \"collection\"),\n    \"#class\"   : shopify.ShopifyCollectionExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.raidlondon.com/collections/flats/products/raid-addyson-chunky-flat-shoe-in-white\",\n    \"#category\": (\"shopify\", \"raidlondon\", \"product\"),\n    \"#class\"   : shopify.ShopifyProductExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/rawkuma.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import rawkuma\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://rawkuma.net/manga/saikyou-onmyouji-no-isekai-tenseiki-geboku-no-youkaidomo-ni-kurabete-monster-ga-yowaisugirundaga/chapter-3.28214/\",\n    \"#class\"   : rawkuma.RawkumaChapterExtractor,\n    \"#pattern\" : r\"https://rcdn\\.kyut\\.dev/s/saikyou\\-onmyouji\\-no\\-isekai\\-tenseiki\\-geboku\\-no\\-youkaidomo\\-ni\\-kurabete\\-monster\\-ga\\-yowaisugirundaga/chapter\\-3/\\d+\\.png\",\n    \"#count\"   : 28,\n\n    \"chapter\"      : 3,\n    \"chapter_id\"   : 28214,\n    \"chapter_minor\": \"\",\n    \"count\"        : 28,\n    \"page\"         : range(1, 28),\n    \"date\"         : \"dt:2025-09-14 15:57:26\",\n    \"extension\"    : \"png\",\n    \"filename\"     : str,\n    \"lang\"         : \"ja\",\n    \"language\"     : \"Japanese\",\n    \"manga\"        : \"Saikyou Onmyouji no Isekai Tenseiki ~Geboku no Youkaidomo ni Kurabete Monster ga Yowaisugirundaga~\",\n    \"manga_id\"     : 784,\n},\n\n{\n    \"#url\"     : \"https://rawkuma.net/manga/makutsu-no-ou-yomei-ikkagetsu-no-doutei-mahou-shoujo-harem-o-kizuite-ou-e-kunrinsu/chapter-3.4.205398/\",\n    \"#class\"   : rawkuma.RawkumaChapterExtractor,\n    \"#pattern\" : r\"https://rcdn\\.kyut\\.dev/m/makutsu\\-no\\-ou\\-yomei\\-ikkagetsu\\-no\\-doutei\\-mahou\\-shoujo\\-harem\\-o\\-kizuite\\-ou\\-e\\-kunrinsu/chapter\\-3\\-4/\\d+\\.jpg\",\n    \"#count\"   : 10,\n\n    \"chapter\"      : 3,\n    \"chapter_id\"   : 205398,\n    \"chapter_minor\": \".4\",\n    \"count\"        : 10,\n    \"page\"         : range(1, 10),\n    \"date\"         : \"dt:2025-10-03 17:41:22\",\n    \"extension\"    : \"jpg\",\n    \"filename\"     : str,\n    \"lang\"         : \"ja\",\n    \"language\"     : \"Japanese\",\n    \"manga\"        : \"Makutsu no Ou ~Yomei Ikkagetsu no Doutei, Mahou Shoujo Harem o Kizuite Ou e Kunrinsu~\",\n    \"manga_id\"     : 194526,\n},\n\n{\n    \"#url\"     : \"https://rawkuma.net/manga/makutsu-no-ou-yomei-ikkagetsu-no-doutei-mahou-shoujo-harem-o-kizuite-ou-e-kunrinsu\",\n    \"#class\"   : rawkuma.RawkumaMangaExtractor,\n    \"#pattern\" : rawkuma.RawkumaChapterExtractor.pattern,\n    \"#count\"   : range(32, 50),\n\n    \"chapter\"      : range(1, 20),\n    \"chapter-minor\": {\"\", \".1\", \".2\", \".3\", \".4\"},\n    \"chapter_id\"   : int,\n    \"manga\"        : \"Makutsu no Ou ~Yomei Ikkagetsu no Doutei, Mahou Shoujo Harem o Kizuite Ou e Kunrinsu~\",\n    \"manga_id\"     : 194526,\n},\n\n)\n"
  },
  {
    "path": "test/results/rbt.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import foolfuuka\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://rbt.asia/g/thread/61487650/\",\n    \"#category\": (\"foolfuuka\", \"rbt\", \"thread\"),\n    \"#class\"   : foolfuuka.FoolfuukaThreadExtractor,\n    \"#sha1_url\": \"fadd274b25150a1bdf03a40c58db320fa3b617c4\",\n},\n\n{\n    \"#url\"     : \"https://archive.rebeccablacktech.com/g/thread/61487650/\",\n    \"#category\": (\"foolfuuka\", \"rbt\", \"thread\"),\n    \"#class\"   : foolfuuka.FoolfuukaThreadExtractor,\n    \"#sha1_url\": \"fadd274b25150a1bdf03a40c58db320fa3b617c4\",\n},\n\n{\n    \"#url\"     : \"https://rbt.asia/g/\",\n    \"#category\": (\"foolfuuka\", \"rbt\", \"board\"),\n    \"#class\"   : foolfuuka.FoolfuukaBoardExtractor,\n},\n\n{\n    \"#url\"     : \"https://rbt.asia/_/search/text/test/\",\n    \"#category\": (\"foolfuuka\", \"rbt\", \"search\"),\n    \"#class\"   : foolfuuka.FoolfuukaSearchExtractor,\n},\n\n{\n    \"#url\"     : \"https://rbt.asia/g/gallery/8\",\n    \"#category\": (\"foolfuuka\", \"rbt\", \"gallery\"),\n    \"#class\"   : foolfuuka.FoolfuukaGalleryExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/reactor.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import reactor\n\n\n__tests__ = (\n{\n    \"#url\"     : \"http://reactor.cc/tag/gif\",\n    \"#category\": (\"reactor\", \"reactor\", \"tag\"),\n    \"#class\"   : reactor.ReactorTagExtractor,\n},\n\n{\n    \"#url\"     : \"http://reactor.cc/search?q=Art\",\n    \"#category\": (\"reactor\", \"reactor\", \"search\"),\n    \"#class\"   : reactor.ReactorSearchExtractor,\n},\n\n{\n    \"#url\"     : \"http://reactor.cc/user/Dioklet\",\n    \"#category\": (\"reactor\", \"reactor\", \"user\"),\n    \"#class\"   : reactor.ReactorUserExtractor,\n},\n\n{\n    \"#url\"     : \"http://reactor.cc/post/4999736\",\n    \"#category\": (\"reactor\", \"reactor\", \"post\"),\n    \"#class\"   : reactor.ReactorPostExtractor,\n    \"#sha1_url\": \"dfc74d150d7267384d8c229c4b82aa210755daa0\",\n},\n\n)\n"
  },
  {
    "path": "test/results/readcomiconline.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import readcomiconline\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://readcomiconline.li/Comic/W-i-t-c-h/Issue-130?id=22289\",\n    \"#class\"   : readcomiconline.ReadcomiconlineIssueExtractor,\n    \"#pattern\"      : r\"https://2\\.bp\\.blogspot\\.com/[\\w-]+=s0\\?.+\",\n    \"#count\"        : 36,\n    \"#sha1_metadata\": \"2d9ec81ce1b11fac06ebf96ce33cdbfca0e85eb5\",\n\n    \"comic\"      : \"W.i.t.c.h.\",\n    \"count\"      : 36,\n    \"extension\"  : \"\",\n    \"filename\"   : str,\n    \"issue\"      : \"130\",\n    \"issue_id\"   : 22289,\n    \"lang\"       : \"en\",\n    \"language\"   : \"English\",\n    \"page\"       : range(1, 36),\n},\n\n{\n    \"#url\"     : \"https://readcomiconline.li/Comic/Captain-Planet/Issue-1?id=238698&s=&readType=1\",\n    \"#comment\" : \"'One page' Reading mode (#7890)\",\n    \"#class\"   : readcomiconline.ReadcomiconlineIssueExtractor,\n    \"#pattern\" : r\"https://2\\.bp\\.blogspot\\.com/pw/[\\w-]+=s0\\?.+\",\n    \"#count\"   : 31,\n\n    \"comic\"      : \"Captain Planet\",\n    \"count\"      : 31,\n    \"extension\"  : \"\",\n    \"filename\"   : str,\n    \"issue\"      : \"1\",\n    \"issue_id\"   : 238698,\n    \"lang\"       : \"en\",\n    \"language\"   : \"English\",\n    \"page\"       : range(1, 31),\n},\n\n{\n    \"#url\"     : \"https://readcomiconline.li/Comic/W-i-t-c-h\",\n    \"#class\"   : readcomiconline.ReadcomiconlineComicExtractor,\n    \"#pattern\" : readcomiconline.ReadcomiconlineIssueExtractor.pattern,\n    \"#sha1_url\"     : \"74eb8b9504b4084fcc9367b341300b2c52260918\",\n    \"#sha1_metadata\": \"574051aaf7a5c92dafed9e94baa40a1a93db5c90\",\n},\n\n{\n    \"#url\"     : \"https://readcomiconline.to/Comic/Bazooka-Jules\",\n    \"#class\"   : readcomiconline.ReadcomiconlineComicExtractor,\n    \"#pattern\" : readcomiconline.ReadcomiconlineIssueExtractor.pattern,\n    \"#sha1_url\"     : \"2f66a467a772df4d4592e97a059ddbc3e8991799\",\n    \"#sha1_metadata\": \"9563a19454e1b4e0da5b7a28112bf00a3e8069a8\",\n},\n\n{\n    \"#url\"     : \"https://readcomiconline.li/Writer/Jody-Houser\",\n    \"#category\": (\"\", \"readcomiconline\", \"writer\"),\n    \"#class\"   : readcomiconline.ReadcomiconlineTagExtractor,\n    \"#pattern\" : readcomiconline.ReadcomiconlineComicExtractor.pattern,\n    \"#count\"   : range(80, 120),\n},\n\n{\n    \"#url\"     : \"https://readcomiconline.li/Artist/Simone-Buonfantino/LatestUpdate\",\n    \"#category\": (\"\", \"readcomiconline\", \"artist\"),\n    \"#class\"   : readcomiconline.ReadcomiconlineTagExtractor,\n    \"#results\" : (\n        \"https://readcomiconline.li/Comic/Spider-Man-Unlimited-Infinity-Comic\",\n        \"https://readcomiconline.li/Comic/Black-Widow-Widow-s-Sting\",\n        \"https://readcomiconline.li/Comic/Age-of-X-Man-Omega\",\n        \"https://readcomiconline.li/Comic/Captain-Marvel-Braver-Mightier\",\n        \"https://readcomiconline.li/Comic/Thor-vs-Hulk-Champions-of-the-Universe\",\n    ),\n\n    \"search_tags\": \"Simone-Buonfantino\",\n},\n\n{\n    \"#url\"     : \"https://readcomiconline.li/Genre/Spy\",\n    \"#category\": (\"\", \"readcomiconline\", \"genre\"),\n    \"#class\"   : readcomiconline.ReadcomiconlineTagExtractor,\n},\n\n{\n    \"#url\"     : \"https://readcomiconline.li/Publisher/Europe-Comics\",\n    \"#category\": (\"\", \"readcomiconline\", \"publisher\"),\n    \"#class\"   : readcomiconline.ReadcomiconlineTagExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/realbooru.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import realbooru\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://realbooru.com/index.php?page=post&s=list&tags=wine\",\n    \"#category\": (\"booru\", \"realbooru\", \"tag\"),\n    \"#class\"   : realbooru.RealbooruTagExtractor,\n    \"#count\"   : \">= 64\",\n},\n\n{\n    \"#url\"     : \"https://realbooru.com/index.php?page=pool&s=show&id=1\",\n    \"#category\": (\"booru\", \"realbooru\", \"pool\"),\n    \"#class\"   : realbooru.RealbooruPoolExtractor,\n    \"#results\" : (\n        \"https://video-cdn.realbooru.com//images/bf/d6/bfd682f338691e5254de796040fcba21.mp4\",\n        \"https://video-cdn.realbooru.com//images/cb/7d/cb7d921673ba99f688031ac554777695.mp4\",\n        \"https://video-cdn.realbooru.com//images/9e/14/9e140edc1cb2e4cc734ba5bdc4870955.mp4\",\n    ),\n},\n\n{\n    \"#url\"     : \"https://realbooru.com/index.php?page=favorites&s=view&id=274\",\n    \"#category\": (\"booru\", \"realbooru\", \"favorite\"),\n    \"#class\"   : realbooru.RealbooruFavoriteExtractor,\n    \"#results\" : \"https://realbooru.com//images/20/3e/203eefb39f54de049e30ff788a022ac7.jpeg\",\n},\n\n{\n    \"#url\"     : \"https://realbooru.com/index.php?page=post&s=view&id=862054\",\n    \"#comment\" : \"regular post\",\n    \"#category\": (\"booru\", \"realbooru\", \"post\"),\n    \"#class\"   : realbooru.RealbooruPostExtractor,\n    \"#results\"     : \"https://realbooru.com//images/8a/34/8a345820da989637c21ac013d522bf69.jpeg\",\n    \"#sha1_content\": \"f6213e6f25c3cb9e3cfefa6d4b3a78e44b9dea5b\",\n\n    \"created_at\"    : \"Jan, 18 2024\",\n    \"date\"          : \"dt:2024-01-18 00:00:00\",\n    \"file_url\"      : \"https://realbooru.com//images/8a/34/8a345820da989637c21ac013d522bf69.jpeg\",\n    \"filename\"      : \"8a345820da989637c21ac013d522bf69\",\n    \"id\"            : \"862054\",\n    \"md5\"           : \"8a345820da989637c21ac013d522bf69\",\n    \"rating\"        : \"e\",\n    \"score\"         : r\"re:\\d+\",\n    \"source\"        : \"https://www.instagram.com/p/CwAO1UyJBnw\",\n    \"tags\"          : \"1girl, asian, bikini, black_hair, breasts, cleavage, female, female_only, floral_print, instagram, japanese, kurita_emi, large_breasts, looking_at_viewer, navel, sauna, short_hair, side-tie_bikini, sitting, solo\",\n    \"tags_copyright\": \"instagram\",\n    \"tags_general\"  : \"1girl, asian, bikini, black_hair, breasts, cleavage, female, female_only, floral_print, large_breasts, looking_at_viewer, navel, sauna, short_hair, side-tie_bikini, sitting, solo\",\n    \"tags_metadata\" : \"japanese\",\n    \"tags_model\"    : \"kurita_emi\",\n},\n\n{\n    \"#url\"     : \"https://realbooru.com/index.php?page=post&s=view&id=568145\",\n    \"#comment\" : \"older post\",\n    \"#category\": (\"booru\", \"realbooru\", \"post\"),\n    \"#class\"   : realbooru.RealbooruPostExtractor,\n    \"#sha1_content\": \"4a7424810f5f846c161b5d3b7c8b0a85a03368c8\",\n},\n\n{\n    \"#url\"     : \"https://realbooru.com/index.php?page=post&s=view&id=825911\",\n    \"#comment\" : \"animated GIF\",\n    \"#category\": (\"booru\", \"realbooru\", \"post\"),\n    \"#class\"   : realbooru.RealbooruPostExtractor,\n    \"#results\" : \"https://realbooru.com//images/a3/9b/a39bfe5ea07f132db486e118b598c66c.gif\",\n\n    \"created_at\"   : \"Feb, 19 2023\",\n    \"date\"         : \"dt:2023-02-19 00:00:00\",\n    \"extension\"    : \"gif\",\n    \"file_url\"     : \"https://realbooru.com//images/a3/9b/a39bfe5ea07f132db486e118b598c66c.gif\",\n    \"_fallback\"    : (),\n    \"filename\"     : \"a39bfe5ea07f132db486e118b598c66c\",\n    \"id\"           : \"825911\",\n    \"md5\"          : \"a39bfe5ea07f132db486e118b598c66c\",\n    \"rating\"       : \"e\",\n    \"score\"        : r\"re:\\d+\",\n    \"source\"       : \"\",\n    \"title\"        : \"\",\n    \"uploader\"     : \"freakyguro\",\n    \"tags\"         : \"1girl, amateur, animated, animated_gif, asian, ass, ass_shake, biting_lip, breasts, brown_hair, butt, cute, fringe, girlfriend, japanese, japanese_(nationality), kitchen, looking_at_viewer, looking_back, medium_breasts, obokozu, panties, petite, seductive_eyes, seductive_smile, solo, underwear\",\n    \"tags_general\" : \"1girl, asian, ass, ass_shake, biting_lip, breasts, brown_hair, butt, cute, fringe, girlfriend, japanese_(nationality), kitchen, looking_at_viewer, looking_back, medium_breasts, panties, petite, seductive_eyes, seductive_smile, solo, underwear\",\n    \"tags_metadata\": \"amateur, animated, animated_gif, japanese\",\n    \"tags_model\"   : \"obokozu\",\n},\n\n{\n    \"#url\"     : \"https://realbooru.com/index.php?page=post&s=view&id=751237\",\n    \"#comment\" : \"video\",\n    \"#category\": (\"booru\", \"realbooru\", \"post\"),\n    \"#class\"   : realbooru.RealbooruPostExtractor,\n    \"#results\" : \"https://video-cdn.realbooru.com//images/3c/34/3c343385126c90a7fdbccc58d342a511.mp4\",\n\n    \"created_at\"    : \"Dec, 07 2020\",\n    \"date\"          : \"dt:2020-12-07 00:00:00\",\n    \"extension\"     : \"mp4\",\n    \"file_url\"      : \"https://video-cdn.realbooru.com//images/3c/34/3c343385126c90a7fdbccc58d342a511.mp4\",\n    \"_fallback\"     : (\"https://video-cdn.realbooru.com//images/3c/34/3c343385126c90a7fdbccc58d342a511.webm\",),\n    \"filename\"      : \"3c343385126c90a7fdbccc58d342a511\",\n    \"id\"            : \"751237\",\n    \"md5\"           : \"3c343385126c90a7fdbccc58d342a511\",\n    \"rating\"        : \"e\",\n    \"score\"         : r\"re:\\d+\",\n    \"source\"        : \"\",\n    \"title\"         : \"\",\n    \"uploader\"      : \"Rock 7800\",\n    \"tags\"          : \"1girl, animated, anri_okita, asian, breasts, brown_hair, brunette, dress, dress_pull, female, female_only, female_solo, huge_breasts, human_only, japanese, lipstick, makeup, model, mostly_clothed, nadine-j, nipples, no_bra, no_sound, topless, undressing, webm\",\n    \"tags_copyright\": \"nadine-j\",\n    \"tags_general\"  : \"1girl, asian, breasts, brown_hair, brunette, dress, dress_pull, female, female_only, female_solo, huge_breasts, human_only, lipstick, makeup, mostly_clothed, nipples, no_bra, topless, undressing\",\n    \"tags_metadata\" : \"animated, japanese, model, no_sound, webm\",\n    \"tags_model\"    : \"anri_okita\",\n},\n\n)\n"
  },
  {
    "path": "test/results/recursive.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import recursive\n\n\n__tests__ = (\n{\n    \"#url\"     : \"recursive:https://pastebin.com/raw/FLwrCYsT\",\n    \"#category\": (\"\", \"recursive\", \"\"),\n    \"#class\"   : recursive.RecursiveExtractor,\n    \"#sha1_url\": \"eee86d65c346361b818e8f4b2b307d9429f136a2\",\n},\n\n)\n"
  },
  {
    "path": "test/results/reddit.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import reddit\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://www.reddit.com/r/lavaporn/\",\n    \"#category\": (\"\", \"reddit\", \"subreddit\"),\n    \"#class\"   : reddit.RedditSubredditExtractor,\n    \"#range\"   : \"1-20\",\n    \"#count\"   : \">= 20\",\n},\n\n{\n    \"#url\"     : \"https://www.reddit.com/r/lavaporn/top/?sort=top&t=month\",\n    \"#category\": (\"\", \"reddit\", \"subreddit-top\"),\n    \"#class\"   : reddit.RedditSubredditExtractor,\n},\n\n{\n    \"#url\"     : \"https://old.reddit.com/r/lavaporn/\",\n    \"#category\": (\"\", \"reddit\", \"subreddit\"),\n    \"#class\"   : reddit.RedditSubredditExtractor,\n},\n\n{\n    \"#url\"     : \"https://np.reddit.com/r/lavaporn/\",\n    \"#category\": (\"\", \"reddit\", \"subreddit\"),\n    \"#class\"   : reddit.RedditSubredditExtractor,\n},\n\n{\n    \"#url\"     : \"https://m.reddit.com/r/lavaporn/\",\n    \"#category\": (\"\", \"reddit\", \"subreddit\"),\n    \"#class\"   : reddit.RedditSubredditExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.reddit.com/r/IdiotsInCars/search/?q=flair%3AOC\",\n    \"#comment\" : \"(#7025)\",\n    \"#category\": (\"\", \"reddit\", \"subreddit-search\"),\n    \"#class\"   : reddit.RedditSubredditExtractor,\n    \"#range\"   : \"1-25\",\n\n    \"subreddit\"      : \"IdiotsInCars\",\n    \"link_flair_text\": \"OC\",\n},\n\n{\n    \"#url\"     : \"https://www.old.reddit.com/r/lavaporn/\",\n    \"#class\"   : reddit.RedditSubredditExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.np.reddit.com/r/lavaporn/\",\n    \"#class\"   : reddit.RedditSubredditExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.m.reddit.com/r/lavaporn/\",\n    \"#class\"   : reddit.RedditSubredditExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.reddit.com/\",\n    \"#category\": (\"\", \"reddit\", \"home\"),\n    \"#class\"   : reddit.RedditHomeExtractor,\n    \"#range\"   : \"1-20\",\n    \"#count\"   : \">= 20\",\n    \"#archive\" : False,\n},\n\n{\n    \"#url\"     : \"https://old.reddit.com/top/?sort=top&t=month\",\n    \"#category\": (\"\", \"reddit\", \"home-top\"),\n    \"#class\"   : reddit.RedditHomeExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.reddit.com/user/username/\",\n    \"#category\": (\"\", \"reddit\", \"user\"),\n    \"#class\"   : reddit.RedditUserExtractor,\n    \"#count\"   : \">= 2\",\n},\n\n{\n    \"#url\"     : \"https://www.reddit.com/user/username/gilded/?sort=top&t=month\",\n    \"#category\": (\"\", \"reddit\", \"user-gilded\"),\n    \"#class\"   : reddit.RedditUserExtractor,\n},\n\n{\n    \"#url\"     : \"https://old.reddit.com/user/username/\",\n    \"#category\": (\"\", \"reddit\", \"user\"),\n    \"#class\"   : reddit.RedditUserExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.reddit.com/u/username/\",\n    \"#category\": (\"\", \"reddit\", \"user\"),\n    \"#class\"   : reddit.RedditUserExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.reddit.com/user/SeichiScout/submitted/?sort=hot\",\n    \"#category\": (\"\", \"reddit\", \"user-submitted\"),\n    \"#class\"   : reddit.RedditUserExtractor,\n    \"#pattern\" : r\"https://i\\.redd\\.it/\\w+\\.jpg\",\n    \"#range\"   : \"1-25\",\n    \"#count\"   : 25,\n    \"#archive\" : False,\n\n    \"author\"          : \"SeichiScout\",\n    \"author_fullname\" : \"t2_l8qpy6td6\",\n    \"user\"            : {\n        \"created_utc\" : 1724480738.0,\n        \"id\"          : \"l8qpy6td6\",\n        \"name\"        : \"SeichiScout\",\n        \"verified\"    : True,\n    },\n},\n\n{\n    \"#url\"     : \"https://www.old.reddit.com/user/username/\",\n    \"#class\"   : reddit.RedditUserExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.reddit.com/user/Leac-/submitted/?sort=new\",\n    \"#comment\" : \"suspended user (#9177)\",\n    \"#category\": (\"\", \"reddit\", \"user-submitted\"),\n    \"#class\"   : reddit.RedditUserExtractor,\n    \"#exception\": \"NotFoundError:Suspended User\",\n},\n\n{\n    \"#url\"     : \"https://www.reddit.com/r/lavaporn/comments/8cqhub/\",\n    \"#category\": (\"\", \"reddit\", \"submission\"),\n    \"#class\"   : reddit.RedditSubmissionExtractor,\n    \"#pattern\" : r\"https://c2.staticflickr.com/8/7272/\\w+_k.jpg\",\n    \"#count\"   : 1,\n},\n\n{\n    \"#url\"     : \"https://www.reddit.com/r/lavaporn/comments/8cqhub/\",\n    \"#category\": (\"\", \"reddit\", \"submission\"),\n    \"#class\"   : reddit.RedditSubmissionExtractor,\n    \"#options\" : {\"comments\": 500},\n    \"#pattern\" : \"https://\",\n    \"#count\"   : 3,\n},\n\n{\n    \"#url\"     : \"https://www.reddit.com/gallery/hrrh23\",\n    \"#category\": (\"\", \"reddit\", \"submission\"),\n    \"#class\"   : reddit.RedditSubmissionExtractor,\n    \"#count\"       : 3,\n    \"#sha1_url\"    : \"25b91ede15459470274dd17291424b037ed8b0ae\",\n    \"#sha1_content\": \"1e7dde4ee7d5f4c4b45749abfd15b2dbfa27df3f\",\n},\n\n{\n    \"#url\"     : \"https://www.reddit.com/r/aww/comments/90bu6w/\",\n    \"#comment\" : \"video (dash)\",\n    \"#category\": (\"\", \"reddit\", \"submission\"),\n    \"#class\"   : reddit.RedditSubmissionExtractor,\n    \"#pattern\" : \"ytdl:https://v.redd.it/gyh95hiqc0b11\",\n    \"#count\"   : 1,\n},\n\n{\n    \"#url\"     : \"https://www.reddit.com/r/aww/comments/90bu6w/\",\n    \"#comment\" : \"video (dash)\",\n    \"#category\": (\"\", \"reddit\", \"submission\"),\n    \"#class\"   : reddit.RedditSubmissionExtractor,\n    \"#options\" : {\"videos\": \"ytdl\"},\n    \"#pattern\" : \"ytdl:https://www.reddit.com/r/aww/comments/90bu6w/heat_index_was_110_degrees_so_we_offered_him_a/\",\n    \"#count\"   : 1,\n},\n\n{\n    \"#url\"     : \"https://www.reddit.com/r/aww/comments/90bu6w/\",\n    \"#comment\" : \"video (dash)\",\n    \"#category\": (\"\", \"reddit\", \"submission\"),\n    \"#class\"   : reddit.RedditSubmissionExtractor,\n    \"#options\" : {\"videos\": \"dash\"},\n    \"#pattern\" : r\"ytdl:https://v.redd.it/gyh95hiqc0b11/DASHPlaylist.mpd\\?a=\",\n    \"#count\"   : 1,\n},\n\n{\n    \"#url\"     : \"https://www.reddit.com/gallery/icfgzv\",\n    \"#comment\" : \"deleted gallery (#953)\",\n    \"#category\": (\"\", \"reddit\", \"submission\"),\n    \"#class\"   : reddit.RedditSubmissionExtractor,\n    \"#count\"   : 0,\n},\n\n{\n    \"#url\"     : \"https://www.reddit.com/r/araragi/comments/ib32hm\",\n    \"#comment\" : \"animated gallery items (#955)\",\n    \"#category\": (\"\", \"reddit\", \"submission\"),\n    \"#class\"   : reddit.RedditSubmissionExtractor,\n    \"#pattern\" : r\"https://i\\.redd\\.it/\\w+\\.gif\",\n    \"#count\"   : 2,\n},\n\n{\n    \"#url\"     : \"https://www.reddit.com/r/cosplay/comments/jvwaqr\",\n    \"#comment\" : \"'failed' gallery item (#1127)\",\n    \"#category\": (\"\", \"reddit\", \"submission\"),\n    \"#class\"   : reddit.RedditSubmissionExtractor,\n    \"#count\"   : 1,\n},\n\n{\n    \"#url\"     : \"https://www.reddit.com/r/kpopfap/comments/qjj04q/\",\n    \"#comment\" : \"gallery with no 'media_metadata' (#2001)\",\n    \"#category\": (\"\", \"reddit\", \"submission\"),\n    \"#class\"   : reddit.RedditSubmissionExtractor,\n    \"#count\"   : 0,\n},\n\n{\n    \"#url\"     : \"https://www.reddit.com/comments/1nz2ic5\",\n    \"#comment\" : \"comment share URL\",\n    \"#category\": (\"\", \"reddit\", \"submission\"),\n    \"#class\"   : reddit.RedditSubmissionExtractor,\n    \"#pattern\" : r\"https://i\\.redd\\.it/\\w+\\.png\",\n    \"#count\"   : 2,\n},\n\n{\n    \"#url\"     : \"https://www.reddit.com/r/RobloxArt/comments/15ko0qu/\",\n    \"#comment\" : \"comment embeds (#5366)\",\n    \"#class\"   : reddit.RedditSubmissionExtractor,\n    \"#options\" : {\"comments\": 10},\n    \"#results\" : (\n        \"https://i.redd.it/ppt5yciyipgb1.jpg\",\n        \"https://i.redd.it/u0ojzd69kpgb1.png\",\n    ),\n},\n\n{\n    \"#url\"     : \"https://www.reddit.com/r/RobloxArt/comments/15ko0qu/\",\n    \"#comment\" : \"disabled comment embeds (#6357)\",\n    \"#class\"   : reddit.RedditSubmissionExtractor,\n    \"#options\" : {\"comments\": 10, \"embeds\": False},\n    \"#results\" : \"https://i.redd.it/ppt5yciyipgb1.jpg\",\n},\n\n{\n    \"#url\"     : \"https://www.reddit.com/user/TheSpiritTree/comments/srilyf/\",\n    \"#comment\" : \"user page submission (#2301)\",\n    \"#category\": (\"\", \"reddit\", \"submission\"),\n    \"#class\"   : reddit.RedditSubmissionExtractor,\n    \"#pattern\" : \"https://i.redd.it/8fpgv17yqlh81.jpg\",\n    \"#count\"   : 1,\n},\n\n{\n    \"#url\"     : \"https://www.reddit.com/r/kittengifs/comments/12m0b8d\",\n    \"#comment\" : \"cross-posted video (#887, #3586, #3976)\",\n    \"#category\": (\"\", \"reddit\", \"submission\"),\n    \"#class\"   : reddit.RedditSubmissionExtractor,\n    \"#pattern\" : r\"ytdl:https://v\\.redd\\.it/cvabpjacrvta1\",\n},\n\n{\n    \"#url\"     : \"https://www.reddit.com/r/europe/comments/pm4531/the_name_of/\",\n    \"#comment\" : \"preview.redd.it (#4470)\",\n    \"#category\": (\"\", \"reddit\", \"submission\"),\n    \"#class\"   : reddit.RedditSubmissionExtractor,\n    \"#results\" : \"https://preview.redd.it/u9ud4k6xaf271.jpg?auto=webp&s=19b1334cb4409111cda136c01f7b44c2c42bf9fb\",\n},\n\n{\n    \"#url\"     : \"https://www.reddit.com/r/gonewildaudio/comments/1j2pxfn/\",\n    \"#comment\" : \"'selftext' option (#7111)\",\n    \"#category\": (\"\", \"reddit\", \"submission\"),\n    \"#class\"   : reddit.RedditSubmissionExtractor,\n    \"#options\" : {\"selftext\": True, \"comments\": 0},\n    \"#results\" : (\n        \"https://www.reddit.com/r/gonewildaudio/s/22pP7vizkx\",\n        \"https://soundgasm.net/u/chuwa/Your-Timid-Neighbor-Asks-You-To-Turn-Your-Music-Down-So-You-Fuck-Her-Stupid\",\n    ),\n},\n\n{\n    \"#url\"     : \"https://old.reddit.com/r/redgifs/comments/gfvw9v/redgifs_links_on_reddit_see_comment/\",\n    \"#comment\" : \"redgifs embed\",\n    \"#category\": (\"\", \"reddit\", \"submission\"),\n    \"#class\"   : reddit.RedditSubmissionExtractor,\n    \"#results\" : \"https://redgifs.com/watch/foolishforkedabyssiniancat\",\n},\n\n{\n    \"#url\"     : \"https://old.reddit.com/r/lavaporn/comments/2a00np/\",\n    \"#category\": (\"\", \"reddit\", \"submission\"),\n    \"#class\"   : reddit.RedditSubmissionExtractor,\n},\n\n{\n    \"#url\"     : \"https://np.reddit.com/r/lavaporn/comments/2a00np/\",\n    \"#category\": (\"\", \"reddit\", \"submission\"),\n    \"#class\"   : reddit.RedditSubmissionExtractor,\n},\n\n{\n    \"#url\"     : \"https://m.reddit.com/r/lavaporn/comments/2a00np/\",\n    \"#category\": (\"\", \"reddit\", \"submission\"),\n    \"#class\"   : reddit.RedditSubmissionExtractor,\n},\n\n{\n    \"#url\"     : \"https://redd.it/2a00np/\",\n    \"#category\": (\"\", \"reddit\", \"submission\"),\n    \"#class\"   : reddit.RedditSubmissionExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.reddit.com/user/-frogchamp-/comments/1n260wh/osc_art_request_timelapsespeed_draw_so_far_flash/\",\n    \"#comment\" : \"video embed (#8139)\",\n    \"#class\"   : reddit.RedditSubmissionExtractor,\n    \"#options\" : {\"api\": \"rest\"},\n    \"#results\" : \"ytdl:https://v.redd.it/link/1n260wh/asset/8q50b220tplf1/DASHPlaylist.mpd?a=1759085227%2CYTU2NGJjNTlmNjBlOGE2NWUwYWI0MjRjZDYzZjllZjk4Nzc3Y2Y4Nzc1NDMzOTBkYTNkOWFjOGMzZjUzZDAzMQ%3D%3D&v=1&f=sd\",\n},\n\n{\n    \"#url\"     : \"https://www.reddit.com/r/vi/comments/1qi6tev/comment/o0v9zh8/\",\n    \"#comment\" : \"'external-preview' embed\",\n    \"#class\"   : reddit.RedditSubmissionExtractor,\n    \"#options\" : {\"comments\": 100},\n    \"#range\"   : \"2\",\n    \"#pattern\" : r\"https://external-preview.redd.it/0DT-wjorv2NNSSoTW-62QG4xs7ZqGE2jcn2OEa2Xnks\\.gif\\?width=200&height=200&s=\\w+\",\n    \"#sha1_content\": \"5014a016d7d5fb2c9fdaa01f0b43afeff158cd9b\",\n},\n\n{\n    \"#url\"     : \"https://www.old.reddit.com/r/lavaporn/comments/2a00np/\",\n    \"#class\"   : reddit.RedditSubmissionExtractor,\n},\n\n{\n    \"#url\"     : \"https://i.redd.it/upjtjcx2npzz.jpg\",\n    \"#category\": (\"\", \"reddit\", \"image\"),\n    \"#class\"   : reddit.RedditImageExtractor,\n    \"#sha1_url\"    : \"0de614900feef103e580b632190458c0b62b641a\",\n    \"#sha1_content\": \"cc9a68cf286708d5ce23c68e79cd9cf7826db6a3\",\n},\n\n{\n    \"#url\"     : \"https://i.reddituploads.com/0f44f1b1fca2461f957c713d9592617d?fit=max&h=1536&w=1536&s=e96ce7846b3c8e1f921d2ce2671fb5e2\",\n    \"#category\": (\"\", \"reddit\", \"image\"),\n    \"#class\"   : reddit.RedditImageExtractor,\n    \"#sha1_url\"    : \"f24f25efcedaddeec802e46c60d77ef975dc52a5\",\n    \"#sha1_content\": \"541dbcc3ad77aa01ee21ca49843c5e382371fae7\",\n},\n\n{\n    \"#url\"     : \"https://preview.redd.it/00af44lpn0u51.jpg?width=960&crop=smart&auto=webp&v=enabled&s=dbca8ab84033f4a433772d9c15dbe0429c74e8ac\",\n    \"#comment\" : \"preview.redd.it -> i.redd.it\",\n    \"#category\": (\"\", \"reddit\", \"image\"),\n    \"#class\"   : reddit.RedditImageExtractor,\n    \"#pattern\" : r\"^https://i\\.redd\\.it/00af44lpn0u51\\.jpg$\",\n},\n\n{\n    \"#url\"     : \"https://www.reddit.com/r/analog/s/hKrTTvFVwZ\",\n    \"#comment\" : \"Mobile share URL\",\n    \"#category\": (\"\", \"reddit\", \"redirect\"),\n    \"#class\"   : reddit.RedditRedirectExtractor,\n    \"#pattern\" : r\"^https://www\\.reddit\\.com/r/analog/comments/179exao/photographing_the_recent_annular_eclipse_with_a\",\n},\n\n{\n    \"#url\"     : \"https://www.reddit.com/u/Tailhook91/s/w4yAMbtOYm\",\n    \"#comment\" : \"Mobile share URL, user submission\",\n    \"#category\": (\"\", \"reddit\", \"redirect\"),\n    \"#class\"   : reddit.RedditRedirectExtractor,\n    \"#pattern\" : r\"^https://www.reddit.com/user/Tailhook91/comments/znfxbr/prove_it/\",\n},\n\n{\n    \"#url\"     : \"https://www.reddit.com/user/Tailhook91/s/w4yAMbtOYm\",\n    \"#comment\" : \"Mobile share URL, user submission\",\n    \"#category\": (\"\", \"reddit\", \"redirect\"),\n    \"#class\"   : reddit.RedditRedirectExtractor,\n    \"#pattern\" : r\"^https://www.reddit.com/user/Tailhook91/comments/znfxbr/prove_it/\",\n},\n\n{\n    \"#url\"     : \"https://www.old.reddit.com/user/Tailhook91/s/w4yAMbtOYm\",\n    \"#class\"   : reddit.RedditRedirectExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/redgifs.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import redgifs\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://www.redgifs.com/users/mmj\",\n    \"#category\": (\"\", \"redgifs\", \"user\"),\n    \"#class\"   : redgifs.RedgifsUserExtractor,\n    \"#pattern\" : r\"https://\\w+\\.redgifs\\.com/[\\w-]+\\.mp4\",\n    \"#count\"   : range(40, 60),\n},\n\n{\n    \"#url\"     : \"https://www.redgifs.com/users/mmj?order=old\",\n    \"#comment\" : \"'order' URL parameter (#4583)\",\n    \"#category\": (\"\", \"redgifs\", \"user\"),\n    \"#class\"   : redgifs.RedgifsUserExtractor,\n    \"#range\"   : \"1-5\",\n    \"#patterns\": (\n        r\"https://thumbs\\d+\\.redgifs\\.com/ShoddyOilyHarlequinbug\\.mp4\",\n        r\"https://thumbs\\d+\\.redgifs\\.com/UnevenPrestigiousKilldeer\\.mp4\",\n        r\"https://thumbs\\d+\\.redgifs\\.com/EveryShockingFlickertailsquirrel\\.mp4\",\n        r\"https://thumbs\\d+\\.redgifs\\.com/NegativeWarlikeAmericancurl\\.mp4\",\n        r\"https://thumbs\\d+\\.redgifs\\.com/PopularTerribleFritillarybutterfly\\.mp4\",\n    ),\n},\n\n{\n    \"#url\"     : \"https://v3.redgifs.com/users/lamsinka89\",\n    \"#comment\" : \"'v3' subdomain (#3588, #3589)\",\n    \"#category\": (\"\", \"redgifs\", \"user\"),\n    \"#class\"   : redgifs.RedgifsUserExtractor,\n    \"#pattern\" : r\"https://\\w+\\.redgifs\\.com/[\\w-]+\\.(mp4|jpg)\",\n    \"#count\"   : \">= 100\",\n},\n\n{\n    \"#url\"     : \"https://www.redgifs.com/users/boombah123/collections/2631326bbd\",\n    \"#category\": (\"\", \"redgifs\", \"collection\"),\n    \"#class\"   : redgifs.RedgifsCollectionExtractor,\n    \"#pattern\" : r\"https://\\w+\\.redgifs\\.com/[\\w-]+\\.mp4\",\n    \"#range\"   : \"1-20\",\n    \"#count\"   : 20,\n},\n\n{\n    \"#url\"     : \"https://www.redgifs.com/users/boombah123/collections/9e6f7dd41f\",\n    \"#category\": (\"\", \"redgifs\", \"collection\"),\n    \"#class\"   : redgifs.RedgifsCollectionExtractor,\n    \"#pattern\" : r\"https://\\w+\\.redgifs\\.com/[\\w-]+\\.mp4\",\n    \"#range\"   : \"1-20\",\n    \"#count\"   : 20,\n},\n\n{\n    \"#url\"     : \"https://www.redgifs.com/users/boombah123/collections\",\n    \"#category\": (\"\", \"redgifs\", \"collections\"),\n    \"#class\"   : redgifs.RedgifsCollectionsExtractor,\n    \"#pattern\" : r\"https://www\\.redgifs\\.com/users/boombah123/collections/\\w+\",\n    \"#count\"   : \">= 3\",\n},\n\n{\n    \"#url\"     : \"https://www.redgifs.com/niches/just-boobs\",\n    \"#category\": (\"\", \"redgifs\", \"niches\"),\n    \"#class\"   : redgifs.RedgifsNichesExtractor,\n    \"#pattern\" : r\"https://\\w+\\.redgifs\\.com/[\\w-]+\\.(mp4|jpg)\",\n    \"#range\"   : \"1-20\",\n    \"#count\"   : 20,\n},\n\n{\n    \"#url\"     : \"https://www.redgifs.com/niches/thick-booty\",\n    \"#category\": (\"\", \"redgifs\", \"niches\"),\n    \"#class\"   : redgifs.RedgifsNichesExtractor,\n    \"#pattern\" : r\"https://\\w+\\.redgifs\\.com/[\\w-]+\\.(mp4|jpg)\",\n    \"#range\"   : \"1-20\",\n    \"#count\"   : 20,\n},\n\n{\n    \"#url\"     : \"https://www.redgifs.com/gifs/jav\",\n    \"#category\": (\"\", \"redgifs\", \"search\"),\n    \"#class\"   : redgifs.RedgifsSearchExtractor,\n    \"#pattern\" : r\"https://\\w+\\.redgifs\\.com/[A-Za-z-]+\\.(mp4|jpg)\",\n    \"#range\"   : \"1-10\",\n    \"#count\"   : 10,\n},\n\n{\n    \"#url\"     : \"https://www.redgifs.com/search/gifs?query=jav+model&order=top\",\n    \"#category\": (\"\", \"redgifs\", \"search\"),\n    \"#class\"   : redgifs.RedgifsSearchExtractor,\n    \"#pattern\" : r\"https://\\w+\\.redgifs\\.com/[A-Za-z-]+\\.(mp4|jpg)\",\n    \"#range\"   : \"1-10\",\n    \"#count\"   : 10,\n},\n\n{\n    \"#url\"     : \"https://www.redgifs.com/search?query=Skinny+Lesbian\",\n    \"#class\"   : redgifs.RedgifsSearchExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.redgifs.com/browse?tags=JAV\",\n    \"#category\": (\"\", \"redgifs\", \"search\"),\n    \"#class\"   : redgifs.RedgifsSearchExtractor,\n    \"#pattern\" : r\"https://\\w+\\.redgifs\\.com/[A-Za-z-]+\\.(mp4|jpg)\",\n    \"#range\"   : \"1-10\",\n    \"#count\"   : 10,\n},\n\n{\n    \"#url\"     : \"https://www.redgifs.com/gifs/jav?order=best&verified=1\",\n    \"#category\": (\"\", \"redgifs\", \"search\"),\n    \"#class\"   : redgifs.RedgifsSearchExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.redgifs.com/browse?type=i&verified=y&order=top7\",\n    \"#category\": (\"\", \"redgifs\", \"search\"),\n    \"#class\"   : redgifs.RedgifsSearchExtractor,\n},\n\n{\n    \"#url\"     : \"https://v3.redgifs.com/browse?tags=JAV\",\n    \"#category\": (\"\", \"redgifs\", \"search\"),\n    \"#class\"   : redgifs.RedgifsSearchExtractor,\n},\n\n{\n    \"#url\"     : \"https://redgifs.com/watch/foolishforkedabyssiniancat\",\n    \"#category\": (\"\", \"redgifs\", \"image\"),\n    \"#class\"   : redgifs.RedgifsImageExtractor,\n    \"#pattern\"     : r\"https://\\w+\\.redgifs\\.com/FoolishForkedAbyssiniancat\\.mp4\",\n    \"#sha1_content\": \"f6e03f1df9a2ff2a74092f53ee7580d2fb943533\",\n},\n\n{\n    \"#url\"     : \"https://www.redgifs.com/watch/desertedbaregraywolf\",\n    \"#comment\" : \"gallery (#4021)\",\n    \"#category\": (\"\", \"redgifs\", \"image\"),\n    \"#class\"   : redgifs.RedgifsImageExtractor,\n    \"#pattern\" : r\"https://\\w+\\.redgifs\\.com/[A-Za-z-]+\\.jpg\",\n    \"#count\"   : 4,\n\n    \"num\"    : int,\n    \"count\"  : 4,\n    \"gallery\": \"187ad979693-1922-fc66-0000-a96fb07b8a5d\",\n},\n\n{\n    \"#url\"     : \"https://redgifs.com/ifr/FoolishForkedAbyssiniancat\",\n    \"#category\": (\"\", \"redgifs\", \"image\"),\n    \"#class\"   : redgifs.RedgifsImageExtractor,\n},\n\n{\n    \"#url\"     : \"https://i.redgifs.com/i/FoolishForkedAbyssiniancat\",\n    \"#category\": (\"\", \"redgifs\", \"image\"),\n    \"#class\"   : redgifs.RedgifsImageExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.gifdeliverynetwork.com/foolishforkedabyssiniancat\",\n    \"#category\": (\"\", \"redgifs\", \"image\"),\n    \"#class\"   : redgifs.RedgifsImageExtractor,\n},\n\n{\n    \"#url\"     : \"https://v3.redgifs.com/watch/FoolishForkedAbyssiniancat\",\n    \"#category\": (\"\", \"redgifs\", \"image\"),\n    \"#class\"   : redgifs.RedgifsImageExtractor,\n},\n\n{\n    \"#url\"     : \"https://v3.redgifs.com/watch/605025947780972895\",\n    \"#category\": (\"\", \"redgifs\", \"image\"),\n    \"#class\"   : redgifs.RedgifsImageExtractor,\n\n    \"id\": \"humblegrippingmole\",\n},\n\n{\n    \"#url\"     : \"https://www.gfycat.com/foolishforkedabyssiniancat\",\n    \"#category\": (\"\", \"redgifs\", \"image\"),\n    \"#class\"   : redgifs.RedgifsImageExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/rule34.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import gelbooru_v02\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://rule34.xxx/index.php?page=post&s=list&tags=danraku\",\n    \"#category\": (\"gelbooru_v02\", \"rule34\", \"tag\"),\n    \"#class\"   : gelbooru_v02.GelbooruV02TagExtractor,\n    \"#results\"     : (\n        \"https://wimg.rule34.xxx/images/4615/00722987a1e8b5617a15a20c19a0915157048d3b.jpg\",\n        \"https://wimg.rule34.xxx/images/1845/04981deeac105a9c5fedc34a6ff017789e74f2a8.jpg\",\n    ),\n    \"#sha1_content\": [\n        \"5c6ae9ee13e6d4bc9cb8bdce224c84e67fbfa36c\",\n        \"622e80be3f496672c44aab5c47fbc6941c61bc79\",\n        \"1e0dced55bcb5eefe5cc32f69c7a8df35547b459\",\n    ],\n\n    \"search_tags\" : \"danraku\",\n    \"search_count\": 2,\n},\n\n{\n    \"#url\"     : \"https://rule34.xxx/index.php?page=pool&s=show&id=179\",\n    \"#category\": (\"gelbooru_v02\", \"rule34\", \"pool\"),\n    \"#class\"   : gelbooru_v02.GelbooruV02PoolExtractor,\n    \"#count\"   : 3,\n},\n\n{\n    \"#url\"     : \"https://rule34.xxx/index.php?page=favorites&s=view&id=1030218\",\n    \"#category\": (\"gelbooru_v02\", \"rule34\", \"favorite\"),\n    \"#class\"   : gelbooru_v02.GelbooruV02FavoriteExtractor,\n    \"#count\"   : 3,\n},\n\n{\n    \"#url\"     : \"https://www.rule34.xxx/index.php?page=post&s=view&id=863\",\n    \"#comment\" : \"www subdomain\",\n    \"#category\": (\"gelbooru_v02\", \"rule34\", \"post\"),\n    \"#class\"   : gelbooru_v02.GelbooruV02PostExtractor,\n},\n\n{\n    \"#url\"     : \"https://rule34.xxx/index.php?page=post&s=view&id=863\",\n    \"#category\": (\"gelbooru_v02\", \"rule34\", \"post\"),\n    \"#class\"   : gelbooru_v02.GelbooruV02PostExtractor,\n    \"#options\"     : {\n        \"tags\" : True,\n        \"notes\": True,\n    },\n    \"#results\"     : r\"https://wimg.rule34.xxx/images/1/6aafbdb3e22f3f3b412ea2cf53321317a37063f3.jpg\",\n    \"#sha1_content\": [\n        \"a43f418aa350039af0d11cae501396a33bbe2201\",\n        \"67b516295950867e1c1ab6bc13b35d3b762ed2a3\",\n    ],\n\n    \"tags_artist\"   : \"reverse_noise yamu_(reverse_noise)\",\n    \"tags_character\": \"hong_meiling\",\n    \"tags_copyright\": \"team_shanghai_alice touhou\",\n    \"tags_general\"  : str,\n    \"tags_metadata\" : \"censored translated\",\n    \"notes\"         : [\n        {\n            \"body\"  : \"It feels angry, I'm losing myself... It won't calm down!\",\n            \"height\": 65,\n            \"id\"    : 93586,\n            \"width\" : 116,\n            \"x\"     : 22,\n            \"y\"     : 333,\n        },\n        {\n            \"body\"  : \"REPUTATION OF RAGE\",\n            \"height\": 272,\n            \"id\"    : 93587,\n            \"width\" : 199,\n            \"x\"     : 78,\n            \"y\"     : 442,\n        },\n    ],\n},\n\n{\n    \"#url\"     : \"https://rule34.xxx/index.php?page=post&s=view&id=13853212\",\n    \"#comment\" : \"HTML response with 'api-cdn.' subdomain (#7697)\",\n    \"#category\": (\"gelbooru_v02\", \"rule34\", \"post\"),\n    \"#class\"   : gelbooru_v02.GelbooruV02PostExtractor,\n    \"#results\" : \"https://wimg.rule34.xxx/images/2164/60d61d06f3cd51be5152852d9c642d80.jpeg\",\n    \"#sha1_content\": [\n        \"0a07eb9e871589a012ec922c71eb4640fce09bb2\",\n    ],\n},\n\n)\n"
  },
  {
    "path": "test/results/rule34hentai.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import shimmie2\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://rule34hentai.net/post/list/mizuki_kotora/1\",\n    \"#category\": (\"shimmie2\", \"rule34hentai\", \"tag\"),\n    \"#class\"   : shimmie2.Shimmie2TagExtractor,\n    \"#results\" : (\n        \"https://rule34hentai.net/_images/7f3a411263d0f6de936e47ae8f9d35fb/332%20-%20Darkstalkers%20Felicia%20mizuki_kotora.jpeg\",\n        \"https://rule34hentai.net/_images/1a8eca7c04f8bf325bc993c5751a91c4/264%20-%20Darkstalkers%20Felicia%20mizuki_kotora.jpeg\",\n        \"https://rule34hentai.net/_images/09511511c4c9e9e1f9b795e059a60832/259%20-%20Darkstalkers%20Felicia%20mizuki_kotora.jpeg\",\n    ),\n\n    \"extension\"  : \"jpeg\",\n    \"file_url\"   : r\"re:https://rule34hentai.net/_images/.+\\.jpeg\",\n    \"filename\"   : r\"re:\\d+ - \\w+\",\n    \"height\"     : range(496, 875),\n    \"id\"         : range(259, 332),\n    \"md5\"        : r\"re:^[0-9a-f]{32}$\",\n    \"search_tags\": \"mizuki_kotora\",\n    \"size\"       : int,\n    \"tags\"       : str,\n    \"width\"      : range(500, 850),\n},\n\n{\n    \"#url\"     : \"https://rule34hentai.net/post/view/264\",\n    \"#category\": (\"shimmie2\", \"rule34hentai\", \"post\"),\n    \"#class\"   : shimmie2.Shimmie2PostExtractor,\n    \"#results\"     : \"https://rule34hentai.net/_images/1a8eca7c04f8bf325bc993c5751a91c4/264%20-%20Darkstalkers%20Felicia%20mizuki_kotora.jpg\",\n    \"#sha1_content\": \"6c23780bb78673cbff1bca9accb77ea11ec734f3\",\n\n    \"extension\": \"jpg\",\n    \"file_url\" : \"https://rule34hentai.net/_images/1a8eca7c04f8bf325bc993c5751a91c4/264%20-%20Darkstalkers%20Felicia%20mizuki_kotora.jpg\",\n    \"filename\" : \"264 - Darkstalkers Felicia mizuki_kotora\",\n    \"height\"   : 875,\n    \"id\"       : 264,\n    \"md5\"      : \"1a8eca7c04f8bf325bc993c5751a91c4\",\n    \"size\"     : 0,\n    \"tags\"     : \"Darkstalkers Felicia mizuki_kotora\",\n    \"width\"    : 657,\n},\n\n)\n"
  },
  {
    "path": "test/results/rule34us.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import rule34us\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://rule34.us/index.php?r=posts/index&q=[terios]_elysion\",\n    \"#category\": (\"booru\", \"rule34us\", \"tag\"),\n    \"#class\"   : rule34us.Rule34usTagExtractor,\n    \"#pattern\" : r\"https://img\\d*\\.rule34\\.us/images/../../[0-9a-f]{32}\\.\\w+\",\n    \"#count\"   : 10,\n\n    \"search_tags\": \"[terios]_elysion\",\n},\n\n{\n    \"#url\"     : \"https://rule34.us/index.php?r=posts/index&q=\",\n    \"#comment\" : \"empty 'q' query parameter (#8546)\",\n    \"#category\": (\"booru\", \"rule34us\", \"tag\"),\n    \"#class\"   : rule34us.Rule34usTagExtractor,\n},\n\n{\n    \"#url\"     : \"https://rule34.us/index.php?r=posts/view&id=3709005\",\n    \"#category\": (\"booru\", \"rule34us\", \"post\"),\n    \"#class\"   : rule34us.Rule34usPostExtractor,\n    \"#pattern\"     : r\"https://img\\d*\\.rule34\\.us/images/14/7b/147bee6fc2e13f73f5f9bac9d4930b13\\.png\",\n    \"#sha1_content\": \"d714342ea84050f82dda5f0c194d677337abafc5\",\n},\n\n{\n    \"#url\"     : \"https://rule34.us/index.php?r=posts/view&id=4576310\",\n    \"#category\": (\"booru\", \"rule34us\", \"post\"),\n    \"#class\"   : rule34us.Rule34usPostExtractor,\n    \"#results\" : \"https://video.rule34.us/images/a2/94/a294ff8e1f8e0efa041e5dc9d1480011.mp4\",\n\n    \"_fallback\"    : (\"https://video-cdn1.rule34.us/images/a2/94/a294ff8e1f8e0efa041e5dc9d1480011.mp4\",),\n    \"extension\"    : \"mp4\",\n    \"file_url\"     : str,\n    \"filename\"     : \"a294ff8e1f8e0efa041e5dc9d1480011\",\n    \"height\"       : \"3982\",\n    \"id\"           : \"4576310\",\n    \"md5\"          : \"a294ff8e1f8e0efa041e5dc9d1480011\",\n    \"score\"        : r\"re:\\d+\",\n    \"tags\"         : \"tagme, video\",\n    \"tags_general\" : \"video\",\n    \"tags_metadata\": \"tagme\",\n    \"uploader\"     : \"Anonymous\",\n    \"width\"        : \"3184\",\n},\n\n)\n"
  },
  {
    "path": "test/results/rule34vault.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import rule34vault\n\n\n__tests__ = (\n{\n    \"#url\"  : \"https://rule34vault.com/sfw\",\n    \"#class\": rule34vault.Rule34vaultTagExtractor,\n    \"#pattern\": r\"https://r34xyz\\.b-cdn\\.net/posts/\\d+/\\d+/\\d+\\.(jpg|mp4)\",\n    \"#range\"  : \"1-10\",\n    \"#count\"  : 10,\n},\n\n{\n    \"#url\"  : \"https://rule34vault.com/playlists/view/20164\",\n    \"#class\": rule34vault.Rule34vaultPlaylistExtractor,\n    \"#pattern\": r\"https://r34xyz\\.b-cdn\\.net/posts/\\d+/\\d+/\\d+\\.(jpg|mp4)\",\n    \"#count\"  : range(55, 75),\n},\n\n{\n    \"#url\"    : \"https://rule34vault.com/post/280517\",\n    \"#comment\": \"image\",\n    \"#class\"  : rule34vault.Rule34vaultPostExtractor,\n    \"#options\": {\"tags\": True},\n    \"#pattern\"     : \"https://r34xyz.b-cdn.net/posts/280/280517/280517.jpg\",\n    \"#sha1_content\": \"1e19d601b4a79c06e6f885a83a5003e7e2a17057\",\n\n    \"created\"   : \"2023-09-01T11:57:57.317331Z\",\n    \"date\"      : \"dt:2023-09-01 11:57:57\",\n    \"extension\" : \"jpg\",\n    \"file_url\"  : \"https://r34xyz.b-cdn.net/posts/280/280517/280517.jpg\",\n    \"filename\"  : \"280517\",\n    \"height\"    : 1152,\n    \"id\"        : 280517,\n    \"likes\"     : range(3, 100),\n    \"posted\"    : \"2023-09-01T12:01:41.008547Z\",\n    \"status\"    : 2,\n    \"type\"      : 0,\n    \"uploaderId\": 20678,\n    \"views\"     : range(90, 999),\n    \"width\"     : 768,\n    \"data\": {\n        \"sources\": [\n            \"https://trynectar.ai/view/87c98fc8-e4f3-447c-a0d3-024b1890580a\",\n        ],\n    },\n    \"tags\": [\n        \"ai generated\",\n        \"demon slayer\",\n        \"kamado nezuko\",\n        \"school uniform\",\n        \"sfw\",\n    ],\n    \"tags_character\": [\n        \"kamado nezuko\",\n    ],\n    \"tags_copyright\": [\n        \"demon slayer\",\n    ],\n    \"tags_general\": [\n        \"ai generated\",\n        \"school uniform\",\n        \"sfw\",\n    ],\n    \"uploader\": {\n        \"created\"      : \"2023-07-24T04:33:36.734495Z\",\n        \"data\"         : None,\n        \"displayName\"  : \"quick1e\",\n        \"emailVerified\": False,\n        \"id\"           : 20678,\n        \"role\"         : 1,\n        \"userName\"     : \"quick1e\",\n    },\n},\n\n{\n    \"#url\"    : \"https://rule34vault.com/post/382937\",\n    \"#comment\": \"video\",\n    \"#class\"  : rule34vault.Rule34vaultPostExtractor,\n    \"#results\"     : \"https://r34xyz.b-cdn.net/posts/382/382937/382937.mp4\",\n    \"#sha1_content\": \"b962e3e2304139767c3792508353e6e83a85a2af\",\n},\n\n)\n"
  },
  {
    "path": "test/results/rule34world.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import rule34xyz\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://rule34.world/post/1280481\",\n    \"#category\": (\"booru\", \"rule34world\", \"post\"),\n    \"#class\"   : rule34xyz.Rule34xyzPostExtractor,\n    \"#options\" : {\"tags\": True},\n    \"#results\" : \"https://rule34.world/posts/1280/1280481/1280481.pic.jpg\",\n\n    \"created\"       : \"2026-03-11T21:03:11.567029Z\",\n    \"data\"          : {\"sources\": [\"https://x.com/i/status/2031516276615499895\"]},\n    \"date\"          : \"dt:2026-03-11 21:03:11\",\n    \"extension\"     : \"jpg\",\n    \"file_url\"      : \"https://rule34.world/posts/1280/1280481/1280481.pic.jpg\",\n    \"filename\"      : \"1280481\",\n    \"format\"        : \"pic\",\n    \"format_id\"     : \"10\",\n    \"height\"        : 1618,\n    \"id\"            : 1280481,\n    \"likes\"         : int,\n    \"posted\"        : \"2026-03-11T21:08:34.291657Z\",\n    \"status\"        : 2,\n    \"tags_artist\"   : [\"cjfurs\"],\n    \"tags_character\": [\"amy rose\"],\n    \"tags_meta\"     : [\"5:4\"],\n    \"type\"          : 0,\n    \"uploaderId\"    : 2,\n    \"width\"         : 2047,\n    \"tags\"          : [\n        \"2d\",\n        \"2d (artwork)\",\n        \"2d artwork\",\n        \"5:4\",\n        \"amy rose\",\n        \"ass\",\n        \"ass focus\",\n        \"ass up\",\n        \"bent over\",\n        \"bent over table\",\n        \"big ass\",\n        \"big butt\",\n        \"bubble ass\",\n        \"bubble butt\",\n        \"cjfurs\",\n        \"diadem\",\n        \"eyelashes\",\n        \"green eyes\",\n        \"half-closed eyes\",\n        \"hedgehog girl\",\n        \"locker room\",\n        \"open mouth\",\n        \"round ass\",\n        \"round butt\",\n        \"sega\",\n        \"sonic (series)\",\n        \"sonic the hedgehog (series)\",\n        \"sports bra\",\n        \"sports shorts\",\n        \"spots\",\n        \"sweat\",\n        \"sweatdrop\",\n        \"sweating\",\n        \"sweaty body\",\n        \"tight clothing\",\n        \"underboob\",\n        \"wiggling ass\",\n        \"wiggling butt\",\n        \"wiggling tail\",\n    ],\n    \"tags_copyright\": [\n        \"sega\",\n        \"sonic (series)\",\n        \"sonic the hedgehog (series)\",\n    ],\n    \"tags_general\"  : [\n        \"2d\",\n        \"2d (artwork)\",\n        \"2d artwork\",\n        \"ass\",\n        \"ass focus\",\n        \"ass up\",\n        \"bent over\",\n        \"bent over table\",\n        \"big ass\",\n        \"big butt\",\n        \"bubble ass\",\n        \"bubble butt\",\n        \"diadem\",\n        \"eyelashes\",\n        \"green eyes\",\n        \"half-closed eyes\",\n        \"hedgehog girl\",\n        \"locker room\",\n        \"open mouth\",\n        \"round ass\",\n        \"round butt\",\n        \"sports bra\",\n        \"sports shorts\",\n        \"spots\",\n        \"sweat\",\n        \"sweatdrop\",\n        \"sweating\",\n        \"sweaty body\",\n        \"tight clothing\",\n        \"underboob\",\n        \"wiggling ass\",\n        \"wiggling butt\",\n        \"wiggling tail\",\n    ],\n},\n\n{\n    \"#url\"     : \"https://rule34.world/zenless_zone_zero\",\n    \"#category\": (\"booru\", \"rule34world\", \"tag\"),\n    \"#class\"   : rule34xyz.Rule34xyzTagExtractor,\n    \"#pattern\" : r\"https://rule34(\\.world|storage.b-cdn.net)/posts/\\d+/\\d+/\\d+\\.(pic\\.jpg|mov.mp4)\",\n    \"#range\"   : \"1-100\",\n    \"#count\"   : 100,\n\n    \"created\"    : \"iso:8601\",\n    \"date\"       : \"type:datetime\",\n    \"extension\"  : {\"jpg\", \"mp4\"},\n    \"file_url\"   : str,\n    \"filename\"   : r\"re:\\d+\",\n    \"format\"     : {\"pic\", \"mov\"},\n    \"format_id\"  : {\"10\", \"100\"},\n    \"width\"      : int,\n    \"height\"     : int,\n    \"id\"         : int,\n    \"likes\"      : int,\n    \"posted\"     : \"iso:8601\",\n    \"search_tags\": \"zenless zone zero\",\n    \"status\"     : 2,\n    \"type\"       : {0, 1},\n    \"uploaderId\" : 2,\n},\n\n{\n    \"#url\"     : \"https://rule34.world/preview_%2528preview0%2529\",\n    \"#category\": (\"booru\", \"rule34world\", \"tag\"),\n    \"#class\"   : rule34xyz.Rule34xyzTagExtractor,\n    \"#range\"   : \"1-100\",\n    \"#count\"   : 16,\n\n    \"search_tags\": \"preview (preview0)\",\n},\n\n{\n    \"#url\"     : \"https://rule34.world/playlists/view/259015\",\n    \"#category\": (\"booru\", \"rule34world\", \"playlist\"),\n    \"#class\"   : rule34xyz.Rule34xyzPlaylistExtractor,\n    \"#count\"   : 21,\n\n    \"playlist_id\": \"259015\",\n},\n\n)\n"
  },
  {
    "path": "test/results/rule34xyz.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import rule34xyz\n\n\n__tests__ = (\n{\n    \"#url\"  : \"https://rule34.xyz/sfw\",\n    \"#class\": rule34xyz.Rule34xyzTagExtractor,\n    \"#pattern\": r\"https://rule34(\\.xyz|xyz\\.b-cdn\\.net)/posts/\\d+/\\d+/\\d+\\.(pic|mov\\d*)\\.(jpg|mp4)\",\n    \"#range\"  : \"1-150\",\n    \"#count\"  : 150,\n\n    \"search_tags\": \"sfw\",\n},\n\n{\n    \"#url\"     : \"https://www.rule34.xyz/sfw\",\n    \"#comment\" : \"URL with 'www' subdomain (#8875)\",\n    \"#class\"   : rule34xyz.Rule34xyzTagExtractor,\n},\n\n{\n    \"#url\"  : \"https://rule34.xyz/playlists/view/119\",\n    \"#class\": rule34xyz.Rule34xyzPlaylistExtractor,\n    \"#pattern\": r\"https://rule34(\\.xyz|xyz\\.b-cdn\\.net)/posts/\\d+/\\d+/\\d+\\.(pic|mov\\d*)\\.(jpg|mp4)\",\n    \"#count\"  : 64,\n\n    \"playlist_id\": \"119\",\n},\n\n{\n    \"#url\"    : \"https://rule34.xyz/post/3613851\",\n    \"#comment\": \"image\",\n    \"#class\"  : rule34xyz.Rule34xyzPostExtractor,\n    \"#options\"     : {\"tags\": True},\n    \"#results\"     : \"https://rule34xyz.b-cdn.net/posts/3613/3613851/3613851.pic.jpg\",\n    \"#sha1_content\": \"4d7146db258fd5b1645a1a5fc01550d102f495e1\",\n\n    \"created\"   : \"2023-03-29T03:00:59.136819Z\",\n    \"date\"      : \"dt:2023-03-29 03:00:59\",\n    \"extension\" : \"jpg\",\n    \"file_url\"  : \"https://rule34xyz.b-cdn.net/posts/3613/3613851/3613851.pic.jpg\",\n    \"filename\"  : \"3613851\",\n    \"format\"    : \"pic\",\n    \"format_id\" : \"10\",\n    \"id\"        : 3613851,\n    \"likes\"     : range(3, 100),\n    \"posted\"    : \"2023-03-29T03:01:07.900161Z\",\n    \"type\"      : 0,\n    \"uploaderId\": 9741,\n    \"views\"     : range(200, 2000),\n    \"status\"    : 2,\n    \"data\"      : {\n        \"sources\": [\n            \"https://twitter.com/DesireDelta13/status/1636502494292373505?t=OrmlnC85cELyY5BPmBy9Hw&s=19\",\n        ],\n    },\n    \"tags\": [\n        \"doki doki literature club\",\n        \"doki doki takeover\",\n        \"friday night funkin\",\n        \"friday night funkin mod\",\n        \"yuri (doki doki literature club)\",\n        \"desiredelta\",\n        \"1girls\",\n        \"big breasts\",\n        \"clothed\",\n        \"clothed female\",\n        \"female\",\n        \"female focus\",\n        \"female only\",\n        \"holding microphone\",\n        \"holding object\",\n        \"long hair\",\n        \"long purple hair\",\n        \"looking at viewer\",\n        \"microphone\",\n        \"open hand\",\n        \"open mouth\",\n        \"purple background\",\n        \"purple hair\",\n        \"solo\",\n        \"solo female\",\n        \"solo focus\",\n        \"sweater\",\n        \"white outline\",\n        \"jpeg\",\n        \"safe for work\",\n        \"sfw\",\n    ],\n    \"tags_artist\": [\n        \"desiredelta\",\n    ],\n    \"tags_character\": [\n        \"yuri (doki doki literature club)\",\n    ],\n    \"tags_copyright\": [\n        \"doki doki literature club\",\n        \"friday night funkin\",\n        \"friday night funkin mod\",\n    ],\n    \"tags_general\": list,\n    \"uploaderId\"  : 9741,\n},\n\n{\n    \"#url\"    : \"https://rule34.xyz/post/3571567\",\n    \"#comment\": \"video\",\n    \"#class\"  : rule34xyz.Rule34xyzPostExtractor,\n    \"#results\"     : \"https://rule34xyz.b-cdn.net/posts/3571/3571567/3571567.mov720.mp4\",\n    \"#sha1_content\": \"c0a5e7e887774f91527f00e6142c435a3c482c1f\",\n\n    \"format\"   : \"mov720\",\n    \"format_id\": \"101\",\n},\n\n{\n    \"#url\"    : \"https://rule34.xyz/post/3571567\",\n    \"#comment\": \"'format' option\",\n    \"#class\"  : rule34xyz.Rule34xyzPostExtractor,\n    \"#options\": {\"format\": \"10,4,5\"},\n    \"#results\": \"https://rule34xyz.b-cdn.net/posts/3571/3571567/3571567.pic.jpg\",\n\n    \"format\"   : \"pic\",\n    \"format_id\": \"10\",\n},\n\n)\n"
  },
  {
    "path": "test/results/s3ndpics.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import s3ndpics\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://s3nd.pics/post/68dce8098ef7a9effbdbafa1\",\n    \"#class\"   : s3ndpics.S3ndpicsPostExtractor,\n    \"#pattern\" : r\"https://s3\\.s3nd\\.pics/s3nd\\-pics/uploads/68be9efde7238de1080bbeec/\\d+\\-\\w+\\.(jpe?g|mp4)\",\n    \"#count\"   : 10,\n\n    \"id\"             : \"68dce8098ef7a9effbdbafa1\",\n    \"title\"          : \"Little meme dump 'o the mornin'\",\n    \"count\"          : 10,\n    \"num\"            : range(1, 10),\n    \"date\"           : \"dt:2025-10-01 08:36:25\",\n    \"date_updated\"   : \"type:datetime\",\n    \"type\"           : {\"image\", \"video\"},\n    \"extension\"      : {\"jpeg\", \"mp4\"},\n    \"imageProcessed\" : True,\n    \"isPublic\"       : True,\n    \"favorites\"      : int,\n    \"downvotes\"      : int,\n    \"likes\"          : int,\n    \"upvotes\"        : int,\n    \"views\"          : int,\n    \"locked\"         : False,\n    \"lockedAt\"       : None,\n    \"lockedBy\"       : None,\n    \"moderatorTags\"  : [],\n    \"photodnaConfidence\": 0,\n    \"photodnaFlagged\": False,\n    \"pinWeight\"      : 0,\n    \"pinned\"         : False,\n    \"pinnedAt\"       : None,\n    \"suspendReason\"  : None,\n    \"suspended\"      : False,\n    \"suspendedAt\"    : None,\n    \"suspendedBy\"    : None,\n    \"description\"    : \"\"\"\\\nDid you know that penguins do a thing called \"pebbling\", where they offer a nice rock or pebble to other penguins they like, to make them feel nice and valued?\n\nConsider then that I am not peddling my stolen memes, but pebbling them to ye wonderful sickos.\n\nHope you'll like it!\\\n\"\"\",\n    \"tags\"           : [\n        \"#dump\",\n        \"#meme\",\n    ],\n    \"user\"           : {\n        \"_id\"          : \"68be9efde7238de1080bbeec\",\n        \"avatar\"       : \"avatars/68be9efde7238de1080bbeec/1758616570175-avatar.jpg\",\n        \"filteredUsers\": [],\n        \"username\"     : \"wildscarf\",\n    },\n},\n\n{\n    \"#url\"     : \"https://s3nd.pics/post/68dce8098ef7a9effbdbafa1?tag=%23dump&context=search\",\n    \"#class\"   : s3ndpics.S3ndpicsPostExtractor,\n},\n\n{\n    \"#url\"     : \"https://s3nd.pics/user/cloacadeepinadragon\",\n    \"#class\"   : s3ndpics.S3ndpicsUserExtractor,\n    \"#pattern\" : r\"https://s3\\.s3nd\\.pics/s3nd\\-pics/uploads/.+\",\n    \"#count\"   : 18,\n\n    \"date\"           : \"type:datetime\",\n    \"date_updated\"   : \"type:datetime\",\n    \"description\"    : str,\n    \"filename\"       : str,\n    \"extension\"      : {\"jpg\", \"jpeg\", \"png\"},\n    \"id\"             : str,\n    \"title\"          : str,\n    \"total\"          : 18,\n    \"type\"           : \"image\",\n    \"tags\"           : list,\n    \"user\"           : {\n        \"_id\"           : \"68ba04803ffea95858f47613\",\n        \"avatar\"        : \"avatars/68ba04803ffea95858f47613/1757021580703-avatar.jpg\",\n        \"createdAt\"     : \"2025-09-04T21:28:32.452Z\",\n        \"email\"         : \"egk251@gmail.com\",\n        \"role\"          : \"user\",\n        \"username\"      : \"cloacadeepinadragon\",\n    },\n},\n\n{\n    \"#url\"     : \"https://s3nd.pics/search?tag=%23memes\",\n    \"#class\"   : s3ndpics.S3ndpicsSearchExtractor,\n    \"#pattern\" : r\"https://s3\\.s3nd\\.pics/s3nd\\-pics/uploads/\\w+/.+\\.(jpe?g|png|mp4)$\",\n    \"#range\"   : \"1-50\",\n    \"#count\"   : 50,\n\n    \"search_tags\": \"#memes\",\n},\n\n)\n"
  },
  {
    "path": "test/results/safebooru.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import gelbooru_v02\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://safebooru.org/index.php?page=post&s=list&tags=bonocho\",\n    \"#category\": (\"gelbooru_v02\", \"safebooru\", \"tag\"),\n    \"#class\"   : gelbooru_v02.GelbooruV02TagExtractor,\n    \"#sha1_url\"    : \"17c61b386530cf4c30842c9f580d15ef1cd09586\",\n    \"#sha1_content\": \"e5ad4c5bf241b1def154958535bef6c2f6b733eb\",\n    \"#results\" : (\n        \"https://safebooru.org/images/344/3894735145db4f94cc6a3839004ebf4f16a9bc10.jpg\",\n        \"https://safebooru.org/images/344/ae7198cbc41bed9b282fe5ec00b5b91509c53b30.jpg\",\n        \"https://safebooru.org/images/264/458a42a01ca7090aca27a85c814557bee9e22a63.jpg\",\n        \"https://safebooru.org/images/263/00a74a8736a7691dc1df3def867c32d5ad0fadeb.jpg\",\n    ),\n\n    \"search_tags\" : \"bonocho\",\n    \"search_count\": 4,\n},\n\n{\n    \"#url\"     : \"https://safebooru.org/index.php?page=post&s=list&tags=all\",\n    \"#category\": (\"gelbooru_v02\", \"safebooru\", \"tag\"),\n    \"#class\"   : gelbooru_v02.GelbooruV02TagExtractor,\n    \"#range\"   : \"1-3\",\n    \"#count\"   : 3,\n\n    \"total\": range(5_600_000, 6_000_000),\n},\n\n{\n    \"#url\"     : \"https://safebooru.org/index.php?page=post&s=list&tags=\",\n    \"#category\": (\"gelbooru_v02\", \"safebooru\", \"tag\"),\n    \"#class\"   : gelbooru_v02.GelbooruV02TagExtractor,\n},\n\n{\n    \"#url\"     : \"https://safebooru.org/index.php?page=pool&s=show&id=11\",\n    \"#category\": (\"gelbooru_v02\", \"safebooru\", \"pool\"),\n    \"#class\"   : gelbooru_v02.GelbooruV02PoolExtractor,\n    \"#count\"   : 5,\n},\n\n{\n    \"#url\"     : \"https://safebooru.org/index.php?page=favorites&s=view&id=17567\",\n    \"#category\": (\"gelbooru_v02\", \"safebooru\", \"favorite\"),\n    \"#class\"   : gelbooru_v02.GelbooruV02FavoriteExtractor,\n    \"#count\"   : 2,\n},\n\n{\n    \"#url\"     : \"https://safebooru.org/index.php?page=post&s=view&id=1169132\",\n    \"#category\": (\"gelbooru_v02\", \"safebooru\", \"post\"),\n    \"#class\"   : gelbooru_v02.GelbooruV02PostExtractor,\n    \"#options\"     : {\"tags\": True},\n    \"#sha1_url\"    : \"cf05e37a3c62b2d55788e2080b8eabedb00f999b\",\n    \"#sha1_content\": \"93b293b27dabd198afafabbaf87c49863ac82f27\",\n\n    \"tags_artist\"   : \"kawanakajima\",\n    \"tags_character\": \"heath_ledger ronald_mcdonald the_joker\",\n    \"tags_copyright\": \"dc_comics mcdonald's the_dark_knight\",\n    \"tags_metadata\" : \"tagme\",\n},\n\n)\n"
  },
  {
    "path": "test/results/sakugabooru.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import moebooru\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://www.sakugabooru.com/post/show/125570\",\n    \"#category\": (\"moebooru\", \"sakugabooru\", \"post\"),\n    \"#class\"   : moebooru.MoebooruPostExtractor,\n    \"#options\" : {\"tags\": True},\n    \"#results\" : \"https://www.sakugabooru.com/data/31db5edb23f7b5db590d182ea84a00b2.mp4\",\n\n    \"actual_preview_height\": 169,\n    \"actual_preview_width\": 300,\n    \"approver_id\": 508,\n    \"author\": \"chii\",\n    \"change\": 595064,\n    \"created_at\": 1592745120,\n    \"creator_id\": 5287,\n    \"date\"      : \"dt:2020-06-21 13:12:00\",\n    \"extension\": \"mp4\",\n    \"file_ext\": \"mp4\",\n    \"file_size\": 3472647,\n    \"file_url\": \"https://www.sakugabooru.com/data/31db5edb23f7b5db590d182ea84a00b2.mp4\",\n    \"filename\": \"31db5edb23f7b5db590d182ea84a00b2\",\n    \"frames\": [],\n    \"frames_pending\": [],\n    \"frames_pending_string\": \"\",\n    \"frames_string\": \"\",\n    \"has_children\": False,\n    \"height\": 480,\n    \"id\": 125570,\n    \"is_held\": False,\n    \"is_note_locked\": False,\n    \"is_pending\": False,\n    \"is_rating_locked\": False,\n    \"is_shown_in_index\": True,\n    \"jpeg_file_size\": 0,\n    \"jpeg_height\": 480,\n    \"jpeg_url\": \"https://www.sakugabooru.com/data/31db5edb23f7b5db590d182ea84a00b2.mp4\",\n    \"jpeg_width\": 854,\n    \"last_commented_at\": 0,\n    \"last_noted_at\": 0,\n    \"md5\": \"31db5edb23f7b5db590d182ea84a00b2\",\n    \"parent_id\": None,\n    \"preview_height\": 84,\n    \"preview_url\": \"https://www.sakugabooru.com/data/preview/31db5edb23f7b5db590d182ea84a00b2.jpg\",\n    \"preview_width\": 150,\n    \"rating\": \"s\",\n    \"sample_file_size\": 0,\n    \"sample_height\": 480,\n    \"sample_url\": \"https://www.sakugabooru.com/data/31db5edb23f7b5db590d182ea84a00b2.mp4\",\n    \"sample_width\": 854,\n    \"score\": range(20, 50),\n    \"source\": \"#14\",\n    \"status\": \"active\",\n    \"tags\": \"animals animated artist_unknown character_acting creatures nichijou smears\",\n    \"tags_artist\": \"artist_unknown\",\n    \"tags_copyright\": \"nichijou\",\n    \"tags_general\": \"animals animated character_acting creatures smears\",\n    \"updated_at\": 1592819293,\n    \"width\": 854,\n},\n\n{\n    \"#url\"     : \"https://www.sakugabooru.com/post?tags=nichijou\",\n    \"#category\": (\"moebooru\", \"sakugabooru\", \"tag\"),\n    \"#class\"   : moebooru.MoebooruTagExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.sakugabooru.com/pool/show/1\",\n    \"#category\": (\"moebooru\", \"sakugabooru\", \"pool\"),\n    \"#class\"   : moebooru.MoebooruPoolExtractor,\n    \"#options\" : {\"metadata\": True},\n    \"#results\" : (\n        \"https://www.sakugabooru.com/data/cd1fe3601ddbb8b13db794a1f51acf36.gif\",\n        \"https://www.sakugabooru.com/data/c6dedf058957f89126bcbdfd209bfc69.gif\",\n        \"https://www.sakugabooru.com/data/3a8d6b7ec40fb66447d160d53759ec71.gif\",\n        \"https://www.sakugabooru.com/data/09f50c0cc6b3d922cd6b34a99103cc51.gif\",\n        \"https://www.sakugabooru.com/data/9d219fd70727eb9fe5a7fb04b7cc7c47.gif\",\n        \"https://www.sakugabooru.com/data/5a2d035974f26221ce3d8914e74695c6.gif\",\n    ),\n\n    \"pool\": {\n        \"created_at\" : \"2013-08-18T15:48:19.938Z\",\n        \"description\": \"\",\n        \"id\"         : 1,\n        \"is_public\"  : True,\n        \"name\"       : \"Yutapon Stranger Genga Comparisons\",\n        \"post_count\" : 6,\n        \"updated_at\" : \"2013-08-18T15:58:19.037Z\",\n        \"user_id\"    : 4,\n    },\n},\n\n{\n    \"#url\"     : \"https://www.sakugabooru.com/post/popular_recent\",\n    \"#category\": (\"moebooru\", \"sakugabooru\", \"popular\"),\n    \"#class\"   : moebooru.MoebooruPopularExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/sankaku.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import sankaku\nfrom gallery_dl import exception\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://sankaku.app/?tags=bonocho\",\n    \"#category\": (\"booru\", \"sankaku\", \"tag\"),\n    \"#class\"   : sankaku.SankakuTagExtractor,\n    \"#pattern\" : r\"https://s\\.sankakucomplex\\.com/o/[^/]{2}/[^/]{2}/[0-9a-f]{32}\\.\\w+\\?e=\\d+&(expires=\\d+&)?m=[^&#]+\",\n    \"#count\"   : 5,\n},\n\n{\n    \"#url\"     : \"https://www.sankakucomplex.com/?tags=bonocho\",\n    \"#category\": (\"booru\", \"sankaku\", \"tag\"),\n    \"#class\"   : sankaku.SankakuTagExtractor,\n},\n\n{\n    \"#url\"     : \"https://beta.sankakucomplex.com/?tags=bonocho\",\n    \"#category\": (\"booru\", \"sankaku\", \"tag\"),\n    \"#class\"   : sankaku.SankakuTagExtractor,\n},\n\n{\n    \"#url\"     : \"https://chan.sankakucomplex.com/?tags=bonocho\",\n    \"#category\": (\"booru\", \"sankaku\", \"tag\"),\n    \"#class\"   : sankaku.SankakuTagExtractor,\n},\n\n{\n    \"#url\"     : \"https://black.sankakucomplex.com/?tags=bonocho\",\n    \"#category\": (\"booru\", \"sankaku\", \"tag\"),\n    \"#class\"   : sankaku.SankakuTagExtractor,\n},\n\n{\n    \"#url\"     : \"https://white.sankakucomplex.com/?tags=bonocho\",\n    \"#category\": (\"booru\", \"sankaku\", \"tag\"),\n    \"#class\"   : sankaku.SankakuTagExtractor,\n},\n\n{\n    \"#url\"     : \"https://sankaku.app/ja?tags=order%3Apopularity\",\n    \"#comment\" : \"ISO 639-1\",\n    \"#category\": (\"booru\", \"sankaku\", \"tag\"),\n    \"#class\"   : sankaku.SankakuTagExtractor,\n},\n\n{\n    \"#url\"     : \"https://sankaku.app/no/?tags=order%3Apopularity\",\n    \"#comment\" : \"ISO 639-1 with trailing '/'\",\n    \"#category\": (\"booru\", \"sankaku\", \"tag\"),\n    \"#class\"   : sankaku.SankakuTagExtractor,\n},\n\n{\n    \"#url\"     : \"https://sankaku.app/zh-CN/?tags=order%3Apopularity\",\n    \"#comment\" : \"locale code (ISO 639-1 + ISO 3166-1) (#8667)\",\n    \"#category\": (\"booru\", \"sankaku\", \"tag\"),\n    \"#class\"   : sankaku.SankakuTagExtractor,\n},\n\n{\n    \"#url\"     : \"https://sankaku.app/zh_CN/?tags=order%3Apopularity\",\n    \"#comment\" : \"locale code (ISO 639-1 + ISO 3166-1) (#8667)\",\n    \"#category\": (\"booru\", \"sankaku\", \"tag\"),\n    \"#class\"   : sankaku.SankakuTagExtractor,\n},\n\n{\n    \"#url\"     : \"https://chan.sankakucomplex.com/posts?tags=TAG\",\n    \"#comment\" : \"'/posts' in tag search URL (#4740)\",\n    \"#category\": (\"booru\", \"sankaku\", \"tag\"),\n    \"#class\"   : sankaku.SankakuTagExtractor,\n},\n\n{\n    \"#url\"     : \"https://chan.sankakucomplex.com/ja/posts/?tags=あえいおう\",\n    \"#comment\" : \"'/posts' in tag search URL (#4740)\",\n    \"#category\": (\"booru\", \"sankaku\", \"tag\"),\n    \"#class\"   : sankaku.SankakuTagExtractor,\n},\n\n{\n    \"#url\"     : \"https://chan.sankakucomplex.com/?tags=bonocho+a+b+c+d\",\n    \"#comment\" : \"error on five or more tags\",\n    \"#category\": (\"booru\", \"sankaku\", \"tag\"),\n    \"#class\"   : sankaku.SankakuTagExtractor,\n    \"#options\"  : {\"username\": None},\n    \"#exception\": exception.AuthorizationError,\n},\n\n{\n    \"#url\"     : \"https://chan.sankakucomplex.com/?tags=marie_rose&page=98&next=3874906&commit=Search\",\n    \"#comment\" : \"match arbitrary query parameters\",\n    \"#category\": (\"booru\", \"sankaku\", \"tag\"),\n    \"#class\"   : sankaku.SankakuTagExtractor,\n},\n\n{\n    \"#url\"     : \"https://chan.sankakucomplex.com/?tags=date:2023-03-20T00:00\",\n    \"#comment\" : \"'date:' tags (#1790)\",\n    \"#category\": (\"booru\", \"sankaku\", \"tag\"),\n    \"#class\"   : sankaku.SankakuTagExtractor,\n    \"#range\"   : \"1\",\n    \"#count\"   : 1,\n},\n\n{\n    \"#url\"     : \"https://chan.sankakucomplex.com/?tags=date:2023-03-20\",\n    \"#comment\" : \"'date:' tags (#1790)\",\n    \"#category\": (\"booru\", \"sankaku\", \"tag\"),\n    \"#class\"   : sankaku.SankakuTagExtractor,\n    \"#range\"   : \"1\",\n    \"#count\"   : 1,\n},\n\n{\n    \"#url\"     : \"https://chan.sankakucomplex.com/en/posts?tags=parent%3APVaDn2DGLRb+rozen_maiden\",\n    \"#comment\" : \"include parent post (#9102)\",\n    \"#category\": (\"booru\", \"sankaku\", \"tag\"),\n    \"#class\"   : sankaku.SankakuTagExtractor,\n    \"#pattern\" : (\n        r\"https://v.sankakucomplex.com/data/1f/23/1f231705b744608313ffa390dd85b6c5.png\",\n        r\"https://s.sankakucomplex.com/o/e5/a9/e5a9b9d8f5d29e54156f272e945da325.png\",\n        r\"https://s.sankakucomplex.com/o/4f/ca/4fca3115a991d1972c188a03238030f3.png\",\n    ),\n\n    \"search_tags\": \"parent:PVaDn2DGLRb rozen_maiden\",\n},\n\n{\n    \"#url\"     : \"https://sankaku.app/books/90\",\n    \"#category\": (\"booru\", \"sankaku\", \"pool\"),\n    \"#class\"   : sankaku.SankakuPoolExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.sankakucomplex.com/books/8YEa7EERmD0\",\n    \"#comment\" : \"alphanumeric book/pool ID (#6757)\",\n    \"#category\": (\"booru\", \"sankaku\", \"pool\"),\n    \"#class\"   : sankaku.SankakuPoolExtractor,\n    \"#count\"   : 5,\n},\n\n{\n    \"#url\"     : \"https://www.sankakucomplex.com/books/90\",\n    \"#category\": (\"booru\", \"sankaku\", \"pool\"),\n    \"#class\"   : sankaku.SankakuPoolExtractor,\n},\n\n{\n    \"#url\"     : \"https://beta.sankakucomplex.com/books/90\",\n    \"#category\": (\"booru\", \"sankaku\", \"pool\"),\n    \"#class\"   : sankaku.SankakuPoolExtractor,\n},\n\n{\n    \"#url\"     : \"https://chan.sankakucomplex.com/pool/show/90\",\n    \"#category\": (\"booru\", \"sankaku\", \"pool\"),\n    \"#class\"   : sankaku.SankakuPoolExtractor,\n},\n\n{\n    \"#url\"     : \"https://chan.sankakucomplex.com/pools/show/90\",\n    \"#category\": (\"booru\", \"sankaku\", \"pool\"),\n    \"#class\"   : sankaku.SankakuPoolExtractor,\n},\n\n{\n    \"#url\"     : \"https://sankaku.app/posts/y0abGlDOr2o\",\n    \"#comment\" : \"extended tag categories; alphanumeric ID (#5073)\",\n    \"#category\": (\"booru\", \"sankaku\", \"post\"),\n    \"#class\"   : sankaku.SankakuPostExtractor,\n    \"#options\"     : {\n        \"tags\"     : True,\n        \"notes\"    : True,\n        \"id-format\": \"alphanumeric\",\n    },\n    \"#sha1_content\": \"5e255713cbf0a8e0801dc423563c34d896bb9229\",\n\n    \"id\": \"y0abGlDOr2o\",\n    \"notes\": (),\n    \"tags_artist\": [\n        \"bonocho\",\n    ],\n    \"tags_character\": [\n        \"batman\",\n        \"letty_whiterock\",\n        \"bruce_wayne\",\n        \"the_joker\",\n        \"heath_ledger\",\n    ],\n    \"tags_copyright\": [\n        \"batman_(series)\",\n        \"the_dark_knight\",\n    ],\n    \"tags_studio\": [\n        \"dc_comics\",\n    ],\n    \"tags_general\": list,\n},\n\n{\n    \"#url\"     : \"https://sankaku.app/posts/y0abGlDOr2o\",\n    \"#comment\" : \"new tag categories (#7333)\",\n    \"#category\": (\"booru\", \"sankaku\", \"post\"),\n    \"#class\"   : sankaku.SankakuPostExtractor,\n    \"#options\" : {\"tags\": \"extended\"},\n\n    \"id\": \"y0abGlDOr2o\",\n    \"tags_anatomy\": [\n        \"brown_eyes\",\n        \"male\",\n        \"upper_body\",\n    ],\n    \"tags_artist\": [\n        \"bonocho\",\n    ],\n    \"tags_character\": [\n        \"batman\",\n        \"letty_whiterock\",\n        \"bruce_wayne\",\n        \"the_joker\",\n        \"heath_ledger\",\n    ],\n    \"tags_copyright\": [\n        \"batman_(series)\",\n        \"the_dark_knight\",\n    ],\n    \"tags_fashion\": [\n        \"black_bodysuit\",\n        \"bodysuit\",\n        \"clothing\",\n        \"collared_shirt\",\n        \"facepaint\",\n        \"pink_shirt\",\n        \"shirt\",\n        \"wing_collar\",\n    ],\n    \"tags_studio\": [\n        \"dc_comics\",\n    ],\n},\n\n{\n    \"#url\"     : \"https://sankaku.app/posts/9PMwlDWjXaB\",\n    \"#comment\" : \">100 tags\",\n    \"#category\": (\"booru\", \"sankaku\", \"post\"),\n    \"#class\"   : sankaku.SankakuPostExtractor,\n    \"#options\" : {\"tags\": True},\n\n    \"id\"  : \"9PMwlDWjXaB\",\n    \"md5\" : \"dc9a3cbfcfee836779bc4f8d5d95c346\",\n    \"tags\": \"len:106\",\n\n    \"tags_copyright\": [\n        \"mahou_shoujo_madoka_magica\",\n        \"mahou_shoujo_madoka_magica:_hangyaku_no_monogatari\",\n    ],\n    \"tags_studio\": [\n        \"pixiv\",\n    ],\n    \"tags_character\": [\n        \"akemi_homura\",\n        \"kaname_madoka\",\n        \"akuma_homura\",\n        \"kaname_madoka_(magical_girl)\",\n    ],\n    \"tags_artist\": [\n        \"mie_haha\",\n    ],\n    \"tags_genre\": [\n        \"size_difference\",\n        \"giant\",\n        \"giantess\",\n    ],\n    \"tags_general\": [\n        \"clothing\",\n        \"tied_hair\",\n        \"headwear\",\n        \"hair_ornament\",\n        \"bangs\",\n        \"skirt\",\n        \"footwear\",\n        \"gloves\",\n        \"dress\",\n        \"twintails\",\n        \"ribbon\",\n        \"bow\",\n        \"shoes\",\n        \"hair_ribbon\",\n        \"boots\",\n        \"hairband\",\n        \"hair_bow\",\n        \"short_sleeves\",\n        \"choker\",\n        \"frills\",\n        \"makeup\",\n        \"high_heels\",\n        \"white_gloves\",\n        \"puffy_sleeves\",\n        \"miniskirt\",\n        \"black_footwear\",\n        \"black_dress\",\n        \"red_bow\",\n        \"red_ribbon\",\n        \"puffy_short_sleeves\",\n        \"eyeshadow\",\n        \"short_twintails\",\n        \"frilled_dress\",\n        \"white_skirt\",\n        \"pink_bow\",\n        \"pink_dress\",\n        \"frilled_skirt\",\n        \"high_heel_boots\",\n        \"frilled_sleeves\",\n        \"white_sleeves\",\n        \"red_hairband\",\n        \"center_frills\",\n        \"red_choker\",\n        \"pink_choker\",\n        \"pink_eyeshadow\",\n        \"crystal_wings\",\n        \"female\",\n        \"long_hair\",\n        \"short_hair\",\n        \"black_hair\",\n        \"pink_hair\",\n        \"wings\",\n        \"pink_eyes\",\n        \"eyelashes\",\n        \"feathers\",\n        \"feathered_wings\",\n        \"black_wings\",\n        \"standing\",\n        \"closed_mouth\",\n        \"frown\",\n        \"outstretched_arms\",\n        \"holding\",\n        \"looking_at_another\",\n        \"looking_down\",\n        \"holding_doll\",\n        \"magical_girl\",\n        \"sparkle\",\n        \"buttons\",\n        \"glass\",\n        \"doll\",\n        \"character_doll\",\n        \"broken_glass\",\n        \"pink_gemstone\",\n        \"bodily_fluids\",\n        \"tears\",\n        \"1girl\",\n        \"solo\",\n        \"multiple_girls\",\n        \"2girls\",\n        \"stairs\",\n        \"bow_choker\",\n        \"button_eyes\",\n        \"chest_jewel\",\n        \"mahou_shoujo_madoka_magica_(anime)\",\n        \"pendant_choker\",\n        \"shards\",\n        \"soul_gem\",\n        \"square_neckline\",\n        \"surreal\",\n        \"high_resolution\",\n        \"very_high_resolution\",\n        \"large_filesize\",\n    ],\n    \"tags_medium\": [\n        \"gradient\",\n        \"gradient_background\",\n        \"black_background\",\n    ],\n},\n\n{\n    \"#url\"     : \"https://www.sankakucomplex.com/posts/26MPkn6JRKx\",\n    \"#comment\" : \"each tag is tripled, wrong 'total' count in /tags API\",\n    \"#category\": (\"booru\", \"sankaku\", \"post\"),\n    \"#class\"   : sankaku.SankakuPostExtractor,\n    \"#options\" : {\"tags\": True},\n\n    \"id\": \"26MPkn6JRKx\",\n    \"md5\": \"a115c0cfbad8e915f046a72973bbd47e\",\n    \"total_tags\": 39,\n    \"tags\": [\n        \"tengen_toppa_gurren-lagann\",\n        \"tengen_toppa_gurren-lagann\",\n        \"tengen_toppa_gurren-lagann\",\n        \"yoko_littner\",\n        \"yoko_littner\",\n        \"yoko_littner\",\n        \"high_resolution\",\n        \"high_resolution\",\n        \"high_resolution\",\n        \"bangs\",\n        \"bangs\",\n        \"bangs\",\n        \"female\",\n        \"female\",\n        \"female\",\n        \"golden_eyes\",\n        \"golden_eyes\",\n        \"golden_eyes\",\n        \"long_hair\",\n        \"long_hair\",\n        \"long_hair\",\n        \"ponytail\",\n        \"ponytail\",\n        \"ponytail\",\n        \"red_hair\",\n        \"red_hair\",\n        \"red_hair\",\n        \"solo\",\n        \"solo\",\n        \"solo\",\n        \"tied_hair\",\n        \"tied_hair\",\n        \"tied_hair\",\n        \"yellow_eyes\",\n        \"yellow_eyes\",\n        \"yellow_eyes\",\n        \"potential_duplicate\",\n        \"potential_duplicate\",\n        \"potential_duplicate\",\n    ],\n    \"tags_copyright\": [\n        \"tengen_toppa_gurren-lagann\",\n    ],\n    \"tags_character\": [\n        \"yoko_littner\",\n    ],\n    \"tags_general\": [\n        \"tied_hair\",\n        \"bangs\",\n        \"ponytail\",\n        \"female\",\n        \"long_hair\",\n        \"yellow_eyes\",\n        \"red_hair\",\n        \"golden_eyes\",\n        \"solo\",\n        \"high_resolution\",\n    ],\n    \"tags_meta\": [\n        \"potential_duplicate\",\n    ],\n},\n\n{\n    \"#url\"     : \"https://sankaku.app/posts/VAr2mjLJ2av\",\n    \"#comment\" : \"notes (#5073)\",\n    \"#category\": (\"booru\", \"sankaku\", \"post\"),\n    \"#class\"   : sankaku.SankakuPostExtractor,\n    \"#options\" : {\"notes\": True},\n\n    \"notes\": [\n        {\n            \"body\"      : \"A lonely person, is a lonely person, because he or she is lonely.\",\n            \"created_at\": 1643733759,\n            #  \"creator_id\": 1370766,\n            \"creator_id\": \"WKaoQv7VRJ0\",\n            \"height\"    : 871,\n            #  \"id\"        : 1832643,\n            \"id\"        : \"e8M5EmNZMzv\",\n            \"is_active\" : True,\n            #  \"post_id\"   : 23688624,\n            \"post_id\"   : \"VAr2mjLJ2av\",\n            \"updated_at\": 1643733759,\n            \"width\"     : 108,\n            \"x\"         : 703,\n            \"y\"         : 83,\n        },\n    ],\n},\n\n{\n    #  \"#url\"     : \"https://sankaku.app/post/show/360451\",\n    \"#url\"     : \"https://sankaku.app/post/show/y0abGlDOr2o\",\n    \"#comment\" : \"legacy post URL\",\n    \"#category\": (\"booru\", \"sankaku\", \"post\"),\n    \"#class\"   : sankaku.SankakuPostExtractor,\n    \"#pattern\" : r\"https://s\\.sankakucomplex\\.com/o/ac/8e/ac8e3b92ea328ce9cf7211e69c905bf9\\.jpg\\?e=.+\",\n\n    #  \"id\": 360451,\n    \"id\": \"y0abGlDOr2o\",\n},\n\n{\n    \"#url\"     : \"https://www.sankakucomplex.com/posts/8JaGbKW4eML\",\n    \"#comment\" : \"'contentious_content'\",\n    \"#category\": (\"booru\", \"sankaku\", \"post\"),\n    \"#class\"   : sankaku.SankakuPostExtractor,\n    \"#auth\"    : True,\n    \"#pattern\" : r\"https://s\\.sankakucomplex\\.com/o/13/3c/133cda3bfde249c504284493903fb985\\.jpg\",\n\n    \"md5\": \"133cda3bfde249c504284493903fb985\",\n},\n\n{\n    \"#url\"     : \"https://sankaku.app/post/show/20758561\",\n    \"#comment\" : \"empty tags (#1617)\",\n    \"#skip\"    : \"legacy, now unsupported, numerical post ID\",\n    \"#category\": (\"booru\", \"sankaku\", \"post\"),\n    \"#class\"   : sankaku.SankakuPostExtractor,\n    \"#options\" : {\"tags\": True},\n    \"#count\"   : 1,\n\n    \"id\"          : 20758561,\n    \"tags\"        : list,\n    \"tags_general\": [\n        \"key(mangaka)\",\n        \"key(mangaka)\",\n        \"english_language\",\n        \"english_language\",\n        \"high_resolution\",\n        \"tagme\",\n        \"very_high_resolution\",\n        \"large_filesize\",\n    ],\n},\n\n{\n    \"#url\"     : \"https://chan.sankakucomplex.com/post/show/f8ba89043078f0e4be2d9c46550b840a\",\n    \"#comment\" : \"md5 hexdigest instead of ID (#3952)\",\n    \"#category\": (\"booru\", \"sankaku\", \"post\"),\n    \"#class\"   : sankaku.SankakuPostExtractor,\n    \"#pattern\" : r\"https://s\\.sankakucomplex\\.com/o/f8/ba/f8ba89043078f0e4be2d9c46550b840a\\.jpg\",\n    \"#count\"   : 1,\n\n    #  \"id\" : 33195194,\n    \"id\" : \"k3R93nWBqaG\",\n    \"md5\": \"f8ba89043078f0e4be2d9c46550b840a\",\n},\n\n{\n    \"#url\"     : \"https://chan.sankakucomplex.com/posts/f8ba89043078f0e4be2d9c46550b840a\",\n    \"#comment\" : \"/posts/ instead of /post/show/ (#4688)\",\n    \"#category\": (\"booru\", \"sankaku\", \"post\"),\n    \"#class\"   : sankaku.SankakuPostExtractor,\n    \"#pattern\" : r\"https://s\\.sankakucomplex\\.com/o/f8/ba/f8ba89043078f0e4be2d9c46550b840a\\.jpg\",\n    \"#count\"   : 1,\n\n    #  \"id\" : 33195194,\n    \"id\" : \"k3R93nWBqaG\",\n    \"md5\": \"f8ba89043078f0e4be2d9c46550b840a\",\n},\n\n{\n    \"#url\"     : \"https://chan.sankakucomplex.com/en/posts/show/ac8e3b92ea328ce9cf7211e69c905bf9\",\n    \"#comment\" : \"/en/posts/show/HEX\",\n    \"#category\": (\"booru\", \"sankaku\", \"post\"),\n    \"#class\"   : sankaku.SankakuPostExtractor,\n\n    #  \"id\" : 360451,\n    \"id\" : \"y0abGlDOr2o\",\n    \"md5\": \"ac8e3b92ea328ce9cf7211e69c905bf9\",\n},\n\n{\n    \"#url\"     : \"https://chan.sankakucomplex.com/post/show/360451\",\n    \"#category\": (\"booru\", \"sankaku\", \"post\"),\n    \"#class\"   : sankaku.SankakuPostExtractor,\n},\n\n{\n    \"#url\"     : \"https://chan.sankakucomplex.com/ja/post/show/360451\",\n    \"#category\": (\"booru\", \"sankaku\", \"post\"),\n    \"#class\"   : sankaku.SankakuPostExtractor,\n},\n\n{\n    \"#url\"     : \"https://beta.sankakucomplex.com/post/show/360451\",\n    \"#category\": (\"booru\", \"sankaku\", \"post\"),\n    \"#class\"   : sankaku.SankakuPostExtractor,\n},\n\n{\n    \"#url\"     : \"https://white.sankakucomplex.com/post/show/360451\",\n    \"#category\": (\"booru\", \"sankaku\", \"post\"),\n    \"#class\"   : sankaku.SankakuPostExtractor,\n},\n\n{\n    \"#url\"     : \"https://black.sankakucomplex.com/post/show/360451\",\n    \"#category\": (\"booru\", \"sankaku\", \"post\"),\n    \"#class\"   : sankaku.SankakuPostExtractor,\n},\n\n{\n    \"#url\"     : \"https://sankaku.app/books?tags=aiue_oka\",\n    \"#category\": (\"booru\", \"sankaku\", \"books\"),\n    \"#class\"   : sankaku.SankakuBooksExtractor,\n    \"#auth\"    : True,\n    \"#range\"   : \"1-20\",\n    \"#count\"   : 20,\n},\n\n{\n    \"#url\"     : \"https://beta.sankakucomplex.com/books?tags=aiue_oka\",\n    \"#category\": (\"booru\", \"sankaku\", \"books\"),\n    \"#class\"   : sankaku.SankakuBooksExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/sankakucomplex.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import sankakucomplex\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://news.sankakucomplex.com/2019/05/11/twitter-cosplayers\",\n    \"#category\": (\"\", \"sankakucomplex\", \"article\"),\n    \"#class\"   : sankakucomplex.SankakucomplexArticleExtractor,\n    \"#pattern\"      : r\"https://news\\.sankakucomplex\\.com/wp-content/uploads/2019/05/maid-day-cosplay-\\d+\\.jpg\",\n    \"#sha1_metadata\": \"21bf106150913a1398860031f06d6e1e6423e518\",\n},\n\n{\n    \"#url\"     : \"https://www.sankakucomplex.com/2009/12/01/sexy-goddesses-of-2ch\",\n    \"#category\": (\"\", \"sankakucomplex\", \"article\"),\n    \"#class\"   : sankakucomplex.SankakucomplexArticleExtractor,\n    \"#pattern\"      : r\"https://news\\.sankakucomplex\\.com/wp-content/uploads/2009/12/Goddesses-of-2ch-amateur-internet-idol-\\d+\\.jpe?g\",\n    \"#sha1_metadata\": \"651e4ee79ecab1771b43df467b5ab32249d69b2a\",\n},\n\n{\n    \"#url\"     : \"https://www.sankakucomplex.com/2019/06/11/darling-ol-goddess-shows-off-her-plump-lower-area/\",\n    \"#comment\" : \"videos (#308)\",\n    \"#category\": (\"\", \"sankakucomplex\", \"article\"),\n    \"#class\"   : sankakucomplex.SankakucomplexArticleExtractor,\n    \"#pattern\" : r\"/wp-content/uploads/2019/06/[^/]+\\d\\.mp4\",\n    \"#range\"   : \"26-\",\n    \"#count\"   : 5,\n},\n\n{\n    \"#url\"     : \"https://www.sankakucomplex.com/2015/02/12/snow-miku-2015-live-magical-indeed/\",\n    \"#comment\" : \"youtube embeds (#308)\",\n    \"#category\": (\"\", \"sankakucomplex\", \"article\"),\n    \"#class\"   : sankakucomplex.SankakucomplexArticleExtractor,\n    \"#options\" : {\"embeds\": True},\n    \"#pattern\" : \"https://www.youtube.com/embed/\",\n    \"#range\"   : \"2-\",\n    \"#count\"   : 2,\n},\n\n{\n    \"#url\"     : \"https://www.sankakucomplex.com/tag/cosplay/\",\n    \"#category\": (\"\", \"sankakucomplex\", \"tag\"),\n    \"#class\"   : sankakucomplex.SankakucomplexTagExtractor,\n    \"#pattern\" : sankakucomplex.SankakucomplexArticleExtractor.pattern,\n    \"#range\"   : \"1-50\",\n    \"#count\"   : 50,\n},\n\n{\n    \"#url\"     : \"https://www.sankakucomplex.com/category/anime/\",\n    \"#category\": (\"\", \"sankakucomplex\", \"tag\"),\n    \"#class\"   : sankakucomplex.SankakucomplexTagExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.sankakucomplex.com/author/rift/page/5/\",\n    \"#category\": (\"\", \"sankakucomplex\", \"tag\"),\n    \"#class\"   : sankakucomplex.SankakucomplexTagExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/schalenetwork.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import schalenetwork\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://niyaniya.moe/g/14216/6c67076fdd45\",\n    \"#class\"   : schalenetwork.SchalenetworkGalleryExtractor,\n    \"#options\" : {\"tags\": True},\n    \"#pattern\" : r\"https://kisakisexo.xyz/download/59896/a4fbd1828229/f47639c6abaf1903dd69c36a3d961da84741a1831aa07a2906ce9c74156a5d75\\?v=1721626410802&w=0\",\n    \"#count\"   : 1,\n\n    \"count\"     : 22,\n    \"created_at\": 1721626410802,\n    \"date\"      : \"dt:2024-07-22 05:33:30\",\n    \"extension\" : \"cbz\",\n    \"filename\"  : \"f47639c6abaf1903dd69c36a3d961da84741a1831aa07a2906ce9c74156a5d75\",\n    \"id\"        : 14216,\n    \"num\"       : 1,\n    \"public_key\": \"6c67076fdd45\",\n    \"tags\": [\n        \"general:beach\",\n        \"general:booty\",\n        \"general:dark skin\",\n        \"general:fingering\",\n        \"general:handjob\",\n        \"general:light hair\",\n        \"general:nakadashi\",\n        \"general:outdoors\",\n        \"general:ponytail\",\n        \"general:swimsuit\",\n        \"general:x-ray\",\n        \"artist:ouchi kaeru\",\n        \"magazine:comic kairakuten 2024-08\",\n        \"female:busty\",\n        \"language:english\",\n        \"language:translated\",\n        \"other:uncensored\",\n        \"other:vanilla\",\n    ],\n    \"tags_artist\": [\n        \"ouchi kaeru\",\n    ],\n    \"tags_female\": [\n        \"busty\",\n    ],\n    \"tags_general\": [\n        \"beach\",\n        \"booty\",\n        \"dark skin\",\n        \"fingering\",\n        \"handjob\",\n        \"light hair\",\n        \"nakadashi\",\n        \"outdoors\",\n        \"ponytail\",\n        \"swimsuit\",\n        \"x-ray\",\n    ],\n    \"tags_language\": [\n        \"english\",\n        \"translated\",\n    ],\n    \"tags_magazine\": [\n        \"comic kairakuten 2024-08\",\n    ],\n    \"tags_other\": [\n        \"uncensored\",\n        \"vanilla\",\n    ],\n    \"title\"     : \"[Ouchi Kaeru] Summer Business (Comic Kairakuten 2024-08)\",\n    \"updated_at\": 1721626410802,\n},\n\n{\n    \"#url\"     : \"https://niyaniya.moe/g/14216/6c67076fdd45\",\n    \"#class\"   : schalenetwork.SchalenetworkGalleryExtractor,\n    \"#options\" : {\"cbz\": False, \"format\": \"780\"},\n    \"#pattern\" : r\"https://koharusexo.xyz/data/59905/2df9110af7f1/a7cbeca3fb9c83aa87582a8a74cc8f8ce1b9e9b434dc1af293628871642f42df/[0-9a-f]+/.+\",\n    \"#count\"   : 22,\n},\n\n{\n    \"#url\"     : \"https://niyaniya.moe/g/14216/6c67076fdd45\",\n    \"#class\"   : schalenetwork.SchalenetworkGalleryExtractor,\n    \"#options\" : {\"cbz\": False, \"format\": \"780\"},\n    \"#range\"   : \"1\",\n    \"#sha1_content\": \"08954e0ae18a900ee7ca144d1661c664468c2525\",\n},\n\n{\n    \"#url\"  : \"https://koharu.to/g/14216/6c67076fdd45\",\n    \"#class\": schalenetwork.SchalenetworkGalleryExtractor,\n},\n{\n    \"#url\"  : \"https://anchira.to/g/14216/6c67076fdd45\",\n    \"#class\": schalenetwork.SchalenetworkGalleryExtractor,\n},\n{\n    \"#url\"  : \"https://seia.to/g/14216/6c67076fdd45\",\n    \"#class\": schalenetwork.SchalenetworkGalleryExtractor,\n},\n{\n    \"#url\"  : \"https://shupogaki.moe/g/14216/6c67076fdd45\",\n    \"#class\": schalenetwork.SchalenetworkGalleryExtractor,\n},\n{\n    \"#url\"  : \"https://hoshino.one/g/14216/6c67076fdd45\",\n    \"#class\": schalenetwork.SchalenetworkGalleryExtractor,\n},\n\n{\n    \"#url\"     : \"https://niyaniya.moe/reader/14216/6c67076fdd45\",\n    \"#class\"   : schalenetwork.SchalenetworkGalleryExtractor,\n},\n\n{\n    \"#url\"     : \"https://niyaniya.moe/?s=tag:^beach$\",\n    \"#class\"   : schalenetwork.SchalenetworkSearchExtractor,\n    \"#pattern\" : schalenetwork.SchalenetworkGalleryExtractor.pattern,\n    \"#count\"   : \">= 50\",\n},\n\n{\n    \"#url\"     : \"https://niyaniya.moe/browse?s=beach\",\n    \"#class\"   : schalenetwork.SchalenetworkSearchExtractor,\n},\n\n{\n    \"#url\"     : \"https://niyaniya.moe/tag/tag:beach\",\n    \"#class\"   : schalenetwork.SchalenetworkSearchExtractor,\n},\n\n{\n    \"#url\"     : \"https://niyaniya.moe/tag/circle:tentou+mushi+no+sanba\",\n    \"#class\"   : schalenetwork.SchalenetworkSearchExtractor,\n    \"#results\" : (\n        \"https://niyaniya.moe/g/26044/9b7ecf9bcf00\",\n        \"https://niyaniya.moe/g/24342/c723a7fe9191\",\n        \"https://niyaniya.moe/g/23787/7a51f4258481\",\n        \"https://niyaniya.moe/g/23784/d81779e07505\",\n        \"https://niyaniya.moe/g/23764/cb867963cfcb\",\n        \"https://niyaniya.moe/g/23760/a667d4a7f447\",\n        \"https://niyaniya.moe/g/23669/9ec3ff4c6737\",\n    ),\n},\n\n{\n    \"#url\"     : \"https://niyaniya.moe/favorites\",\n    \"#class\"   : schalenetwork.SchalenetworkFavoriteExtractor,\n    \"#pattern\" : schalenetwork.SchalenetworkGalleryExtractor.pattern,\n    \"#auth\"    : True,\n    \"#results\" : (\n        \"https://niyaniya.moe/g/14216/6c67076fdd45\",\n    ),\n},\n\n{\n    \"#url\"     : \"https://niyaniya.moe/favorites?cat=6&sort=4\",\n    \"#class\"   : schalenetwork.SchalenetworkFavoriteExtractor,\n    \"#pattern\" : schalenetwork.SchalenetworkGalleryExtractor.pattern,\n    \"#auth\"    : True,\n    \"#results\" : (\n        \"https://niyaniya.moe/g/14216/6c67076fdd45\",\n    ),\n},\n\n)\n"
  },
  {
    "path": "test/results/schan.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\ngallery_dl = __import__(\"gallery_dl.extractor.2chen\")\n_2chen = getattr(gallery_dl.extractor, \"2chen\")\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://schan.help/tv/757\",\n    \"#category\": (\"2chen\", \"schan\", \"thread\"),\n    \"#class\"   : _2chen._2chenThreadExtractor,\n    \"#pattern\" : r\"https://schan\\.help/assets/images/src/\\w{40}\\.\\w+$\",\n    \"#count\"   : \">= 179\",\n\n    \"board\" : \"tv\",\n    \"date\"  : \"type:datetime\",\n    \"hash\"  : \"\",\n    \"name\"  : \"Anonymous\",\n    \"no\"    : r\"re:\\d+\",\n    \"thread\": \"757\",\n    \"time\"  : int,\n    \"title\" : \"「/ttg/ #1: The Future of Schan」\",\n    \"url\"   : str,\n},\n\n{\n    \"#url\"     : \"https://schan.help/tv/\",\n    \"#category\": (\"2chen\", \"schan\", \"board\"),\n    \"#class\"   : _2chen._2chenBoardExtractor,\n    \"#pattern\" : _2chen._2chenThreadExtractor.pattern,\n},\n\n)\n"
  },
  {
    "path": "test/results/scrolller.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import scrolller\n\n\n__tests__ = (\n{\n    \"#url\"    : \"https://scrolller.com/r/AmateurPhotography\",\n    \"#class\"  : scrolller.ScrolllerSubredditExtractor,\n    \"#pattern\": r\"https://images\\.scrolller\\.com/\\w+/[\\w-]+\\.\\w+\",\n    \"#range\"  : \"1-100\",\n    \"#count\"  : 100,\n\n    \"displayName\"     : None,\n    \"fullLengthSource\": None,\n    \"gfycatSource\"    : None,\n    \"hasAudio\"        : bool,\n    \"height\"          : int,\n    \"id\"              : int,\n    \"isFavorite\"      : False,\n    \"isNsfw\"          : False,\n    \"isOptimized\"     : bool,\n    \"isPaid\"          : bool,\n    \"mediaSources\"    : list,\n    \"ownerAvatar\"     : None,\n    \"redditPath\"      : r\"re:/r/AmateurPhotography/comments/...\",\n    \"redgifsSource\"   : None,\n    \"subredditId\"     : {0, 413},\n    \"subredditTitle\"  : \"AmateurPhotography\",\n    \"subredditUrl\"    : \"/r/AmateurPhotography\",\n    \"tags\"            : None,\n    \"title\"           : str,\n    \"url\"             : str,\n    \"username\"        : str,\n    \"width\"           : int,\n},\n\n{\n    \"#url\"    : \"https://scrolller.com/r/gonewanton\",\n    \"#comment\": \"NSFW subreddit\",\n    \"#class\"  : scrolller.ScrolllerSubredditExtractor,\n    \"#pattern\": r\"https://\\w+\\.scrolller\\.com/(\\w+/)?[\\w-]+\\.\\w+\",\n    \"#range\"  : \"1-100\",\n    \"#count\"  : 100,\n\n    \"isNsfw\"          : True,\n    \"redditPath\"      : r\"re:/r/gonewanton/comments/...\",\n    \"subredditId\"     : {0, 4403},\n    \"subredditTitle\"  : \"gonewanton\",\n    \"subredditUrl\"    : \"/r/gonewanton\",\n},\n\n{\n    \"#url\"  : \"https://scrolller.com/cabin-in-northern-finland-7nagf1929p\",\n    \"#class\": scrolller.ScrolllerPostExtractor,\n    \"#pattern\": \"https://images.scrolller.com/yocto/cabin-in-northern-finland-93vjsuxmcz.jpg\",\n\n    \"count\"           : 1,\n    \"displayName\"     : None,\n    \"extension\"       : \"jpg\",\n    \"filename\"        : \"cabin-in-northern-finland-93vjsuxmcz\",\n    \"fullLengthSource\": None,\n    \"gfycatSource\"    : None,\n    \"hasAudio\"        : False,\n    \"height\"          : 1350,\n    \"id\"              : 10478722,\n    \"isNsfw\"          : False,\n    \"isOptimized\"     : False,\n    \"isPaid\"          : False,\n    \"mediaSources\"    : list,\n    \"num\"             : 0,\n    \"ownerAvatar\"     : None,\n    \"redditPath\"      : \"/r/AmateurPhotography/comments/jj048q/cabin_in_northern_finland/\",\n    \"redgifsSource\"   : None,\n    \"subredditId\"     : 413,\n    \"subredditTitle\"  : \"AmateurPhotography\",\n    \"subredditUrl\"    : \"/r/AmateurPhotography\",\n    \"tags\"            : None,\n    \"title\"           : \"Cabin in northern Finland\",\n    \"url\"             : \"https://images.scrolller.com/yocto/cabin-in-northern-finland-93vjsuxmcz.jpg\",\n    \"username\"        : \"\",\n    \"width\"           : 1080,\n},\n\n{\n    \"#url\"    : \"https://scrolller.com/long-comic-the-twelve-tasks-of-eve-12ch1ve8ko\",\n    \"#comment\": \"album post (#7339)\",\n    \"#class\"  : scrolller.ScrolllerPostExtractor,\n    \"#pattern\": r\"https://images\\.scrolller\\.com/\\w+/long-comic-the-twelve-tasks-of-eve-\\d+-\\w+\\.png\",\n    \"#count\"  : 177,\n\n    \"count\": 177,\n    \"num\"  : range(1, 177),\n},\n\n{\n    \"#url\"    : \"https://scrolller.com/some-quick-news-tboi-rule-34-mod-czedll1bum\",\n    \"#comment\": \"album post with empty 'mediaSources' (#7428)\",\n    \"#class\"  : scrolller.ScrolllerPostExtractor,\n    \"#results\": \"https://images.scrolller.com/gamma/some-quick-news-tboi-rule-34-mod-1-50uolks94u.png\",\n    \"#count\"  : 1,\n\n    \"count\": 1,\n    \"num\"  : 1,\n},\n\n{\n    \"#url\"    : \"https://scrolller.com/following\",\n    \"#class\"  : scrolller.ScrolllerFollowingExtractor,\n    \"#pattern\": scrolller.ScrolllerSubredditExtractor.pattern,\n    \"#auth\"   : True,\n},\n\n{\n    \"#url\"     : \"https://scrolller.com/reddit-user/Jonttufromesbo\",\n    \"#class\"   : scrolller.ScrolllerUserExtractor,\n    \"#pattern\" : (\n        r\"https://images\\.scrolller\\.com/\\w+/cabin-in-northern-finland-93vjsuxmcz.jpg\",\n        r\"https://images\\.scrolller\\.com/\\w+/northern-lights-in-northern-finland-6ibp3516z1.jpg\",\n    ),\n\n    \"blurredMediaSources\": [],\n    \"commentsCount\"   : 0,\n    \"commentsRepliesCount\": 0,\n    \"count\"           : 1,\n    \"createdAt\"       : \"iso:8601\",\n    \"displayName\"     : None,\n    \"duration\"        : None,\n    \"extension\"       : \"jpg\",\n    \"favoriteCount\"   : 0,\n    \"filename\"        : str,\n    \"fullLengthSource\": None,\n    \"gfycatSource\"    : None,\n    \"hasAudio\"        : False,\n    \"width\"           : {1080, 2048},\n    \"height\"          : {1350, 1638},\n    \"id\"              : {10478722, 32426595},\n    \"isFavorite\"      : False,\n    \"isNsfw\"          : False,\n    \"isOptimized\"     : False,\n    \"isPaid\"          : False,\n    \"num\"             : 0,\n    \"ownerAvatar\"     : None,\n    \"posted_by\"       : \"Jonttufromesbo\",\n    \"redditPath\"      : r\"re:/r/AmateurPhotography/comments/\\w+/\\w+/\",\n    \"reddit_posted_by\": \"Jonttufromesbo\",\n    \"redgifsSource\"   : None,\n    \"subredditId\"     : 413,\n    \"subredditIsFollowing\": False,\n    \"subredditTitle\"  : \"AmateurPhotography\",\n    \"subredditUrl\"    : \"/r/AmateurPhotography\",\n    \"tags\"            : None,\n    \"title\"           : str,\n    \"url\"             : \"re:https://images.scrolller.com/.+\",\n    \"username\"        : \"\",\n    \"mediaSources\"    : list\n},\n\n)\n"
  },
  {
    "path": "test/results/seiga.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import seiga\nfrom gallery_dl import exception\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://seiga.nicovideo.jp/user/illust/39537793\",\n    \"#category\": (\"\", \"seiga\", \"user\"),\n    \"#class\"   : seiga.SeigaUserExtractor,\n    \"#pattern\" : r\"https://lohas\\.nicoseiga\\.jp/priv/[0-9a-f]+/\\d+/\\d+\",\n    \"#count\"   : \">= 4\",\n\n    \"user\"     : {\n        \"id\"     : 39537793,\n        \"message\": str,\n        \"name\"   : str,\n    },\n    \"clips\"    : int,\n    \"comments\" : int,\n    \"count\"    : int,\n    \"extension\": None,\n    \"image_id\" : int,\n    \"title\"    : str,\n    \"views\"    : int,\n},\n\n{\n    \"#url\"     : \"https://seiga.nicovideo.jp/user/illust/79433\",\n    \"#category\": (\"\", \"seiga\", \"user\"),\n    \"#class\"   : seiga.SeigaUserExtractor,\n    \"#exception\": exception.NotFoundError,\n},\n\n{\n    \"#url\"     : \"https://seiga.nicovideo.jp/user/illust/39537793?sort=image_view&target=illust_all\",\n    \"#category\": (\"\", \"seiga\", \"user\"),\n    \"#class\"   : seiga.SeigaUserExtractor,\n},\n\n{\n    \"#url\"     : \"https://sp.seiga.nicovideo.jp/user/illust/39537793\",\n    \"#category\": (\"\", \"seiga\", \"user\"),\n    \"#class\"   : seiga.SeigaUserExtractor,\n},\n\n{\n    \"#url\"     : \"https://seiga.nicovideo.jp/seiga/im5977527\",\n    \"#category\": (\"\", \"seiga\", \"image\"),\n    \"#class\"   : seiga.SeigaImageExtractor,\n    \"#sha1_metadata\": \"c8339781da260f7fc44894ad9ada016f53e3b12a\",\n    \"#sha1_content\" : \"d9202292012178374d57fb0126f6124387265297\",\n},\n\n{\n    \"#url\"     : \"https://seiga.nicovideo.jp/seiga/im123\",\n    \"#category\": (\"\", \"seiga\", \"image\"),\n    \"#class\"   : seiga.SeigaImageExtractor,\n    \"#exception\": exception.NotFoundError,\n},\n\n{\n    \"#url\"     : \"https://seiga.nicovideo.jp/seiga/im10877923\",\n    \"#category\": (\"\", \"seiga\", \"image\"),\n    \"#class\"   : seiga.SeigaImageExtractor,\n    \"#pattern\" : r\"https://lohas\\.nicoseiga\\.jp/priv/5936a2a6c860a600e465e0411c0822e0b510e286/1688757110/10877923\",\n},\n\n{\n    \"#url\"     : \"https://seiga.nicovideo.jp/image/source/5977527\",\n    \"#category\": (\"\", \"seiga\", \"image\"),\n    \"#class\"   : seiga.SeigaImageExtractor,\n},\n\n{\n    \"#url\"     : \"https://sp.seiga.nicovideo.jp/seiga/#!/im5977527\",\n    \"#category\": (\"\", \"seiga\", \"image\"),\n    \"#class\"   : seiga.SeigaImageExtractor,\n},\n\n{\n    \"#url\"     : \"https://lohas.nicoseiga.jp/thumb/5977527i\",\n    \"#category\": (\"\", \"seiga\", \"image\"),\n    \"#class\"   : seiga.SeigaImageExtractor,\n},\n\n{\n    \"#url\"     : \"https://lohas.nicoseiga.jp/priv/759a4ef1c639106ba4d665ee6333832e647d0e4e/1549727594/5977527\",\n    \"#category\": (\"\", \"seiga\", \"image\"),\n    \"#class\"   : seiga.SeigaImageExtractor,\n},\n\n{\n    \"#url\"     : \"https://lohas.nicoseiga.jp/o/759a4ef1c639106ba4d665ee6333832e647d0e4e/1549727594/5977527\",\n    \"#category\": (\"\", \"seiga\", \"image\"),\n    \"#class\"   : seiga.SeigaImageExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/senmanga.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import senmanga\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://raw.senmanga.com/Bokura-wa-Minna-Kawaisou/37A/1\",\n    \"#category\": (\"\", \"senmanga\", \"chapter\"),\n    \"#class\"   : senmanga.SenmangaChapterExtractor,\n    \"#pattern\"     : r\"https://raw\\.senmanga\\.com/viewer/Bokura-wa-Minna-Kawaisou/37A/[12]\",\n    \"#sha1_url\"    : \"5f95140ff511d8497e2ec08fa7267c6bb231faec\",\n    \"#sha1_content\": \"556a16d5ca3441d7a5807b6b5ac06ec458a3e4ba\",\n\n    \"chapter\"  : \"37A\",\n    \"count\"    : 2,\n    \"extension\": \"\",\n    \"filename\" : r\"re:[12]\",\n    \"lang\"     : \"ja\",\n    \"language\" : \"Japanese\",\n    \"manga\"    : \"Bokura wa Minna Kawaisou\",\n    \"page\"     : int,\n},\n\n{\n    \"#url\"     : \"http://raw.senmanga.com/Love-Lab/2016-03/1\",\n    \"#category\": (\"\", \"senmanga\", \"chapter\"),\n    \"#class\"   : senmanga.SenmangaChapterExtractor,\n    \"#pattern\" : r\"https://raw\\.senmanga\\.com/viewer/Love-Lab/2016-03/\\d\",\n    \"#sha1_url\": \"8347b9f00c14b864dd3c19a1f5ae52adb2ef00de\",\n\n    \"chapter\"  : \"2016-03\",\n    \"count\"    : 9,\n    \"extension\": \"\",\n    \"filename\" : r\"re:\\d\",\n    \"manga\"    : \"Renai Lab   恋愛ラボ\",\n},\n\n{\n    \"#url\"     : \"https://raw.senmanga.com/akabane-honeko-no-bodyguard/1\",\n    \"#category\": (\"\", \"senmanga\", \"chapter\"),\n    \"#class\"   : senmanga.SenmangaChapterExtractor,\n    \"#pattern\" : r\"https://i\\d\\.wp\\.com/kumacdn.club/image-new-2/a/akabane-honeko-no-bodyguard/chapter-1/\\d+-[0-9a-f]{13}\\.jpg\",\n\n    \"chapter\"  : \"1\",\n    \"count\"    : 65,\n    \"extension\": \"jpg\",\n    \"filename\" : r\"re:\\d+-\\w+\",\n    \"manga\"    : \"Akabane Honeko no Bodyguard\",\n},\n\n{\n    \"#url\"     : \"https://raw.senmanga.com/amama-cinderella/3\",\n    \"#comment\" : \"no http scheme ()\",\n    \"#category\": (\"\", \"senmanga\", \"chapter\"),\n    \"#class\"   : senmanga.SenmangaChapterExtractor,\n    \"#pattern\" : r\"^https://kumacdn.club/image-new-2/a/amama-cinderella/chapter-3/.+\\.jpg\",\n    \"#count\"   : 30,\n},\n\n)\n"
  },
  {
    "path": "test/results/sexcom.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import sexcom\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://www.sex.com/pin/21241874-sexy-ecchi-girls-166/\",\n    \"#comment\" : \"picture (legacy URL)\",\n    \"#category\": (\"\", \"sexcom\", \"pin\"),\n    \"#class\"   : sexcom.SexcomPinExtractor,\n    \"#skip\"    : \"legacy\",\n    \"#results\"     : \"https://imagex1.sx.cdn.live/images/pinporn/2014/08/26/7637609.jpg\",\n    \"#sha1_content\": \"8cd419c6790ef7348bd398c364ab10f956e438dc\",\n\n    \"comments\" : range(0, 5),\n    \"date\"     : \"dt:2014-10-19 15:45:44\",\n    \"date_url\" : \"dt:2014-08-26 00:00:00\",\n    \"extension\": \"jpg\",\n    \"filename\" : \"7637609\",\n    \"likes\"    : range(240, 275),\n    \"pin_id\"   : 21241874,\n    \"repins\"   : range(90, 120),\n    \"thumbnail\": \"https://imagex1.sx.cdn.live/images/pinporn/2014/08/26/7637609.jpg?width=300\",\n    \"title\"    : \"Sexy Ecchi Girls 166\",\n    \"type\"     : \"picture\",\n    \"uploader\" : \"mangazeta\",\n    \"url\"      : \"https://imagex1.sx.cdn.live/images/pinporn/2014/08/26/7637609.jpg\",\n    \"tags\": [\n        \"ecchi\",\n        \"ecchi-girls\",\n        \"Hot\",\n        \"sexy-ecchi\",\n    ],\n},\n\n{\n    \"#url\"     : \"https://www.sex.com/en/pics/612398\",\n    \"#comment\" : \"picture\",\n    \"#category\": (\"\", \"sexcom\", \"pin\"),\n    \"#class\"   : sexcom.SexcomPinExtractor,\n    \"#results\" : \"https://imagex1.sx.cdn.live/images/pinporn/2014/08/26/7637609.jpg\",\n\n    \"date\"     : \"dt:2014-08-26 00:00:00\",\n    \"date_url\" : \"dt:2014-08-26 00:00:00\",\n    \"extension\": \"jpg\",\n    \"filename\" : \"7637609\",\n    \"pin_id\"   : 612398,\n    \"tags\"     : [\"Hot\"],\n    \"title\"    : \"Sexy Ecchi Girls 166\",\n    \"type\"     : \"picture\",\n},\n\n{\n    \"#url\"     : \"https://www.sex.com/pin/55435122-ecchi/\",\n    \"#comment\" : \"gif (legacy URL)\",\n    \"#category\": (\"\", \"sexcom\", \"pin\"),\n    \"#class\"   : sexcom.SexcomPinExtractor,\n    \"#results\"     : \"https://imagex1.sx.cdn.live/images/pinporn/2017/12/07/18760842.gif\",\n    \"#sha1_content\": \"176cc63fa05182cb0438c648230c0f324a5965fe\",\n\n    \"date\"     : \"dt:2017-12-07 00:00:00\",\n    \"date_url\" : \"dt:2017-12-07 00:00:00\",\n    \"extension\": \"gif\",\n    \"filename\" : \"18760842\",\n    \"pin_id\"   : 209061,\n    \"title\"    : \"Ecchi\",\n    \"type\"     : \"gif\",\n    \"url\"      : \"https://imagex1.sx.cdn.live/images/pinporn/2017/12/07/18760842.gif\",\n    \"_fallback\": (\"https://imagex1.sx.cdn.live/images/pinporn/2017/12/07/18760842.webp\",),\n    \"tags\"     : [\n        \"Big Tits\",\n        \"Hentai\",\n    ],\n},\n\n{\n    \"#url\"     : \"https://www.sex.com/en/gifs/209061\",\n    \"#comment\" : \"gif\",\n    \"#category\": (\"\", \"sexcom\", \"pin\"),\n    \"#class\"   : sexcom.SexcomPinExtractor,\n    \"#options\" : {\"gifs\": False},\n    \"#results\"     : \"https://imagex1.sx.cdn.live/images/pinporn/2017/12/07/18760842.webp\",\n    \"#sha1_content\": \"d5d58fbb92f87be49a37d29d82687c9efa7f796f\",\n\n    \"date\"     : \"dt:2017-12-07 00:00:00\",\n    \"date_url\" : \"dt:2017-12-07 00:00:00\",\n    \"extension\": \"webp\",\n    \"filename\" : \"18760842\",\n    \"pin_id\"   : 209061,\n    \"title\"    : \"Ecchi\",\n    \"type\"     : \"gif\",\n    \"url\"      : \"https://imagex1.sx.cdn.live/images/pinporn/2017/12/07/18760842.webp\",\n    \"tags\"     : [\n        \"Big Tits\",\n        \"Hentai\",\n    ],\n},\n\n{\n    \"#url\"     : \"https://www.sex.com/pin/55748341/\",\n    \"#comment\" : \"video (legacy URL)\",\n    \"#category\": (\"\", \"sexcom\", \"pin\"),\n    \"#class\"   : sexcom.SexcomPinExtractor,\n    \"#skip\"    : \"gone\",\n    \"#results\"     : \"https://video1.sx.cdn.live/videos/pinporn/2018/02/10/776229_hd.mp4\",\n    \"#sha1_content\": \"e1a5834869163e2c4d1ca2677f5b7b367cf8cfff\",\n\n    \"comments\" : range(0, 5),\n    \"date\"     : \"dt:2018-02-10 14:58:55\",\n    \"date_url\" : \"dt:2018-02-10 00:00:00\",\n    \"extension\": \"mp4\",\n    \"filename\" : \"776229_hd\",\n    \"likes\"    : range(30, 50),\n    \"pin_id\"   : 55748341,\n    \"repins\"   : range(10, 20),\n    \"thumbnail\": \"https://imagex1.sx.cdn.live/images/pinporn/2018/02/10/19082009.jpg?width=300\",\n    \"title\"    : \"Pin #55748341\",\n    \"type\"     : \"video\",\n    \"uploader\" : \"Vinsein\",\n    \"url\"      : \"https://video1.sx.cdn.live/videos/pinporn/2018/02/10/776229_hd.mp4\",\n    \"tags\": [\n        \"Hentai\",\n    ],\n},\n\n{\n    \"#url\"     : \"https://www.sex.com/en/videos/680933\",\n    \"#comment\" : \"video\",\n    \"#category\": (\"\", \"sexcom\", \"pin\"),\n    \"#class\"   : sexcom.SexcomPinExtractor,\n    \"#results\" : \"ytdl:https://videos.sex.com/680933/video.m3u8\",\n\n    \"extension\": \"mp4\",\n    \"pin_id\"   : 680933,\n    \"title\"    : \"Underwater Big Boobs Kristy with Small Boobs Petra\",\n    \"type\"     : \"video\",\n    \"url\"      : \"ytdl:https://videos.sex.com/680933/video.m3u8\",\n    \"tags\"     : [\n        \"Babe\",\n        \"Beach\",\n        \"Big Boobs\",\n        \"Bikini\",\n        \"Brunette\",\n        \"Brunette Babe\",\n        \"Girlfriend\",\n        \"Natural Tits\",\n        \"Nude\",\n        \"Pool\",\n        \"Poolside\",\n        \"Public Sex\",\n        \"Russian\",\n        \"Shower\",\n        \"Small Boobs\",\n        \"Swimming\",\n        \"Swimming Pool\",\n        \"Tight Pussy\",\n        \"Underwater\",\n        \"With\",\n        \"Young\",\n    ],\n},\n\n{\n    \"#url\"     : \"https://www.sex.com/pin/55847384-very-nicely-animated/\",\n    \"#comment\" : \"pornhub embed (404 gone)\",\n    \"#category\": (\"\", \"sexcom\", \"pin\"),\n    \"#class\"   : sexcom.SexcomPinExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.sex.com/pin/21241874/#related\",\n    \"#category\": (\"\", \"sexcom\", \"related-pin\"),\n    \"#class\"   : sexcom.SexcomRelatedPinExtractor,\n    \"#count\"   : \">= 20\",\n},\n\n{\n    \"#url\"     : \"https://www.sex.com/user/sirjuan79/pins/\",\n    \"#category\": (\"\", \"sexcom\", \"pins\"),\n    \"#class\"   : sexcom.SexcomPinsExtractor,\n    \"#count\"   : \">= 4\",\n},\n\n{\n    \"#url\"     : \"https://www.sex.com/user/sirjuan79/likes/\",\n    \"#category\": (\"\", \"sexcom\", \"likes\"),\n    \"#class\"   : sexcom.SexcomLikesExtractor,\n    \"#range\"   : \"1-30\",\n    \"#count\"   : \">= 25\",\n},\n\n{\n    \"#url\"     : \"https://www.sex.com/user/ronin17/exciting-hentai/\",\n    \"#category\": (\"\", \"sexcom\", \"board\"),\n    \"#class\"   : sexcom.SexcomBoardExtractor,\n    \"#count\"   : \">= 10\",\n},\n\n{\n    \"#url\"     : \"https://www.sex.com/search/pics?query=ecchi\",\n    \"#category\": (\"\", \"sexcom\", \"search\"),\n    \"#class\"   : sexcom.SexcomSearchExtractor,\n    \"#range\"   : \"1-10\",\n    \"#count\"   : 10,\n},\n\n{\n    \"#url\"     : \"https://www.sex.com/videos/hentai/\",\n    \"#category\": (\"\", \"sexcom\", \"search\"),\n    \"#class\"   : sexcom.SexcomSearchExtractor,\n    \"#range\"   : \"1-10\",\n    \"#count\"   : 10,\n},\n\n{\n    \"#url\"     : \"https://www.sex.com/pics/?sort=popular&sub=all&page=1\",\n    \"#category\": (\"\", \"sexcom\", \"search\"),\n    \"#class\"   : sexcom.SexcomSearchExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.sex.com/en/gifs?search=bed\",\n    \"#class\"   : sexcom.SexcomSearchExtractor,\n    \"#pattern\" : r\"https://imagex1.sx.cdn.live/images/pinporn/\\d\\d\\d\\d/\\d\\d/\\d\\d/\\d+\\.gif\",\n    \"#range\"   : \"1-50\",\n    \"#count\"   : 50,\n\n    \"date\"       : \"type:datetime\",\n    \"date_url\"   : \"type:datetime\",\n    \"extension\"  : \"gif\",\n    \"externalId\" : int,\n    \"filename\"   : str,\n    \"width\"      : range(10, 1000),\n    \"height\"     : range(10, 1000),\n    \"pin_id\"     : int,\n    \"search\"     : {\n        \"order\"             : \"likeCount\",\n        \"search\"            : \"bed\",\n        \"sexual-orientation\": \"straight\",\n        \"type\"              : \"gif\",\n    },\n    \"title\"      : str,\n    \"type\"       : \"gif\",\n    \"uri\"        : str,\n    \"url\"        : str,\n},\n\n{\n    \"#url\"  : \"https://www.sex.com/feed/\",\n    \"#class\": sexcom.SexcomFeedExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/silverpic.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import imagehosts\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://www.silverpic.net/8k562jyix8xq/jxU_0001.JPG.html\",\n    \"#category\": (\"imagehost\", \"silverpic\", \"image\"),\n    \"#class\"   : imagehosts.SilverpicImageExtractor,\n    \"#results\" : \"https://silverpic.net/img/z7esmp7eor37ssodt4ptpxbzoy/jxU_0001.JPG\",\n\n    \"filename\" : \"jxU_0001\",\n    \"extension\": \"jpg\",\n    \"token\"    : \"8k562jyix8xq\",\n    \"width\"    : 3744,\n    \"height\"   : 5616,\n},\n\n{\n    \"#url\"     : \"https://www.silverpic.com/8k562jyix8xq/jxU_0001.JPG.html\",\n    \"#category\": (\"imagehost\", \"silverpic\", \"image\"),\n    \"#class\"   : imagehosts.SilverpicImageExtractor,\n    \"#results\" : \"https://silverpic.net/img/z7esmp7eor37ssodt4ptpxbzoy/jxU_0001.JPG\",\n},\n\n)\n"
  },
  {
    "path": "test/results/simpcity.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import xenforo\nfrom gallery_dl import exception\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://simpcity.cr/threads/ririkana-rr_loveit.10731/post-1753131\",\n    \"#category\": (\"xenforo\", \"simpcity\", \"post\"),\n    \"#class\"   : xenforo.XenforoPostExtractor,\n    \"#auth\"    : True,\n    \"#results\" : \"https://jpg6.su/img/coWRwo\",\n\n    \"count\" : 1,\n    \"num\"   : 1,\n    \"post\"  : {\n        \"author\"    : \"Zebrabobinn\",\n        \"author_id\" : \"171827\",\n        \"author_url\": \"https://simpcity.cr/members/zebrabobinn.171827/\",\n        \"author_slug\": \"zebrabobinn\",\n        \"count\"     : 1,\n        \"date\"      : \"dt:2023-03-08 12:59:10\",\n        \"id\"        : \"1753131\",\n        \"content\"   : str,\n    },\n    \"thread\": {\n        \"author\"    : \"eula\",\n        \"author_id\" : \"54987\",\n        \"author_url\": \"https://simpcity.cr/members/eula.54987/\",\n        \"author_slug\": \"eula\",\n        \"date\"      : \"dt:2022-03-11 17:15:59\",\n        \"id\"        : \"10731\",\n        \"posts\"     : range(320, 500),\n        \"section\"   : \"Asians\",\n        \"title\"     : \"Ririkana | RR_loveit\",\n        \"url\"       : \"https://simpcity.cr/threads/ririkana-rr_loveit.10731/\",\n        \"views\"     : range(900_000, 2_000_000),\n        \"tags\"      : [\n            \"asian\",\n            \"big ass\",\n            \"gravure\",\n            \"japanese\",\n            \"japanese big ass\",\n            \"small tits\",\n            \"thicc\",\n        ],\n    },\n},\n\n{\n    \"#url\"     : \"https://simpcity.cr/threads/ririkana-rr_loveit.10731/post-1753131\",\n    \"#category\": (\"xenforo\", \"simpcity\", \"post\"),\n    \"#class\"   : xenforo.XenforoPostExtractor,\n    \"#auth\"     : False,\n    \"#exception\": exception.AuthRequired,\n},\n\n{\n    \"#url\"     : \"https://simpcity.cr/threads/puutin_cos.219873/post-26053409\",\n    \"#comment\" : \"iframe embeds (#8214)\",\n    \"#category\": (\"xenforo\", \"simpcity\", \"post\"),\n    \"#class\"   : xenforo.XenforoPostExtractor,\n    \"#auth\"    : True,\n    \"#results\" : (\n        \"https://jpg6.su/img/NNFssUg\",\n        \"https://turbo.cr/embed/nPy1kG3w55V\",\n        \"https://turbo.cr/embed/c0KhPjU4-F3\",\n        \"https://turbo.cr/embed/sZWnVZ_mQsV\",\n        \"https://turbo.cr/embed/MEBiLx6DETQ\",\n    ),\n},\n\n{\n    \"#url\"     : \"https://simpcity.cr/threads/shinhashimoto00-shinhashimoto01.184378/post-13389764\",\n    \"#comment\" : \"quote in post content (#8214)\",\n    \"#category\": (\"xenforo\", \"simpcity\", \"post\"),\n    \"#class\"   : xenforo.XenforoPostExtractor,\n    \"#auth\"    : True,\n    \"#results\" : \"https://cyberdrop.cr/a/Sh9GlG38\",\n},\n\n{\n    \"#url\"     : \"https://simpcity.cr/threads/kayle-oralglory.36572/post-12065490\",\n    \"#comment\" : \"deleted thread author (#8323)\",\n    \"#category\": (\"xenforo\", \"simpcity\", \"post\"),\n    \"#class\"   : xenforo.XenforoPostExtractor,\n    \"#auth\"    : True,\n    \"#results\" : (\n        \"https://redgifs.com/ifr/trainedovercookedsquid\",\n        \"https://jpg6.su/img/aKroBJp\",\n        \"https://jpg6.su/img/aKroy2E\",\n        \"https://jpg6.su/img/aKrofqa\",\n        \"https://jpg6.su/img/aKroDgo\",\n        \"https://bunkr.cr/v/6sErIc9pjrnQ3\",\n    ),\n\n    \"post\"  : {\n        \"author\"    : \"Hexorium\",\n        \"author_id\" : \"3715883\",\n        \"author_url\": \"https://simpcity.cr/members/hexorium.3715883/\",\n        \"author_slug\": \"hexorium\",\n        \"count\"     : 6,\n        \"date\"      : \"dt:2024-12-15 21:37:05\",\n        \"id\"        : \"12065490\",\n    },\n    \"thread\": {\n        \"author\"    : \"Deleted member 166159\",\n        \"author_id\" : \"166159\",\n        \"author_url\": \"\",\n        \"author_slug\": \"deleted-member\",\n        \"date\"      : \"dt:2022-04-05 14:48:14\",\n        \"id\"        : \"36572\",\n        \"section\"   : \"Premium Asians\",\n        \"title\"     : \"Kayle OralGlory\",\n        \"url\"       : \"https://simpcity.cr/threads/kayle-oralglory.36572/\",\n    },\n},\n\n{\n    \"#url\"     : \"https://simpcity.cr/threads/sophia-diamond.10049/post-10891\",\n    \"#category\": (\"xenforo\", \"simpcity\", \"post\"),\n    \"#class\"   : xenforo.XenforoPostExtractor,\n    \"#auth\"    : True,\n    \"#results\" : (\n        \"https://brandarmy.com/SophiaDiamond\",\n        \"https://www.tiktok.com/@sophia.ilysm?lang=en\",\n        \"https://www.instagram.com/sophiadiamond/\",\n        \"https://simpcity.cr/attachments/sophiadiamond_239636842_558607608495946_5357173067872834144_n-jpg.65924/\",\n    ),\n\n    \"count\"       : 4,\n    \"num\"         : range(1, 4),\n    \"num_external\": range(1, 3),\n    \"num_internal\": {0, 1},\n    \"type\"        : {\"inline\", \"external\"},\n    \"post\"        : {\n        \"attachments\": \"\",\n        \"author\"     : \"inoncognito\",\n        \"author_id\"  : \"53824\",\n        \"author_url\" : \"/members/inoncognito.53824/\",\n        \"count\"      : 4,\n        \"date\"       : \"dt:2022-03-11 00:41:28\",\n        \"id\"         : \"10891\",\n        \"content\"    : str,\n    },\n    \"thread\"      : {\n        \"author\"    : \"inoncognito\",\n        \"author_id\" : \"53824\",\n        \"author_url\": \"https://simpcity.cr/members/inoncognito.53824/\",\n        \"date\"      : \"dt:2022-03-11 00:41:28\",\n        \"id\"        : \"10049\",\n        \"posts\"     : range(1_000, 2_000),\n        \"section\"   : \"TikTok\",\n        \"title\"     : \"Sophia Diamond\",\n        \"url\"       : \"https://simpcity.cr/threads/sophia-diamond.10049/\",\n        \"views\"     : range(4_200_000, 6_000_000),\n        \"tags\"      : [\n            \"busty\",\n            \"diamond\",\n            \"slut\",\n            \"sophia\",\n            \"sophiadiamond\",\n            \"tease\",\n            \"teen\",\n            \"tiktok\",\n            \"tits\",\n        ],\n    },\n},\n\n{\n    \"#url\"     : \"https://simpcity.cr/threads/sophia-diamond.10049/post-18744\",\n    \"#category\": (\"xenforo\", \"simpcity\", \"post\"),\n    \"#class\"   : xenforo.XenforoPostExtractor,\n    \"#auth\"    : True,\n    \"#results\" : \"https://simpcity.cr/attachments/sophiadiamondcancunbikiniwp-png.36179/\",\n\n    \"count\"       : 1,\n    \"extension\"   : \"png\",\n    \"filename\"    : \"SophiaDiamondCancunBikiniWP\",\n    \"id\"          : 36179,\n    \"num\"         : 1,\n    \"num_external\": 0,\n    \"num_internal\": 1,\n    \"type\"        : \"inline\",\n    \"post\"        : {\n        \"author\"     : \"ElyseGooner\",\n        \"author_id\"  : \"65059\",\n        \"author_url\" : \"https://simpcity.cr/members/elysegooner.65059/\",\n        \"count\"      : 1,\n        \"date\"       : \"dt:2022-03-11 22:39:06\",\n        \"id\"         : \"18744\",\n        \"attachments\": str,\n        \"content\"    : r're:<div class=\"bbWrapper\">Collage</div>\\s+</div>',\n    },\n    \"thread\"      : {\n        \"date\"      : \"dt:2022-03-11 00:41:28\",\n        \"id\"        : \"10049\",\n        \"section\"   : \"TikTok\",\n        \"title\"     : \"Sophia Diamond\",\n    },\n},\n\n{\n    \"#url\"     : \"https://simpcity.cr/threads/lustn4lexi-hot4lexi-lexi-2-legit-hott4lexi-lexi.175167/post-2512729\",\n    \"#comment\" : \"'Click here to load redgifs media' (#8609)\",\n    \"#category\": (\"xenforo\", \"simpcity\", \"post\"),\n    \"#class\"   : xenforo.XenforoPostExtractor,\n    \"#auth\"    : True,\n    \"#results\" : \"https://redgifs.com/ifr/unusedsubmissivemullet\",\n},\n\n{\n    \"#url\"     : \"https://simpcity.cr/threads/snowball7766.1490941/post-38256229\",\n    \"#comment\" : \"bbImageWrapper blocks (#8868)\",\n    \"#category\": (\"xenforo\", \"simpcity\", \"post\"),\n    \"#class\"   : xenforo.XenforoPostExtractor,\n    \"#auth\"    : True,\n    \"#results\" : (\n        \"https://www.instagram.com/snowball7766/\",\n        \"https://www.threads.com/@snowball7766\",\n        \"https://www.facebook.com/ball.snow.7773#\",\n        \"https://simp6.selti-delivery.ru/images3/82489639_3012191048813651_5086244093198073856_o94d88f27678869a9.jpg\",\n        \"https://simp6.selti-delivery.ru/images3/81822451_3012190595480363_7751669220496113664_od8fd937464a00f73.jpg\",\n        \"https://simp6.selti-delivery.ru/images3/81459259_3012190838813672_781773786918682624_o880c1a4f0aa402c2.jpg\",\n        \"https://simp6.selti-delivery.ru/images3/81936276_3012190545480368_1653404646221283328_oab69119416354f10.jpg\",\n        \"https://simp6.selti-delivery.ru/images3/82375666_3012191015480321_6208103187434438656_of41cd9f33c2f5d2b.jpg\",\n        \"https://simp6.selti-delivery.ru/images3/82453137_3012190802147009_1828394700625674240_o9f4ee63fffbfc3de.jpg\",\n        \"https://simp6.selti-delivery.ru/images3/82710873_3012190752147014_2275240512531202048_o31a8e0ff56b05fa6.jpg\",\n        \"https://simp6.selti-delivery.ru/images3/82919150_3012190608813695_447262977784020992_o8105006061c5fd32.jpg\",\n        \"https://simp6.selti-delivery.ru/images3/49013049333_eed56c4a94_ob0809a5e8b720026.jpg\",\n        \"https://simp6.selti-delivery.ru/images3/49013783762_6143932191_o76c074c41effc49f.jpg\",\n        \"https://simp6.selti-delivery.ru/images3/49013792187_8a9f1066e6_o9359dcb86e42dba6.jpg\",\n        \"https://simp6.selti-delivery.ru/images3/52044873_2357680964251830_3177806178725920768_n3f60e7febf7ed056.jpg\",\n    ),\n},\n\n{\n    \"#url\"     : \"https://simpcity.cr/threads/arianaskyeshelby-itsarianaskyebaby-busty.1237895/post-40205575\",\n    \"#comment\" : \"tiktok s9e media embed iframe (#8994)\",\n    \"#category\": (\"xenforo\", \"simpcity\", \"post\"),\n    \"#class\"   : xenforo.XenforoPostExtractor,\n    \"#auth\"    : True,\n    \"#results\" : \"https://www.tiktok.com/@/video/7556556034794425631\",\n},\n\n{\n    \"#url\"     : \"https://simpcity.cr/threads/alrightsierra.70601/post-571509\",\n    \"#comment\" : \"reddit s9e media embed iframe (#8996)\",\n    \"#category\": (\"xenforo\", \"simpcity\", \"post\"),\n    \"#class\"   : xenforo.XenforoPostExtractor,\n    \"#auth\"    : True,\n    \"#results\" : \"https://embed.reddit.com/r/TikTokFeet/comments/rtzwnz#theme=auto\",\n},\n\n{\n    \"#url\"     : \"https://simpcity.cr/threads/meme-thread.96106/page-7#post-1034578\",\n    \"#comment\" : \"quoted content (#9207)\",\n    \"#category\": (\"xenforo\", \"simpcity\", \"post\"),\n    \"#class\"   : xenforo.XenforoPostExtractor,\n    \"#auth\"    : True,\n    \"#results\" : \"https://jpg6.su/img/hqx4cS\",\n\n    \"count\"       : 1,\n    \"num_external\": 1,\n    \"type\"        : \"external\",\n    \"post\"        : {\n        \"attachments\": \"\",\n        \"author\"     : \"aaaa22223333\",\n        \"author_id\"  : \"1550683\",\n        \"author_slug\": \"aaaa22223333\",\n        \"author_url\" : \"https://simpcity.cr/members/aaaa22223333.1550683/\",\n        \"date\"       : \"dt:2022-10-02 02:18:24\",\n        \"id\"         : \"1034578\",\n        \"content\"    : r\"\"\"\\\n<div class=\"bbWrapper\"><a href=\"https://jpg6.su/img/hqx4cS\" target=\"_blank\" class=\"link link--external\" rel=\"nofollow ugc noopener\"><img src=\"https://simp4.selti-delivery.ru/ZomboMeme-01102022201614.md.jpg\" data-url=\"https://simp4.selti-delivery.ru/ZomboMeme-01102022201614.md.jpg\" class=\"bbImage \" loading=\"lazy\"\n\\ŧ\\ŧalt=\"ZomboMeme-01102022201614.md.jpg\" title=\"ZomboMeme-01102022201614.md.jpg\" style=\"\" width=\"\" height=\"\" /></a></div>\n\n\\ŧ\\ŧ\\ŧ</div>\\\n\"\"\",\n    },\n    \"thread\"      : {\n        \"author\"     : \"SimpCity\",\n        \"author_id\"  : \"67275\",\n        \"author_slug\": \"simpcity\",\n        \"author_url\" : \"https://simpcity.cr/members/simpcity.67275/\",\n        \"date\"       : \"dt:2022-08-27 21:16:53\",\n        \"id\"         : \"96106\",\n        \"section\"    : \"General Discussion\",\n        \"tags\"       : [\"memes\"],\n        \"title\"      : \"Meme Thread\",\n        \"url\"        : \"https://simpcity.cr/threads/meme-thread.96106/\",\n    },\n},\n\n{\n    \"#url\"     : \"https://simpcity.cr/threads/alua-tatakai.89490/\",\n    \"#category\": (\"xenforo\", \"simpcity\", \"thread\"),\n    \"#class\"   : xenforo.XenforoThreadExtractor,\n    \"#auth\"    : True,\n    \"#pattern\" : r\"https://(jpg6\\.su/img/\\w+|bunkr\\.\\w+/[fiv]/\\w+|pixeldrain.com/l/\\w+|alua.com/tatakai)|turbo(vid)?.cr/embed\",\n    \"#count\"   : range(100, 300),\n\n    \"count\" : int,\n    \"num\"   : int,\n    \"post\"  : {\n        \"author\"    : str,\n        \"author_id\" : r\"re:\\d+\",\n        \"author_url\": str,\n        \"content\"   : str,\n        \"count\"     : int,\n        \"date\"      : \"type:datetime\",\n        \"id\"        : r\"re:\\d+\",\n    },\n    \"thread\": {\n        \"author\"    : \"Ekalamosus\",\n        \"author_id\" : \"1036155\",\n        \"author_url\": \"https://simpcity.cr/members/ekalamosus.1036155/\",\n        \"date\"      : \"dt:2022-07-31 15:40:14\",\n        \"id\"        : \"89490\",\n        \"posts\"     : 45,\n        \"section\"   : \"Asians\",\n        \"title\"     : \"Alua tatakai\",\n        \"url\"       : \"https://simpcity.cr/threads/alua-tatakai.89490/\",\n        \"views\"     : range(47_000, 60_000),\n        \"tags\"      : [\n            \"alter\",\n            \"alua\",\n            \"pinay\",\n        ],\n    },\n},\n\n{\n    \"#url\"     : \"https://simpcity.su/threads/angel-chan-wlep-wlop-menruinyanko_.12948/\",\n    \"#category\": (\"xenforo\", \"simpcity\", \"thread\"),\n    \"#class\"   : xenforo.XenforoThreadExtractor,\n},\n\n{\n    \"#url\"     : \"https://simpcity.cr/threads/ririkana-rr_loveit.10731/\",\n    \"#comment\" : \"post order by reaction score (#8997)\",\n    \"#category\": (\"xenforo\", \"simpcity\", \"thread\"),\n    \"#class\"   : xenforo.XenforoThreadExtractor,\n    \"#auth\"    : True,\n    \"#options\" : {\n        \"post-range\" : 1,\n        \"order-posts\": \"reaction\",\n    },\n    \"#results\" : \"https://bunkr.cr/v/BKLYkkr9KK6dg\",\n},\n\n{\n    \"#url\"     : \"https://simpcity.cr/forums/asians.48/\",\n    \"#category\": (\"xenforo\", \"simpcity\", \"forum\"),\n    \"#class\"   : xenforo.XenforoForumExtractor,\n    \"#pattern\" : xenforo.XenforoThreadExtractor.pattern,\n    \"#range\"   : \"1-100\",\n    \"#count\"   : 100,\n},\n\n)\n"
  },
  {
    "path": "test/results/simplyhentai.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import simplyhentai\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://www.simply-hentai.com/puella-magi-madoka-magica/hangyaku-no-hanafuda-monogatari-english\",\n    \"#class\"   : simplyhentai.SimplyhentaiGalleryExtractor,\n    \"#pattern\" : r\"https://images\\.sh\\-cdn\\.com/0/c/1/e/365291/\\w+\\.jpg\",\n    \"#count\"   : 25,\n\n    \"artists\"        : [],\n    \"comment_count\"  : int,\n    \"count\"          : 25,\n    \"cover\"          : \"https://images.sh-cdn.com/0/c/1/e/365291/1f47ed30.jpg\",\n    \"created_at\"     : \"2024-01-07T15:03:11.924+01:00\",\n    \"date\"           : \"dt:2024-01-07 14:03:11\",\n    \"description\"    : None,\n    \"extension\"      : \"jpg\",\n    \"filename\"       : str,\n    \"gallery_id\"     : 365291,\n    \"id\"             : int,\n    \"image_count\"    : 25,\n    \"lang\"           : \"en\",\n    \"language\"       : \"English\",\n    \"new\"            : False,\n    \"num\"            : range(1, 25),\n    \"other_languages\": [],\n    \"parodies\"       : [\"Puella Magi Madoka Magica\"],\n    \"reactions\"      : {},\n    \"redirected\"     : False,\n    \"series\"         : \"Puella Magi Madoka Magica\",\n    \"slug\"           : \"hangyaku-no-hanafuda-monogatari-english\",\n    \"title\"          : \"Hangyaku no Hanafuda Monogatari [English}\",\n    \"translators\"    : [],\n    \"type\"           : \"Album\",\n    \"user\"           : None,\n    \"views\"          : range(80_000, 500_000),\n    \"characters\"     : [\n        \"homura akemi\",\n        \"kyouko sakura\",\n        \"madoka kaname\",\n        \"mami tomoe\",\n        \"sayaka miki\",\n    ],\n    \"tags\"           : [\n        \"females only\",\n        \"Giantess\",\n        \"Kimono\",\n        \"Yuri\",\n    ],\n    \"anijunky\"       : dict,\n    \"interactions\"   : dict,\n    \"!related\"       : dict,\n    \"!images\"        : list,\n    \"!pages\"         : list,\n},\n\n{\n    \"#url\"     : \"https://www.simply-hentai.com/dungeon-ni-deai-o-motomeru-no-wa-machigatteiru-darou-ka/aisha-defeated-by-bell\",\n    \"#comment\" : \"12 or less pages\",\n    \"#class\"   : simplyhentai.SimplyhentaiGalleryExtractor,\n    \"#pattern\" : r\"https://images\\.sh\\-cdn\\.com/f/0/6/6/364253/\\w+\\.jpg\",\n    \"#count\"   : 11,\n\n    \"artists\"        : [\"bareisho tarou\"],\n    \"count\"          : 11,\n    \"cover\"          : \"https://images.sh-cdn.com/f/0/6/6/364253/a313a9ae.jpg\",\n    \"date\"           : \"dt:2023-12-11 07:14:11\",\n    \"gallery_id\"     : 364253,\n    \"id\"             : int,\n    \"lang\"           : \"en\",\n    \"parodies\"       : [\"dungeon ni deai o motomeru no wa machigatteiru darou ka\"],\n    \"series\"         : \"dungeon ni deai o motomeru no wa machigatteiru darou ka\",\n    \"title\"          : \"Aisha Defeated By Bell\",\n    \"characters\"     : [\n        \"aisha belka\",\n        \"bell cranel\",\n        \"haruhime sanjouno\",\n    ],\n    \"tags\"           : [\n        \"big breasts\",\n        \"big penis\",\n        \"Dark Skin\",\n        \"Fox Girl\",\n        \"Harem\",\n        \"Impregnation\",\n        \"kemonomimi | animal ears\",\n        \"Kimono\",\n        \"multi-work series\",\n        \"Nakadashi\",\n        \"Pregnant\",\n        \"Prostitution\",\n        \"sole female\",\n        \"sole male\",\n        \"very long hair\",\n    ],\n},\n\n{\n    \"#url\"     : \"https://www.simply-hentai.com/series/mysterious-girlfriend-x\",\n    \"#class\"   : simplyhentai.SimplyhentaiSeriesExtractor,\n    \"#results\" : (\n        \"https://www.simply-hentai.com/mysterious-girlfriend-x/sekkyokuteki-na-kanojo-b2d61\",\n        \"https://www.simply-hentai.com/mysterious-girlfriend-x/sekkyokuteki-na-kanojo-30cd3\",\n        \"https://www.simply-hentai.com/mysterious-girlfriend-x/sekkyokuteki-na-kanojo\",\n        \"https://www.simply-hentai.com/mysterious-girlfriend-x/urabe-to-shitemita\",\n    ),\n},\n\n{\n    \"#url\"     : \"https://www.simply-hentai.com/series/mysterious-girlfriend-x/tag-nakadashi/sort-popularity\",\n    \"#class\"   : simplyhentai.SimplyhentaiSeriesExtractor,\n    \"#results\" : (\n        \"https://www.simply-hentai.com/mysterious-girlfriend-x/sekkyokuteki-na-kanojo\",\n        \"https://www.simply-hentai.com/mysterious-girlfriend-x/urabe-to-shitemita\",\n    ),\n},\n\n{\n    \"#url\"     : \"https://www.simply-hentai.com/series/mysterious-girlfriend-x?filter%5Btags%5D%5B0%5D=schoolgirl%20uniform&filter%5Btags%5D%5B1%5D=Nakadashi&filter%5Bparodies%5D%5B0%5D=Nazo%20no%20Kanojo%20X\",\n    \"#class\"   : simplyhentai.SimplyhentaiSeriesExtractor,\n    \"#results\" : \"https://www.simply-hentai.com/mysterious-girlfriend-x/sekkyokuteki-na-kanojo\",\n},\n\n{\n    \"#url\"     : \"https://www.simply-hentai.com/2-mangas\",\n    \"#class\"   : simplyhentai.SimplyhentaiMangaExtractor,\n    \"#pattern\" : simplyhentai.SimplyhentaiGalleryExtractor.pattern,\n    \"#range\"   : \"1-5\",\n},\n\n{\n    \"#url\"     : \"https://www.simply-hentai.com/2-mangas/sort-most-viewed\",\n    \"#class\"   : simplyhentai.SimplyhentaiMangaExtractor,\n    \"#range\"   : \"1-5\",\n    \"#results\" : (\n        \"https://www.simply-hentai.com/8-original-work/1-ikura-de-yaremasu-ka-a0031\",\n        \"https://www.simply-hentai.com/8-naruto/3-konoha-genei-jutsu\",\n        \"https://www.simply-hentai.com/8-naruto/3-erokosu-vol14\",\n        \"https://www.simply-hentai.com/2-dragon-ball-super/3-special-training\",\n        \"https://www.simply-hentai.com/8-naruto/3-hinata-fight\",\n    ),\n},\n\n{\n    \"#url\"     : \"https://www.simply-hentai.com/language/polish\",\n    \"#class\"   : simplyhentai.SimplyhentaiLanguageExtractor,\n    \"#results\" : (\n        \"https://www.simply-hentai.com/chrono-trigger/lucca-no-hikigane-luccas-trigger\",\n        \"https://www.simply-hentai.com/sword-art-online/omodume-box-xxiii\",\n        \"https://www.simply-hentai.com/8-original-work/sdpo-seimukan-no-susume-6bedc\",\n    ),\n},\n\n{\n    \"#url\"     : \"https://www.simply-hentai.com/language/german/sort-newest/page-12\",\n    \"#class\"   : simplyhentai.SimplyhentaiLanguageExtractor,\n    \"#pattern\" : simplyhentai.SimplyhentaiGalleryExtractor.pattern,\n    \"#count\"   : 12,\n},\n\n{\n    \"#url\"     : \"https://www.simply-hentai.com/parody/touhou\",\n    \"#class\"   : simplyhentai.SimplyhentaiTagExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.simply-hentai.com/tag/1-sole-female\",\n    \"#class\"   : simplyhentai.SimplyhentaiTagExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.simply-hentai.com/character/producer\",\n    \"#class\"   : simplyhentai.SimplyhentaiTagExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.simply-hentai.com/collection/english-b5e31\",\n    \"#class\"   : simplyhentai.SimplyhentaiTagExtractor,\n    \"#results\" : (\n        \"https://www.simply-hentai.com/8-original-work/i_did_naughty_things_with_my__sister\",\n        \"https://www.simply-hentai.com/8-original-work/suki-suki-daisuki-onee-chan-i-love-love-love-you-onee-chan\",\n        \"https://www.simply-hentai.com/2-detective-conan/13303-f-l-o-w-e-r-03\",\n    ),\n},\n\n{\n    \"#url\"     : \"https://www.simply-hentai.com/artist/crimson-carmine/tag-color/sort-alphabetical\",\n    \"#class\"   : simplyhentai.SimplyhentaiTagExtractor,\n    \"#pattern\" : simplyhentai.SimplyhentaiGalleryExtractor.pattern,\n    \"#count\"   : 25,\n},\n\n{\n    \"#url\"     : \"https://www.simply-hentai.com/translator/10-team-vanilla\",\n    \"#class\"   : simplyhentai.SimplyhentaiTagExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/sizebooru.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import sizebooru\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://sizebooru.com/Details/283342\",\n    \"#class\"   : sizebooru.SizebooruPostExtractor,\n    \"#results\" : \"https://sizebooru.com/Picture/283342\",\n    \"#sha1_content\": \"ae8bcbe95d58ba8ed4f33fe017088c9ec0f09515\",\n\n    \"id\"           : 283342,\n    \"filename\"     : \"283342\",\n    \"extension\"    : \"jpg\",\n    \"file_url\"     : \"https://sizebooru.com/Picture/283342\",\n},\n\n{\n    \"#url\"     : \"https://sizebooru.com/Details/283342\",\n    \"#class\"   : sizebooru.SizebooruPostExtractor,\n    \"#options\" : {\"metadata\": True},\n    \"#results\" : \"https://sizebooru.com/Picture/283342\",\n    \"#sha1_content\": \"ae8bcbe95d58ba8ed4f33fe017088c9ec0f09515\",\n\n    \"approver\"     : \"Mr_Red\",\n    \"artist\"       : None,\n    \"date\"         : \"dt:2025-07-30 00:00:00\",\n    \"date_approved\": \"dt:2025-08-01 00:00:00\",\n    \"extension\"    : \"jpg\",\n    \"file_url\"     : \"https://sizebooru.com/Picture/283342\",\n    \"filename\"     : \"Gnlib9eaMAAXtfQ\",\n    \"id\"           : 283342,\n    \"source\"       : \"https://x.com/kashmimo/status/1907664168381255942\",\n    \"uploader\"     : \"Shadow_Blaze_23\",\n    \"views\"        : range(200, 900),\n    \"favorite\"     : [\n        \"GTSfan295\",\n        \"Zephyr\",\n        \"HeroDjango\",\n    ],\n    \"tags\"         : [\n        \"drawing\",\n        \"giantess\",\n        \"pokemon\",\n        \"blushing\",\n        \"black_hair\",\n        \"color\",\n        \"long_hair\",\n        \"sweat\",\n        \"parody\",\n        \"shrunken_man\",\n        \"hat\",\n        \"orange_hair\",\n        \"looking_at_tiny\",\n        \"leaf_(pokemon)\",\n        \"kashmimo\",\n    ],\n},\n\n{\n    \"#url\"     : \"https://sizebooru.com/Details/2\",\n    \"#class\"   : sizebooru.SizebooruPostExtractor,\n    \"#options\" : {\"metadata\": True},\n    \"#results\" : \"https://sizebooru.com/Picture/2\",\n\n    \"approver\"     : \"Giantessbooru\",\n    \"artist\"       : None,\n    \"date\"         : \"dt:2010-11-26 00:00:00\",\n    \"date_approved\": \"dt:2010-11-26 00:00:00\",\n    \"extension\"    : \"jpg\",\n    \"file_url\"     : \"https://sizebooru.com/Picture/2\",\n    \"filename\"     : \"10000 - tagme\",\n    \"id\"           : 2,\n    \"source\"       : None,\n    \"uploader\"     : \"Giantess-7of9\",\n    \"views\"        : range(40, 200),\n    \"favorite\"     : list,\n    \"tags\"         : [\n        \"breasts\",\n        \"gentle\",\n        \"nude\",\n        \"black_hair\",\n        \"long_hair\",\n        \"brunette\",\n        \"hand\",\n        \"shrunken_man\",\n        \"indoors\",\n        \"digital_render\",\n    ],\n},\n\n{\n    \"#url\"     : \"https://sizebooru.com/Details/283318\",\n    \"#class\"   : sizebooru.SizebooruPostExtractor,\n    \"#options\" : {\"metadata\": True},\n    \"#results\" : \"https://sizebooru.com/Picture/283318\",\n\n    \"approver\"     : \"Mr_Red\",\n    \"artist\"       : \"megamaliit\",\n    \"date\"         : \"dt:2025-07-26 00:00:00\",\n    \"date_approved\": \"dt:2025-07-26 00:00:00\",\n    \"extension\"    : \"png\",\n    \"file_url\"     : \"https://sizebooru.com/Picture/283318\",\n    \"filename\"     : \"big babes of bed rock\",\n    \"id\"           : 283318,\n    \"source\"       : \"https://www.deviantart.com/megamaliit/art/Big-Babes-of-Bed-Rock-AT-845335093\",\n    \"uploader\"     : \"Mr_Red\",\n    \"views\"        : int,\n    \"favorite\"     : list,\n    \"tags\"         : list,\n},\n\n{\n    \"#url\"     : \"https://sizebooru.com/Search/parody\",\n    \"#category\": (\"booru\", \"sizebooru\", \"tag\"),\n    \"#class\"   : sizebooru.SizebooruTagExtractor,\n    \"#pattern\" : r\"https://sizebooru\\.com/Picture/\\d+\",\n    \"#count\"   : range(200, 300),\n\n    \"id\"         : int,\n    \"filename\"   : r\"re:\\d+\",\n    \"extension\"  : {\"jpg\", \"png\"},\n    \"file_url\"   : r\"re:https://stizebooru.com/Picture/\\d+\",\n    \"search_tags\": \"parody\",\n},\n\n{\n    \"#url\"     : \"https://sizebooru.com/Galleries/List/7\",\n    \"#category\": (\"booru\", \"sizebooru\", \"gallery\"),\n    \"#class\"   : sizebooru.SizebooruGalleryExtractor,\n    \"#pattern\" : r\"https://sizebooru\\.com/Picture/\\d+\",\n    \"#count\"   : 103,\n\n    \"gallery_id\"  : 7,\n    \"gallery_name\": \"lilipucien's work\",\n},\n\n{\n    \"#url\"     : \"https://sizebooru.com/Profile/Uploads/hueyriley\",\n    \"#category\": (\"booru\", \"sizebooru\", \"user\"),\n    \"#class\"   : sizebooru.SizebooruUserExtractor,\n    \"#count\"   : 0,\n},\n\n{\n    \"#url\"     : \"https://sizebooru.com/Profile/Uploads/GtsXxx\",\n    \"#category\": (\"booru\", \"sizebooru\", \"user\"),\n    \"#class\"   : sizebooru.SizebooruUserExtractor,\n    \"#pattern\" : r\"https://sizebooru\\.com/Picture/\\d+\",\n    \"#count\"   : 256,\n\n    \"user\"     : \"GtsXxx\",\n},\n\n{\n    \"#url\"     : \"https://sizebooru.com/Profile/Favorites/GtsXxx\",\n    \"#category\": (\"booru\", \"sizebooru\", \"favorite\"),\n    \"#class\"   : sizebooru.SizebooruFavoriteExtractor,\n    \"#results\" : (\n        \"https://sizebooru.com/Picture/266778\",\n        \"https://sizebooru.com/Picture/266385\",\n        \"https://sizebooru.com/Picture/266243\",\n        \"https://sizebooru.com/Picture/265039\",\n    ),\n\n    \"user\"     : \"GtsXxx\",\n},\n\n)\n"
  },
  {
    "path": "test/results/skeb.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import skeb\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://skeb.jp/@kanade_cocotte/works/38\",\n    \"#class\"   : skeb.SkebPostExtractor,\n    \"#count\"   : 2,\n\n    \"anonymous\"       : False,\n    \"body\"            : r\"re:はじめまして。私はYouTubeにてVTuberとして活動をしている湊ラ\",\n    \"count\"           : 2,\n    \"num\"             : range(1, 2),\n    \"client\"          : {\n        \"avatar_url\" : r\"re:https://pbs.twimg.com/profile_images/\\d+/\\w+\\.jpg\",\n        \"header_url\" : None,\n        \"id\"         : 1196514,\n        \"name\"       : str,\n        \"screen_name\": \"minato_ragi\",\n    },\n    \"content_category\": \"preview\",\n    \"creator\"         : {\n        \"avatar_url\" : \"https://pbs.twimg.com/profile_images/1225470417063645184/P8_SiB0V.jpg\",\n        \"header_url\" : \"https://pbs.twimg.com/profile_banners/71243217/1647958329/1500x500\",\n        \"id\"         : 159273,\n        \"name\"       : \"イチノセ奏\",\n        \"screen_name\": \"kanade_cocotte\",\n    },\n    \"file_id\"         : int,\n    \"file_url\"        : str,\n    \"genre\"           : \"art\",\n    \"nsfw\"            : False,\n    \"original\"        : {\n        \"byte_size\" : int,\n        \"duration\"  : None,\n        \"extension\" : {\"psd\", \"png\"},\n        \"frame_rate\": None,\n        \"height\"    : 3727,\n        \"is_movie\"  : False,\n        \"width\"     : 2810,\n    },\n    \"post_num\"        : \"38\",\n    \"post_url\"        : \"https://skeb.jp/@kanade_cocotte/works/38\",\n    \"source_body\"     : None,\n    \"source_thanks\"   : None,\n    \"tags\"            : list,\n    \"thanks\"          : None,\n    \"translated_body\" : False,\n    \"translated_thanks\": None,\n},\n\n{\n    \"#url\"     : \"https://skeb.jp/@kanade_cocotte\",\n    \"#class\"   : skeb.SkebUserExtractor,\n    \"#results\" : (\n        \"https://skeb.jp/@kanade_cocotte/works\",\n    ),\n},\n\n{\n    \"#url\"     : \"https://skeb.jp/@kanade_cocotte\",\n    \"#class\"   : skeb.SkebUserExtractor,\n    \"#options\" : {\"include\": \"all\"},\n    \"#results\" : (\n        \"https://skeb.jp/@kanade_cocotte/works\",\n        \"https://skeb.jp/@kanade_cocotte/sentrequests\",\n    ),\n},\n\n{\n    \"#url\"     : \"https://skeb.jp/@kanade_cocotte\",\n    \"#class\"   : skeb.SkebUserExtractor,\n    \"#options\" : {\"sent-requests\": True},\n    \"#results\" : (\n        \"https://skeb.jp/@kanade_cocotte/works\",\n        \"https://skeb.jp/@kanade_cocotte/sentrequests\",\n    ),\n},\n\n{\n    \"#url\"     : \"https://skeb.jp/@kanade_cocotte/works\",\n    \"#class\"   : skeb.SkebWorksExtractor,\n    \"#pattern\" : r\"https://si\\.imgix\\.net/\\w+/uploads/origins/[\\w-]+\",\n    \"#range\"   : \"1-5\",\n\n    \"count\": int,\n    \"num\"  : int,\n},\n\n{\n    \"#url\"     : \"https://skeb.jp/@kanade_cocotte/works\",\n    \"#class\"   : skeb.SkebWorksExtractor,\n    \"#pattern\" : r\"https://si\\.imgix\\.net/\\w+/uploads/origins/[\\w-]+\",\n    \"#range\"   : \"1-5\",\n\n    \"count\": int,\n    \"num\"  : int,\n},\n\n{\n    \"#url\"     : \"https://skeb.jp/@kanade_cocotte/sent-requests\",\n    \"#class\"   : skeb.SkebSentrequestsExtractor,\n},\n\n{\n    \"#url\"     : \"https://skeb.jp/@4ra_su4/sentrequests\",\n    \"#class\"   : skeb.SkebSentrequestsExtractor,\n    \"#pattern\" : (\n        r\"https://si.imgix.net/4e44b668/uploads/origins/e42cbd8e-44af-4aaa-a11b-6a174f42202c\\?bg=%23fff&auto=format&fm=webp&w=800&s=\\w+\",\n        r\"https://si.imgix.net/4d30e75e/uploads/origins/6d3bb612-3f45-4d8e-9d31-49dceb3dab11\\?bg=%23fff&auto=format&fm=webp&w=800&s=\\w+\",\n    ),\n\n    \"anonymous\"       : False,\n    \"body\"            : \"\"\"\\\nリクエスト失礼致します。\nうちの子の福良ことりちゃん（https://twitter.com/sousaku_suru/status/1404393369564946432）（https://twitter.com/sousaku_suru/status/1523336440062820354）がナース衣装のコスプレをしている作品をご依頼したいです！コス衣装にカチューシャについているクローバーが反映されていると嬉しいです。ご検討よろしくお願い致します！\n\nhttps://twitter.com/sousaku_suru/status/1404393369564946432\\\n\"\"\",\n    \"content_category\": \"preview\",\n    \"count\"           : 2,\n    \"extension\"       : \"\",\n    \"file_id\"         : {950467, 950468},\n    \"file_url\"        : r\"re:https://si.imgix.net/.+\",\n    \"filename\"        : str,\n    \"genre\"           : \"art\",\n    \"nsfw\"            : False,\n    \"num\"             : range(1, 2),\n    \"post_id\"         : 802511,\n    \"post_num\"        : \"2\",\n    \"post_url\"        : \"https://skeb.jp/@okonimi_hyu/works/2\",\n    \"source_body\"     : None,\n    \"source_thanks\"   : None,\n    \"thanks\"          : None,\n    \"translated_body\" : False,\n    \"translated_thanks\": None,\n    \"tags\"            : [\n        \"よろしく\",\n        \"お願い\",\n        \"作品\",\n        \"嬉しい\",\n        \"うちの子\",\n        \"コスプレ\",\n        \"カチューシャ\",\n        \"ナース\",\n        \"クローバー\",\n        \"ことりちゃん\",\n    ],\n    \"client\"          : {\n        \"avatar_url\" : \"https://pbs.twimg.com/profile_images/1916152385107632128/pygB7-jf.jpg\",\n        \"header_url\" : \"https://pbs.twimg.com/profile_banners/1134460426006159360/1717082866/1500x500\",\n        \"id\"         : 2017625,\n        \"name\"       : \"しろえ\",\n        \"screen_name\": \"4ra_su4\",\n    },\n    \"creator\"         : {\n        \"avatar_url\" : \"https://pbs.twimg.com/profile_images/1943287378149543937/EaUIMtnM.jpg\",\n        \"header_url\" : \"https://pbs.twimg.com/profile_banners/2931377426/1523678757/1500x500\",\n        \"id\"         : 341737,\n        \"name\"       : \"Hyu@はゆ〜\",\n        \"screen_name\": \"okonimi_hyu\",\n    },\n    \"original\"        : {\n        \"byte_size\" : {18463023, 793631},\n        \"duration\"  : None,\n        \"extension\" : {\"psd\", \"png\"},\n        \"frame_rate\": None,\n        \"height\"    : 1754,\n        \"is_movie\"  : False,\n        \"software\"  : None,\n        \"transcoder\": \"image\",\n        \"width\"     : 1275,\n    },\n},\n\n{\n    \"#url\"     : \"https://skeb.jp/search?q=bunny%20tree&t=works\",\n    \"#class\"   : skeb.SkebSearchExtractor,\n    \"#count\"   : \">= 18\",\n\n    \"search_tags\": \"bunny tree\",\n},\n\n{\n    \"#url\"     : \"https://skeb.jp/@user/following_creators\",\n    \"#class\"   : skeb.SkebFollowingExtractor,\n},\n\n{\n    \"#url\"     : \"https://skeb.jp/following_users\",\n    \"#class\"   : skeb.SkebFollowingUsersExtractor,\n    \"#pattern\" : skeb.SkebUserExtractor.pattern,\n    \"#auth\"    : True,\n},\n\n)\n"
  },
  {
    "path": "test/results/slickpic.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import slickpic\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://mattcrandall.slickpic.com/albums/LamborghiniMurcielago/\",\n    \"#category\": (\"\", \"slickpic\", \"album\"),\n    \"#class\"   : slickpic.SlickpicAlbumExtractor,\n    \"#pattern\"      : r\"https://stored-cf\\.slickpic\\.com/NDk5MjNmYTc1MzU0MQ,,/20160807/\\w+/p/o/JSBFSS-\\d+\\.jpg\",\n    \"#count\"        : 102,\n    \"#sha1_metadata\": \"c37c4ce9c54c09abc6abdf295855d46f11529cbf\",\n},\n\n{\n    \"#url\"     : \"https://mattcrandall.slickpic.com/albums/LamborghiniMurcielago/\",\n    \"#category\": (\"\", \"slickpic\", \"album\"),\n    \"#class\"   : slickpic.SlickpicAlbumExtractor,\n    \"#range\"       : \"34\",\n    \"#sha1_content\": [\n        \"276eb2c902187bb177ae8013e310e1d6641fba9a\",\n        \"52b5a310587de1048030ab13a912f6a3a9cc7dab\",\n        \"cec6630e659dc72db1ee1a9a6f3b525189261988\",\n        \"6f81e1e74c6cd6db36844e7211eef8e7cd30055d\",\n        \"22e83645fc242bc3584eca7ec982c8a53a4d8a44\",\n    ],\n},\n\n{\n    \"#url\"     : \"https://mattcrandall.slickpic.com/gallery/\",\n    \"#category\": (\"\", \"slickpic\", \"user\"),\n    \"#class\"   : slickpic.SlickpicUserExtractor,\n    \"#pattern\" : slickpic.SlickpicAlbumExtractor.pattern,\n    \"#count\"   : \">= 358\",\n},\n\n{\n    \"#url\"     : \"https://mattcrandall.slickpic.com/\",\n    \"#category\": (\"\", \"slickpic\", \"user\"),\n    \"#class\"   : slickpic.SlickpicUserExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/slideshare.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import slideshare\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://www.slideshare.net/Slideshare/get-started-with-slide-share\",\n    \"#category\": (\"\", \"slideshare\", \"presentation\"),\n    \"#class\"   : slideshare.SlidesharePresentationExtractor,\n    \"#pattern\"     : r\"https://image\\.slidesharecdn\\.com/getstartedwithslideshare-150520173821-lva1-app6892/95/Getting-Started-With-SlideShare-\\d+-1024\\.jpg\",\n    \"#count\"       : 19,\n    \"#sha1_content\": \"2b6a191eab60b3978fdacfecf2da302dd45bc108\",\n\n    \"description\" : \"SlideShare is a global platform for sharing presentations, infographics, videos and documents. It has over 18 million pieces of professional content uploaded by experts like Eric Schmidt and Guy Kawasaki. The document provides tips for setting up an account on SlideShare, uploading content, optimizing it for searchability, and sharing it on social media to build an audience and reputation as a subject matter expert.\",\n    \"likes\"       : int,\n    \"presentation\": \"get-started-with-slide-share\",\n    \"date\"        : \"dt:2015-05-20 17:38:21\",\n    \"title\"       : \"Getting Started With SlideShare\",\n    \"user\"        : \"Slideshare\",\n    \"views\"       : int,\n},\n\n{\n    \"#url\"     : \"https://www.slideshare.net/pragmaticsolutions/warum-sie-nicht-ihren-mitarbeitenden-ndern-sollten-sondern-ihr-managementsystem\",\n    \"#comment\" : \"long title and description\",\n    \"#category\": (\"\", \"slideshare\", \"presentation\"),\n    \"#class\"   : slideshare.SlidesharePresentationExtractor,\n    \"#sha1_url\": \"c2d0079cc3b05de0fd93b0d0b1f47ff2a32119b7\",\n\n    \"title\"      : \"Warum Sie nicht Ihren Mitarbeitenden ändern sollten, sondern Ihr Managementsystem\",\n    \"description\": \"Mitarbeitende verhalten sich mehrheitlich so, wie das System es ihnen vorgibt. Welche Voraussetzungen es braucht, damit Ihre Mitarbeitenden ihr ganzes Herzblut einsetzen, bespricht Fredi Schmidli in diesem Referat.\",\n},\n\n{\n    \"#url\"     : \"https://www.slideshare.net/mobile/uqudent/introduction-to-fixed-prosthodontics\",\n    \"#comment\" : \"mobile URL\",\n    \"#category\": (\"\", \"slideshare\", \"presentation\"),\n    \"#class\"   : slideshare.SlidesharePresentationExtractor,\n    \"#pattern\" : r\"https://image\\.slidesharecdn\\.com/introductiontofixedprosthodonticsfinal-110427200948-phpapp02/95/Introduction-to-fixed-prosthodontics-\\d+-1024\\.jpg\",\n    \"#count\"   : 27,\n},\n\n)\n"
  },
  {
    "path": "test/results/smugloli.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import vichan\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://smuglo.li/a/res/1143245.html\",\n    \"#category\": (\"vichan\", \"smugloli\", \"thread\"),\n    \"#class\"   : vichan.VichanThreadExtractor,\n    \"#pattern\" : r\"https://smug.+/a/src/\\d+(-\\d)?\\.\\w+\",\n    \"#count\"   : \">= 50\",\n\n    \"board\" : \"a\",\n    \"thread\": \"1143245\",\n    \"title\": \"Rabbit Rabbit Thread #4\",\n},\n\n{\n    \"#url\"     : \"https://smugloli.net/a/res/1145409.html\",\n    \"#category\": (\"vichan\", \"smugloli\", \"thread\"),\n    \"#class\"   : vichan.VichanThreadExtractor,\n},\n\n{\n    \"#url\"     : \"https://smuglo.li/a\",\n    \"#category\": (\"vichan\", \"smugloli\", \"board\"),\n    \"#class\"   : vichan.VichanBoardExtractor,\n    \"#pattern\" : vichan.VichanThreadExtractor.pattern,\n    \"#count\"   : \">= 100\",\n},\n\n{\n    \"#url\"     : \"https://smuglo.li/a/1.html\",\n    \"#category\": (\"vichan\", \"smugloli\", \"board\"),\n    \"#class\"   : vichan.VichanBoardExtractor,\n},\n\n{\n    \"#url\"     : \"https://smugloli.net/cute/catalog.html\",\n    \"#category\": (\"vichan\", \"smugloli\", \"board\"),\n    \"#class\"   : vichan.VichanBoardExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/smugmug.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import smugmug\n\n\n__tests__ = (\n{\n    \"#url\"     : \"smugmug:album:cr4C7f\",\n    \"#category\": (\"\", \"smugmug\", \"album\"),\n    \"#class\"   : smugmug.SmugmugAlbumExtractor,\n    \"#results\": (\n        \"https://photos.smugmug.com/Nature/Dove/i-XvZFJFG/0/DMk7cm6qRBSFPvQgT9C4t4jtBJKF7JSK9jszgHZnr/O/Dual%20Suicide_20070721-DSC_4804.jpg\",\n        \"https://photos.smugmug.com/Nature/Dove/i-2wVPqHf/0/DBXmTSTqVWzTLZxL3JPVK7hGT9zp8tzsFdhtWm68v/O/Morning%20Dove2_20070621-DSC_3222.jpg\",\n        \"https://photos.smugmug.com/Nature/Dove/i-QHFnmb8/0/GKLvnm7zFQWX2G2VcJRprx8WZqTfFJkn8C5nRnCk/O/Speed%20Skater_03082008_POR7728.jpg\",\n        \"https://photos.smugmug.com/Nature/Dove/i-MXQZKws/0/D6XCS9xnncDVtZ9NtVq66ZK9xjL4D2H9KSbpFMjfM/O/Airing%20it%20Out0_5142008_DSC_8166.jpg\",\n        \"https://photos.smugmug.com/Nature/Dove/i-kCsLJT6/0/FfB6gSx8X6MS7Hvww7GK7tWsrfdtwCx79hCVzwSm/O/Fluff_20090521-_DSC1542.jpg\",\n        \"https://photos.smugmug.com/Nature/Dove/i-T9Qv5Pm/0/CFT4MB9hg7rKwWmbFhGQTCnmxdpnGBKPDbHTPLSgV/O/D2F_D300_20090827-_TDM5650.jpg\",\n    ),\n},\n\n{\n    \"#url\"     : \"smugmug:album:Fb7hMs\",\n    \"#comment\" : \"empty\",\n    \"#category\": (\"\", \"smugmug\", \"album\"),\n    \"#class\"   : smugmug.SmugmugAlbumExtractor,\n    \"#count\"   : 0,\n},\n\n{\n    \"#url\"     : \"smugmug:album:6VRT8G\",\n    \"#comment\" : \"no 'User'\",\n    \"#category\": (\"\", \"smugmug\", \"album\"),\n    \"#class\"   : smugmug.SmugmugAlbumExtractor,\n    \"#sha1_url\": \"17837ff2c78a6e2335291666f43d620d82f2926a\",\n\n    \"User\": {\n        \"Name\"         : \"\",\n        \"NickName\"     : \"\",\n        \"QuickShare\"   : False,\n        \"RefTag\"       : \"\",\n        \"ResponseLevel\": \"Public\",\n        \"Uri\"          : \"\",\n        \"ViewPassHint\" : \"\",\n        \"WebUri\"       : \"\",\n    },\n},\n\n{\n    \"#url\"     : \"https://tdm.smugmug.com/Nature/Dove/i-kCsLJT6\",\n    \"#category\": (\"\", \"smugmug\", \"image\"),\n    \"#class\"   : smugmug.SmugmugImageExtractor,\n    \"#results\"     : \"https://photos.smugmug.com/Nature/Dove/i-kCsLJT6/0/FfB6gSx8X6MS7Hvww7GK7tWsrfdtwCx79hCVzwSm/O/Fluff_20090521-_DSC1542.jpg\",\n    \"#sha1_content\": \"ecbd9d7b4f75a637abc8d35319be9ec065a44eb0\",\n\n    \"Image\": {\n        \"Altitude\"   : 0,\n        \"CanBuy\"     : True,\n        \"CanEdit\"    : False,\n        \"CanShare\"   : True,\n        \"Caption\"    : \"White Wing Dove\",\n        \"Collectable\": False,\n        \"Comments\"   : True,\n        \"ComponentFileTypes\": [],\n        \"Date\"       : \"2009-08-01T23:00:56+00:00\",\n        \"DateTimeOriginal\": \"2009-05-22T00:05:36+00:00\",\n        \"DateTimeUploaded\": \"2009-08-01T23:00:56+00:00\",\n        \"EZProject\"  : False,\n        \"FileName\"   : \"Fluff_20090521-_DSC1542.jpg\",\n        \"Format\"     : \"JPG\",\n        \"FormattedValues\": {\n            \"Caption\": {\n                \"html\": \"White Wing Dove\",\n                \"text\": \"White Wing Dove\",\n            },\n            \"FileName\": {\n                \"html\": \"Fluff_20090521-_DSC1542.jpg\",\n                \"text\": \"Fluff_20090521-_DSC1542.jpg\",\n            },\n        },\n        \"Height\"     : 1008,\n        \"Hidden\"     : False,\n        \"ImageKey\"   : \"kCsLJT6\",\n        \"IsArchive\"  : False,\n        \"IsVideo\"    : False,\n        \"KeywordArray\": [\n            \"Birds\",\n            \"Dove\",\n            \"White Wing Dove\",\n        ],\n        \"Keywords\"   : \"Birds; Dove; White Wing Dove\",\n        \"LastUpdated\": \"2012-11-03T20:01:15+00:00\",\n        \"Latitude\"   : \"0\",\n        \"Longitude\"  : \"0\",\n        \"OriginalHeight\": 1008,\n        \"OriginalSize\": 381297,\n        \"OriginalWidth\": 1024,\n        \"PreferredDisplayFileExtension\": \"JPG\",\n        \"Processing\" : False,\n        \"Protected\"  : True,\n        \"Serial\"     : 0,\n        \"ShowKeywords\": True,\n        \"Size\"       : 381297,\n        \"Status\"     : \"Open\",\n        \"SubStatus\"  : \"NFS\",\n        \"ThumbnailUrl\": \"https://photos.smugmug.com/Nature/Dove/i-kCsLJT6/0/Df2nQXwHWSmmh4W2CjhJJdxDcZWbhkKTG86JXp9x2/Th/Fluff_20090521-_DSC1542-Th.jpg\",\n        \"Title\"      : \"\",\n        \"UploadKey\"  : \"608043804\",\n        \"Uri\"        : \"/api/v2/image/kCsLJT6-0\",\n        \"Url\"        : \"https://photos.smugmug.com/Nature/Dove/i-kCsLJT6/0/FfB6gSx8X6MS7Hvww7GK7tWsrfdtwCx79hCVzwSm/O/Fluff_20090521-_DSC1542.jpg\",\n        \"Watermark\"  : \"No\",\n        \"Watermarked\": False,\n        \"Width\"      : 1024,\n    },\n    \"extension\": \"jpg\",\n    \"filename\": \"Fluff_20090521-_DSC1542\",\n},\n\n{\n    \"#url\"     : \"https://tstravels.smugmug.com/Dailies/Daily-Dose-2015/i-39JFNzB\",\n    \"#comment\" : \"video\",\n    \"#category\": (\"\", \"smugmug\", \"image\"),\n    \"#class\"   : smugmug.SmugmugImageExtractor,\n    \"#results\"      : \"https://photos.smugmug.com/Dailies/Daily-Dose-2015/i-39JFNzB/0/Q4Qg6kt4SqVcKsSLWM4PnhMhSTS2r5BkmBMd9Dx4/1920/657%20WS3-1920.mp4\",\n},\n\n{\n    \"#url\"     : \"https://tdm.smugmug.com/Nature/Dove\",\n    \"#category\": (\"\", \"smugmug\", \"path\"),\n    \"#class\"   : smugmug.SmugmugPathExtractor,\n    \"#pattern\" : \"smugmug:album:cr4C7f$\",\n},\n\n{\n    \"#url\"     : \"https://tdm.smugmug.com/\",\n    \"#category\": (\"\", \"smugmug\", \"path\"),\n    \"#class\"   : smugmug.SmugmugPathExtractor,\n    \"#pattern\" : smugmug.SmugmugAlbumExtractor.pattern,\n    \"#sha1_url\": \"1640028712875b90974e5aecd91b60e6de6138c7\",\n},\n\n{\n    \"#url\"     : \"https://www.smugmug.com/gallery/n-GLCjnD/\",\n    \"#comment\" : \"gallery node without owner\",\n    \"#category\": (\"\", \"smugmug\", \"path\"),\n    \"#class\"   : smugmug.SmugmugPathExtractor,\n    \"#pattern\" : \"smugmug:album:6VRT8G$\",\n},\n\n{\n    \"#url\"     : \"smugmug:www.sitkapics.com/TREES-and-TRAILS/\",\n    \"#comment\" : \"custom domain\",\n    \"#category\": (\"\", \"smugmug\", \"path\"),\n    \"#class\"   : smugmug.SmugmugPathExtractor,\n    \"#pattern\" : \"smugmug:album:ct8Nds$\",\n},\n\n{\n    \"#url\"     : \"smugmug:www.sitkapics.com/\",\n    \"#category\": (\"\", \"smugmug\", \"path\"),\n    \"#class\"   : smugmug.SmugmugPathExtractor,\n    \"#pattern\" : r\"smugmug:album:\\w{6}$\",\n    \"#count\"   : \">= 14\",\n},\n\n{\n    \"#url\"     : \"smugmug:https://www.sitkapics.com/\",\n    \"#category\": (\"\", \"smugmug\", \"path\"),\n    \"#class\"   : smugmug.SmugmugPathExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/snootbooru.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import szurubooru\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://snootbooru.com/posts/query=sport\",\n    \"#category\": (\"szurubooru\", \"snootbooru\", \"tag\"),\n    \"#class\"   : szurubooru.SzurubooruTagExtractor,\n    \"#pattern\" : r\"https://snootbooru\\.com/data/posts/\\d+_[0-9a-f]{16}\\.\\w+\",\n    \"#count\"   : range(100, 300),\n},\n\n{\n    \"#url\"     : \"https://snootbooru.com/post/14511\",\n    \"#category\": (\"szurubooru\", \"snootbooru\", \"post\"),\n    \"#class\"   : szurubooru.SzurubooruPostExtractor,\n    \"#results\"     : \"https://snootbooru.com/data/posts/14511_e753313112755da6.png\",\n    \"#sha1_content\": \"e69e61e61c5372514808480aae3a8e355c9cd6fb\",\n\n    \"canvasHeight\" : 1000,\n    \"canvasWidth\"  : 1414,\n    \"checksum\"     : \"e69e61e61c5372514808480aae3a8e355c9cd6fb\",\n    \"checksumMD5\"  : \"f4f4ddfcbdf367f466ede0980acb3d7d\",\n    \"commentCount\" : int,\n    \"comments\"     : list,\n    \"contentUrl\"   : \"data/posts/14511_e753313112755da6.png\",\n    \"creationTime\" : \"2023-12-02T01:11:01.433664Z\",\n    \"date\"         : \"dt:2023-12-02 01:11:01\",\n    \"extension\"    : \"png\",\n    \"favoriteCount\": int,\n    \"favoritedBy\"  : list,\n    \"featureCount\" : int,\n    \"fileSize\"     : 270639,\n    \"filename\"     : \"14511_e753313112755da6\",\n    \"flags\"        : [],\n    \"hasCustomThumbnail\": False,\n    \"id\"           : 14511,\n    \"lastEditTime\" : \"2023-12-02T01:12:09.500217Z\",\n    \"lastFeatureTime\": None,\n    \"mimeType\"     : \"image/png\",\n    \"noteCount\"    : 0,\n    \"notes\"        : [],\n    \"ownFavorite\"  : False,\n    \"ownScore\"     : 0,\n    \"pools\"        : [],\n    \"relationCount\": 0,\n    \"relations\"    : [],\n    \"safety\"       : \"safe\",\n    \"score\"        : range(1, 10),\n    \"source\"       : None,\n    \"tagCount\"     : 3,\n    \"tags\"         : [\n        \"transparent\",\n        \"sport\",\n        \"text\",\n    ],\n    \"tags_default\" : [\n        \"sport\",\n        \"text\"\n    ],\n    \"thumbnailUrl\" : \"data/generated-thumbnails/14511_e753313112755da6.jpg\",\n    \"type\"         : \"image\",\n    \"user\"         : {\n        \"avatarUrl\": \"data/avatars/komp.png\",\n        \"name\": \"komp\"\n    },\n    \"version\"      : 2,\n},\n\n)\n"
  },
  {
    "path": "test/results/socialmediagirlsforum.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import xenforo\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://forums.socialmediagirls.com/threads/clara-jenyfer-clarajenyferr.482246/post-4743566\",\n    \"#category\": (\"xenforo\", \"socialmediagirlsforum\", \"post\"),\n    \"#class\"   : xenforo.XenforoPostExtractor,\n    \"#auth\"    : True,\n    \"#results\" : \"https://pixeldrain.com/u/zHNNuNqF\",\n\n    \"count\"       : 1,\n    \"num\"         : 1,\n    \"num_external\": 1,\n    \"num_internal\": 0,\n    \"type\"        : \"external\",\n    \"post\"        : {\n        \"attachments\": \"\",\n        \"author\"     : \"gat0deb0tas\",\n        \"author_id\"  : \"4665384\",\n        \"author_slug\": \"gat0deb0tas\",\n        \"author_url\" : \"https://forums.socialmediagirls.com/members/gat0deb0tas.4665384/\",\n        \"count\"      : 1,\n        \"date\"       : \"dt:2025-09-03 22:15:05\",\n        \"id\"         : \"4743566\",\n        \"content\"    : str,\n    },\n    \"thread\"      : {\n        \"author\"     : \"Arrascaeta_14\",\n        \"author_id\"  : \"4736433\",\n        \"author_slug\": \"arrascaeta_14\",\n        \"author_url\" : \"https://forums.socialmediagirls.com/members/arrascaeta_14.4736433/\",\n        \"date\"       : \"dt:2025-09-02 17:33:45\",\n        \"id\"         : \"482246\",\n        \"posts\"      : range(11, 50),\n        \"section\"    : \"Instagram Models\",\n        \"tags\"       : (),\n        \"title\"      : \"Clara jenyfer - clarajenyferr\",\n        \"url\"        : \"https://forums.socialmediagirls.com/threads/clara-jenyfer-clarajenyferr.482246/\",\n        \"views\"      : range(14_000, 80_000),\n    },\n},\n\n{\n    \"#url\"     : \"https://forums.socialmediagirls.com/threads/nilce-moretto.64148/post-2803132\",\n    \"#comment\" : \"imgur s9e media embed iframe (#9127)\",\n    \"#category\": (\"xenforo\", \"socialmediagirlsforum\", \"post\"),\n    \"#class\"   : xenforo.XenforoPostExtractor,\n    \"#auth\"    : True,\n    \"#results\" : \"https://imgur.com/a/TluZbDn\",\n},\n\n{\n    \"#url\"     : \"https://forums.socialmediagirls.com/threads/casallr.435949/unread\",\n    \"#category\": (\"xenforo\", \"socialmediagirlsforum\", \"thread\"),\n    \"#class\"   : xenforo.XenforoThreadExtractor,\n    \"#auth\"    : True,\n    \"#pattern\" : r\"https://(pixeldrain\\.com/|gofile\\.io|\\w+\\.erome\\.com)\",\n    \"#count\"   : 13,\n\n    \"count\"       : int,\n    \"type\"        : \"external\",\n    \"post\"        : dict,\n    \"thread\"      : {\n        \"author\"     : \"Deleted member 379830\",\n        \"author_id\"  : \"379830\",\n        \"author_slug\": \"deleted-member-379830\",\n        \"author_url\" : \"https://forums.socialmediagirls.com/members/deleted-member-379830.379830/\",\n        \"date\"       : \"dt:2025-01-24 17:49:20\",\n        \"id\"         : \"435949\",\n        \"posts\"      : 3,\n        \"section\"    : \"Instagram Models\",\n        \"title\"      : \"Casallr\",\n        \"url\"        : \"https://forums.socialmediagirls.com/threads/casallr.435949/\",\n        \"views\"      : range(10_000, 50_000),\n        \"tags\"       : [\n            \"amador\",\n            \"casal\",\n            \"safada\",\n            \"swing\",\n        ],\n    },\n},\n\n{\n    \"#url\"     : \"https://forums.socialmediagirls.com/forums/bikini-girls.80/\",\n    \"#category\": (\"xenforo\", \"socialmediagirlsforum\", \"forum\"),\n    \"#class\"   : xenforo.XenforoForumExtractor,\n    \"#pattern\" : xenforo.XenforoThreadExtractor.pattern,\n    \"#auth\"    : True,\n    \"#range\"   : \"1-50\",\n    \"#count\"   : 50,\n},\n\n)\n"
  },
  {
    "path": "test/results/soundgasm.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import soundgasm\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://soundgasm.net/u/ClassWarAndPuppies2/687-Otto-von-Toontown-12822\",\n    \"#category\": (\"\", \"soundgasm\", \"audio\"),\n    \"#class\"   : soundgasm.SoundgasmAudioExtractor,\n    \"#pattern\" : r\"https://media\\.soundgasm\\.net/sounds/26cb2b23b2f2c6094b40ee3a9167271e274b570a\\.m4a\",\n\n    \"description\": \"We celebrate today’s important prisoner swap, and finally bring the 2022 mid-terms to a close with Raphael Warnock’s defeat of Herschel Walker in Georgia. Then, we take a look at the Qanon-addled attempt to overthrow the German government and install Heinrich XIII Prince of Reuss as kaiser.\",\n    \"extension\"  : \"m4a\",\n    \"filename\"   : \"26cb2b23b2f2c6094b40ee3a9167271e274b570a\",\n    \"slug\"       : \"687-Otto-von-Toontown-12822\",\n    \"title\"      : \"687 - Otto von Toontown (12/8/22)\",\n    \"user\"       : \"ClassWarAndPuppies2\",\n},\n\n{\n    \"#url\"     : \"https://www.soundgasm.net/user/ClassWarAndPuppies2/687-Otto-von-Toontown-12822\",\n    \"#category\": (\"\", \"soundgasm\", \"audio\"),\n    \"#class\"   : soundgasm.SoundgasmAudioExtractor,\n},\n\n{\n    \"#url\"     : \"https://soundgasm.net/u/fierce-aphrodite\",\n    \"#category\": (\"\", \"soundgasm\", \"user\"),\n    \"#class\"   : soundgasm.SoundgasmUserExtractor,\n    \"#pattern\" : r\"https://media\\.soundgasm\\.net/sounds/[0-9a-f]{40}\\.m4a\",\n    \"#count\"   : \">= 15\",\n\n    \"description\": str,\n    \"extension\"  : \"m4a\",\n    \"filename\"   : r\"re:^[0-9a-f]{40}$\",\n    \"slug\"       : str,\n    \"title\"      : str,\n    \"url\"        : str,\n    \"user\"       : \"fierce-aphrodite\",\n},\n\n)\n"
  },
  {
    "path": "test/results/soybooru.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import shimmie2\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://soybooru.com/post/view/142188\",\n    \"#category\": (\"shimmie2\", \"soybooru\", \"post\"),\n    \"#class\"   : shimmie2.Shimmie2PostExtractor,\n    \"#results\" : \"https://soybooru.com/_images/79a229384727558c651816e4c30c9b4d/142188%20-%20SoyBooru.png\",\n\n    \"extension\": \"png\",\n    \"file_url\" : \"https://soybooru.com/_images/79a229384727558c651816e4c30c9b4d/142188%20-%20SoyBooru.png\",\n    \"filename\" : \"142188 - SoyBooru\",\n    \"height\"   : 600,\n    \"id\"       : 142188,\n    \"md5\"      : \"79a229384727558c651816e4c30c9b4d\",\n    \"size\"     : 0,\n    \"tags\"     : \"body clothes dark dark_room glasses hair ominous shadow subvariant:pol_face template variant:chudjak white_shirt white_skin\",\n    \"width\"    : 600,\n},\n\n{\n    \"#url\"     : \"https://soybooru.com/post/list/dark_room/1\",\n    \"#category\": (\"shimmie2\", \"soybooru\", \"tag\"),\n    \"#class\"   : shimmie2.Shimmie2TagExtractor,\n    \"#pattern\" : r\"https://soybooru.com/_images/\\w{32}/\\d+.+\\.(jpe?g|png|gif|mp4|webm)\",\n    \"#count\"   : range(16, 24),\n\n    \"extension\"  : str,\n    \"file_url\"   : str,\n    \"filename\"   : str,\n    \"height\"     : int,\n    \"id\"         : int,\n    \"md5\"        : \"hash:md5\",\n    \"search_tags\": \"dark_room\",\n    \"size\"       : int,\n    \"tags\"       : str,\n    \"width\"      : int,\n},\n\n)\n"
  },
  {
    "path": "test/results/speakerdeck.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import speakerdeck\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://speakerdeck.com/speakerdeck/introduction-to-speakerdeck\",\n    \"#category\": (\"\", \"speakerdeck\", \"presentation\"),\n    \"#class\"   : speakerdeck.SpeakerdeckPresentationExtractor,\n    \"#pattern\"     : r\"https://files.speakerdeck.com/presentations/50021f75cf1db900020005e7/slide_\\d+.jpg\",\n    \"#count\"       : 6,\n    \"#sha1_content\": \"75c7abf0969b0bcab23e0da9712c95ee5113db3a\",\n\n    \"author\"         : \"Speaker Deck\",\n    \"count\"          : 6,\n    \"num\"            : range(1, 6),\n    \"presentation\"   : \"introduction-to-speakerdeck\",\n    \"presentation_id\": \"50021f75cf1db900020005e7\",\n    \"title\"          : \"Introduction to SpeakerDeck\",\n    \"user\"           : \"speakerdeck\",\n},\n\n)\n"
  },
  {
    "path": "test/results/steamgriddb.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import steamgriddb\nfrom gallery_dl import exception\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://www.steamgriddb.com/grid/368023\",\n    \"#comment\" : \"deleted\",\n    \"#category\": (\"\", \"steamgriddb\", \"asset\"),\n    \"#class\"   : steamgriddb.SteamgriddbAssetExtractor,\n    \"#exception\": exception.NotFoundError,\n},\n\n{\n    \"#url\"     : \"https://www.steamgriddb.com/grid/132605\",\n    \"#category\": (\"\", \"steamgriddb\", \"asset\"),\n    \"#class\"   : steamgriddb.SteamgriddbAssetExtractor,\n    \"#count\"   : 2,\n    \"#sha1_url\"    : \"4ff9158c008a1f01921d7553bcabf5e6204cdc79\",\n    \"#sha1_content\": \"bc16c5eebf71463abdb33cfbf4b45a2fe092a2b2\",\n\n    \"game\": {\n        \"id\"  : 5247997,\n        \"name\": \"OMORI\",\n    },\n},\n\n{\n    \"#url\"     : \"https://www.steamgriddb.com/grid/132605\",\n    \"#category\": (\"\", \"steamgriddb\", \"asset\"),\n    \"#class\"   : steamgriddb.SteamgriddbAssetExtractor,\n    \"#options\" : {\"download-fake-png\": False},\n    \"#count\"   : 1,\n    \"#sha1_url\"    : \"f6819c593ff65f15864796fb89581f05d21adddb\",\n    \"#sha1_content\": \"0d9e6114dd8bb9699182fbb7c6bd9064d8b0b6cd\",\n\n    \"game\": {\n        \"id\"  : 5247997,\n        \"name\": \"OMORI\",\n    },\n},\n\n{\n    \"#url\"     : \"https://www.steamgriddb.com/hero/61104\",\n    \"#category\": (\"\", \"steamgriddb\", \"asset\"),\n    \"#class\"   : steamgriddb.SteamgriddbAssetExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.steamgriddb.com/logo/9610\",\n    \"#category\": (\"\", \"steamgriddb\", \"asset\"),\n    \"#class\"   : steamgriddb.SteamgriddbAssetExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.steamgriddb.com/icon/173\",\n    \"#category\": (\"\", \"steamgriddb\", \"asset\"),\n    \"#class\"   : steamgriddb.SteamgriddbAssetExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.steamgriddb.com/game/5259324/grids\",\n    \"#category\": (\"\", \"steamgriddb\", \"grids\"),\n    \"#class\"   : steamgriddb.SteamgriddbGridsExtractor,\n    \"#range\"   : \"1-10\",\n    \"#count\"   : 10,\n},\n\n{\n    \"#url\"     : \"https://www.steamgriddb.com/game/5259324/grids\",\n    \"#category\": (\"\", \"steamgriddb\", \"grids\"),\n    \"#class\"   : steamgriddb.SteamgriddbGridsExtractor,\n    \"#options\" : {\"humor\": False, \"epilepsy\": False, \"untagged\": False},\n    \"#range\"   : \"1-30\",\n    \"#count\"   : range(1, 30),\n},\n\n{\n    \"#url\"     : \"https://www.steamgriddb.com/game/5331605/heroes\",\n    \"#category\": (\"\", \"steamgriddb\", \"heroes\"),\n    \"#class\"   : steamgriddb.SteamgriddbHeroesExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.steamgriddb.com/game/5255394/logos\",\n    \"#category\": (\"\", \"steamgriddb\", \"logos\"),\n    \"#class\"   : steamgriddb.SteamgriddbLogosExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.steamgriddb.com/game/5279790/icons\",\n    \"#category\": (\"\", \"steamgriddb\", \"icons\"),\n    \"#class\"   : steamgriddb.SteamgriddbIconsExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.steamgriddb.com/collection/332/grids\",\n    \"#category\": (\"\", \"steamgriddb\", \"grids\"),\n    \"#class\"   : steamgriddb.SteamgriddbGridsExtractor,\n    \"#range\"   : \"1-10\",\n    \"#count\"   : 10,\n},\n\n{\n    \"#url\"     : \"https://www.steamgriddb.com/collection/332/heroes\",\n    \"#category\": (\"\", \"steamgriddb\", \"heroes\"),\n    \"#class\"   : steamgriddb.SteamgriddbHeroesExtractor,\n    \"#options\" : {\"animated\": False},\n    \"#count\"   : 0,\n},\n\n)\n"
  },
  {
    "path": "test/results/sturdychan.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\ngallery_dl = __import__(\"gallery_dl.extractor.2chen\")\n_2chen = getattr(gallery_dl.extractor, \"2chen\")\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://sturdychan.help/tv/268929\",\n    \"#category\": (\"2chen\", \"sturdychan\", \"thread\"),\n    \"#class\"   : _2chen._2chenThreadExtractor,\n    \"#pattern\" : r\"https://sturdychan\\.help/assets/images/src/\\w{40}\\.\\w+$\",\n    \"#count\"   : \">= 179\",\n\n    \"board\" : \"tv\",\n    \"date\"  : \"type:datetime\",\n    \"hash\"  : r\"re:[0-9a-f]{40}\",\n    \"name\"  : \"Anonymous\",\n    \"no\"    : r\"re:\\d+\",\n    \"thread\": \"268929\",\n    \"time\"  : int,\n    \"title\" : \"「/ttg/ #118: 🇧🇷 edition」\",\n    \"url\"   : str,\n},\n\n{\n    \"#url\"     : \"https://2chen.club/tv/1\",\n    \"#category\": (\"2chen\", \"sturdychan\", \"thread\"),\n    \"#class\"   : _2chen._2chenThreadExtractor,\n},\n\n{\n    \"#url\"     : \"https://2chen.moe/jp/303786\",\n    \"#category\": (\"2chen\", \"sturdychan\", \"thread\"),\n    \"#class\"   : _2chen._2chenThreadExtractor,\n},\n\n{\n    \"#url\"     : \"https://sturdychan.help/co/\",\n    \"#category\": (\"2chen\", \"sturdychan\", \"board\"),\n    \"#class\"   : _2chen._2chenBoardExtractor,\n    \"#pattern\" : _2chen._2chenThreadExtractor.pattern,\n},\n\n{\n    \"#url\"     : \"https://2chen.moe/co\",\n    \"#category\": (\"2chen\", \"sturdychan\", \"board\"),\n    \"#class\"   : _2chen._2chenBoardExtractor,\n},\n\n{\n    \"#url\"     : \"https://2chen.club/tv\",\n    \"#category\": (\"2chen\", \"sturdychan\", \"board\"),\n    \"#class\"   : _2chen._2chenBoardExtractor,\n},\n\n{\n    \"#url\"     : \"https://2chen.moe/co/catalog\",\n    \"#category\": (\"2chen\", \"sturdychan\", \"board\"),\n    \"#class\"   : _2chen._2chenBoardExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/subscribestar.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import subscribestar\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://www.subscribestar.com/subscribestar\",\n    \"#category\": (\"\", \"subscribestar\", \"user\"),\n    \"#class\"   : subscribestar.SubscribestarUserExtractor,\n    \"#pattern\" : r\"https://(www\\.subscribestar\\.com/uploads\\?payload=.+|(ss-uploads-prod\\.b-cdn|\\w+\\.cloudfront)\\.net/uploads(_v2)?/users/11/)\",\n    \"#count\"   : range(20, 50),\n\n    \"author_id\"  : 11,\n    \"author_name\": \"subscribestar\",\n    \"author_nick\": \"SubscribeStar\",\n    \"content\"    : str,\n    \"date\"       : \"type:datetime\",\n    \"id\"         : int,\n    \"num\"        : int,\n    \"post_id\"    : int,\n    \"tags\"       : list,\n    \"title\"      : str,\n    \"type\"       : r\"re:image|video|attachment\",\n    \"url\"        : str,\n    \"?pinned\"    : bool,\n},\n\n{\n    \"#url\"     : \"https://www.subscribestar.com/subscribestar\",\n    \"#category\": (\"\", \"subscribestar\", \"user\"),\n    \"#class\"   : subscribestar.SubscribestarUserExtractor,\n    \"#options\" : {\"metadata\": True},\n    \"#range\"   : \"1\",\n\n    \"date\": \"type:datetime\",\n},\n\n{\n    \"#url\"     : \"https://www.subscribestar.com/subscribestar?tag=Security\",\n    \"#comment\" : \"'tag' query parameter (#8737)\",\n    \"#class\"   : subscribestar.SubscribestarUserExtractor,\n    \"#count\"   : 0,\n    \"#metadata\": \"post\",\n\n    \"author_id\"  : 11,\n    \"author_name\": \"subscribestar\",\n    \"author_nick\": \"SubscribeStar\",\n    \"content\"    : \"\\n<h1>Enhance Your Account Security with OTP</h1>\\n<div>In addition to our existing email-based Two-Factor Authentication (2FA), we encourage everyone to use a more secure and convenient method: One-Time Password (OTP) 2FA using authentication apps like 1Password, Google Authenticator etc. To get started:</div>\\n<ol>\\n<li>Navigate to <strong>Menu → Account Settings</strong>, scroll down to the Authenticator Apps (OTP 2FA) section.</li>\\n<li>Click the \\\"<strong>Set up OTP</strong>\\\" button and follow the instructions.<br><br>\\n</li>\\n</ol>\\n<div>The entire process should take less than 5 minutes. You can opt out of using email 2FA then.</div>\\n<div><br></div>\\n<div><strong>Why Choose OTP 2FA Over Email 2FA?</strong></div>\\n<div>\\n<strong>Stronger Security</strong>: While email 2FA adds an extra layer of protection, OTP 2FA generates codes directly on your mobile device, reducing the risk associated with email interception or unauthorized access.</div>\\n<div>\\n<strong>Instant Access</strong>: Authentication apps provide time-sensitive codes without the need for an internet connection or waiting for an email to arrive.</div>\\n<div>\\n<strong>Enhanced Protection Against Phishing</strong>: OTP codes from authentication apps are less susceptible to phishing attacks compared to email-based codes.</div>\\n\\n\",\n    \"date\"       : \"dt:2024-09-30 20:46:00\",\n    \"post_id\"    : 1320999,\n    \"search_tags\": \"Security\",\n    \"title\"      : \"Enhance Your Account Security with OTP\",\n    \"tags\"       : [\n        \"Security\",\n        \"PlatformUpdates\",\n    ],\n},\n\n{\n    \"#url\"     : \"https://subscribestar.adult/kanashiipanda\",\n    \"#category\": (\"\", \"subscribestar\", \"user-adult\"),\n    \"#class\"   : subscribestar.SubscribestarUserExtractor,\n    \"#auth\"    : True,\n    \"#range\"   : \"1-10\",\n    \"#count\"   : 10,\n},\n\n{\n    \"#url\"     : \"https://www.subscribestar.com/posts/102468\",\n    \"#category\": (\"\", \"subscribestar\", \"post\"),\n    \"#class\"   : subscribestar.SubscribestarPostExtractor,\n    \"#count\"   : 1,\n\n    \"author_id\"  : 11,\n    \"author_name\": \"subscribestar\",\n    \"author_nick\": \"SubscribeStar\",\n    \"content\"    : r\"re:<h1>Brand Guidelines and Assets</h1>\",\n    \"date\"       : \"dt:2020-05-07 12:33:00\",\n    \"extension\"  : \"jpg\",\n    \"filename\"   : \"ss_page-brand\",\n    \"group\"      : \"imgs_and_videos\",\n    \"height\"     : 291,\n    \"id\"         : 203885,\n    \"num\"        : 1,\n    \"pinned\"     : False,\n    \"post_id\"    : 102468,\n    \"tags\"       : [],\n    \"title\"      : \"Brand Guidelines and Assets\",\n    \"type\"       : \"image\",\n    \"width\"      : 700,\n},\n\n{\n    \"#url\"     : \"https://www.subscribestar.com/posts/920015\",\n    \"#comment\" : \"attachment (#6721)\",\n    \"#category\": (\"\", \"subscribestar\", \"post\"),\n    \"#class\"   : subscribestar.SubscribestarPostExtractor,\n    \"#range\"   : \"2\",\n    \"#pattern\" : r\"https://\\w+.cloudfront.net/uploads_v2/users/11/posts/920015/bc018a55-9668-47f4-a664-b5fd66b56aaa.pdf\\?filename=Training%2520for%2520freelancers%2520-%2520Fiverr.pdf&.+\",\n    \"date\"     : \"dt:2023-05-30 09:20:00\",\n    \"extension\": \"pdf\",\n    \"filename\" : \"Training for freelancers - Fiverr\",\n    \"id\"       : 1957727,\n    \"name\"     : \"Training for freelancers - Fiverr.pdf\",\n    \"num\"      : 2,\n    \"post_id\"  : 920015,\n    \"title\"    : \"\",\n    \"type\"     : \"attachment\",\n},\n\n{\n    \"#url\"     : \"https://www.subscribestar.com/posts/1851025\",\n    \"#comment\" : \"content / title not inside <body> (#7486)\",\n    \"#category\": (\"\", \"subscribestar\", \"post\"),\n    \"#class\"   : subscribestar.SubscribestarPostExtractor,\n\n    \"author_id\"  : 581352,\n    \"author_name\": \"inelia-benz\",\n    \"author_nick\": \"Inelia Benz\",\n    \"content\"    : \"<h1>Listening to Sasquatch - Driving to the Rez - Episode 243 - Part One</h1>\\n\\n<p>Topics we cover:</p>\\n\\n<p>Tree breaks, Foot stomps, Tracks and trackways, Hoots/calls with answers, \\nTree structures, nests, Portal Cracks, Shapeshifting, Shimmer/invisibility \\ncloaking, direct physical interaction inside the cloaking field, manipulation \\nof canoe while we are in it, face to face interactions with multiple individuals \\nteen aged and adult, male and female, cloaked and not cloaked, \\nand vocalizations like drops of water. Truly amazing stories.</p>\\n\\n<p><a href=\\\"https://www.subscribestar.com/posts/1853792\\\" data-href=\\\"https://www.subscribestar.com/posts/1853792\\\">Go To Part Two</a></p>\\n\\n<p><a href=\\\"/away?url=aHR0cHM6Ly92aWRlby5pbmVsaWFiZW56LmNvbS9saXN0ZW5pbmctdG8tc2Fz%0AcXVhdGNoLWRyaXZpbmctdG8tdGhlLXJlei1lcGlzb2RlLTI0My1wYXJ0LW9u%0AZQ==%0A\\\" data-href=\\\"https://video.ineliabenz.com/listening-to-sasquatch-driving-to-the-rez-episode-243-part-one\\\">Watch the Video</a></p>\\n\\n<p><a href=\\\"/away?url=aHR0cHM6Ly9pbmVsaWEuc3Vic3RhY2suY29tL3AvbGlzdGVuaW5nLXRvLXNh%0Ac3F1YXRjaA==%0A\\\" data-href=\\\"https://inelia.substack.com/p/listening-to-sasquatch\\\">Read the article</a></p>\\n\\n<p>Audio is attached to this post.</p>\",\n    \"date\"       : \"dt:2025-05-07 13:23:00\",\n    \"extension\"  : {\"mp3\", \"jpg\"},\n    \"filename\"   : {\"dttr-243-sasquatch-part1\", \"yt-243-pt1\"},\n    \"id\"         : {0, 4627253},\n    \"num\"        : range(1, 2),\n    \"post_id\"    : 1851025,\n    \"tags\"       : [],\n    \"title\"      : \"Listening to Sasquatch - Driving to the Rez - Episode 243 - Part One\",\n    \"type\"       : {\"audio\", \"image\"},\n},\n\n{\n    \"#url\"     : \"https://subscribestar.adult/posts/22950\",\n    \"#category\": (\"\", \"subscribestar\", \"post-adult\"),\n    \"#class\"   : subscribestar.SubscribestarPostExtractor,\n    \"#auth\"    : True,\n    \"#count\"   : 1,\n\n    \"date\": \"dt:2019-04-28 07:32:00\",\n},\n\n)\n"
  },
  {
    "path": "test/results/sushiski.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import misskey\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://sushi.ski/@ui@misskey.04.si\",\n    \"#category\": (\"misskey\", \"sushi.ski\", \"user\"),\n    \"#class\"   : misskey.MisskeyUserExtractor,\n},\n\n{\n    \"#url\"     : \"https://sushi.ski/@hatusimo_sigure/following\",\n    \"#category\": (\"misskey\", \"sushi.ski\", \"following\"),\n    \"#class\"   : misskey.MisskeyFollowingExtractor,\n},\n\n{\n    \"#url\"     : \"https://sushi.ski/notes/9bm3x4ksqw\",\n    \"#category\": (\"misskey\", \"sushi.ski\", \"note\"),\n    \"#class\"   : misskey.MisskeyNoteExtractor,\n    \"#pattern\" : r\"https://media\\.sushi\\.ski/files/[\\w-]+\\.png\",\n    \"#count\"   : 1,\n},\n\n{\n    \"#url\"     : \"https://sushi.ski/my/favorites\",\n    \"#category\": (\"misskey\", \"sushi.ski\", \"favorite\"),\n    \"#class\"   : misskey.MisskeyFavoriteExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/sxypix.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import sxypix\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://sxypix.com/w/c42ec529474789f812dd8ba26103855f\",\n    \"#category\": (\"\", \"sxypix\", \"gallery\"),\n    \"#class\"   : sxypix.SxypixGalleryExtractor,\n    \"#count\"   : 35,\n\n    \"count\"     : 35,\n    \"num\"       : range(1, 35),\n    \"filename\"  : \"hash:md5\",\n    \"extension\" : \"webp\",\n    \"gallery_id\": \"c42ec529474789f812dd8ba26103855f\",\n    \"title\"     : \"#MommysBoy - Penny Barber - Coddling Her Boy\",\n},\n\n)\n"
  },
  {
    "path": "test/results/tapas.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import tapas\nfrom gallery_dl import exception\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://tapas.io/series/just-leave-me-be\",\n    \"#class\"   : tapas.TapasSeriesExtractor,\n    \"#pattern\" : tapas.TapasEpisodeExtractor.pattern,\n    \"#count\"   : 128,\n},\n\n{\n    \"#url\"     : \"https://tapas.io/series/yona\",\n    \"#comment\" : \"mature\",\n    \"#class\"   : tapas.TapasSeriesExtractor,\n    \"#pattern\" : tapas.TapasEpisodeExtractor.pattern,\n    \"#count\"   : 17,\n},\n\n{\n    \"#url\"     : \"https://tapas.io/episode/2068651\",\n    \"#comment\" : \"book / html\",\n    \"#class\"   : tapas.TapasEpisodeExtractor,\n    \"#pattern\" : \"^text:\",\n    \"#sha1_url\": \"0b53644c864a0a097f65accea6bb620be9671078\",\n\n    \"book\"            : True,\n    \"comment_cnt\"     : int,\n    \"date\"            : \"dt:2021-02-23 16:02:07\",\n    \"early_access\"    : False,\n    \"escape_title\"    : \"You are a Tomb Raider (2)\",\n    \"free\"            : True,\n    \"id\"              : 2068651,\n    \"like_cnt\"        : int,\n    \"liked\"           : bool,\n    \"mature\"          : False,\n    \"next_ep_id\"      : 2068652,\n    \"nsfw\"            : False,\n    \"nu\"              : False,\n    \"num\"             : 1,\n    \"open_comments\"   : True,\n    \"pending_scene\"   : 2,\n    \"prev_ep_id\"      : 2068650,\n    \"publish_date\"    : \"2021-02-23T16:02:07Z\",\n    \"read\"            : bool,\n    \"related_ep_id\"   : None,\n    \"relative_publish_date\": \"Feb 23, 2021\",\n    \"scene\"           : 2,\n    \"scheduled\"       : False,\n    \"title\"           : \"You are a Tomb Raider (2)\",\n    \"unlock_cnt\"      : 0,\n    \"unlocked\"        : False,\n    \"view_cnt\"        : int,\n    \"series\"          : {\n        \"genre\"         : dict,\n        \"has_book_cover\": True,\n        \"has_top_banner\": True,\n        \"id\"            : 199931,\n        \"premium\"       : True,\n        \"sale_type\"     : \"WAIT_OR_MUST_PAY\",\n        \"subscribed\"    : bool,\n        \"thumbsup_cnt\"  : int,\n        \"title\"         : \"Tomb Raider King\",\n        \"type\"          : \"BOOKS\",\n        \"url\"           : \"tomb-raider-king-novel\",\n    },\n},\n\n{\n    \"#url\"     : \"https://tapas.io/episode/2717154\",\n    \"#comment\" : \"comic / jpg\",\n    \"#class\"   : tapas.TapasEpisodeExtractor,\n    \"#pattern\" : r\"https://us-a\\.tapas\\.io/pc/\\w\\w/[0-9a-f-]{36}(-\\d)?\\.jpg\\?__token__=.+\",\n    \"#count\"   : 63,\n\n    \"book\"         : False,\n    \"closing\"      : None,\n    \"closing_date\" : None,\n    \"comment_cnt\"  : range(330, 500),\n    \"date\"         : \"dt:2023-01-13 19:00:00\",\n    \"early_access\" : False,\n    \"escape_title\" : \"Episode 3\",\n    \"extension\"    : \"jpg\",\n    \"filename\"     : str,\n    \"free\"         : True,\n    \"free_access\"  : False,\n    \"has_bgm\"      : False,\n    \"id\"           : 2717154,\n    \"like_cnt\"     : range(7000, 20000),\n    \"liked\"        : False,\n    \"mature\"       : False,\n    \"must_pay\"     : False,\n    \"next_ep_id\"   : 2717157,\n    \"nsfw\"         : False,\n    \"nu\"           : False,\n    \"num\"          : range(1, 63),\n    \"open_comments\": True,\n    \"pending_scene\": 3,\n    \"prev_ep_id\"   : 2717153,\n    \"publish_date\" : \"2023-01-13T19:00:00Z\",\n    \"read\"         : False,\n    \"related_ep_id\": None,\n    \"relative_publish_date\": \"Jan 13, 2023\",\n    \"scene\"        : 3,\n    \"scheduled\"    : False,\n    \"thumb_url\"    : \"https://us-a.tapas.io/sa/f9/a42d3c2f-d642-4481-9583-1d3bacbd302b.png\",\n    \"title\"        : \"Episode 3\",\n    \"unlock_cnt\"   : 0,\n    \"unlocked\"     : False,\n    \"view_cnt\"     : range(70000, 200000),\n    \"series\"       : {\n        \"badges\"        : list,\n        \"book\"          : False,\n        \"comic\"         : True,\n        \"discount_rate\" : int,\n        \"escape_title\"  : \"The Doctor Is Out\",\n        \"genre\"         : dict,\n        \"has_book_cover\": True,\n        \"has_top_banner\": True,\n        \"id\"            : 251031,\n        \"item_type\"     : None,\n        \"on_sale_now\"   : False,\n        \"premium\"       : True,\n        \"sale_type\"     : \"WAIT_OR_MUST_PAY\",\n        \"subscribed\"    : False,\n        \"thumb_url\"     : \"https://us-a.tapas.io/sa/32/7e1daa84-8951-4a53-9574-48ca7d0b0aca_m.jpg\",\n        \"thumbsup_cnt\"  : range(240000, 500000),\n        \"title\"         : \"The Doctor Is Out\",\n        \"type\"          : \"COMICS\",\n        \"url\"           : \"the-doctor-is-out\",\n    },\n},\n\n{\n    \"#url\"      : \"https://tapas.io/episode/2717157\",\n    \"#comment\"  : \"locked\",\n    \"#class\"    : tapas.TapasEpisodeExtractor,\n    \"#exception\": exception.AuthorizationError,\n},\n\n{\n    \"#url\"     : \"https://tapas.io/SANG123/series\",\n    \"#comment\" : \"#5306\",\n    \"#class\"   : tapas.TapasCreatorExtractor,\n    \"#results\" : (\n        \"https://tapas.io/series/the-return-of-the-disaster-class-hero\",\n        \"https://tapas.io/series/the-return-of-the-disaster-class-hero-novel\",\n        \"https://tapas.io/series/tomb-raider-king\",\n        \"https://tapas.io/series/tomb-raider-king-novel\",\n    ),\n},\n\n)\n"
  },
  {
    "path": "test/results/tbib.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import gelbooru_v02\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://tbib.org/index.php?page=post&s=list&tags=yuyaiyaui\",\n    \"#category\": (\"gelbooru_v02\", \"tbib\", \"tag\"),\n    \"#class\"   : gelbooru_v02.GelbooruV02TagExtractor,\n    \"#count\"   : \">= 120\",\n},\n\n{\n    \"#url\"     : \"https://tbib.org/index.php?page=favorites&s=view&id=7881\",\n    \"#category\": (\"gelbooru_v02\", \"tbib\", \"favorite\"),\n    \"#class\"   : gelbooru_v02.GelbooruV02FavoriteExtractor,\n    \"#count\"   : 3,\n},\n\n{\n    \"#url\"     : \"https://tbib.org/index.php?page=post&s=view&id=9233957\",\n    \"#category\": (\"gelbooru_v02\", \"tbib\", \"post\"),\n    \"#class\"   : gelbooru_v02.GelbooruV02PostExtractor,\n    \"#sha1_url\"    : \"5a6ebe07bfff8e6d27f7c30b5480f27abcb577d2\",\n    \"#sha1_content\": \"1c3831b6fbaa4686e3c79035b5d98460b1c85c43\",\n},\n\n)\n"
  },
  {
    "path": "test/results/tcbscans.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import tcbscans\nfrom gallery_dl import exception\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://tcbscans.me/chapters/4708/chainsaw-man-chapter-108\",\n    \"#category\": (\"\", \"tcbscans\", \"chapter\"),\n    \"#class\"   : tcbscans.TcbscansChapterExtractor,\n    \"#pattern\" : r\"https://cdn\\.[^/]+/(file|attachments/[^/]+)/[^/]+/[^.]+\\.\\w+\",\n    \"#count\"   : 17,\n\n    \"manga\"        : \"Chainsaw Man\",\n    \"chapter\"      : 108,\n    \"chapter_minor\": \"\",\n    \"lang\"         : \"en\",\n    \"language\"     : \"English\",\n},\n\n{\n    \"#url\"     : \"https://onepiecechapters.com/chapters/4716/one-piece-chapter-1065\",\n    \"#category\": (\"\", \"tcbscans\", \"chapter\"),\n    \"#class\"   : tcbscans.TcbscansChapterExtractor,\n    \"#pattern\" : r\"https://cdn\\.[^/]+/(file|attachments/[^/]+)/[^/]+/[^.]+\\.\\w+\",\n    \"#count\"   : 18,\n\n    \"manga\"        : \"One Piece\",\n    \"chapter\"      : 1065,\n    \"chapter_minor\": \"\",\n    \"lang\"         : \"en\",\n    \"language\"     : \"English\",\n    \"#exception\"   : exception.HttpError,\n},\n\n{\n    \"#url\"     : \"https://onepiecechapters.com/chapters/44/ace-novel-manga-adaptation-chapter-1\",\n    \"#category\": (\"\", \"tcbscans\", \"chapter\"),\n    \"#class\"   : tcbscans.TcbscansChapterExtractor,\n    \"#exception\": exception.HttpError,\n},\n\n{\n    \"#url\"     : \"https://tcbscans.me/chapters/7719/jujutsu-kaisen-chapter-258\",\n    \"#category\": (\"\", \"tcbscans\", \"chapter\"),\n    \"#class\"   : tcbscans.TcbscansChapterExtractor,\n    \"#pattern\" : r\"https://cdn\\.[^/]+/(file|attachments/[^/]+)/[^/]+/[^.]+\\.\\w+\",\n    \"#count\"   : 15,\n\n    \"manga\"        : \"Jujutsu Kaisen\",\n    \"chapter\"      : 258,\n    \"chapter_minor\": \"\",\n    \"lang\"         : \"en\",\n    \"language\"     : \"English\",\n},\n\n{\n    \"#url\"     : \"https://tcbscans.me/mangas/13/chainsaw-man\",\n    \"#category\": (\"\", \"tcbscans\", \"manga\"),\n    \"#class\"   : tcbscans.TcbscansMangaExtractor,\n    \"#pattern\" : tcbscans.TcbscansChapterExtractor.pattern,\n    \"#range\"   : \"1-50\",\n    \"#count\"   : 50,\n},\n\n{\n    \"#url\"     : \"https://onepiecechapters.com/mangas/4/jujutsu-kaisen\",\n    \"#category\": (\"\", \"tcbscans\", \"manga\"),\n    \"#class\"   : tcbscans.TcbscansMangaExtractor,\n    \"#pattern\" : tcbscans.TcbscansChapterExtractor.pattern,\n    \"#range\"   : \"1-50\",\n    \"#count\"   : 50,\n    \"#exception\": exception.HttpError,\n},\n\n{\n    \"#url\"     : \"https://onepiecechapters.com/mangas/15/hunter-x-hunter\",\n    \"#category\": (\"\", \"tcbscans\", \"manga\"),\n    \"#class\"   : tcbscans.TcbscansMangaExtractor,\n    \"#exception\": exception.HttpError,\n},\n\n{\n    \"#url\"     : \"https://tcbscans.com/mangas/4/jujutsu-kaisen\",\n    \"#class\"   : tcbscans.TcbscansMangaExtractor,\n},\n\n{\n    \"#url\"     : \"https://tcb-backup.bihar-mirchi.com/chapters/7719/jujutsu-kaisen-chapter-258\",\n    \"#class\"   : tcbscans.TcbscansChapterExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/tco.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import urlshortener\nfrom gallery_dl import exception\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://t.co/bCgBY8Iv5n\",\n    \"#category\": (\"urlshortener\", \"tco\", \"link\"),\n    \"#class\"   : urlshortener.UrlshortenerLinkExtractor,\n    \"#pattern\" : \"^https://twitter.com/elonmusk/status/1421395561324896257/photo/1\",\n    \"#count\"   : 1,\n},\n\n{\n    \"#url\"     : \"https://t.co/abcdefghij\",\n    \"#category\": (\"urlshortener\", \"tco\", \"link\"),\n    \"#class\"   : urlshortener.UrlshortenerLinkExtractor,\n    \"#exception\": exception.NotFoundError,\n},\n\n)\n"
  },
  {
    "path": "test/results/telegraph.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import telegraph\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://telegra.ph/Telegraph-Test-03-28\",\n    \"#category\": (\"\", \"telegraph\", \"gallery\"),\n    \"#class\"   : telegraph.TelegraphGalleryExtractor,\n    \"#pattern\" : r\"https://telegra\\.ph/file/[0-9a-f]+\\.png\",\n\n    \"author\"     : \"mikf\",\n    \"caption\"    : r\"re:test|\",\n    \"count\"      : 2,\n    \"date\"       : \"dt:2022-03-28 16:01:36\",\n    \"description\": \"Just a test\",\n    \"post_url\"   : \"https://telegra.ph/Telegraph-Test-03-28\",\n    \"slug\"       : \"Telegraph-Test-03-28\",\n    \"title\"      : \"Telegra.ph Test\",\n},\n\n{\n    \"#url\"     : \"https://telegra.ph/森-03-28\",\n    \"#category\": (\"\", \"telegraph\", \"gallery\"),\n    \"#class\"   : telegraph.TelegraphGalleryExtractor,\n    \"#pattern\" : \"https://telegra.ph/file/3ea79d23b0dd0889f215a.jpg\",\n    \"#count\"   : 1,\n\n    \"author\"       : \"&\",\n    \"caption\"      : \"kokiri\",\n    \"count\"        : 1,\n    \"date\"         : \"dt:2022-03-28 16:31:26\",\n    \"description\"  : \"コキリの森\",\n    \"extension\"    : \"jpg\",\n    \"filename\"     : \"3ea79d23b0dd0889f215a\",\n    \"num\"          : 1,\n    \"num_formatted\": \"1\",\n    \"post_url\"     : \"https://telegra.ph/森-03-28\",\n    \"slug\"         : \"森-03-28\",\n    \"title\"        : \"\\\"森\\\"\",\n    \"url\"          : \"https://telegra.ph/file/3ea79d23b0dd0889f215a.jpg\",\n},\n\n{\n    \"#url\"     : \"https://telegra.ph/Vsyo-o-druzyah-moej-sestricy-05-27\",\n    \"#category\": (\"\", \"telegraph\", \"gallery\"),\n    \"#class\"   : telegraph.TelegraphGalleryExtractor,\n    \"#pattern\" : r\"^https://pith1\\.ru/uploads/posts/2019-12/\\d+_\\d+\\.jpg$\",\n    \"#sha1_url\": \"c1f3048e5d94bee53af30a8c27f70b0d3b15438e\",\n\n    \"author\"       : \"Shotacon - заходи сюда\",\n    \"caption\"      : \"\",\n    \"count\"        : 19,\n    \"date\"         : \"dt:2022-05-27 16:17:27\",\n    \"description\"  : \"\",\n    \"num_formatted\": r\"re:^\\d{2}$\",\n    \"post_url\"     : \"https://telegra.ph/Vsyo-o-druzyah-moej-sestricy-05-27\",\n    \"slug\"         : \"Vsyo-o-druzyah-moej-sestricy-05-27\",\n    \"title\"        : \"Всё о друзьях моей сестрицы\",\n},\n\n{\n    \"#url\"     : \"https://telegra.ph/Disharmonica---Saber-Nero-02-21\",\n    \"#category\": (\"\", \"telegraph\", \"gallery\"),\n    \"#class\"   : telegraph.TelegraphGalleryExtractor,\n    \"#pattern\" : r\"https://telegra\\.ph/file/[0-9a-f]+\\.(jpg|png)\",\n\n    \"author\"       : \"cosmos\",\n    \"caption\"      : \"\",\n    \"count\"        : 89,\n    \"date\"         : \"dt:2022-02-21 05:57:39\",\n    \"description\"  : \"\",\n    \"num_formatted\": r\"re:^\\d{2}$\",\n    \"post_url\"     : \"https://telegra.ph/Disharmonica---Saber-Nero-02-21\",\n    \"slug\"         : \"Disharmonica---Saber-Nero-02-21\",\n    \"title\"        : \"Disharmonica - Saber Nero\",\n},\n\n)\n"
  },
  {
    "path": "test/results/tenor.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import tenor\n\n\n__tests__ = (\n{\n    \"#url\"  : \"https://tenor.com/view/moving-gif-8525772382434057283\",\n    \"#class\": tenor.TenorImageExtractor,\n    \"#results\": \"https://media1.tenor.com/m/dlGgz3LRXEMAAAAC/moving.gif\",\n\n    \"bg_color\" : \"\",\n    \"description\": \"an illustration of a tree with green leaves\",\n    \"created\"  : 1687512768.687436,\n    \"date\"     : \"dt:2023-06-23 09:32:48\",\n    \"embed\"    : r\"re:<div class=.+\",\n    \"extension\": \"gif\",\n    \"filename\" : \"moving\",\n    \"!h1_title\": \"Moving Sticker\",\n    \"hasaudio\" : False,\n    \"format\"   : \"gif\",\n    \"width\"    : 467,\n    \"height\"   : 498,\n    \"size\"     : 60157,\n    \"duration\" : 0,\n    \"id\"       : \"8525772382434057283\",\n    \"id_format\": \"dlGgz3LRXEMAAAAC\",\n    \"index\"    : 0,\n    \"itemurl\"  : \"https://tenor.com/view/moving-gif-8525772382434057283\",\n    \"long_title\": \"Moving Sticker - Moving Stickers\",\n    \"media_formats\": dict,\n    \"policy_status\": \"POLICY_STATUS_UNSPECIFIED\",\n    \"shares\"   : range(70_000, 200_000),\n    \"source_id\": \"\",\n    \"title\"    : \"Moving Sticker\",\n    \"url\"      : \"https://tenor.com/kjYh53rdMGt.gif\",\n    \"flags\"    : [\n        \"static\",\n        \"sticker\",\n    ],\n    \"legacy_info\": {\n        \"post_id\": \"200777050\"\n    },\n    \"tags\": [\n        \"moving\",\n    ],\n    \"user\": {\n        \"avatars\"     : {},\n        \"flags\"       : [],\n        \"partnerbanner\": {},\n        \"partnercategories\": [],\n        \"partnerlinks\": [],\n        \"partnername\" : \"\",\n        \"profile_id\"  : \"11989898659889539214\",\n        \"tagline\"     : \"\",\n        \"url\"         : \"https://tenor.com/users/imenabdelmalek\",\n        \"userid\"      : \"0\",\n        \"username\"    : \"imenabdelmalek\",\n        \"usertype\"    : \"user\",\n    },\n},\n\n{\n    \"#url\"    : \"https://tenor.com/view/moving-gif-8525772382434057283\",\n    \"#comment\": \"'format' option\",\n    \"#class\"  : tenor.TenorImageExtractor,\n    \"#options\": {\"format\": [\"mkv\", \"foobar\", \"webp\"]},\n    \"#results\": \"https://media.tenor.com/dlGgz3LRXEMAAAAx/moving.webp\",\n\n    \"format\"   : \"webp\",\n    \"width\"    : 468,\n    \"height\"   : 498,\n    \"size\"     : 9808,\n    \"duration\" : 0,\n    \"id\"       : \"8525772382434057283\",\n    \"id_format\": \"dlGgz3LRXEMAAAAx\",\n},\n\n{\n    \"#url\"    : \"https://tenor.com/view/vtuber-hololive-%E3%83%9B%E3%83%AD%E3%83%A9%E3%82%A4%E3%83%96-hologra-%E3%83%9B%E3%83%AD%E3%81%90%E3%82%89-gif-26058046\",\n    \"#comment\": \"non-ASCII characters in URL\",\n    \"#class\"  : tenor.TenorImageExtractor,\n    \"#results\": \"https://media1.tenor.com/m/jHugoUKy-T0AAAAC/vtuber-hololive.gif\",\n\n    \"id\": \"10122861201914526013\",\n},\n\n{\n    \"#url\"  : \"https://tenor.com/ja/view/moving-gif-8525772382434057283\",\n    \"#class\": tenor.TenorImageExtractor,\n},\n\n{\n    \"#url\"     : \"https://tenor.com/view/dance-dancing-rhythm-music-party-gif-10174070686436479501\",\n    \"#class\"   : tenor.TenorImageExtractor,\n    \"#results\" : \"https://media1.tenor.com/m/jTGPYoDC0g0AAAAC/dance-dancing.gif\",\n\n    \"bg_color\"     : \"\",\n    \"created\"      : 1761968378.53755,\n    \"date\"         : \"dt:2025-11-01 03:39:38\",\n    \"description\"  : \"a blue and white penguin with the word pengu written below it\",\n    \"duration\"     : 1.4,\n    \"embed\"        : \"\"\"<div class=\"tenor-gif-embed\" data-postid=\"10174070686436479501\" data-share-method=\"host\" data-aspect-ratio=\"1\" data-width=\"100%\"><a href=\"https://tenor.com/view/dance-dancing-rhythm-music-party-gif-10174070686436479501\">Dance Dancing Sticker</a>from <a href=\"https://tenor.com/search/dance-stickers\">Dance Stickers</a></div> <script type=\"text/javascript\" async src=\"https://tenor.com/embed.js\"></script>\"\"\",\n    \"extension\"    : \"gif\",\n    \"filename\"     : \"dance-dancing\",\n    \"flags\"        : [\"sticker\"],\n    \"format\"       : \"gif\",\n    \"hasaudio\"     : False,\n    \"height\"       : 498,\n    \"id\"           : \"10174070686436479501\",\n    \"id_format\"    : \"jTGPYoDC0g0AAAAC\",\n    \"index\"        : 0,\n    \"itemurl\"      : \"https://tenor.com/view/dance-dancing-rhythm-music-party-gif-10174070686436479501\",\n    \"legacy_info\"  : {\"post_id\": \"0\"},\n    \"long_title\"   : \"Dance Dancing Sticker - Dance Dancing Rhythm Stickers\",\n    \"policy_status\": \"POLICY_STATUS_UNSPECIFIED\",\n    \"shares\"       : range(20_000, 200_000),\n    \"size\"         : 379845,\n    \"source_id\"    : \"\",\n    \"title\"        : \"Dance Dancing Sticker\",\n    \"url\"          : \"https://tenor.com/mhJvX13UBsL.gif\",\n    \"width\"        : 498,\n    \"media_formats\": dict,\n    \"tags\"         : [\n        \"dance\",\n        \"dancing\",\n        \"rhythm\",\n        \"music\",\n        \"party\",\n        \"happy\",\n        \"meme\",\n        \"funny\",\n        \"dancer\",\n        \"cute\",\n        \"excited\",\n        \"pengu\",\n        \"pudgy\",\n        \"penguin\",\n        \"pudgypenguins\",\n    ],\n    \"user\"         : {\n        \"flags\"        : [\"partner\"],\n        \"partnername\"  : \"Pudgy Penguins\",\n        \"profile_id\"   : \"7220590138123212970\",\n        \"tagline\"      : \"Pudgy Penguins supplying the internet with good vibes\",\n        \"url\"          : \"https://tenor.com/official/PudgyPenguins\",\n        \"userid\"       : \"12680456\",\n        \"username\"     : \"PudgyPenguins\",\n        \"usertype\"     : \"partner\",\n    },\n},\n\n{\n    \"#url\"    : \"https://tenor.com/search/trees-gifs\",\n    \"#class\"  : tenor.TenorSearchExtractor,\n    \"#pattern\": r\"https://media\\d+\\.tenor\\.com/m/[\\w-]+/[\\w%-]+\\.gif\",\n    \"#range\"  : \"1-80\",\n    \"#count\"  : 80,\n\n    \"search_tags\": \"trees\",\n},\n\n{\n    \"#url\"  : \"https://tenor.com/en-GB/search/trees-gifs\",\n    \"#class\": tenor.TenorSearchExtractor,\n},\n\n{\n    \"#url\"    : \"https://tenor.com/search/trees-water-wind-sun-%3C&%3E-gifs\",\n    \"#class\"  : tenor.TenorSearchExtractor,\n    \"#pattern\": r\"https://media\\d+\\.tenor\\.com/m/[\\w-]+/[\\w%-]+\\.gif\",\n    \"#range\"  : \"1-80\",\n    \"#count\"  : 80,\n\n    \"search_tags\": \"trees water wind sun <&>\",\n},\n\n{\n    \"#url\"    : \"https://tenor.com/users/robloxfan123\",\n    \"#class\"  : tenor.TenorUserExtractor,\n    \"#results\": \"https://media1.tenor.com/m/1auSjzCikuoAAAAC/2016-roblox.gif\",\n\n    \"user\": {\n        \"profile_id\": \"8180139772821505417\",\n        \"url\"       : \"https://tenor.com/users/ROBLOXfan123\",\n        \"userid\"    : \"11206759\",\n        \"username\"  : \"ROBLOXfan123\",\n        \"usertype\"  : \"user\",\n    },\n},\n\n{\n    \"#url\"    : \"https://tenor.com/users/annetv\",\n    \"#class\"  : tenor.TenorUserExtractor,\n    \"#pattern\": r\"https://media\\d+\\.tenor\\.com/m/[\\w-]+/[\\w%-]+\\.gif\",\n    \"#count\"  : range(70, 100),\n\n    \"user\": {\n        \"profile_id\": \"14727075564983373376\",\n        \"url\"       : \"https://tenor.com/users/annetv\",\n        \"userid\"    : \"8529134\",\n        \"username\"  : \"annetv\",\n    },\n},\n\n{\n    \"#url\"  : \"https://tenor.com/official/rwrbonprime\",\n    \"#class\": tenor.TenorUserExtractor,\n    \"#range\": \"1\",\n\n    \"user\": {\n        \"avatars\"      : dict,\n        \"flags\"        : [\"partner\"],\n        \"partnerbanner\": dict,\n        \"partnercategories\": [],\n        \"partnercta\"   : {\n            \"text\": \"Learn More\",\n            \"url\" : \"https://www.amazon.com/dp/B0BYSWHGB9\",\n        },\n        \"partnerlinks\" : list,\n        \"partnername\"  : \"Red, White & Royal Blue\",\n        \"profile_id\"   : \"9116468280322048077\",\n        \"tagline\"      : \"Love is about to get royally complicated\",\n        \"url\"          : \"https://tenor.com/official/RWRBonPrime\",\n        \"userid\"       : \"0\",\n        \"username\"     : \"RWRBonPrime\",\n        \"usertype\"     : \"partner\",\n    },\n},\n\n)\n"
  },
  {
    "path": "test/results/thebarchive.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import foolfuuka\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://thebarchive.com/b/thread/739772332/\",\n    \"#category\": (\"foolfuuka\", \"thebarchive\", \"thread\"),\n    \"#class\"   : foolfuuka.FoolfuukaThreadExtractor,\n    \"#sha1_url\": \"e8b18001307d130d67db31740ce57c8561b5d80c\",\n},\n\n{\n    \"#url\"     : \"https://thebarchive.com/b/\",\n    \"#category\": (\"foolfuuka\", \"thebarchive\", \"board\"),\n    \"#class\"   : foolfuuka.FoolfuukaBoardExtractor,\n},\n\n{\n    \"#url\"     : \"https://thebarchive.com/_/search/text/test/\",\n    \"#category\": (\"foolfuuka\", \"thebarchive\", \"search\"),\n    \"#class\"   : foolfuuka.FoolfuukaSearchExtractor,\n},\n\n{\n    \"#url\"     : \"https://thebarchive.com/b/gallery/9\",\n    \"#category\": (\"foolfuuka\", \"thebarchive\", \"gallery\"),\n    \"#class\"   : foolfuuka.FoolfuukaGalleryExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/thecollection.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import gelbooru_v01\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://the-collection.booru.org/index.php?page=post&s=list&tags=parody\",\n    \"#category\": (\"gelbooru_v01\", \"thecollection\", \"tag\"),\n    \"#class\"   : gelbooru_v01.GelbooruV01TagExtractor,\n    \"#range\"   : \"1-25\",\n    \"#count\"   : 25,\n},\n\n{\n    \"#url\"     : \"https://the-collection.booru.org/index.php?page=favorites&s=view&id=1166\",\n    \"#category\": (\"gelbooru_v01\", \"thecollection\", \"favorite\"),\n    \"#class\"   : gelbooru_v01.GelbooruV01FavoriteExtractor,\n    \"#count\"   : 2,\n},\n\n{\n    \"#url\"     : \"https://the-collection.booru.org/index.php?page=post&s=view&id=100520\",\n    \"#category\": (\"gelbooru_v01\", \"thecollection\", \"post\"),\n    \"#class\"   : gelbooru_v01.GelbooruV01PostExtractor,\n    \"#sha1_url\"    : \"0329ac8588bb93cf242ca0edbe3e995b4ba554e8\",\n    \"#sha1_content\": \"1e585874e7b874f7937df1060dd1517fef2f4dfb\",\n},\n\n)\n"
  },
  {
    "path": "test/results/thecollectionS.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import shimmie2\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://co.llection.pics/post/view/23\",\n    \"#category\": (\"shimmie2\", \"thecollectionS\", \"post\"),\n    \"#class\"   : shimmie2.Shimmie2PostExtractor,\n    \"#results\" : \"https://co.llection.pics/_images/0f431221d96251e6db62cb2c57f5b29f/23%20-%20crossovers%20dc%20goku%20kal-el%20superman.jpg\",\n\n    \"extension\": \"jpg\",\n    \"file_url\" : \"https://co.llection.pics/_images/0f431221d96251e6db62cb2c57f5b29f/23%20-%20crossovers%20dc%20goku%20kal-el%20superman.jpg\",\n    \"filename\" : \"23 - crossovers dc goku kal-el superman\",\n    \"height\"   : 628,\n    \"id\"       : 23,\n    \"md5\"      : \"0f431221d96251e6db62cb2c57f5b29f\",\n    \"size\"     : 0,\n    \"tags\"     : \"crossovers dc goku kal-el superman\",\n    \"width\"    : 1124,\n},\n\n{\n    \"#url\"     : \"https://co.llection.pics/post/list/crossovers/1\",\n    \"#category\": (\"shimmie2\", \"thecollectionS\", \"tag\"),\n    \"#class\"   : shimmie2.Shimmie2TagExtractor,\n    \"#range\"   : \"1-80\",\n    \"#pattern\" : r\"https://co\\.llection\\.pics/_images/\\w{32}/\\d+.+\\.(jpe?g|png|gif)\",\n    \"#count\"   : 80,\n\n    \"id\"         : int,\n    \"filename\"   : str,\n    \"extension\"  : {\"jpg\", \"jpeg\", \"png\", \"gif\"},\n    \"file_url\"   : r\"re:https://co.llection.pics/_images/\\w{32}/.+\",\n    \"width\"      : range(100, 8_000),\n    \"height\"     : range(100, 8_000),\n    \"md5\"        : r\"re:\\w{32}\",\n    \"search_tags\": \"crossovers\",\n    \"size\"       : range(1_000, 8_000_000),\n    \"tags\"       : str,\n},\n\n)\n"
  },
  {
    "path": "test/results/thefap.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import thefap\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://thefap.net/zoey.curly-374261/xpics/i8\",\n    \"#class\"   : thefap.ThefapPostExtractor,\n    \"#results\" : \"https://cdn31.xpics.me/photo/2024/10/01/09/CR98EY1fSquX.jpg\",\n\n    \"extension\" : \"jpg\",\n    \"filename\"  : \"CR98EY1fSquX\",\n    \"kind\"      : \"xpics\",\n    \"model\"     : \"zoey.curly\",\n    \"model_id\"  : 374261,\n    \"model_name\": \"Zoey Curly\",\n    \"num\"       : 1,\n    \"post_id\"   : 8,\n},\n\n{\n    \"#url\"     : \"https://thefap.net/analovesbananaas-979268/fap-onlyfans-0-1qcckka/i2\",\n    \"#class\"   : thefap.ThefapPostExtractor,\n    \"#results\" : \"https://i0.wp.com/i.redd.it/b4o1olbgi8dg1.jpg\",\n\n    \"extension\" : \"jpg\",\n    \"kind\"      : \"fap-onlyfans-0-1qcckka\",\n    \"model\"     : \"analovesbananaas\",\n    \"model_id\"  : 979268,\n    \"model_name\": \"analovesbananaas\",\n    \"num\"       : 1,\n    \"post_id\"   : 2,\n},\n\n{\n    \"#url\"     : \"https://thefap.net/tatted-mamma-979518/twpornstars/i1\",\n    \"#class\"   : thefap.ThefapPostExtractor,\n    \"#results\" : \"https://pbs.twimg.com/media/GFmqJn2a8AAAtKu.jpg:orig\",\n\n    \"extension\" : \"jpg:orig\",\n    \"filename\"  : \"GFmqJn2a8AAAtKu\",\n    \"kind\"      : \"twpornstars\",\n    \"model\"     : \"tatted-mamma\",\n    \"model_id\"  : 979518,\n    \"model_name\": \"tatted_mamma\",\n    \"num\"       : 1,\n    \"post_id\"   : 1,\n},\n\n{\n    \"#url\"     : \"https://thefap.net/zoey.curly-374261/\",\n    \"#class\"   : thefap.ThefapModelExtractor,\n    \"#range\"   : \"1-100\",\n    \"#count\"   : 100,\n\n    \"extension\" : \"jpg\",\n    \"filename\"  : str,\n    \"model\"     : \"zoey.curly\",\n    \"model_id\"  : 374261,\n    \"model_name\": \"Zoey Curly\",\n    \"num\"       : range(1, 100),\n},\n\n{\n    \"#url\"     : \"https://thefap.net/analovesbananaas-979268/\",\n    \"#class\"   : thefap.ThefapModelExtractor,\n    \"#results\" : (\n        \"https://i0.wp.com/i.redd.it/icndsjbgi8dg1.jpg\",\n        \"https://i0.wp.com/i.redd.it/b4o1olbgi8dg1.jpg\",\n        \"https://i0.wp.com/i.redd.it/aqilnkbgi8dg1.jpg\",\n    ),\n\n    \"extension\" : \"jpg\",\n    \"filename\"  : str,\n    \"model\"     : \"analovesbananaas\",\n    \"model_id\"  : 979268,\n    \"model_name\": \"analovesbananaas\",\n    \"num\"       : range(1, 3),\n},\n\n)\n"
  },
  {
    "path": "test/results/thehentaiworld.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import thehentaiworld\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://thehentaiworld.com/hentai-images/samus-aran-aurahack-metroid-2/\",\n    \"#class\"   : thehentaiworld.ThehentaiworldPostExtractor,\n    \"#results\" : \"https://thehentaiworld.com/wp-content/uploads/2020/06/Samus-Aran-Aurahack-Metroid-Hentai.jpeg\",\n\n    \"count\"         : 1,\n    \"num\"           : 0,\n    \"date\"          : \"dt:2020-06-05 00:00:00\",\n    \"extension\"     : \"jpeg\",\n    \"file_url\"      : \"https://thehentaiworld.com/wp-content/uploads/2020/06/Samus-Aran-Aurahack-Metroid-Hentai.jpeg\",\n    \"filename\"      : \"Samus-Aran-Aurahack-Metroid-Hentai\",\n    \"height\"        : 2893,\n    \"id\"            : 147048,\n    \"score\"         : range(3, 5),\n    \"slug\"          : \"samus-aran-aurahack-metroid-2\",\n    \"title\"         : \"Samus Aran – Aurahack – Metroid\",\n    \"type\"          : \"image\",\n    \"votes\"         : range(5, 20),\n    \"width\"         : 2000,\n    \"tags\"          : [\n        \"Metroid\",\n        \"Samus Aran\",\n        \"Aurahack18\",\n        \"Blonde\",\n        \"blush\",\n        \"sweat\",\n    ],\n    \"tags_general\"  : [\n        \"Blonde\",\n        \"blush\",\n        \"sweat\",\n    ],\n    \"tags_artist\"   : [\"Aurahack18\"],\n    \"tags_character\": [\"Samus Aran\"],\n    \"tags_origin\"   : [\"Metroid\"],\n},\n\n{\n    \"#url\"     : \"https://thehentaiworld.com/hentai-images/ubel-nt00-sousou-no-frieren/\",\n    \"#class\"   : thehentaiworld.ThehentaiworldPostExtractor,\n    \"#results\" : (\n        \"https://thehentaiworld.com/wp-content/uploads/2024/04/Ubel-nt00-Sousou-no-Frieren-Hentai.jpg\",\n        \"https://thehentaiworld.com/wp-content/uploads/2024/04/Ubel-–-nt00-–-Sousou-no-Frieren-Hentai.jpg\",\n    ),\n\n    \"count\"         : 2,\n    \"num\"           : range(1, 2),\n    \"date\"          : \"dt:2024-04-16 00:00:00\",\n    \"extension\"     : \"jpg\",\n    \"file_url\"      : \"https://thehentaiworld.com/wp-content/uploads/2024/04/Ubel-nt00-Sousou-no-Frieren-Hentai.jpg\",\n    \"filename\"      : {\n        \"Ubel-nt00-Sousou-no-Frieren-Hentai\",\n        \"Ubel-–-nt00-–-Sousou-no-Frieren-Hentai\",\n    },\n    \"height\"        : 1422,\n    \"id\"            : 226208,\n    \"score\"         : range(3, 5),\n    \"slug\"          : \"ubel-nt00-sousou-no-frieren\",\n    \"title\"         : \"Ubel – nt00 – Sousou no Frieren\",\n    \"type\"          : \"image\",\n    \"votes\"         : range(10, 20),\n    \"width\"         : 800,\n    \"file_urls\"     : [\n        \"https://thehentaiworld.com/wp-content/uploads/2024/04/Ubel-nt00-Sousou-no-Frieren-Hentai.jpg\",\n        \"https://thehentaiworld.com/wp-content/uploads/2024/04/Ubel-–-nt00-–-Sousou-no-Frieren-Hentai.jpg\",\n    ],\n    \"tags\"          : [\n        \"Sousou no Frieren\",\n        \"Ubel\",\n        \"nt00\",\n        \"blush\",\n        \"Green Hair\",\n        \"pubic hair\",\n        \"smile\",\n    ],\n    \"tags_general\"  : [\n        \"blush\",\n        \"Green Hair\",\n        \"pubic hair\",\n        \"smile\",\n    ],\n    \"tags_artist\"   : [\"nt00\"],\n    \"tags_character\": [\"Ubel\"],\n    \"tags_origin\"   : [\"Sousou no Frieren\"],\n},\n\n{\n    \"#url\"     : \"https://thehentaiworld.com/videos/lucy-heartfilia-and-natsu-dragneel-shiina-ecchi-fairy-tail/#comment-396839\",\n    \"#class\"   : thehentaiworld.ThehentaiworldPostExtractor,\n    \"#results\" : \"https://thehentaiworld.com/wp-content/uploads/2025/09/Lucy-Heartfilia-and-Natsu-Dragneel-Shiina-Ecchi-Fairy-Tail-Animated-Hentai-Video.mp4\",\n\n    \"count\"         : 1,\n    \"num\"           : 0,\n    \"date\"          : \"dt:2025-09-19 00:00:00\",\n    \"extension\"     : \"mp4\",\n    \"file_url\"      : \"https://thehentaiworld.com/wp-content/uploads/2025/09/Lucy-Heartfilia-and-Natsu-Dragneel-Shiina-Ecchi-Fairy-Tail-Animated-Hentai-Video.mp4\",\n    \"filename\"      : \"Lucy-Heartfilia-and-Natsu-Dragneel-Shiina-Ecchi-Fairy-Tail-Animated-Hentai-Video\",\n    \"height\"        : 0,\n    \"id\"            : 253263,\n    \"score\"         : 5.0,\n    \"slug\"          : \"lucy-heartfilia-and-natsu-dragneel-shiina-ecchi-fairy-tail\",\n    \"title\"         : \"Lucy Heartfilia and Natsu Dragneel – Shiina Ecchi – Fairy Tail\",\n    \"type\"          : \"video\",\n    \"votes\"         : range(25, 50),\n    \"width\"         : 0,\n    \"tags\"          : [\n        \"Fairy Tail\",\n        \"Animated\",\n        \"sound\",\n        \"video\",\n        \"lucy heartfilia\",\n        \"Natsu Dragneel\",\n        \"Shiina Ecchi\",\n        \"arse\",\n        \"blush\",\n        \"Cowgirl Ride\",\n        \"cum\",\n        \"cum inside\",\n        \"eye roll\",\n        \"Fingering\",\n        \"Jiggle\",\n        \"legs spread\",\n        \"masturbating\",\n        \"moan\",\n        \"panties\",\n        \"pov\",\n        \"ride\",\n        \"smile\",\n        \"squeeze\",\n        \"vagina\",\n        \"x-ray\",\n    ],\n    \"tags_character\": [\n        \"lucy heartfilia\",\n        \"Natsu Dragneel\",\n    ],\n    \"tags_general\"  : [\n        \"arse\",\n        \"blush\",\n        \"Cowgirl Ride\",\n        \"cum\",\n        \"cum inside\",\n        \"eye roll\",\n        \"Fingering\",\n        \"Jiggle\",\n        \"legs spread\",\n        \"masturbating\",\n        \"moan\",\n        \"panties\",\n        \"pov\",\n        \"ride\",\n        \"smile\",\n        \"squeeze\",\n        \"vagina\",\n        \"x-ray\",\n    ],\n    \"tags_media\"    : [\n        \"Animated\",\n        \"sound\",\n        \"video\",\n    ],\n    \"tags_artist\"   : [\"Shiina Ecchi\"],\n    \"tags_origin\"   : [\"Fairy Tail\"],\n},\n\n{\n    \"#url\"     : \"https://thehentaiworld.com/3d-cgi-hentai-images/ciri-and-shani-cekc-the-witcher-3/\",\n    \"#class\"   : thehentaiworld.ThehentaiworldPostExtractor,\n    \"#results\" : \"https://thehentaiworld.com/wp-content/uploads/2025/08/Ciri-and-Shani-CEKC-The-Witcher-3-Hentai-3D-CGI.jpeg\",\n\n    \"type\"     : \"3d cgi\",\n},\n\n{\n    \"#url\"     : \"https://thehentaiworld.com/gif-animated-hentai-images/rangiku-matsumoto-sketchdrif-bleach/\",\n    \"#class\"   : thehentaiworld.ThehentaiworldPostExtractor,\n    \"#results\" : \"https://thehentaiworld.com/wp-content/uploads/2025/05/Rangiku-Matsumoto-Sketchdrif-Bleach-Animated-Hentai.gif\",\n\n    \"type\"     : \"animated\",\n},\n\n{\n    \"#url\"     : \"https://thehentaiworld.com/tag/aurahack/\",\n    \"#class\"   : thehentaiworld.ThehentaiworldTagExtractor,\n    \"#pattern\" : r\"https://thehentaiworld\\.com/wp\\-content/uploads/20\\d\\d/.+\",\n    \"#range\"   : \"20-\",\n    \"#count\"   : 10,\n\n    \"count\"         : {1, 2},\n    \"num\"           : {1, 2, 0},\n    \"date\"          : \"type:datetime\",\n    \"extension\"     : {\"jpg\", \"png\"},\n    \"file_url\"      : str,\n    \"filename\"      : str,\n    \"height\"        : int,\n    \"id\"            : int,\n    \"score\"         : float,\n    \"search_tags\"   : \"aurahack\",\n    \"slug\"          : str,\n    \"tags_artist\"   : [\"Aurahack18\"],\n    \"title\"         : str,\n    \"type\"          : \"image\",\n    \"votes\"         : int,\n    \"width\"         : int,\n    \"tags\"          : list,\n},\n\n)\n"
  },
  {
    "path": "test/results/tiktok.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import tiktok\n\nPATTERN = r\"https://p1[69]-[^/?#.]+\\.tiktokcdn[^/?#.]*\\.com/[^/?#]+/\\w+~.*\\.(jpe?g|image)\"\nPATTERN_WITH_AUDIO = r\"(?:\" + PATTERN + r\"|https://v\\d+m?\\.tiktokcdn[^/?#.]*\\.com/[^?#]+\\?[^/?#]+)\"\nVIDEO_PATTERN = r\"https://v1[69]-webapp-prime.tiktok.com/video/tos/[^?#]+\\?[^/?#]+\"\nOLD_VIDEO_PATTERN = r\"https://www.tiktok.com/aweme/v1/play/\\?[^/?#]+\"\nCOMBINED_VIDEO_PATTERN = r\"(?:\" + VIDEO_PATTERN + r\")|(?:\" + OLD_VIDEO_PATTERN + r\")\"\nUSER_PATTERN = r\"(https://www.tiktok.com/@([\\w_.-]+)/video/(\\d+)|\" + PATTERN + r\")\"\nSUBTITLE_PATTERN = r\"https://v1[69]-[^/?#.]+\\.tiktokcdn[^/?#.]*\\.com/[^/?#]+/.*\"\n\n\n__tests__ = (\n{\n    \"#url\"      : \"https://www.tiktok.com/@chillezy/photo/7240568259186019630\",\n    \"#comment\"  : \"/photo/ link: many photos\",\n    \"#category\" : (\"\", \"tiktok\", \"post\"),\n    \"#class\"    : tiktok.TiktokPostExtractor,\n    \"#pattern\"  : PATTERN,\n    \"#options\"  : {\"videos\": False, \"audio\": False},\n},\n\n{\n    \"#url\"      : \"https://www.tiktok.com/@chillezy/video/7240568259186019630\",\n    \"#comment\"  : \"/video/ link: many photos\",\n    \"#category\" : (\"\", \"tiktok\", \"post\"),\n    \"#class\"    : tiktok.TiktokPostExtractor,\n    \"#pattern\"  : PATTERN,\n    \"#options\"  : {\"videos\": False, \"audio\": False},\n},\n\n{\n    \"#url\"      : \"https://www.tiktokv.com/share/video/7240568259186019630\",\n    \"#comment\"  : \"www.tiktokv.com link: many photos\",\n    \"#category\" : (\"\", \"tiktok\", \"post\"),\n    \"#class\"    : tiktok.TiktokPostExtractor,\n    \"#pattern\"  : PATTERN,\n    \"#options\"  : {\"videos\": False, \"audio\": False},\n},\n\n{\n    \"#url\"      : \"https://www.tiktok.com/@hullcity/photo/7557376330036153622\",\n    \"#comment\"  : \"/photo/ link: single photo\",\n    \"#category\" : (\"\", \"tiktok\", \"post\"),\n    \"#class\"    : tiktok.TiktokPostExtractor,\n    \"#pattern\"  : PATTERN,\n    \"#options\"  : {\"videos\": False, \"audio\": False},\n},\n\n{\n    \"#url\"      : \"https://www.tiktok.com/@hullcity/video/7557376330036153622\",\n    \"#comment\"  : \"/video/ link: single photo\",\n    \"#category\" : (\"\", \"tiktok\", \"post\"),\n    \"#class\"    : tiktok.TiktokPostExtractor,\n    \"#pattern\"  : PATTERN,\n    \"#options\"  : {\"videos\": False, \"audio\": False},\n},\n\n{\n    \"#url\"      : \"https://www.tiktokv.com/share/video/7557376330036153622\",\n    \"#comment\"  : \"www.tiktokv.com link: single photo\",\n    \"#category\" : (\"\", \"tiktok\", \"post\"),\n    \"#class\"    : tiktok.TiktokPostExtractor,\n    \"#pattern\"  : PATTERN,\n    \"#options\"  : {\"videos\": False, \"audio\": False},\n},\n\n{\n    \"#url\"      : \"https://www.tiktok.com/@hullcity/photo/7553302113757990166\",\n    \"#comment\"  : \"/photo/ link: few photos\",\n    \"#category\" : (\"\", \"tiktok\", \"post\"),\n    \"#class\"    : tiktok.TiktokPostExtractor,\n    \"#pattern\"  : PATTERN,\n    \"#options\"  : {\"videos\": False, \"audio\": False},\n},\n\n{\n    \"#url\"      : \"https://www.tiktok.com/@hullcity/video/7553302113757990166\",\n    \"#comment\"  : \"/video/ link: few photos\",\n    \"#category\" : (\"\", \"tiktok\", \"post\"),\n    \"#class\"    : tiktok.TiktokPostExtractor,\n    \"#pattern\"  : PATTERN,\n    \"#options\"  : {\"videos\": False, \"audio\": False},\n},\n\n{\n    \"#url\"      : \"https://www.tiktokv.com/share/video/7553302113757990166\",\n    \"#comment\"  : \"www.tiktokv.com link: few photos\",\n    \"#category\" : (\"\", \"tiktok\", \"post\"),\n    \"#class\"    : tiktok.TiktokPostExtractor,\n    \"#pattern\"  : PATTERN,\n    \"#options\"  : {\"videos\": False, \"audio\": False},\n},\n\n{\n    \"#url\"      : \"https://www.tiktok.com/@ughuwhguweghw/video/1\",\n    \"#comment\"  : \"deleted post\",\n    \"#category\" : (\"\", \"tiktok\", \"post\"),\n    \"#class\"    : tiktok.TiktokPostExtractor,\n    \"#options\"  : {\"videos\": False, \"audio\": False},\n    \"#count\"    : 0,\n},\n\n{\n    \"#url\"      : \"https://www.tiktok.com/@memezar/video/7449708266168274208\",\n    \"#comment\"  : \"Video post\",\n    \"#category\" : (\"\", \"tiktok\", \"post\"),\n    \"#class\"    : tiktok.TiktokPostExtractor,\n    \"#pattern\"  : COMBINED_VIDEO_PATTERN,\n    \"#options\"  : {\"videos\": True, \"audio\": True},\n},\n\n{\n    \"#url\"      : \"https://www.tiktok.com/@memezar/video/7449708266168274208\",\n    \"#comment\"  : \"Video post (via yt-dlp)\",\n    \"#category\" : (\"\", \"tiktok\", \"post\"),\n    \"#class\"    : tiktok.TiktokPostExtractor,\n    \"#results\"  : \"ytdl:https://www.tiktok.com/@memezar/video/7449708266168274208\",\n    \"#options\"  : {\"videos\": \"ytdl\", \"audio\": True},\n},\n\n{\n    \"#url\"      : \"https://www.tiktok.com/@memezar/video/7449708266168274208\",\n    \"#comment\"  : \"video post cover image\",\n    \"#class\"    : tiktok.TiktokPostExtractor,\n    \"#pattern\"  : PATTERN,\n    \"#count\"    : 1,\n    \"#options\"  : {\"videos\": False, \"covers\": True},\n\n\n},\n\n{\n    \"#url\"      : \"https://www.tiktok.com/@memezar/video/7449708266168274208\",\n    \"#comment\"  : \"all video post cover images\",\n    \"#class\"    : tiktok.TiktokPostExtractor,\n    \"#pattern\"  : PATTERN,\n    \"#count\"    : 3,\n    \"#options\"  : {\"videos\": False, \"covers\": \"all\"},\n\n\n},\n\n{\n    \"#url\"      : \"https://www.tiktok.com/@memezar/photo/7449708266168274208\",\n    \"#comment\"  : \"Video post as a /photo/ link\",\n    \"#category\" : (\"\", \"tiktok\", \"post\"),\n    \"#class\"    : tiktok.TiktokPostExtractor,\n    \"#pattern\"  : COMBINED_VIDEO_PATTERN,\n    \"#options\"  : {\"videos\": True, \"audio\": True},\n},\n\n{\n    \"#url\"      : \"https://www.tiktokv.com/share/video/7240568259186019630\",\n    \"#comment\"  : \"www.tiktokv.com link: many photos with audio\",\n    \"#category\" : (\"\", \"tiktok\", \"post\"),\n    \"#class\"    : tiktok.TiktokPostExtractor,\n    \"#options\"  : {\"audio\": True},\n    \"#pattern\"  : PATTERN_WITH_AUDIO,\n    \"#count\"    : 17,\n},\n\n{\n    \"#url\"      : \"https://www.tiktokv.com/share/video/7240568259186019630\",\n    \"#comment\"  : \"www.tiktokv.com link: many photos with audio disabled\",\n    \"#category\" : (\"\", \"tiktok\", \"post\"),\n    \"#class\"    : tiktok.TiktokPostExtractor,\n    \"#options\"  : {\"audio\": False},\n    \"#pattern\"  : PATTERN,\n    \"#count\"    : 16,\n},\n\n{\n    \"#url\"      : \"https://www.tiktokv.com/share/video/7449708266168274208\",\n    \"#comment\"  : \"Video post as a share link\",\n    \"#category\" : (\"\", \"tiktok\", \"post\"),\n    \"#class\"    : tiktok.TiktokPostExtractor,\n    \"#pattern\"  : COMBINED_VIDEO_PATTERN,\n    \"#options\"  : {\"videos\": True},\n},\n\n{\n    \"#url\"      : \"https://www.tiktok.com/@memezar/video/7449708266168274208\",\n    \"#comment\"  : \"Skipping video post\",\n    \"#category\" : (\"\", \"tiktok\", \"post\"),\n    \"#class\"    : tiktok.TiktokPostExtractor,\n    \"#results\"  : (),\n    \"#options\"  : {\"videos\": False},\n},\n\n{\n    \"#url\"      : \"https://www.tiktok.com/@chillezy/photo/7240568259186019630\",\n    \"#comment\"  : \"/photo/ link: many photos with audio\",\n    \"#category\" : (\"\", \"tiktok\", \"post\"),\n    \"#class\"    : tiktok.TiktokPostExtractor,\n    \"#pattern\"  : PATTERN_WITH_AUDIO,\n    \"#options\"  : {\"videos\": True},\n},\n\n{\n    \"#url\"      : \"https://www.tiktok.com/@chillezy/video/7240568259186019630\",\n    \"#comment\"  : \"/video/ link: many photos with audio\",\n    \"#category\" : (\"\", \"tiktok\", \"post\"),\n    \"#class\"    : tiktok.TiktokPostExtractor,\n    \"#pattern\"  : PATTERN_WITH_AUDIO,\n    \"#options\"  : {\"videos\": True},\n},\n\n{\n    \"#url\"      : \"https://www.tiktok.com/@/video/7240568259186019630\",\n    \"#class\"    : tiktok.TiktokPostExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.tiktok.com/@veronicaperasso_1/video/7212008840433274118\",\n    \"#comment\" : \"no 'author' (#8189)\",\n    \"#class\"   : tiktok.TiktokPostExtractor,\n    \"#results\" : \"ytdl:https://www.tiktok.com/@veronicaperasso_1/video/7212008840433274118\",\n    \"#options\" : {\"videos\": \"ytdl\"},\n},\n\n{\n    \"#url\"     : \"https://www.tiktok.com/@memezar/video/7588916452304997635\",\n    \"#comment\" : \"default subtitles\",\n    \"#class\"   : tiktok.TiktokPostExtractor,\n    \"#pattern\" : SUBTITLE_PATTERN,\n    \"#count\"   : 1,\n    \"#options\" : {\"videos\": False, \"covers\": False, \"subtitles\": True}\n},\n\n{\n    \"#url\"     : \"https://www.tiktok.com/@memezar/video/7588916452304997635\",\n    \"#comment\" : \"english subtitles\",\n    \"#class\"   : tiktok.TiktokPostExtractor,\n    \"#pattern\" : SUBTITLE_PATTERN,\n    \"#count\"   : 1,\n    \"#options\" : {\"videos\": False, \"covers\": False, \"subtitles\": \"eng-US\"}\n},\n\n# This test is prone to break when more translation agents are added!\n{\n    \"#url\"     : \"https://www.tiktok.com/@memezar/video/7588916452304997635\",\n    \"#comment\" : \"combined subtitle filter\",\n    \"#class\"   : tiktok.TiktokPostExtractor,\n    \"#pattern\" : SUBTITLE_PATTERN,\n    \"#count\"   : 6,\n    \"#options\" : {\"videos\": False, \"covers\": False, \"subtitles\": \"ASR,deu-DE\"}\n},\n\n# This test is prone to break when new languages or more translation agents are added!\n{\n    \"#url\"     : \"https://www.tiktok.com/@memezar/video/7588916452304997635\",\n    \"#comment\" : \"all subtitles\",\n    \"#class\"   : tiktok.TiktokPostExtractor,\n    \"#pattern\" : SUBTITLE_PATTERN,\n    \"#count\"   : 64,\n    \"#options\" : {\"videos\": False, \"covers\": False, \"subtitles\": \"all\"}\n},\n\n{\n    \"#url\"      : \"https://vm.tiktok.com/ZGdh4WUhr/\",\n    \"#comment\"  : \"vm.tiktok.com link: many photos\",\n    \"#category\" : (\"\", \"tiktok\", \"vmpost\"),\n    \"#class\"    : tiktok.TiktokVmpostExtractor,\n    \"#pattern\"  : tiktok.TiktokPostExtractor.pattern,\n},\n\n{\n    \"#url\"      : \"https://vm.tiktok.com/ZGdhVtER2/\",\n    \"#comment\"  : \"vm.tiktok.com link: single photo\",\n    \"#category\" : (\"\", \"tiktok\", \"vmpost\"),\n    \"#class\"    : tiktok.TiktokVmpostExtractor,\n    \"#pattern\"  : tiktok.TiktokPostExtractor.pattern,\n},\n\n{\n    \"#url\"      : \"https://vm.tiktok.com/ZGdhVW3cu/\",\n    \"#comment\"  : \"vm.tiktok.com link: few photos\",\n    \"#category\" : (\"\", \"tiktok\", \"vmpost\"),\n    \"#class\"    : tiktok.TiktokVmpostExtractor,\n    \"#pattern\"  : tiktok.TiktokPostExtractor.pattern,\n},\n\n{\n    \"#url\"      : \"https://vm.tiktok.com/ZGdht7cjp/\",\n    \"#comment\"  : \"Video post as a VM link\",\n    \"#category\" : (\"\", \"tiktok\", \"vmpost\"),\n    \"#class\"    : tiktok.TiktokVmpostExtractor,\n    \"#pattern\"  : tiktok.TiktokPostExtractor.pattern,\n},\n\n{\n    \"#url\"      : \"https://vm.tiktok.com/ZGdh4WUhr/\",\n    \"#comment\"  : \"vm.tiktok.com link: many photos with audio\",\n    \"#category\" : (\"\", \"tiktok\", \"vmpost\"),\n    \"#class\"    : tiktok.TiktokVmpostExtractor,\n    \"#pattern\"  : tiktok.TiktokPostExtractor.pattern,\n},\n\n{\n    \"#url\"      : \"https://vt.tiktok.com/ZGdhVtER2\",\n    \"#comment\"  : \"vt.tiktok.com link: single photo\",\n    \"#category\" : (\"\", \"tiktok\", \"vmpost\"),\n    \"#class\"    : tiktok.TiktokVmpostExtractor,\n    \"#pattern\"  : tiktok.TiktokPostExtractor.pattern,\n},\n\n{\n    \"#url\"      : \"https://www.tiktok.com/t/ZGdhVtER2//\",\n    \"#comment\"  : \"www.tiktok.com/t/ link: single photo\",\n    \"#category\" : (\"\", \"tiktok\", \"vmpost\"),\n    \"#class\"    : tiktok.TiktokVmpostExtractor,\n    \"#pattern\"  : tiktok.TiktokPostExtractor.pattern,\n},\n\n{\n    \"#url\"      : \"https://www.tiktok.com/@chillezy\",\n    \"#comment\"  : \"User profile\",\n    \"#category\" : (\"\", \"tiktok\", \"user\"),\n    \"#class\"    : tiktok.TiktokUserExtractor,\n    \"#pattern\"  : USER_PATTERN,\n    \"#count\"    : 11,  # 10 posts + 1 avatar\n    \"#options\"  : {\"videos\": True, \"audio\": True, \"tiktok-range\": \"1-10\"},\n},\n\n# order-posts currently has no effect if logged-in cookies aren't used.\n\n# {\n#     \"#url\"      : \"https://www.tiktok.com/@chillezy\",\n#     \"#comment\"  : \"User profile ascending order\",\n#     \"#category\" : (\"\", \"tiktok\", \"user\"),\n#     \"#class\"    : tiktok.TiktokUserExtractor,\n#     \"#results\"  : \"https://www.tiktok.com/@chillezy/video/7112145009356344622\",\n#     \"#options\"  : {\"videos\": True, \"audio\": True, \"avatar\": False, \"tiktok-range\": \"1\", \"order-posts\": \"asc\"},\n# },\n\n# {\n#     \"#url\"      : \"https://www.tiktok.com/@chillezy\",\n#     \"#comment\"  : \"User profile popular order\",\n#     \"#category\" : (\"\", \"tiktok\", \"user\"),\n#     \"#class\"    : tiktok.TiktokUserExtractor,\n#     \"#results\"  : \"https://www.tiktok.com/@chillezy/video/7240568259186019630\",\n#     \"#options\"  : {\"videos\": True, \"audio\": True, \"avatar\": False, \"tiktok-range\": \"1\", \"order-posts\": \"popular\"},\n# },\n\n{\n    \"#url\"      : \"https://www.tiktok.com/@chillezy\",\n    \"#comment\"  : \"User profile via yt-dlp\",\n    \"#category\" : (\"\", \"tiktok\", \"user\"),\n    \"#class\"    : tiktok.TiktokUserExtractor,\n    \"#pattern\"  : USER_PATTERN,\n    \"#count\"    : 11,  # 10 posts + 1 avatar\n    \"#options\"  : {\"videos\": True, \"audio\": True, \"tiktok-range\": \"1-10\", \"tiktok-user-extractor\": \"ytdl\"},\n},\n\n{\n    \"#url\"      : \"https://www.tiktok.com/@chillezy\",\n    \"#comment\"  : \"User profile without avatar\",\n    \"#category\" : (\"\", \"tiktok\", \"user\"),\n    \"#class\"    : tiktok.TiktokUserExtractor,\n    \"#pattern\"  : USER_PATTERN,\n    \"#count\"    : 10,  # 10 posts\n    \"#options\"  : {\"videos\": True, \"audio\": True, \"avatar\": False, \"tiktok-range\": \"1-10\"},\n},\n\n{\n    \"#url\"      : \"https://www.tiktok.com/@joeysc14/\",\n    \"#comment\"  : \"Public user profile with no content\",\n    \"#category\" : (\"\", \"tiktok\", \"user\"),\n    \"#class\"    : tiktok.TiktokUserExtractor,\n    \"#pattern\"  : PATTERN,\n    \"#options\"  : {\"videos\": False, \"tiktok-range\": \"1\"},\n    \"#count\"    : 1,  # 1 avatar\n},\n\n{\n    \"#url\"     : \"https://www.tiktok.com/@chillezy/avatar\",\n    \"#class\"   : tiktok.TiktokAvatarExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.tiktok.com/@chillezy/posts\",\n    \"#class\"   : tiktok.TiktokPostsExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.tiktok.com/@chillezy/reposts\",\n    \"#class\"   : tiktok.TiktokRepostsExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.tiktok.com/@chillezy/stories\",\n    \"#class\"   : tiktok.TiktokStoriesExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.tiktok.com/@chillezy/likes\",\n    \"#class\"   : tiktok.TiktokLikesExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.tiktok.com/@chillezy/saved\",\n    \"#class\"   : tiktok.TiktokSavedExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/titsintops.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import xenforo\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://titsintops.com/phpBB2/threads/mia-big-titty-boston-blonde.13575039/post-3265146\",\n    \"#category\": (\"xenforo\", \"titsintops\", \"post\"),\n    \"#class\"   : xenforo.XenforoPostExtractor,\n    \"#results\" : (\n        \"https://titsintops.com/phpBB2/attachments/img_3763-webp.6091490/\",\n        \"https://titsintops.com/phpBB2/attachments/img_3765-webp.6091491/\",\n        \"https://titsintops.com/phpBB2/attachments/img_3755-webp.6091492/\",\n        \"https://titsintops.com/phpBB2/attachments/img_3754-webp.6091493/\",\n        \"https://titsintops.com/phpBB2/attachments/img_3753-webp.6091494/\",\n        \"https://titsintops.com/phpBB2/attachments/img_3759-webp.6091495/\",\n        \"https://titsintops.com/phpBB2/attachments/img_3751-webp.6091496/\",\n    ),\n\n    \"count\"       : 7,\n    \"num\"         : range(1, 7),\n    \"num_internal\": range(1, 7),\n    \"num_external\": 0,\n    \"extension\"   : \"webp\",\n    \"filename\"    : str,\n    \"id\"          : range(6091490, 6091496),\n    \"type\"        : \"inline\",\n    \"post\"        : {\n        \"author\"     : \"Nsfwev\",\n        \"author_id\"  : \"1178301\",\n        \"author_slug\": \"nsfwev\",\n        \"author_url\" : \"https://titsintops.com/phpBB2/members/nsfwev.1178301/\",\n        \"count\"      : 7,\n        \"date\"       : \"dt:2025-08-27 05:39:53\",\n        \"id\"         : \"3265146\",\n        \"attachments\": str,\n        \"content\"    : \"\"\"re:<b>soo hot one of my favorites</b>\"\"\",\n    },\n    \"thread\"      : {\n        \"author\"     : \"lazywriterx\",\n        \"author_id\"  : \"323115\",\n        \"author_slug\": \"lazywriterx\",\n        \"author_url\" : \"https://titsintops.com/phpBB2/members/lazywriterx.323115/\",\n        \"date\"       : \"dt:2025-05-02 13:33:58\",\n        \"id\"         : \"13575039\",\n        \"posts\"      : range(3, 10),\n        \"section\"    : \"Tits in Tops & Social Media\",\n        \"title\"      : \"Mia - Big titty Boston blonde\",\n        \"url\"        : \"https://titsintops.com/phpBB2/threads/mia-big-titty-boston-blonde.13575039/\",\n        \"views\"      : range(2_000, 10_000),\n        \"tags\"       : [\n            \"big tit blonde\",\n            \"blonde\",\n        ],\n    },\n},\n\n{\n    \"#url\"     : \"https://titsintops.com/phpBB2/threads/sofi_zeus-sexy-curvy-big-titty-camgirl.13586747/post-3443278\",\n    \"#comment\" : \"video attachment (#8947)\",\n    \"#category\": (\"xenforo\", \"titsintops\", \"post\"),\n    \"#class\"   : xenforo.XenforoPostExtractor,\n    \"#auth\"    : True,\n    \"#results\" : (\n        \"https://chaturbate.com/sofi_zeus/\",\n        \"https://titsintops.com/phpBB2/data/video/6436/6436292-e23925aebd8cd253097bbee0f884cf52.mp4?hash=wbvMEhEJZC\",\n        \"https://titsintops.com/phpBB2/attachments/sofi-webp.6512452/\",\n    ),\n\n    \"thread\"      : {\n        \"author\"     : \"monsieurmoose\",\n        \"author_id\"  : \"1358306\",\n        \"author_slug\": \"monsieurmoose\",\n        \"author_url\" : \"https://titsintops.com/phpBB2/members/monsieurmoose.1358306/\",\n        \"date\"       : \"dt:2026-01-16 16:34:59\",\n        \"id\"         : \"13586747\",\n        \"posts\"      : int,\n        \"section\"    : \"Busty Amateurs\",\n        \"tags\"       : (),\n        \"title\"      : \"sofi_zeus - sexy curvy big titty camgirl\",\n        \"url\"        : \"https://titsintops.com/phpBB2/threads/sofi_zeus-sexy-curvy-big-titty-camgirl.13586747/\",\n        \"views\"      : int,\n    },\n},\n\n{\n    \"#url\"     : \"https://titsintops.com/phpBB2/threads/cute_caprice-alexa-young-camgirl-with-big-tits-and-ass.13560733/post-2771597\",\n    \"#comment\" : \"attachments\",\n    \"#category\": (\"xenforo\", \"titsintops\", \"post\"),\n    \"#class\"   : xenforo.XenforoPostExtractor,\n    \"#results\" : (\n        \"https://chaturbate.com/cute_caprice/\",\n        \"https://titsintops.com/phpBB2/attachments/1-gif.5021643/\",\n        \"https://titsintops.com/phpBB2/attachments/2024-07-26-1-mp4.5021644/\",\n        \"https://titsintops.com/phpBB2/attachments/2024-07-26-2-mp4.5021645/\",\n        \"https://titsintops.com/phpBB2/attachments/2024-07-26-3-mp4.5021646/\",\n    ),\n\n    \"count\"       : 5,\n    \"post\"        : {\n        \"author_id\"  : \"132601\",\n        \"author_slug\": \"salmonskrank\",\n        \"date\"       : \"dt:2024-07-26 20:40:24\",\n        \"id\"         : \"2771597\",\n    },\n    \"thread\"      : {\n        \"author_id\"  : \"132601\",\n        \"author_slug\": \"salmonskrank\",\n        \"date\"       : \"dt:2024-07-26 20:40:24\",\n        \"id\"         : \"13560733\",\n    },\n},\n\n{\n    \"#url\"     : \"https://titsintops.com/phpBB2/threads/cute_caprice-alexa-young-camgirl-with-big-tits-and-ass.13560733/post-2778563\",\n    \"#comment\" : \"attachments 2\",\n    \"#category\": (\"xenforo\", \"titsintops\", \"post\"),\n    \"#class\"   : xenforo.XenforoPostExtractor,\n    \"#results\" : (\n        \"https://titsintops.com/phpBB2/attachments/4-webp.5037692/\",\n        \"https://titsintops.com/phpBB2/attachments/3-webp.5037693/\",\n        \"https://titsintops.com/phpBB2/attachments/2-webp.5037694/\",\n        \"https://titsintops.com/phpBB2/attachments/1-webp.5037695/\",\n    ),\n\n    \"count\"       : 4,\n    \"extension\"   : \"webp\",\n    \"type\"        : \"inline\",\n    \"post\"        : {\n        \"author_id\"  : \"132601\",\n        \"author_slug\": \"salmonskrank\",\n        \"date\"       : \"dt:2024-07-31 19:36:20\",\n        \"id\"         : \"2778563\",\n    },\n},\n\n{\n    \"#url\"     : \"https://titsintops.com/phpBB2/threads/mia-big-titty-boston-blonde.13575039/\",\n    \"#category\": (\"xenforo\", \"titsintops\", \"thread\"),\n    \"#class\"   : xenforo.XenforoThreadExtractor,\n    \"#pattern\" : r\"https://titsintops\\.com/phpBB2/attachments/.+\",\n    \"#count\"   : range(13, 100),\n\n    \"extension\"   : \"webp\",\n    \"id\"          : int,\n    \"type\"        : \"inline\",\n    \"post\"        : dict,\n    \"thread\"      : {\n        \"author\"     : \"lazywriterx\",\n        \"author_id\"  : \"323115\",\n        \"author_slug\": \"lazywriterx\",\n        \"author_url\" : \"https://titsintops.com/phpBB2/members/lazywriterx.323115/\",\n        \"date\"       : \"dt:2025-05-02 13:33:58\",\n        \"id\"         : \"13575039\",\n        \"section\"    : \"Tits in Tops & Social Media\",\n        \"title\"      : \"Mia - Big titty Boston blonde\",\n        \"url\"        : \"https://titsintops.com/phpBB2/threads/mia-big-titty-boston-blonde.13575039/\",\n        \"tags\"       : [\n            \"big tit blonde\",\n            \"blonde\",\n        ],\n    },\n},\n\n{\n    \"#url\"     : \"https://titsintops.com/phpBB2/forums/tits-in-tops-social-media.1/\",\n    \"#category\": (\"xenforo\", \"titsintops\", \"forum\"),\n    \"#class\"   : xenforo.XenforoForumExtractor,\n    \"#pattern\" : xenforo.XenforoThreadExtractor.pattern,\n    \"#range\"   : \"1-50\",\n    \"#count\"   : 50,\n},\n\n)\n"
  },
  {
    "path": "test/results/tmohentai.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import tmohentai\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://tmohentai.com/contents/653c2aeaa693c\",\n    \"#category\": (\"\", \"tmohentai\", \"gallery\"),\n    \"#class\"   : tmohentai.TmohentaiGalleryExtractor,\n    \"#pattern\" : r\"https://imgrojo\\.tmohentai\\.com/contents/653c2aeaa693c/\\d\\d\\d\\.webp\",\n    \"#count\"   : 46,\n\n    \"artists\"   : [\"Andoryu\"],\n    \"genres\"    : [\n        \"Big Breasts\",\n        \"BlowJob\",\n        \"Cheating\",\n        \"Mature\",\n        \"Milf\",\n        \"Student\",\n    ],\n    \"count\"     : 46,\n    \"extension\" : \"webp\",\n    \"gallery_id\": \"653c2aeaa693c\",\n    \"language\"  : \"Español\",\n    \"num\"       : int,\n    \"tags\"      : [\n        \"milf\",\n        \"Madre\",\n        \"enormes pechos\",\n        \"Peluda\",\n        \"nakadashi\",\n        \"cheating\",\n        \"madura\",\n        \"sexo a escondidas\",\n        \"Ama de casa\",\n        \"mamada\",\n    ],\n    \"title\"     : \"La Mama de mi Novia es tan Pervertida que no Pude Soportarlo mas\",\n    \"uploader\"  : \"NekoCreme Fansub\",\n},\n\n{\n    \"#url\"     : \"https://tmohentai.com/reader/653c2aeaa693c/paginated/1\",\n    \"#category\": (\"\", \"tmohentai\", \"gallery\"),\n    \"#class\"   : tmohentai.TmohentaiGalleryExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/toyhouse.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import toyhouse\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://www.toyhou.se/d-floe/art\",\n    \"#category\": (\"\", \"toyhouse\", \"art\"),\n    \"#class\"   : toyhouse.ToyhouseArtExtractor,\n    \"#pattern\" : r\"https://f\\d+\\.toyhou\\.se/file/f\\d+-toyhou-se/images/\\d+_\\w+\\.\\w+$\",\n    \"#range\"   : \"1-30\",\n    \"#count\"   : 30,\n\n    \"artists\"   : list,\n    \"characters\": list,\n    \"date\"      : \"type:datetime\",\n    \"hash\"      : r\"re:\\w+\",\n    \"id\"        : r\"re:\\d+\",\n    \"url\"       : str,\n    \"user\"      : \"d-floe\",\n},\n\n{\n    \"#url\"     : \"https://www.toyhou.se/kroksoc/art\",\n    \"#comment\" : \"protected by Content Warning\",\n    \"#category\": (\"\", \"toyhouse\", \"art\"),\n    \"#class\"   : toyhouse.ToyhouseArtExtractor,\n    \"#count\"   : \">= 19\",\n},\n\n{\n    \"#url\"     : \"https://toyhou.se/~images/40587320\",\n    \"#category\": (\"\", \"toyhouse\", \"image\"),\n    \"#class\"   : toyhouse.ToyhouseImageExtractor,\n    \"#sha1_content\": \"058ec8427977ab432c4cc5be5a6dd39ce18713ef\",\n\n    \"artists\"   : [\"d-floe\"],\n    \"characters\": [\"Sumi\"],\n    \"date\"      : \"dt:2021-10-08 01:32:47\",\n    \"extension\" : \"png\",\n    \"filename\"  : \"40587320_TT1NaBUr3FLkS1p\",\n    \"hash\"      : \"TT1NaBUr3FLkS1p\",\n    \"id\"        : \"40587320\",\n    \"url\"       : \"https://f2.toyhou.se/file/f2-toyhou-se/images/40587320_TT1NaBUr3FLkS1p.png\",\n},\n\n{\n    \"#url\"     : \"https://f2.toyhou.se/file/f2-toyhou-se/watermarks/36817425_bqhGcwcnU.png?1625561467\",\n    \"#comment\" : \"direct link, multiple artists\",\n    \"#category\": (\"\", \"toyhouse\", \"image\"),\n    \"#class\"   : toyhouse.ToyhouseImageExtractor,\n\n    \"artists\"   : [\n        \"http://aminoapps.com/p/92sf3z\",\n        \"kroksoc (Color)\",\n    ],\n    \"characters\": [\"Reiichi❀\"],\n    \"date\"      : \"dt:2021-07-03 20:02:02\",\n    \"hash\"      : \"bqhGcwcnU\",\n    \"id\"        : \"36817425\",\n},\n\n{\n    \"#url\"     : \"https://f2.toyhou.se/file/f2-toyhou-se/images/40587320_TT1NaBUr3FLkS1p.png\",\n    \"#category\": (\"\", \"toyhouse\", \"image\"),\n    \"#class\"   : toyhouse.ToyhouseImageExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/tumblr.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import tumblr\nfrom gallery_dl import exception\n\n\n__tests__ = (\n{\n    \"#url\"     : \"http://demo.tumblr.com/\",\n    \"#class\"   : tumblr.TumblrUserExtractor,\n    \"#options\" : {\"posts\": \"photo\"},\n    \"#pattern\" : r\"https://\\d+\\.media\\.tumblr\\.com/tumblr_[^/_]+_\\d+\\.jpg\",\n    \"#count\"   : 1,\n},\n\n{\n    \"#url\"     : \"http://demo.tumblr.com/\",\n    \"#class\"   : tumblr.TumblrUserExtractor,\n    \"#options\" : {\n        \"posts\"   : \"all\",\n        \"external\": True,\n    },\n    \"#pattern\" : r\"https?://(?:$|\\d+\\.media\\.tumblr\\.com/.+\\.(jpg|png|gif|mp3|mp4)|v?a\\.(media\\.)?tumblr\\.com/tumblr_\\w+)\",\n    \"#count\"   : 27,\n},\n\n{\n    \"#url\"     : \"https://mikf123-hidden.tumblr.com/\",\n    \"#comment\" : \"dashboard-only\",\n    \"#class\"   : tumblr.TumblrUserExtractor,\n    \"#options\"  : {\"access-token\": None},\n    \"#exception\": exception.AuthorizationError,\n},\n\n{\n    \"#url\"     : \"https://mikf123-hidden.tumblr.com/\",\n    \"#comment\" : \"dashboard-only\",\n    \"#class\"   : tumblr.TumblrUserExtractor,\n    \"#count\"   : 2,\n\n    \"tags\": [\n        \"test\",\n        \"hidden\",\n    ],\n},\n\n{\n    \"#url\"     : \"https://mikf123-private.tumblr.com/\",\n    \"#comment\" : \"password protected\",\n    \"#class\"   : tumblr.TumblrUserExtractor,\n    \"#count\"   : 2,\n\n    \"tags\": [\n        \"test\",\n        \"private\",\n    ],\n},\n\n{\n    \"#url\"     : \"https://mikf123-private-hidden.tumblr.com/\",\n    \"#comment\" : \"dashboard-only & password protected\",\n    \"#class\"   : tumblr.TumblrUserExtractor,\n    \"#count\"   : 2,\n\n    \"tags\": [\n        \"test\",\n        \"private\",\n        \"hidden\",\n    ],\n},\n\n{\n    \"#url\"     : \"https://mikf123.tumblr.com/\",\n    \"#comment\" : \"date-min/-max (#337)\",\n    \"#class\"   : tumblr.TumblrUserExtractor,\n    \"#options\" : {\n        \"date-min\": \"2018-04-01\",\n        \"date-max\": \"2018-05-01\",\n    },\n    \"#count\"   : 4,\n},\n\n{\n    \"#url\"     : \"https://mikf123.tumblr.com/\",\n    \"#comment\" : \"date-before/-after\",\n    \"#class\"   : tumblr.TumblrUserExtractor,\n    \"#options\" : {\n        \"date-before\": \"2018-05-01\",\n        \"date-after\" : \"2018-04-01\",\n    },\n    \"#count\"   : 4,\n},\n\n{\n    \"#url\"     : \"https://tumblr.com/mikf123\",\n    \"#comment\" : \"no 'www.' subdomain (#7358)\",\n    \"#class\"   : tumblr.TumblrUserExtractor,\n},\n\n{\n    \"#url\"     : \"https://donttrustthetits.tumblr.com/\",\n    \"#comment\" : \"pagination with 'date-max' (#2191) and 'api-key'\",\n    \"#class\"   : tumblr.TumblrUserExtractor,\n    \"#options\" : {\n        \"access-token\": None,\n        \"original\"    : False,\n        \"date-max\"    : \"2015-04-25T00:00:00\",\n        \"date-min\"    : \"2015-04-01T00:00:00\",\n    },\n    \"#count\"   : 192,\n},\n\n{\n    \"#url\"     : \"https://demo.tumblr.com/page/2\",\n    \"#class\"   : tumblr.TumblrUserExtractor,\n},\n\n{\n    \"#url\"     : \"https://demo.tumblr.com/archive\",\n    \"#class\"   : tumblr.TumblrUserExtractor,\n},\n\n{\n    \"#url\"     : \"tumblr:http://www.b-authentique.com/\",\n    \"#class\"   : tumblr.TumblrUserExtractor,\n},\n\n{\n    \"#url\"     : \"tumblr:www.b-authentique.com\",\n    \"#class\"   : tumblr.TumblrUserExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.tumblr.com/blog/view/smarties-art\",\n    \"#class\"   : tumblr.TumblrUserExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.tumblr.com/blog/smarties-art\",\n    \"#class\"   : tumblr.TumblrUserExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.tumblr.com/smarties-art\",\n    \"#class\"   : tumblr.TumblrUserExtractor,\n},\n\n{\n    \"#url\"     : \"http://demo.tumblr.com/post/459265350\",\n    \"#class\"   : tumblr.TumblrPostExtractor,\n    \"#pattern\" : r\"https://\\d+\\.media\\.tumblr\\.com/tumblr_[^/_]+_1280.jpg\",\n    \"#count\"   : 1,\n},\n\n{\n    \"#url\"     : \"https://mikf123.tumblr.com/post/167770226574/text-post\",\n    \"#class\"   : tumblr.TumblrPostExtractor,\n    \"#count\"   : 2,\n},\n\n{\n    \"#url\"     : \"https://mikf123.tumblr.com/post/181022561719/quote-post\",\n    \"#class\"   : tumblr.TumblrPostExtractor,\n    \"#count\"   : 1,\n},\n\n{\n    \"#url\"     : \"https://mikf123.tumblr.com/post/167623351559/link-post\",\n    \"#class\"   : tumblr.TumblrPostExtractor,\n    \"#count\"   : 2,\n},\n\n{\n    \"#url\"     : \"https://mikf123.tumblr.com/post/167633596145/video-post\",\n    \"#class\"   : tumblr.TumblrPostExtractor,\n    \"#count\"   : 2,\n},\n\n{\n    \"#url\"     : \"https://mikf123.tumblr.com/post/167770026604/audio-post\",\n    \"#class\"   : tumblr.TumblrPostExtractor,\n    \"#count\"   : 2,\n},\n\n{\n    \"#url\"     : \"https://mikf123.tumblr.com/post/172687798174/photo-post\",\n    \"#class\"   : tumblr.TumblrPostExtractor,\n    \"#count\"   : 4,\n},\n\n{\n    \"#url\"     : \"https://mikf123.tumblr.com/post/181022380064/chat-post\",\n    \"#class\"   : tumblr.TumblrPostExtractor,\n    \"#count\"   : 0,\n},\n\n{\n    \"#url\"     : \"https://kichatundk.tumblr.com/post/654953419288821760\",\n    \"#comment\" : \"high-quality images (#1846)\",\n    \"#class\"   : tumblr.TumblrPostExtractor,\n    \"#count\"       : 2,\n    \"#sha1_content\": \"d6fcc7b6f750d835d55c7f31fa3b63be26c9f89b\",\n},\n\n{\n    \"#url\"     : \"https://hameru-is-cool.tumblr.com/post/639261855227002880\",\n    \"#comment\" : \"high-quality images (#1344)\",\n    \"#class\"   : tumblr.TumblrPostExtractor,\n    \"#exception\"   : exception.NotFoundError,\n    \"#count\"       : 2,\n    \"#sha1_content\": \"6bc19a42787e46e1bba2ef4aeef5ca28fcd3cd34\",\n},\n\n{\n    \"#url\"     : \"https://k-eke.tumblr.com/post/185341184856\",\n    \"#comment\" : \"wrong extension returned by api (#3095)\",\n    \"#class\"   : tumblr.TumblrPostExtractor,\n    \"#options\"     : {\"retries\": 0},\n    \"#results\"     : \"https://64.media.tumblr.com/5e9d760aba24c65beaf0e72de5aae4dd/tumblr_psj5yaqV871t1ig6no1_1280.gif\",\n    \"#sha1_content\": \"3508d894b6cc25e364d182a8e1ff370d706965fb\",\n},\n\n{\n    \"#url\"     : \"https://mikf123.tumblr.com/image/689860196535762944\",\n    \"#class\"   : tumblr.TumblrPostExtractor,\n    \"#pattern\" : r\"^https://\\d+\\.media\\.tumblr\\.com/134791621559a79793563b636b5fe2c6/8f1131551cef6e74-bc/s99999x99999/188cf9b8915b0d0911c6c743d152fc62e8f38491\\.png$\",\n},\n\n{\n    \"#url\"     : \"http://ziemniax.tumblr.com/post/109697912859/\",\n    \"#comment\" : \"HTML response (#297)\",\n    \"#class\"   : tumblr.TumblrPostExtractor,\n    \"#exception\": exception.NotFoundError,\n},\n\n{\n    \"#url\"     : \"http://demo.tumblr.com/image/459265350\",\n    \"#class\"   : tumblr.TumblrPostExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.tumblr.com/blog/view/smarties-art/686047436641353728\",\n    \"#class\"   : tumblr.TumblrPostExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.tumblr.com/blog/smarties-art/686047436641353728\",\n    \"#class\"   : tumblr.TumblrPostExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.tumblr.com/smarties-art/686047436641353728\",\n    \"#class\"   : tumblr.TumblrPostExtractor,\n},\n\n{\n    \"#url\"     : \"https://tumblr.com/smarties-art/686047436641353728\",\n    \"#class\"   : tumblr.TumblrPostExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.tumblr.com/ominous-signs/809452804394663936/one-way-to-hell\",\n    \"#comment\" : \"'answer'/'asks' post (#4509)\",\n    \"#class\"   : tumblr.TumblrPostExtractor,\n    \"#auth\"    : True,\n    \"#results\" : \"https://64.media.tumblr.com/a68cf0e9287c4557f4c0950edafb836b/5cf90cb0eb792b63-a4/s99999x99999/139223e8f778e3005e5748099f684c7816b10b14.jpg\",\n\n    \"type\"     : \"answer\",\n},\n\n{\n    \"#url\"     : \"http://demo.tumblr.com/tagged/Times%20Square\",\n    \"#class\"   : tumblr.TumblrTagExtractor,\n    \"#pattern\" : r\"https://\\d+\\.media\\.tumblr\\.com/tumblr_[^/_]+_1280.jpg\",\n    \"#count\"   : 1,\n\n    \"search_tags\": \"Times Square\",\n},\n\n{\n    \"#url\"     : \"https://www.tumblr.com/blog/view/smarties-art/tagged/undertale\",\n    \"#class\"   : tumblr.TumblrTagExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.tumblr.com/blog/smarties-art/tagged/undertale\",\n    \"#class\"   : tumblr.TumblrTagExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.tumblr.com/smarties-art/tagged/undertale\",\n    \"#class\"   : tumblr.TumblrTagExtractor,\n},\n\n{\n    \"#url\"     : \"https://hirespokemon.tumblr.com/archive/tagged/ken%20sugimori\",\n    \"#class\"   : tumblr.TumblrTagExtractor,\n},\n\n{\n    \"#url\"     : \"https://mikf123.tumblr.com/day/2018/01/05\",\n    \"#class\"   : tumblr.TumblrDayExtractor,\n    \"#pattern\" : r\"https://64\\.media\\.tumblr\\.com/1a2be8c63f1df58abd2622861696c72a/tumblr_ozm9nqst9t1wgha4yo1_1280\\.jpg\",\n    \"#count\"   : 1,\n\n    \"id\": 169341068404,\n},\n\n{\n    \"#url\"     : \"https://www.tumblr.com/blog/view/mikf123/day/2018/01/05\",\n    \"#class\"   : tumblr.TumblrDayExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.tumblr.com/blog/mikf123/day/2018/01/05\",\n    \"#class\"   : tumblr.TumblrDayExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.tumblr.com/mikf123/day/2018/01/05\",\n    \"#class\"   : tumblr.TumblrDayExtractor,\n},\n\n{\n    \"#url\"     : \"http://mikf123.tumblr.com/likes\",\n    \"#class\"   : tumblr.TumblrLikesExtractor,\n    \"#count\"   : 1,\n},\n\n{\n    \"#url\"     : \"http://mikf123.tumblr.com/likes\",\n    \"#class\"   : tumblr.TumblrLikesExtractor,\n    \"#options\" : {\"api-secret\": None},\n    \"#count\"   : 1,\n},\n\n{\n    \"#url\"     : \"https://www.tumblr.com/blog/view/mikf123/likes\",\n    \"#class\"   : tumblr.TumblrLikesExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.tumblr.com/blog/mikf123/likes\",\n    \"#class\"   : tumblr.TumblrLikesExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.tumblr.com/mikf123/likes\",\n    \"#class\"   : tumblr.TumblrLikesExtractor,\n},\n\n{\n    \"#url\"     : \"https://tumblr.com/mikf123/likes\",\n    \"#class\"   : tumblr.TumblrLikesExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.tumblr.com/search/nathan fielder\",\n    \"#class\"   : tumblr.TumblrSearchExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.tumblr.com/search/nathan fielder/recent/quote?src=typed_query\",\n    \"#class\"   : tumblr.TumblrSearchExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.tumblr.com/search/nathan%20fielder?t=90\",\n    \"#class\"   : tumblr.TumblrSearchExtractor,\n},\n\n{\n    \"#url\"     : \"https://tumblr.com/search/nathan%20fielder?t=90\",\n    \"#class\"   : tumblr.TumblrSearchExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.tumblr.com/search/Sonic?src=typed_query\",\n    \"#class\"   : tumblr.TumblrSearchExtractor,\n    \"#options\" : {\"original\": False},\n    \"#pattern\" : r\"https://\\w+\\.media\\.tumblr\\.com/.{10,}.\\w+\",\n    \"#range\"   : \"1-10\",\n    \"count\"    : range(1, 10),\n},\n\n{\n    \"#url\"     : \"https://www.tumblr.com/mikf123/following\",\n    \"#class\"   : tumblr.TumblrFollowingExtractor,\n    \"#results\" : (\n        \"https://smarties-art.tumblr.com/\",\n        \"https://demo.tumblr.com/\",\n    ),\n\n    \"can_show_badges\": bool,\n    \"description\"    : str,\n    \"name\"           : str,\n    \"title\"          : str,\n    \"tumblrmart_accessories\": {},\n    \"updated\"        : int,\n    \"url\"            : str,\n    \"uuid\"           : str,\n},\n\n{\n    \"#url\"      : \"https://www.tumblr.com/mikf123/followers\",\n    \"#class\"    : tumblr.TumblrFollowersExtractor,\n    \"#exception\": exception.AuthorizationError,\n},\n\n)\n"
  },
  {
    "path": "test/results/tumblrgallery.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import tumblrgallery\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://tumblrgallery.xyz/tumblrblog/gallery/103975.html\",\n    \"#category\": (\"\", \"tumblrgallery\", \"tumblrblog\"),\n    \"#class\"   : tumblrgallery.TumblrgalleryTumblrblogExtractor,\n},\n\n{\n    \"#url\"     : \"https://tumblrgallery.xyz/post/405674.html\",\n    \"#category\": (\"\", \"tumblrgallery\", \"post\"),\n    \"#class\"   : tumblrgallery.TumblrgalleryPostExtractor,\n    \"#pattern\" : r\"https://78\\.media\\.tumblr\\.com/bec67072219c1f3bc04fd9711dec42ef/tumblr_p51qq1XCHS1txhgk3o1_1280\\.jpg\",\n    \"#count\"   : 3,\n},\n\n{\n    \"#url\"     : \"https://tumblrgallery.xyz/s.php?q=everyday-life\",\n    \"#category\": (\"\", \"tumblrgallery\", \"search\"),\n    \"#class\"   : tumblrgallery.TumblrgallerySearchExtractor,\n    \"#pattern\" : r\"https://\\d+\\.media\\.tumblr\\.com/.+\",\n    \"#count\"   : \"< 1000\",\n},\n\n)\n"
  },
  {
    "path": "test/results/tungsten.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import tungsten\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://tungsten.run/post/VuXCdyw9vohCAFyN\",\n    \"#class\"   : tungsten.TungstenPostExtractor,\n    \"#results\" : \"https://api.tungsten.run/v1/upload/aPg2RYeezA9wZ8uQ\",\n\n    \"comment_count\"  : int,\n    \"created_at\"     : \"2024-12-25T00:05:39Z\",\n    \"date\"           : \"dt:2024-12-25 00:05:39\",\n    \"description\"    : None,\n    \"extension\"      : \"webp\",\n    \"filename\"       : \"aPg2RYeezA9wZ8uQ\",\n    \"generation_postprocessing\": None,\n    \"generation_type\": \"flux1d\",\n    \"is_tweakable\"   : True,\n    \"like_count\"     : range(50, 200),\n    \"model_versions\" : [{\n        \"cover_url\"   : \"https://api.tungsten.run/v1/model_version_cover/48M6hN3HsQ/1726596449\",\n        \"created_at\"  : \"2024-09-17T18:05:05Z\",\n        \"fullname\"    : \"Flux.1 [Dev] - v1\",\n        \"model_base\"  : \"flux1d\",\n        \"model_name\"  : \"Flux.1 [Dev]\",\n        \"model_resolved_nsfw_score\": 0,\n        \"model_type\"  : \"checkpoint\",\n        \"model_uuid\"  : \"9fThn2WaVP\",\n        \"name\"        : \"v1\",\n        \"sm_cover_url\": \"https://api.tungsten.run/v1/model_version_cover/48M6hN3HsQ/1726596449\",\n        \"status\"      : \"approved\",\n        \"uuid\"        : \"48M6hN3HsQ\",\n        \"extra\"       : {\"dtype\": \"fp16\"},\n    }],\n    \"nsfw\"           : False,\n    \"original_url\"   : \"https://api.tungsten.run/v1/upload/aPg2RYeezA9wZ8uQ\",\n    \"original_width\" : 832,\n    \"original_height\": 1216,\n    \"rating\"         : None,\n    \"resized_url\"    : \"https://api.tungsten.run/v1/upload/jRJMBAD5zG3NFar6\",\n    \"resized_width\"  : 832,\n    \"resized_height\" : 1216,\n    \"title\"          : \"Moon\",\n    \"uuid\"           : \"VuXCdyw9vohCAFyN\",\n    \"view_count\"     : int,\n    \"tags\"           : [\n        \"art\",\n        \"moon\",\n        \"surreal\",\n    ],\n    \"generation_data\": {\n        \"cfg\"        : 4,\n        \"height\"     : 1216,\n        \"img2img\"    : None,\n        \"loras\"      : [],\n        \"model_version_uuid\": \"48M6hN3HsQ\",\n        \"num_images\" : 2,\n        \"postprocess\": [],\n        \"prompt\"     : \"low angle photograph of a woman, standing on top of a small mountain, hands in the air holding up the full moon late at night, long brunette hair, loose flowing spaghetti strap dress, hair and dress blowing in the wind, cler sky\",\n        \"sampler\"    : \"euler\",\n        \"seed\"       : 7147106691,\n        \"steps\"      : 20,\n        \"width\"      : 832,\n    },\n    \"user\"           : {\n        \"avatar_url\"   : \"https://api.tungsten.run/v1/avatar/512x512/HDiix5cCtzg2VrZU/1726485265\",\n        \"badges\"       : list,\n        \"bio\"          : \"Hello, world! 😋\",\n        \"created_at\"   : \"2024-09-16T17:18:58Z\",\n        \"display_name\" : \"Survivor\",\n        \"sm_avatar_url\": \"https://api.tungsten.run/v1/avatar/64x64/HDiix5cCtzg2VrZU/1726485265\",\n        \"type\"         : \"admin\",\n        \"username\"     : \"survivor\",\n        \"uuid\"         : \"HDiix5cCtzg2VrZU\",\n    },\n},\n\n{\n    \"#url\"     : \"https://tungsten.run/model/9vHB2hNUdg/chroma\",\n    \"#class\"   : tungsten.TungstenModelExtractor,\n    \"#pattern\" : r\"https://api\\.tungsten\\.run/v1/upload/\\w+\",\n    \"#count\"   : 22,\n\n    \"comment_count\"  : int,\n    \"created_at\"     : \"iso:8601\",\n    \"date\"           : \"type:datetime\",\n    \"extension\"      : \"webp\",\n    \"filename\"       : str,\n    \"like_count\"     : int,\n    \"nsfw\"           : bool,\n    \"original_url\"   : str,\n    \"original_width\" : int,\n    \"original_height\": int,\n    \"rating\"         : None,\n    \"resized_url\"    : str,\n    \"resized_width\"  : int,\n    \"resized_height\" : int,\n    \"title\"          : str,\n    \"uuid\"           : \"len:str:16\",\n    \"view_count\"     : int,\n    \"user\"           : {\n        \"avatar_url\"   : str,\n        \"badges\"       : list,\n        \"bio\"          : {str, None},\n        \"created_at\"   : str,\n        \"display_name\" : str,\n        \"sm_avatar_url\": str,\n        \"type\"         : \"regular\",\n        \"username\"     : str,\n        \"uuid\"         : \"len:str:16\",\n    },\n},\n\n{\n    \"#url\"     : \"https://tungsten.run/model/9vHB2hNUdg?model_version=CxvEH6esrG\",\n    \"#class\"   : tungsten.TungstenModelExtractor,\n    \"#range\"   : \"1-50\",\n    \"#pattern\" : r\"https://api\\.tungsten\\.run/v1/upload/\\w+\",\n    \"#count\"   : 50,\n},\n\n{\n    \"#url\"     : \"https://tungsten.run/user/lynodie\",\n    \"#class\"   : tungsten.TungstenUserExtractor,\n    \"#range\"   : \"1-50\",\n    \"#pattern\" : r\"https://api\\.tungsten\\.run/v1/upload/\\w+\",\n    \"#count\"   : 50,\n\n    \"user\"           : {\n        \"avatar_url\"   : \"https://api.tungsten.run/v1/avatar/512x512/EYJsXQDD69u26NYT/1751499148\",\n        \"bio\"          : \"Just a normal lesbian girl from Germany.\",\n        \"created_at\"   : \"2024-09-16T18:01:20Z\",\n        \"display_name\" : \"Sarah\",\n        \"sm_avatar_url\": \"https://api.tungsten.run/v1/avatar/64x64/EYJsXQDD69u26NYT/1751499148\",\n        \"type\"         : \"regular\",\n        \"username\"     : \"lynodie\",\n        \"uuid\"         : \"EYJsXQDD69u26NYT\",\n    },\n},\n\n{\n    \"#url\"     : \"https://tungsten.run/user/RD.Tungsten?tag=thanks&sort=top_month\",\n    \"#comment\" : \"query parameters\",\n    \"#class\"   : tungsten.TungstenUserExtractor,\n    \"#results\" : \"https://api.tungsten.run/v1/upload/TYLShY6EDVyiV8ye\",\n\n    \"comment_count\"  : int,\n    \"created_at\"     : \"2025-08-10T11:13:55Z\",\n    \"date\"           : \"dt:2025-08-10 11:13:55\",\n    \"extension\"      : \"webp\",\n    \"filename\"       : \"TYLShY6EDVyiV8ye\",\n    \"like_count\"     : range(15, 50),\n    \"nsfw\"           : False,\n    \"original_height\": 1216,\n    \"original_url\"   : \"https://api.tungsten.run/v1/upload/TYLShY6EDVyiV8ye\",\n    \"original_width\" : 832,\n    \"rating\"         : None,\n    \"resized_height\" : 936,\n    \"resized_url\"    : \"https://api.tungsten.run/v1/upload/bMfhipdctg79TqCo\",\n    \"resized_width\"  : 640,\n    \"search_tags\"    : \"thanks\",\n    \"title\"          : \"Thank you !!\",\n    \"uuid\"           : \"FcRnVM6btz7vbM9Z\",\n    \"view_count\"     : int,\n    \"user\"           : {\n        \"avatar_url\"   : \"https://api.tungsten.run/v1/avatar/512x512/dTqnGJmASg9WSjfL/1746094098\",\n        \"badges\"       : [],\n        \"bio\"          : None,\n        \"created_at\"   : \"2025-04-27T14:48:27Z\",\n        \"display_name\" : \"RD.Tungsten\",\n        \"sm_avatar_url\": \"https://api.tungsten.run/v1/avatar/64x64/dTqnGJmASg9WSjfL/1746094098\",\n        \"type\"         : \"regular\",\n        \"username\"     : \"RD.Tungsten\",\n        \"uuid\"         : \"dTqnGJmASg9WSjfL\",\n    },\n},\n\n)\n"
  },
  {
    "path": "test/results/turbo.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import turbo\n\n\n__tests__ = (\n{\n    \"#url\"  : \"https://turbo.cr/a/2c5iuWHTumH\",\n    \"#class\": turbo.TurboAlbumExtractor,\n    \"#pattern\": (\n        r\"https://dl\\d+.turbocdn.st/data/3b125e3fb4b98693f17d85cb53590215.mp4\\?exp=\\d+&token=\\w+&fn=3b125e3fb4b98693f17d85cb53590215.mp4\",\n        r\"https://dl\\d+.turbocdn.st/data/3b1ccebf3576f8d5aac3ee0e5a12da95.mp4\\?exp=\\d+&token=\\w+&fn=3b1ccebf3576f8d5aac3ee0e5a12da95.mp4\",\n    ),\n\n    \"album_id\"   : \"2c5iuWHTumH\",\n    \"album_name\" : \"animations\",\n    \"album_size\" : 37083862,\n    \"count\"      : 2,\n    \"description\": \"Descriptions can contain only alphanumeric ASCII characters\",\n    \"extension\"  : \"mp4\",\n    \"file\"       : r\"re:https://...\",\n    \"filename\"   : {\"3b1ccebf3576f8d5aac3ee0e5a12da95-6lC7mKrJst8\",\n                    \"3b125e3fb4b98693f17d85cb53590215-ze10Ohbpoy5\"},\n    \"id\"         : {\"6lC7mKrJst8\",\n                    \"ze10Ohbpoy5\"},\n    \"name\"       : {\"3b1ccebf3576f8d5aac3ee0e5a12da95\",\n                    \"3b125e3fb4b98693f17d85cb53590215\"},\n    \"num\"        : {1, 2},\n},\n\n{\n    \"#url\"     : \"https://turbovid.cr/a/FiphGijfJoR\",\n    \"#comment\" : \"'turbovid' album (#8851)\",\n    \"#category\": (\"lolisafe\", \"turbo\", \"album\"),\n    \"#class\"   : turbo.TurboAlbumExtractor,\n    \"#pattern\" : (\n        r\"https://dl\\d+.turbocdn.st/data/WkD7hRaHdBpBI.mp4\\?exp=\\d+&token=\\w+&fn=3b1ccebf3576f8d5aac3ee0e5a12da95-6lC7mKrJst8.mp4\",\n        r\"https://dl\\d+.turbocdn.st/data/eJ9fLurGdaHqS.mp4\\?exp=\\d+&token=\\w+&fn=3b125e3fb4b98693f17d85cb53590215-ze10Ohbpoy5.mp4\",\n        r\"https://dl\\d+.turbocdn.st/data/jZqe1xxqw9bX7.mp4\\?exp=\\d+&token=\\w+&fn=test-%E3%83%86%E3%82%B9%E3%83%88-%2522%26%3E.mp4\",\n    ),\n\n    \"album_id\"   : \"FiphGijfJoR\",\n    \"album_name\" : \"\"\"test-テスト-\"&> album\"\"\",\n    \"album_size\" : 37165256,\n    \"count\"      : 3,\n    \"num\"        : range(1, 3),\n    \"description\": \"\"\"test-テスト-\"&> description\"\"\",\n    \"extension\"  : \"mp4\",\n    \"file\"       : r\"re:https://dl\\d+.turbocdn.st/data/.+\",\n    \"filename\"   : str,\n    \"id\"         : str,\n    \"name\"       : str,\n    \"size\"       : int,\n},\n\n{\n    \"#url\"     : \"https://saint2.su/a/FiphGijfJoR\",\n    \"#comment\" : \"'saint' album (#8888)\",\n    \"#class\"   : turbo.TurboAlbumExtractor,\n},\n\n{\n    \"#url\"  : \"https://turbo.cr/embed/6lC7mKrJst8\",\n    \"#class\": turbo.TurboMediaExtractor,\n    \"#pattern\"     : r\"https://dl\\d+.turbocdn.st/data/3b1ccebf3576f8d5aac3ee0e5a12da95.mp4\",\n    \"#sha1_content\": \"39037a029b3fe96f838b4545316caaa545c84075\",\n\n    \"count\"    : 1,\n    \"date\"     : \"dt:2024-10-18 00:00:00\",\n    \"extension\": \"mp4\",\n    \"file\"     : str,\n    \"filename\" : \"3b1ccebf3576f8d5aac3ee0e5a12da95-6lC7mKrJst8\",\n    \"id\"       : \"6lC7mKrJst8\",\n    \"name\"     : \"3b1ccebf3576f8d5aac3ee0e5a12da95\",\n    \"num\"      : 1,\n},\n\n{\n    \"#url\"    : \"https://turbo.cr/d/M2IxMjVlM2ZiNGI5ODY5M2YxN2Q4NWNiNTM1OTAyMTUubXA0\",\n    \"#comment\": \"'Page not found'\",\n    \"#class\"  : turbo.TurboMediaExtractor,\n    \"#count\"  : 0,\n},\n\n{\n    \"#url\"  : \"https://saint2.pk/embed/6lC7mKrJst8\",\n    \"#class\": turbo.TurboMediaExtractor,\n},\n\n{\n    \"#url\"  : \"https://saint2.cr/embed/6lC7mKrJst8\",\n    \"#class\": turbo.TurboMediaExtractor,\n},\n\n{\n    \"#url\"  : \"https://saint.to/embed/6lC7mKrJst8\",\n    \"#class\": turbo.TurboMediaExtractor,\n},\n\n{\n    \"#url\"     : \"https://turbovid.cr/embed/WkD7hRaHdBpBI\",\n    \"#comment\" : \"'turbovid' URL\",\n    \"#category\": (\"lolisafe\", \"turbo\", \"media\"),\n    \"#class\"   : turbo.TurboMediaExtractor,\n    \"#pattern\" : r\"https://dl\\d+.turbocdn.st/data/\\w+.mp4\",\n\n    \"extension\"  : \"mp4\",\n    \"file\"       : str,\n    \"filename\"   : \"3b1ccebf3576f8d5aac3ee0e5a12da95-6lC7mKrJst8-WkD7hRaHdBpBI\",\n    \"id\"         : \"WkD7hRaHdBpBI\",\n    \"name\"       : \"3b1ccebf3576f8d5aac3ee0e5a12da95-6lC7mKrJst8\",\n},\n\n{\n    \"#url\"     : \"https://saint2.su/embed/WkD7hRaHdBpBI\",\n    \"#comment\" : \"'saint' URL\",\n    \"#category\": (\"lolisafe\", \"turbo\", \"media\"),\n    \"#class\"   : turbo.TurboMediaExtractor,\n},\n\n{\n    \"#url\"     : \"https://turbo.cr/v/6lC7mKrJst8\",\n    \"#comment\" : \"'/v/' URL\",\n    \"#category\": (\"lolisafe\", \"turbo\", \"media\"),\n    \"#class\"   : turbo.TurboMediaExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/turboimagehost.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import imagehosts\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://www.turboimagehost.com/p/39078423/test--.png.html\",\n    \"#category\": (\"imagehost\", \"turboimagehost\", \"image\"),\n    \"#class\"   : imagehosts.TurboimagehostImageExtractor,\n    \"#sha1_url\"     : \"b94de43612318771ced924cb5085976f13b3b90e\",\n    \"#sha1_content\" : (\n        \"f38b54b17cd7462e687b58d83f00fca88b1b105a\",\n        \"0c8768055e4e20e7c7259608b67799171b691140\",\n        \"961b25d85b5f5bd18cbe3e847ac55925f14d0286\"\n    ),\n\n    \"filename\" : \"test--\",\n    \"extension\": \"png\",\n    \"token\"    : \"39078423\",\n    \"post_url\" : \"https://www.turboimagehost.com/p/39078423/test--.png.html\",\n},\n\n{\n    \"#url\"     : \"https://www.turboimagehost.com/album/344597/testimagegallery\",\n    \"#category\": (\"imagehost\", \"turboimagehost\", \"gallery\"),\n    \"#class\"   : imagehosts.TurboimagehostGalleryExtractor,\n    \"#pattern\" : imagehosts.TurboimagehostImageExtractor.pattern,\n    \"#sha1_url\": \"f2d4fe102fdd71dd1f595cdb0c16ce999d6bb19b\",\n    \"#count\"   : 110,\n},\n\n)\n"
  },
  {
    "path": "test/results/twibooru.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import twibooru\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://twibooru.org/1\",\n    \"#category\": (\"philomena\", \"twibooru\", \"post\"),\n    \"#class\"   : twibooru.TwibooruPostExtractor,\n    \"#pattern\"     : \"https://cdn.twibooru.org/img/2020/7/8/1/full.png\",\n    \"#sha1_content\": \"aac4d1dba611883ac701aaa8f0b2b322590517ae\",\n\n    \"animated\"        : False,\n    \"aspect_ratio\"    : 1.0,\n    \"comment_count\"   : int,\n    \"created_at\"      : \"2020-07-08T22:26:55.743Z\",\n    \"date\"            : \"dt:2020-07-08 22:26:55\",\n    \"description\"     : \"Why have I done this?\",\n    \"downvotes\"       : range(1, 10),\n    \"duration\"        : 0.0,\n    \"faves\"           : int,\n    \"first_seen_at\"   : \"2020-07-08T22:26:55.743Z\",\n    \"format\"          : \"png\",\n    \"height\"          : 576,\n    \"hidden_from_users\": False,\n    \"id\"              : 1,\n    \"intensities\"     : dict,\n    \"media_type\"      : \"image\",\n    \"mime_type\"       : \"image/png\",\n    \"name\"            : \"1676547__safe_artist-colon-scraggleman_oc_oc-colon-floor+bored_oc+only_bags+under+eyes_bust_earth+pony_female_goggles_helmet_mare_meme_neet_neet+home+g.png\",\n    \"orig_sha512_hash\": r\"re:8b4c00d2[0-9a-f]{120}\",\n    \"processed\"       : True,\n    \"representations\" : dict,\n    \"score\"           : int,\n    \"sha512_hash\"     : \"8b4c00d2eff52d51ad9647e14738944ab306fd1d8e1bf634fbb181b32f44070aa588938e26c4eb072b1eb61489aaf3062fb644a76c79f936b97723a2c3e0e5d3\",\n    \"size\"            : 70910,\n    \"source_url\"      : str,\n    \"tag_ids\"         : list,\n    \"tags\"            : list,\n    \"thumbnails_generated\": True,\n    \"updated_at\"      : str,\n    \"upvotes\"         : int,\n    \"view_url\"        : \"https://cdn.twibooru.org/img/2020/7/8/1/full.png\",\n    \"width\"           : 576,\n    \"wilson_score\"    : float,\n    \"locations\": [\n        {\n            \"id_at_location\": 1676547,\n            \"location\": \"derpibooru\",\n            \"url_at_location\": \"https://derpibooru.org/images/1676547\",\n        },\n    ],\n\n},\n\n{\n    \"#url\"     : \"https://twibooru.org/523964\",\n    \"#comment\" : \"svg (#5643)\",\n    \"#category\": (\"philomena\", \"twibooru\", \"post\"),\n    \"#class\"   : twibooru.TwibooruPostExtractor,\n    \"#results\"     : \"https://cdn.twibooru.org/img/2020/7/13/523964/full.svg\",\n    \"#sha1_content\": \"15590fe151ff65ef767b409e46dfdf708b339f4d\",\n\n    \"extension\": \"svg\",\n    \"format\"   : \"svg\",\n},\n\n{\n    \"#url\"     : \"https://twibooru.org/523964\",\n    \"#comment\" : \"svg (#5643)\",\n    \"#category\": (\"philomena\", \"twibooru\", \"post\"),\n    \"#class\"   : twibooru.TwibooruPostExtractor,\n    \"#options\" : {\"svg\": False},\n    \"#results\"     : \"https://cdn.twibooru.org/img/2020/7/13/523964/full.png\",\n    \"#sha1_content\": \"f8ff78e6a929a024f8529199f9a600617898d03c\",\n\n    \"extension\": \"png\",\n    \"format\"   : \"svg\",\n},\n\n{\n    \"#url\"     : \"https://twibooru.org/search?q=cute\",\n    \"#category\": (\"philomena\", \"twibooru\", \"search\"),\n    \"#class\"   : twibooru.TwibooruSearchExtractor,\n    \"#range\"   : \"40-60\",\n    \"#count\"   : 21,\n},\n\n{\n    \"#url\"     : \"https://twibooru.org/tags/cute\",\n    \"#category\": (\"philomena\", \"twibooru\", \"search\"),\n    \"#class\"   : twibooru.TwibooruSearchExtractor,\n    \"#range\"   : \"1-20\",\n    \"#count\"   : 20,\n},\n\n{\n    \"#url\"     : \"https://www.twibooru.org/tags/cute\",\n    \"#class\"   : twibooru.TwibooruSearchExtractor,\n},\n\n{\n    \"#url\"     : \"https://twibooru.org/galleries/1\",\n    \"#category\": (\"philomena\", \"twibooru\", \"gallery\"),\n    \"#class\"   : twibooru.TwibooruGalleryExtractor,\n    \"#range\"   : \"1-20\",\n\n    \"gallery\": {\n        \"description\"    : \"Best nation pone and russian related pics.\",\n        \"id\"             : 1,\n        \"spoiler_warning\": \"Russia\",\n        \"thumbnail_id\"   : 694923,\n        \"title\"          : \"Marussiaverse\",\n    },\n},\n\n)\n"
  },
  {
    "path": "test/results/twitter.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import twitter\nfrom gallery_dl import util, exception\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://twitter.com/supernaturepics\",\n    \"#category\": (\"\", \"twitter\", \"user\"),\n    \"#class\"   : twitter.TwitterUserExtractor,\n    \"#options\" : {\"include\": \"all\"},\n    \"#results\" : (\n        \"https://x.com/supernaturepics/info\",\n        \"https://x.com/supernaturepics/photo\",\n        \"https://x.com/supernaturepics/header_photo\",\n        \"https://x.com/supernaturepics/timeline\",\n        \"https://x.com/supernaturepics/tweets\",\n        \"https://x.com/supernaturepics/media\",\n        \"https://x.com/supernaturepics/with_replies\",\n        \"https://x.com/supernaturepics/highlights\",\n        \"https://x.com/supernaturepics/likes\",\n    ),\n},\n\n{\n    \"#url\"     : \"https://mobile.twitter.com/supernaturepics?p=i\",\n    \"#category\": (\"\", \"twitter\", \"user\"),\n    \"#class\"   : twitter.TwitterUserExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.twitter.com/id:2976459548\",\n    \"#category\": (\"\", \"twitter\", \"user\"),\n    \"#class\"   : twitter.TwitterUserExtractor,\n},\n\n{\n    \"#url\"     : \"https://twitter.com/i/user/2976459548\",\n    \"#category\": (\"\", \"twitter\", \"user\"),\n    \"#class\"   : twitter.TwitterUserExtractor,\n},\n\n{\n    \"#url\"     : \"https://twitter.com/intent/user?user_id=2976459548\",\n    \"#category\": (\"\", \"twitter\", \"user\"),\n    \"#class\"   : twitter.TwitterUserExtractor,\n},\n\n{\n    \"#url\"     : \"https://fxtwitter.com/supernaturepics\",\n    \"#category\": (\"\", \"twitter\", \"user\"),\n    \"#class\"   : twitter.TwitterUserExtractor,\n},\n\n{\n    \"#url\"     : \"https://vxtwitter.com/supernaturepics\",\n    \"#category\": (\"\", \"twitter\", \"user\"),\n    \"#class\"   : twitter.TwitterUserExtractor,\n},\n\n{\n    \"#url\"     : \"https://fixupx.com/supernaturepics\",\n    \"#category\": (\"\", \"twitter\", \"user\"),\n    \"#class\"   : twitter.TwitterUserExtractor,\n},\n\n{\n    \"#url\"     : \"https://fixvx.com/supernaturepics\",\n    \"#category\": (\"\", \"twitter\", \"user\"),\n    \"#class\"   : twitter.TwitterUserExtractor,\n},\n\n{\n    \"#url\"     : \"https://x.com/supernaturepics\",\n    \"#category\": (\"\", \"twitter\", \"user\"),\n    \"#class\"   : twitter.TwitterUserExtractor,\n},\n\n{\n    \"#url\"     : \"https://twitter.com/supernaturepics/timeline\",\n    \"#category\": (\"\", \"twitter\", \"timeline\"),\n    \"#class\"   : twitter.TwitterTimelineExtractor,\n    \"#range\"   : \"1-40\",\n    \"#sha1_url\": \"c570ac1aae38ed1463be726cc46f31cac3d82a40\",\n\n    \"author\": {\n        \"date\"            : \"dt:2015-01-12 10:25:22\",\n        \"description\"     : \"The very best nature pictures.\",\n        \"favourites_count\": int,\n        \"followers_count\" : int,\n        \"friends_count\"   : int,\n        \"listed_count\"    : int,\n        \"media_count\"     : int,\n        \"statuses_count\"  : int,\n        \"id\"              : 2976459548,\n        \"location\"        : \"Earth\",\n        \"name\"            : \"supernaturepics\",\n        \"nick\"            : \"Nature Pictures\",\n        \"profile_banner\"  : \"https://pbs.twimg.com/profile_banners/2976459548/1421058583\",\n        \"profile_image\"   : \"https://pbs.twimg.com/profile_images/554585280938659841/FLVAlX18.jpeg\",\n        \"protected\"       : False,\n        \"verified\"        : False,\n    },\n    \"user\": {\n        \"date\"            : \"dt:2015-01-12 10:25:22\",\n        \"description\"     : \"The very best nature pictures.\",\n        \"favourites_count\": int,\n        \"followers_count\" : int,\n        \"friends_count\"   : int,\n        \"listed_count\"    : int,\n        \"media_count\"     : int,\n        \"statuses_count\"  : int,\n        \"id\"              : 2976459548,\n        \"location\"        : \"Earth\",\n        \"name\"            : \"supernaturepics\",\n        \"nick\"            : \"Nature Pictures\",\n        \"profile_banner\"  : \"https://pbs.twimg.com/profile_banners/2976459548/1421058583\",\n        \"profile_image\"   : \"https://pbs.twimg.com/profile_images/554585280938659841/FLVAlX18.jpeg\",\n        \"protected\"       : False,\n        \"verified\"        : False,\n    },\n    \"tweet_id\"       : range(400000000000000000, 800000000000000000),\n    \"conversation_id\": range(400000000000000000, 800000000000000000),\n    \"quote_id\"       : 0,\n    \"reply_id\"       : 0,\n    \"retweet_id\"     : 0,\n    \"count\"          : range(1, 4),\n    \"num\"            : range(1, 4),\n    \"favorite_count\" : int,\n    \"quote_count\"    : int,\n    \"reply_count\"    : int,\n    \"retweet_count\"  : int,\n    \"content\"        : str,\n    \"lang\"           : str,\n    \"date\"           : \"type:datetime\",\n    \"sensitive\"      : False,\n    \"source\"         : \"nature_pics\",\n},\n\n{\n    \"#url\"     : \"https://twitter.com/OptionalTypo/timeline\",\n    \"#comment\" : \"suspended account (#2216)\",\n    \"#category\": (\"\", \"twitter\", \"timeline\"),\n    \"#class\"   : twitter.TwitterTimelineExtractor,\n    \"#exception\": exception.NotFoundError,\n},\n\n{\n    \"#url\"     : \"https://twitter.com/id:772949683521978368/timeline\",\n    \"#comment\" : \"suspended account user ID\",\n    \"#category\": (\"\", \"twitter\", \"timeline\"),\n    \"#class\"   : twitter.TwitterTimelineExtractor,\n    \"#exception\": exception.NotFoundError,\n},\n\n{\n    \"#url\"     : \"https://mobile.twitter.com/supernaturepics/timeline#t\",\n    \"#category\": (\"\", \"twitter\", \"timeline\"),\n    \"#class\"   : twitter.TwitterTimelineExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.twitter.com/id:2976459548/timeline\",\n    \"#category\": (\"\", \"twitter\", \"timeline\"),\n    \"#class\"   : twitter.TwitterTimelineExtractor,\n},\n\n{\n    \"#url\"     : \"https://twitter.com/supernaturepics/tweets\",\n    \"#category\": (\"\", \"twitter\", \"tweets\"),\n    \"#class\"   : twitter.TwitterTweetsExtractor,\n    \"#range\"   : \"1-40\",\n    \"#sha1_url\": \"c570ac1aae38ed1463be726cc46f31cac3d82a40\",\n},\n\n{\n    \"#url\"     : \"https://mobile.twitter.com/supernaturepics/tweets#t\",\n    \"#category\": (\"\", \"twitter\", \"tweets\"),\n    \"#class\"   : twitter.TwitterTweetsExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.twitter.com/id:2976459548/tweets\",\n    \"#category\": (\"\", \"twitter\", \"tweets\"),\n    \"#class\"   : twitter.TwitterTweetsExtractor,\n},\n\n{\n    \"#url\"     : \"https://twitter.com/supernaturepics/with_replies\",\n    \"#category\": (\"\", \"twitter\", \"with-replies\"),\n    \"#class\"   : twitter.TwitterWithRepliesExtractor,\n    \"#range\"   : \"1-40\",\n    \"#sha1_url\": \"c570ac1aae38ed1463be726cc46f31cac3d82a40\",\n},\n\n{\n    \"#url\"     : \"https://mobile.twitter.com/supernaturepics/with_replies#t\",\n    \"#category\": (\"\", \"twitter\", \"with-replies\"),\n    \"#class\"   : twitter.TwitterWithRepliesExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.twitter.com/id:2976459548/with_replies\",\n    \"#category\": (\"\", \"twitter\", \"with-replies\"),\n    \"#class\"   : twitter.TwitterWithRepliesExtractor,\n},\n\n{\n    \"#url\"     : \"https://twitter.com/supernaturepics/media\",\n    \"#category\": (\"\", \"twitter\", \"media\"),\n    \"#class\"   : twitter.TwitterMediaExtractor,\n    \"#range\"   : \"1-40\",\n    \"#sha1_url\": \"c570ac1aae38ed1463be726cc46f31cac3d82a40\",\n},\n\n{\n    \"#url\"     : \"https://mobile.twitter.com/supernaturepics/media#t\",\n    \"#category\": (\"\", \"twitter\", \"media\"),\n    \"#class\"   : twitter.TwitterMediaExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.twitter.com/id:2976459548/media\",\n    \"#category\": (\"\", \"twitter\", \"media\"),\n    \"#class\"   : twitter.TwitterMediaExtractor,\n},\n\n{\n    \"#url\"     : \"https://twitter.com/supernaturepics/likes\",\n    \"#category\": (\"\", \"twitter\", \"likes\"),\n    \"#class\"   : twitter.TwitterLikesExtractor,\n},\n\n{\n    \"#url\"     : \"https://twitter.com/i/bookmarks\",\n    \"#category\": (\"\", \"twitter\", \"bookmark\"),\n    \"#class\"   : twitter.TwitterBookmarkExtractor,\n},\n\n{\n    \"#url\"     : \"https://twitter.com/i/lists/784214683683127296\",\n    \"#category\": (\"\", \"twitter\", \"list\"),\n    \"#class\"   : twitter.TwitterListExtractor,\n    \"#range\"   : \"1-40\",\n    \"#count\"   : 40,\n    \"#archive\" : False,\n},\n\n{\n    \"#url\"     : \"https://twitter.com/i/lists/784214683683127296/members\",\n    \"#category\": (\"\", \"twitter\", \"list-members\"),\n    \"#class\"   : twitter.TwitterListMembersExtractor,\n    \"#pattern\" : twitter.TwitterUserExtractor.pattern,\n    \"#range\"   : \"1-40\",\n    \"#count\"   : 40,\n},\n\n{\n    \"#url\"     : \"https://twitter.com/supernaturepics/following\",\n    \"#category\": (\"\", \"twitter\", \"following\"),\n    \"#class\"   : twitter.TwitterFollowingExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.twitter.com/id:2976459548/following\",\n    \"#category\": (\"\", \"twitter\", \"following\"),\n    \"#class\"   : twitter.TwitterFollowingExtractor,\n},\n\n{\n    \"#url\"     : \"https://twitter.com/supernaturepics/followers\",\n    \"#category\": (\"\", \"twitter\", \"followers\"),\n    \"#class\"   : twitter.TwitterFollowersExtractor,\n},\n\n{\n    \"#url\"     : \"https://twitter.com/search?q=nature\",\n    \"#category\": (\"\", \"twitter\", \"search\"),\n    \"#class\"   : twitter.TwitterSearchExtractor,\n    \"#range\"   : \"1-20\",\n    \"#count\"   : 20,\n    \"#archive\" : False,\n},\n\n{\n    \"#url\"     : \"https://twitter.com/hashtag/nature\",\n    \"#category\": (\"\", \"twitter\", \"hashtag\"),\n    \"#class\"   : twitter.TwitterHashtagExtractor,\n    \"#pattern\" : twitter.TwitterSearchExtractor.pattern,\n    \"#results\" : \"https://x.com/search?q=%23nature\",\n},\n\n{\n    \"#url\"     : \"https://twitter.com/i/events/1484669206993903616\",\n    \"#category\": (\"\", \"twitter\", \"event\"),\n    \"#class\"   : twitter.TwitterEventExtractor,\n    \"#range\"   : \"1-20\",\n    \"#count\"   : \">=1\",\n},\n\n{\n    \"#url\"     : \"https://twitter.com/i/communities\",\n    \"#category\": (\"\", \"twitter\", \"communities\"),\n    \"#class\"   : twitter.TwitterCommunitiesExtractor,\n    \"#range\"   : \"1-20\",\n    \"#count\"   : 20,\n},\n\n{\n    \"#url\"     : \"https://twitter.com/i/communities/1651515740753735697\",\n    \"#category\": (\"\", \"twitter\", \"community\"),\n    \"#class\"   : twitter.TwitterCommunityExtractor,\n    \"#range\"   : \"1-20\",\n    \"#count\"   : 20,\n},\n\n{\n    \"#url\"     : \"https://twitter.com/supernaturepics/status/604341487988576256\",\n    \"#comment\" : \"all Tweets from a 'conversation' (#1319)\",\n    \"#category\": (\"\", \"twitter\", \"tweet\"),\n    \"#class\"   : twitter.TwitterTweetExtractor,\n    \"#sha1_url\"    : \"88a40f7d25529c2501c46f2218f9e0de9aa634b4\",\n    \"#sha1_content\": \"ab05e1d8d21f8d43496df284d31e8b362cd3bcab\",\n},\n\n{\n    \"#url\"     : \"https://twitter.com/perrypumas/status/894001459754180609\",\n    \"#comment\" : \"4 images\",\n    \"#category\": (\"\", \"twitter\", \"tweet\"),\n    \"#class\"   : twitter.TwitterTweetExtractor,\n    \"#sha1_url\": \"3a2a43dc5fb79dd5432c701d8e55e87c4e551f47\",\n\n    \"type\"        : \"photo\",\n    \"source_id\"   : 0,\n    \"!source_user\": dict,\n},\n\n{\n    \"#url\"     : \"https://twitter.com/perrypumas/status/1065692031626829824?s=20\",\n    \"#comment\" : \"video\",\n    \"#category\": (\"\", \"twitter\", \"tweet\"),\n    \"#class\"   : twitter.TwitterTweetExtractor,\n    \"#pattern\" : r\"https://video.twimg.com/ext_tw_video/.+\\.mp4\\?tag=5\",\n\n    \"type\": \"video\",\n},\n\n{\n    \"#url\"     : \"https://x.com/GrimboGrim/status/1839019491835129889\",\n    \"#comment\" : \"animated GIF\",\n    \"#class\"   : twitter.TwitterTweetExtractor,\n    \"#results\" : \"https://video.twimg.com/tweet_video/GYWCeZAaMAQ32uh.mp4\",\n\n    \"extension\": \"mp4\",\n    \"type\"     : \"animated_gif\",\n},\n\n{\n    \"#url\"     : \"https://x.com/carrotsprout_/status/1577924293023133696\",\n    \"#comment\" : \"mixed image & video\",\n    \"#class\"   : twitter.TwitterTweetExtractor,\n    \"#results\" : (\n        \"https://pbs.twimg.com/media/FeXpxOyaYAA9L88?format=jpg&name=orig\",\n        \"https://video.twimg.com/ext_tw_video/1577924276447248386/pu/vid/720x800/kNsjUvJ5knrSx5WM.mp4?tag=12\",\n    ),\n},\n\n{\n    \"#url\"     : \"https://x.com/gopherfootball/status/1950259395432239395\",\n    \"#comment\" : \"mixed images & video; 'videos' disabled (#7932)\",\n    \"#class\"   : twitter.TwitterTweetExtractor,\n    \"#options\" : {\"videos\": False},\n    \"#results\" : (\n        \"https://pbs.twimg.com/media/GxC2eRJWAAAH_NM?format=jpg&name=orig\",\n        \"https://pbs.twimg.com/media/GxC2eRAWoAA8gGQ?format=jpg&name=orig\",\n    ),\n},\n\n{\n    \"#url\"     : \"https://twitter.com/playpokemon/status/1263832915173048321/\",\n    \"#comment\" : \"content with emoji, newlines, hashtags (#338)\",\n    \"#category\": (\"\", \"twitter\", \"tweet\"),\n    \"#class\"   : twitter.TwitterTweetExtractor,\n\n    \"source\" : \"Sprinklr\",\n    \"content\": r\"\"\"re:Gear up for #PokemonSwordShieldEX with special Mystery Gifts! \\n\nYou’ll be able to receive four Galarian form Pokémon with Hidden Abilities, plus some very useful items. It’s our \\(Mystery\\) Gift to you, Trainers! \\n\n❓🎁➡️ \"\"\",\n},\n\n{\n    \"#url\"     : \"https://twitter.com/i/web/status/1170041925560258560\",\n    \"#comment\" : \"'replies' option (#705)\",\n    \"#category\": (\"\", \"twitter\", \"tweet\"),\n    \"#class\"   : twitter.TwitterTweetExtractor,\n    \"#pattern\" : \"https://pbs.twimg.com/media/EDzS7VrU0AAFL4_\",\n},\n\n{\n    \"#url\"     : \"https://twitter.com/i/web/status/1170041925560258560\",\n    \"#comment\" : \"'replies' option (#705)\",\n    \"#category\": (\"\", \"twitter\", \"tweet\"),\n    \"#class\"   : twitter.TwitterTweetExtractor,\n    \"#options\" : {\"replies\": False},\n    \"#count\"   : 0,\n},\n\n{\n    \"#url\"     : \"https://twitter.com/i/web/status/1424882930803908612\",\n    \"#comment\" : \"'replies' to self (#1254)\",\n    \"#category\": (\"\", \"twitter\", \"tweet\"),\n    \"#class\"   : twitter.TwitterTweetExtractor,\n    \"#options\" : {\"replies\": \"self\"},\n    \"#count\"   : 4,\n\n    \"user\": {\n        \"description\": r\"re:business email-- rhettaro.bloom@gmail.com patreon- http://patreon.com/Princecanary\",\n        \"url\"        : \"http://princecanary.tumblr.com\",\n    },\n},\n\n{\n    \"#url\"     : \"https://twitter.com/i/web/status/1424898916156284928\",\n    \"#category\": (\"\", \"twitter\", \"tweet\"),\n    \"#class\"   : twitter.TwitterTweetExtractor,\n    \"#options\" : {\"replies\": \"self\"},\n    \"#count\"   : 1,\n},\n\n{\n    \"#url\"     : \"https://twitter.com/StobiesGalaxy/status/1270755918330896395\",\n    \"#comment\" : \"quoted tweet (#526, #854)\",\n    \"#category\": (\"\", \"twitter\", \"tweet\"),\n    \"#class\"   : twitter.TwitterTweetExtractor,\n    \"#options\" : {\"quoted\": True},\n    \"#pattern\" : r\"https://pbs\\.twimg\\.com/media/Ea[KG].+=jpg\",\n    \"#count\"   : 8,\n},\n\n{\n    \"#url\"     : \"https://twitter.com/StobiesGalaxy/status/1270755918330896395\",\n    \"#comment\" : \"quoted tweet (#526, #854)\",\n    \"#category\": (\"\", \"twitter\", \"tweet\"),\n    \"#class\"   : twitter.TwitterTweetExtractor,\n    \"#pattern\" : r\"https://pbs\\.twimg\\.com/media/EaK.+=jpg\",\n    \"#count\"   : 4,\n},\n\n{\n    \"#url\"     : \"https://twitter.com/web/status/1644907989109751810\",\n    \"#comment\" : \"different 'user' and 'author' in quoted Tweet (#3922)\",\n    \"#category\": (\"\", \"twitter\", \"tweet\"),\n    \"#class\"   : twitter.TwitterTweetExtractor,\n\n    \"author\": {\n        \"id\"  : 321629993,\n        \"name\": \"Cakes_Comics\",\n    },\n    \"user\"  : {\n        \"id\"  : 718928225360080897,\n        \"name\": \"StobiesGalaxy\",\n    },\n},\n\n{\n    \"#url\"     : \"https://twitter.com/i/web/status/112900228289540096\",\n    \"#comment\" : \"TwitPic embeds (#579)\",\n    \"#category\": (\"\", \"twitter\", \"tweet\"),\n    \"#class\"   : twitter.TwitterTweetExtractor,\n    \"#options\" : {\n        \"twitpic\": True,\n        \"cards\"  : False,\n    },\n    \"#pattern\" : r\"https://\\w+.cloudfront.net/photos/large/\\d+.jpg\",\n    \"#count\"   : 2,\n},\n\n{\n    \"#url\"     : \"https://twitter.com/shimoigusaP/status/8138669971\",\n    \"#comment\" : \"TwitPic URL not in 'urls' (#3792)\",\n    \"#category\": (\"\", \"twitter\", \"tweet\"),\n    \"#class\"   : twitter.TwitterTweetExtractor,\n    \"#options\" : {\"twitpic\": True},\n    \"#pattern\" : r\"https://\\w+.cloudfront.net/photos/large/\\d+.png\",\n    \"#count\"   : 1,\n},\n\n{\n    \"#url\"     : \"https://twitter.com/billboard/status/1306599586602135555\",\n    \"#comment\" : \"Twitter card (#1005)\",\n    \"#category\": (\"\", \"twitter\", \"tweet\"),\n    \"#class\"   : twitter.TwitterTweetExtractor,\n    \"#options\" : {\"cards\": True},\n    \"#pattern\" : r\"https://pbs.twimg.com/card_img/\\d+/\",\n},\n\n{\n    \"#url\"     : \"https://twitter.com/i/web/status/1561674543323910144\",\n    \"#comment\" : \"unified_card image_website (#2875)\",\n    \"#category\": (\"\", \"twitter\", \"tweet\"),\n    \"#class\"   : twitter.TwitterTweetExtractor,\n    \"#options\" : {\"cards\": True},\n    \"#pattern\" : r\"https://pbs\\.twimg\\.com/media/F.+=jpg\",\n},\n\n{\n    \"#url\"     : \"https://twitter.com/doax_vv_staff/status/1479438945662685184\",\n    \"#comment\" : \"unified_card image_carousel_website\",\n    \"#category\": (\"\", \"twitter\", \"tweet\"),\n    \"#class\"   : twitter.TwitterTweetExtractor,\n    \"#options\" : {\"cards\": True},\n    \"#pattern\" : r\"https://pbs\\.twimg\\.com/media/F.+=png\",\n    \"#count\"   : 6,\n},\n\n{\n    \"#url\"     : \"https://twitter.com/bang_dream_1242/status/1561548715348746241\",\n    \"#comment\" : \"unified_card video_website (#2875)\",\n    \"#category\": (\"\", \"twitter\", \"tweet\"),\n    \"#class\"   : twitter.TwitterTweetExtractor,\n    \"#options\" : {\"cards\": True},\n    \"#pattern\" : r\"https://video\\.twimg\\.com/amplify_video/1560607284333449216/vid/720x720/\\w+\\.mp4\",\n},\n\n{\n    \"#url\"     : \"https://twitter.com/i/web/status/1466183847628865544\",\n    \"#comment\" : \"unified_card without type\",\n    \"#category\": (\"\", \"twitter\", \"tweet\"),\n    \"#class\"   : twitter.TwitterTweetExtractor,\n    \"#count\"   : 0,\n},\n\n{\n    \"#url\"     : \"https://twitter.com/i/web/status/1571141912295243776\",\n    \"#comment\" : \"'cards-blacklist' option\",\n    \"#category\": (\"\", \"twitter\", \"tweet\"),\n    \"#class\"   : twitter.TwitterTweetExtractor,\n    \"#options\" : {\n        \"cards\"          : \"ytdl\",\n        \"cards-blacklist\": (\"twitch.tv\",),\n    },\n    \"#count\"   : 0,\n},\n\n{\n    \"#url\"     : \"https://twitter.com/jessica_3978/status/1296304589591810048\",\n    \"#comment\" : \"original retweets (#1026)\",\n    \"#category\": (\"\", \"twitter\", \"tweet\"),\n    \"#class\"   : twitter.TwitterTweetExtractor,\n    \"#options\" : {\"retweets\": True},\n    \"#count\"   : 2,\n\n    \"tweet_id\"     : 1296304589591810048,\n    \"retweet_id\"   : 1296296016002547713,\n    \"date\"         : \"dt:2020-08-20 04:34:32\",\n    \"date_original\": \"dt:2020-08-20 04:00:28\",\n},\n\n{\n    \"#url\"     : \"https://twitter.com/jessica_3978/status/1296304589591810048\",\n    \"#comment\" : \"original retweets (#1026)\",\n    \"#category\": (\"\", \"twitter\", \"tweet\"),\n    \"#class\"   : twitter.TwitterTweetExtractor,\n    \"#options\" : {\"retweets\": \"original\"},\n    \"#count\"   : 2,\n\n    \"tweet_id\"     : 1296296016002547713,\n    \"retweet_id\"   : 1296296016002547713,\n    \"date\"         : \"dt:2020-08-20 04:00:28\",\n    \"date_original\": \"dt:2020-08-20 04:00:28\",\n},\n\n{\n    \"#url\"     : \"https://twitter.com/supernaturepics/status/604341487988576256\",\n    \"#comment\" : \"all Tweets from a 'conversation' (#1319)\",\n    \"#category\": (\"\", \"twitter\", \"tweet\"),\n    \"#class\"   : twitter.TwitterTweetExtractor,\n    \"#options\" : {\"conversations\": True},\n    \"#count\"   : 5,\n},\n\n{\n    \"#url\"     : \"https://twitter.com/supernaturepics/status/604341487988576256/photo/1\",\n    \"#comment\" : \"/photo/ URL (#5443)\",\n    \"#category\": (\"\", \"twitter\", \"tweet\"),\n    \"#class\"   : twitter.TwitterTweetExtractor,\n},\n\n{\n    \"#url\"     : \"https://twitter.com/perrypumas/status/1065692031626829824/video/1\",\n    \"#comment\" : \"/video/ URL\",\n    \"#category\": (\"\", \"twitter\", \"tweet\"),\n    \"#class\"   : twitter.TwitterTweetExtractor,\n},\n\n{\n    \"#url\"     : \"https://twitter.com/morino_ya/status/1392763691599237121\",\n    \"#comment\" : \"retweet with missing media entities (#1555)\",\n    \"#category\": (\"\", \"twitter\", \"tweet\"),\n    \"#class\"   : twitter.TwitterTweetExtractor,\n    \"#options\" : {\"retweets\": True},\n    \"#count\"   : 4,\n},\n\n{\n    \"#url\"     : \"https://twitter.com/i/web/status/1460044411165888515\",\n    \"#comment\" : \"deleted quote tweet (#2225)\",\n    \"#category\": (\"\", \"twitter\", \"tweet\"),\n    \"#class\"   : twitter.TwitterTweetExtractor,\n    \"#count\"   : 0,\n},\n\n{\n    \"#url\"     : \"https://twitter.com/i/web/status/1486373748911575046\",\n    \"#comment\" : \"'Misleading' content\",\n    \"#category\": (\"\", \"twitter\", \"tweet\"),\n    \"#class\"   : twitter.TwitterTweetExtractor,\n    \"#count\"   : 4,\n},\n\n{\n    \"#url\"     : \"https://twitter.com/mightbecursed/status/1492954264909479936\",\n    \"#comment\" : \"age-restricted (#2354)\",\n    \"#category\": (\"\", \"twitter\", \"tweet\"),\n    \"#class\"   : twitter.TwitterTweetExtractor,\n    \"#auth\"     : False,\n    \"#exception\": exception.AuthorizationError,\n},\n\n{\n    \"#url\"     : \"https://twitter.com/my0nruri/status/1528379296041299968\",\n    \"#comment\" : \"media alt texts / descriptions (#2617)\",\n    \"#category\": (\"\", \"twitter\", \"tweet\"),\n    \"#class\"   : twitter.TwitterTweetExtractor,\n\n    \"description\": \"oc\",\n    \"type\"       : \"photo\",\n},\n\n{\n    \"#url\"     : \"https://twitter.com/poco_dandy/status/1150646424461176832\",\n    \"#comment\" : \"'?format=...&name=...'-style URLs\",\n    \"#category\": (\"\", \"twitter\", \"tweet\"),\n    \"#class\"   : twitter.TwitterTweetExtractor,\n    \"#options\" : {\"cards\": True},\n    \"#pattern\" : r\"https://pbs.twimg.com/card_img/17\\d+/[\\w-]+\\?format=(jpg|png)&name=orig$\",\n    \"#range\"   : \"1,3\",\n},\n\n{\n    \"#url\"     : \"https://twitter.com/i/web/status/1629193457112686592\",\n    \"#comment\" : \"note tweet with long 'content'\",\n    \"#category\": (\"\", \"twitter\", \"tweet\"),\n    \"#class\"   : twitter.TwitterTweetExtractor,\n\n    \"content\": \"\"\"BREAKING - DEADLY LIES: Independent researchers at Texas A&M University have just contradicted federal government regulators, saying that toxic air pollutants in East Palestine, Ohio, could pose long-term risks. \\n\nThe Washington Post writes, \"Three weeks after the toxic train derailment in Ohio, an analysis of Environmental Protection Agency data has found nine air pollutants at levels that could raise long-term health concerns in and around East Palestine, according to an independent analysis. \\n\n\"The analysis by Texas A&M University seems to contradict statements by state and federal regulators that air near the crash site is completely safe, despite residents complaining about rashes, breathing problems and other health effects.\" Your reaction.\"\"\",\n},\n\n{\n    \"#url\"     : \"https://twitter.com/KrisKobach1787/status/1765935595702919299\",\n    \"#comment\" : \"'birdwatch' note (#5317)\",\n    \"#category\": (\"\", \"twitter\", \"tweet\"),\n    \"#class\"   : twitter.TwitterTweetExtractor,\n    \"#options\" : {\"text-tweets\": True},\n\n    \"birdwatch\": \"In addition to the known harm of lead exposure, especially to children, Mr. Kobach is incorrect when he states the mandate is unfunded. In fact, the BIPARTISAN Infrastructure Law Joe Biden signed into law in Nov 2021 provides $15B toward lead service line replacement projects. epa.gov/ground-water-a…\",\n    \"content\"  : \"Biden wants to replace lead pipes. He failed to mention that the unfunded mandate sets an almost impossible timeline, will cost billions, infringe on the rights of the States and their residents – all for benefits that may be entirely speculative. #sotu https://ag.ks.gov/media-center/news-releases/2024/02/09/kobach-leads-coalition-demanding-biden-drop-unnecessary-epa-rule\",\n},\n\n{\n    \"#url\"     : \"https://x.com/jsports_motor/status/1801338077618524583\",\n    \"#comment\" : \"geo-restricted video (#5736)\",\n    \"#category\": (\"\", \"twitter\", \"tweet\"),\n    \"#class\"   : twitter.TwitterTweetExtractor,\n    \"#count\"   : 0,\n},\n\n{\n    \"#url\"     : \"https://x.com/fw_rion_/status/1866737025824829544\",\n    \"#comment\" : \"grok share (#7040)\",\n    \"#category\": (\"\", \"twitter\", \"tweet\"),\n    \"#class\"   : twitter.TwitterTweetExtractor,\n    \"#options\" : {\"cards\": True},\n    \"#results\" : \"https://pbs.twimg.com/grok-img-share/1866736156786008064.jpg\",\n},\n\n{\n    \"#url\"     : \"https://x.com/gdldev/status/1932109706354733077\",\n    \"#comment\" : \"'source_id' and 'source_user' metadata (#7470, #7640)\",\n    \"#category\": (\"\", \"twitter\", \"tweet\"),\n    \"#class\"   : twitter.TwitterTweetExtractor,\n    \"#results\" : (\n        \"https://video.twimg.com/amplify_video/1932079443264376832/vid/avc1/640x336/7xo7NCPkMLRWb8NZ.mp4?tag=14\",\n        \"https://video.twimg.com/ext_tw_video/1930425322333229056/pu/vid/avc1/1024x576/6f_cdEPY3a5CcbZP.mp4?tag=12\",\n    ),\n\n    \"source_id\"  : {1932079546590982508, 1930425346404274416},\n    \"source_user\": {\n        \"name\": {\"Satorin69\", \"Derlan144p_\"},\n    },\n},\n\n{\n    \"#url\"     : \"https://twitter.com/playpokemon/status/1263832915173048321/quotes\",\n    \"#category\": (\"\", \"twitter\", \"quotes\"),\n    \"#class\"   : twitter.TwitterQuotesExtractor,\n    \"#pattern\" : twitter.TwitterSearchExtractor.pattern,\n    \"#results\" : \"https://x.com/search?q=quoted_tweet_id:1263832915173048321\",\n},\n\n{\n    \"#url\"     : \"https://twitter.com/supernaturepics/info\",\n    \"#category\": (\"\", \"twitter\", \"info\"),\n    \"#class\"   : twitter.TwitterInfoExtractor,\n},\n\n{\n    \"#url\"     : \"https://twitter.com/supernaturepics/photo\",\n    \"#category\": (\"\", \"twitter\", \"avatar\"),\n    \"#class\"   : twitter.TwitterAvatarExtractor,\n    \"#results\" : \"https://pbs.twimg.com/profile_images/554585280938659841/FLVAlX18.jpeg\",\n\n    \"date\"     : \"dt:2015-01-12 10:26:49\",\n    \"extension\": \"jpeg\",\n    \"filename\" : \"FLVAlX18\",\n    \"tweet_id\" : 554585280938659841,\n},\n\n{\n    \"#url\"     : \"https://twitter.com/User16/photo\",\n    \"#category\": (\"\", \"twitter\", \"avatar\"),\n    \"#class\"   : twitter.TwitterAvatarExtractor,\n    \"#count\"   : 0,\n},\n\n{\n    \"#url\"     : \"https://twitter.com/i_n_u/photo\",\n    \"#comment\" : \"old avatar with small ID and no valid 'date' (#4696)\",\n    \"#category\": (\"\", \"twitter\", \"avatar\"),\n    \"#class\"   : twitter.TwitterAvatarExtractor,\n    \"#results\" : \"https://pbs.twimg.com/profile_images/2946444489/32028c6affdab425e037ff5a6bf77c1d.jpeg\",\n\n    \"date\"     : util.NONE,\n    \"tweet_id\" : 2946444489,\n},\n\n{\n    \"#url\"     : \"https://twitter.com/supernaturepics/header_photo\",\n    \"#category\": (\"\", \"twitter\", \"background\"),\n    \"#class\"   : twitter.TwitterBackgroundExtractor,\n    \"#pattern\" : r\"https://pbs\\.twimg\\.com/profile_banners/2976459548/1421058583\",\n\n    \"date\"    : \"dt:2015-01-12 10:29:43\",\n    \"filename\": \"1421058583\",\n    \"tweet_id\": 554586009367478272,\n},\n\n{\n    \"#url\"     : \"https://twitter.com/User16/header_photo\",\n    \"#category\": (\"\", \"twitter\", \"background\"),\n    \"#class\"   : twitter.TwitterBackgroundExtractor,\n    \"#count\"   : 0,\n},\n\n{\n    \"#url\"     : \"https://pbs.twimg.com/media/EqcpviCVoAAG-QG?format=jpg&name=orig\",\n    \"#category\": (\"\", \"twitter\", \"image\"),\n    \"#class\"   : twitter.TwitterImageExtractor,\n    \"#options\" : {\"size\": \"4096x4096,orig\"},\n    \"#sha1_url\": \"cb3042a6f6826923da98f0d2b66c427e9385114c\",\n},\n\n{\n    \"#url\"     : \"https://pbs.twimg.com/media/EqcpviCVoAAG-QG.jpg:orig\",\n    \"#category\": (\"\", \"twitter\", \"image\"),\n    \"#class\"   : twitter.TwitterImageExtractor,\n},\n\n{\n    \"#url\"     : \"https://x.com/tetsuoai/highlights\",\n    \"#class\"   : twitter.TwitterHighlightsExtractor,\n},\n\n{\n    \"#url\"     : \"https://x.com/home\",\n    \"#class\"   : twitter.TwitterHomeExtractor,\n},\n\n{\n    \"#url\"     : \"https://x.com/home/for_you\",\n    \"#class\"   : twitter.TwitterHomeExtractor,\n},\n\n{\n    \"#url\"     : \"https://x.com/home/following\",\n    \"#class\"   : twitter.TwitterHomeExtractor,\n},\n\n{\n    \"#url\"     : \"https://x.com/notifications\",\n    \"#class\"   : twitter.TwitterNotificationsExtractor,\n},\n\n{\n    \"#url\"     : \"https://x.com/i/timeline\",\n    \"#class\"   : twitter.TwitterNotificationsExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/unique-vintage.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import shopify\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://www.unique-vintage.com/collections/flapper-1920s\",\n    \"#category\": (\"shopify\", \"unique-vintage\", \"collection\"),\n    \"#class\"   : shopify.ShopifyCollectionExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.unique-vintage.com/collections/flapper-1920s/products/unique-vintage-plus-size-black-silver-beaded-troyes-flapper-dress\",\n    \"#category\": (\"shopify\", \"unique-vintage\", \"product\"),\n    \"#class\"   : shopify.ShopifyProductExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/unsplash.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import unsplash\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://unsplash.com/photos/red-wooden-cross-on-gray-concrete-pathway-between-green-trees-during-daytime-kaoHI0iHJPM\",\n    \"#category\": (\"\", \"unsplash\", \"image\"),\n    \"#class\"   : unsplash.UnsplashImageExtractor,\n    \"#pattern\" : r\"https://images\\.unsplash\\.com/photo-1601823984263-b87b59798b\",\n\n    \"alt_description\": \"red wooden cross on gray concrete pathway between green trees during daytime\",\n    \"blur_hash\"  : \"LIAwhq%e4TRjXAIBMyt89GRj%fj[\",\n    \"breadcrumbs\": list,\n    \"color\"      : \"#0c2626\",\n    \"created_at\" : \"2020-10-04T15:13:59Z\",\n    \"date\"       : \"dt:2020-10-04 15:13:59\",\n    \"description\": None,\n    \"downloads\"  : range(50000, 300000),\n    \"exif\"       : {\n        \"aperture\"     : \"9\",\n        \"exposure_time\": \"1/125\",\n        \"focal_length\" : \"35.0\",\n        \"iso\"          : 800,\n        \"make\"         : \"SONY\",\n        \"model\"        : \"ILCE-7M3\",\n        \"name\"         : \"SONY, ILCE-7M3\",\n    },\n    \"extension\"  : \"jpg\",\n    \"filename\"   : \"photo-1601823984263-b87b59798b70\",\n    \"height\"     : 5371,\n    \"id\"         : \"kaoHI0iHJPM\",\n    \"liked_by_user\": False,\n    \"likes\"      : range(1000, 10000),\n    \"links\"      : dict,\n    \"location\"   : {\n        \"city\"    : \"箱根町\",\n        \"country\" : \"日本\",\n        \"name\"    : \"Hakone, 神奈川県 日本\",\n        \"position\": {\n            \"latitude\" :  35.232383,\n            \"longitude\": 139.106936,\n        },\n    },\n    \"meta\"       : {\n        \"index\": True,\n    },\n    \"plus\"       : False,\n    \"premium\"    : False,\n    \"promoted_at\": \"2020-10-05T13:04:43Z\",\n    \"public_domain\": False,\n    \"slug\"       : \"red-wooden-cross-on-gray-concrete-pathway-between-green-trees-during-daytime-kaoHI0iHJPM\",\n    \"sponsorship\": None,\n    \"subcategory\": \"image\",\n    \"tags\"       : list,\n    \"topic_submissions\": {},\n    \"topics\"     : [],\n    \"updated_at\" : str,\n    \"urls\": dict,\n    \"user\": {\n        \"accepted_tos\"      : True,\n        \"bio\"               : \"Professional photographer.\\r\\nBased in Japan.\",\n        \"first_name\"        : \"Syuhei\",\n        \"for_hire\"          : True,\n        \"id\"                : \"F4HO358YSeo\",\n        \"instagram_username\": \"_______life_\",\n        \"last_name\"         : \"Inoue\",\n        \"links\": {\n            \"followers\": \"https://api.unsplash.com/users/_______life_/followers\",\n            \"following\": \"https://api.unsplash.com/users/_______life_/following\",\n            \"html\"     : \"https://unsplash.com/@_______life_\",\n            \"likes\"    : \"https://api.unsplash.com/users/_______life_/likes\",\n            \"photos\"   : \"https://api.unsplash.com/users/_______life_/photos\",\n            \"portfolio\": \"https://api.unsplash.com/users/_______life_/portfolio\",\n            \"self\"     : \"https://api.unsplash.com/users/_______life_\",\n        },\n        \"location\"          : \"Yokohama, Japan\",\n        \"name\"              : \"Syuhei Inoue\",\n        \"portfolio_url\"     : \"https://syuheiinoue.life/\",\n        \"profile_image\"     : {\n            \"large\" : \"https://images.unsplash.com/profile-1601689368522-8855bbd61be6image?ixlib=rb-4.0.3&crop=faces&fit=crop&w=128&h=128\",\n            \"medium\": \"https://images.unsplash.com/profile-1601689368522-8855bbd61be6image?ixlib=rb-4.0.3&crop=faces&fit=crop&w=64&h=64\",\n            \"small\" : \"https://images.unsplash.com/profile-1601689368522-8855bbd61be6image?ixlib=rb-4.0.3&crop=faces&fit=crop&w=32&h=32\",\n        },\n        \"social\"            : {\n            \"instagram_username\": \"_______life_\",\n            \"paypal_email\"      : None,\n            \"portfolio_url\"     : \"https://syuheiinoue.life/\",\n            \"twitter_username\"  : None,\n        },\n        \"total_collections\" : 2,\n        \"total_likes\"       : 32,\n        \"total_photos\"      : 86,\n        \"total_promoted_photos\": 24,\n        \"twitter_username\"  : None,\n        \"updated_at\"        : str,\n        \"username\"          : \"_______life_\"\n    },\n    \"views\": range(2000000, 10000000),\n    \"width\": 3581,\n},\n\n{\n    \"#url\"     : \"https://unsplash.com/@_______life_\",\n    \"#category\": (\"\", \"unsplash\", \"user\"),\n    \"#class\"   : unsplash.UnsplashUserExtractor,\n    \"#pattern\" : r\"https://images\\.unsplash\\.com/(photo-\\d+-\\w+|reserve/[^/?#]+)\\?ixid=\\w+&ixlib=rb-4\\.0\\.3$\",\n    \"#range\"   : \"1-30\",\n    \"#count\"   : 30,\n},\n\n{\n    \"#url\"     : \"https://unsplash.com/@_______life_/likes\",\n    \"#category\": (\"\", \"unsplash\", \"favorite\"),\n    \"#class\"   : unsplash.UnsplashFavoriteExtractor,\n    \"#pattern\" : r\"https://images\\.unsplash\\.com/(photo-\\d+-\\w+|reserve/[^/?#]+)\\?ixid=\\w+&ixlib=rb-4\\.0\\.3$\",\n    \"#count\"   : range(25, 35),\n},\n\n{\n    \"#url\"     : \"https://unsplash.com/collections/3178572/winter\",\n    \"#category\": (\"\", \"unsplash\", \"collection\"),\n    \"#class\"   : unsplash.UnsplashCollectionExtractor,\n    \"#pattern\" : r\"https://images\\.unsplash\\.com/(photo-\\d+-\\w+|reserve/[^/?#]+)\\?ixid=\\w+&ixlib=rb-4\\.0\\.3$\",\n    \"#range\"   : \"1-30\",\n    \"#count\"   : 30,\n\n    \"collection_id\"   : \"3178572\",\n    \"collection_title\": \"winter\",\n},\n\n{\n    \"#url\"     : \"https://unsplash.com/collections/3178572/\",\n    \"#category\": (\"\", \"unsplash\", \"collection\"),\n    \"#class\"   : unsplash.UnsplashCollectionExtractor,\n},\n\n{\n    \"#url\"     : \"https://unsplash.com/collections/_8qJQ2bCMWE/2021.05\",\n    \"#category\": (\"\", \"unsplash\", \"collection\"),\n    \"#class\"   : unsplash.UnsplashCollectionExtractor,\n},\n\n{\n    \"#url\"     : \"https://unsplash.com/s/photos/hair-style\",\n    \"#category\": (\"\", \"unsplash\", \"search\"),\n    \"#class\"   : unsplash.UnsplashSearchExtractor,\n    \"#pattern\" : r\"https://(images|plus)\\.unsplash\\.com/((flagged/|premium_)?photo-\\d+-\\w+|reserve/[^/?#]+)\\?ixid=\\w+&ixlib=rb-4\\.0\\.3$\",\n    \"#range\"   : \"1-30\",\n    \"#count\"   : 30,\n},\n\n)\n"
  },
  {
    "path": "test/results/uploadir.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import uploadir\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://uploadir.com/u/rd3t46ry\",\n    \"#comment\" : \"image\",\n    \"#category\": (\"\", \"uploadir\", \"file\"),\n    \"#class\"   : uploadir.UploadirFileExtractor,\n    \"#pattern\" : r\"https://uploadir\\.com/u/rd3t46ry\",\n    \"#count\"   : 1,\n\n    \"extension\": \"jpg\",\n    \"filename\" : \"Chloe and Rachel 4K jpg\",\n    \"id\"       : \"rd3t46ry\",\n},\n\n{\n    \"#url\"     : \"https://uploadir.com/uploads/gxe8ti9v/downloads/new\",\n    \"#comment\" : \"archive\",\n    \"#category\": (\"\", \"uploadir\", \"file\"),\n    \"#class\"   : uploadir.UploadirFileExtractor,\n    \"#pattern\" : r\"https://uploadir\\.com/uploads/gxe8ti9v/downloads\",\n    \"#count\"   : 1,\n\n    \"extension\": \"zip\",\n    \"filename\" : \"NYAN-Mods-Pack#1\",\n    \"id\"       : \"gxe8ti9v\",\n},\n\n{\n    \"#url\"     : \"https://uploadir.com/u/fllda6xl\",\n    \"#comment\" : \"utf-8 filename\",\n    \"#category\": (\"\", \"uploadir\", \"file\"),\n    \"#class\"   : uploadir.UploadirFileExtractor,\n    \"#pattern\" : r\"https://uploadir\\.com/u/fllda6xl\",\n    \"#count\"   : 1,\n\n    \"extension\": \"png\",\n    \"filename\" : \"_圖片_🖼_image_\",\n    \"id\"       : \"fllda6xl\",\n},\n\n{\n    \"#url\"     : \"https://uploadir.com/uploads/rd3t46ry\",\n    \"#category\": (\"\", \"uploadir\", \"file\"),\n    \"#class\"   : uploadir.UploadirFileExtractor,\n},\n\n{\n    \"#url\"     : \"https://uploadir.com/user/uploads/rd3t46ry\",\n    \"#category\": (\"\", \"uploadir\", \"file\"),\n    \"#class\"   : uploadir.UploadirFileExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/urlgalleries.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import urlgalleries\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://urlgalleries.com/photos2q/7851311/clarice-window-8/\",\n    \"#comment\" : \"'legacy' gallery\",\n    \"#class\"   : urlgalleries.UrlgalleriesGalleryExtractor,\n    \"#range\"   : \"1-3\",\n    \"#results\" : (\n        \"https://fappic.com/x207mqkn2463/4gq1yv.jpg\",\n        \"https://fappic.com/q684ua2rp0j9/4gq1xv.jpg\",\n        \"https://fappic.com/8vf3n8fgz9po/4gq1ya.jpg\",\n    ),\n\n    \"blog\"      : \"photos2q\",\n    \"count\"     : 39,\n    \"num\"       : range(1, 3),\n    \"date\"      : \"dt:2023-12-08 12:59:31\",\n    \"gallery_id\": \"7851311\",\n    \"title\"     : \"Clarice window 8\",\n    \"tags\"      : [\n        \"Blondes\",\n        \"Softcore\",\n        \"Teens\",\n        \"Brunettes\",\n    ],\n},\n\n{\n    \"#url\"     : \"https://urlgalleries.com/dreamer/7645840\",\n    \"#comment\" : \"no slug\",\n    \"#class\"   : urlgalleries.UrlgalleriesGalleryExtractor,\n    \"#range\"   : \"1-3\",\n    \"#results\" : (\n        \"https://www.fappic.com/vj7up04ny487/AmourAngels-0001.jpg\",\n        \"https://www.fappic.com/zfgsmpm36iyv/AmourAngels-0002.jpg\",\n        \"https://www.fappic.com/rqpt37rdbwa5/AmourAngels-0003.jpg\",\n    ),\n\n    \"blog\"      : \"Dreamer\",\n    \"count\"     : 105,\n    \"num\"       : range(1, 3),\n    \"date\"      : \"dt:2020-03-10 20:17:23\",\n    \"gallery_id\": \"7645840\",\n    \"title\"     : \"Angelika - Rustic Charm - AmourAngels 2016-09-27\",\n    \"tags\": [\n        \"Outdoors\",\n        \"Teens\",\n        \"Complete-Sets\",\n        \"Brunettes\",\n    ],\n},\n\n{\n    \"#url\"     : \"https://urlgalleries.com/xarchivesx/6722560/caroline/\",\n    \"#comment\" : \"image host URLs with query parameters (#7888)\",\n    \"#class\"   : urlgalleries.UrlgalleriesGalleryExtractor,\n    \"#range\"   : \"1-3\",\n    \"#results\" : (\n        \"http://img272.imagevenue.com/img.php?image=63353_qedf2jsd4j_123_376lo.jpg\",\n        \"http://img220.imagevenue.com/img.php?image=63140_hl2kkhv0n4_123_621lo.jpg\",\n        \"http://img217.imagevenue.com/img.php?image=63140_z2edqlkpkz_123_986lo.jpg\",\n    ),\n\n    \"blog\"      : \"The Archives Blog\",\n    \"count\"     : 141,\n    \"num\"       : range(1, 3),\n    \"date\"      : \"dt:2016-06-11 12:20:06\",\n    \"gallery_id\": \"6722560\",\n    \"title\"     : \"Caroline\",\n    \"tags\"      : [\"Complete-Sets\"],\n},\n\n{\n    \"#url\"     : \"https://urlgalleries.com/beautiesonearth/7893239/bianca-bell-alluring-smile/\",\n    \"#comment\" : \"'new-style' gallery\",\n    \"#class\"   : urlgalleries.UrlgalleriesGalleryExtractor,\n    \"#range\"   : \"1-3\",\n    \"#results\" : (\n        \"https://fappic.com/8w2tgkh73bqw/89nywrd17cru.jpg\",\n        \"https://fappic.com/rc7rvvlqq6tz/8opev82wdj12.jpg\",\n        \"https://fappic.com/61j49l13pc1x/7b3d9bydrsg0.jpg\",\n    ),\n\n    \"blog\"      : \"Beautiesonearth\",\n    \"count\"     : 44,\n    \"num\"       : range(1, 3),\n    \"date\"      : \"dt:2026-02-19 05:02:37\",\n    \"gallery_id\": \"7893239\",\n    \"title\"     : \"Bianca Bell - Alluring Smile\",\n    \"tags\"      : [\n        \"Blondes\",\n        \"Softcore\",\n        \"Teens\",\n        \"Voyeur\",\n    ],\n},\n\n)\n"
  },
  {
    "path": "test/results/vanillarock.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import vanillarock\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://vanilla-rock.com/mizuhashi_parsee-5\",\n    \"#category\": (\"\", \"vanillarock\", \"post\"),\n    \"#class\"   : vanillarock.VanillarockPostExtractor,\n    \"#sha1_url\"     : \"7fb9a4d18d9fa22d7295fee8d94ab5a7a52265dd\",\n    \"#sha1_metadata\": \"b91df99b714e1958d9636748b1c81a07c3ef52c9\",\n},\n\n{\n    \"#url\"     : \"https://vanilla-rock.com/tag/%e5%b0%84%e5%91%bd%e4%b8%b8%e6%96%87\",\n    \"#category\": (\"\", \"vanillarock\", \"tag\"),\n    \"#class\"   : vanillarock.VanillarockTagExtractor,\n    \"#pattern\" : vanillarock.VanillarockPostExtractor.pattern,\n    \"#count\"   : \">= 12\",\n},\n\n{\n    \"#url\"     : \"https://vanilla-rock.com/category/%e4%ba%8c%e6%ac%a1%e3%82%a8%e3%83%ad%e7%94%bb%e5%83%8f/%e8%90%8c%e3%81%88%e3%83%bb%e3%82%bd%e3%83%95%e3%83%88%e3%82%a8%e3%83%ad\",\n    \"#category\": (\"\", \"vanillarock\", \"tag\"),\n    \"#class\"   : vanillarock.VanillarockTagExtractor,\n    \"#pattern\" : vanillarock.VanillarockPostExtractor.pattern,\n    \"#count\"   : \">= 5\",\n},\n\n)\n"
  },
  {
    "path": "test/results/vidyapics.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import shimmie2\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://vidya.pics/post/list/kirby/1\",\n    \"#category\": (\"shimmie2\", \"vidyapics\", \"tag\"),\n    \"#class\"   : shimmie2.Shimmie2TagExtractor,\n    \"#pattern\" : r\"https://vidya.pics/_images/[0-9a-f]{32}/\\d+\",\n    \"#range\"   : \"1-100\",\n    \"#count\"   : 100,\n},\n\n{\n    \"#url\"     : \"https://vidya.pics/post/view/108820\",\n    \"#category\": (\"shimmie2\", \"vidyapics\", \"post\"),\n    \"#class\"   : shimmie2.Shimmie2PostExtractor,\n    \"#pattern\"     : r\"https://vidya\\.pics/_images/277ecdb90285bfa6e0c4cd46d9515b11/108820.+\\.png\",\n    \"#sha1_content\": \"7d2fe9327759c231ff17f6e341df749b70b191ce\",\n\n    \"extension\": \"png\",\n    \"file_url\" : \"https://vidya.pics/_images/277ecdb90285bfa6e0c4cd46d9515b11/108820%20-%201boy%20artist%3Aunknown%20flag%20kirby%20kirby_%28series%29.png\",\n    \"filename\" : \"108820 - 1boy artist:unknown flag kirby kirby_(series)\",\n    \"height\"   : 700,\n    \"id\"       : 108820,\n    \"md5\"      : \"277ecdb90285bfa6e0c4cd46d9515b11\",\n    \"size\"     : 0,\n    \"tags\"     : \"1boy artist:unknown flag kirby kirby_(series\",\n    \"width\"    : 700,\n},\n\n)\n"
  },
  {
    "path": "test/results/vidyart2.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import gelbooru_v01\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://vidyart2.booru.org/index.php?page=post&s=list&tags=all\",\n    \"#category\": (\"gelbooru_v01\", \"vidyart2\", \"tag\"),\n    \"#class\"   : gelbooru_v01.GelbooruV01TagExtractor,\n},\n\n{\n    \"#url\"     : \"https://vidyart2.booru.org/index.php?page=favorites&s=view&id=1\",\n    \"#category\": (\"gelbooru_v01\", \"vidyart2\", \"favorite\"),\n    \"#class\"   : gelbooru_v01.GelbooruV01FavoriteExtractor,\n},\n\n{\n    \"#url\"     : \"https://vidyart2.booru.org/index.php?page=post&s=view&id=39168\",\n    \"#category\": (\"gelbooru_v01\", \"vidyart2\", \"post\"),\n    \"#class\"   : gelbooru_v01.GelbooruV01PostExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/vipergirls.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import vipergirls\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://vipergirls.to/threads/4328304-2011-05-28-Danica-Simply-Beautiful-x112-4500x3000\",\n    \"#category\": (\"\", \"vipergirls\", \"thread\"),\n    \"#class\"   : vipergirls.VipergirlsThreadExtractor,\n    \"#count\"   : 225,\n    \"#sha1_url\": \"3a127b2d4f61d538ac4ad5340a787ef9f0b05b1f\",\n\n    \"count\"       : {112, 113},\n    \"num\"         : range(1, 113),\n    \"forum_title\" : \"Artistic Photo Sets (Archive)\",\n    \"post_id\"     : {\"116038081\", \"42953564\"},\n    \"post_num\"    : {\"1\", \"2\"},\n    \"post_title\"  : str,\n    \"thread_id\"   : \"4328304\",\n    \"thread_title\": \"FemJoy 2011-05-28 Danica - Simply Beautiful [x112] 4500x3000\",\n},\n\n{\n    \"#url\"     : \"https://vipergirls.to/threads/6858916-Karina/page4\",\n    \"#category\": (\"\", \"vipergirls\", \"thread\"),\n    \"#class\"   : vipergirls.VipergirlsThreadExtractor,\n    \"#options\" : {\"order-posts\": \"asc\"},\n    \"#count\"   : 1279,\n},\n\n{\n    \"#url\"     : \"https://vipergirls.to/threads/4328304-2011-05-28-Danica-Simply-Beautiful-x112-4500x3000?highlight=foobar\",\n    \"#category\": (\"\", \"vipergirls\", \"thread\"),\n    \"#class\"   : vipergirls.VipergirlsThreadExtractor,\n},\n\n{\n    \"#url\"     : \"https://vipergirls.to/threads/4328304?foo=bar\",\n    \"#category\": (\"\", \"vipergirls\", \"thread\"),\n    \"#class\"   : vipergirls.VipergirlsThreadExtractor,\n},\n\n{\n    \"#url\"     : \"https://vipergirls.to/threads/4328304\",\n    \"#category\": (\"\", \"vipergirls\", \"thread\"),\n    \"#class\"   : vipergirls.VipergirlsThreadExtractor,\n},\n\n{\n    \"#url\"     : \"https://vipergirls.to/threads/4328304-2011-05-28-Danica-Simply-Beautiful-x112-4500x3000?p=116038081&viewfull=1#post116038081\",\n    \"#category\": (\"\", \"vipergirls\", \"post\"),\n    \"#class\"   : vipergirls.VipergirlsPostExtractor,\n    \"#pattern\" : r\"https://vipr\\.im/\\w{12}$\",\n    \"#range\"   : \"2-113\",\n    \"#count\"   : 112,\n\n    \"count\"       : 113,\n    \"num\"         : range(2, 113),\n    \"post_id\"     : \"116038081\",\n    \"post_num\"    : \"116038081\",\n    \"post_title\"  : \"FemJoy Danica - Simply Beautiful (x112) 3000x4500\",\n    \"thread_id\"   : \"4328304\",\n    \"thread_title\": \"FemJoy 2011-05-28 Danica - Simply Beautiful [x112] 4500x3000\",\n},\n\n)\n"
  },
  {
    "path": "test/results/vipr.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import imagehosts\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://vipr.im/kcd5jcuhgs3v.html\",\n    \"#category\": (\"imagehost\", \"vipr\", \"image\"),\n    \"#class\"   : imagehosts.ViprImageExtractor,\n    \"#results\" : \"https://i7.vipr.im/i/00021/kcd5jcuhgs3v.jpg/sommer01035.jpg\",\n\n    \"extension\": \"jpg\",\n    \"filename\" : \"sommer01035\",\n    \"post_url\" : \"https://vipr.im/kcd5jcuhgs3v\",\n    \"token\"    : \"kcd5jcuhgs3v\",\n},\n\n{\n    \"#url\"     : \"https://vipr.im/yyqomiutt768\",\n    \"#category\": (\"imagehost\", \"vipr\", \"image\"),\n    \"#class\"   : imagehosts.ViprImageExtractor,\n    \"#exception\": \"NotFoundError\",\n},\n\n)\n"
  },
  {
    "path": "test/results/visuabusters.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import szurubooru\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://www.visuabusters.com/booru/posts/query=mincy_mouse\",\n    \"#category\": (\"szurubooru\", \"visuabusters\", \"tag\"),\n    \"#class\"   : szurubooru.SzurubooruTagExtractor,\n    \"#pattern\" : r\"https://www\\.visuabusters\\.com/booru/data/posts/visuabusters_\\d+_\\w{16}\\.\\w+\",\n    \"#count\"   : range(2, 5),\n},\n\n{\n    \"#url\"     : \"https://www.visuabusters.com/booru/posts/query=\",\n    \"#category\": (\"szurubooru\", \"visuabusters\", \"tag\"),\n    \"#class\"   : szurubooru.SzurubooruTagExtractor,\n},\n\n{\n    \"#url\"     : \"https://visuabusters.com/booru/posts\",\n    \"#category\": (\"szurubooru\", \"visuabusters\", \"tag\"),\n    \"#class\"   : szurubooru.SzurubooruTagExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.visuabusters.com/booru/post/2485\",\n    \"#category\": (\"szurubooru\", \"visuabusters\", \"post\"),\n    \"#class\"   : szurubooru.SzurubooruPostExtractor,\n    \"#results\"     : \"https://www.visuabusters.com/booru/data/posts/visuabusters_2485_ynmXFhNmBs3x0cCm.gif\",\n    \"#sha1_content\": \"781fc0f063503d9d3f282558b9fcd69e37045e88\",\n},\n\n)\n"
  },
  {
    "path": "test/results/vk.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import vk\nfrom gallery_dl import exception\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://vk.com/id398982326\",\n    \"#category\": (\"\", \"vk\", \"photos\"),\n    \"#class\"   : vk.VkPhotosExtractor,\n    \"#pattern\" : r\"https://sun\\d+-\\d+\\.userapi\\.com/c\\d+/v\\d+/\\w+/[\\w-]+\\.\\w+\",\n    \"#count\"   : \">= 35\",\n\n    \"id\"  : r\"re:\\d+\",\n    \"date\": \"type:datetime\",\n    \"user\": {\n        \"group\": False,\n        \"id\"  : \"398982326\",\n        \"info\": \"Мы за Движуху! – m1ni SounD #4 [EROmusic]\",\n        \"name\": \"id398982326\",\n        \"nick\": \"Dobrov Kurva\",\n    },\n},\n\n{\n    \"#url\"     : \"https://vk.com/cosplayinrussia\",\n    \"#category\": (\"\", \"vk\", \"photos\"),\n    \"#class\"   : vk.VkPhotosExtractor,\n    \"#range\"   : \"15-25\",\n\n    \"id\"  : r\"re:\\d+\",\n    \"date\": \"type:datetime\",\n    \"user\": {\n        \"group\": True,\n        \"id\"  : \"-165740836\",\n        \"info\": \"\",\n        \"name\": \"cosplayinrussia\",\n        \"nick\": \"Косплей | Cosplay 18+\",\n    },\n},\n\n{\n    \"#url\"     : \"https://vk.com/id76957806\",\n    \"#comment\" : \"photos without width/height (#2535)\",\n    \"#category\": (\"\", \"vk\", \"photos\"),\n    \"#class\"   : vk.VkPhotosExtractor,\n    \"#pattern\" : r\"https://sun\\d+-\\d+\\.userapi\\.com/\",\n    \"#range\"   : \"1-9\",\n    \"#count\"   : 9,\n\n    \"date\": \"type:datetime\",\n},\n\n{\n    \"#url\"     : \"https://m.vk.com/albums398982326\",\n    \"#category\": (\"\", \"vk\", \"photos\"),\n    \"#class\"   : vk.VkPhotosExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.vk.com/id398982326?profile=1\",\n    \"#category\": (\"\", \"vk\", \"photos\"),\n    \"#class\"   : vk.VkPhotosExtractor,\n},\n\n{\n    \"#url\"     : \"https://vk.com/albums-165740836\",\n    \"#category\": (\"\", \"vk\", \"photos\"),\n    \"#class\"   : vk.VkPhotosExtractor,\n},\n\n{\n    \"#url\"     : \"https://vk.com/album-165740836_281339889\",\n    \"#category\": (\"\", \"vk\", \"album\"),\n    \"#class\"   : vk.VkAlbumExtractor,\n    \"#count\"   : 12,\n    \"#log\"     : \"-165740836_281339889: Failed to extract metadata ('Access denied')\",\n\n    \"album\": {\n        \"id\"    : \"281339889\",\n        \"!name\" : str,\n        \"!count\": int,\n    },\n    \"user\": {\n        \"id\": \"-165740836\",\n        \"!name\" : str,\n        \"!nick\" : str,\n        \"!group\": bool,\n\n    },\n},\n\n{\n    \"#url\"     : \"https://vk.com/album-53775183_00\",\n    \"#comment\" : \"'Access denied' (#2556)\",\n    \"#category\": (\"\", \"vk\", \"album\"),\n    \"#class\"   : vk.VkAlbumExtractor,\n    \"#exception\": exception.AuthorizationError,\n},\n\n{\n    \"#url\"     : \"https://vk.com/album232175027_00\",\n    \"#category\": (\"\", \"vk\", \"album\"),\n    \"#class\"   : vk.VkAlbumExtractor,\n    \"#exception\": exception.AuthorizationError,\n},\n\n{\n    \"#url\"     : \"https://vk.com/album-205150448_00\",\n    \"#class\"   : vk.VkAlbumExtractor,\n    \"#range\"   : \"1-25\",\n    \"#count\"   : 25,\n\n    \"id\"           : r\"re:\\d+\",\n    \"width\"        : range(100, 8_000),\n    \"height\"       : range(100, 8_000),\n    \"filename\"     : str,\n    \"extension\"    : {\"jpg\", \"png\"},\n    \"date\"         : \"type:datetime\",\n    \"count\"        : 826,\n    \"num\"          : range(1, 25),\n    \"description\"  : str,\n    \"hash\"         : r\"re:[0-9a-f]{18}\",\n    \"likes\"        : int,\n    \"album\"        : {\n        \"id\"   : \"00\",\n        \"name\" : \"Community wall photos\",\n        \"count\": 826,\n    },\n    \"user\"         : {\n        \"id\"   : \"-205150448\",\n        \"name\" : \"otjareniy\",\n        \"nick\" : \"Отжареный Овощ(16+)\",\n        \"group\": True,\n    },\n},\n\n{\n    \"#url\"     : \"https://vk.com/tag304303884\",\n    \"#category\": (\"\", \"vk\", \"tagged\"),\n    \"#class\"   : vk.VkTaggedExtractor,\n    \"#exception\": exception.AuthorizationError,\n},\n\n{\n    \"#url\"     : \"https://vk.com/wall-213352498_2115\",\n    \"#class\"   : vk.VkWallPostExtractor,\n    \"#results\" : (\n        \"https://sun9-42.userapi.com/s/v1/ig2/53qxcL7M8408L2HNDTHdHz-HXbprXBn1BLbE5HTuj-OsZD4I483jtZb8yMk9Mr4zzfPJhqBIJlAprWVhIqlk4Fn4.jpg?quality=95&as=32x57,48x85,72x128,108x192,160x284,240x427,360x640,480x853,540x960,640x1138,720x1280&from=bu&cs=720x0\",\n        \"https://sun9-49.userapi.com/s/v1/ig2/FnvT8T3mC2yQWc5yJTOe25Kj864ohqvTgOcTudqrE4sPfCMexS1mzNmgUndgxUbqhht-YmIVKW_edDFtzCLXzf7h.jpg?quality=95&as=32x57,48x85,72x128,108x192,160x284,240x427,360x640,480x853,540x960,640x1138,720x1280&from=bu&cs=720x0\",\n        \"https://sun9-78.userapi.com/s/v1/ig2/6VB0Cnmdtb9rDNFd5iHv5QJAJ-y-xSVELEoCLlOf_ej2BWVf61G3DSXbnXgmx-QFtQkOOnHIhCLFFLTIFKeVBR5Q.jpg?quality=95&as=32x57,48x85,72x128,108x192,160x284,240x427,360x640,480x853,540x960,640x1138,720x1280&from=bu&cs=720x0\",\n        \"https://sun9-60.userapi.com/s/v1/ig2/KO5SzdRUHjZRKlHii4oJ4BrTo5nbdyP3CCpf6_RfHhrEIx6jiVPlWH1R--fpoK5-0rigqXuaG68q39m5VQVy6YFo.jpg?quality=95&as=32x57,48x85,72x128,108x192,160x284,240x427,360x640,480x853,540x960,640x1138,720x1280&from=bu&cs=720x0\",\n        \"https://sun9-33.userapi.com/s/v1/ig2/IAN1ZHmVVtjRj0U7wGAfnMc5Xp83EFFYZAVqNgMKpfthLHOe6wh0bodM_xDwIALvVl4pcZ66Fv3bOROG4sUTwY21.jpg?quality=95&as=32x57,48x85,72x128,108x192,160x284,240x427,360x640,480x853,540x960,640x1138,720x1280&from=bu&cs=720x0\",\n        \"https://sun9-44.userapi.com/s/v1/ig2/RLzDGnlmu7C0sLh2YI2R4L9RBgZ061QLOsxogjEtC0cBZJ9HvhNwe1V16QX0tNLkTOLELAp8JDHwOo6dMvoWydeh.jpg?quality=95&as=32x57,48x85,72x128,108x192,160x284,240x427,360x640,480x853,540x960,640x1138,720x1280&from=bu&cs=720x0\",\n    ),\n\n    \"id\"           : r\"re:^\\d+$\",\n    \"width\"        : 720,\n    \"height\"       : 1280,\n    \"count\"        : 6,\n    \"num\"          : range(1, 6),\n    \"likes\"        : int,\n    \"user\"         : {\n        \"id\": \"-213352498\",\n    },\n    \"wall\"         : {\n        \"description\": \"🎄 Обновляем не только аватарки, но и обои на телефоне\",\n        \"id\"         : \"2115\",\n    },\n},\n\n)\n"
  },
  {
    "path": "test/results/vsco.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import vsco\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://vsco.co/missuri\",\n    \"#category\": (\"\", \"vsco\", \"user\"),\n    \"#class\"   : vsco.VscoUserExtractor,\n    \"#results\" : \"https://vsco.co/missuri/gallery\",\n},\n\n{\n    \"#url\"     : \"https://vsco.co/missuri\",\n    \"#category\": (\"\", \"vsco\", \"user\"),\n    \"#class\"   : vsco.VscoUserExtractor,\n    \"#options\" : {\"include\": \"all\"},\n    \"#results\" : (\n        \"https://vsco.co/missuri/avatar\",\n        \"https://vsco.co/missuri/gallery\",\n        \"https://vsco.co/missuri/spaces\",\n        \"https://vsco.co/missuri/collection\",\n    ),\n},\n\n{\n    \"#url\"     : \"https://vsco.co/missuri/gallery\",\n    \"#category\": (\"\", \"vsco\", \"gallery\"),\n    \"#class\"   : vsco.VscoGalleryExtractor,\n    \"#pattern\" : r\"https://image(-aws.+)?\\.vsco\\.co/[0-9a-f/]+/[\\w-]+\\.\\w+|^ytdl:https://stream\\.mux\\.com/.+\",\n    \"#range\"   : \"1-80\",\n    \"#count\"   : 80,\n},\n\n{\n    \"#url\"     : \"https://vsco.co/shavonec/gallery\",\n    \"#comment\" : \"meu8 video (#7113)\",\n    \"#category\": (\"\", \"vsco\", \"gallery\"),\n    \"#class\"   : vsco.VscoGalleryExtractor,\n    \"#pattern\" : r\"^ytdl:https://stream\\.mux\\.com/3o01XbjqTI4rkeRwPzR17H9i7VSFdWX1h\\.m3u8\\?token=ey.+\",\n    \"#range\"   : \"8\",\n\n    \"date\"       : \"dt:2020-02-04 15:11:52\",\n    \"description\": \"Big news: 🎥 YOU CAN NOW POST VIDEOS TO VSCO ⚡️⚡️💪🏾. ⁣\\n⁣\\n🔗BTS from my #FashionIsActivism panel with the California African American Arts museum ✊🏾\",\n    \"extension\"  : \"mp4\",\n    \"filename\"   : \"3o01XbjqTI4rkeRwPzR17H9i7VSFdWX1h\",\n    \"grid\"       : \"\",\n    \"id\"         : \"c5eb34bb-dd13-4d7a-a09c-2a7cd719c9fa\",\n    \"meta\"       : {},\n    \"tags\"       : [],\n    \"user\"       : \"shavonec\",\n    \"video\"      : True,\n    \"width\"      : 624,\n    \"height\"     : 1232,\n},\n\n{\n    \"#url\"     : \"https://vsco.co/missuri/images/1\",\n    \"#category\": (\"\", \"vsco\", \"gallery\"),\n    \"#class\"   : vsco.VscoGalleryExtractor,\n},\n\n{\n    \"#url\"     : \"https://vsco.co/vsco/collection/1\",\n    \"#category\": (\"\", \"vsco\", \"collection\"),\n    \"#class\"   : vsco.VscoCollectionExtractor,\n    \"#pattern\" : r\"https://image(-aws.+)?\\.vsco\\.co/[0-9a-f/]+/[\\w\\s-]+\\.\\w+\",\n    \"#range\"   : \"1-80\",\n    \"#count\"   : 80,\n},\n\n{\n    \"#url\"     : \"https://vsco.co/spaces/6320a3e1e0338d1350b33fea\",\n    \"#category\": (\"\", \"vsco\", \"space\"),\n    \"#class\"   : vsco.VscoSpaceExtractor,\n    \"#pattern\" : r\"https://image(-aws.+)?\\.vsco\\.co/[0-9a-f/]+/[\\w\\s-]+\\.\\w+\",\n    \"#count\"   : range(100, 150),\n},\n\n{\n    \"#url\"     : \"https://vsco.co/missuri/spaces\",\n    \"#category\": (\"\", \"vsco\", \"spaces\"),\n    \"#class\"   : vsco.VscoSpacesExtractor,\n    \"#results\" : (\n        \"https://vsco.co/spaces/62e4934e6920440801d19f05\",\n    ),\n},\n\n{\n    \"#url\"     : \"https://vsco.co/vsco/avatar\",\n    \"#category\": (\"\", \"vsco\", \"avatar\"),\n    \"#class\"   : vsco.VscoAvatarExtractor,\n    \"#pattern\" : r\"https://(?:image-aws-us-west-2|img).vsco.co/3c69ae/304128/652d9f3b39a6007526dda683/vscoprofile-avatar.jpg\",\n    \"#sha1_content\" : \"57cd648759e34a6daefc5c79542ddb4595b9b677\",\n\n    \"id\": \"652d9f3b39a6007526dda683\",\n},\n\n{\n    \"#url\"     : \"https://vsco.co/erenyildiz/media/5d34b93ef632433030707ce2\",\n    \"#category\": (\"\", \"vsco\", \"image\"),\n    \"#class\"   : vsco.VscoImageExtractor,\n    \"#sha1_url\"    : \"a45f9712325b42742324b330c348b72477996031\",\n    \"#sha1_content\": \"1394d070828d82078035f19a92f404557b56b83f\",\n\n    \"id\"         : \"5d34b93ef632433030707ce2\",\n    \"user\"       : \"erenyildiz\",\n    \"grid\"       : \"erenyildiz\",\n    \"meta\"       : dict,\n    \"tags\"       : list,\n    \"date\"       : \"dt:2019-07-21 19:12:11\",\n    \"video\"      : False,\n    \"width\"      : 1537,\n    \"height\"     : 1537,\n    \"description\": r\"re:Ni seviyorum. #vsco #vscox #vscochallenges\",\n},\n\n{\n    \"#url\"     : \"https://vsco.co/jimenalazof/media/5b4feec558f6c45c18c040fd\",\n    \"#category\": (\"\", \"vsco\", \"image\"),\n    \"#class\"   : vsco.VscoImageExtractor,\n    \"#sha1_url\"    : \"c2cf4bd2a627419785613dc5475cbb7c2699f3dd\",\n    \"#sha1_content\": \"e739f058d726ee42c51c180a505747972a7dfa47\",\n\n    \"video\": True,\n},\n\n{\n    \"#url\"     : \"https://vsco.co/shavonec/video/c5eb34bb-dd13-4d7a-a09c-2a7cd719c9fa\",\n    \"#category\": (\"\", \"vsco\", \"video\"),\n    \"#class\"   : vsco.VscoVideoExtractor,\n    \"#pattern\" : r\"^ytdl:https://stream\\.mux\\.com/3o01XbjqTI4rkeRwPzR17H9i7VSFdWX1h\\.m3u8\\?token=ey.+\",\n\n    \"date\"       : \"dt:2020-02-04 15:11:52\",\n    \"description\": \"Big news: 🎥 YOU CAN NOW POST VIDEOS TO VSCO ⚡️⚡️💪🏾. ⁣\\n⁣\\n🔗BTS from my #FashionIsActivism panel with the California African American Arts museum ✊🏾\",\n    \"extension\"  : \"mp4\",\n    \"filename\"   : \"3o01XbjqTI4rkeRwPzR17H9i7VSFdWX1h\",\n    \"grid\"       : \"\",\n    \"id\"         : \"c5eb34bb-dd13-4d7a-a09c-2a7cd719c9fa\",\n    \"meta\"       : {},\n    \"tags\"       : [],\n    \"user\"       : \"shavonec\",\n    \"video\"      : True,\n    \"width\"      : 624,\n    \"height\"     : 1232,\n},\n\n)\n"
  },
  {
    "path": "test/results/wallhaven.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import wallhaven\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://wallhaven.cc/search?q=touhou\",\n    \"#category\": (\"\", \"wallhaven\", \"search\"),\n    \"#class\"   : wallhaven.WallhavenSearchExtractor,\n    \"#pattern\" : r\"https://w\\.wallhaven\\.cc/full/\\w\\w/wallhaven-\\w+\\.\\w+\",\n    \"#range\"   : \"1-10\",\n\n    \"search\": {\n        \"q\"     : \"touhou\",\n        \"tags\"  : \"touhou\",\n        \"tag_id\": 0,\n    },\n},\n\n{\n    \"#url\"     : \"https://wallhaven.cc/search?q=id%3A87&categories=111&purity=100&sorting=date_added&order=asc&page=3\",\n    \"#category\": (\"\", \"wallhaven\", \"search\"),\n    \"#class\"   : wallhaven.WallhavenSearchExtractor,\n    \"#pattern\" : r\"https://w\\.wallhaven\\.cc/full/\\w\\w/wallhaven-\\w+\\.\\w+\",\n    \"#count\"   : \"<= 30\",\n\n    \"search\": {\n        \"categories\": \"111\",\n        \"order\"     : \"asc\",\n        \"page\"      : \"3\",\n        \"purity\"    : \"100\",\n        \"sorting\"   : \"date_added\",\n        \"q\"         : \"id:87\",\n        \"tags\"      : \"Fujibayashi Kyou\",\n        \"tag_id\"    : 87,\n    },\n},\n\n{\n    \"#url\"     : \"https://wallhaven.cc/user/AksumkA/favorites/74\",\n    \"#category\": (\"\", \"wallhaven\", \"collection\"),\n    \"#class\"   : wallhaven.WallhavenCollectionExtractor,\n    \"#count\"   : \">= 50\",\n},\n\n{\n    \"#url\"     : \"https://wallhaven.cc/user/AksumkA/\",\n    \"#category\": (\"\", \"wallhaven\", \"user\"),\n    \"#class\"   : wallhaven.WallhavenUserExtractor,\n},\n\n{\n    \"#url\"     : \"https://wallhaven.cc/user/AksumkA/favorites\",\n    \"#category\": (\"\", \"wallhaven\", \"collections\"),\n    \"#class\"   : wallhaven.WallhavenCollectionsExtractor,\n    \"#pattern\" : wallhaven.WallhavenCollectionExtractor.pattern,\n    \"#count\"   : 4,\n},\n\n{\n    \"#url\"     : \"https://wallhaven.cc/user/AksumkA/uploads\",\n    \"#category\": (\"\", \"wallhaven\", \"uploads\"),\n    \"#class\"   : wallhaven.WallhavenUploadsExtractor,\n    \"#pattern\" : r\"https://[^.]+\\.wallhaven\\.cc/full/\\w\\w/wallhaven-\\w+\\.\\w+\",\n    \"#range\"   : \"1-100\",\n    \"#count\"   : 100,\n},\n\n{\n    \"#url\"     : \"https://wallhaven.cc/w/01w334\",\n    \"#category\": (\"\", \"wallhaven\", \"image\"),\n    \"#class\"   : wallhaven.WallhavenImageExtractor,\n    \"#pattern\"     : r\"https://[^.]+\\.wallhaven\\.cc/full/01/wallhaven-01w334\\.jpg\",\n    \"#sha1_content\": \"497212679383a465da1e35bd75873240435085a2\",\n\n    \"id\"         : \"01w334\",\n    \"width\"      : 1920,\n    \"height\"     : 1200,\n    \"resolution\" : \"1920x1200\",\n    \"ratio\"      : \"1.6\",\n    \"colors\"     : list,\n    \"tags\"       : list,\n    \"file_size\"  : 278799,\n    \"file_type\"  : \"image/jpeg\",\n    \"purity\"     : \"sfw\",\n    \"short_url\"  : \"https://whvn.cc/01w334\",\n    \"source\"     : str,\n    \"uploader\"   : {\n        \"group\"   : \"Owner/Developer\",\n        \"username\": \"AksumkA\",\n    },\n    \"date\"       : \"dt:2014-08-31 06:17:19\",\n    \"wh_category\": \"anime\",\n    \"views\"      : int,\n    \"favorites\"  : int,\n},\n\n{\n    \"#url\"     : \"https://wallhaven.cc/w/dge6v3\",\n    \"#comment\" : \"NSFW\",\n    \"#category\": (\"\", \"wallhaven\", \"image\"),\n    \"#class\"   : wallhaven.WallhavenImageExtractor,\n    \"#sha1_url\": \"e4b802e70483f659d790ad5d0bd316245badf2ec\",\n},\n\n{\n    \"#url\"     : \"https://whvn.cc/01w334\",\n    \"#category\": (\"\", \"wallhaven\", \"image\"),\n    \"#class\"   : wallhaven.WallhavenImageExtractor,\n},\n\n{\n    \"#url\"     : \"https://w.wallhaven.cc/full/01/wallhaven-01w334.jpg\",\n    \"#category\": (\"\", \"wallhaven\", \"image\"),\n    \"#class\"   : wallhaven.WallhavenImageExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/wallpapercave.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import wallpapercave\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://wallpapercave.com/w/wp10270355\",\n    \"#category\": (\"\", \"wallpapercave\", \"image\"),\n    \"#class\"   : wallpapercave.WallpapercaveImageExtractor,\n    \"#results\"     : \"https://wallpapercave.com/download/sekai-saikou-no-ansatsusha-isekai-kizoku-ni-tensei-suru-wallpapers-wp10270355\",\n    \"#sha1_content\": \"58b088aaa1cf1a60e347015019eb0c5a22b263a6\",\n},\n\n{\n    \"#url\"     : \"https://wallpapercave.com/apple-wwdc-2024-wallpapers\",\n    \"#comment\" : \"album listing\",\n    \"#category\": (\"\", \"wallpapercave\", \"image\"),\n    \"#class\"   : wallpapercave.WallpapercaveImageExtractor,\n    \"#archive\" : False,\n    \"#results\" : (\n        \"https://wallpapercave.com/wp/wp13775438.jpg\",\n        \"https://wallpapercave.com/wp/wp13775439.jpg\",\n        \"https://wallpapercave.com/wp/wp13775440.jpg\",\n        \"https://wallpapercave.com/wp/wp13775441.jpg\",\n    ),\n},\n\n)\n"
  },
  {
    "path": "test/results/warosu.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import warosu\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://warosu.org/jp/thread/16656025\",\n    \"#category\": (\"\", \"warosu\", \"thread\"),\n    \"#class\"   : warosu.WarosuThreadExtractor,\n    \"#results\" : (\n        \"https://i.warosu.org/data/jp/img/0166/56/1488487280004.png\",\n        \"https://i.warosu.org/data/jp/img/0166/56/1488493239417.png\",\n        \"https://i.warosu.org/data/jp/img/0166/56/1488493636725.jpg\",\n        \"https://i.warosu.org/data/jp/img/0166/56/1488493700040.jpg\",\n        \"https://i.warosu.org/data/jp/img/0166/56/1488499585168.jpg\",\n        \"https://i.warosu.org/data/jp/img/0166/56/1488530851199.jpg\",\n        \"https://i.warosu.org/data/jp/img/0166/56/1488536072155.jpg\",\n        \"https://i.warosu.org/data/jp/img/0166/56/1488603426484.png\",\n        \"https://i.warosu.org/data/jp/img/0166/56/1488647021253.jpg\",\n        \"https://i.warosu.org/data/jp/img/0166/56/1488866825031.jpg\",\n        \"https://i.warosu.org/data/jp/img/0166/56/1489094956868.jpg\",\n    ),\n},\n\n{\n    \"#url\"     : \"https://warosu.org/jp/thread/16658073\",\n    \"#category\": (\"\", \"warosu\", \"thread\"),\n    \"#class\"   : warosu.WarosuThreadExtractor,\n    \"#sha1_content\" : \"d48df0a701e6599312bfff8674f4aa5d4fb8db1c\",\n    \"#results\"      : \"https://i.warosu.org/data/jp/img/0166/58/1488521824388.jpg\",\n    \"#count\"        : 1,\n\n    \"board\"     : \"jp\",\n    \"board_name\": \"Otaku Culture\",\n    \"com\"       : \"Is this canon?\",\n    \"ext\"       : \".jpg\",\n    \"extension\" : \"jpg\",\n    \"filename\"  : \"sadako-vs-kayako-movie-review\",\n    \"fsize\"     : \"55 KB\",\n    \"h\"         : 675,\n    \"image\"     : \"https://i.warosu.org/data/jp/img/0166/58/1488521824388.jpg\",\n    \"name\"      : \"Anonymous\",\n    \"no\"        : 16658073,\n    \"now\"       : \"Fri, Mar 3, 2017 01:17:04\",\n    \"thread\"    : \"16658073\",\n    \"tim\"       : 1488521824388,\n    \"time\"      : 1488503824,\n    \"title\"     : \"Is this canon?\",\n    \"w\"         : 450,\n},\n\n{\n    \"#url\"     : \"https://warosu.org/jp/thread/45886210\",\n    \"#comment\" : \"deleted post (#5289)\",\n    \"#category\": (\"\", \"warosu\", \"thread\"),\n    \"#class\"   : warosu.WarosuThreadExtractor,\n    \"#count\"   : \"> 150\",\n\n    \"board\"     : \"jp\",\n    \"board_name\": \"Otaku Culture\",\n    \"title\"     : \"/07/th Expansion Thread\",\n},\n\n{\n    \"#url\"     : \"https://warosu.org/ic/thread/4604652\",\n    \"#category\": (\"\", \"warosu\", \"thread\"),\n    \"#class\"   : warosu.WarosuThreadExtractor,\n    \"#pattern\" : r\"https://i.warosu\\.org/data/ic/img/0046/04/1590\\d{9}\\.jpg\",\n    \"#count\"   : 133,\n\n    \"board\"     : \"ic\",\n    \"board_name\": \"Artwork/Critique\",\n    \"com\"       : str,\n    \"ext\"       : \".jpg\",\n    \"filename\"  : str,\n    \"fsize\"     : str,\n    \"h\"         : range(200, 3507),\n    \"image\"     : r\"re:https://i.warosu\\.org/data/ic/img/0046/04/1590\\d+\\.jpg\",\n    \"name\"      : \"re:Anonymous|Dhe Specky Spider-Man\",\n    \"no\"        : range(4604652, 4620000),\n    \"now\"       : r\"re:\\w\\w\\w, \\w\\w\\w \\d\\d?, 2020 \\d\\d:\\d\\d:\\d\\d\",\n    \"thread\"    : \"4604652\",\n    \"tim\"       : range(1590430159651, 1590755510488),\n    \"time\"      : range(1590415759, 1590755510),\n    \"title\"     : \"American Classic Comic Artists\",\n    \"w\"         : range(200, 3000),\n},\n\n{\n    \"#url\"     : \"https://warosu.org/fa/thread/18460691\",\n    \"#comment\" : \"non-archived post (#7698)\",\n    \"#class\"   : warosu.WarosuThreadExtractor,\n    \"#pattern\" : r\"https://i.warosu.org/data/fa/img/0184/60/17\\d+\\.\\w+\",\n    \"#count\"   : 20,\n},\n\n)\n"
  },
  {
    "path": "test/results/weasyl.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import weasyl\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://www.weasyl.com/~fiz/submissions/2031/a-wesley\",\n    \"#category\": (\"\", \"weasyl\", \"submission\"),\n    \"#class\"   : weasyl.WeasylSubmissionExtractor,\n    \"#pattern\" : \"https://cdn.weasyl.com/~fiz/submissions/2031/41ebc1c2940be928532785dfbf35c37622664d2fbb8114c3b063df969562fc51/fiz-a-wesley.png\",\n\n    \"comments\"    : int,\n    \"date\"        : \"dt:2012-04-20 00:38:04\",\n    \"description\" : \"\"\"<p>(flex)</p>\n\"\"\",\n    \"favorites\"   : int,\n    \"folder_name\" : \"Wesley Stuff\",\n    \"folderid\"    : 2081,\n    \"friends_only\": False,\n    \"owner\"       : \"Fiz\",\n    \"owner_login\" : \"fiz\",\n    \"rating\"      : \"general\",\n    \"submitid\"    : 2031,\n    \"subtype\"     : \"visual\",\n    \"tags\"        : list,\n    \"title\"       : \"A Wesley!\",\n    \"type\"        : \"submission\",\n    \"views\"       : int,\n},\n\n{\n    \"#url\"     : \"https://www.weasyl.com/submission/2031/a-wesley\",\n    \"#category\": (\"\", \"weasyl\", \"submission\"),\n    \"#class\"   : weasyl.WeasylSubmissionExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.weasyl.com/view/2031/a-wesley\",\n    \"#category\": (\"\", \"weasyl\", \"submission\"),\n    \"#class\"   : weasyl.WeasylSubmissionExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.weasyl.com/~tanidareal\",\n    \"#category\": (\"\", \"weasyl\", \"submissions\"),\n    \"#class\"   : weasyl.WeasylSubmissionsExtractor,\n    \"#count\"   : \">= 200\",\n},\n\n{\n    \"#url\"     : \"https://www.weasyl.com/submissions/tanidareal\",\n    \"#category\": (\"\", \"weasyl\", \"submissions\"),\n    \"#class\"   : weasyl.WeasylSubmissionsExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.weasyl.com/~aro~so\",\n    \"#category\": (\"\", \"weasyl\", \"submissions\"),\n    \"#class\"   : weasyl.WeasylSubmissionsExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.weasyl.com/submissions/tanidareal?folderid=7403\",\n    \"#category\": (\"\", \"weasyl\", \"folder\"),\n    \"#class\"   : weasyl.WeasylFolderExtractor,\n    \"#count\"   : \">= 12\",\n},\n\n{\n    \"#url\"     : \"https://www.weasyl.com/journal/17647/bbcode\",\n    \"#category\": (\"\", \"weasyl\", \"journal\"),\n    \"#class\"   : weasyl.WeasylJournalExtractor,\n\n    \"title\"  : \"BBCode\",\n    \"date\"   : \"dt:2013-09-19 23:11:23\",\n    \"content\": \"\"\"<p><a>javascript:alert(42);</a></p>\n\n<p>No more of that!</p>\n\"\"\",\n},\n\n{\n    \"#url\"     : \"https://www.weasyl.com/journals/charmander\",\n    \"#category\": (\"\", \"weasyl\", \"journals\"),\n    \"#class\"   : weasyl.WeasylJournalsExtractor,\n    \"#count\"   : \">= 2\",\n},\n\n{\n    \"#url\"     : \"https://www.weasyl.com/favorites?userid=184616&feature=submit\",\n    \"#category\": (\"\", \"weasyl\", \"favorite\"),\n    \"#class\"   : weasyl.WeasylFavoriteExtractor,\n    \"#count\"   : \">= 5\",\n},\n\n{\n    \"#url\"     : \"https://www.weasyl.com/favorites/furoferre\",\n    \"#category\": (\"\", \"weasyl\", \"favorite\"),\n    \"#class\"   : weasyl.WeasylFavoriteExtractor,\n    \"#count\"   : \">= 5\",\n}\n\n)\n"
  },
  {
    "path": "test/results/webmshare.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import webmshare\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://webmshare.com/O9mWY\",\n    \"#category\": (\"\", \"webmshare\", \"video\"),\n    \"#class\"   : webmshare.WebmshareVideoExtractor,\n\n    \"date\"     : \"dt:2022-12-04 00:00:00\",\n    \"extension\": \"webm\",\n    \"filename\" : \"O9mWY\",\n    \"height\"   : 568,\n    \"id\"       : \"O9mWY\",\n    \"thumb\"    : \"https://s1.webmshare.com/t/O9mWY.jpg\",\n    \"title\"    : \"Yeah buddy over here\",\n    \"url\"      : \"https://s1.webmshare.com/O9mWY.webm\",\n    \"views\"    : int,\n    \"width\"    : 320,\n},\n\n{\n    \"#url\"     : \"https://s1.webmshare.com/zBGAg.webm\",\n    \"#category\": (\"\", \"webmshare\", \"video\"),\n    \"#class\"   : webmshare.WebmshareVideoExtractor,\n\n    \"date\"  : \"dt:2018-12-07 00:00:00\",\n    \"height\": 1080,\n    \"id\"    : \"zBGAg\",\n    \"thumb\" : \"https://s1.webmshare.com/t/zBGAg.jpg\",\n    \"title\" : \"\",\n    \"url\"   : \"https://s1.webmshare.com/zBGAg.webm\",\n    \"views\" : int,\n    \"width\" : 1920,\n},\n\n{\n    \"#url\"     : \"https://webmshare.com/play/zBGAg\",\n    \"#category\": (\"\", \"webmshare\", \"video\"),\n    \"#class\"   : webmshare.WebmshareVideoExtractor,\n},\n\n{\n    \"#url\"     : \"https://webmshare.com/download-webm/zBGAg\",\n    \"#category\": (\"\", \"webmshare\", \"video\"),\n    \"#class\"   : webmshare.WebmshareVideoExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/webtoons.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import webtoons\nfrom gallery_dl import exception\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://www.webtoons.com/en/comedy/safely-endangered/ep-572-earth/viewer?title_no=352&episode_no=572\",\n    \"#category\": (\"\", \"webtoons\", \"episode\"),\n    \"#class\"   : webtoons.WebtoonsEpisodeExtractor,\n    \"#count\"   : 5,\n    \"#results\" : (\n        \"https://swebtoon-phinf.pstatic.net/20200513_191/1589322488148XfdRr_PNG/15893224850013525720.png?type=opti\",\n        \"https://swebtoon-phinf.pstatic.net/20200513_143/1589322489499KJLvU_PNG/15893224866183525723.png?type=opti\",\n        \"https://swebtoon-phinf.pstatic.net/20200513_281/15893224881499wbH7_PNG/15893224865073525729.png?type=opti\",\n        \"https://swebtoon-phinf.pstatic.net/20200513_155/1589322489501Kuczb_PNG/15893224866533525729.png?type=opti\",\n        \"https://swebtoon-phinf.pstatic.net/20200513_122/1589322489499nS1t2_PNG/15893224863973525726.png?type=opti\",\n    ),\n    \"#sha1_url\"    : \"55bec5d7c42aba19e3d0d56db25fdf0b0b13be38\",\n    \"#sha1_content\": [\n        \"1748c7e82b6db910fa179f6dc7c4281b0f680fa7\",\n        \"42055e44659f6ffc410b3fb6557346dfbb993df3\",\n        \"49e1f2def04c6f7a6a3dacf245a1cd9abe77a6a9\",\n    ],\n\n    \"author_name\" : \"Chris McCoy\",\n    \"comic\"       : \"safely-endangered\",\n    \"comic_name\"  : \"Safely Endangered\",\n    \"count\"       : 5,\n    \"description\" : \"Silly comics for silly people.\",\n    \"episode\"     : \"572\",\n    \"episode_name\": \"Ep. 572 - Earth\",\n    \"episode_no\"  : \"572\",\n    \"genre\"       : \"comedy\",\n    \"lang\"        : \"en\",\n    \"language\"    : \"English\",\n    \"num\"         : range(1, 5),\n    \"title\"       : \"Safely Endangered - Ep. 572 - Earth\",\n    \"title_no\"    : \"352\",\n    \"username\"    : \"safelyendangered\",\n},\n\n{\n    \"#url\"     : \"https://www.webtoons.com/en/comedy/safely-endangered/ep-572-earth/viewer?title_no=352&episode_no=572\",\n    \"#comment\" : \"thumbnails (#6468 #7441)\",\n    \"#class\"   : webtoons.WebtoonsEpisodeExtractor,\n    \"#options\" : {\"thumbnails\": True},\n    \"#range\"   : \"1\",\n    \"#results\" : \"https://swebtoon-phinf.pstatic.net/20200513_37/1589322553469E5p76_PNG/thumb_15893224866533525729.png\",\n    \"#sha1_content\": \"e01e70610821df6ece601393eb6fd7d80fc42f9a\",\n\n    \"count\": 5,\n    \"num\"  : 0,\n    \"type\" : \"thumbnail\",\n},\n\n{\n    \"#url\"     : \"https://www.webtoons.com/en/challenge/punderworld/happy-earth-day-/viewer?title_no=312584&episode_no=40\",\n    \"#category\": (\"\", \"webtoons\", \"episode\"),\n    \"#class\"   : webtoons.WebtoonsEpisodeExtractor,\n    \"#exception\": exception.NotFoundError,\n\n    \"comic\"      : \"punderworld\",\n    \"description\": str,\n    \"episode\"    : \"36\",\n    \"episode_no\" : \"40\",\n    \"genre\"      : \"challenge\",\n    \"title\"      : r\"re:^Punderworld - .+\",\n    \"title_no\"   : \"312584\",\n},\n\n{\n    \"#url\"     : \"https://www.webtoons.com/en/canvas/i-want-to-be-a-cute-anime-girl/209-the-storys-story/viewer?title_no=349416&episode_no=214\",\n    \"#category\": (\"\", \"webtoons\", \"episode\"),\n    \"#class\"   : webtoons.WebtoonsEpisodeExtractor,\n    \"#results\" : (\n        \"https://swebtoon-phinf.pstatic.net/20220121_262/1642763563000TUsiC_JPEG/7ddc535a-0bde-40df-ab62-f912aed1c751.jpg\",\n        \"https://swebtoon-phinf.pstatic.net/20220121_152/1642763564219c8T9I_JPEG/73ccdf9f-c46c-4760-8553-799713300fd7.jpg\",\n        \"https://swebtoon-phinf.pstatic.net/20220121_80/16427635653964Eh5i_JPEG/1bd3c498-656b-4b1f-bf22-e25c01a01679.jpg\",\n        \"https://swebtoon-phinf.pstatic.net/20220121_224/1642763566551Rx6e2_JPEG/6e61cddc-0af5-4e2a-b3b4-67fdd258feac.jpg\",\n    ),\n\n    \"comic_name\"  : \"I want to be a cute anime girl\",\n    \"episode_name\": \"209 - The story's story\",\n    \"episode\"     : \"214\",\n    \"username\"    : \"m9huj\",\n    \"author_name\" : \"Azul Crescent\",\n},\n\n{\n    \"#url\"     : \"https://www.webtoons.com/en/canvas/i-want-to-be-a-cute-anime-girl/174-not-194-it-was-a-typo-later/viewer?title_no=349416&episode_no=179\",\n    \"#category\": (\"\", \"webtoons\", \"episode\"),\n    \"#class\"   : webtoons.WebtoonsEpisodeExtractor,\n    \"#options\" : {\"quality\": 50},\n    \"#results\" : (\n        \"https://swebtoon-phinf.pstatic.net/20210629_102/1624911944660PIYD2_JPEG/27c5312d-7b9b-4b75-8026-526e9a55331a.jpg?type=q50\",\n        \"https://swebtoon-phinf.pstatic.net/20210629_295/1624911951107dhQEw_JPEG/fc4bd86a-effc-4f0e-88d5-8c48d6ec3902.jpg?type=q50\",\n        \"https://swebtoon-phinf.pstatic.net/20210629_293/16249119579830kbnl_JPEG/96203608-31e7-4f1c-a9e0-db5d43457884.jpg?type=q50\",\n        \"https://swebtoon-phinf.pstatic.net/20210629_152/1624911964359nWSlj_JPEG/510e1c7e-2d13-4757-b215-8fbd1883e81e.jpg?type=q50\",\n    ),\n\n    \"comic_name\"  : \"I want to be a cute anime girl\",\n    \"episode_name\": \"174 (not 194, it was a typo) - Later\",\n    \"episode\"     : \"179\",\n    \"username\"    : \"m9huj\",\n    \"author_name\" : \"Azul Crescent\",\n},\n\n{\n    \"#url\"     : \"https://www.webtoons.com/en/canvas/us-over-here/1-the-wheel/viewer?title_no=919536&episode_no=1\",\n    \"#category\": (\"\", \"webtoons\", \"episode\"),\n    \"#class\"   : webtoons.WebtoonsEpisodeExtractor,\n    \"#options\" : {\"quality\": {\"jpg\": \"q0\", \"jpeg\": \"q100\", \"png\": False}},\n    \"#results\" : (\n        \"https://swebtoon-phinf.pstatic.net/20240125_32/17061125731244mMCw_JPEG/0001.JPEG?type=q100\",\n        \"https://swebtoon-phinf.pstatic.net/20240125_290/1706112575827OXqUk_JPEG/0059.JPEG?type=q100\",\n        \"https://swebtoon-phinf.pstatic.net/20240125_211/1706112575860p6rEU_JPEG/0060.JPEG?type=q100\",\n    ),\n\n    \"comic_name\"  : \"(news soon)\",\n    \"episode_name\": \"1. The Wheel\",\n    \"episode\"     : \"1\",\n    \"username\"    : \"i94q8\",\n    \"author_name\" : \"spin.ani\",\n},\n\n{\n    \"#url\"     : \"https://www.webtoons.com/en/super-hero/unordinary/episode-20/viewer?title_no=679&episode_no=21\",\n    \"#comment\" : \"background music (#8733)\",\n    \"#class\"   : webtoons.WebtoonsEpisodeExtractor,\n    \"#options\" : {\"bgm\": True},\n    \"#range\"   : \"1\",\n    \"#pattern\" : r\"ytdl:https://apis.naver.com/audioc/audiocplay/play/audio/4A10DDE1B92388DA164C48B0356AA442/hls/manifest\\.m3u8\\?apigw-routing-key=KR&codec=AAC&kbps=64&tt=\\d+&tv=.+\",\n\n    \"audioId\"         : \"4A10DDE1B92388DA164C48B0356AA442\",\n    \"author_name\"     : \"uru-chan\",\n    \"codec\"           : \"AAC\",\n    \"comic\"           : \"unordinary\",\n    \"comic_name\"      : \"unOrdinary\",\n    \"count\"           : 63,\n    \"cpContentId\"     : None,\n    \"cpNo\"            : 5,\n    \"description\"     : \"Nobody paid much attention to John – just a normal teenager at a high school where the social elite happen to possess unthinkable powers and abilities. But John’s got a secret past that threatens to bring down the school’s whole social order – and much more. Fulfilling his destiny won’t be easy though, because there are battles, frenemies and deadly conspiracies around every corner.\",\n    \"duration\"        : 90.096692,\n    \"encodingTargetYn\": False,\n    \"episode\"         : \"21\",\n    \"episodeNo\"       : 21,\n    \"episode_name\"    : \"Episode 20\",\n    \"episode_no\"      : \"21\",\n    \"expireTime\"      : int,\n    \"extension\"       : \"mp3\",\n    \"filePath\"        : \"679_21/1475723621351drama7.mp3\",\n    \"genre\"           : \"super-hero\",\n    \"kbps\"            : 64,\n    \"lang\"            : \"en\",\n    \"language\"        : \"English\",\n    \"num\"             : 0,\n    \"num_play\"        : 17,\n    \"num_stop\"        : 0,\n    \"filename_play\"   : \"1475724249934679214\",\n    \"filename_stop\"   : \"\",\n    \"objectType\"      : \"mp4a.40.2\",\n    \"originalFileSize\": 0,\n    \"playImageUrl\"    : \"/20161006_271/1475724249957QlGUF_JPEG/1475724249934679214.jpg\",\n    \"region\"          : \"KR\",\n    \"registerYmdt\"    : \"2016-10-06 12:25:09\",\n    \"sortOrder\"       : 1,\n    \"stopImageUrl\"    : \"\",\n    \"title\"           : \"unOrdinary - Episode 20\",\n    \"titleNo\"         : 679,\n    \"title_no\"        : \"679\",\n    \"type\"            : \"bgm\",\n    \"username\"        : \"62610\",\n},\n\n{\n    \"#url\"     : \"https://www.webtoons.com/en/comedy/live-with-yourself/list?title_no=919\",\n    \"#comment\" : \"english\",\n    \"#category\": (\"\", \"webtoons\", \"comic\"),\n    \"#class\"   : webtoons.WebtoonsComicExtractor,\n    \"#pattern\" : webtoons.WebtoonsEpisodeExtractor.pattern,\n    \"#range\"   : \"1-15\",\n    \"#count\"   : \">= 14\",\n\n    \"page\"      : range(1, 2),\n    \"title_no\"  : 919,\n    \"episode_no\": range(1, 14),\n},\n\n{\n    \"#url\"     : \"https://www.webtoons.com/en/comedy/live-with-yourself/list?title_no=919\",\n    \"#comment\" : \"banner (#6468)\",\n    \"#category\": (\"\", \"webtoons\", \"comic\"),\n    \"#class\"   : webtoons.WebtoonsComicExtractor,\n    \"#options\" : {\"banners\": True},\n    \"#range\"   : \"1-3\",\n    \"#results\" : (\n        \"https://swebtoon-phinf.pstatic.net/20190126_226/1548461599138G7THv_PNG/03_EC9E91ED9288EC8381EC84B8_PC_ECBA90EBA6ADED84B0.png\",\n        \"https://www.webtoons.com/en/comedy/live-with-yourself/ep-12-aint-gonna-face-no-defeat/viewer?title_no=919&episode_no=14\",\n        \"https://www.webtoons.com/en/comedy/live-with-yourself/interlude-2/viewer?title_no=919&episode_no=13\",\n        \"https://www.webtoons.com/en/comedy/live-with-yourself/ep-11-can-barely-stand-on-my-feet/viewer?title_no=919&episode_no=12\",\n    ),\n\n    \"?type\"      : \"banner\",\n    \"title_no\"   : 919,\n    \"?episode_no\": range(12, 14),\n},\n\n{\n    \"#url\"     : \"https://www.webtoons.com/fr/romance/subzero/list?title_no=1845&page=7\",\n    \"#comment\" : \"french\",\n    \"#category\": (\"\", \"webtoons\", \"comic\"),\n    \"#class\"   : webtoons.WebtoonsComicExtractor,\n    \"#count\"   : \">= 15\",\n\n    \"page\"      : range(7, 25),\n    \"title_no\"  : 1845,\n    \"episode_no\": int,\n},\n\n{\n    \"#url\"     : \"https://www.webtoons.com/en/challenge/scoob-and-shag/list?title_no=210827&page=9\",\n    \"#comment\" : \"(#820)\",\n    \"#category\": (\"\", \"webtoons\", \"comic\"),\n    \"#class\"   : webtoons.WebtoonsComicExtractor,\n    \"#count\"   : \">= 18\",\n\n    \"page\"      : int,\n    \"title_no\"  : 210827,\n    \"episode_no\": int,\n},\n\n{\n    \"#url\"     : \"https://www.webtoons.com/es/romance/lore-olympus/list?title_no=1725\",\n    \"#comment\" : \"(#1643)\",\n    \"#category\": (\"\", \"webtoons\", \"comic\"),\n    \"#class\"   : webtoons.WebtoonsComicExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.webtoons.com/p/community/en/u/g6vj8\",\n    \"#class\"   : webtoons.WebtoonsArtistExtractor,\n    \"#results\" : (\n        \"https://www.webtoons.com/en/canvas/scoob-and-shag/list?title_no=210827\",\n        \"https://www.webtoons.com/en/canvas/sparkle-kid/list?title_no=205304\",\n    ),\n\n    \"id\"     : {\"210827\", \"205304\"},\n    \"subject\": {\"Scoob and Shag\", \"Sparkle Kid\"},\n    \"authors\": [\n        {\n            \"nickname\": \"Misterie Krew\",\n        },\n    ],\n\n},\n\n)\n"
  },
  {
    "path": "test/results/weebcentral.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import weebcentral\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://weebcentral.com/chapters/01J76XZ4PC3VW91BYFBQJA44C3\",\n    \"#class\"   : weebcentral.WeebcentralChapterExtractor,\n    \"#pattern\" : r\"https://official\\.lowee\\.us/manga/Aria/0067\\.5-0\\d\\d\\.png\",\n    \"#count\"   : 17,\n\n    \"author\"       : [\"AMANO Kozue\"],\n    \"chapter\"      : 67,\n    \"chapter_id\"   : \"01J76XZ4PC3VW91BYFBQJA44C3\",\n    \"chapter_minor\": \".5\",\n    \"chapter_type\" : \"Navigation\",\n    \"count\"        : 17,\n    \"description\"  : \"On the planet Aqua, a world once known as Mars, Mizunashi Akari has just made her home in the town of Neo-VENEZIA, a futuristic imitation of the ancient city of Venice. The technology of \\\"Man Home\\\" (formerly Earth) has not entirely reached this planet, and Akari is alone, having no contact with family or friends. Nonetheless, the town, with its charming labyrinths of rivers and canals, becomes Akari's new infatuation, along with the dream of becoming a full-fledged gondolier. Reverting to a more \\\"primitive\\\" lifestyle and pursuing a new trade, the character of Akari becomes both adventurous and heartwarming all at once.\",\n    \"extension\"    : \"png\",\n    \"filename\"     : r\"re:0067\\.5-0\\d\\d\",\n    \"width\"        : {1129, 2133},\n    \"height\"       : {1511, 1600},\n    \"lang\"         : \"en\",\n    \"language\"     : \"English\",\n    \"manga\"        : \"Aria\",\n    \"manga_id\"     : \"01J76XY8G1GK8EJ9VQG92C3DKM\",\n    \"official\"     : True,\n    \"page\"         : range(1, 17),\n    \"release\"      : \"2002\",\n    \"status\"       : \"Complete\",\n    \"type\"         : \"Manga\",\n    \"tags\"         : [\n        \"Adventure\",\n        \"Comedy\",\n        \"Drama\",\n        \"Sci-fi\",\n        \"Shounen\",\n        \"Slice of Life\",\n    ],\n},\n\n{\n    \"#url\"     : \"https://weebcentral.com/chapters/01J76XZ4PBJFDFCQA6NQP4HDNJ\",\n    \"#comment\" : \"wrong page count after chapter with less pages (#6966)\",\n    \"#class\"   : weebcentral.WeebcentralChapterExtractor,\n    \"#pattern\" : r\"https://official\\.lowee\\.us/manga/Aria/0001-0\\d\\d\\.png\",\n    \"#count\"   : 42,\n\n    \"chapter\"      : 1,\n    \"chapter_id\"   : \"01J76XZ4PBJFDFCQA6NQP4HDNJ\",\n    \"chapter_minor\": \"\",\n    \"chapter_type\" : \"Navigation\",\n    \"count\"        : 42,\n    \"page\"         : range(1, 42),\n},\n\n{\n    \"#url\"     : \"https://weebcentral.com/series/01J76XY8G1GK8EJ9VQG92C3DKM/Aria\",\n    \"#class\"   : weebcentral.WeebcentralMangaExtractor,\n    \"#pattern\" : weebcentral.WeebcentralChapterExtractor.pattern,\n    \"#count\"   : 75,\n\n    \"author\"       : [\"AMANO Kozue\"],\n    \"chapter\"      : range(1, 70),\n    \"chapter_id\"   : r\"re:01J\\w{23}\",\n    \"chapter_minor\": {\"\", \".5\"},\n    \"chapter_type\" : \"Navigation\",\n    \"date\"         : \"type:datetime\",\n    \"description\"  : \"On the planet Aqua, a world once known as Mars, Mizunashi Akari has just made her home in the town of Neo-VENEZIA, a futuristic imitation of the ancient city of Venice. The technology of \\\"Man Home\\\" (formerly Earth) has not entirely reached this planet, and Akari is alone, having no contact with family or friends. Nonetheless, the town, with its charming labyrinths of rivers and canals, becomes Akari's new infatuation, along with the dream of becoming a full-fledged gondolier. Reverting to a more \\\"primitive\\\" lifestyle and pursuing a new trade, the character of Akari becomes both adventurous and heartwarming all at once.\",\n    \"lang\"         : \"en\",\n    \"language\"     : \"English\",\n    \"manga\"        : \"Aria\",\n    \"manga_id\"     : \"01J76XY8G1GK8EJ9VQG92C3DKM\",\n    \"official\"     : True,\n    \"release\"      : \"2002\",\n    \"status\"       : \"Complete\",\n    \"type\"         : \"Manga\",\n    \"tags\"         : [\n        \"Adventure\",\n        \"Comedy\",\n        \"Drama\",\n        \"Sci-fi\",\n        \"Shounen\",\n        \"Slice of Life\",\n    ],\n},\n\n)\n"
  },
  {
    "path": "test/results/weebdex.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import weebdex\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://weebdex.org/chapter/f6c0awnrba\",\n    \"#class\"   : weebdex.WeebdexChapterExtractor,\n    \"#pattern\" : r\"https://s\\d+\\.weebdex\\.net/data/f6c0awnrba/\\d+-\\w{64}\\.png\",\n    \"#count\"   : 14,\n\n    \"artist\"       : [\"Nokomi (のこみ)\"],\n    \"author\"       : [\"Tanabata Satori\"],\n    \"chapter\"      : 11,\n    \"chapter_id\"   : \"f6c0awnrba\",\n    \"chapter_minor\": \".5\",\n    \"count\"        : 14,\n    \"page\"         : range(1, 14),\n    \"date\"         : \"dt:2025-10-13 10:47:26\",\n    \"date_updated\" : \"type:datetime\",\n    \"demographic\"  : \"shoujo\",\n    \"extension\"    : \"png\",\n    \"filename\"     : str,\n    \"group\"        : [\"Knights of Earl Grey\"],\n    \"width\"        : range(800, 2800),\n    \"height\"       : range(800, 2800),\n    \"lang\"         : \"en\",\n    \"manga\"        : \"Akuyaku Reijou Level 99: Watashi wa UraBoss desu ga Maou de wa Arimasen\",\n    \"manga_date\"   : \"dt:2025-10-09 07:32:07\",\n    \"manga_id\"     : \"raa6dfy3da\",\n    \"origin\"       : \"ja\",\n    \"status\"       : \"ongoing\",\n    \"title\"        : \"[Extra] A Day In The Life Of Patrick\",\n    \"uploader\"     : \"system\",\n    \"version\"      : 1,\n    \"volume\"       : 2,\n    \"year\"         : 2020,\n    \"description\"  : \"\"\"\\\nI reincarnated as the \"Villainess Eumiella\" from an RPG Otome game. In the main story, Eumiella is merely a side character, but after the ending, she re-enters the story as the Hidden Boss, a character boasting high stats on par with the heroes! Lighting a fire in my gamer's soul, and taking advantage of being left on my own in my parent's territory, I trained, trained, and trained! As a result of my training... by the time I enrolled in the academy, I managed to reach level 99. Though I had planned to live out my days as inconspicuously and peacefully as possible, soon after entering the school, I'm suspected by the Heroine and Love Interests of being the \"Demon Lord\"...?\n___\n**Links:**\n- Alternative Official Raw - [Niconico](https://manga.nicovideo.jp/comic/46067)\\\n\"\"\",\n    \"tags\"         : [\n        \"format:Adaptation\",\n        \"genre:Action\",\n        \"genre:Comedy\",\n        \"genre:Fantasy\",\n        \"genre:Isekai\",\n        \"genre:Romance\",\n        \"theme:Demons\",\n        \"theme:Magic\",\n        \"theme:Monsters\",\n        \"theme:Reincarnation\",\n        \"theme:School Life\",\n        \"theme:Video Games\",\n        \"theme:Villainess\",\n    ],\n},\n\n{\n    \"#url\"     : \"https://weebdex.org/chapter/itizot1rxc\",\n    \"#class\"   : weebdex.WeebdexChapterExtractor,\n    \"#pattern\" : r\"https://s\\d+\\.weebdex\\.net/data/itizot1rxc/\\d+\\-\\w+\\.jpg\",\n    \"#count\"   : 17,\n\n    \"artist\"       : [\"Matsuda Minoru\"],\n    \"author\"       : [\"Matsuda Minoru\"],\n    \"chapter\"      : 10,\n    \"chapter_id\"   : \"itizot1rxc\",\n    \"chapter_minor\": \"\",\n    \"count\"        : 17,\n    \"demographic\"  : \"seinen\",\n    \"group\"        : [\"BBB Translation (Big Beaming Bluewhale)\"],\n    \"lang\"         : \"vi\",\n    \"manga\"        : \"Ani Datta Mono\",\n    \"manga_date\"   : \"dt:2025-10-09 19:02:04\",\n    \"manga_id\"     : \"3o0icxno26\",\n    \"origin\"       : \"ja\",\n    \"title\"        : \"Cuộc hẹn tại phía Đông vườn địa đàng\",\n    \"uploader\"     : str,\n    \"version\"      : 1,\n    \"volume\"       : 2,\n    \"year\"         : 2021,\n},\n\n{\n    \"#url\"     : \"https://weebdex.org/chapter/itizot1rxc\",\n    \"#comment\" : \"'data-saver' option (#8914)\",\n    \"#class\"   : weebdex.WeebdexChapterExtractor,\n    \"#options\" : {\"data-saver\": True},\n    \"#range\"   : \"1-3\",\n    \"#results\" : (\n        \"https://s11.weebdex.net/data/itizot1rxc/1-fa838df3d9b2d16ab3f900a5d74d8e1fe2b15446825978209053297b8e3f7d0d.webp\",\n        \"https://s11.weebdex.net/data/itizot1rxc/2-b4914436ab21e7915440024a5b5bd705c3a844df314c3c7d722388b3ce81626a.webp\",\n        \"https://s11.weebdex.net/data/itizot1rxc/3-6b0e1901cfae7d6d5c21769d714c992fb1fa1af6e9e8e10083ea5a6bf5d53cef.webp\",\n    ),\n},\n\n{\n    \"#url\"     : \"https://weebdex.org/chapter/spsn025m8a\",\n    \"#comment\" : \"no 'demographic' data (#8939)\",\n    \"#class\"   : weebdex.WeebdexChapterExtractor,\n    \"#count\"   : 49,\n\n    \"artist\"       : [\"Fuugetsu Makoto\"],\n    \"author\"       : [\"Koyagi Mei\"],\n    \"chapter\"      : 1,\n    \"chapter_id\"   : \"spsn025m8a\",\n    \"chapter_minor\": \"\",\n    \"count\"        : 49,\n    \"date\"         : \"dt:2026-01-19 11:12:36\",\n    \"demographic\"  : None,\n    \"group\"        : [\"Asmodeus Scans\"],\n    \"lang\"         : \"en\",\n    \"manga\"        : \"Yuusha no Sensei, Saikyou no Kuzu ni naru. ~S-kyuu Party no Moto Eiyuu, Ura Shakai no Ihou Guild de Nariagari~\",\n    \"manga_date\"   : \"dt:2025-10-09 09:21:18\",\n    \"manga_id\"     : \"vtyi8syfjd\",\n    \"origin\"       : \"ja\",\n    \"original\"     : True,\n    \"title\"        : \"\",\n    \"uploader\"     : \"asmodai\",\n    \"version\"      : 1,\n    \"volume\"       : 1,\n    \"year\"         : 2023,\n},\n\n{\n    \"#url\"     : \"https://weebdex.org/chapter/0fignihbv8\",\n    \"#comment\" : \"no 'volume' data\",\n    \"#class\"   : weebdex.WeebdexChapterExtractor,\n    \"#count\"   : 47,\n\n    \"artist\"       : [\"Azuma Kiyohiko\"],\n    \"author\"       : [\"Azuma Kiyohiko\"],\n    \"chapter\"      : 114,\n    \"chapter_id\"   : \"0fignihbv8\",\n    \"chapter_minor\": \"\",\n    \"count\"        : 47,\n    \"date\"         : \"dt:2025-10-17 23:36:27\",\n    \"demographic\"  : \"shounen\",\n    \"group\"        : [\"Ralen\"],\n    \"lang\"         : \"en\",\n    \"manga\"        : \"Yotsuba to!\",\n    \"volume\"       : 0,\n},\n\n{\n    \"#url\"     : \"https://weebdex.org/chapter/8oay4lxrk7\",\n    \"#comment\" : \"no 'chapter' data / oneshot (#9123)\",\n    \"#class\"   : weebdex.WeebdexChapterExtractor,\n    \"#pattern\" : r\"https://s15\\.weebdex\\.net/data/8oay4lxrk7/.+\",\n    \"#count\"   : 18,\n\n    \"chapter\"      : 0,\n    \"chapter_id\"   : \"8oay4lxrk7\",\n    \"chapter_minor\": \"\",\n},\n\n{\n    \"#url\"     : \"https://weebdex.org/title/3o0icxno26/ani-datta-mono\",\n    \"#class\"   : weebdex.WeebdexMangaExtractor,\n    \"#pattern\" : weebdex.WeebdexChapterExtractor.pattern,\n    \"#options\" : {\"lang\": None},\n    \"#count\"   : range(120, 300),\n\n    \"artist\"       : [\"Matsuda Minoru\"],\n    \"author\"       : [\"Matsuda Minoru\"],\n    \"volume\"       : int,\n    \"chapter\"      : int,\n    \"chapter_minor\": {\"\", \".5\"},\n    \"created_at\"   : \"iso:dt\",\n    \"published_at\" : \"iso:dt\",\n    \"updated_at\"   : \"iso:dt\",\n    \"demographic\"  : \"seinen\",\n    \"id\"           : str,\n    \"language\"     : {\"en\", \"vi\"},\n    \"manga\"        : \"Ani Datta Mono\",\n    \"manga_date\"   : \"dt:2025-10-09 19:02:04\",\n    \"manga_id\"     : \"3o0icxno26\",\n    \"origin\"       : \"ja\",\n    \"status\"       : \"ongoing\",\n    \"version\"      : {1, 2, 3, 4},\n    \"year\"         : 2021,\n    \"description\"  : \"\"\"\\\nMy brother died. When I went to visit my brother's grave with my brother's lover——……\n\nThis is the story of my brother's lover, me, and “the thing that was my brother”.\n___\n**Additional Links:**\n- [Official TikTok](http://tiktok.com/@anidattamono)\n- [Official X](https://x.com/anidattamono)\\\n\"\"\",\n    \"tags\"         : [\n        \"genre:Drama\",\n        \"genre:Horror\",\n        \"genre:Psychological\",\n        \"genre:Romance\",\n        \"genre:Thriller\",\n        \"theme:Ghosts\",\n        \"theme:Supernatural\",\n    ],\n},\n\n{\n    \"#url\"     : \"https://weebdex.org/title/f29vfu3dd4/yotsuba-to\",\n    \"#comment\" : \"no 'volume' data (#8954)\",\n    \"#class\"   : weebdex.WeebdexMangaExtractor,\n    \"#pattern\" : weebdex.WeebdexChapterExtractor.pattern,\n\n    \"artist\"     : [\"Azuma Kiyohiko\"],\n    \"author\"     : [\"Azuma Kiyohiko\"],\n    \"demographic\": \"shounen\",\n    \"manga\"      : \"Yotsuba to!\",\n    \"manga_date\" : \"dt:2025-10-09 09:34:27\",\n    \"manga_id\"   : \"f29vfu3dd4\",\n    \"origin\"     : \"ja\",\n    \"volume\"     : int,\n    \"year\"       : 2003,\n},\n\n{\n    \"#url\"     : \"https://weebdex.org/title/3o0icxno26/ani-datta-mono\",\n    \"#comment\" : \"'lang' option (#8957)\",\n    \"#class\"   : weebdex.WeebdexMangaExtractor,\n    \"#pattern\" : weebdex.WeebdexChapterExtractor.pattern,\n    \"#options\" : {\"lang\": \"vi\"},\n    \"#count\"   : 105,\n\n    \"artist\"       : [\"Matsuda Minoru\"],\n    \"author\"       : [\"Matsuda Minoru\"],\n    \"chapter\"      : range(1, 105),\n    \"chapter_minor\": {\"\", \".5\"},\n    \"demographic\"  : \"seinen\",\n    \"description\"  : str,\n    \"language\"     : \"vi\",\n    \"manga\"        : \"Ani Datta Mono\",\n    \"manga_date\"   : \"dt:2025-10-09 19:02:04\",\n    \"manga_id\"     : \"3o0icxno26\",\n    \"origin\"       : \"ja\",\n    \"status\"       : \"ongoing\",\n    \"version\"      : int,\n    \"volume\"       : int,\n    \"year\"         : 2021,\n    \"tags\"         : [\n        \"genre:Drama\",\n        \"genre:Horror\",\n        \"genre:Psychological\",\n        \"genre:Romance\",\n        \"genre:Thriller\",\n        \"theme:Ghosts\",\n        \"theme:Supernatural\",\n    ],\n    \"relationships\": {\n        \"groups\"  : [{\n            \"id\"  : \"u7kmeka8v6\",\n            \"name\": \"BBB Translation (Big Beaming Bluewhale)\",\n        }],\n    },\n},\n\n{\n    \"#url\"     : \"https://weebdex.org/title/3o0icxno26/ani-datta-mono?group=j0fsj3oem3&order=desc\",\n    \"#comment\" : \"query parameters (#8957)\",\n    \"#class\"   : weebdex.WeebdexMangaExtractor,\n    \"#range\"   : \"1-3\",\n    \"#results\" : (\n        \"https://weebdex.org/chapter/u18zq7aclx\",\n        \"https://weebdex.org/chapter/2qb8jw9paz\",\n        \"https://weebdex.org/chapter/aht5ukglyv\",\n    ),\n\n    \"artist\"       : [\"Matsuda Minoru\"],\n    \"author\"       : [\"Matsuda Minoru\"],\n    \"chapter\"      : range(30, 50),\n    \"language\"     : \"en\",\n    \"manga\"        : \"Ani Datta Mono\",\n    \"manga_date\"   : \"dt:2025-10-09 19:02:04\",\n    \"manga_id\"     : \"3o0icxno26\",\n    \"version\"      : 1,\n    \"volume\"       : {3, 4, 5},\n    \"relationships\": {\n        \"groups\"  : [{\n            \"id\"  : \"j0fsj3oem3\",\n            \"name\": \"Rainbow D Translations\",\n        }],\n    },\n},\n\n{\n    \"#url\"     : \"https://weebdex.org/title/3o0icxno26/ani-datta-mono?group=j0fsj3oem3\",\n    \"#comment\" : \"'chapter-reverse' option (#9041)\",\n    \"#class\"   : weebdex.WeebdexMangaExtractor,\n    \"#options\" : {\"chapter-reverse\": True},\n    \"#range\"   : \"1-3\",\n    \"#results\" : (\n        \"https://weebdex.org/chapter/xv2cm9bj1a\",\n        \"https://weebdex.org/chapter/etoaxmxgcq\",\n        \"https://weebdex.org/chapter/24ew0mo562\",\n    ),\n},\n\n{\n    \"#url\"     : \"https://weebdex.org/title/s7ej90rn2z/watashi-no-te-ga-suki-na-hito\",\n    \"#comment\" : \"no 'chapter' data / oneshot (#9123)\",\n    \"#class\"   : weebdex.WeebdexMangaExtractor,\n    \"#results\" : \"https://weebdex.org/chapter/8oay4lxrk7\",\n\n    \"chapter\"      : 0,\n    \"chapter_minor\": \"\",\n},\n\n)\n"
  },
  {
    "path": "test/results/weibo.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import weibo\nfrom gallery_dl import exception\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://weibo.com/1758989602\",\n    \"#category\": (\"\", \"weibo\", \"user\"),\n    \"#class\"   : weibo.WeiboUserExtractor,\n    \"#results\" : \"https://weibo.com/u/1758989602?tabtype=feed\",\n},\n\n{\n    \"#url\"     : \"https://weibo.com/1758989602\",\n    \"#category\": (\"\", \"weibo\", \"user\"),\n    \"#class\"   : weibo.WeiboUserExtractor,\n    \"#options\" : {\"include\": \"all\"},\n    \"#results\" : (\n        \"https://weibo.com/u/1758989602?tabtype=home\",\n        \"https://weibo.com/u/1758989602?tabtype=feed\",\n        \"https://weibo.com/u/1758989602?tabtype=video\",\n        \"https://weibo.com/u/1758989602?tabtype=newVideo\",\n        \"https://weibo.com/u/1758989602?tabtype=article\",\n        \"https://weibo.com/u/1758989602?tabtype=album\",\n        \"https://weibo.com/u/1758989602?tabtype=album-only\",\n    ),\n},\n\n{\n    \"#url\"     : \"https://weibo.com/zhouyuxi77\",\n    \"#category\": (\"\", \"weibo\", \"user\"),\n    \"#class\"   : weibo.WeiboUserExtractor,\n    \"#results\" : \"https://weibo.com/u/7488709788?tabtype=feed\",\n},\n\n{\n    \"#url\"     : \"https://www.weibo.com/n/周于希Sally\",\n    \"#category\": (\"\", \"weibo\", \"user\"),\n    \"#class\"   : weibo.WeiboUserExtractor,\n    \"#results\" : \"https://weibo.com/u/7488709788?tabtype=feed\",\n},\n\n{\n    \"#url\"     : \"https://weibo.com/u/1758989602\",\n    \"#category\": (\"\", \"weibo\", \"user\"),\n    \"#class\"   : weibo.WeiboUserExtractor,\n},\n\n{\n    \"#url\"     : \"https://weibo.com/p/1758989602\",\n    \"#category\": (\"\", \"weibo\", \"user\"),\n    \"#class\"   : weibo.WeiboUserExtractor,\n},\n\n{\n    \"#url\"     : \"https://m.weibo.cn/profile/2314621010\",\n    \"#category\": (\"\", \"weibo\", \"user\"),\n    \"#class\"   : weibo.WeiboUserExtractor,\n},\n\n{\n    \"#url\"     : \"https://m.weibo.cn/p/2304132314621010_-_WEIBO_SECOND_PROFILE_WEIBO\",\n    \"#category\": (\"\", \"weibo\", \"user\"),\n    \"#class\"   : weibo.WeiboUserExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.weibo.com/p/1003062314621010/home\",\n    \"#category\": (\"\", \"weibo\", \"user\"),\n    \"#class\"   : weibo.WeiboUserExtractor,\n},\n\n{\n    \"#url\"     : \"https://weibo.com/1758989602?tabtype=home\",\n    \"#comment\" : \"'tabtype=home' is broken on website itself\",\n    \"#category\": (\"\", \"weibo\", \"home\"),\n    \"#class\"   : weibo.WeiboHomeExtractor,\n    \"#range\"   : \"1-30\",\n    \"#count\"   : 0,\n},\n\n{\n    \"#url\"     : \"https://weibo.com/2553930725?tabtype=feed\",\n    \"#category\": (\"\", \"weibo\", \"feed\"),\n    \"#class\"   : weibo.WeiboFeedExtractor,\n    \"#range\"   : \"1-30\",\n    \"#count\"   : 30,\n},\n\n{\n    \"#url\"     : \"https://weibo.com/zhouyuxi77?tabtype=feed\",\n    \"#category\": (\"\", \"weibo\", \"feed\"),\n    \"#class\"   : weibo.WeiboFeedExtractor,\n    \"#range\"   : \"1\",\n\n    \"status\": {\n        \"user\": {\n            \"id\": 7488709788,\n        },\n    },\n},\n\n{\n    \"#url\"     : \"https://www.weibo.com/n/周于希Sally?tabtype=feed\",\n    \"#category\": (\"\", \"weibo\", \"feed\"),\n    \"#class\"   : weibo.WeiboFeedExtractor,\n    \"#range\"   : \"1\",\n\n\n    \"status\": {\n        \"user\": {\n            \"id\": 7488709788,\n        },\n    },\n},\n\n{\n    \"#url\"     : \"https://weibo.com/u/7500315942?tabtype=feed\",\n    \"#comment\" : \"deleted (#2521)\",\n    \"#category\": (\"\", \"weibo\", \"feed\"),\n    \"#class\"   : weibo.WeiboFeedExtractor,\n    \"#count\"   : 0,\n},\n\n{\n    \"#url\"     : \"https://weibo.com/1758989602?tabtype=video\",\n    \"#category\": (\"\", \"weibo\", \"videos\"),\n    \"#class\"   : weibo.WeiboVideosExtractor,\n    \"#pattern\" : r\"https://f\\.(video\\.weibocdn\\.com|us\\.sinaimg\\.cn)/(../)?\\w+\\.mp4\\?label=mp\",\n    \"#range\"   : \"1-30\",\n    \"#count\"   : 30,\n},\n\n{\n    \"#url\"     : \"https://weibo.com/1758989602?tabtype=newVideo\",\n    \"#category\": (\"\", \"weibo\", \"newvideo\"),\n    \"#class\"   : weibo.WeiboNewvideoExtractor,\n    \"#pattern\" : r\"https://f\\.video\\.weibocdn\\.com/(../)?\\w+\\.mp4\\?label=mp\",\n    \"#range\"   : \"1-30\",\n    \"#count\"   : 30,\n},\n\n{\n    \"#url\"     : \"https://weibo.com/1758989602?tabtype=article\",\n    \"#category\": (\"\", \"weibo\", \"article\"),\n    \"#class\"   : weibo.WeiboArticleExtractor,\n    \"#count\"   : 0,\n},\n\n{\n    \"#url\"     : \"https://weibo.com/1758989602?tabtype=album\",\n    \"#category\": (\"\", \"weibo\", \"album\"),\n    \"#class\"   : weibo.WeiboAlbumExtractor,\n    \"#pattern\" : r\"https://(wx\\d+\\.sinaimg\\.cn/large/\\w{32}\\.(jpg|png|gif)|g\\.us\\.sinaimg\\.cn/../\\w+\\.mp4)\",\n    \"#range\"   : \"1-3\",\n    \"#count\"   : 3,\n},\n\n{\n    \"#url\"     : \"https://weibo.com/u/2142058927?tabtype=album-头像\",\n    \"#comment\" : \"subalbum\",\n    \"#class\"   : weibo.WeiboAlbumExtractor,\n    \"#range\"   : \"1-3\",\n    \"#results\" : (\n        \"https://weibo.com/ajax/common/download?pid=002kXRnxly8i5b4anvvxbj60u00u078w02\",\n        \"https://weibo.com/ajax/common/download?pid=002kXRnxly8i2b7u68bfhj60u00u0dl002\",\n        \"https://weibo.com/ajax/common/download?pid=002kXRnxly8i2b6rmr1trj60rs0rstdn02\",\n    ),\n\n    \"extension\": \"jpg\",\n    \"pid\"      : str,\n    \"type\"     : \"pic\",\n    \"subalbum\" : {\n        \"containerid\": \"2318262142058927_-_pc_profile_album_-_photo_-_avatar_-_35046512_-_%E5%A4%B4%E5%83%8F\",\n        \"pic\"        : \"https://wx1.sinaimg.cn/webp720/002kXRnxly8i5b4anvvxbj60u00u078w02.jpg\",\n        \"pic_title\"  : \"头像\",\n    },\n    \"user\"     : {\n        \"id\"         : 2142058927,\n        \"idstr\"      : \"2142058927\",\n        \"location\"   : \"上海 黄浦区\",\n        \"profile_url\": \"/u/2142058927\",\n        \"screen_name\": \"吴磊LEO\",\n    },\n},\n\n{\n    \"#url\"     : \"https://m.weibo.cn/detail/4323047042991618\",\n    \"#category\": (\"\", \"weibo\", \"status\"),\n    \"#class\"   : weibo.WeiboStatusExtractor,\n    \"#pattern\" : r\"https?://wx\\d+.sinaimg.cn/large/\\w+.jpg\",\n\n    \"status\": {\n        \"count\": 1,\n        \"date\" : \"dt:2018-12-30 13:56:36\",\n    },\n},\n\n{\n    \"#url\"     : \"https://m.weibo.cn/detail/4339748116375525\",\n    \"#category\": (\"\", \"weibo\", \"status\"),\n    \"#class\"   : weibo.WeiboStatusExtractor,\n    \"#pattern\" : r\"https?://f.us.sinaimg.cn/\\w+\\.mp4\\?label=mp4_1080p\",\n},\n\n{\n    \"#url\"     : \"https://m.weibo.cn/status/4268682979207023\",\n    \"#comment\" : \"unavailable video (#427)\",\n    \"#category\": (\"\", \"weibo\", \"status\"),\n    \"#class\"   : weibo.WeiboStatusExtractor,\n    \"#exception\": exception.NotFoundError,\n},\n\n{\n    \"#url\"     : \"https://weibo.com/3314883543/Iy7fj4qVg\",\n    \"#comment\" : \"non-numeric status ID (#664)\",\n    \"#category\": (\"\", \"weibo\", \"status\"),\n    \"#class\"   : weibo.WeiboStatusExtractor,\n},\n\n{\n    \"#url\"     : \"https://weibo.cn/detail/4600272267522211\",\n    \"#comment\" : \"retweet\",\n    \"#category\": (\"\", \"weibo\", \"status\"),\n    \"#class\"   : weibo.WeiboStatusExtractor,\n    \"#count\"   : 0,\n},\n\n{\n    \"#url\"     : \"https://weibo.cn/detail/4600272267522211\",\n    \"#comment\" : \"retweet\",\n    \"#category\": (\"\", \"weibo\", \"status\"),\n    \"#class\"   : weibo.WeiboStatusExtractor,\n    \"#options\" : {\"retweets\": True},\n    \"#count\"   : 2,\n\n    \"status\": {\n        \"id\"                     : 4600272267522211,\n        \"retweeted_status\": {\"id\": 4600167083287033},\n    },\n},\n\n{\n    \"#url\"     : \"https://m.weibo.cn/detail/4600272267522211\",\n    \"#comment\" : \"original retweets (#1542)\",\n    \"#category\": (\"\", \"weibo\", \"status\"),\n    \"#class\"   : weibo.WeiboStatusExtractor,\n    \"#options\" : {\"retweets\": \"original\"},\n\n    \"status\": {\"id\": 4600167083287033},\n},\n\n{\n    \"#url\"     : \"https://weibo.com/3194672795/OuxSwgUrC\",\n    \"#comment\" : \"type == livephoto (#2146, #6471)\",\n    \"#category\": (\"\", \"weibo\", \"status\"),\n    \"#class\"   : weibo.WeiboStatusExtractor,\n    \"#pattern\" : r\"https://livephoto\\.us\\.sinaimg\\.cn/\\w+\\.mov\\?Expires=\\d+&ssig=[^&#]+&KID=unistore,video\",\n    \"#range\"   : \"2,4\",\n\n    \"filename\" : {\"000yfKhRjx08hBAXxdZ60f0f0100tBPr0k01\", \"000GEYrCjx08hBAXUFo40f0f0100vS5G0k01\"},\n    \"extension\": \"mov\",\n},\n\n{\n    \"#url\"     : \"https://weibo.com/1758989602/LvBhm5DiP\",\n    \"#comment\" : \"type == gif\",\n    \"#category\": (\"\", \"weibo\", \"status\"),\n    \"#class\"   : weibo.WeiboStatusExtractor,\n    \"#results\" : \"https://wx4.sinaimg.cn/large/68d80d22gy1h2ryfa8k0kg208w06o7wh.gif\",\n\n    \"extension\": \"gif\",\n},\n\n{\n    \"#url\"     : \"https://weibo.com/1758989602/LvBhm5DiP\",\n    \"#comment\" : \"type == gif as video (#5183)\",\n    \"#category\": (\"\", \"weibo\", \"status\"),\n    \"#class\"   : weibo.WeiboStatusExtractor,\n    \"#options\" : {\"gifs\": \"video\"},\n    \"#pattern\" : r\"https://g\\.us\\.sinaimg.cn/o0/qNZcaAAglx07Wuf921CM0104120005tc0E010\\.mp4\\?label=gif_mp4\",\n},\n\n{\n    \"#url\"     : \"https://weibo.com/2909128931/4409545658754086\",\n    \"#comment\" : \"missing 'playback_list' (#2792)\",\n    \"#category\": (\"\", \"weibo\", \"status\"),\n    \"#class\"   : weibo.WeiboStatusExtractor,\n    \"#count\"   : 9,\n},\n\n{\n    \"#url\"     : \"https://weibo.com/1501933722/4142890299009993\",\n    \"#comment\" : \"empty 'playback_list' (#3301)\",\n    \"#category\": (\"\", \"weibo\", \"status\"),\n    \"#class\"   : weibo.WeiboStatusExtractor,\n    \"#pattern\" : r\"https://f\\.us\\.sinaimg\\.cn/004zstGKlx07dAHg4ZVu010f01000OOl0k01\\.mp4\\?label=mp4_hd&template=template_7&ori=0&ps=1CwnkDw1GXwCQx.+&KID=unistore,video\",\n    \"#count\"   : 1,\n},\n\n{\n    \"#url\"     : \"https://weibo.com/2427303621/MxojLlLgQ\",\n    \"#comment\" : \"mix_media_info (#3793)\",\n    \"#category\": (\"\", \"weibo\", \"status\"),\n    \"#class\"   : weibo.WeiboStatusExtractor,\n    \"#count\"   : 9,\n},\n\n{\n    \"#url\"     : \"https://weibo.com/1919017185/4246199458129705\",\n    \"#comment\" : \"'movie'-type video (#3793)\",\n    \"#category\": (\"\", \"weibo\", \"status\"),\n    \"#class\"   : weibo.WeiboStatusExtractor,\n    \"#options\" : {\"movies\": True},\n    \"#results\" : (\n        \"https://wx4.sinaimg.cn/large/7261e0e1gy1frvyc1xnkfj20qo0zkwjh.jpg\",\n        \"https://wx2.sinaimg.cn/large/7261e0e1gy1frvyc30b1jj20zk0qojwh.jpg\",\n        \"https://wx4.sinaimg.cn/large/7261e0e1gy1frvyc44lx8j20qo0zk7a6.jpg\",\n        \"https://gslb.miaopai.com/stream/KdhuavhOnJ7R6zJFXfEXm-sDthpmC5DIGqrdOg__.mp4?yx=&refer=weibo_app&tags=weibocard\",\n    ),\n},\n\n{\n    \"#url\"     : \"https://m.weibo.cn/status/4339748116375525\",\n    \"#category\": (\"\", \"weibo\", \"status\"),\n    \"#class\"   : weibo.WeiboStatusExtractor,\n},\n\n{\n    \"#url\"     : \"https://m.weibo.cn/5746766133/4339748116375525\",\n    \"#category\": (\"\", \"weibo\", \"status\"),\n    \"#class\"   : weibo.WeiboStatusExtractor,\n},\n\n{\n    \"#url\"     : \"https://weibo.com/7926989456/5160875674043425\",\n    \"#comment\" : \"'replay_hd' video (live replay #8339)\",\n    \"#class\"   : weibo.WeiboStatusExtractor,\n    \"#results\" : \"ytdl:https://live.video.weibocdn.com/4817f457-c9be-47f7-a5a0-8591fd363cb1_index.m3u8\",\n},\n\n{\n    \"#url\"     : \"https://weibo.com/7117031969/5208376084532264\",\n    \"#comment\" : \"'.m3u8' manifest (live replay #8339)\",\n    \"#class\"   : weibo.WeiboStatusExtractor,\n    \"#pattern\" : r\"ytdl:https://live.video.weibocdn.com/0f9e059c-3438-49ab-a84c-671a04d37b92_index.m3u8\\?media_id=5208391172685924&.+&KID=unistore,video\",\n    \"#count\"   : 1,\n},\n\n{\n    \"#url\"     : \"https://weibo.com/2683260651/3774796733364550\",\n    \"#comment\" : \"'.m3u8' manifest (from 2014)\",\n    \"#class\"   : weibo.WeiboStatusExtractor,\n    \"#pattern\" : r\"ytdl:https://us.sinaimg.cn/001xN98Njx06NszB2n15010d0100008H0k01.m3u8\\?ori=0&.+&KID=unistore,video\",\n    \"#count\"   : 1,\n},\n\n{\n    \"#url\"     : \"https://weibo.com/3317906495/5217357545080355\",\n    \"#comment\" : \"stream as 'wblive-out.api.weibo.com' URL (#8339)\",\n    \"#class\"   : weibo.WeiboStatusExtractor,\n    \"#results\" : \"ytdl:https://live.video.weibocdn.com/5073cc59-42fc-4b9c-9a61-852d44b0ccc3_index.m3u8\",\n},\n\n{\n    \"#url\"     : \"https://weibo.com/7130470964/5217692969600188\",\n    \"#comment\" : \"stream without replay (#8339)\",\n    \"#class\"   : weibo.WeiboStatusExtractor,\n    \"#count\"   : 0,\n    \"#log\"     : \"HttpError: '404 ' for 'https://wblive-out.api.weibo.com/2/wblive/room/play?id=1022:2321325216257942356128'\",\n},\n\n{\n    \"#url\"     : \"https://weibo.com/1893905030/Q9yKt97ID\",\n    \"#comment\" : \"truncated 'text' / 'isLongText: true' (#8422)\",\n    \"#class\"   : weibo.WeiboStatusExtractor,\n    \"#results\" : (\n        \"https://wx1.sinaimg.cn/large/70e2b286gy1i6fho7ydx0j20fa08lt8u.jpg\",\n        \"https://wx1.sinaimg.cn/large/70e2b286gy1i6fho7zwhmj20u00gvmzg.jpg\",\n        \"https://wx4.sinaimg.cn/large/70e2b286gy1i6fho80b47j21u8112wh3.jpg\",\n    ),\n\n    \"status\"   : {\n        \"id\"              : 5222785292174627,\n        \"isLongText\"      : True,\n        \"textLength\"      : 750,\n        \"text\"            : \"\"\"【加快生产速度！曝任天堂明年3月生产2500万台<a href=\"//s.weibo.com/weibo?q=%23Switch2%23\" target=\"_blank\">#Switch2#</a>】据彭博社报道，<a href=\"//s.weibo.com/weibo?q=%23%E4%BB%BB%E5%A4%A9%E5%A0%82%23\" target=\"_blank\">#任天堂#</a>已要求供应商在2026年3月底之前生产多达2500万台Switch 2。<br /><br />知情人士透露，考虑到今年假期旺季（黑色星期五、圣诞节和新年假期）以及明年初的持续需求，任天堂已要求制造合作伙伴加快生产进度。尽管任天堂计划自2024年底开始组装Switch 2，但根据年底购物季的实际需求情况，最终产量目标仍有可能进行调整。<br /><br />彭博社分析认为，任天堂的出货量很可能轻松超越分析师预测的1760万台，甚至超出公司自身更为保守的公开预期。根据组装厂商的发货估算，任天堂在本财年（截至2026年3月）预计将售出约2000万台Switch 2，剩余库存则将结转到下一财年。<br /><br />市场研究机构Circana的数据显示，美国为任天堂最大市场，Switch 2的销售表现比2017年发售的初代Switch高出77%。按照这一趋势，任天堂很可能提前几个月就能超额完成其保守的销售目标。\"\"\",\n        \"text_raw\"        : \"\"\"\\\n【加快生产速度！曝任天堂明年3月生产2500万台#Switch2#】据彭博社报道，#任天堂#已要求供应商在2026年3月底之前生产多达2500万台Switch 2。\n\n知情人士透露，考虑到今年假期旺季（黑色星期五、圣诞节和新年假期）以及明年初的持续需求，任天堂已要求制造合作伙伴加快生产进度。尽管任天堂计划自2024年底开始组装Switch 2，但根据年底购物季的实际需求情况，最终产量目标仍有可能进行调整。\n\n彭博社分析认为，任天堂的出货量很可能轻松超越分析师预测的1760万台，甚至超出公司自身更为保守的公开预期。根据组装厂商的发货估算，任天堂在本财年（截至2026年3月）预计将售出约2000万台Switch 2，剩余库存则将结转到下一财年。\n\n市场研究机构Circana的数据显示，美国为任天堂最大市场，Switch 2的销售表现比2017年发售的初代Switch高出77%。按照这一趋势，任天堂很可能提前几个月就能超额完成其保守的销售目标。\\\n\"\"\",\n        \"longText\"        : {\n            \"created_at\"    : \"Fri Oct 17 17:15:11 +0800 2025\",\n            \"mblog_vip_type\": 0,\n            \"show_attitude_bar\": 0,\n            \"weibo_position\": 1,\n            \"content\"       : \"\"\"\\\n【加快生产速度！曝任天堂明年3月生产2500万台#Switch2#】据彭博社报道，#任天堂#已要求供应商在2026年3月底之前生产多达2500万台Switch 2。\n\n知情人士透露，考虑到今年假期旺季（黑色星期五、圣诞节和新年假期）以及明年初的持续需求，任天堂已要求制造合作伙伴加快生产进度。尽管任天堂计划自2024年底开始组装Switch 2，但根据年底购物季的实际需求情况，最终产量目标仍有可能进行调整。\n\n彭博社分析认为，任天堂的出货量很可能轻松超越分析师预测的1760万台，甚至超出公司自身更为保守的公开预期。根据组装厂商的发货估算，任天堂在本财年（截至2026年3月）预计将售出约2000万台Switch 2，剩余库存则将结转到下一财年。\n\n市场研究机构Circana的数据显示，美国为任天堂最大市场，Switch 2的销售表现比2017年发售的初代Switch高出77%。按照这一趋势，任天堂很可能提前几个月就能超额完成其保守的销售目标。\\\n\"\"\",\n        },\n    },\n},\n\n)\n"
  },
  {
    "path": "test/results/whyp.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import whyp\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://whyp.it/tracks/13721/fallout-3-intro-remake\",\n    \"#class\"   : whyp.WhypAudioExtractor,\n    \"#pattern\" : r\"https://cdn.whyp.it/5e9de576-f33a-40ea-bd43-1693a568a6a0.mp3\\?token=.+\",\n\n    \"allow_downloads\": False,\n    \"artwork_url\"    : None,\n    \"artwork_url_fallback\": \"https://cdn.whyp.it/a46f3485-8d19-4753-98e0-76011c7e33b0.jpg\",\n    \"comments_count\" : int,\n    \"created_at\"     : \"2025-11-24T16:59:50+00:00\",\n    \"date\"           : \"dt:2025-11-24 16:59:50\",\n    \"description\"    : \"\",\n    \"duration\"       : 46.34,\n    \"extension\"      : \"mp3\",\n    \"filename\"       : \"5e9de576-f33a-40ea-bd43-1693a568a6a0\",\n    \"id\"             : 13721,\n    \"lossless_size\"  : None,\n    \"lossless_url\"   : None,\n    \"lossy_size\"     : 1853719,\n    \"lossy_url\"      : r\"re:https://cdn.whyp.it/5e9de576-f33a-40ea-bd43-1693a568a6a0.mp3\",\n    \"original\"       : False,\n    \"public\"         : True,\n    \"settings_comments\": \"users\",\n    \"slug\"           : \"fallout-3-intro-remake\",\n    \"title\"          : \"Fallout 3 Intro Remake\",\n    \"token\"          : \"k5E2z\",\n    \"user_id\"        : 1,\n    \"waveform_url\"   : r\"re:https://cdn.whyp.it/5e9de576-f33a-40ea-bd43-1693a568a6a0.json\",\n    \"user\"           : {\n        \"avatar\"          : \"https://cdn.whyp.it/a46f3485-8d19-4753-98e0-76011c7e33b0.jpg\",\n        \"has_enterprise\"  : True,\n        \"has_pro\"         : True,\n        \"has_pro_lifetime\": False,\n        \"id\"              : 1,\n        \"slug\"            : \"brad\",\n        \"status\"          : \"Coding 👨🏻‍💻\",\n        \"tracks_count\"    : 3,\n        \"username\"        : \"Brad\",\n    },\n},\n\n{\n    \"#url\"     : \"https://whyp.it/tracks/12345?token=tokenstring\",\n    \"#comment\" : \"'token' paraneter (#9133)\",\n    \"#class\"   : whyp.WhypAudioExtractor,\n},\n\n{\n    \"#url\"     : \"https://whyp.it/tracks/12345/slug-of-track-title?token=tokenstring\",\n    \"#comment\" : \"'token' paraneter (#9133)\",\n    \"#class\"   : whyp.WhypAudioExtractor,\n},\n\n{\n    \"#url\"     : \"https://whyp.it/users/1/brad\",\n    \"#class\"   : whyp.WhypUserExtractor,\n    \"#pattern\" : (\n        r\"https://cdn.whyp.it/5e9de576-f33a-40ea-bd43-1693a568a6a0.mp3\\?token=.+\",\n        r\"https://cdn.whyp.it/0d7a196b-3e1a-4510-a4a4-6189c56ecb27.flac\\?token=.+\",\n        r\"https://cdn.whyp.it/3d134d07-7c55-4a6b-b321-56ce90ee1fc8.flac\\?token=.+\",\n    ),\n\n    \"allow_downloads\": bool,\n    \"artwork_url\"    : {str, None},\n    \"artwork_url_fallback\": str,\n    \"comments_count\" : int,\n    \"created_at\"     : \"iso:dt\",\n    \"date\"           : \"type:datetime\",\n    \"description\"    : str,\n    \"duration\"       : float,\n    \"extension\"      : {\"mp3\", \"flac\"},\n    \"filename\"       : \"iso:uuid\",\n    \"id\"             : {13721, 18337, 324260},\n    \"lossless_size\"  : {int, None},\n    \"lossless_url\"   : {str, None},\n    \"lossy_size\"     : int,\n    \"lossy_url\"      : str,\n    \"original\"       : bool,\n    \"public\"         : True,\n    \"settings_comments\": \"users\",\n    \"slug\"           : str,\n    \"title\"          : str,\n    \"token\"          : str,\n    \"user_id\"        : 1,\n    \"waveform_url\"   : str,\n    \"user\"           : {\n        \"avatar\"          : \"https://cdn.whyp.it/a46f3485-8d19-4753-98e0-76011c7e33b0.jpg\",\n        \"has_enterprise\"  : True,\n        \"has_pro\"         : True,\n        \"has_pro_lifetime\": False,\n        \"id\"              : 1,\n        \"slug\"            : \"brad\",\n        \"status\"          : \"Coding 👨🏻‍💻\",\n        \"tracks_count\"    : 3,\n        \"username\"        : \"Brad\",\n    },\n},\n\n{\n    \"#url\"     : \"https://whyp.it/collections/1/example-collection\",\n    \"#class\"   : whyp.WhypCollectionExtractor,\n    \"#pattern\" : (\n        r\"https://cdn.whyp.it/3d134d07-7c55-4a6b-b321-56ce90ee1fc8.flac\\?token=.+\",\n        r\"https://cdn.whyp.it/0d7a196b-3e1a-4510-a4a4-6189c56ecb27.flac\\?token=.+\",\n    ),\n\n    \"extension\"       : \"flac\",\n    \"id\"              : {18337, 324260},\n    \"original\"        : True,\n    \"pivot_collection_id\": 1,\n    \"pivot_created_at\": \"iso:dt\",\n    \"pivot_order\"     : int,\n    \"public\"          : True,\n    \"collection\"      : {\n        \"artwork_url\" : \"https://cdn.whyp.it/60fff341-02ef-4fe2-86f7-f283b2df1557.jpg\",\n        \"artwork_url_fallback\": \"https://cdn.whyp.it/b42b34d3-5839-4a26-9c32-41e917f31f6b.jpg\",\n        \"created_at\"  : \"2023-07-20T16:14:33+00:00\",\n        \"description\" : \"This is an example collection on Whyp!\",\n        \"duration\"    : 352.59,\n        \"hidden_tracks_count\": 0,\n        \"id\"          : 1,\n        \"order\"       : {1, 3},\n        \"public\"      : True,\n        \"slug\"        : \"example-collection\",\n        \"title\"       : \"Example Collection\",\n        \"token\"       : \"VFc7Q\",\n        \"tracks_count\": 2,\n        \"updated_at\"  : \"iso:8601\",\n        \"user_id\"     : 1,\n        \"user\"        : dict,\n    },\n    \"user\"            : {\n        \"avatar\"          : \"https://cdn.whyp.it/a46f3485-8d19-4753-98e0-76011c7e33b0.jpg\",\n        \"has_enterprise\"  : True,\n        \"has_pro\"         : True,\n        \"has_pro_lifetime\": False,\n        \"id\"              : 1,\n        \"slug\"            : \"brad\",\n        \"status\"          : \"Coding 👨🏻‍💻\",\n        \"tracks_count\"    : 3,\n        \"username\"        : \"Brad\",\n    },\n},\n\n)\n"
  },
  {
    "path": "test/results/wikiart.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import wikiart\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://www.wikiart.org/en/thomas-cole\",\n    \"#category\": (\"\", \"wikiart\", \"artist\"),\n    \"#class\"   : wikiart.WikiartArtistExtractor,\n    \"#pattern\" : r\"https://uploads\\d+\\.wikiart\\.org/(\\d+/)?images/thomas-cole/[\\w()-]+\\.(jpg|png)\",\n    \"#count\"   : \"> 100\",\n\n    \"albums\"     : None,\n    \"artist\"     : {\n        \"OriginalArtistName\": \"Thomas Cole\",\n        \"activeYearsCompletion\": None,\n        \"activeYearsStart\"  : None,\n        \"artistName\"        : \"Thomas Cole\",\n        \"biography\"         : \"Thomas Cole inspired the generation of American [url href=https://www.wikiart.org/en/paintings-by-genre/landscape]landscape[/url] painters that came to be known as the [url href=https://www.wikiart.org/en/artists-by-painting-school/hudson-river-school]Hudson River School[/url]. Born in Bolton-le-Moors, Lancashire, England, in 1801, at the age of seventeen he emigrated with his family to the United States, first working as a wood engraver in Philadelphia before going to Steubenville, Ohio, where his father had established a wallpaper manufacturing business. \\n\\nCole received rudimentary instruction from an itinerant artist, began painting portraits, genre scenes, and a few landscapes, and set out to seek his fortune through Ohio and Pennsylvania. He soon moved on to Philadelphia to pursue his art, inspired by paintings he saw at the Pennsylvania Academy of the Fine Arts. Moving to New York City in spring 1825, Cole made a trip up the Hudson River to the eastern Catskill Mountains. Based on his sketches there, he executed three landscapes that a city bookseller agreed to display in his window. Colonel [url href=https://www.wikiart.org/en/john-trumbull]John Trumbull[/url], already renowned as the painter of the American Revolution, saw Cole’s pictures and instantly purchased one, recommending the other two to his colleagues William Dunlap and [url href=https://www.wikiart.org/en/asher-brown-durand]Asher B. Durand[/url]. \\n\\nWhat Trumbull recognized in the work of the young painter was the perception of wildness inherent in American scenery that landscape artists had theretofore ignored. Trumbull brought Cole to the attention of various patrons, who began eagerly buying his work. Dunlap publicized the discovery of the new talent, and Cole was welcomed into New York’s cultural community, which included the poet and editor William Cullen Bryant and the author James Fenimore Cooper. Cole became one of the founding members of the National Academy of Design in 1825. Even as Cole expanded his travels and subjects to include scenes in the White Mountains of New Hampshire, he aspired to what he termed a “higher style of a landscape” that included narrative—some of the paintings in paired series—including biblical and literary subjects, such as Cooper’s popular [url href=https://www.wikiart.org/en/thomas-cole/scene-from-the-last-of-the-mohicans-by-james-fenimore-cooper-1827][i]Last of the Mohicans[/i][/url]. \\n\\nBy 1829, his success enabled him to take the Grand Tour of Europe and especially Italy, where he remained in 1831–32, visiting Florence, Rome, and Naples. Thereafter he painted many Italian subjects, like [url href=https://www.wikiart.org/en/thomas-cole/a-view-near-tivoli-morning-1832][i]View near Tivoli. Morning[/i][/url] (1832). The region around Rome, along with the classical myth, also inspired [url href=https://www.wikiart.org/en/thomas-cole/the-titan-s-goblet-1833][i]The Titan’s Goblet[/i][/url] (1833). Cole’s travels and the encouragement and patronage of the New York merchant Luman Reed culminated in his most ambitious historical landscape series, [url href=https://www.wikiart.org/en/thomas-cole/all-works#!#filterName:Series_the-course-of-empire,resultType:masonry][i]The Course of Empire[/i][/url] (1833–1836), five pictures dramatizing the rise and fall of an ancient classical state. \\n\\nCole also continued to paint, with ever-rising technical assurance, sublime American scenes such as the [url href=https://www.wikiart.org/en/thomas-cole/view-from-mount-holyoke-1836][i]View from Mount Holyoke[/i][/url] (1836), [url href=https://www.wikiart.org/en/thomas-cole/the-oxbow-the-connecticut-river-near-northampton-1836][i]The Oxbow[/i][/url] (1836), in which he included a portrait of himself painting the vista and [url href=https://www.wikiart.org/en/thomas-cole/view-on-the-catskill-early-autunm-1837][i]View on the Catskill—Early Autumn[/i][/url] (1836-1837), in which he pastorally interpreted the prospect of his beloved Catskill Mountains from the village of Catskill, where he had moved the year before and met his wife-to-be, Maria Bartow. \\n\\nThe artist’s marriage brought with it increasing religious piety manifested in the four-part series [url href=https://www.wikiart.org/en/thomas-cole/all-works#!#filterName:Series_the-voyage-of-life,resultType:masonry][i]The Voyage of Life[/i][/url] (1840). In it, a river journey represents the human passage through life to eternal reward. Cole painted and exhibited a replica of the series in Rome, where he returned in 1841–42, traveling south to Sicily. After his return, he lived and worked chiefly in Catskill, keeping up with art activity in New York primarily through Durand. He continued to produce American and foreign landscape subjects of incredible beauty, including the [url href=https://www.wikiart.org/en/thomas-cole/the-mountain-ford-1846][i]Mountain Ford[/i][/url] (1846). \\n\\nIn 1844, Cole welcomed into his Catskill studio the young [url href=https://www.wikiart.org/en/frederic-edwin-church]Frederic Church[/url], who studied with him until 1846 and went on to become the most renowned exponent of the generation that followed Cole. By 1846, Cole was at work on his largest and most ambitious series, [url href=https://www.wikiart.org/en/thomas-cole/all-works#!#filterName:Series_the-cross-and-the-world,resultType:masonry][i]The Cross and the World[/i][/url], but in February 1848 contracted pleurisy and died before completing it. \\n\\nThe paintings of Thomas Cole, like the writings of his contemporary Ralph Waldo Emerson, stand as monuments to the dreams and anxieties of the fledgling American nation during the mid-19th century; and they are also euphoric celebrations of its natural landscapes. Cole is considered the first artist to bring the eye of a European [url href=https://www.wikiart.org/en/artists-by-art-movement/romanticism]Romantic[/url] landscape painter to those environments, but also a figure whose idealism and religious sensibilities expressed a uniquely American spirit. In his works, we find the dramatic splendor of [url href=https://www.wikiart.org/en/caspar-david-friedrich]Caspar David Freidrich[/url] or [url href=https://www.wikiart.org/en/william-turner]J.M.W Turner[/url] transposed onto the Catskill and Adirondack Mountains. But whereas younger American painters such as [url href=https://www.wikiart.org/en/albert-bierstadt]Albert Bierstadt[/url] had come into direct contact with [url href=https://www.wikiart.org/en/artists-by-art-institution/kunstakademie-dusseldorf-dusseldorf-germany#!#resultType:masonry]The Düsseldorf School of painting[/url], and thus with the tradition in which they placed themselves, Cole was largely self-tutored, representing something of the archetypal American figure of the auto-didact.\\n\\nIn many ways, Cole's art epitomizes all contradictions of European settler culture in America. He was in love with the sublime wildness of the American landscape and sought to preserve it with his art, but his very presence in that landscape, and the development of his career, depended on the processes of urbanization and civilization which threatened it. From a modern perspective, Cole's Eurocentric gaze on seemingly empty wildernesses which had, in fact, been populated for centuries, also seems troubling; where Native Americans do appear in his work, as in [url href=https://www.wikiart.org/en/thomas-cole/falls-of-the-kaaterskill-1826][i]Falls of the Kaaterskill[/i][/url] (1826), it is as picturesque flecks rather than characterized participants in the scene.\\n\\nCole's legacy is evident in the work of future American artists who advanced the Hudson River style, including his student Frederic Edwin Church, Albert Bierstadt, Jasper Cropsey, Asher B. Durand, [url href=https://www.wikiart.org/en/george-inness]George Inness[/url], [url href=https://www.wikiart.org/en/john-frederick-kensett]John Kensett[/url], and [url href=https://www.wikiart.org/en/thomas-moran]Thomas Moran[/url]. Speaking more broadly, a whole sweep of 20th-century North-American art, from [url href=https://www.wikiart.org/en/artists-by-art-movement/precisionism]Precisionism[/url] to [url href=https://www.wikiart.org/en/artists-by-art-movement/environmental-art]Land Art[/url], might be seen to have inherited something of the grand scale and ambition of Cole's work. In this sense, his paintings capture not only the character of American culture during the mid-19th century but perhaps something more enduring about the open and expansive quality of that culture.\",\n        \"birthDay\"          : \"/Date(-5330448000000)/\",\n        \"birthDayAsString\"  : \"February 1, 1801\",\n        \"contentId\"         : 254330,\n        \"deathDay\"          : \"/Date(-3846441600000)/\",\n        \"deathDayAsString\"  : \"February 11, 1848\",\n        \"dictonaries\"       : [\n            1368,\n            11415,\n            310,\n        ],\n        \"gender\"            : \"male\",\n        \"image\"             : \"https://uploads8.wikiart.org/temp/19f6a140-59d2-4959-8d11-fd4ca582b7f2.jpg!Portrait.jpg\",\n        \"lastNameFirst\"     : \"Cole Thomas\",\n        \"periodsOfWork\"     : \"\",\n        \"relatedArtistsIds\" : [],\n        \"series\"            : \"The Cross and the World\\r\\nThe Course of Empire\\r\\nThe Voyage of Life\",\n        \"story\"             : \"http://en.wikipedia.org/wiki/Thomas_Cole\",\n        \"themes\"            : \"\",\n        \"url\"               : \"thomas-cole\",\n        \"wikipediaUrl\"      : \"http://en.wikipedia.org/wiki/Thomas_Cole\"\n    },\n    \"artistName\" : \"Thomas Cole\",\n    \"artistUrl\"  : \"/en/thomas-cole\",\n    \"extension\"  : str,\n    \"filename\"   : str,\n    \"flags\"      : int,\n    \"height\"     : int,\n    \"id\"         : r\"re:[0-9a-f]+\",\n    \"image\"      : str,\n    \"map\"        : str,\n    \"paintingUrl\": r\"re:/en/thomas-cole/.+\",\n    \"title\"      : str,\n    \"width\"      : int,\n    \"year\"       : str,\n},\n\n{\n    \"#url\"     : \"https://www.wikiart.org/en/thomas-cole/the-departure-1838\",\n    \"#category\": (\"\", \"wikiart\", \"image\"),\n    \"#class\"   : wikiart.WikiartImageExtractor,\n    \"#sha1_url\"     : \"976cc2545f308a650b5dbb35c29d3cee0f4673b3\",\n    \"#sha1_metadata\": \"8e80cdcb01c1fedb934633d1c4c3ab0419cfbedf\",\n},\n\n{\n    \"#url\"     : \"https://www.wikiart.org/en/huang-shen/summer\",\n    \"#comment\" : \"no year or '-' in slug\",\n    \"#category\": (\"\", \"wikiart\", \"image\"),\n    \"#class\"   : wikiart.WikiartImageExtractor,\n    \"#sha1_url\": \"d7f60118c34067b2b37d9577e412dc1477b94207\",\n    \"#results\" : (\n        \"https://uploads5.wikiart.org/images/huang-shen/summer.jpg\",\n    ),\n},\n\n{\n    \"#url\"     : \"https://www.wikiart.org/en/paintings-by-media/grisaille\",\n    \"#category\": (\"\", \"wikiart\", \"artworks\"),\n    \"#class\"   : wikiart.WikiartArtworksExtractor,\n    \"#sha1_url\": \"36e054fcb3363b7f085c81f4778e6db3994e56a3\",\n    \"#results\" : (\n        \"https://uploads4.wikiart.org/images/hieronymus-bosch/triptych-of-last-judgement.jpg\",\n        \"https://uploads6.wikiart.org/images/hieronymus-bosch/triptych-of-last-judgement-1.jpg\",\n        \"https://uploads0.wikiart.org/images/hieronymus-bosch/tiptych-of-temptation-of-st-anthony-1506.jpg\",\n        \"https://uploads7.wikiart.org/images/matthias-grünewald/st-elizabeth-and-a-saint-woman-with-palm-1511.jpg\",\n        \"https://uploads2.wikiart.org/images/matthias-grünewald/st-lawrence-and-st-cyricus-1511.jpg\",\n        \"https://uploads0.wikiart.org/images/pieter-bruegel-the-elder/the-death-of-the-virgin.jpg\",\n        \"https://uploads4.wikiart.org/images/pieter-bruegel-the-elder/christ-and-the-woman-taken-in-adultery-1565-1.jpg\",\n        \"https://uploads6.wikiart.org/images/giovanni-battista-tiepolo/not_detected_241014.jpg\",\n        \"https://uploads4.wikiart.org/images/edgar-degas/interior-the-rape-1869.jpg\",\n        \"https://uploads3.wikiart.org/00265/images/john-singer-sargent/1396294310-dame-alice-ellen-terry-by-john-singer-sargent.jpg\",\n        \"https://uploads0.wikiart.org/00293/images/hryhorii-havrylenko/1954-18-5-32-5.jpg\",\n    ),\n},\n\n{\n    \"#url\"     : \"https://www.wikiart.org/en/artists-by-century/12\",\n    \"#category\": (\"\", \"wikiart\", \"artists\"),\n    \"#class\"   : wikiart.WikiartArtistsExtractor,\n    \"#pattern\" : wikiart.WikiartArtistExtractor.pattern,\n    \"#count\"   : \">= 8\",\n},\n\n)\n"
  },
  {
    "path": "test/results/wikibooks.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import wikimedia\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://www.wikibooks.org/wiki/Title\",\n    \"#category\": (\"wikimedia\", \"wikibooks\", \"article\"),\n    \"#class\"   : wikimedia.WikimediaArticleExtractor,\n},\n\n{\n    \"#url\"     : \"https://en.wikibooks.org/wiki/Category:Title\",\n    \"#category\": (\"wikimedia\", \"wikibooks\", \"category\"),\n    \"#class\"   : wikimedia.WikimediaArticleExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/wikidata.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import wikimedia\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://www.wikidata.org/wiki/Title\",\n    \"#category\": (\"wikimedia\", \"wikidata\", \"article\"),\n    \"#class\"   : wikimedia.WikimediaArticleExtractor,\n},\n\n{\n    \"#url\"     : \"https://en.wikidata.org/wiki/Category:Title\",\n    \"#category\": (\"wikimedia\", \"wikidata\", \"category\"),\n    \"#class\"   : wikimedia.WikimediaArticleExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/wikifeet.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import wikifeet\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://www.wikifeet.com/Madison_Beer\",\n    \"#category\": (\"\", \"wikifeet\", \"gallery\"),\n    \"#class\"   : wikifeet.WikifeetGalleryExtractor,\n    \"#pattern\" : r\"https://pics\\.wikifeet\\.com/Madison_Beer-Feet-\\d+\\.jpg\",\n    \"#count\"   : \">= 352\",\n\n    \"celeb\"     : \"Madison_Beer\",\n    \"celebrity\" : \"Madison Beer\",\n    \"birthday\"  : \"dt:1999-03-05 00:00:00\",\n    \"birthplace\": \"United States\",\n    \"rating\"    : float,\n    \"pid\"       : int,\n    \"width\"     : int,\n    \"height\"    : int,\n    \"shoesize\"  : r\"re:\\d+\",\n    \"type\"      : \"women\",\n    \"tags\"      : list,\n},\n\n{\n    \"#url\"     : \"https://men.wikifeet.com/Chris_Hemsworth\",\n    \"#category\": (\"\", \"wikifeet\", \"gallery\"),\n    \"#class\"   : wikifeet.WikifeetGalleryExtractor,\n    \"#pattern\" : r\"https://pics\\.wikifeet\\.com/Chris_Hemsworth-Feet-\\d+\\.jpg\",\n    \"#count\"   : \">= 860\",\n\n    \"celeb\"     : \"Chris_Hemsworth\",\n    \"celebrity\" : \"Chris Hemsworth\",\n    \"birthday\"  : \"dt:1983-08-11 00:00:00\",\n    \"birthplace\": \"Australia\",\n    \"rating\"    : float,\n    \"pid\"       : int,\n    \"width\"     : int,\n    \"height\"    : int,\n    \"shoesize\"  : \"22\",\n    \"type\"      : \"men\",\n    \"tags\"      : list,\n},\n\n)\n"
  },
  {
    "path": "test/results/wikifeetx.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import wikifeet\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://www.wikifeetx.com/Tifa_Quinn\",\n    \"#category\": (\"\", \"wikifeetx\", \"gallery\"),\n    \"#class\"   : wikifeet.WikifeetGalleryExtractor,\n    \"#pattern\" : r\"https://pics\\.wikifeet\\.com/Tifa_Quinn-Feet-\\d+\\.jpg\",\n    \"#count\"   : \">= 9\",\n\n    \"celeb\"     : \"Tifa_Quinn\",\n    \"celebrity\" : \"Tifa Quinn\",\n    \"birthday\"  : \"\",\n    \"birthplace\": \"United States\",\n    \"rating\"    : float,\n    \"pid\"       : int,\n    \"width\"     : int,\n    \"height\"    : int,\n    \"shoesize\"  : \"5\",\n    \"type\"      : \"women\",\n    \"tags\"      : list,\n},\n\n)\n"
  },
  {
    "path": "test/results/wikigg.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import wikimedia\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://www.wiki.gg/wiki/Title\",\n    \"#comment\" : \"for scripts/supportedsites.py\",\n    \"#category\": (\"wikimedia\", \"wikigg-www\", \"article\"),\n    \"#class\"   : wikimedia.WikimediaArticleExtractor,\n},\n\n{\n    \"#url\"     : \"https://hearthstone.wiki.gg/wiki/Flame_Juggler\",\n    \"#category\": (\"wikimedia\", \"wikigg-hearthstone\", \"article\"),\n    \"#class\"   : wikimedia.WikimediaArticleExtractor,\n},\n\n{\n    \"#url\"     : \"https://terraria.wiki.gg/de/wiki/Golem\",\n    \"#comment\" : \"non-English language prefix (#6370)\",\n    \"#category\": (\"wikimedia\", \"wikigg-terraria\", \"article\"),\n    \"#class\"   : wikimedia.WikimediaArticleExtractor,\n    \"#count\"   : \"> 45\",\n    \"#archive\" : False,\n},\n\n)\n"
  },
  {
    "path": "test/results/wikimediacommons.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import wikimedia\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://commons.wikimedia.org/wiki/File:Starr-050516-1367-Pimenta_dioica-flowers-Maunaloa-Molokai_(24762757525).jpg\",\n    \"#category\": (\"wikimedia\", \"wikimediacommons\", \"file\"),\n    \"#class\"   : wikimedia.WikimediaArticleExtractor,\n    \"#results\" : \"https://upload.wikimedia.org/wikipedia/commons/f/fa/Starr-050516-1367-Pimenta_dioica-flowers-Maunaloa-Molokai_%2824762757525%29.jpg?format=original\",\n},\n\n{\n    \"#url\"     : \"https://commons.wikimedia.org/wiki/Category:Network_maps_of_the_Paris_Metro\",\n    \"#category\": (\"wikimedia\", \"wikimediacommons\", \"category\"),\n    \"#class\"   : wikimedia.WikimediaArticleExtractor,\n},\n\n{\n    \"#url\"     : \"https://commons.wikimedia.org/wiki/Category:Ivan_Shishkin\",\n    \"#comment\" : \"subcategories\",\n    \"#category\": (\"wikimedia\", \"wikimediacommons\", \"category\"),\n    \"#class\"   : wikimedia.WikimediaArticleExtractor,\n    \"#options\" : {\"image-filter\": \"False\"},\n\n    \"#results\": (\n        \"https://commons.wikimedia.org/wiki/Category:3558_Shishkin\",\n        \"https://commons.wikimedia.org/wiki/Category:Drawings_by_Ivan_Shishkin\",\n        \"https://commons.wikimedia.org/wiki/Category:Ivan_Shishkin_grave\",\n        \"https://commons.wikimedia.org/wiki/Category:Ivan_Shishkin_in_art\",\n        \"https://commons.wikimedia.org/wiki/Category:Ivan_Shishkin._To_the_190th_anniversary_of_the_birth\",\n        \"https://commons.wikimedia.org/wiki/Category:Paintings_by_Ivan_Shishkin\",\n        \"https://commons.wikimedia.org/wiki/Category:Shishkin_Street_(Martyshkino)\",\n        \"https://commons.wikimedia.org/wiki/Category:Shishkin_street,_Moscow\",\n        \"https://commons.wikimedia.org/wiki/Category:Shishkin's_Pine\",\n    ),\n},\n\n)\n"
  },
  {
    "path": "test/results/wikinews.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import wikimedia\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://www.wikinews.org/wiki/Title\",\n    \"#category\": (\"wikimedia\", \"wikinews\", \"article\"),\n    \"#class\"   : wikimedia.WikimediaArticleExtractor,\n},\n\n{\n    \"#url\"     : \"https://en.wikinews.org/wiki/Category:Title\",\n    \"#category\": (\"wikimedia\", \"wikinews\", \"category\"),\n    \"#class\"   : wikimedia.WikimediaArticleExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/wikipedia.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import wikimedia\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://www.wikipedia.org/wiki/Title\",\n    \"#category\": (\"wikimedia\", \"wikipedia\", \"article\"),\n    \"#class\"   : wikimedia.WikimediaArticleExtractor,\n},\n\n{\n    \"#url\"     : \"https://en.wikipedia.org/wiki/Athena\",\n    \"#category\": (\"wikimedia\", \"wikipedia\", \"article\"),\n    \"#class\"   : wikimedia.WikimediaArticleExtractor,\n    \"#pattern\" : r\"https://upload.wikimedia.org/wikipedia/.+\",\n    \"#count\"   : range(50, 100),\n\n    \"bitdepth\"      : int,\n    \"canonicaltitle\": str,\n    \"comment\"       : str,\n    \"commonmetadata\": dict,\n    \"date\"          : \"type:datetime\",\n    \"descriptionshorturl\": str,\n    \"descriptionurl\": str,\n    \"extension\"     : str,\n    \"extmetadata\"   : dict,\n    \"filename\"      : str,\n    \"height\"        : int,\n    \"lang\"          : \"en\",\n    \"metadata\"      : dict,\n    \"mime\"          : r\"re:image/\\w+\",\n    \"page\"          : \"Athena\",\n    \"sha1\"          : r\"re:^[0-9a-f]{40}$\",\n    \"size\"          : int,\n    \"timestamp\"     : str,\n    \"url\"           : str,\n    \"user\"          : str,\n    \"userid\"        : int,\n    \"width\"         : int,\n},\n\n{\n    \"#url\"     : \"https://en.wikipedia.org/wiki/Title\",\n    \"#comment\" : \"force download revisions of images in an article\",\n    \"#category\": (\"wikimedia\", \"wikipedia\", \"article\"),\n    \"#class\"   : wikimedia.WikimediaArticleExtractor,\n    \"#options\" : {\"image-revisions\": 5},\n    \"#count\"   : \"> 8\",\n\n    \"page\" : \"Title\",\n    \"count\": {2, 5},\n    \"num\"  : range(1, 5),\n},\n\n{\n    \"#url\"     : \"https://en.wikipedia.org/wiki/Category:Physics\",\n    \"#category\": (\"wikimedia\", \"wikipedia\", \"category\"),\n    \"#class\"   : wikimedia.WikimediaArticleExtractor,\n},\n\n{\n    \"#url\"     : \"https://en.wikipedia.org\",\n    \"#category\": (\"wikimedia\", \"wikipedia\", \"wiki\"),\n    \"#class\"   : wikimedia.WikimediaWikiExtractor,\n    \"#range\"   : \"1-10\",\n    \"#count\"   : 10,\n},\n\n)\n"
  },
  {
    "path": "test/results/wikiquote.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import wikimedia\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://www.wikiquote.org/wiki/Title\",\n    \"#category\": (\"wikimedia\", \"wikiquote\", \"article\"),\n    \"#class\"   : wikimedia.WikimediaArticleExtractor,\n},\n\n{\n    \"#url\"     : \"https://en.wikiquote.org/wiki/Category:Title\",\n    \"#category\": (\"wikimedia\", \"wikiquote\", \"category\"),\n    \"#class\"   : wikimedia.WikimediaArticleExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/wikisource.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import wikimedia\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://www.wikisource.org/wiki/Title\",\n    \"#category\": (\"wikimedia\", \"wikisource\", \"article\"),\n    \"#class\"   : wikimedia.WikimediaArticleExtractor,\n},\n\n{\n    \"#url\"     : \"https://en.wikisource.org/wiki/Category:Title\",\n    \"#category\": (\"wikimedia\", \"wikisource\", \"category\"),\n    \"#class\"   : wikimedia.WikimediaArticleExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/wikispecies.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import wikimedia\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://species.wikimedia.org/wiki/Geranospiza\",\n    \"#category\": (\"wikimedia\", \"wikispecies\", \"article\"),\n    \"#class\"   : wikimedia.WikimediaArticleExtractor,\n    \"#results\"     : \"https://upload.wikimedia.org/wikipedia/commons/0/01/Geranospiza_caerulescens.jpg?format=original\",\n    \"#sha1_content\": \"3a17c14b15489928e4154f826af1c42afb5a523e\",\n},\n\n{\n    \"#url\"     : \"https://species.wikimedia.org/wiki/Category:Names\",\n    \"#category\": (\"wikimedia\", \"wikispecies\", \"category\"),\n    \"#class\"   : wikimedia.WikimediaArticleExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/wikiversity.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import wikimedia\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://www.wikiversity.org/wiki/Title\",\n    \"#category\": (\"wikimedia\", \"wikiversity\", \"article\"),\n    \"#class\"   : wikimedia.WikimediaArticleExtractor,\n},\n\n{\n    \"#url\"     : \"https://en.wikiversity.org/wiki/Category:Title\",\n    \"#category\": (\"wikimedia\", \"wikiversity\", \"category\"),\n    \"#class\"   : wikimedia.WikimediaArticleExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/wikivoyage.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import wikimedia\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://www.wikivoyage.org/wiki/Title\",\n    \"#category\": (\"wikimedia\", \"wikivoyage\", \"article\"),\n    \"#class\"   : wikimedia.WikimediaArticleExtractor,\n},\n\n{\n    \"#url\"     : \"https://en.wikivoyage.org/wiki/Category:Title\",\n    \"#category\": (\"wikimedia\", \"wikivoyage\", \"category\"),\n    \"#class\"   : wikimedia.WikimediaArticleExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/wiktionary.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import wikimedia\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://www.wiktionary.org/wiki/Word\",\n    \"#category\": (\"wikimedia\", \"wiktionary\", \"article\"),\n    \"#class\"   : wikimedia.WikimediaArticleExtractor,\n},\n\n{\n    \"#url\"     : \"https://en.wiktionary.org/wiki/Category:Words\",\n    \"#category\": (\"wikimedia\", \"wiktionary\", \"category\"),\n    \"#class\"   : wikimedia.WikimediaArticleExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/windsorstore.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import shopify\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://www.windsorstore.com/collections/dresses-ball-gowns\",\n    \"#category\": (\"shopify\", \"windsorstore\", \"collection\"),\n    \"#class\"   : shopify.ShopifyCollectionExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.windsorstore.com/collections/accessories-belts/products/rhine-buckle-dbl-o-ring-pu-strap-belt-073010158001\",\n    \"#category\": (\"shopify\", \"windsorstore\", \"product\"),\n    \"#class\"   : shopify.ShopifyProductExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/xasiat.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import xasiat\n\n\n__tests__ = (\n{\n    \"#url\"    : \"https://www.xasiat.com/albums/28156/photobook-2024-12-09-bomb/\",\n    \"#class\"  : xasiat.XasiatAlbumExtractor,\n    \"#pattern\": r\"https://www.xasiat.com/get_image/2/\\w{32}/sources/28000/28156/\\d+.jpg/\",\n    \"#count\"  : 61,\n\n    \"title\"         : \"[Photobook] 2024.12.09 白濱美兎『忘れられない恋の味』BOMBデジタル写真集\",\n    \"album_category\": \"JAV & AV Models\",\n    \"album_id\"      : 28156,\n    \"album_url\"     : \"https://www.xasiat.com/albums/28156/photobook-2024-12-09-bomb/\",\n    \"count\"         : 61,\n    \"num\"           : range(1, 61),\n    \"extension\"     : \"jpg\",\n    \"filename\"      : r\"re:\\d+\",\n    \"model\"         : [],\n    \"tags\"          : [\n        \"BOMB Photobook\",\n        \"Photobook\",\n    ],\n},\n\n{\n    \"#url\"  : \"https://www.xasiat.com/ja/albums/28155/cosplay1813/\",\n    \"#class\": xasiat.XasiatAlbumExtractor,\n    \"#count\": 40,\n\n    \"title\"         : \"[Cosplay] 喜欢爱理吗 - 早濑优香\",\n    \"album_category\": \"グラビアアイドル\",\n    \"album_id\"      : 28155,\n    \"album_url\"     : \"https://www.xasiat.com/ja/albums/28155/cosplay1813/\",\n    \"count\"         : 40,\n    \"num\"           : range(1, 40),\n    \"model\"         : [],\n    \"tags\"          : [\"コスプレ\"],\n},\n\n{\n    \"#url\"  : \"https://www.xasiat.com/fr/albums/23354/friday-impact-beauty-col-1/\",\n    \"#class\": xasiat.XasiatAlbumExtractor,\n    \"#count\": 51,\n\n    \"title\"         : \"FRIDAYデジタル写真集 下村明香『Impact Beauty col.1』全カット\",\n    \"album_category\": \"Gravure Idols\",\n    \"model\"         : [\"Sayaka Shimomura\"],\n    \"tags\"          : [\n        \"FRIDAY Digital Photobook\",\n        \"De Toute Beauté\",\n    ],\n},\n\n{\n    \"#url\"     : \"https://www.xasiat.com/albums/30478/gekkan-young-magazine-2025-no-11/\",\n    \"#comment\" : \"no 'album_category' (#8569)\",\n    \"#class\"   : xasiat.XasiatAlbumExtractor,\n    \"#pattern\" : r\"https://www\\.xasiat\\.com/get_image/\\d+/\\w+\",\n    \"#count\"   : 13,\n\n    \"album_category\": \"\",\n    \"album_id\"      : 30478,\n    \"album_url\"     : \"https://www.xasiat.com/albums/30478/gekkan-young-magazine-2025-no-11/\",\n    \"count\"         : 13,\n    \"extension\"     : \"jpg\",\n    \"model\"         : [],\n    \"title\"         : \"[Gekkan Young Magazine] 2025 No.11\",\n    \"tags\"          : [\n        \"Young Magazine\",\n        \"Teen\",\n    ],\n},\n\n{\n    \"#url\"    : \"https://www.xasiat.com/albums/categories/gravure-idols/\",\n    \"#class\"  : xasiat.XasiatCategoryExtractor,\n    \"#pattern\": xasiat.XasiatAlbumExtractor.pattern,\n    \"#range\"  : \"1-50\",\n    \"#count\"  : 50,\n},\n\n{\n    \"#url\"    : \"https://www.xasiat.com/albums/tags/japan/\",\n    \"#class\"  : xasiat.XasiatTagExtractor,\n    \"#pattern\": xasiat.XasiatAlbumExtractor.pattern,\n    \"#range\"  : \"1-50\",\n    \"#count\"  : 50,\n},\n\n{\n    \"#url\"    : \"https://www.xasiat.com/albums/models/remu-suzumori/\",\n    \"#class\"  : xasiat.XasiatModelExtractor,\n    \"#pattern\": xasiat.XasiatAlbumExtractor.pattern,\n    \"#range\"  : \"1-15\",\n    \"#count\"  : 15,\n},\n\n)\n"
  },
  {
    "path": "test/results/xbooru.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import gelbooru_v02\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://xbooru.com/index.php?page=post&s=list&tags=konoyan\",\n    \"#category\": (\"gelbooru_v02\", \"xbooru\", \"tag\"),\n    \"#class\"   : gelbooru_v02.GelbooruV02TagExtractor,\n    \"#count\"   : range(28, 40),\n},\n\n{\n    \"#url\"     : \"https://xbooru.com/index.php?page=pool&s=show&id=757\",\n    \"#category\": (\"gelbooru_v02\", \"xbooru\", \"pool\"),\n    \"#class\"   : gelbooru_v02.GelbooruV02PoolExtractor,\n    \"#results\": (\n        \"https://img.xbooru.com/images/154/aeca160f8c7131f6a93033adac5416d7.jpeg\",\n        \"https://img.xbooru.com/images/278/6185a8a71547568020e45e8319c02978.jpeg\",\n        \"https://img.xbooru.com/images/524/0fc2b1e2e3cc8be259e9712ca3f48b0b.jpeg\",\n        \"https://img.xbooru.com/images/253/74412b59a60fac5040c6cfe8efe7a625.jpeg\",\n        \"https://img.xbooru.com/images/590/2eacd900958a467fb053b8a92145b55b.jpeg\",\n    ),\n},\n\n{\n    \"#url\"     : \"https://xbooru.com/index.php?page=favorites&s=view&id=45206\",\n    \"#category\": (\"gelbooru_v02\", \"xbooru\", \"favorite\"),\n    \"#class\"   : gelbooru_v02.GelbooruV02FavoriteExtractor,\n    \"#count\"   : 4,\n},\n\n{\n    \"#url\"     : \"https://xbooru.com/index.php?page=post&s=view&id=1025649\",\n    \"#category\": (\"gelbooru_v02\", \"xbooru\", \"post\"),\n    \"#class\"   : gelbooru_v02.GelbooruV02PostExtractor,\n    \"#pattern\"     : r\"https://img\\.xbooru\\.com/images/444/f3eda549ad8b9db244ac335c7406c92f\\.jpeg\",\n    \"#sha1_content\": \"086668afd445438d491ecc11cee3ac69b4d65530\",\n},\n\n)\n"
  },
  {
    "path": "test/results/xcancel.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import nitter\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://xcancel.com/supernaturepics/status/604341487988576256\",\n    \"#category\": (\"nitter\", \"xcancel\", \"tweet\"),\n    \"#class\"   : nitter.NitterTweetExtractor,\n},\n\n{\n    \"#url\"     : \"https://xcancel.com/supernaturepics\",\n    \"#category\": (\"nitter\", \"xcancel\", \"tweets\"),\n    \"#class\"   : nitter.NitterTweetsExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/xfolio.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import xfolio\nfrom gallery_dl import exception\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://xfolio.jp/portfolio/yutakashii/works/23977\",\n    \"#class\"   : xfolio.XfolioWorkExtractor,\n    \"#results\" : (\n        \"https://xfolio.jp/user_asset.php?id=113179&work_id=23977&work_image_id=113179&type=work_image\",\n        \"https://xfolio.jp/user_asset.php?id=113182&work_id=23977&work_image_id=113182&type=work_image\",\n        \"https://xfolio.jp/user_asset.php?id=113185&work_id=23977&work_image_id=113185&type=work_image\",\n        \"https://xfolio.jp/user_asset.php?id=113188&work_id=23977&work_image_id=113188&type=work_image\",\n        \"https://xfolio.jp/user_asset.php?id=113191&work_id=23977&work_image_id=113191&type=work_image\",\n        \"https://xfolio.jp/user_asset.php?id=113194&work_id=23977&work_image_id=113194&type=work_image\",\n        \"https://xfolio.jp/user_asset.php?id=113197&work_id=23977&work_image_id=113197&type=work_image\",\n        \"https://xfolio.jp/user_asset.php?id=113200&work_id=23977&work_image_id=113200&type=work_image\",\n        \"https://xfolio.jp/user_asset.php?id=113203&work_id=23977&work_image_id=113203&type=work_image\",\n    ),\n\n    \"count\"          : 9,\n    \"num\"            : range(1, 9),\n    \"creator_id\"     : \"1495\",\n    \"creator_name\"   : \"香椎ゆたか\",\n    \"creator_profile\": \"連載中：「いつまでも可愛くしてると思うなよ！」 https://booklive.jp/product/index/title_id/10003104/vol_no/001\\r\\n 過去作：「まじとら！」「男友達ガール」\\r\\npixiv：http://pixiv.me/yutakashii\\r\\nskeb：http://skeb.jp/@yutakashii\",\n    \"creator_slug\"   : \"yutakashii\",\n    \"creator_userid\" : \"3778\",\n    \"description\"    : \"BookLive NINOにて「男友達ガール」連載開始しました。ルームシェア＋TSFで、ある日突然同居人が可愛い女の子になったら…という感じのラブ(？)コメディ...\",\n    \"extension\"      : \"jpg\",\n    \"image_id\"       : r\"re:113\\d\\d\\d\",\n    \"series_id\"      : \"\",\n    \"title\"          : \"新連載「男友達ガール」冒頭試し読み\",\n    \"url\"            : str,\n    \"work_id\"        : \"23977\",\n},\n\n{\n    \"#url\"     : \"https://xfolio.jp/portfolio/yutakashii\",\n    \"#class\"   : xfolio.XfolioUserExtractor,\n    \"#pattern\" : xfolio.XfolioWorkExtractor.pattern,\n    \"#count\"   : range(50, 100),\n},\n\n{\n    \"#url\"     : \"https://xfolio.jp/portfolio/yutakashii/works\",\n    \"#class\"   : xfolio.XfolioUserExtractor,\n},\n{\n    \"#url\"     : \"https://xfolio.jp/portfolio/yutakashii/works?page=3\",\n    \"#class\"   : xfolio.XfolioUserExtractor,\n},\n{\n    \"#url\"     : \"https://xfolio.jp/en/portfolio/yutakashii\",\n    \"#class\"   : xfolio.XfolioUserExtractor,\n},\n{\n    \"#url\"     : \"https://xfolio.jp/ko/portfolio/yutakashii\",\n    \"#class\"   : xfolio.XfolioUserExtractor,\n},\n{\n    \"#url\"     : \"https://xfolio.jp/zh-CN/portfolio/yutakashii\",\n    \"#class\"   : xfolio.XfolioUserExtractor,\n},\n\n{\n    \"#url\"      : \"https://xfolio.jp/portfolio/donguri/series/1391402\",\n    \"#class\"    : xfolio.XfolioSeriesExtractor,\n    \"#auth\"     : False,\n    \"#exception\": exception.AbortExtraction,\n},\n\n{\n    \"#url\"     : \"https://xfolio.jp/portfolio/donguri/series/1391402\",\n    \"#class\"   : xfolio.XfolioSeriesExtractor,\n    \"#auth\"    : True,\n    \"#results\" : (\n        \"https://xfolio.jp/portfolio/donguri/works/2472402\",\n        \"https://xfolio.jp/portfolio/donguri/works/2470700\",\n    ),\n},\n\n)\n"
  },
  {
    "path": "test/results/xhamster.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import xhamster\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://xhamster.com/photos/gallery/take-me-to-the-carwash-at-digitaldesire-15860946\",\n    \"#category\": (\"\", \"xhamster\", \"gallery\"),\n    \"#class\"   : xhamster.XhamsterGalleryExtractor,\n    \"#pattern\" : r\"https://ic-ph-\\w+\\.xhcdn\\.com/a/\\w+/webp/000/\\d+/\\d+/\\d+_1000\\.jpg$\",\n    \"#count\"   : 19,\n\n    \"comments\": int,\n    \"count\"   : int,\n    \"favorite\": bool,\n    \"id\"      : int,\n    \"num\"     : int,\n    \"height\"  : int,\n    \"width\"   : int,\n    \"imageURL\": str,\n    \"pageURL\" : str,\n    \"thumbURL\": str,\n    \"gallery\" : {\n        \"date\"       : \"dt:2022-02-02 06:30:09\",\n        \"description\": \"Alina Henessy loves to wash her car, and we love seeing every inch of her gorgeous body. More at DigitalDesire.com\",\n        \"dislikes\"   : int,\n        \"id\"         : 15860946,\n        \"likes\"      : int,\n        \"tags\"       : [\n            \"Babe\",\n            \"Public Nudity\",\n            \"Take\",\n            \"Taking\",\n            \"Masturbation\",\n            \"Take Me\",\n        ],\n        \"thumbnail\"  : str,\n        \"title\"      : \"Take me to the carwash at DigitalDesire\",\n        \"views\"      : range(100000, 200000),\n\n    },\n    \"user\"    : {\n        \"id\"         : 4741860,\n        \"name\"       : \"DaringSex\",\n        \"?retired\"   : False,\n        \"subscribers\": range(25000, 50000),\n        \"url\"        : \"https://xhamster.com/users/daringsex\",\n        \"verified\"   : False,\n    },\n},\n\n{\n    \"#url\"     : \"https://jp.xhamster2.com/photos/gallery/take-me-to-the-carwash-at-digitaldesire-15860946\",\n    \"#category\": (\"\", \"xhamster\", \"gallery\"),\n    \"#class\"   : xhamster.XhamsterGalleryExtractor,\n    \"#pattern\" : r\"https://ic-ph-\\w+\\.xhcdn\\.com/a/\\w+/webp/000/\\d+/\\d+/\\d+_1000\\.jpg$\",\n    \"#count\"   : 19,\n},\n\n{\n    \"#url\"     : \"https://xhamster.com/photos/gallery/make-the-world-better-11748968\",\n    \"#category\": (\"\", \"xhamster\", \"gallery\"),\n    \"#class\"   : xhamster.XhamsterGalleryExtractor,\n},\n\n{\n    \"#url\"     : \"https://xhamster.com/photos/gallery/11748968\",\n    \"#category\": (\"\", \"xhamster\", \"gallery\"),\n    \"#class\"   : xhamster.XhamsterGalleryExtractor,\n},\n\n{\n    \"#url\"     : \"https://xhamster.one/photos/gallery/11748968\",\n    \"#category\": (\"\", \"xhamster\", \"gallery\"),\n    \"#class\"   : xhamster.XhamsterGalleryExtractor,\n},\n\n{\n    \"#url\"     : \"https://xhamster.desi/photos/gallery/11748968\",\n    \"#category\": (\"\", \"xhamster\", \"gallery\"),\n    \"#class\"   : xhamster.XhamsterGalleryExtractor,\n},\n\n{\n    \"#url\"     : \"https://xhamster2.com/photos/gallery/11748968\",\n    \"#category\": (\"\", \"xhamster\", \"gallery\"),\n    \"#class\"   : xhamster.XhamsterGalleryExtractor,\n},\n\n{\n    \"#url\"     : \"https://en.xhamster.com/photos/gallery/11748968\",\n    \"#category\": (\"\", \"xhamster\", \"gallery\"),\n    \"#class\"   : xhamster.XhamsterGalleryExtractor,\n},\n\n{\n    \"#url\"     : \"https://xhamster.porncache.net/photos/gallery/11748968\",\n    \"#category\": (\"\", \"xhamster\", \"gallery\"),\n    \"#class\"   : xhamster.XhamsterGalleryExtractor,\n},\n\n{\n    \"#url\"     : \"https://xhamster.com/users/daringsex/photos\",\n    \"#category\": (\"\", \"xhamster\", \"user\"),\n    \"#class\"   : xhamster.XhamsterUserExtractor,\n    \"#pattern\" : xhamster.XhamsterGalleryExtractor.pattern,\n    \"#range\"   : \"1-50\",\n    \"#count\"   : 50,\n},\n\n{\n    \"#url\"     : \"https://xhamster.com/users/nickname68\",\n    \"#category\": (\"\", \"xhamster\", \"user\"),\n    \"#class\"   : xhamster.XhamsterUserExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/xvideos.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import xvideos\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://www.xvideos.com/profiles/pervertedcouple/photos/751031\",\n    \"#category\": (\"\", \"xvideos\", \"gallery\"),\n    \"#class\"   : xvideos.XvideosGalleryExtractor,\n    \"#pattern\" : r\"https://profile-pics-cdn\\d+\\.xvideos-cdn\\.com/[^/]+\\,\\d+/videos/profiles/galleries/84/ca/37/pervertedcouple/gal751031/pic_\\d+_big\\.jpg\",\n    \"#count\"   : 8,\n\n    \"gallery\": {\n        \"id\"   : 751031,\n        \"title\": \"Random Stuff\",\n        \"tags\" : list,\n    },\n    \"user\"   : {\n        \"id\"         : 20245371,\n        \"name\"       : \"pervertedcouple\",\n        \"display\"    : \"Pervertedcouple\",\n        \"sex\"        : \"Woman\",\n        \"description\": str,\n    },\n},\n\n{\n    \"#url\"     : \"https://www.xvideos.com/amateur-channels/pervertedcouple/photos/12\",\n    \"#category\": (\"\", \"xvideos\", \"gallery\"),\n    \"#class\"   : xvideos.XvideosGalleryExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.xvideos.com/model-channels/pervertedcouple/photos/12\",\n    \"#category\": (\"\", \"xvideos\", \"gallery\"),\n    \"#class\"   : xvideos.XvideosGalleryExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.xvideos.com/channels/pervertedcouple/photos/12\",\n    \"#comment\" : \"/channels/ URL (#5244)\",\n    \"#category\": (\"\", \"xvideos\", \"gallery\"),\n    \"#class\"   : xvideos.XvideosGalleryExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.xvideos.com/profiles/pervertedcouple\",\n    \"#category\": (\"\", \"xvideos\", \"user\"),\n    \"#class\"   : xvideos.XvideosUserExtractor,\n    \"#sha1_url\"     : \"a413f3e60d6d3a2de79bd44fa3b7a9c03db4336e\",\n    \"#sha1_metadata\": \"335a3304941ff2e666c0201e9122819b61b34adb\",\n},\n\n{\n    \"#url\"     : \"https://www.xvideos.com/profiles/pervertedcouple#_tabPhotos\",\n    \"#category\": (\"\", \"xvideos\", \"user\"),\n    \"#class\"   : xvideos.XvideosUserExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.xvideos.com/channels/pervertedcouple\",\n    \"#category\": (\"\", \"xvideos\", \"user\"),\n    \"#class\"   : xvideos.XvideosUserExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.xvideos.com/amateur-channels/pervertedcouple\",\n    \"#category\": (\"\", \"xvideos\", \"user\"),\n    \"#class\"   : xvideos.XvideosUserExtractor,\n},\n\n{\n    \"#url\"     : \"https://www.xvideos.com/model-channels/pervertedcouple\",\n    \"#category\": (\"\", \"xvideos\", \"user\"),\n    \"#class\"   : xvideos.XvideosUserExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/yandere.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import moebooru\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://yande.re/post/show/51824\",\n    \"#category\": (\"moebooru\", \"yandere\", \"post\"),\n    \"#class\"   : moebooru.MoebooruPostExtractor,\n    \"#options\"     : {\"tags\": True},\n    \"#sha1_content\": \"59201811c728096b2d95ce6896fd0009235fe683\",\n\n    \"tags_artist\"   : \"sasaki_tamaru\",\n    \"tags_circle\"   : \"softhouse_chara\",\n    \"tags_copyright\": \"ouzoku\",\n    \"tags_general\"  : str,\n},\n\n{\n    \"#url\"     : \"https://yande.re/post/show/993156\",\n    \"#category\": (\"moebooru\", \"yandere\", \"post\"),\n    \"#class\"   : moebooru.MoebooruPostExtractor,\n    \"#options\"     : {\"notes\": True},\n    \"#sha1_content\": \"fed722bd90f48de41ec163692befc701056e2b1e\",\n\n    \"notes\": [\n        {\n            \"id\"    : 7096,\n            \"x\"     : 90,\n            \"y\"     : 626,\n            \"width\" : 283,\n            \"height\": 529,\n            \"body\"  : \"Please keep this as a secret for me!!\",\n        },\n        {\n            \"id\"    : 7095,\n            \"x\"     : 900,\n            \"y\"     : 438,\n            \"width\" : 314,\n            \"height\": 588,\n            \"body\"  : \"The facts that I love playing games\",\n        },\n    ],\n},\n\n{\n    \"#url\"     : \"https://yande.re/post?tags=ouzoku+armor\",\n    \"#category\": (\"moebooru\", \"yandere\", \"tag\"),\n    \"#class\"   : moebooru.MoebooruTagExtractor,\n    \"#sha1_content\": \"59201811c728096b2d95ce6896fd0009235fe683\",\n},\n\n{\n    \"#url\"     : \"https://yande.re/pool/show/318\",\n    \"#category\": (\"moebooru\", \"yandere\", \"pool\"),\n    \"#class\"   : moebooru.MoebooruPoolExtractor,\n    \"#sha1_content\": \"2a35b9d6edecce11cc2918c6dce4de2198342b68\",\n    \"#results\"     : (\n        \"https://files.yande.re/image/62558ad1d68ffb47e903694d2c5f9e53/yande.re%2051824%20armor%20ouzoku%20pantsu%20sasaki_tamaru%20softhouse_chara%20sword%20thighhighs.jpg\",\n        \"https://files.yande.re/image/c57fcd886f643297f1283242e572b81d/yande.re%2036975%20ashita_no_kimi_to_au_tame_ni%20cleavage%20kurashima_tomoyasu%20pantsu%20wakamiya_asuka.jpg\",\n        \"https://files.yande.re/image/c877ec0dc18dd79d69217011adfa5af3/yande.re%2051832%20aburidashi_zakuro%20animal_ears%20erect_nipples%20pantsu%20tail.jpg\",\n    ),\n},\n\n{\n    \"#url\"     : \"https://yande.re/pool/show/318\",\n    \"#comment\" : \"'metadata' option (#4646)\",\n    \"#category\": (\"moebooru\", \"yandere\", \"pool\"),\n    \"#class\"   : moebooru.MoebooruPoolExtractor,\n    \"#options\" : {\"metadata\": True},\n    \"#count\"   : 3,\n\n    \"pool\": {\n        \"created_at\" : \"2008-12-13T15:56:10.728Z\",\n        \"description\": \"Dengeki Hime's posts are in pool #97.\",\n        \"id\"         : 318,\n        \"is_public\"  : True,\n        \"name\"       : \"Galgame Mag 08\",\n        \"post_count\" : 3,\n        \"updated_at\" : \"2012-03-11T14:31:00.935Z\",\n        \"user_id\"    : 1305,\n    },\n\n},\n\n{\n    \"#url\"     : \"https://yande.re/post/popular_by_month?month=6&year=2014\",\n    \"#category\": (\"moebooru\", \"yandere\", \"popular\"),\n    \"#class\"   : moebooru.MoebooruPopularExtractor,\n    \"#count\"   : 40,\n},\n\n{\n    \"#url\"     : \"https://yande.re/post/popular_recent\",\n    \"#category\": (\"moebooru\", \"yandere\", \"popular\"),\n    \"#class\"   : moebooru.MoebooruPopularExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/yiffverse.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import yiffverse\n\n\n__tests__ = (\n{\n    \"#url\"    : \"https://yiffverse.com/post/574342\",\n    \"#comment\": \"image\",\n    \"#class\"  : yiffverse.YiffversePostExtractor,\n    \"#options\"     : {\"tags\": True},\n    \"#pattern\"     : r\"https://(yiffverse\\.com|furry34com\\.b-cdn\\.net)/posts/574/574342/574342\\.pic\\.jpg\",\n    \"#sha1_content\": \"0f169fddbd320eae904508f83a722bb3633ad507\",\n\n    \"created\"  : \"2024-12-06T13:55:24.483002Z\",\n    \"date\"     : \"dt:2024-12-06 13:55:24\",\n    \"extension\": \"jpg\",\n    \"file_url\" : str,\n    \"filename\" : \"574342\",\n    \"format\"   : \"pic\",\n    \"format_id\": \"10\",\n    \"height\"   : 862,\n    \"id\"       : 574342,\n    \"likes\"    : range(5, 100),\n    \"posted\"   : \"2024-12-06T13:55:55.299953Z\",\n    \"status\"   : 2,\n    \"type\"     : 0,\n    \"tags\"        : list,\n    \"tags_general\": list,\n    \"tags_artist\" : [\"imanika\"],\n    \"tags_meta\"   : [\"6:5\"],\n    \"uploaderId\"  : 2,\n    \"width\"       : 950,\n    \"data\"        : {\n        \"sources\": [\n            \"https://www.furaffinity.net/view/59071676/\",\n            \"https://www.furaffinity.net/user/imanika/\",\n            \"https://d.furaffinity.net/art/imanika/1733430246/1733430246.imanika_dream_girl_ych_slot1_web.jpg\",\n        ],\n    },\n},\n\n{\n    \"#url\"    : \"https://yiffverse.com/post/575680\",\n    \"#comment\": \"video\",\n    \"#class\"  : yiffverse.YiffversePostExtractor,\n    \"#results\"     : \"https://furry34com.b-cdn.net/posts/575/575680/575680.mov.mp4\",\n    \"#sha1_content\": \"8952fc794e58c531b4e3b01cfe9e14b1c59ad9ef\",\n},\n\n{\n    \"#url\"  : \"https://yiffverse.com/tag/tree\",\n    \"#class\": yiffverse.YiffverseTagExtractor,\n    \"#pattern\": r\"https://yiffverse\\.com/posts/\\d+/\\d+/\\d+\\.(pic\\.jpg|mov\\d*\\.mp4)\",\n    \"#range\"  : \"1-10\",\n    \"#count\"  : 10,\n},\n\n{\n    \"#url\"  : \"https://yiffverse.com/playlist/6842\",\n    \"#class\": yiffverse.YiffversePlaylistExtractor,\n    \"#pattern\": r\"https://(yiffverse\\.com|furry34com\\.b-cdn\\.net)/posts/\\d+/\\d+/\\d+\\.mov(720)?\\.mp4\",\n    \"#count\"  : range(40, 60),\n},\n\n)\n"
  },
  {
    "path": "test/results/yourlesbians.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import yourlesbians\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://yourlesbians.com/album/lana-rhoades-nude/\",\n    \"#class\"   : yourlesbians.YourlesbiansAlbumExtractor,\n    \"#pattern\" : r\"https://yourlesbians\\.com/get_image/2/\\w+/sources/\\d/\\d/\\d+\\.jpg/\\?i\\-acctoken=\\w+\",\n    \"#count\"   : 30,\n\n    \"album_thumbnail\": \"https://www.yourlesbians.com/contents/albums/preview/480x640/0/2/preview.jpg\",\n    \"album_url\"      : \"https://yourlesbians.com/album/lana-rhoades-nude/\",\n    \"count\"          : 30,\n    \"num\"            : range(1, 30),\n    \"description\"    : \"Lana Rhoades porn actress, born Amara Lynee Maple on September 6, 1996, in McHenry, Illinois, was a cheerleader and gymnast raised by a single mom of Slovenian-Czech descent. After waitressing at Tilted Kilt and Hooters, she answered a 2016 Craigslist ad, shot her first scene with FTV Girls at 19, then signed with Spiegler Girls and moved to LA. Exploding into over 300 titles for Blacked, Tushy, Vixen, Brazzers, Evil Angel and more, she became legendary for anal, gangbangs, interracial, and showcases like Lana and Ultimate Fuck Toy: Lana Rhoades, cementing her as an all-time superstar.\",\n    \"extension\"      : \"jpg\",\n    \"filename\"       : r\"re:\\d+\",\n    \"tags\"           : [\"lana rhoades ass\"],\n    \"title\"          : \"Lana Rhoades Nude\",\n},\n\n{\n    \"#url\"     : \"https://yourlesbians.com/album/splatxo-nude/\",\n    \"#class\"   : yourlesbians.YourlesbiansAlbumExtractor,\n    \"#results\" : (\n        \"https://yourlesbians.com/get_image/2/592e928637aadc3a6c96c03a4775ff3b/sources/0/189/4101.jpg/?i-acctoken=MXw0N2I3MDhlMjMzNjNkMTgxNGZkNzc2NThlNDQ3ZThjYg\",\n        \"https://yourlesbians.com/get_image/2/8b89201a5618af434079cbdaa72a4af1/sources/0/189/4102.jpg/?i-acctoken=MXw0N2I3MDhlMjMzNjNkMTgxNGZkNzc2NThlNDQ3ZThjYg\",\n        \"https://yourlesbians.com/get_image/2/c14f64d23ddbe9a918a9794f0660dca0/sources/0/189/4103.jpg/?i-acctoken=MXw0N2I3MDhlMjMzNjNkMTgxNGZkNzc2NThlNDQ3ZThjYg\",\n        \"https://yourlesbians.com/get_image/2/740eda0ee59b3f66b96e5a3f6359fc4a/sources/0/189/4104.jpg/?i-acctoken=MXw0N2I3MDhlMjMzNjNkMTgxNGZkNzc2NThlNDQ3ZThjYg\",\n        \"https://yourlesbians.com/get_image/2/9c83001c16963a0cab10f3d715eb8ea2/sources/0/189/4105.jpg/?i-acctoken=MXw0N2I3MDhlMjMzNjNkMTgxNGZkNzc2NThlNDQ3ZThjYg\",\n        \"https://yourlesbians.com/get_image/2/ae3de334429a294d7aba755a21781b29/sources/0/189/4106.jpg/?i-acctoken=MXw0N2I3MDhlMjMzNjNkMTgxNGZkNzc2NThlNDQ3ZThjYg\",\n        \"https://yourlesbians.com/get_image/2/6d816e8de589ac963cae844a492c4b63/sources/0/189/4107.jpg/?i-acctoken=MXw0N2I3MDhlMjMzNjNkMTgxNGZkNzc2NThlNDQ3ZThjYg\",\n    ),\n\n    \"album_thumbnail\": \"https://www.yourlesbians.com/contents/albums/preview/480x640/0/189/preview.jpg\",\n    \"album_url\"      : \"https://yourlesbians.com/album/splatxo-nude/\",\n    \"count\"          : 7,\n    \"num\"            : range(1, 7),\n    \"description\"    : \"\",\n    \"extension\"      : \"jpg\",\n    \"filename\"       : str,\n    \"tags\"           : [],\n    \"title\"          : \"SplatXo Nude\",\n},\n\n)\n"
  },
  {
    "path": "test/results/ytdl.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import ytdl\n\n\n__tests__ = (\n{\n    \"#url\"     : \"ytdl:https://www.youtube.com/watch?v=BaW_jenozKc&t=1s&end=9\",\n    \"#category\": (\"\", \"ytdl\", \"Youtube\"),\n    \"#class\"   : ytdl.YoutubeDLExtractor,\n},\n\n{\n    \"#url\"     : \"ytdl:http://media.w3.org/2010/05/sintel/trailer.mp4\",\n    \"#category\": (\"\", \"ytdl-generic\", \"media.w3.org\"),\n    \"#class\"   : ytdl.YoutubeDLExtractor,\n},\n\n)\n"
  },
  {
    "path": "test/results/zerochan.py",
    "content": "# -*- coding: utf-8 -*-\n\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nfrom gallery_dl.extractor import zerochan\n\n\n__tests__ = (\n{\n    \"#url\"     : \"https://www.zerochan.net/Perth+%28Kantai+Collection%29\",\n    \"#category\": (\"booru\", \"zerochan\", \"tag\"),\n    \"#class\"   : zerochan.ZerochanTagExtractor,\n    \"#pattern\" : r\"https://static\\.zerochan\\.net/\\.full\\.\\d+\\.jpg\",\n    \"#count\"   : \"> 50\",\n\n    \"extension\"  : r\"jpg\",\n    \"file_url\"   : r\"re:https://static\\.zerochan\\.net/\\.full\\.\\d+\\.jpg\",\n    \"filename\"   : r\"re:\\.full\\.\\d+\",\n    \"height\"     : int,\n    \"id\"         : int,\n    \"search_tags\": \"Perth (Kantai Collection)\",\n    \"tag\"        : r\"re:(Perth \\(Kantai Collection\\)|Kantai Collection)\",\n    \"tags\"       : list,\n    \"width\"      : int,\n},\n\n{\n    \"#url\"     : \"https://www.zerochan.net/Perth+%28Kantai+Collection%29\",\n    \"#category\": (\"booru\", \"zerochan\", \"tag\"),\n    \"#class\"   : zerochan.ZerochanTagExtractor,\n    \"#options\" : {\"pagination\": \"html\"},\n    \"#pattern\" : r\"https://static\\.zerochan\\.net/.+\\.full\\.\\d+\\.(jpg|png)\",\n    \"#count\"   : \"> 45\",\n\n    \"extension\"  : r\"re:jpg|png\",\n    \"file_url\"   : r\"re:https://static\\.zerochan\\.net/.+\\.full\\.\\d+\\.(jpg|png)\",\n    \"filename\"   : r\"re:(Perth\\.\\(Kantai\\.Collection\\)|Kantai\\.Collection)\\.full\\.\\d+\",\n    \"height\"     : r\"re:^\\d+$\",\n    \"id\"         : r\"re:^\\d+$\",\n    \"name\"       : r\"re:(Perth \\(Kantai Collection\\)|Kantai Collection)\",\n    \"search_tags\": \"Perth (Kantai Collection)\",\n    \"size\"       : r\"re:^\\d+k$\",\n    \"width\"      : r\"re:^\\d+$\",\n},\n\n{\n    \"#url\"     : \"https://www.zerochan.net/non_existant_tag\",\n    \"#comment\" : \"handle HttpError exception (#8313)\",\n    \"#class\"   : zerochan.ZerochanTagExtractor,\n    \"#count\"   : 0,\n},\n\n{\n    \"#url\"     : \"https://www.zerochan.net/2920445\",\n    \"#category\": (\"booru\", \"zerochan\", \"image\"),\n    \"#class\"   : zerochan.ZerochanImageExtractor,\n    \"#pattern\" : r\"https://static\\.zerochan\\.net/Perth\\.%28Kantai\\.Collection%29\\.full.2920445\\.jpg\",\n    \"#auth\"    : True,\n\n    \"author\"  : \"YeFan 葉凡\",\n    \"date\"    : \"dt:2020-04-24 21:33:44\",\n    \"file_url\": \"https://static.zerochan.net/Perth.%28Kantai.Collection%29.full.2920445.jpg\",\n    \"filename\": \"Perth.(Kantai.Collection).full.2920445\",\n    \"height\"  : 1366,\n    \"id\"      : 2920445,\n    \"path\"    : [\n        \"Kantai Collection\",\n        \"Perth (Kantai Collection)\",\n    ],\n    \"size\"    : 1975296,\n    \"source\"  : \"\",\n    \"tags\"    : [\n        \"Mangaka:YeFan 葉凡\",\n        \"Game:Kantai Collection\",\n        \"Character:Perth (Kantai Collection)\",\n        \"Theme:Blonde Hair\",\n        \"Theme:Braids\",\n        \"Theme:Coat\",\n        \"Theme:Female\",\n        \"Theme:Firefighter Outfit\",\n        \"Theme:Group\",\n        \"Theme:Long Sleeves\",\n        \"Theme:Personification\",\n        \"Theme:Pins\",\n        \"Theme:Ribbon\",\n        \"Theme:Short Hair\",\n        \"Theme:Top\",\n    ],\n    \"uploader\": \"YukinoTokisaki\",\n    \"width\"   : 1920,\n},\n\n{\n    \"#url\"     : \"https://www.zerochan.net/2920445\",\n    \"#category\": (\"booru\", \"zerochan\", \"image\"),\n    \"#class\"   : zerochan.ZerochanImageExtractor,\n    \"#pattern\" : r\"https://static\\.zerochan\\.net/Perth\\.%28Kantai\\.Collection%29\\.full.2920445\\.jpg\",\n    \"#auth\"    : False,\n\n    \"author\"  : \"YeFan 葉凡\",\n    \"date\"    : \"dt:2020-04-24 21:33:44\",\n    \"file_url\": \"https://static.zerochan.net/Perth.%28Kantai.Collection%29.full.2920445.jpg\",\n    \"filename\": \"Perth.(Kantai.Collection).full.2920445\",\n    \"height\"  : 1366,\n    \"id\"      : 2920445,\n    \"path\"    : [\n        \"Kantai Collection\",\n        \"Perth (Kantai Collection)\",\n    ],\n    \"size\"    : 1975296,\n    \"source\"  : \"\",\n    \"tags\"    : [\n        \"Mangaka:YeFan 葉凡\",\n        \"Game:Kantai Collection\",\n        \"Character:Perth (Kantai Collection)\",\n        \"Theme:Firefighter Outfit\",\n        \"Theme:Pins\",\n    ],\n    \"uploader\": \"YukinoTokisaki\",\n    \"width\"   : 1920,\n},\n\n{\n    \"#url\"     : \"https://www.zerochan.net/4233756\",\n    \"#category\": (\"booru\", \"zerochan\", \"image\"),\n    \"#class\"   : zerochan.ZerochanImageExtractor,\n    \"#results\" : \"https://static.zerochan.net/DRAGON.BALL.full.4233756.jpg\",\n    \"#options\" : {\"tags\": True},\n\n    \"author\"   : \"Raydash\",\n    \"date\"     : \"dt:2024-07-23 00:10:51\",\n    \"extension\": \"jpg\",\n    \"file_url\" : \"https://static.zerochan.net/DRAGON.BALL.full.4233756.jpg\",\n    \"filename\" : \"DRAGON.BALL.full.4233756\",\n    \"height\"   : 1125,\n    \"id\"       : 4233756,\n    \"path\"     : [\n        \"Manga\",\n        \"DRAGON BALL\",\n    ],\n    \"size\"     : 136192,\n    \"source\"   : \"https://x.com/Raydash30/status/1766012730769862774\",\n    \"tags\"     : [\n        \"Mangaka:Raydash\",\n        \"Series:DRAGON BALL\",\n        \"Series:DRAGON BALL Z\",\n        \"Character:Piccolo\",\n        \"Character:Son Gohan\",\n        \"Theme:Duo\",\n        \"Theme:Green Skin\",\n        \"Theme:Male\",\n        \"Theme:Male Focus\",\n        \"Theme:Two Males\",\n        \"Source:Fanart\",\n        \"Source:Fanart from X (Twitter)\",\n        \"Source:X (Twitter)\",\n    ],\n    \"tags_character\": [\n        \"Piccolo\",\n        \"Son Gohan\",\n    ],\n    \"tags_mangaka\"  : [\n        \"Raydash\",\n    ],\n    \"tags_series\"   : [\n        \"DRAGON BALL\",\n        \"DRAGON BALL Z\",\n    ],\n    \"tags_source\"   : [\n        \"Fanart\",\n        \"Fanart from X (Twitter)\",\n        \"X (Twitter)\",\n    ],\n    \"tags_theme\"    : [\n        \"Duo\",\n        \"Green Skin\",\n        \"Male\",\n        \"Male Focus\",\n        \"Two Males\",\n    ],\n    \"uploader\" : \"menotbug\",\n    \"width\"    : 750,\n},\n\n{\n    \"#url\"     : \"https://www.zerochan.net/4233756\",\n    \"#class\"   : zerochan.ZerochanImageExtractor,\n    \"#auth\"    : False,\n    \"#results\" : \"https://static.zerochan.net/DRAGON.BALL.full.4233756.jpg\",\n\n    \"source\"   : \"https://x.com/Raydash30/status/1766012730769862774\",\n    \"tags\"     : [\n        \"Mangaka:Raydash\",\n        \"Series:DRAGON BALL\",\n        \"Series:DRAGON BALL Z\",\n        \"Character:Piccolo\",\n        \"Character:Son Gohan\",\n        \"Theme:Green Skin\",\n        \"Source:Fanart\",\n        \"Source:Fanart from X (Twitter)\",\n        \"Source:X (Twitter)\",\n    ],\n},\n\n{\n    \"#url\"     : \"https://www.zerochan.net/1395035\",\n    \"#comment\" : \"Invalid control character '\\r' in 'source' field (#5892)\",\n    \"#category\": (\"booru\", \"zerochan\", \"image\"),\n    \"#class\"   : zerochan.ZerochanImageExtractor,\n    \"#auth\"    : True,\n    \"#options\" : {\"metadata\": True},\n\n    \"source\": \"http://www.youtube.com/watch?v=0vodqkGPxt8\",\n},\n\n{\n    \"#url\"     : \"https://www.zerochan.net/4354955\",\n    \"#comment\" : \"unescaped quotes in 'JSON' data (#6632)\",\n    \"#category\": (\"booru\", \"zerochan\", \"image\"),\n    \"#class\"   : zerochan.ZerochanImageExtractor,\n    \"#auth\"    : False,\n    \"#options\" : {\"metadata\": True},\n\n    \"author\"  : \"SEGA\",\n    \"date\"    : \"dt:2024-12-05 06:06:14\",\n    \"file_url\": \"https://static.zerochan.net/Miles.%22Tails%22.Prower.full.4354955.jpg\",\n    \"filename\": \"Miles.\\\"Tails\\\".Prower.full.4354955\",\n    \"height\"  : 705,\n    \"id\"      : 4354955,\n    \"name\"    : \"Miles \\\"Tails\\\" Prower\",\n    \"size\"    : 252928,\n    \"source\"  : \"https://x.com/kellanstover/status/1580237736874606597\",\n    \"uploader\": \"Anima-Chao\",\n    \"width\"   : 4096,\n    \"path\"    : [\n        \"Sonic the Hedgehog\",\n        \"Miles \\\"Tails\\\" Prower\",\n    ],\n    \"tags\"    : [\n        \"Male\",\n        \"Animal\",\n        \"Fox\",\n        \"Sonic the Hedgehog\",\n        \"Flying\",\n        \"Character Sheet\",\n        \"Airplane\",\n        \"SEGA\",\n        \"Miles \\\"Tails\\\" Prower\",\n        \"Official Art\",\n        \"Midair\",\n        \"X (Twitter)\",\n        \"Sonic Origins\",\n        \"Official Art from X\",\n        \"Tory Patterson\",\n    ],\n},\n\n{\n    \"#url\"     : \"https://www.zerochan.net/4354955\",\n    \"#comment\" : \"quotes in HTML tags\",\n    \"#category\": (\"booru\", \"zerochan\", \"image\"),\n    \"#class\"   : zerochan.ZerochanImageExtractor,\n    \"#auth\"    : False,\n    \"#options\" : {\"metadata\": False},\n\n    \"tags\": [\n        \"Mangaka:Tory Patterson\",\n        \"Studio:SEGA\",\n        \"Game:Sonic Origins\",\n        \"Series:Sonic the Hedgehog\",\n        \"Character:Miles \\\"Tails\\\" Prower\",\n        \"Theme:Airplane\",\n        \"Theme:Flying\",\n        \"Theme:Fox\",\n        \"Source:Character Sheet\",\n        \"Source:Official Art\",\n        \"Source:Official Art from X\",\n        \"Source:X (Twitter)\",\n    ],\n},\n\n{\n    \"#url\"     : \"https://www.zerochan.net/2275437\",\n    \"#comment\" : \"unicode escapes with surrogate pair (#7178)\",\n    \"#category\": (\"booru\", \"zerochan\", \"image\"),\n    \"#class\"   : zerochan.ZerochanImageExtractor,\n    \"#auth\"    : False,\n    \"#options\" : {\"metadata\": False},\n\n    \"author\"   : \"MAYO🍚\",\n    \"date\"     : \"dt:2018-02-25 16:03:48\",\n    \"extension\": \"png\",\n    \"file_url\" : \"https://static.zerochan.net/Kongou.full.2275437.png\",\n    \"filename\" : \"Kongou.full.2275437\",\n    \"width\"    : 1047,\n    \"height\"   : 1365,\n    \"id\"       : 2275437,\n    \"size\"     : 502784,\n    \"source\"   : \"\",\n    \"uploader\" : \"SubaruSumeragi\",\n    \"path\"     : [\n        \"Kantai Collection\",\n        \"Kongou\",\n    ],\n    \"tags\"     : [\n        \"Mangaka:MAYO🍚\",\n        \"Game:Kantai Collection\",\n        \"Character:Kongou\"\n    ],\n},\n\n{\n    \"#url\"     : \"https://www.zerochan.net/4147104\",\n    \"#comment\" : \"no 'author' in JSON-LD data (#7282)\",\n    \"#category\": (\"booru\", \"zerochan\", \"image\"),\n    \"#class\"   : zerochan.ZerochanImageExtractor,\n    \"#auth\"    : False,\n\n    \"author\"   : \"\",\n    \"date\"     : \"dt:2024-04-02 12:09:30\",\n    \"extension\": \"jpg\",\n    \"file_url\" : \"https://static.zerochan.net/Lycoris.Recoil.full.4147104.jpg\",\n    \"filename\" : \"Lycoris.Recoil.full.4147104\",\n    \"width\"    : 1061,\n    \"height\"   : 1500,\n    \"id\"       : 4147104,\n    \"size\"     : 224256,\n    \"source\"   : \"https://twitter.com/animetv_jp/status/1775101399648374835/\",\n    \"uploader\" : \"cutesherry\",\n    \"path\"     : [\n        \"Lycoris Recoil\",\n    ],\n    \"tags\"     : [\n        \"Studio:A-1 Pictures\",\n        \"Series:Lycoris Recoil\",\n        \"Character:Inoue Takina\",\n        \"Character:Nishikigi Chisato\",\n        \"Theme:Bench\",\n        \"Theme:Cherry Tree\",\n        \"Theme:Floating Hair\",\n        \"Theme:Sitting On Bench\",\n        \"Theme:Sneakers\",\n        \"Theme:Spring\",\n        \"Source:Key Visual\",\n        \"Source:Official Art\",\n    ],\n},\n\n{\n    \"#url\"     : \"https://www.zerochan.net/1\",\n    \"#category\": (\"booru\", \"zerochan\", \"image\"),\n    \"#class\"   : zerochan.ZerochanImageExtractor,\n    \"#count\"   : 0,\n    \"#log\"     : \"'deleted'\",\n},\n\n{\n    \"#url\"     : \"https://www.zerochan.net/9876540\",\n    \"#category\": (\"booru\", \"zerochan\", \"image\"),\n    \"#class\"   : zerochan.ZerochanImageExtractor,\n    \"#count\"   : 0,\n    \"#log\"     : \"'Not found'\",\n},\n\n)\n"
  },
  {
    "path": "test/test_cache.py",
    "content": "#!/usr/bin/env python3\n# -*- coding: utf-8 -*-\n\n# Copyright 2020-2026 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nimport os\nimport sys\nimport unittest\nfrom unittest.mock import patch\n\nsys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))\nfrom gallery_dl import extractor, cache, config, util  # noqa E402\nfrom gallery_dl.extractor.common import CACHE_MEMORY as CACHE  # noqa E402\n\n\ndef setUpModule():\n    if cache.DATABASE is None:\n        import atexit\n        import tempfile\n        dbpath = tempfile.mkstemp()[1]\n        config.set((\"cache\",), \"file\", dbpath)\n        atexit.register(util.remove_file, dbpath)\n\n\nclass TestCache(unittest.TestCase):\n\n    @classmethod\n    def setUpClass(cls):\n        cls.extr = extr = extractor.find(\"noop\")\n        cls.cache = extr.cache\n        cls.cache_update = extr.cache_update\n\n    def setUp(self):\n        CACHE.clear()\n\n    def tearDown(self):\n        CACHE.clear()\n\n    def _eq(self, func, expected, args, **kwargs):\n        self.assertEqual(\n            self.extr.cache(func, *args, **kwargs),\n            expected,\n            f\"{func.__name__}({args}, {kwargs})\",\n        )\n\n    def test_keyarg_mem_simple(self):\n        def ka(a, b, c):\n            return a+b+c\n\n        self._eq(ka, 3, (1, 1, 1), _key=2)\n        self._eq(ka, 6, (2, 2, 2), _key=2)\n\n        self._eq(ka, 3, (0, 0, 1), _key=2)\n        self._eq(ka, 3, (9, 9, 1), _key=2)\n        self._eq(ka, 6, (0, 0, 2), _key=2)\n        self._eq(ka, 6, (9, 9, 2), _key=2)\n\n    def test_keyarg_mem(self):\n        def ka(a, b, c):\n            return a+b+c\n\n        self._eq(ka, 3, (1, 1, 1), _key=2, _exp=10)\n        self._eq(ka, 6, (2, 2, 2), _key=2, _exp=10)\n\n        self._eq(ka, 3, (0, 0, 1), _key=2, _exp=10)\n        self._eq(ka, 3, (9, 9, 1), _key=2, _exp=10)\n        self._eq(ka, 6, (0, 0, 2), _key=2, _exp=10)\n        self._eq(ka, 6, (9, 9, 2), _key=2, _exp=10)\n\n    def test_keyarg_db(self):\n        def ka(a, b, c):\n            return a+b+c\n\n        self._eq(ka, 3, (1, 1, 1), _key=2, _exp=10, _mem=False)\n        self._eq(ka, 6, (2, 2, 2), _key=2, _exp=10, _mem=False)\n\n        self._eq(ka, 3, (0, 0, 1), _key=2, _exp=10, _mem=False)\n        self._eq(ka, 3, (9, 9, 1), _key=2, _exp=10, _mem=False)\n        self._eq(ka, 6, (0, 0, 2), _key=2, _exp=10, _mem=False)\n        self._eq(ka, 6, (9, 9, 2), _key=2, _exp=10, _mem=False)\n\n    def test_expires_mem(self):\n        def ex(a, b, c):\n            return a+b+c\n\n        with patch(\"time.time\") as tmock:\n            tmock.return_value = 8.001\n            self._eq(ex, 3, (1, 1, 1), _key=None, _exp=2)\n            self._eq(ex, 3, (2, 2, 2), _key=None, _exp=2)\n            self._eq(ex, 3, (3, 3, 3), _key=None, _exp=2)\n\n            # value is still cached after 1 second\n            tmock.return_value += 1.0\n            self._eq(ex, 3, (3, 3, 3), _key=None, _exp=2)\n            self._eq(ex, 3, (2, 2, 2), _key=None, _exp=2)\n            self._eq(ex, 3, (1, 1, 1), _key=None, _exp=2)\n\n            # new value after '_exp' seconds\n            tmock.return_value += 1.0\n            self._eq(ex, 9, (3, 3, 3), _key=None, _exp=2)\n            self._eq(ex, 9, (2, 2, 2), _key=None, _exp=2)\n            self._eq(ex, 9, (1, 1, 1), _key=None, _exp=2)\n\n    def test_expires_db(self):\n        def ex(a, b, c):\n            return a+b+c\n\n        self.cache_update(ex, None)  # delete old db entry\n        with patch(\"time.time\") as tmock:\n            tmock.return_value = 8.999\n            self._eq(ex, 3, (1, 1, 1), _key=None, _exp=2, _mem=False)\n            self._eq(ex, 3, (2, 2, 2), _key=None, _exp=2, _mem=False)\n            self._eq(ex, 3, (3, 3, 3), _key=None, _exp=2, _mem=False)\n\n            # value is still cached after 1 second\n            tmock.return_value += 1.0\n            self._eq(ex, 3, (3, 3, 3), _key=None, _exp=2, _mem=False)\n            self._eq(ex, 3, (2, 2, 2), _key=None, _exp=2, _mem=False)\n            self._eq(ex, 3, (1, 1, 1), _key=None, _exp=2, _mem=False)\n\n            # new value after '_exp' seconds\n            tmock.return_value += 1.0\n            self._eq(ex, 9, (3, 3, 3), _key=None, _exp=2, _mem=False)\n            self._eq(ex, 9, (2, 2, 2), _key=None, _exp=2, _mem=False)\n            self._eq(ex, 9, (1, 1, 1), _key=None, _exp=2, _mem=False)\n\n    def test_update_mem_simple(self):\n        def up(a, b, c):\n            return a+b+c\n\n        self._eq(up, 3, (1, 1, 1))\n        self.cache_update(up, 1, 0)\n        self.cache_update(up, 2, 9)\n        self._eq(up, 0, (1, 0, 0))\n        self._eq(up, 9, (2, 0, 0))\n\n    def test_update_mem(self):\n        def up(a, b, c):\n            return a+b+c\n\n        self._eq(up, 3, (1, 1, 1), _exp=10)\n        self.cache_update(up, 1, 0, _exp=10)\n        self.cache_update(up, 2, 9, _exp=10)\n        self._eq(up, 0, (1, 0, 0), _exp=10)\n        self._eq(up, 9, (2, 0, 0), _exp=10)\n\n    def test_update_db(self):\n        def up(a, b, c):\n            return a+b+c\n\n        self.cache_update(up, 1, None)  # delete old db entry\n        self._eq(up, 3, (1, 1, 1), _exp=10, _mem=False)\n\n        self.cache_update(up, 1, 0, _exp=10)\n        self.cache_update(up, 2, 9, _exp=10)\n        self._eq(up, 0, (1, 0, 0), _exp=10, _mem=False)\n        self._eq(up, 9, (2, 0, 0), _exp=10, _mem=False)\n\n    def test_invalidate_mem_simple(self):\n        def inv(a, b, c):\n            return a+b+c\n\n        self._eq(inv, 3, (1, 1, 1))\n        self.cache_update(inv, 1, None, _mem=True)\n        self.cache_update(inv, 2, None, _mem=True)\n        self._eq(inv, 1, (1, 0, 0))\n        self._eq(inv, 2, (2, 0, 0))\n\n    def test_invalidate_mem(self):\n        def inv(a, b, c):\n            return a+b+c\n\n        self._eq(inv, 3, (1, 1, 1), _exp=10)\n        self.cache_update(inv, 1, None, _mem=True)\n        self.cache_update(inv, 2, None, _mem=True)\n        self._eq(inv, 1, (1, 0, 0), _exp=10)\n        self._eq(inv, 2, (2, 0, 0), _exp=10)\n\n    def test_invalidate_db(self):\n        def inv(a, b, c):\n            return a+b+c\n\n        self.cache_update(inv, 1, None)  # delete old db entry\n        self._eq(inv, 3, (1, 1, 1), _exp=10, _mem=False)\n\n        self.cache_update(inv, 1, None)\n        self.cache_update(inv, 2, None)\n        self._eq(inv, 1, (1, 0, 0), _exp=10, _mem=False)\n        self._eq(inv, 2, (2, 0, 0), _exp=10, _mem=False)\n\n    def test_database_read(self):\n        def db(a, b, c):\n            return a+b+c\n        db.__module__ = \"test\"\n\n        # delete old db entries\n        self.cache_update(db, 1, None)\n        self.cache_update(db, 2, None)\n\n        # initialize cache\n        self._eq(db, 3, (1, 1, 1), _mem=False)\n        self.cache_update(db, 2, 6)\n\n        # check and clear the in-memory portion of said cache\n        self.assertEqual(CACHE[\"test.db-1\"], (3, 0))\n        self.assertEqual(CACHE[\"test.db-1\"], (3, 0))\n        CACHE.clear()\n        self.assertEqual(CACHE, {})\n\n        # fetch results from database\n        self._eq(db, 3, (1, 0, 0), _mem=False)\n        self._eq(db, 6, (2, 0, 0), _mem=False)\n\n        # check in-memory cache updates\n        self.assertEqual(CACHE[\"test.db-1\"], (3, 0))\n        self.assertEqual(CACHE[\"test.db-2\"], (6, 0))\n\n\nif __name__ == \"__main__\":\n    unittest.main()\n"
  },
  {
    "path": "test/test_config.py",
    "content": "#!/usr/bin/env python3\n# -*- coding: utf-8 -*-\n\n# Copyright 2015-2025 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nimport os\nimport sys\nimport unittest\n\nimport tempfile\n\nROOTDIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))\nsys.path.insert(0, ROOTDIR)\nfrom gallery_dl import config, util  # noqa E402\n\n\nclass TestConfig(unittest.TestCase):\n\n    def setUp(self):\n        config.set(()        , \"a\", 1)\n        config.set((\"b\",)    , \"a\", 2)\n        config.set((\"b\", \"b\"), \"a\", 3)\n        config.set((\"b\",)    , \"c\", \"text\")\n        config.set((\"b\", \"b\"), \"c\", [8, 9])\n\n    def tearDown(self):\n        config.clear()\n\n    def test_get(self):\n        self.assertEqual(config.get(()        , \"a\")   , 1)\n        self.assertEqual(config.get((\"b\",)    , \"a\")   , 2)\n        self.assertEqual(config.get((\"b\", \"b\"), \"a\")   , 3)\n\n        self.assertEqual(config.get(()        , \"c\")   , None)\n        self.assertEqual(config.get((\"b\",)    , \"c\")   , \"text\")\n        self.assertEqual(config.get((\"b\", \"b\"), \"c\")   , [8, 9])\n\n        self.assertEqual(config.get((\"a\",)    , \"g\")   , None)\n        self.assertEqual(config.get((\"a\", \"a\"), \"g\")   , None)\n        self.assertEqual(config.get((\"e\", \"f\"), \"g\")   , None)\n        self.assertEqual(config.get((\"e\", \"f\"), \"g\", 4), 4)\n\n    def test_interpolate(self):\n        self.assertEqual(config.interpolate(()        , \"a\"), 1)\n        self.assertEqual(config.interpolate((\"b\",)    , \"a\"), 1)\n        self.assertEqual(config.interpolate((\"b\", \"b\"), \"a\"), 1)\n\n        self.assertEqual(config.interpolate(()        , \"c\"), None)\n        self.assertEqual(config.interpolate((\"b\",)    , \"c\"), \"text\")\n        self.assertEqual(config.interpolate((\"b\", \"b\"), \"c\"), [8, 9])\n\n        self.assertEqual(config.interpolate((\"a\",)    , \"g\")   , None)\n        self.assertEqual(config.interpolate((\"a\", \"a\"), \"g\")   , None)\n        self.assertEqual(config.interpolate((\"e\", \"f\"), \"g\")   , None)\n        self.assertEqual(config.interpolate((\"e\", \"f\"), \"g\", 4), 4)\n\n        self.assertEqual(config.interpolate((\"b\",), \"d\", 1) , 1)\n        self.assertEqual(config.interpolate((\"d\",), \"d\", 1) , 1)\n        config.set(()    , \"d\", 2)\n        self.assertEqual(config.interpolate((\"b\",), \"d\", 1) , 2)\n        self.assertEqual(config.interpolate((\"d\",), \"d\", 1) , 2)\n        config.set((\"b\",), \"d\", 3)\n        self.assertEqual(config.interpolate((\"b\",), \"d\", 1) , 2)\n        self.assertEqual(config.interpolate((\"d\",), \"d\", 1) , 2)\n\n    def test_interpolate_common(self):\n\n        def lookup():\n            return config.interpolate_common(\n                (\"Z1\", \"Z2\"), (\n                    (\"A1\", \"A2\"),\n                    (\"B1\",),\n                    (\"C1\", \"C2\", \"C3\"),\n                ), \"KEY\", \"DEFAULT\",\n            )\n\n        def test(path, value, expected=None):\n            config.set(path, \"KEY\", value)\n            self.assertEqual(lookup(), expected or value)\n\n        self.assertEqual(lookup(), \"DEFAULT\")\n        test((\"Z1\",), 1)\n        test((\"Z1\", \"Z2\"), 2)\n        test((\"Z1\", \"Z2\", \"C1\"), 3)\n        test((\"Z1\", \"Z2\", \"C1\", \"C2\"), 4)\n        test((\"Z1\", \"Z2\", \"C1\", \"C2\", \"C3\"), 5)\n        test((\"Z1\", \"Z2\", \"B1\"), 6)\n        test((\"Z1\", \"Z2\", \"A1\"), 7)\n        test((\"Z1\", \"Z2\", \"A1\", \"A2\"), 8)\n        test((\"Z1\", \"A1\", \"A2\"), 999, 8)\n        test((\"Z1\", \"Z2\", \"A1\", \"A2\", \"A3\"), 999, 8)\n        test((), 9)\n\n    def test_accumulate(self):\n        self.assertEqual(config.accumulate((), \"l\"), [])\n\n        config.set(()        , \"l\", [5, 6])\n        config.set((\"c\",)    , \"l\", [3, 4])\n        config.set((\"c\", \"c\"), \"l\", [1, 2])\n        self.assertEqual(\n            config.accumulate((), \"l\")        , [5, 6])\n        self.assertEqual(\n            config.accumulate((\"c\",), \"l\")    , [3, 4, 5, 6])\n        self.assertEqual(\n            config.accumulate((\"c\", \"c\"), \"l\"), [1, 2, 3, 4, 5, 6])\n\n        config.set((\"c\",), \"l\", None)\n        config.unset((\"c\", \"c\"), \"l\")\n        self.assertEqual(\n            config.accumulate((), \"l\")        , [5, 6])\n        self.assertEqual(\n            config.accumulate((\"c\",), \"l\")    , [5, 6])\n        self.assertEqual(\n            config.accumulate((\"c\", \"c\"), \"l\"), [5, 6])\n\n        config.set(()        , \"l\", 4)\n        config.set((\"c\",)    , \"l\", [2, 3])\n        config.set((\"c\", \"c\"), \"l\", 1)\n        self.assertEqual(\n            config.accumulate((), \"l\")        , [4])\n        self.assertEqual(\n            config.accumulate((\"c\",), \"l\")    , [2, 3, 4])\n        self.assertEqual(\n            config.accumulate((\"c\", \"c\"), \"l\"), [1, 2, 3, 4])\n\n        config.set((\"c\",), \"l\", None)\n        self.assertEqual(\n            config.accumulate((), \"l\")        , [4])\n        self.assertEqual(\n            config.accumulate((\"c\",), \"l\")    , [4])\n        self.assertEqual(\n            config.accumulate((\"c\", \"c\"), \"l\"), [1, 4])\n\n    def test_set(self):\n        config.set(()        , \"c\", [1, 2, 3])\n        config.set((\"b\",)    , \"c\", [1, 2, 3])\n        config.set((\"e\", \"f\"), \"g\", value=234)\n        self.assertEqual(config.get(()        , \"c\"), [1, 2, 3])\n        self.assertEqual(config.get((\"b\",)    , \"c\"), [1, 2, 3])\n        self.assertEqual(config.get((\"e\", \"f\"), \"g\"), 234)\n\n    def test_setdefault(self):\n        config.setdefault(()        , \"c\", [1, 2, 3])\n        config.setdefault((\"b\",)    , \"c\", [1, 2, 3])\n        config.setdefault((\"e\", \"f\"), \"g\", value=234)\n        self.assertEqual(config.get(()        , \"c\"), [1, 2, 3])\n        self.assertEqual(config.get((\"b\",)    , \"c\"), \"text\")\n        self.assertEqual(config.get((\"e\", \"f\"), \"g\"), 234)\n\n    def test_unset(self):\n        config.unset(()    , \"a\")\n        config.unset((\"b\",), \"c\")\n        config.unset((\"a\",), \"d\")\n        config.unset((\"b\",), \"d\")\n        config.unset((\"c\",), \"d\")\n        self.assertEqual(config.get(()    , \"a\"), None)\n        self.assertEqual(config.get((\"b\",), \"a\"), 2)\n        self.assertEqual(config.get((\"b\",), \"c\"), None)\n        self.assertEqual(config.get((\"a\",), \"d\"), None)\n        self.assertEqual(config.get((\"b\",), \"d\"), None)\n        self.assertEqual(config.get((\"c\",), \"d\"), None)\n\n    def test_apply(self):\n        options = (\n            ((\"b\",)    , \"c\", [1, 2, 3]),\n            ((\"e\", \"f\"), \"g\", 234),\n            ((\"e\", \"f\"), \"g\", 234),\n        )\n\n        self.assertEqual(config.get((\"b\",)    , \"c\"), \"text\")\n        self.assertEqual(config.get((\"e\", \"f\"), \"g\"), None)\n\n        with config.apply(options):\n            self.assertEqual(config.get((\"b\",)    , \"c\"), [1, 2, 3])\n            self.assertEqual(config.get((\"e\", \"f\"), \"g\"), 234)\n\n        self.assertEqual(config.get((\"b\",)    , \"c\"), \"text\")\n        self.assertEqual(config.get((\"e\", \"f\"), \"g\"), None)\n\n    def test_load(self):\n        with tempfile.TemporaryDirectory() as base:\n            path1 = os.path.join(base, \"cfg1\")\n            with open(path1, \"w\") as fp:\n                fp.write('{\"a\": 1, \"b\": {\"a\": 2, \"c\": \"text\"}}')\n\n            path2 = os.path.join(base, \"cfg2\")\n            with open(path2, \"w\") as fp:\n                fp.write('{\"a\": 7, \"b\": {\"a\": 8, \"e\": \"foo\"}}')\n\n            config.clear()\n            config.load((path1,))\n            self.assertEqual(config.get(()    , \"a\"), 1)\n            self.assertEqual(config.get((\"b\",), \"a\"), 2)\n            self.assertEqual(config.get((\"b\",), \"c\"), \"text\")\n\n            config.load((path2,))\n            self.assertEqual(config.get(()    , \"a\"), 7)\n            self.assertEqual(config.get((\"b\",), \"a\"), 8)\n            self.assertEqual(config.get((\"b\",), \"c\"), \"text\")\n            self.assertEqual(config.get((\"b\",), \"e\"), \"foo\")\n\n            config.clear()\n            config.load((path1, path2))\n            self.assertEqual(config.get(()    , \"a\"), 7)\n            self.assertEqual(config.get((\"b\",), \"a\"), 8)\n            self.assertEqual(config.get((\"b\",), \"c\"), \"text\")\n            self.assertEqual(config.get((\"b\",), \"e\"), \"foo\")\n\n\nclass TestConfigFiles(unittest.TestCase):\n\n    def test_default_config(self):\n        cfg = self._load(\"gallery-dl.conf\")\n        self.assertIsInstance(cfg, dict)\n        self.assertTrue(cfg)\n\n    def test_example_config(self):\n        cfg = self._load(\"gallery-dl-example.conf\")\n        self.assertIsInstance(cfg, dict)\n        self.assertTrue(cfg)\n\n    def _load(self, name):\n        path = os.path.join(ROOTDIR, \"docs\", name)\n        try:\n            with open(path) as fp:\n                return util.json_loads(fp.read())\n        except FileNotFoundError:\n            raise unittest.SkipTest(f\"{path} not available\")\n\n\nif __name__ == \"__main__\":\n    unittest.main()\n"
  },
  {
    "path": "test/test_cookies.py",
    "content": "#!/usr/bin/env python3\n# -*- coding: utf-8 -*-\n\n# Copyright 2017-2025 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nimport os\nimport sys\nimport unittest\nfrom unittest import mock\n\nimport time\nimport logging\nimport datetime\nimport tempfile\nfrom os.path import join\n\nsys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))\nfrom gallery_dl import config, extractor  # noqa E402\n\n\nclass TestCookiejar(unittest.TestCase):\n\n    @classmethod\n    def setUpClass(cls):\n        cls.path = tempfile.TemporaryDirectory()\n\n        cls.cookiefile = join(cls.path.name, \"cookies.txt\")\n        with open(cls.cookiefile, \"w\") as fp:\n            fp.write(\"\"\"# HTTP Cookie File\n.example.org\\tTRUE\\t/\\tFALSE\\t253402210800\\tNAME\\tVALUE\n\"\"\")\n\n        cls.invalid_cookiefile = join(cls.path.name, \"invalid.txt\")\n        with open(cls.invalid_cookiefile, \"w\") as fp:\n            fp.write(\"\"\"# asd\n.example.org\\tTRUE/FALSE\\t253402210800\\tNAME\\tVALUE\n\"\"\")\n\n    @classmethod\n    def tearDownClass(cls):\n        cls.path.cleanup()\n        config.clear()\n\n    def test_cookiefile(self):\n        config.set((), \"cookies\", self.cookiefile)\n        cookies = _get_extractor(\"test\").cookies\n        self.assertEqual(len(cookies), 1)\n\n        cookie = next(iter(cookies))\n        self.assertEqual(cookie.domain, \".example.org\")\n        self.assertEqual(cookie.path  , \"/\")\n        self.assertEqual(cookie.name  , \"NAME\")\n        self.assertEqual(cookie.value , \"VALUE\")\n\n    def test_invalid_cookiefile(self):\n        self._test_warning(self.invalid_cookiefile, ValueError)\n\n    def test_invalid_filename(self):\n        self._test_warning(join(self.path.name, \"nothing\"), FileNotFoundError)\n\n    def _test_warning(self, filename, exc):\n        config.set((), \"cookies\", filename)\n        log = logging.getLogger(\"generic\")\n\n        with mock.patch.object(log, \"warning\") as mock_warning:\n            cookies = _get_extractor(\"test\").cookies\n\n        self.assertEqual(len(cookies), 0)\n        self.assertEqual(mock_warning.call_count, 1)\n        self.assertIsInstance(mock_warning.call_args[0][-1], exc)\n\n\nclass TestCookiedict(unittest.TestCase):\n\n    def setUp(self):\n        self.cdict = {\"NAME1\": \"VALUE1\", \"NAME2\": \"VALUE2\"}\n        config.set((), \"cookies\", self.cdict)\n\n    def tearDown(self):\n        config.clear()\n\n    def test_dict(self):\n        cookies = _get_extractor(\"test\").cookies\n\n        self.assertEqual(len(cookies), len(self.cdict))\n        self.assertEqual(sorted(cookies.keys()), sorted(self.cdict.keys()))\n        self.assertEqual(sorted(cookies.values()), sorted(self.cdict.values()))\n\n    def test_domain(self):\n        for category in [\"exhentai\", \"nijie\", \"horne\"]:\n            extr = _get_extractor(category)\n            cookies = extr.cookies\n            for key in self.cdict:\n                self.assertTrue(key in cookies)\n            for c in cookies:\n                self.assertEqual(c.domain, extr.cookies_domain)\n\n\nclass TestCookieLogin(unittest.TestCase):\n\n    def tearDown(self):\n        config.clear()\n\n    def test_cookie_login(self):\n        extr_cookies = {\n            \"exhentai\"   : (\"ipb_member_id\", \"ipb_pass_hash\"),\n            \"nijie\"      : (\"nijie_tok\",),\n            \"horne\"      : (\"horne_tok\",),\n        }\n        for category, cookienames in extr_cookies.items():\n            cookies = {name: \"value\" for name in cookienames}\n            config.set((), \"cookies\", cookies)\n            extr = _get_extractor(category)\n            with mock.patch.object(extr, \"_login_impl\") as mock_login:\n                extr.login()\n                mock_login.assert_not_called()\n\n\nclass TestCookieUtils(unittest.TestCase):\n\n    def test_check_cookies(self):\n        extr = _get_extractor(\"test\")\n        self.assertFalse(extr.cookies, \"empty\")\n        self.assertFalse(extr.cookies_domain, \"empty\")\n\n        # always returns False when checking for empty cookie list\n        self.assertFalse(extr.cookies_check(()))\n\n        self.assertFalse(extr.cookies_check((\"a\",)))\n        self.assertFalse(extr.cookies_check((\"a\", \"b\")))\n        self.assertFalse(extr.cookies_check((\"a\", \"b\", \"c\")))\n\n        extr.cookies.set(\"a\", \"1\")\n        self.assertTrue(extr.cookies_check((\"a\",)))\n        self.assertFalse(extr.cookies_check((\"a\", \"b\")))\n        self.assertFalse(extr.cookies_check((\"a\", \"b\", \"c\")))\n\n        extr.cookies.set(\"b\", \"2\")\n        self.assertTrue(extr.cookies_check((\"a\",)))\n        self.assertTrue(extr.cookies_check((\"a\", \"b\")))\n        self.assertFalse(extr.cookies_check((\"a\", \"b\", \"c\")))\n\n    def test_check_cookies_domain(self):\n        extr = _get_extractor(\"test\")\n        self.assertFalse(extr.cookies, \"empty\")\n        extr.cookies_domain = \".example.org\"\n\n        self.assertFalse(extr.cookies_check((\"a\",)))\n        self.assertFalse(extr.cookies_check((\"a\", \"b\")))\n\n        extr.cookies.set(\"nd_a\", \"1\")\n        self.assertFalse(extr.cookies_check((\"nd_a\",)))\n\n        extr.cookies.set(\"cd_a\", \"1\", domain=extr.cookies_domain)\n        self.assertTrue(extr.cookies_check((\"cd_a\",)))\n\n        extr.cookies.set(\"wd_a\", \"1\", domain=f\"www{extr.cookies_domain}\")\n        self.assertFalse(extr.cookies_check((\"wd_a\",)))\n        self.assertEqual(len(extr.cookies), 3)\n\n        extr.cookies.set(\"cd_b\", \"2\", domain=extr.cookies_domain)\n        extr.cookies.set(\"cd_c\", \"3\", domain=extr.cookies_domain)\n        self.assertFalse(extr.cookies_check((\"nd_a\", \"cd_b\", \"cd_c\")))\n        self.assertTrue(extr.cookies_check((\"cd_a\", \"cd_b\", \"cd_c\")))\n        self.assertFalse(extr.cookies_check((\"wd_a\", \"cd_b\", \"cd_c\")))\n        self.assertEqual(len(extr.cookies), 5)\n\n    def test_check_cookies_domain_sub(self):\n        extr = _get_extractor(\"test\")\n        self.assertFalse(extr.cookies, \"empty\")\n        extr.cookies_domain = \".example.org\"\n\n        self.assertFalse(extr.cookies_check((\"a\",), subdomains=True))\n        self.assertFalse(extr.cookies_check((\"a\", \"b\"), subdomains=True))\n\n        extr.cookies.set(\"nd_a\", \"1\")\n        self.assertFalse(extr.cookies_check((\"nd_a\",), subdomains=True))\n\n        extr.cookies.set(\"cd_a\", \"1\", domain=extr.cookies_domain)\n        self.assertTrue(extr.cookies_check((\"cd_a\",), subdomains=True))\n\n        extr.cookies.set(\"wd_a\", \"1\", domain=f\"www{extr.cookies_domain}\")\n        self.assertTrue(extr.cookies_check((\"wd_a\",), subdomains=True))\n\n        extr.cookies.set(\"cd_b\", \"2\", domain=extr.cookies_domain)\n        extr.cookies.set(\"cd_c\", \"3\", domain=extr.cookies_domain)\n        self.assertEqual(len(extr.cookies), 5)\n        self.assertFalse(extr.cookies_check(\n            (\"nd_a\", \"cd_b\", \"cd_c\"), subdomains=True))\n        self.assertTrue(extr.cookies_check(\n            (\"cd_a\", \"cd_b\", \"cd_c\"), subdomains=True))\n        self.assertTrue(extr.cookies_check(\n            (\"wd_a\", \"cd_b\", \"cd_c\"), subdomains=True))\n\n    def test_check_cookies_expires(self):\n        extr = _get_extractor(\"test\")\n        self.assertFalse(extr.cookies, \"empty\")\n        self.assertFalse(extr.cookies_domain, \"empty\")\n\n        now = int(time.time())\n        log = logging.getLogger(\"generic\")\n\n        extr.cookies.set(\"a\", \"1\", expires=now-100, domain=\".example.org\")\n        with mock.patch.object(log, \"warning\") as mw:\n            self.assertFalse(extr.cookies_check((\"a\",)))\n            self.assertEqual(mw.call_count, 1)\n            self.assertEqual(mw.call_args[0], (\n                \"cookies: %s/%s expired at %s\", \"example.org\", \"a\",\n                datetime.datetime.fromtimestamp(now-100)))\n\n        extr.cookies.set(\"a\", \"1\", expires=now+100, domain=\".example.org\")\n        with mock.patch.object(log, \"warning\") as mw:\n            self.assertTrue(extr.cookies_check((\"a\",)))\n            self.assertEqual(mw.call_count, 1)\n            self.assertEqual(mw.call_args[0], (\n                \"cookies: %s/%s will expire in less than %s hour%s\",\n                \"example.org\", \"a\", 1, \"\"))\n\n        extr.cookies.set(\"a\", \"1\", expires=now+100+7200, domain=\".example.org\")\n        with mock.patch.object(log, \"warning\") as mw:\n            self.assertTrue(extr.cookies_check((\"a\",)))\n            self.assertEqual(mw.call_count, 1)\n            self.assertEqual(mw.call_args[0], (\n                \"cookies: %s/%s will expire in less than %s hour%s\",\n                \"example.org\", \"a\", 3, \"s\"))\n\n        extr.cookies.set(\n            \"a\", \"1\", expires=now+100+24*3600, domain=\".example.org\")\n        with mock.patch.object(log, \"warning\") as mw:\n            self.assertTrue(extr.cookies_check((\"a\",)))\n            self.assertEqual(mw.call_count, 0)\n\n\ndef _get_extractor(category):\n    extr = extractor.find(URLS[category])\n    extr.initialize()\n    return extr\n\n\nURLS = {\n    \"exhentai\"   : \"https://exhentai.org/g/1200119/d55c44d3d0/\",\n    \"nijie\"      : \"https://nijie.info/view.php?id=1\",\n    \"horne\"      : \"https://horne.red/view.php?id=1\",\n    \"test\"       : \"generic:https://example.org/\",\n}\n\n\nif __name__ == \"__main__\":\n    unittest.main()\n"
  },
  {
    "path": "test/test_downloader.py",
    "content": "#!/usr/bin/env python3\n# -*- coding: utf-8 -*-\n\n# Copyright 2018-2025 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nimport os\nimport sys\nimport unittest\nfrom unittest.mock import Mock, MagicMock, patch\n\nimport re\nimport logging\nimport os.path\nimport binascii\nimport tempfile\nimport threading\nimport http.server\n\nsys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))\nfrom gallery_dl import downloader, extractor, output, config, path  # noqa E402\nfrom gallery_dl.downloader.http import MIME_TYPES, SIGNATURE_CHECKS # noqa E402\n\n\nclass MockDownloaderModule(Mock):\n    __downloader__ = \"mock\"\n\n\nclass FakeJob():\n\n    def __init__(self):\n        self.extractor = extractor.find(\"generic:https://example.org/\")\n        self.extractor.initialize()\n        self.pathfmt = path.PathFormat(self.extractor)\n        self.out = output.NullOutput()\n        self.get_logger = logging.getLogger\n\n\nclass TestDownloaderModule(unittest.TestCase):\n\n    @classmethod\n    def setUpClass(cls):\n        # allow import of ytdl downloader module without youtube_dl installed\n        cls._orig_ytdl = sys.modules.get(\"youtube_dl\")\n        sys.modules[\"youtube_dl\"] = MagicMock()\n\n    @classmethod\n    def tearDownClass(cls):\n        if cls._orig_ytdl:\n            sys.modules[\"youtube_dl\"] = cls._orig_ytdl\n        else:\n            del sys.modules[\"youtube_dl\"]\n\n    def setUp(self):\n        downloader._cache.clear()\n\n    def tearDown(self):\n        downloader._cache.clear()\n\n    def test_find(self):\n        cls = downloader.find(\"http\")\n        self.assertEqual(cls.__name__, \"HttpDownloader\")\n        self.assertEqual(cls.scheme  , \"http\")\n\n        cls = downloader.find(\"https\")\n        self.assertEqual(cls.__name__, \"HttpDownloader\")\n        self.assertEqual(cls.scheme  , \"http\")\n\n        cls = downloader.find(\"text\")\n        self.assertEqual(cls.__name__, \"TextDownloader\")\n        self.assertEqual(cls.scheme  , \"text\")\n\n        cls = downloader.find(\"ytdl\")\n        self.assertEqual(cls.__name__, \"YoutubeDLDownloader\")\n        self.assertEqual(cls.scheme  , \"ytdl\")\n\n        self.assertEqual(downloader.find(\"ftp\"), None)\n        self.assertEqual(downloader.find(\"foo\"), None)\n        self.assertEqual(downloader.find(1234) , None)\n        self.assertEqual(downloader.find(None) , None)\n\n    @patch(\"builtins.__import__\")\n    def test_cache(self, import_module):\n        import_module.return_value = MockDownloaderModule()\n        downloader.find(\"http\")\n        downloader.find(\"text\")\n        downloader.find(\"ytdl\")\n        self.assertEqual(import_module.call_count, 3)\n        downloader.find(\"http\")\n        downloader.find(\"text\")\n        downloader.find(\"ytdl\")\n        self.assertEqual(import_module.call_count, 3)\n\n    @patch(\"builtins.__import__\")\n    def test_cache_http(self, import_module):\n        import_module.return_value = MockDownloaderModule()\n        downloader.find(\"http\")\n        downloader.find(\"https\")\n        self.assertEqual(import_module.call_count, 1)\n\n    @patch(\"builtins.__import__\")\n    def test_cache_https(self, import_module):\n        import_module.return_value = MockDownloaderModule()\n        downloader.find(\"https\")\n        downloader.find(\"http\")\n        self.assertEqual(import_module.call_count, 1)\n\n\nclass TestDownloaderConfig(unittest.TestCase):\n\n    def setUp(self):\n        config.clear()\n\n    def tearDown(self):\n        config.clear()\n\n    def test_default_http(self):\n        job = FakeJob()\n        extr = job.extractor\n        dl = downloader.find(\"http\")(job)\n\n        self.assertEqual(dl.adjust_extension, True)\n        self.assertEqual(dl.chunk_size, 32768)\n        self.assertEqual(dl.metadata, None)\n        self.assertEqual(dl.progress, 3.0)\n        self.assertEqual(dl.validate, True)\n        self.assertEqual(dl.headers, None)\n        self.assertEqual(dl.minsize, None)\n        self.assertEqual(dl.maxsize, None)\n        self.assertEqual(dl.mtime, True)\n        self.assertEqual(dl.rate, None)\n        self.assertEqual(dl.part, True)\n        self.assertEqual(dl.partdir, None)\n\n        self.assertIs(dl.interval_429, extr._interval_429)\n        self.assertIs(dl.retry_codes, extr._retry_codes)\n        self.assertIs(dl.retries, extr._retries)\n        self.assertIs(dl.timeout, extr._timeout)\n        self.assertIs(dl.proxies, extr._proxies)\n        self.assertIs(dl.verify, extr._verify)\n\n    def test_config_http(self):\n        config.set((), \"rate\", 42)\n        config.set((), \"mtime\", False)\n        config.set((), \"headers\", {\"foo\": \"bar\"})\n        config.set((\"downloader\",), \"retries\", -1)\n        config.set((\"downloader\", \"http\"), \"filesize-min\", \"10k\")\n        config.set((\"extractor\", \"generic\"), \"verify\", False)\n        config.set((\"extractor\", \"generic\", \"example.org\"), \"timeout\", 10)\n        config.set((\"extractor\", \"generic\", \"http\"), \"part\", False)\n        config.set(\n            (\"extractor\", \"generic\", \"example.org\", \"http\"), \"headers\", {})\n\n        job = FakeJob()\n        dl = downloader.find(\"http\")(job)\n\n        self.assertEqual(dl.headers, {\"foo\": \"bar\"})\n        self.assertEqual(dl.minsize, 10240)\n        self.assertEqual(dl.retries, float(\"inf\"))\n        self.assertEqual(dl.timeout, 10)\n        self.assertEqual(dl.verify, False)\n        self.assertEqual(dl.mtime, False)\n        self.assertEqual(dl.rate(), 42)\n        self.assertEqual(dl.part, False)\n\n\nclass TestDownloaderBase(unittest.TestCase):\n\n    @classmethod\n    def setUpClass(cls):\n        cls.dir = tempfile.TemporaryDirectory()\n        cls.fnum = 0\n        config.set((), \"base-directory\", cls.dir.name)\n        cls.job = FakeJob()\n\n    @classmethod\n    def tearDownClass(cls):\n        cls.dir.cleanup()\n        config.clear()\n\n    @classmethod\n    def _prepare_destination(cls, content=None, part=True, extension=None):\n        name = f\"file-{cls.fnum}\"\n        cls.fnum += 1\n\n        kwdict = {\n            \"category\"   : \"test\",\n            \"subcategory\": \"test\",\n            \"filename\"   : name,\n            \"extension\"  : extension,\n        }\n\n        pathfmt = cls.job.pathfmt\n        pathfmt.set_directory(kwdict)\n        pathfmt.set_filename(kwdict)\n        pathfmt.build_path()\n\n        if content:\n            mode = \"wb\" if isinstance(content, bytes) else \"w\"\n            with pathfmt.open(mode) as fp:\n                fp.write(content)\n\n        return pathfmt\n\n    def _run_test(self, url, input, output,\n                  extension, expected_extension=None):\n        pathfmt = self._prepare_destination(input, extension=extension)\n        success = self.downloader.download(url, pathfmt)\n\n        # test successful download\n        self.assertTrue(success, f\"downloading '{url}' failed\")\n\n        # test content\n        mode = \"rb\" if isinstance(output, bytes) else \"r\"\n        with pathfmt.open(mode) as fp:\n            content = fp.read()\n        self.assertEqual(content, output)\n\n        # test filename extension\n        self.assertEqual(\n            pathfmt.extension,\n            expected_extension,\n            content[0:16],\n        )\n        self.assertEqual(\n            os.path.splitext(pathfmt.realpath)[1][1:],\n            expected_extension,\n        )\n\n\nclass TestHTTPDownloader(TestDownloaderBase):\n\n    @classmethod\n    def setUpClass(cls):\n        TestDownloaderBase.setUpClass()\n        cls.downloader = downloader.find(\"http\")(cls.job)\n\n        host = \"127.0.0.1\"\n        port = 0  # select random not-in-use port\n\n        try:\n            server = http.server.HTTPServer((host, port), HttpRequestHandler)\n        except OSError as exc:\n            raise unittest.SkipTest(\n                f\"cannot spawn local HTTP server ({exc})\")\n\n        host, port = server.server_address\n        cls.address = f\"http://{host}:{port}\"\n        threading.Thread(target=server.serve_forever, daemon=True).start()\n\n    def _run_test(self, ext, input, output,\n                  extension, expected_extension=None):\n        TestDownloaderBase._run_test(\n            self, f\"{self.address}/{ext}\", input, output,\n            extension, expected_extension)\n\n    def tearDown(self):\n        self.downloader.minsize = self.downloader.maxsize = None\n\n    def test_http_download(self):\n        self._run_test(\"jpg\", None, DATA[\"jpg\"], \"jpg\", \"jpg\")\n        self._run_test(\"png\", None, DATA[\"png\"], \"png\", \"png\")\n        self._run_test(\"gif\", None, DATA[\"gif\"], \"gif\", \"gif\")\n\n    def test_http_offset(self):\n        self._run_test(\"jpg\", DATA[\"jpg\"][:123], DATA[\"jpg\"], \"jpg\", \"jpg\")\n        self._run_test(\"png\", DATA[\"png\"][:12] , DATA[\"png\"], \"png\", \"png\")\n        self._run_test(\"gif\", DATA[\"gif\"][:1]  , DATA[\"gif\"], \"gif\", \"gif\")\n\n    def test_http_extension(self):\n        self._run_test(\"jpg\", None, DATA[\"jpg\"], None, \"jpg\")\n        self._run_test(\"png\", None, DATA[\"png\"], None, \"png\")\n        self._run_test(\"gif\", None, DATA[\"gif\"], None, \"gif\")\n\n    def test_http_adjust_extension(self):\n        self._run_test(\"jpg\", None, DATA[\"jpg\"], \"png\", \"jpg\")\n        self._run_test(\"png\", None, DATA[\"png\"], \"gif\", \"png\")\n        self._run_test(\"gif\", None, DATA[\"gif\"], \"jpg\", \"gif\")\n\n    def test_http_filesize_min(self):\n        url = f\"{self.address}/gif\"\n        pathfmt = self._prepare_destination(None, extension=None)\n        self.downloader.minsize = 100\n        with self.assertLogs(self.downloader.log, \"WARNING\"):\n            success = self.downloader.download(url, pathfmt)\n        self.assertTrue(success)\n        self.assertEqual(pathfmt.temppath, \"\")\n\n    def test_http_filesize_max(self):\n        url = f\"{self.address}/jpg\"\n        pathfmt = self._prepare_destination(None, extension=None)\n        self.downloader.maxsize = 100\n        with self.assertLogs(self.downloader.log, \"WARNING\"):\n            success = self.downloader.download(url, pathfmt)\n        self.assertTrue(success)\n        self.assertEqual(pathfmt.temppath, \"\")\n\n    def test_http_empty(self):\n        url = f\"{self.address}/~NUL\"\n        pathfmt = self._prepare_destination(None, extension=None)\n        with self.assertLogs(self.downloader.log, \"WARNING\") as log_info:\n            success = self.downloader.download(url, pathfmt)\n        self.assertFalse(success)\n        self.assertEqual(log_info.output[0],\n                         \"WARNING:downloader.http:Empty file\")\n\n\nclass TestTextDownloader(TestDownloaderBase):\n\n    @classmethod\n    def setUpClass(cls):\n        TestDownloaderBase.setUpClass()\n        cls.downloader = downloader.find(\"text\")(cls.job)\n\n    def test_text_download(self):\n        self._run_test(\"text:foobar\", None, \"foobar\", \"txt\", \"txt\")\n\n    def test_text_offset(self):\n        self._run_test(\"text:foobar\", \"foo\", \"foobar\", \"txt\", \"txt\")\n\n    def test_text_empty(self):\n        self._run_test(\"text:\", None, \"\", \"txt\", \"txt\")\n\n\nclass HttpRequestHandler(http.server.BaseHTTPRequestHandler):\n\n    def do_GET(self):\n        try:\n            output = DATA[self.path[1:]]\n        except KeyError:\n            self.send_response(404)\n            self.wfile.write(self.path.encode())\n            return\n\n        headers = {\"Content-Length\": len(output)}\n\n        if \"Range\" in self.headers:\n            status = 206\n\n            match = re.match(r\"bytes=(\\d+)-\", self.headers[\"Range\"])\n            start = int(match[1])\n\n            headers[\"Content-Range\"] = \\\n                f\"bytes {start}-{len(output) - 1}/{len(output)}\"\n            output = output[start:]\n        else:\n            status = 200\n\n        self.send_response(status)\n        for key, value in headers.items():\n            self.send_header(key, value)\n        self.end_headers()\n        self.wfile.write(output)\n\n\nSAMPLES = {\n    (\"jpg\" , binascii.a2b_base64(\n        \"/9j/4AAQSkZJRgABAQEASABIAAD/2wBDAAEBAQEBAQEBAQEBAQEBAQEBAQEBAQEB\"\n        \"AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/2wBDAQEB\"\n        \"AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEB\"\n        \"AQEBAQEBAQEBAQEBAQH/wAARCAABAAEDAREAAhEBAxEB/8QAFAABAAAAAAAAAAAA\"\n        \"AAAAAAAACv/EABQQAQAAAAAAAAAAAAAAAAAAAAD/xAAUAQEAAAAAAAAAAAAAAAAA\"\n        \"AAAA/8QAFBEBAAAAAAAAAAAAAAAAAAAAAP/aAAwDAQACEQMRAD8AfwD/2Q==\")),\n    (\"png\" , binascii.a2b_base64(\n        \"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAAAAAA6fptVAAAACklEQVQIHWP4DwAB\"\n        \"AQEANl9ngAAAAABJRU5ErkJggg==\")),\n    (\"gif\" , binascii.a2b_base64(\n        \"R0lGODdhAQABAIAAAP///////ywAAAAAAQABAAACAkQBADs=\")),\n    (\"bmp\" , b\"BM\"),\n    (\"webp\", b\"RIFF????WEBP\"),\n    (\"avif\", b\"????ftypavif\"),\n    (\"avif\", b\"????ftypavis\"),\n    (\"heic\", b\"????ftypheic\"),\n    (\"heic\", b\"????ftypheim\"),\n    (\"heic\", b\"????ftypheis\"),\n    (\"heic\", b\"????ftypheix\"),\n    (\"svg\" , b\"<?xml\"),\n    (\"html\", b\"<!DOCTYPE html><html>...</html>\"),\n    (\"html\", b\"  \\n  \\n\\r\\t\\n  <!DOCTYPE html><html>...</html>\"),\n    (\"ico\" , b\"\\x00\\x00\\x01\\x00\"),\n    (\"cur\" , b\"\\x00\\x00\\x02\\x00\"),\n    (\"psd\" , b\"8BPS\"),\n    (\"mp4\" , b\"????ftypmp4\"),\n    (\"mp4\" , b\"????ftypavc1\"),\n    (\"mp4\" , b\"????ftypiso3\"),\n    (\"m4v\" , b\"????ftypM4V\"),\n    (\"mov\" , b\"????ftypqt  \"),\n    (\"webm\", b\"\\x1A\\x45\\xDF\\xA3\"),\n    (\"ogg\" , b\"OggS\"),\n    (\"wav\" , b\"RIFF????WAVE\"),\n    (\"mp3\" , b\"ID3\"),\n    (\"mp3\" , b\"\\xFF\\xFB\"),\n    (\"mp3\" , b\"\\xFF\\xF3\"),\n    (\"mp3\" , b\"\\xFF\\xF2\"),\n    (\"aac\" , b\"\\xFF\\xF9\"),\n    (\"aac\" , b\"\\xFF\\xF1\"),\n    (\"m3u8\", b\"#EXTM3U\\n#EXT-X-STREAM-INF:PROGRAM-ID=1, BANDWIDTH=200000\"),\n    (\"mpd\" , b'<MPD xmlns=\"urn:mpeg:dash:schema:mpd:2011\"'),\n    (\"zip\" , b\"PK\\x03\\x04\"),\n    (\"zip\" , b\"PK\\x05\\x06\"),\n    (\"zip\" , b\"PK\\x07\\x08\"),\n    (\"rar\" , b\"Rar!\\x1A\\x07\"),\n    (\"rar\" , b\"\\x52\\x61\\x72\\x21\\x1A\\x07\"),\n    (\"7z\"  , b\"\\x37\\x7A\\xBC\\xAF\\x27\\x1C\"),\n    (\"pdf\" , b\"%PDF-\"),\n    (\"swf\" , b\"FWS\"),\n    (\"swf\" , b\"CWS\"),\n    (\"blend\", b\"BLENDER-v303RENDH\"),\n    (\"obj\" , b\"# Blender v3.2.0 OBJ File: 'foo.blend'\"),\n    (\"clip\", b\"CSFCHUNK\\x00\\x00\\x00\\x00\"),\n    (\"~NUL\", b\"\"),\n}\n\n\nDATA = {}\n\nfor ext, content in SAMPLES:\n    if ext not in DATA:\n        DATA[ext] = content\n\nfor idx, (_, content) in enumerate(SAMPLES):\n    DATA[f\"S{idx:>02}\"] = content\n\n\n# reverse mime types mapping\nMIME_TYPES = {\n    ext: mtype\n    for mtype, ext in MIME_TYPES.items()\n}\n\n\ndef generate_tests():\n    def generate_test(idx, ext, content):\n        def test(self):\n            self._run_test(f\"S{idx:>02}\", None, content, \"bin\", ext)\n        test.__name__ = f\"test_http_ext_{idx:>02}_{ext}\"\n        return test\n\n    for idx, (ext, content) in enumerate(SAMPLES):\n        if ext[0].isalnum():\n            test = generate_test(idx, ext, content)\n            setattr(TestHTTPDownloader, test.__name__, test)\n\n\ngenerate_tests()\nif __name__ == \"__main__\":\n    unittest.main()\n"
  },
  {
    "path": "test/test_dt.py",
    "content": "#!/usr/bin/env python3\n# -*- coding: utf-8 -*-\n\n# Copyright 2025 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nimport os\nimport sys\nimport unittest\n\nimport datetime\n\nsys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))\nfrom gallery_dl import dt # noqa E402\n\n\nclass TestDatetime(unittest.TestCase):\n\n    def test_convert(self, f=dt.convert):\n\n        def _assert(value, expected):\n            result = f(value)\n            self.assertIsInstance(result, datetime.datetime)\n            self.assertEqual(result, expected, msg=repr(value))\n\n        d = datetime.datetime(2010, 1, 1)\n        self.assertIs(f(d), d)\n\n        _assert(d             , d)\n        _assert(1262304000    , d)\n        _assert(1262304000.0  , d)\n        _assert(1262304000.123, d)\n        _assert(\"1262304000\"  , d)\n\n        _assert(\"2010-01-01\"                      , d)\n        _assert(\"2010-01-01 00:00:00\"             , d)\n        _assert(\"2010-01-01T00:00:00\"             , d)\n        _assert(\"2010-01-01T00:00:00.123456\"      , d)\n        _assert(\"2009-12-31T19:00:00-05:00\"       , d)\n        _assert(\"2009-12-31T19:00:00.123456-05:00\", d)\n        _assert(\"2010-01-01T00:00:00Z\"            , d)\n        _assert(\"2010-01-01T00:00:00.123456Z\"     , d)\n        _assert(\"2009-12-31T19:00:00-0500\"        , d)\n        _assert(\"2009-12-31T19:00:00.123456-0500\" , d)\n\n        _assert(0    , dt.NONE)\n        _assert(\"\"   , dt.NONE)\n        _assert(\"foo\", dt.NONE)\n        _assert(None , dt.NONE)\n        _assert(()   , dt.NONE)\n        _assert([]   , dt.NONE)\n        _assert({}   , dt.NONE)\n        _assert((1, 2, 3), dt.NONE)\n\n    @unittest.skipIf(sys.hexversion < 0x30b0000,\n                     \"extended fromisoformat timezones\")\n    def test_convert_tz(self, f=dt.convert):\n\n        def _assert(value, expected):\n            result = f(value)\n            self.assertIsInstance(result, datetime.datetime)\n            self.assertEqual(result, expected, msg=repr(value))\n\n        d = datetime.datetime(2010, 1, 1)\n        _assert(\"2009-12-31T19:00:00-05\"          , d)\n        _assert(\"2009-12-31T19:00:00.123456-05\"   , d)\n\n    def test_to_timestamp(self, f=dt.to_ts):\n        self.assertEqual(f(dt.EPOCH), 0.0)\n        self.assertEqual(f(datetime.datetime(2010, 1, 1)), 1262304000.0)\n        self.assertEqual(f(datetime.datetime(2010, 1, 1, 0, 0, 0, 128000)),\n                         1262304000.128000)\n        with self.assertRaises(TypeError):\n            f(None)\n\n    def test_to_timestamp_string(self, f=dt.to_ts_string):\n        self.assertEqual(f(dt.EPOCH), \"0\")\n        self.assertEqual(f(datetime.datetime(2010, 1, 1)), \"1262304000\")\n        self.assertEqual(f(None), \"\")\n\n    def test_from_timestamp(self, f=dt.from_ts):\n        self.assertEqual(f(0.0), dt.EPOCH)\n        self.assertEqual(f(1262304000.0), datetime.datetime(2010, 1, 1))\n        self.assertEqual(f(1262304000.128000).replace(microsecond=0),\n                         datetime.datetime(2010, 1, 1, 0, 0, 0))\n\n    def test_now(self, f=dt.now):\n        self.assertIsInstance(f(), datetime.datetime)\n\n    def test_parse_timestamp(self, f=dt.parse_ts):\n        null = dt.from_ts(0)\n        value = dt.from_ts(1555816235)\n\n        self.assertEqual(f(0)           , null)\n        self.assertEqual(f(\"0\")         , null)\n        self.assertEqual(f(1555816235)  , value)\n        self.assertEqual(f(\"1555816235\"), value)\n\n        for value in ((), [], {}, None, \"\"):\n            self.assertEqual(f(value), dt.NONE)\n            self.assertEqual(f(value, \"foo\"), \"foo\")\n\n    def test_parse(self, f=dt.parse):\n        self.assertEqual(\n            f(\"1970.01.01\", \"%Y.%m.%d\"),\n            dt.EPOCH,\n        )\n        self.assertEqual(\n            f(\"May 7, 2019 9:33 am\", \"%B %d, %Y %I:%M %p\"),\n            datetime.datetime(2019, 5, 7, 9, 33, 0),\n        )\n        self.assertEqual(\n            f(\"2019-05-07T21:25:02.753+0900\", \"%Y-%m-%dT%H:%M:%S.%f%z\"),\n            datetime.datetime(2019, 5, 7, 12, 25, 2),\n        )\n\n        for value in ((), [], {}, None, 1, 2.3):\n            self.assertEqual(f(value, \"%Y\"), dt.NONE)\n\n    def test_parse_iso(self, f=dt.parse_iso):\n        self.assertEqual(\n            f(\"1970-01-01T00:00:00+00:00\"),\n            dt.from_ts(0),\n        )\n        self.assertEqual(\n            f(\"2019-05-07T21:25:02+09:00\"),\n            datetime.datetime(2019, 5, 7, 12, 25, 2),\n        )\n        self.assertEqual(\n            f(\"2019-05-07T12:25:02Z\"),\n            datetime.datetime(2019, 5, 7, 12, 25, 2),\n        )\n        self.assertEqual(\n            f(\"2019-05-07 21:25:02\"),\n            datetime.datetime(2019, 5, 7, 21, 25, 2),\n        )\n        self.assertEqual(\n            f(\"1970-01-01\"),\n            dt.EPOCH,\n        )\n        self.assertEqual(\n            f(\"1970.01.01\"),\n            dt.NONE,\n        )\n        self.assertEqual(\n            f(\"1970-01-01T00:00:00+0000\"),\n            dt.EPOCH,\n        )\n        self.assertEqual(\n            f(\"2019-05-07T21:25:02.753+0900\"),\n            datetime.datetime(2019, 5, 7, 12, 25, 2),\n        )\n\n        for value in ((), [], {}, None, 1, 2.3):\n            self.assertEqual(f(value), dt.NONE)\n\n    def test_none(self):\n        self.assertFalse(dt.NONE)\n        self.assertIsInstance(dt.NONE, dt.datetime)\n        self.assertEqual(str(dt.NONE), \"[Invalid DateTime]\")\n\n\nif __name__ == \"__main__\":\n    unittest.main()\n"
  },
  {
    "path": "test/test_extractor.py",
    "content": "#!/usr/bin/env python3\n# -*- coding: utf-8 -*-\n\n# Copyright 2018-2026 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nimport os\nimport sys\nimport unittest\nfrom unittest.mock import patch\n\nimport time\nimport string\n\nsys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))\nfrom gallery_dl import extractor, util, dt, config, cache  # noqa E402\nfrom gallery_dl.extractor import mastodon  # noqa E402\nfrom gallery_dl.extractor.common import Extractor, Message  # noqa E402\nfrom gallery_dl.extractor.directlink import DirectlinkExtractor  # noqa E402\n\n_list_classes = extractor._list_classes\n\ntry:\n    RESULTS = os.environ.get(\"GDL_TEST_RESULTS\")\n    if RESULTS:\n        results = util.import_file(RESULTS)\n    else:\n        from test import results\nexcept ImportError:\n    results = None\n\n\ndef setUpModule():\n    if cache.DATABASE is None:\n        import atexit\n        import tempfile\n        dbpath = tempfile.mkstemp()[1]\n        config.set((\"cache\",), \"file\", dbpath)\n        atexit.register(util.remove_file, dbpath)\n\n\nclass FakeExtractor(Extractor):\n    category = \"fake\"\n    subcategory = \"test\"\n    pattern = \"fake:\"\n\n    def items(self):\n        yield Message.Noop\n        yield Message.Url, \"text:foobar\", {}\n\n\nclass TestExtractorModule(unittest.TestCase):\n    VALID_URIS = (\n        \"https://example.org/file.jpg\",\n        \"tumblr:foobar\",\n        \"oauth:flickr\",\n        \"generic:https://example.org/\",\n        \"recursive:https://example.org/document.html\",\n    )\n\n    def setUp(self):\n        extractor._cache.clear()\n        extractor._module_iter = extractor._modules_internal()\n        extractor._list_classes = _list_classes\n\n    def test_find(self):\n        for uri in self.VALID_URIS:\n            result = extractor.find(uri)\n            self.assertIsInstance(result, Extractor, uri)\n\n        for not_found in (\"\", \"/tmp/file.ext\"):\n            self.assertIsNone(extractor.find(not_found))\n\n        for invalid in (None, [], {}, 123, b\"test:\"):\n            with self.assertRaises(TypeError):\n                extractor.find(invalid)\n\n    def test_add(self):\n        uri = \"fake:foobar\"\n        self.assertIsNone(extractor.find(uri))\n\n        extractor.add(FakeExtractor)\n        self.assertIsInstance(extractor.find(uri), FakeExtractor)\n\n    def test_add_module(self):\n        uri = \"fake:foobar\"\n        self.assertIsNone(extractor.find(uri))\n\n        classes = extractor.add_module(sys.modules[__name__])\n        self.assertEqual(len(classes), 1)\n        self.assertEqual(classes[0].pattern, FakeExtractor.pattern)\n        self.assertEqual(classes[0], FakeExtractor)\n        self.assertIsInstance(extractor.find(uri), FakeExtractor)\n\n    def test_from_url(self):\n        for uri in self.VALID_URIS:\n            cls = extractor.find(uri).__class__\n            extr = cls.from_url(uri)\n            self.assertIs(type(extr), cls)\n            self.assertIsInstance(extr, Extractor)\n\n        for not_found in (\"\", \"/tmp/file.ext\"):\n            self.assertIsNone(FakeExtractor.from_url(not_found))\n\n        for invalid in (None, [], {}, 123, b\"test:\"):\n            with self.assertRaises(TypeError):\n                FakeExtractor.from_url(invalid)\n\n    @unittest.skipIf(not results, \"no test data\")\n    def test_categories(self):\n        for result in results.all():\n            if result.get(\"#fail\"):\n                try:\n                    self.assertCategories(result)\n                except AssertionError:\n                    pass\n                else:\n                    self.fail(f\"{result['#url']}: Test did not fail\")\n            else:\n                self.assertCategories(result)\n\n    def assertCategories(self, result):\n        url = result[\"#url\"]\n        cls = result[\"#class\"]\n\n        try:\n            extr = cls.from_url(url)\n            find = extractor.find(url)\n        except ImportError as exc:\n            if exc.name in (\"youtube_dl\", \"yt_dlp\"):\n                return sys.stdout.write(\n                    f\"Skipping '{cls.category}' category checks\\n\")\n            raise\n        self.assertTrue(extr, url)\n        self.assertIs(extr.__class__, find.__class__, url)\n\n        categories = result.get(\"#category\")\n        if categories:\n            base, cat, sub = categories\n        else:\n            cat = cls.category\n            sub = cls.subcategory\n            base = cls.basecategory\n        self.assertEqual(extr.category, cat, url)\n        self.assertEqual(extr.subcategory, sub, url)\n        self.assertEqual(extr.basecategory, base, url)\n\n        if base not in (\"reactor\", \"wikimedia\"):\n            self.assertEqual(extr._cfgpath, (\"extractor\", cat, sub), url)\n\n    def test_init(self):\n        \"\"\"Test for exceptions in Extractor.initialize() and .finalize()\"\"\"\n        def fail_request(*args, **kwargs):\n            self.fail(\"called 'request() during initialization\")\n\n        for cls in extractor.extractors():\n            if cls.category == \"ytdl\":\n                continue\n            extr = cls.from_url(cls.example)\n            if not extr:\n                if cls.basecategory and not cls.instances:\n                    continue\n                self.fail(f\"{cls.__name__} pattern does not match \"\n                          f\"example URL '{cls.example}'\")\n\n            self.assertEqual(cls, extr.__class__)\n            self.assertEqual(cls, extractor.find(cls.example).__class__)\n\n            extr.request = fail_request\n            extr.initialize()\n            if extr.finalize is not None:\n                extr.finalize(0)\n\n    def test_init_ytdl(self):\n        try:\n            extr = extractor.find(\"ytdl:\")\n            extr.initialize()\n            if extr.finalize is not None:\n                extr.finalize(0)\n        except ImportError as exc:\n            if exc.name in (\"youtube_dl\", \"yt_dlp\"):\n                raise unittest.SkipTest(f\"cannot import module '{exc.name}'\")\n            raise\n\n    def test_docstrings(self):\n        \"\"\"Ensure docstring uniqueness\"\"\"\n        for extr1 in extractor.extractors():\n            for extr2 in extractor.extractors():\n                if extr1 != extr2 and extr1.__doc__ and extr2.__doc__:\n                    self.assertNotEqual(\n                        extr1.__doc__,\n                        extr2.__doc__,\n                        f\"{extr1} <-> {extr2}\",\n                    )\n\n    def test_names(self):\n        \"\"\"Ensure extractor classes are named CategorySubcategoryExtractor\"\"\"\n        def capitalize(c):\n            if \"-\" in c:\n                return string.capwords(c.replace(\"-\", \" \")).replace(\" \", \"\")\n            return c.capitalize()\n\n        for extr in extractor.extractors():\n            if extr.category not in (\"\", \"oauth\", \"ytdl\"):\n                expected = (f\"{capitalize(extr.category)}\"\n                            f\"{capitalize(extr.subcategory)}Extractor\")\n                if expected[0].isdigit():\n                    expected = f\"_{expected}\"\n                self.assertEqual(expected, extr.__name__)\n\n\nclass TestExtractorWait(unittest.TestCase):\n\n    def test_wait_seconds(self):\n        extr = extractor.find(\"generic:https://example.org/\")\n        seconds = 5\n        until = time.time() + seconds\n\n        with patch(\"time.sleep\") as sleep, patch.object(extr, \"log\") as log:\n            extr.wait(seconds=seconds)\n\n            sleep.assert_called_once_with(6.0)\n\n            calls = log.info.mock_calls\n            self.assertEqual(len(calls), 1)\n            self.assertEqual(calls[0][1][1], \"6 seconds\")\n            self._assert_isotime(calls[0][1][2], until)\n\n    def test_wait_seconds_long(self):\n        extr = extractor.find(\"generic:https://example.org/\")\n        seconds = 5000\n        until = time.time() + seconds\n\n        with patch(\"time.sleep\") as sleep, patch.object(extr, \"log\") as log:\n            extr.wait(seconds=seconds)\n\n            sleep.assert_called_once_with(5001.0)\n\n            calls = log.info.mock_calls\n            self.assertEqual(len(calls), 1)\n            self.assertEqual(calls[0][1][1], \"1h 23min\")\n            self._assert_isotime(calls[0][1][2], until)\n\n    def test_wait_until(self):\n        extr = extractor.find(\"generic:https://example.org/\")\n        until = time.time() + 5\n\n        with patch(\"time.sleep\") as sleep, patch.object(extr, \"log\") as log:\n            extr.wait(until=until)\n\n            calls = sleep.mock_calls\n            self.assertEqual(len(calls), 1)\n            self.assertAlmostEqual(calls[0][1][0], 6.0, places=0)\n\n            calls = log.info.mock_calls\n            self.assertEqual(len(calls), 1)\n            self.assertEqual(calls[0][1][1], \"5 seconds\")\n            self._assert_isotime(calls[0][1][2], until)\n\n    def test_wait_until_datetime(self):\n        extr = extractor.find(\"generic:https://example.org/\")\n        until = dt.now() + dt.timedelta(seconds=5)\n        until_local = dt.datetime.now() + dt.timedelta(seconds=5)\n\n        if not until.microsecond:\n            until = until.replace(microsecond=until_local.microsecond)\n\n        with patch(\"time.sleep\") as sleep, patch.object(extr, \"log\") as log:\n            extr.wait(until=until)\n\n            calls = sleep.mock_calls\n            self.assertEqual(len(calls), 1)\n            self.assertAlmostEqual(calls[0][1][0], 6.0, places=1)\n\n            calls = log.info.mock_calls\n            self.assertEqual(len(calls), 1)\n            self.assertEqual(calls[0][1][1], \"5 seconds\")\n            self._assert_isotime(calls[0][1][2], until_local)\n\n    def _assert_isotime(self, output, until):\n        if not isinstance(until, dt.datetime):\n            until = dt.datetime.fromtimestamp(until)\n        o = self._isotime_to_seconds(output)\n        u = self._isotime_to_seconds(until.time().isoformat()[:8])\n        self.assertLessEqual(o-u, 1.0)\n\n    def _isotime_to_seconds(self, isotime):\n        parts = isotime.split(\":\")\n        return int(parts[0]) * 3600 + int(parts[1]) * 60 + int(parts[2])\n\n\nclass TextExtractorCommonDateminmax(unittest.TestCase):\n\n    def setUp(self):\n        config.clear()\n\n    tearDown = setUp\n\n    def test_date_min_max_default(self):\n        extr = extractor.find(\"generic:https://example.org/\")\n\n        dmin, dmax = extr._get_date_min_max()\n        self.assertEqual(dmin, None)\n        self.assertEqual(dmax, None)\n\n        dmin, dmax = extr._get_date_min_max(..., -1)\n        self.assertEqual(dmin, ...)\n        self.assertEqual(dmax, -1)\n\n    def test_date_min_max_timestamp(self):\n        extr = extractor.find(\"generic:https://example.org/\")\n        config.set((), \"date-min\", 1262304000)\n        config.set((), \"date-max\", 1262304000.123)\n\n        dmin, dmax = extr._get_date_min_max()\n        self.assertEqual(dmin, 1262304000)\n        self.assertEqual(dmax, 1262304000.123)\n\n    def test_date_min_max_iso(self):\n        extr = extractor.find(\"generic:https://example.org/\")\n        config.set((), \"date-min\", \"2010-01-01\")\n        config.set((), \"date-max\", \"2010-01-01T00:01:03\")\n\n        dmin, dmax = extr._get_date_min_max()\n        self.assertEqual(dmin, 1262304000)\n        self.assertEqual(dmax, 1262304063)\n\n    def test_date_min_max_iso_invalid(self):\n        extr = extractor.find(\"generic:https://example.org/\")\n        config.set((), \"date-min\", \"2010-01-01\")\n        config.set((), \"date-max\", \"2010-01\")\n\n        with self.assertLogs() as log_info:\n            dmin, dmax = extr._get_date_min_max()\n        self.assertEqual(dmin, 1262304000)\n        self.assertEqual(dmax, None)\n\n        self.assertEqual(len(log_info.output), 1)\n        self.assertEqual(\n            log_info.output[0],\n            \"WARNING:generic:Unable to parse 'date-max': \"\n            \"Invalid ISO 8601 date/time value '2010-01'\")\n\n    def test_date_min_max(self):\n        extr = extractor.find(\"generic:https://example.org/\")\n        config.set((), \"date-min\", \"2010-01-01\")\n        config.set((), \"date-max\", \"2022-08-18\")\n\n        dmin, dmax = extr._get_date_min_max()\n        self.assertEqual(dmin, 1262304000)\n        self.assertEqual(dmax, 1660780800)\n\n    def test_date_min_max_mix(self):\n        extr = extractor.find(\"generic:https://example.org/\")\n        config.set((), \"date-min\", \"2010-01-01\")\n        config.set((), \"date-max\", 1262304061)\n\n        dmin, dmax = extr._get_date_min_max()\n        self.assertEqual(dmin, 1262304000)\n        self.assertEqual(dmax, 1262304061)\n\n\nclass TextExtractorOAuth(unittest.TestCase):\n\n    def test_oauth1(self):\n        for category in (\"flickr\", \"smugmug\", \"tumblr\"):\n            extr = extractor.find(f\"oauth:{category}\")\n\n            with patch.object(extr, \"_oauth1_authorization_flow\") as m:\n                for msg in extr:\n                    pass\n                self.assertEqual(len(m.mock_calls), 1)\n\n    def test_oauth2(self):\n        for category in (\"deviantart\", \"reddit\"):\n            extr = extractor.find(f\"oauth:{category}\")\n\n            with patch.object(extr, \"_oauth2_authorization_code_grant\") as m:\n                for msg in extr:\n                    pass\n                self.assertEqual(len(m.mock_calls), 1)\n\n    def test_oauth2_mastodon(self):\n        extr = extractor.find(\"oauth:mastodon:pawoo.net\")\n\n        with patch.object(extr, \"_oauth2_authorization_code_grant\") as m, \\\n                patch.object(extr, \"_register\") as r:\n            for msg in extr:\n                pass\n            self.assertEqual(len(r.mock_calls), 0)\n            self.assertEqual(len(m.mock_calls), 1)\n\n    def test_oauth2_mastodon_unknown(self):\n        extr = extractor.find(\"oauth:mastodon:example.com\")\n\n        with patch.object(extr, \"_oauth2_authorization_code_grant\") as m, \\\n                patch.object(extr, \"_register\") as r:\n            r.__name__ = \"_register\"\n            r.return_value = {\n                \"client-id\"    : \"foo\",\n                \"client-secret\": \"bar\",\n            }\n\n            extr.cache_update(r, \"example.com\", None)\n            for msg in extr:\n                pass\n\n            self.assertEqual(len(r.mock_calls), 1)\n            self.assertEqual(len(m.mock_calls), 1)\n\n\nif __name__ == \"__main__\":\n    unittest.main()\n"
  },
  {
    "path": "test/test_formatter.py",
    "content": "#!/usr/bin/env python3\n# -*- coding: utf-8 -*-\n\n# Copyright 2021-2026 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nimport os\nimport sys\nimport time\nimport unittest\nimport datetime\nimport tempfile\n\nsys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))\nfrom gallery_dl import formatter, text, dt, util, config  # noqa E402\n\ntry:\n    import jinja2\nexcept ImportError:\n    jinja2 = None\n\n\nclass TestFormatter(unittest.TestCase):\n\n    def tearDown(self):\n        config.clear()\n\n    kwdict = {\n        \"a\": \"hElLo wOrLd\",\n        \"b\": \"äöü\",\n        \"j\": \"げんそうきょう\",\n        \"J\": \"%E3%81%92%E3%82%93%E3%81%9D\",\n        \"d\": {\"a\": \"foo\", \"b\": 0, \"c\": None},\n        \"i\": 2,\n        \"l\": [\"a\", \"b\", \"c\"],\n        \"L\": [\n            {\"name\": \"John Doe\"      , \"age\": 42, \"email\": \"jd@example.org\"},\n            {\"name\": \"Jane Smith\"    , \"age\": 24, \"email\": None},\n            {\"name\": \"Max Mustermann\", \"age\": False},\n        ],\n        \"n\": None,\n        \"s\": \" \\n\\r\\tSPACE    \",\n        \"S\": \" \\n\\r\\tS  P         A\\tC\\nE    \",\n        \"h\": \"<p>foo </p> &amp; bar <p> </p>\",\n        \"H\": \"\"\"<p>\n  <a href=\"http://www.example.com\">Lorem ipsum dolor sit amet</a>.\n  Duis aute irure <a href=\"http://blog.example.org/lorem?foo=bar\">\n  http://blog.example.org</a>.\n</p>\"\"\",\n        \"u\": \"&#x27;&lt; / &gt;&#x27;\",\n        \"t\": 1262304000,\n        \"ds\": \"2010-01-01T01:00:00+01:00\",\n        \"dt\": datetime.datetime(2010, 1, 1),\n        \"dt_dst\": datetime.datetime(2010, 6, 1),\n        \"i_str\": \"12345\",\n        \"f_str\": \"12.45\",\n        \"lang\": \"en\",\n        \"name\": \"Name\",\n        \"title1\": \"Title\",\n        \"title2\": \"\",\n        \"title3\": None,\n        \"title4\": 0,\n    }\n\n    def test_conversions(self):\n        self._run_test(\"{a!l}\", \"hello world\")\n        self._run_test(\"{a!u}\", \"HELLO WORLD\")\n        self._run_test(\"{a!c}\", \"Hello world\")\n        self._run_test(\"{a!C}\", \"Hello World\")\n        self._run_test(\"{s!t}\", \"SPACE\")\n        self._run_test(\"{S!t}\", \"S  P         A\\tC\\nE\")\n        self._run_test(\"{a!U}\", self.kwdict[\"a\"])\n        self._run_test(\"{u!U}\", \"'< / >'\")\n        self._run_test(\"{a!H}\", self.kwdict[\"a\"])\n        self._run_test(\"{h!H}\", \"foo & bar\")\n        self._run_test(\"{u!H}\", \"'< / >'\")\n        self._run_test(\"{n!H}\", \"\")\n        self._run_test(\"{h!R}\", [])\n        self._run_test(\"{H!R}\", [\"http://www.example.com\",\n                                 \"http://blog.example.org/lorem?foo=bar\",\n                                 \"http://blog.example.org\"])\n        self._run_test(\"{a!s}\", self.kwdict[\"a\"])\n        self._run_test(\"{a!r}\", f\"'{self.kwdict['a']}'\")\n        self._run_test(\"{a!a}\", f\"'{self.kwdict['a']}'\")\n        self._run_test(\"{b!a}\", \"'\\\\xe4\\\\xf6\\\\xfc'\")\n        self._run_test(\"{a!S}\", self.kwdict[\"a\"])\n        self._run_test(\"{l!S}\", \"a, b, c\")\n        self._run_test(\"{n!S}\", \"\")\n        self._run_test(\"{t!d}\", datetime.datetime(2010, 1, 1))\n        self._run_test(\"{t!d:%Y-%m-%d}\", \"2010-01-01\")\n        self._run_test(\"{t!D}\" , datetime.datetime(2010, 1, 1))\n        self._run_test(\"{ds!D}\", datetime.datetime(2010, 1, 1))\n        self._run_test(\"{dt!D}\", datetime.datetime(2010, 1, 1))\n        self._run_test(\"{t!D:%Y-%m-%d}\", \"2010-01-01\")\n        self._run_test(\"{dt!T}\", \"1262304000\")\n        self._run_test(\"{l!j}\", '[\"a\",\"b\",\"c\"]')\n        self._run_test(\"{dt!j}\", '\"2010-01-01 00:00:00\"')\n        self._run_test(\"{a!g}\", \"hello-world\")\n        self._run_test(\"{lang!L}\", \"English\")\n        self._run_test(\"{'fr'!L}\", \"French\")\n        self._run_test(\"{a!L}\", None)\n        self._run_test(\"{a!n}\", 11)\n        self._run_test(\"{l!n}\", 3)\n        self._run_test(\"{d!n}\", 3)\n        self._run_test(\"{s!W}\", \"SPACE\")\n        self._run_test(\"{S!W}\", \"S P A C E\")\n        self._run_test(\"{i_str!i}\", 12345)\n        self._run_test(\"{i_str!f}\", 12345.0)\n        self._run_test(\"{f_str!f}\", 12.45)\n        self._run_test(\"{j!q}\", \"%E3%81%92%E3%82%93%E3%81%9D\"\n                                \"%E3%81%86%E3%81%8D%E3%82%87%E3%81%86\")\n        self._run_test(\"{J!Q}\", \"げんそ\")\n\n        # undefined conversion\n        with self.assertRaises(KeyError):\n            self._run_test(\"{a!z}\", \"hello world\")\n\n    def test_optional(self):\n        self._run_test(\"{name}{title1}\", \"NameTitle\")\n        self._run_test(\"{name}{title1:?//}\", \"NameTitle\")\n        self._run_test(\"{name}{title1:? **/''/}\", \"Name **Title''\")\n\n        self._run_test(\"{name}{title2}\", \"Name\")\n        self._run_test(\"{name}{title2:?//}\", \"Name\")\n        self._run_test(\"{name}{title2:? **/''/}\", \"Name\")\n\n        self._run_test(\"{name}{title3}\", \"NameNone\")\n        self._run_test(\"{name}{title3:?//}\", \"Name\")\n        self._run_test(\"{name}{title3:? **/''/}\", \"Name\")\n\n        self._run_test(\"{name}{title4}\", \"Name0\")\n        self._run_test(\"{name}{title4:?//}\", \"Name\")\n        self._run_test(\"{name}{title4:? **/''/}\", \"Name\")\n\n    def test_missing(self):\n        replacement = \"None\"\n\n        self._run_test(\"{missing}\", replacement)\n        self._run_test(\"{missing.attr}\", replacement)\n        self._run_test(\"{missing[key]}\", replacement)\n        self._run_test(\"{missing:?a//}\", \"\")\n\n        self._run_test(\"{name[missing]}\", replacement)\n        self._run_test(\"{name[missing].attr}\", replacement)\n        self._run_test(\"{name[missing][key]}\", replacement)\n        self._run_test(\"{name[missing]:?a//}\", \"\")\n\n    def test_missing_custom_default(self):\n        replacement = default = \"foobar\"\n        self._run_test(\"{missing}\"     , replacement, default)\n        self._run_test(\"{missing.attr}\", replacement, default)\n        self._run_test(\"{missing[key]}\", replacement, default)\n        self._run_test(\"{missing:?a//}\", f\"a{default}\", default)\n\n    def test_fmt_func(self):\n        self._run_test(\"{t}\" , self.kwdict[\"t\"] , None, int)\n        self._run_test(\"{t}\" , self.kwdict[\"t\"] , None, util.identity)\n        self._run_test(\"{dt}\", self.kwdict[\"dt\"], None, util.identity)\n        self._run_test(\"{ds}\", self.kwdict[\"dt\"], None, dt.parse_iso)\n        self._run_test(\"{ds:D%Y-%m-%dT%H:%M:%S%z}\", self.kwdict[\"dt\"],\n                       None, util.identity)\n\n    def test_fmt_func_multi(self):\n        self._run_test(\"foo {t}\" , f\"foo {self.kwdict['t']}\",\n                       None, int)\n        self._run_test(\"bar {ds}\", f\"bar {self.kwdict['dt']}\",\n                       None, dt.parse_iso)\n        self._run_test(\"foo {ds:D%Y-%m-%dT%H:%M:%S%z} bar\",\n                       f\"foo {self.kwdict['dt']} bar\",\n                       None, util.identity)\n\n    def test_alternative(self):\n        self._run_test(\"{a|z}\"    , \"hElLo wOrLd\")\n        self._run_test(\"{z|a}\"    , \"hElLo wOrLd\")\n        self._run_test(\"{z|y|a}\"  , \"hElLo wOrLd\")\n        self._run_test(\"{z|y|x|a}\", \"hElLo wOrLd\")\n        self._run_test(\"{z|n|a|y}\", \"hElLo wOrLd\")\n\n        self._run_test(\"{z|a!C}\"      , \"Hello World\")\n        self._run_test(\"{z|a:Rh/C/}\"  , \"CElLo wOrLd\")\n        self._run_test(\"{z|a!C:RH/C/}\", \"Cello World\")\n        self._run_test(\"{z|y|x:?</>/}\", \"\")\n\n        self._run_test(\"{d[c]|d[b]|d[a]}\", \"foo\")\n        self._run_test(\"{d[a]|d[b]|d[c]}\", \"foo\")\n        self._run_test(\"{d[z]|d[y]|d[x]}\", \"None\")\n\n    def test_indexing(self):\n        self._run_test(\"{l[0]}\" , \"a\")\n        self._run_test(\"{a[6]}\" , \"w\")\n\n    def test_indexing_negative(self):\n        self._run_test(\"{l[-1]}\" , \"c\")\n        self._run_test(\"{a[-7]}\" , \"o\")\n        self._run_test(\"{a[-0]}\" , \"h\")  # same as a[0]\n\n    def test_dict_access(self):\n        self._run_test(\"{d[a]}\"  , \"foo\")\n        self._run_test(\"{d['a']}\", \"foo\")\n        self._run_test('{d[\"a\"]}', \"foo\")\n\n    def test_dot_index(self):\n        self._run_test(\"{l.1}\"  , \"b\")\n        self._run_test(\"{a.6}\"  , \"w\")\n        self._run_test(\"{a.99}\" , \"None\")\n        self._run_test(\"{l.-1}\" , \"c\")\n        self._run_test(\"{a.-7}\" , \"o\")\n        self._run_test(\"{a.-0}\" , \"h\")  # same as a[0]\n        self._run_test(\"{a.-99}\", \"None\")\n\n    def test_dot_access_dict(self):\n        self._run_test(\"{d.a}\", \"foo\")\n        self._run_test(\"{d.d}\", \"None\")\n        self._run_test(\"{a.d}\", \"None\")\n        self._run_test(\"{L.0.age}\", \"42\")\n        self._run_test(\"{L.-1.name.2}\", \"x\")\n        self._run_test(\"{L.1:I}\", self.kwdict[\"L\"][1])\n\n    def test_dot_access_attr(self):\n        self._run_test(\"{t.real}\", \"1262304000\")\n        self._run_test(\"{dt.year}\", \"2010\")\n\n    def test_slice_str(self):\n        v = self.kwdict[\"a\"]\n        self._run_test(\"{a[1:10]}\"  , v[1:10])\n        self._run_test(\"{a[-10:-1]}\", v[-10:-1])\n        self._run_test(\"{a[5:]}\" , v[5:])\n        self._run_test(\"{a[50:]}\", v[50:])\n        self._run_test(\"{a[:5]}\" , v[:5])\n        self._run_test(\"{a[:50]}\", v[:50])\n        self._run_test(\"{a[:]}\"  , v)\n        self._run_test(\"{a[1:10:2]}\"  , v[1:10:2])\n        self._run_test(\"{a[-10:-1:2]}\", v[-10:-1:2])\n        self._run_test(\"{a[5::2]}\" , v[5::2])\n        self._run_test(\"{a[50::2]}\", v[50::2])\n        self._run_test(\"{a[:5:2]}\" , v[:5:2])\n        self._run_test(\"{a[:50:2]}\", v[:50:2])\n        self._run_test(\"{a[::]}\"   , v)\n\n        self._run_test(\"{a:[1:10]}\"  , v[1:10])\n        self._run_test(\"{a:[-10:-1]}\", v[-10:-1])\n        self._run_test(\"{a:[5:]}\" , v[5:])\n        self._run_test(\"{a:[50:]}\", v[50:])\n        self._run_test(\"{a:[:5]}\" , v[:5])\n        self._run_test(\"{a:[:50]}\", v[:50])\n        self._run_test(\"{a:[:]}\"  , v)\n        self._run_test(\"{a:[1:10:2]}\"  , v[1:10:2])\n        self._run_test(\"{a:[-10:-1:2]}\", v[-10:-1:2])\n        self._run_test(\"{a:[5::2]}\" , v[5::2])\n        self._run_test(\"{a:[50::2]}\", v[50::2])\n        self._run_test(\"{a:[:5:2]}\" , v[:5:2])\n        self._run_test(\"{a:[:50:2]}\", v[:50:2])\n        self._run_test(\"{a:[::]}\"   , v)\n\n    def test_slice_bytes(self):\n        v = self.kwdict[\"j\"]\n        self._run_test(\"{j[b1:10]}\"  , v[1:3])\n        self._run_test(\"{j[b-10:-1]}\", v[-3:-1])\n        self._run_test(\"{j[b5:]}\"    , v[2:])\n        self._run_test(\"{j[b50:]}\"   , v[50:])\n        self._run_test(\"{j[b:5]}\"    , v[:1])\n        self._run_test(\"{j[b:50]}\"   , v[:50])\n        self._run_test(\"{j[b:]}\"     , v)\n        self._run_test(\"{j[b::]}\"    , v)\n\n        self._run_test(\"{j:[b1:10]}\"  , v[1:3])\n        self._run_test(\"{j:[b-10:-1]}\", v[-3:-1])\n        self._run_test(\"{j:[b5:]}\"    , v[2:])\n        self._run_test(\"{j:[b50:]}\"   , v[50:])\n        self._run_test(\"{j:[b:5]}\"    , v[:1])\n        self._run_test(\"{j:[b:50]}\"   , v[:50])\n        self._run_test(\"{j:[b:]}\"     , v)\n        self._run_test(\"{j:[b::]}\"    , v)\n\n    def test_specifier_maxlen(self):\n        v = self.kwdict[\"a\"]\n        self._run_test(\"{a:L5/foo/}\" , \"foo\")\n        self._run_test(\"{a:L50/foo/}\", v)\n        self._run_test(\"{a:L50/foo/>50}\", \" \" * 39 + v)\n        self._run_test(\"{a:L50/foo/>51}\", \"foo\")\n        self._run_test(\"{a:Lab/foo/}\", \"foo\")\n\n    def test_specifier_maxlen_bytes(self):\n        v = self.kwdict[\"a\"]\n        self._run_test(\"{a:Lb5/foo/}\" , \"foo\")\n        self._run_test(\"{a:Lb50/foo/}\", v)\n        self._run_test(\"{a:Lb50/foo/>50}\", \" \" * 39 + v)\n        self._run_test(\"{a:Lb50/foo/>51}\", \"foo\")\n        self._run_test(\"{a:Lbab/foo/}\", \"foo\")\n\n        v = self.kwdict[\"j\"]\n        self._run_test(\"{j:Lb5/foo/}\" , \"foo\")\n        self._run_test(\"{j:Lb50/foo/}\", v)\n        self._run_test(\"{j:Lbab/foo/}\", \"foo\")\n\n    def test_specifier_join(self):\n        self._run_test(\"{l:J}\"       , \"abc\")\n        self._run_test(\"{l:J,}\"      , \"a,b,c\")\n        self._run_test(\"{l:J,/}\"     , \"a,b,c\")\n        self._run_test(\"{l:J,/>20}\"  , \"               a,b,c\")\n        self._run_test(\"{l:J - }\"    , \"a - b - c\")\n        self._run_test(\"{l:J - /}\"   , \"a - b - c\")\n        self._run_test(\"{l:J - />20}\", \"           a - b - c\")\n\n        self._run_test(\"{a:J/}\"      , self.kwdict[\"a\"])\n        self._run_test(\"{a:J, /}\"    , self.kwdict[\"a\"])\n\n    def test_specifier_replace(self):\n        self._run_test(\"{a:Rh/C/}\"  , \"CElLo wOrLd\")\n        self._run_test(\"{a!l:Rh/C/}\", \"Cello world\")\n        self._run_test(\"{a!u:Rh/C/}\", \"HELLO WORLD\")\n\n        self._run_test(\"{a!l:Rl/_/}\", \"he__o wor_d\")\n        self._run_test(\"{a!l:Rl//}\" , \"heo word\")\n        self._run_test(\"{name:Rame/othing/}\", \"Nothing\")\n\n    def test_specifier_datetime(self):\n        self._run_test(\"{ds:D%Y-%m-%dT%H:%M:%S%z}\", \"2010-01-01 00:00:00\")\n        self._run_test(\"{ds:D%Y}\", \"[Invalid DateTime]\")\n        self._run_test(\"{l2:D%Y}\", \"[Invalid DateTime]\")\n\n    def test_specifier_offset(self):\n        self._run_test(\"{dt:O 01:00}\", \"2010-01-01 01:00:00\")\n        self._run_test(\"{dt:O+02:00}\", \"2010-01-01 02:00:00\")\n        self._run_test(\"{dt:O-03:45}\", \"2009-12-31 20:15:00\")\n\n        self._run_test(\"{dt:O12}\", \"2010-01-01 12:00:00\")\n        self._run_test(\"{dt:O-24}\", \"2009-12-31 00:00:00\")\n\n        self._run_test(\"{ds:D%Y-%m-%dT%H:%M:%S%z/O1}\", \"2010-01-01 01:00:00\")\n        self._run_test(\"{t!d:O2}\", \"2010-01-01 02:00:00\")\n\n    def test_specifier_offset_local(self):\n        ts = self.kwdict[\"dt\"].replace(\n            tzinfo=datetime.timezone.utc).timestamp()\n        offset = time.localtime(ts).tm_gmtoff\n        dt = self.kwdict[\"dt\"] + datetime.timedelta(seconds=offset)\n        self._run_test(\"{dt:O}\", str(dt))\n        self._run_test(\"{dt:Olocal}\", str(dt))\n\n        ts = self.kwdict[\"dt_dst\"].replace(\n            tzinfo=datetime.timezone.utc).timestamp()\n        offset = time.localtime(ts).tm_gmtoff\n        dt = self.kwdict[\"dt_dst\"] + datetime.timedelta(seconds=offset)\n        self._run_test(\"{dt_dst:O}\", str(dt))\n        self._run_test(\"{dt_dst:Olocal}\", str(dt))\n\n    def test_specifier_sort(self):\n        self._run_test(\"{l:S}\" , \"['a', 'b', 'c']\")\n        self._run_test(\"{l:Sa}\", \"['a', 'b', 'c']\")\n        self._run_test(\"{l:Sd}\", \"['c', 'b', 'a']\")\n        self._run_test(\"{l:Sr}\", \"['c', 'b', 'a']\")\n\n        self._run_test(\n            \"{a:S}\", \"[' ', 'E', 'L', 'L', 'O', 'd', 'h', 'l', 'o', 'r', 'w']\")\n        self._run_test(\n            \"{a:S-asc}\",  # starts with 'S', contains 'a'\n            \"[' ', 'E', 'L', 'L', 'O', 'd', 'h', 'l', 'o', 'r', 'w']\")\n        self._run_test(\n            \"{a:Sort-reverse}\",  # starts with 'S', contains 'r'\n            \"['w', 'r', 'o', 'l', 'h', 'd', 'O', 'L', 'L', 'E', ' ']\")\n\n    def test_specifier_arithmetic(self):\n        self._run_test(\"{i:A+1}\", \"3\")\n        self._run_test(\"{i:A-1}\", \"1\")\n        self._run_test(\"{i:A*3}\", \"6\")\n\n    def test_specifier_conversions(self):\n        self._run_test(\"{a:Cl}\"   , \"hello world\")\n        self._run_test(\"{h:CHC}\"  , \"Foo & Bar\")\n        self._run_test(\"{l:CSulc}\", \"A, b, c\")\n\n    def test_specifier_limit(self):\n        self._run_test(\"{a:X20/ */}\", \"hElLo wOrLd\")\n        self._run_test(\"{a:X10/ */}\", \"hElLo wO *\")\n\n        with self.assertRaises(ValueError):\n            self._run_test(\"{a:Xfoo/ */}\", \"hello wo *\")\n\n    def test_specifier_limit_bytes(self):\n        self._run_test(\"{a:Xb20/ */}\", \"hElLo wOrLd\")\n        self._run_test(\"{a:Xb10/ */}\", \"hElLo wO *\")\n\n        self._run_test(\"{j:Xb50/〜/}\", \"げんそうきょう\")\n        self._run_test(\"{j:Xb20/〜/}\", \"げんそうき〜\")\n        self._run_test(\"{j:Xb20/ */}\", \"げんそうきょ *\")\n\n        with self.assertRaises(ValueError):\n            self._run_test(\"{a:Xbfoo/ */}\", \"hello wo *\")\n\n    def test_specifier_map(self):\n        self._run_test(\"{L:Mname/}\" ,\n                       \"['John Doe', 'Jane Smith', 'Max Mustermann']\")\n        self._run_test(\"{L:Mage/}\"  ,\n                       \"[42, 24, False]\")\n\n        self._run_test(\"{a:Mname}\", self.kwdict[\"a\"])\n        self._run_test(\"{n:Mname}\", \"None\")\n        self._run_test(\"{title4:Mname}\", \"0\")\n\n        with self.assertRaises(ValueError):\n            self._run_test(\"{t:Mname\", \"\")\n\n    def test_specifier_identity(self):\n        self._run_test(\"{a:I}\", self.kwdict[\"a\"])\n        self._run_test(\"{i:I}\", self.kwdict[\"i\"])\n        self._run_test(\"{dt:I}\", self.kwdict[\"dt\"])\n\n        self._run_test(\"{t!D:I}\", self.kwdict[\"dt\"])\n        self._run_test(\"{t!D:I/O+01:30}\", self.kwdict[\"dt\"])\n        self._run_test(\"{i:A+1/I}\", self.kwdict[\"i\"]+1)\n\n    def test_chain_special(self):\n        # multiple replacements\n        self._run_test(\"{a:Rh/C/RE/e/RL/l/}\", \"Cello wOrld\")\n        self._run_test(\"{d[b]!s:R1/Q/R2/A/R0/Y/}\", \"Y\")\n\n        # join-and-replace\n        self._run_test(\"{l:J-/Rb/E/}\", \"a-E-c\")\n\n        # join and slice\n        self._run_test(\"{l:J-/[1:-1]}\", \"-b-\")\n\n        # optional-and-maxlen\n        self._run_test(\"{d[a]:?</>/L1/too long/}\", \"<too long>\")\n        self._run_test(\"{d[c]:?</>/L5/too long/}\", \"\")\n\n        # parse and format datetime\n        self._run_test(\"{ds:D%Y-%m-%dT%H:%M:%S%z/%Y%m%d}\", \"20100101\")\n\n        # sort and join\n        self._run_test(\"{a:S/J}\", \" ELLOdhlorw\")\n\n        # map and join\n        self._run_test(\"{L:Mname/J-}\", \"John Doe-Jane Smith-Max Mustermann\")\n\n    def test_separator(self):\n        orig_separator = formatter._SEPARATOR\n        try:\n            formatter._SEPARATOR = \"|\"\n            self._run_test(\"{a:Rh|C|RE|e|RL|l|}\", \"Cello wOrld\")\n            self._run_test(\"{d[b]!s:R1|Q|R2|A|R0|Y|}\", \"Y\")\n\n            formatter._SEPARATOR = \"##\"\n            self._run_test(\"{l:J-##Rb##E##}\", \"a-E-c\")\n            self._run_test(\"{l:J-##[1:-1]}\", \"-b-\")\n\n            formatter._SEPARATOR = \"\\0\"\n            self._run_test(\"{d[a]:?<\\0>\\0L1\\0too long\\0}\", \"<too long>\")\n            self._run_test(\"{d[c]:?<\\0>\\0L5\\0too long\\0}\", \"\")\n\n            formatter._SEPARATOR = \"?\"\n            self._run_test(\"{ds:D%Y-%m-%dT%H:%M:%S%z?%Y%m%d}\", \"20100101\")\n        finally:\n            formatter._SEPARATOR = orig_separator\n\n    def test_globals_env(self):\n        os.environ[\"FORMATTER_TEST\"] = value = self.kwdict[\"a\"]\n\n        self._run_test(\"{_env[FORMATTER_TEST]}\"  , value)\n        self._run_test(\"{_env[FORMATTER_TEST]!l}\", value.lower())\n        self._run_test(\"{z|_env[FORMATTER_TEST]}\", value)\n\n    def test_globals_now(self):\n        fmt = formatter.parse(\"{_now}\")\n        out1 = fmt.format_map(self.kwdict)\n        self.assertRegex(out1, r\"^\\d{4}-\\d\\d-\\d\\d \\d\\d:\\d\\d:\\d\\d(\\.\\d+)?$\")\n\n        out = formatter.parse(\"{_now:%Y%m%d}\").format_map(self.kwdict)\n        now = datetime.datetime.now()\n        self.assertRegex(out, r\"^\\d{8}$\")\n        self.assertEqual(out, format(now, \"%Y%m%d\"))\n\n        out = formatter.parse(\"{z|_now:%Y}\").format_map(self.kwdict)\n        self.assertRegex(out, r\"^\\d{4}$\")\n        self.assertEqual(out, format(now, \"%Y\"))\n\n        out2 = fmt.format_map(self.kwdict)\n        self.assertRegex(out1, r\"^\\d{4}-\\d\\d-\\d\\d \\d\\d:\\d\\d:\\d\\d(\\.\\d+)?$\")\n        self.assertNotEqual(out1, out2)\n\n    def test_globals_nul(self):\n        value = \"None\"\n\n        self._run_test(\"{_nul}\"         , value)\n        self._run_test(\"{_nul[key]}\"    , value)\n        self._run_test(\"{z|_nul}\"       , value)\n        self._run_test(\"{z|_nul:%Y%m%s}\", value)\n\n    def test_literals(self):\n        value = \"foo\"\n\n        self._run_test(\"{'foo'}\"      , value)\n        self._run_test(\"{'foo'!u}\"    , value.upper())\n        self._run_test(\"{'f00':R0/o/}\", value)\n\n        self._run_test(\"{z|'foo'}\"      , value)\n        self._run_test(\"{z|''|'foo'}\"   , value)\n        self._run_test(\"{z|'foo'!u}\"    , value.upper())\n        self._run_test(\"{z|'f00':R0/o/}\", value)\n\n        self._run_test(\"{_lit[foo]}\"       , value)\n        self._run_test(\"{_lit[foo]!u}\"     , value.upper())\n        self._run_test(\"{_lit[f00]:R0/o/}\" , value)\n        self._run_test(\"{_lit[foobar][:3]}\", value)\n        self._run_test(\"{z|_lit[foo]}\"     , value)\n\n        # empty (#4492)\n        self._run_test(\"{z|''}\" , \"\")\n        self._run_test(\"{''|''}\", \"\")\n\n        # special characters (dots, brackets, singlee quotes) (#5539)\n        self._run_test(\"{'f.o.o'}\"    , \"f.o.o\")\n        self._run_test(\"{_lit[f.o.o]}\", \"f.o.o\")\n        self._run_test(\"{_lit[f'o'o]}\", \"f'o'o\")\n        self._run_test(\"{'f.[].[]'}\"  , \"f.[].[]\")\n        self._run_test(\"{z|'f.[].[]'}\", \"f.[].[]\")\n\n    def test_template(self):\n        with tempfile.TemporaryDirectory() as tmpdirname:\n            path1 = os.path.join(tmpdirname, \"tpl1\")\n            path2 = os.path.join(tmpdirname, \"tpl2\")\n\n            with open(path1, \"w\") as fp:\n                fp.write(\"{a}\")\n            fmt1 = formatter.parse(f\"\\fT {path1}\")\n\n            with open(path2, \"w\") as fp:\n                fp.write(\"{a!u:Rh/C/}\\nFooBar\")\n            fmt2 = formatter.parse(f\"\\fT {path2}\")\n\n        self.assertEqual(fmt1.format_map(self.kwdict), self.kwdict[\"a\"])\n        self.assertEqual(fmt2.format_map(self.kwdict), \"HELLO WORLD\\nFooBar\")\n\n        with self.assertRaises(OSError):\n            formatter.parse(\"\\fT /\")\n\n    def test_expression(self):\n        self._run_test(\"\\fE a\", self.kwdict[\"a\"])\n        self._run_test(\n            \"\\fE name * 2 + ' ' + a\",\n            f\"{self.kwdict['name']}{self.kwdict['name']} {self.kwdict['a']}\")\n\n    def test_fstring(self):\n        self._run_test(\"\\fF {a}\", self.kwdict[\"a\"])\n        self._run_test(\n            \"\\fF {name}{name} {a}\",\n            f\"{self.kwdict['name']}{self.kwdict['name']} {self.kwdict['a']}\")\n        self._run_test(\n            \"\\fF foo-'\\\"{a.upper()}\\\"'-bar\",\n            f\"\"\"foo-'\"{self.kwdict['a'].upper()}\"'-bar\"\"\")\n\n    def test_template_fstring(self):\n        with tempfile.TemporaryDirectory() as tmpdirname:\n            path1 = os.path.join(tmpdirname, \"tpl1\")\n            path2 = os.path.join(tmpdirname, \"tpl2\")\n\n            with open(path1, \"w\") as fp:\n                fp.write(\"{a}\")\n            fmt1 = formatter.parse(f\"\\fTF {path1}\")\n\n            with open(path2, \"w\") as fp:\n                fp.write(\"foo-'\\\"{a.upper()}\\\"'-bar\")\n            fmt2 = formatter.parse(f\"\\fTF {path2}\")\n\n        self.assertEqual(fmt1.format_map(self.kwdict), self.kwdict[\"a\"])\n        self.assertEqual(fmt2.format_map(self.kwdict),\n                         f\"\"\"foo-'\"{self.kwdict['a'].upper()}\"'-bar\"\"\")\n\n        with self.assertRaises(OSError):\n            formatter.parse(\"\\fTF /\")\n\n    @unittest.skipIf(jinja2 is None, \"no jinja2\")\n    def test_jinja(self):\n        formatter.JinjaFormatter.env = None\n\n        self._run_test(\"\\fJ {{a}}\", self.kwdict[\"a\"])\n        self._run_test(\n            \"\\fJ {{name}}{{name}} {{a}}\",\n            f\"{self.kwdict['name']}{self.kwdict['name']} {self.kwdict['a']}\")\n        self._run_test(\n            \"\\fJ foo-'\\\"{{a | upper}}\\\"'-bar\",\n            f\"\"\"foo-'\"{self.kwdict['a'].upper()}\"'-bar\"\"\")\n\n    @unittest.skipIf(jinja2 is None, \"no jinja2\")\n    def test_template_jinja(self):\n        formatter.JinjaFormatter.env = None\n\n        with tempfile.TemporaryDirectory() as tmpdirname:\n            path1 = os.path.join(tmpdirname, \"tpl1\")\n            path2 = os.path.join(tmpdirname, \"tpl2\")\n\n            with open(path1, \"w\") as fp:\n                fp.write(\"{{a}}\")\n            fmt1 = formatter.parse(f\"\\fTJ {path1}\")\n\n            with open(path2, \"w\") as fp:\n                fp.write(\"foo-'\\\"{{a | upper}}\\\"'-bar\")\n            fmt2 = formatter.parse(f\"\\fTJ {path2}\")\n\n        self.assertEqual(fmt1.format_map(self.kwdict), self.kwdict[\"a\"])\n        self.assertEqual(fmt2.format_map(self.kwdict),\n                         f\"\"\"foo-'\"{self.kwdict['a'].upper()}\"'-bar\"\"\")\n\n        with self.assertRaises(OSError):\n            formatter.parse(\"\\fTJ /\")\n\n    @unittest.skipIf(jinja2 is None, \"no jinja2\")\n    def test_template_jinja_opts(self):\n        formatter.JinjaFormatter.env = None\n\n        with tempfile.TemporaryDirectory() as tmpdirname:\n            path_filters = os.path.join(tmpdirname, \"jinja_filters.py\")\n            path_template = os.path.join(tmpdirname, \"jinja_template.txt\")\n\n            config.set((), \"jinja\", {\n                \"environment\": {\n                    \"variable_start_string\": \"(((\",\n                    \"variable_end_string\"  : \")))\",\n                    \"keep_trailing_newline\": True,\n                },\n                \"filters\": path_filters,\n            })\n\n            with open(path_filters, \"w\") as fp:\n                fp.write(r\"\"\"\nimport re\n\ndef datetime_format(value, format=\"%H:%M %d-%m-%y\"):\n    return value.strftime(format)\n\ndef sanitize(value):\n    return re.sub(r\"\\s+\", \" \", value.strip())\n\n__filters__ = {\n    \"dt_fmt\": datetime_format,\n    \"sanitize_whitespace\": sanitize,\n}\n\"\"\")\n\n            with open(path_template, \"w\") as fp:\n                fp.write(\"\"\"\\\nPresent Day  is ((( dt | dt_fmt(\"%B %d, %Y\") )))\nPresent Time is ((( dt | dt_fmt(\"%H:%M:%S\") )))\n\nHello ((( s | sanitize_whitespace ))).\nI hope there is enough \"(((S|sanitize_whitespace)))\" for you.\n\"\"\")\n            fmt = formatter.parse(f\"\\fTJ {path_template}\")\n\n        self.assertEqual(fmt.format_map(self.kwdict), \"\"\"\\\nPresent Day  is January 01, 2010\nPresent Time is 00:00:00\n\nHello SPACE.\nI hope there is enough \"S P A C E\" for you.\n\"\"\")\n\n    def test_module(self):\n        with tempfile.TemporaryDirectory() as tmpdirname:\n            path = os.path.join(tmpdirname, \"testmod.py\")\n\n            with open(path, \"w\") as fp:\n                fp.write(\"\"\"\ndef gentext(kwdict):\n    name = kwdict.get(\"Name\") or kwdict.get(\"name\") or \"foo\"\n    return \"'{title1}' by {}\".format(name, **kwdict)\n\ndef lengths(kwdict):\n    a = 0\n    for k, v in kwdict.items():\n        if k == k.lower():\n            try:\n                a += len(v)\n            except TypeError:\n                pass\n    return format(a)\n\ndef noarg():\n    return \"\"\n\"\"\")\n            sys.path.insert(0, tmpdirname)\n            try:\n                fmt1 = formatter.parse(\"\\fM testmod:gentext\")\n                fmt2 = formatter.parse(\"\\fM testmod:lengths\")\n                fmt0 = formatter.parse(\"\\fM testmod:noarg\")\n\n                with self.assertRaises(AttributeError):\n                    formatter.parse(\"\\fM testmod:missing\")\n                with self.assertRaises(ImportError):\n                    formatter.parse(\"\\fM missing:missing\")\n            finally:\n                sys.path.pop(0)\n\n            fmt3 = formatter.parse(f\"\\fM {path}:gentext\")\n            fmt4 = formatter.parse(f\"\\fM {path}:lengths\")\n\n        self.assertEqual(fmt1.format_map(self.kwdict), \"'Title' by Name\")\n        self.assertEqual(fmt2.format_map(self.kwdict), \"139\")\n\n        self.assertEqual(fmt3.format_map(self.kwdict), \"'Title' by Name\")\n        self.assertEqual(fmt4.format_map(self.kwdict), \"139\")\n\n        with self.assertRaises(TypeError):\n            self.assertEqual(fmt0.format_map(self.kwdict), \"\")\n\n    def _run_test(self, format_string, result, default=None, fmt=format):\n        fmt = formatter.parse(format_string, default, fmt)\n        output = fmt.format_map(self.kwdict)\n        self.assertEqual(output, result, format_string)\n\n\nif __name__ == \"__main__\":\n    unittest.main()\n"
  },
  {
    "path": "test/test_job.py",
    "content": "#!/usr/bin/env python3\n# -*- coding: utf-8 -*-\n\n# Copyright 2021-2026 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nimport os\nimport sys\nimport unittest\nfrom unittest.mock import patch\n\nimport io\n\nsys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))\nfrom gallery_dl import job, config, text  # noqa E402\nfrom gallery_dl.extractor.common import Extractor, Message  # noqa E402\n\n\nclass TestJob(unittest.TestCase):\n\n    def tearDown(self):\n        config.clear()\n\n    def _capture_stdout(self, extr_or_job):\n        if isinstance(extr_or_job, Extractor):\n            jobinstance = self.jobclass(extr_or_job)\n        else:\n            jobinstance = extr_or_job\n\n        with io.StringIO() as buffer:\n            stdout = sys.stdout\n            sys.stdout = buffer\n            try:\n                jobinstance.run()\n            finally:\n                sys.stdout = stdout\n\n            return buffer.getvalue()\n\n\nclass TestDownloadJob(TestJob):\n    jobclass = job.DownloadJob\n\n    def test_extractor_filter(self):\n        extr = TestExtractor.from_url(\"test:\")\n        tjob = self.jobclass(extr)\n\n        func = tjob._build_extractor_filter()\n        self.assertEqual(func(TestExtractor)      , False)\n        self.assertEqual(func(TestExtractorParent), False)\n        self.assertEqual(func(TestExtractorNoop)  , True)\n\n        config.set((), \"blacklist\", \":test_subcategory\")\n        func = tjob._build_extractor_filter()\n        self.assertEqual(func(TestExtractor)      , False)\n        self.assertEqual(func(TestExtractorParent), True)\n        self.assertEqual(func(TestExtractorNoop)  , False)\n\n        config.set((), \"whitelist\", \"test_category:test_subcategory\")\n        func = tjob._build_extractor_filter()\n        self.assertEqual(func(TestExtractor)      , True)\n        self.assertEqual(func(TestExtractorParent), False)\n        self.assertEqual(func(TestExtractorNoop)  , False)\n\n    def test_opt_init(self):\n        config.set((), \"init\", True)\n        config.set((), \"archive\", \":memory:\")\n        config.set((), \"postprocessors\", \"directory\")\n\n        extr = TestExtractorNoop.from_url(\"test:noop\")\n        tjob = self.jobclass(extr)\n        tjob._init()\n\n        self.assertTrue(tjob.pathfmt)\n        self.assertTrue(tjob.archive)\n        self.assertTrue(tjob.hooks)\n\n    def test_opt_init_false(self):\n        config.set((), \"init\", False)\n        config.set((), \"archive\", \":memory:\")\n        config.set((), \"postprocessors\", \"directory\")\n\n        extr = TestExtractorNoop.from_url(\"test:noop\")\n        tjob = self.jobclass(extr)\n        tjob._init()\n\n        self.assertFalse(tjob.pathfmt)\n        self.assertFalse(tjob.archive)\n        self.assertFalse(tjob.hooks)\n\n    def test_parent_metadata_extractor(self):\n        config.set((), \"parent-metadata\", True)\n\n        config.set((\"output\",), \"mode\", False)\n        config.set((), \"download\", False)\n\n        config.set((), \"postprocessors\", [{\n            \"name\"  : \"metadata/print@init\",\n            \"format\": \"{num}\",\n        }])\n\n        extr = TestExtractorParent.from_url(\"test:parent:3\")\n        out = self._capture_stdout(extr)\n        # no output if '_extractor' is overwritten (#8958)\n        self.assertEqual(out, \"11\\n\")\n\n\nclass TestKeywordJob(TestJob):\n    jobclass = job.KeywordJob\n\n    def test_default(self):\n        self.maxDiff = None\n        extr = TestExtractor.from_url(\"test:self\")\n        self.assertEqual(self._capture_stdout(extr), \"\"\"\\\nKeywords for directory names:\n-----------------------------\nauthor['id']\n  123\nauthor['name']\n  test\nauthor['self']\n  <circular reference>\ncategory\n  test_category\nsubcategory\n  test_subcategory\nuser['id']\n  123\nuser['name']\n  test\nuser['self']\n  <circular reference>\n\nKeywords for filenames and --filter:\n------------------------------------\nauthor['id']\n  123\nauthor['name']\n  test\nauthor['self']\n  <circular reference>\ncategory\n  test_category\nextension\n  jpg\nfilename\n  1\nnum\n  1\nsubcategory\n  test_subcategory\ntags[N]\n  0 foo\n  1 bar\n  2 テスト\nuser['id']\n  123\nuser['name']\n  test\nuser['self']\n  <circular reference>\n\"\"\")\n\n    def test_opt_init(self):\n        config.set((), \"init\", True)\n\n        extr = TestExtractorNoop.from_url(\"test:noop\")\n        tjob = self.jobclass(extr)\n        tjob._init()\n\n\nclass TestUrlJob(TestJob):\n    jobclass = job.UrlJob\n\n    def test_default(self):\n        extr = TestExtractor.from_url(\"test:\")\n        self.assertEqual(self._capture_stdout(extr), \"\"\"\\\nhttps://example.org/1.jpg\nhttps://example.org/2.jpg\nhttps://example.org/3.jpg\n\"\"\")\n\n    def test_fallback(self):\n        extr = TestExtractor.from_url(\"test:\")\n        tjob = self.jobclass(extr)\n        tjob.handle_url = tjob.handle_url_fallback\n\n        self.assertEqual(self._capture_stdout(tjob), \"\"\"\\\nhttps://example.org/1.jpg\n| https://example.org/alt/1.jpg\nhttps://example.org/2.jpg\n| https://example.org/alt/2.jpg\nhttps://example.org/3.jpg\n| https://example.org/alt/3.jpg\n\"\"\")\n\n    def test_parent(self):\n        extr = TestExtractorParent.from_url(\"test:parent\")\n        self.assertEqual(self._capture_stdout(extr), \"\"\"\\\ntest:child\ntest:child\ntest:child\n\"\"\")\n\n    def test_child(self):\n        extr = TestExtractorParent.from_url(\"test:parent\")\n        tjob = self.jobclass(extr, depth=0)\n        self.assertEqual(self._capture_stdout(tjob), 3 * \"\"\"\\\nhttps://example.org/1.jpg\nhttps://example.org/2.jpg\nhttps://example.org/3.jpg\n\"\"\")\n\n    def test_opt_init(self):\n        config.set((), \"init\", True)\n\n        extr = TestExtractorNoop.from_url(\"test:noop\")\n        tjob = self.jobclass(extr)\n        tjob._init()\n\n    def test_opt_follow(self):\n        config.set((), \"follow\", \"{user[bio]}\")\n\n        extr = TestExtractor.from_url(\"test:urls\")\n        tjob = self.jobclass(extr)\n        self.assertEqual(self._capture_stdout(tjob), \"\"\"\\\nhttps://example.org/1.jpg\nhttps://example.org/2.jpg\nhttps://example.org/3.jpg\nhttps://example1.org/content/abc\nhttps://example2.org/content?query=123\nhttps://example3.org/content/#frag\n\"\"\")\n\n\nclass TestInfoJob(TestJob):\n    jobclass = job.InfoJob\n\n    def test_default(self):\n        extr = TestExtractor.from_url(\"test:\")\n        self.assertEqual(self._capture_stdout(extr), \"\"\"\\\nCategory / Subcategory\n  \"test_category\" / \"test_subcategory\"\n\nFilename format (default):\n  \"test_{filename}.{extension}\"\n\nDirectory format (default):\n  [\"{category}\"]\n\n\"\"\")\n\n    def test_custom(self):\n        config.set((), \"filename\", \"custom\")\n        config.set((), \"directory\", (\"custom\",))\n        config.set((), \"sleep-request\", 321)\n        extr = TestExtractor.from_url(\"test:\")\n        extr.request_interval = 123.456\n\n        self.assertEqual(self._capture_stdout(extr), \"\"\"\\\nCategory / Subcategory\n  \"test_category\" / \"test_subcategory\"\n\nFilename format (custom):\n  \"custom\"\nFilename format (default):\n  \"test_{filename}.{extension}\"\n\nDirectory format (custom):\n  [\"custom\"]\nDirectory format (default):\n  [\"{category}\"]\n\nRequest interval (custom):\n  321\nRequest interval (default):\n  123.456\n\n\"\"\")\n\n    def test_base_category(self):\n        extr = TestExtractor.from_url(\"test:\")\n        extr.basecategory = \"test_basecategory\"\n        extr.basesubcategory = \"test_basesubcategory\"\n\n        self.assertEqual(self._capture_stdout(extr), \"\"\"\\\nCategory / Subcategory / Basecategory\n  \"test_category\" / \"test_subcategory\" / \"test_basecategory\"\n\nFilename format (default):\n  \"test_{filename}.{extension}\"\n\nDirectory format (default):\n  [\"{category}\"]\n\n\"\"\")\n\n    def test_opt_init(self):\n        config.set((), \"init\", True)\n\n        extr = TestExtractorNoop.from_url(\"test:noop\")\n        tjob = self.jobclass(extr)\n        tjob._init()\n\n\nclass TestDataJob(TestJob):\n    jobclass = job.DataJob\n\n    def test_default(self):\n        extr = TestExtractor.from_url(\"test:\")\n        tjob = self.jobclass(extr, file=io.StringIO())\n        user = {\"id\": 123, \"name\": \"test\"}\n\n        tjob.run()\n\n        self.assertEqual(tjob.data, [\n            (Message.Directory, {\n                \"category\"   : \"test_category\",\n                \"subcategory\": \"test_subcategory\",\n                \"user\"       : user,\n                \"author\"     : user,\n            }),\n            (Message.Url, \"https://example.org/1.jpg\", {\n                \"category\"   : \"test_category\",\n                \"subcategory\": \"test_subcategory\",\n                \"filename\"   : \"1\",\n                \"extension\"  : \"jpg\",\n                \"num\"        : 1,\n                \"tags\"       : [\"foo\", \"bar\", \"テスト\"],\n                \"user\"       : user,\n                \"author\"     : user,\n            }),\n            (Message.Url, \"https://example.org/2.jpg\", {\n                \"category\"   : \"test_category\",\n                \"subcategory\": \"test_subcategory\",\n                \"filename\"   : \"2\",\n                \"extension\"  : \"jpg\",\n                \"num\"        : 2,\n                \"tags\"       : [\"foo\", \"bar\", \"テスト\"],\n                \"user\"       : user,\n                \"author\"     : user,\n            }),\n            (Message.Url, \"https://example.org/3.jpg\", {\n                \"category\"   : \"test_category\",\n                \"subcategory\": \"test_subcategory\",\n                \"filename\"   : \"3\",\n                \"extension\"  : \"jpg\",\n                \"num\"        : 3,\n                \"tags\"       : [\"foo\", \"bar\", \"テスト\"],\n                \"user\"       : user,\n                \"author\"     : user,\n            }),\n        ])\n\n    def test_exception(self):\n        extr = TestExtractorException.from_url(\"test:exception\")\n        tjob = self.jobclass(extr, file=io.StringIO())\n        tjob.run()\n        self.assertEqual(\n            tjob.data[-1],\n            (-1, {\n                \"error\"  : \"ZeroDivisionError\",\n                \"message\": \"division by zero\",\n            })\n        )\n\n    def test_private(self):\n        config.set((\"output\",), \"private\", True)\n        extr = TestExtractor.from_url(\"test:\")\n        tjob = self.jobclass(extr, file=io.StringIO())\n\n        tjob.run()\n\n        for i in range(1, 4):\n            self.assertEqual(\n                tjob.data[i][2][\"_fallback\"],\n                (f\"https://example.org/alt/{i}.jpg\",),\n            )\n\n    def test_sleep(self):\n        extr = TestExtractor.from_url(\"test:\")\n        tjob = self.jobclass(extr, file=io.StringIO())\n\n        config.set((), \"sleep-extractor\", 123)\n        with patch(\"time.sleep\") as sleep:\n            tjob.run()\n        sleep.assert_called_once_with(123)\n\n        config.set((), \"sleep-extractor\", 0)\n        with patch(\"time.sleep\") as sleep:\n            tjob.run()\n        sleep.assert_not_called()\n\n    def test_ascii(self):\n        extr = TestExtractor.from_url(\"test:\")\n        tjob = self.jobclass(extr)\n\n        tjob.file = buffer = io.StringIO()\n        tjob.run()\n        self.assertIn(\"\"\"\\\n      \"tags\": [\n        \"foo\",\n        \"bar\",\n        \"\\\\u30c6\\\\u30b9\\\\u30c8\"\n      ],\n\"\"\", buffer.getvalue())\n\n        tjob.file = buffer = io.StringIO()\n        tjob.ascii = False\n        tjob.run()\n        self.assertIn(\"\"\"\\\n      \"tags\": [\n        \"foo\",\n        \"bar\",\n        \"テスト\"\n      ],\n\"\"\", buffer.getvalue())\n\n    def test_num_string(self):\n        extr = TestExtractor.from_url(\"test:\")\n        tjob = self.jobclass(extr, file=io.StringIO())\n\n        with patch(\"gallery_dl.util.number_to_string\") as nts:\n            tjob.run()\n        self.assertEqual(len(nts.call_args_list), 0)\n\n        config.set((\"output\",), \"num-to-str\", True)\n        with patch(\"gallery_dl.util.number_to_string\") as nts:\n            tjob.run()\n        self.assertEqual(len(nts.call_args_list), 72)\n\n        tjob.run()\n        self.assertEqual(tjob.data[-1][0], Message.Url)\n        self.assertEqual(tjob.data[-1][2][\"num\"], \"3\")\n\n    def test_jsonl(self):\n        extr = TestExtractor.from_url(\"test:\")\n        tjob = self.jobclass(extr, file=io.StringIO())\n        with patch(\"gallery_dl.job.DataJob.out\") as out:\n            tjob.run()\n        self.assertEqual(len(out.call_args_list), 0)\n\n        config.set((\"output\",), \"jsonl\", True)\n        extr = TestExtractor.from_url(\"test:\")\n        file = io.StringIO()\n        tjob = self.jobclass(extr, file=file)\n        with patch(\"gallery_dl.job.DataJob.out\") as out:\n            tjob.run()\n        self.assertEqual(len(out.call_args_list), 4)\n\n        tjob.run()\n        for line in file.getvalue().split():\n            self.assertRegex(line, r\"\"\"^\\[[23],(\"http[^\"]+\",)?\\{.+\\}\\]$\"\"\")\n\n    def test_opt_init(self):\n        config.set((), \"init\", True)\n\n        extr = TestExtractorNoop.from_url(\"test:noop\")\n        tjob = self.jobclass(extr)\n        tjob._init()\n\n    def test_opt_follow(self):\n        config.set((), \"follow\", \"{user[bio]!R}\")\n\n        extr = TestExtractor.from_url(\"test:urls\")\n        tjob = self.jobclass(extr, file=None)\n        tjob.run()\n        self.assertEqual(tjob.data_urls, [\n            \"https://example.org/1.jpg\",\n            \"https://example.org/2.jpg\",\n            \"https://example.org/3.jpg\",\n            \"https://example1.org/content/abc\",\n            \"https://example2.org/content?query=123\",\n            \"https://example3.org/content/#frag\"\n        ])\n\n    def test_resolve(self):\n        extr = TestExtractorParent.from_url(\"test:parent:3\")\n        tjob = self.jobclass(extr, file=None, resolve=0)\n        tjob.run()\n        self.assertEqual(len(tjob.data_urls), 3)\n        for url in tjob.data_urls:\n            self.assertEqual(url, \"test:parent:2\")\n\n        extr = TestExtractorParent.from_url(\"test:parent:3\")\n        tjob = self.jobclass(extr, file=None, resolve=1)\n        tjob.run()\n        self.assertEqual(len(tjob.data_urls), 9)\n        for url in tjob.data_urls:\n            self.assertEqual(url, \"test:parent:1\")\n\n        extr = TestExtractorParent.from_url(\"test:parent\")\n        tjob = self.jobclass(extr, file=None, resolve=64)\n        tjob.run()\n        self.assertEqual(len(tjob.data_urls), 9)\n        for url in tjob.data_urls:\n            self.assertRegex(url, r\"^https://example.org/\\d\\.jpg$\")\n\n        extr = TestExtractorParent.from_url(\"test:parent:1\")\n        tjob = self.jobclass(extr, file=None, resolve=64)\n        tjob.run()\n        self.assertEqual(len(tjob.data_urls), 27)\n\n        extr = TestExtractorParent.from_url(\"test:parent:2\")\n        tjob = self.jobclass(extr, file=None, resolve=64)\n        tjob.run()\n        self.assertEqual(len(tjob.data_urls), 81)\n\n\nclass TestExtractor(Extractor):\n    category = \"test_category\"\n    subcategory = \"test_subcategory\"\n    directory_fmt = (\"{category}\",)\n    filename_fmt = \"test_{filename}.{extension}\"\n    pattern = r\"test:(child|self|urls)?$\"\n\n    def __init__(self, match):\n        Extractor.__init__(self, match)\n        self.user = {\"id\": 123, \"name\": \"test\"}\n        if match[1] == \"self\":\n            self.user[\"self\"] = self.user\n        elif match[1] == \"urls\":\n            self.user[\"bio\"] = \"\"\"\nSite 1:\n* https://example1.org/content/abc\nSite 2:\n* https://example2.org/content?query=123\n\n<a href=\"https://example3.org/content/#frag\">Site 3</a>\n\"\"\"\n\n    def items(self):\n        root = \"https://example.org\"\n        user = self.user\n\n        yield Message.Directory, \"\", {\n            \"user\": user,\n            \"author\": user,\n        }\n\n        for i in range(1, 4):\n            url = f\"{root}/{i}.jpg\"\n            yield Message.Url, url, text.nameext_from_url(url, {\n                \"num\" : i,\n                \"tags\": [\"foo\", \"bar\", \"テスト\"],\n                \"user\": user,\n                \"author\": user,\n                \"_fallback\": (f\"{root}/alt/{i}.jpg\",),\n            })\n\n\nclass TestExtractorParent(Extractor):\n    category = \"test_category\"\n    subcategory = \"test_subcategory_parent\"\n    pattern = r\"test:parent(:\\d+)?\"\n\n    def items(self):\n        level = self.groups[0]\n        if level in {None, \":0\"}:\n            url = \"test:child\"\n            extr = TestExtractor\n        else:\n            url = f\"test:parent:{int(level[1:])-1}\"\n            extr = TestExtractorParent\n\n        for i in range(11, 14):\n            yield Message.Queue, url, {\n                \"num\" : i,\n                \"tags\": [\"abc\", \"def\"],\n                \"_extractor\": extr,\n            }\n\n\nclass TestExtractorException(Extractor):\n    category = \"test_category\"\n    subcategory = \"test_subcategory_exception\"\n    pattern = r\"test:exception$\"\n\n    def items(self):\n        return 1/0\n\n\nclass TestExtractorNoop(Extractor):\n    category = \"test_category_alt\"\n    subcategory = \"test_subcategory\"\n    pattern = r\"test:noop\"\n\n\nif __name__ == \"__main__\":\n    unittest.main()\n"
  },
  {
    "path": "test/test_oauth.py",
    "content": "#!/usr/bin/env python3\n# -*- coding: utf-8 -*-\n\n# Copyright 2018-2023 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nimport os\nimport sys\nimport unittest\nfrom unittest.mock import patch\n\nsys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))\nfrom gallery_dl import oauth, text  # noqa E402\n\nTESTSERVER = \"http://term.ie/oauth/example\"\nCONSUMER_KEY = \"key\"\nCONSUMER_SECRET = \"secret\"\nREQUEST_TOKEN = \"requestkey\"\nREQUEST_TOKEN_SECRET = \"requestsecret\"\nACCESS_TOKEN = \"accesskey\"\nACCESS_TOKEN_SECRET = \"accesssecret\"\n\n\nclass TestOAuthSession(unittest.TestCase):\n\n    def test_concat(self):\n        concat = oauth.concat\n\n        self.assertEqual(concat(), \"\")\n        self.assertEqual(concat(\"str\"), \"str\")\n        self.assertEqual(concat(\"str1\", \"str2\"), \"str1&str2\")\n\n        self.assertEqual(concat(\"&\", \"?/\"), \"%26&%3F%2F\")\n        self.assertEqual(\n            concat(\"GET\", \"http://example.org/\", \"foo=bar&baz=a\"),\n            \"GET&http%3A%2F%2Fexample.org%2F&foo%3Dbar%26baz%3Da\"\n        )\n\n    def test_nonce(self, size=16):\n        nonce_values = set(oauth.nonce(size) for _ in range(size))\n\n        # uniqueness\n        self.assertEqual(len(nonce_values), size)\n\n        # length\n        for nonce in nonce_values:\n            self.assertEqual(len(nonce), size)\n\n    def test_quote(self):\n        quote = oauth.quote\n\n        reserved = \",;:!\\\"§$%&/(){}[]=?`´+*'äöü\"\n        unreserved = (\"ABCDEFGHIJKLMNOPQRSTUVWXYZ\"\n                      \"abcdefghijklmnopqrstuvwxyz\"\n                      \"0123456789-._~\")\n\n        for char in unreserved:\n            self.assertEqual(quote(char), char)\n\n        for char in reserved:\n            quoted = quote(char)\n            quoted_hex = quoted.replace(\"%\", \"\")\n            self.assertTrue(quoted.startswith(\"%\"))\n            self.assertTrue(len(quoted) >= 3)\n            self.assertEqual(quoted_hex.upper(), quoted_hex)\n\n    def test_generate_signature(self):\n        client = oauth.OAuth1Client(\n            CONSUMER_KEY, CONSUMER_SECRET, ACCESS_TOKEN, ACCESS_TOKEN_SECRET)\n\n        request = MockRequest()\n        params = []\n        self.assertEqual(\n            client.generate_signature(request, params),\n            \"Wt2xo49dM5pkL4gsnCakNdHaVUo%3D\")\n\n        request = MockRequest(\"https://example.org/\")\n        params = [(\"hello\", \"world\"), (\"foo\", \"bar\")]\n        self.assertEqual(\n            client.generate_signature(request, params),\n            \"ay2269%2F8uKpZqKJR1doTtpv%2Bzn0%3D\")\n\n        request = MockRequest(\"https://example.org/index.html\"\n                              \"?hello=world&foo=bar\", method=\"POST\")\n        params = [(\"oauth_signature_method\", \"HMAC-SHA1\")]\n        self.assertEqual(\n            client.generate_signature(request, params),\n            \"yVZWb1ts4smdMmXxMlhaXrkoOng%3D\")\n\n    def test_dunder_call(self):\n        client = oauth.OAuth1Client(\n            CONSUMER_KEY, CONSUMER_SECRET, ACCESS_TOKEN, ACCESS_TOKEN_SECRET)\n        request = MockRequest(\"https://example.org/\")\n\n        with patch(\"time.time\") as tmock, \\\n             patch(\"gallery_dl.oauth.nonce\") as nmock:\n            tmock.return_value = 123456789.123\n            nmock.return_value = \"abcdefghijklmno\"\n\n            client(request)\n\n        self.assertEqual(\n            request.headers[\"Authorization\"],\n            \"\"\"OAuth \\\noauth_consumer_key=\"key\",\\\noauth_nonce=\"abcdefghijklmno\",\\\noauth_signature_method=\"HMAC-SHA1\",\\\noauth_timestamp=\"123456789\",\\\noauth_version=\"1.0\",\\\noauth_token=\"accesskey\",\\\noauth_signature=\"DjtTk5j5P3BDZFnstZ%2FtEYcwD6c%3D\"\\\n\"\"\")\n\n    def test_request_token(self):\n        response = self._oauth_request(\n            \"/request_token.php\", {})\n        expected = \"oauth_token=requestkey&oauth_token_secret=requestsecret\"\n        self.assertEqual(response, expected, msg=response)\n\n        data = text.parse_query(response)\n        self.assertTrue(data[\"oauth_token\"], REQUEST_TOKEN)\n        self.assertTrue(data[\"oauth_token_secret\"], REQUEST_TOKEN_SECRET)\n\n    def test_access_token(self):\n        response = self._oauth_request(\n            \"/access_token.php\", {}, REQUEST_TOKEN, REQUEST_TOKEN_SECRET)\n        expected = \"oauth_token=accesskey&oauth_token_secret=accesssecret\"\n        self.assertEqual(response, expected, msg=response)\n\n        data = text.parse_query(response)\n        self.assertTrue(data[\"oauth_token\"], ACCESS_TOKEN)\n        self.assertTrue(data[\"oauth_token_secret\"], ACCESS_TOKEN_SECRET)\n\n    def test_authenticated_call(self):\n        params = {\"method\": \"foo\", \"a\": \"äöüß/?&#\", \"äöüß/?&#\": \"a\"}\n        response = self._oauth_request(\n            \"/echo_api.php\", params, ACCESS_TOKEN, ACCESS_TOKEN_SECRET)\n\n        self.assertEqual(text.parse_query(response), params)\n\n    def _oauth_request(self, endpoint, params=None,\n                       oauth_token=None, oauth_token_secret=None):\n        # the test server at 'term.ie' is unreachable\n        raise unittest.SkipTest()\n\n        session = oauth.OAuth1Session(\n            CONSUMER_KEY, CONSUMER_SECRET,\n            oauth_token, oauth_token_secret,\n        )\n        try:\n            response = session.get(TESTSERVER + endpoint, params=params)\n            response.raise_for_status()\n            return response.text\n        except OSError:\n            raise unittest.SkipTest()\n\n\nclass MockRequest():\n\n    def __init__(self, url=\"\", method=\"GET\"):\n        self.url = url\n        self.method = method\n        self.headers = {}\n\n\nif __name__ == \"__main__\":\n    unittest.main(warnings=\"ignore\")\n"
  },
  {
    "path": "test/test_output.py",
    "content": "#!/usr/bin/env python3\n# -*- coding: utf-8 -*-\n\n# Copyright 2021 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nimport os\nimport sys\nimport unittest\n\nsys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))\nfrom gallery_dl import output  # noqa E402\n\n\nclass TestShorten(unittest.TestCase):\n\n    def test_shorten_noop(self, f=output.shorten_string):\n        self.assertEqual(f(\"\"      , 10), \"\")\n        self.assertEqual(f(\"foobar\", 10), \"foobar\")\n\n    def test_shorten(self, f=output.shorten_string):\n        s = \"01234567890123456789\"  # string of length 20\n        self.assertEqual(f(s, 30), s)\n        self.assertEqual(f(s, 25), s)\n        self.assertEqual(f(s, 20), s)\n        self.assertEqual(f(s, 19), \"012345678…123456789\")\n        self.assertEqual(f(s, 18), \"01234567…123456789\")\n        self.assertEqual(f(s, 17), \"01234567…23456789\")\n        self.assertEqual(f(s, 16), \"0123456…23456789\")\n        self.assertEqual(f(s, 15), \"0123456…3456789\")\n        self.assertEqual(f(s, 14), \"012345…3456789\")\n        self.assertEqual(f(s, 13), \"012345…456789\")\n        self.assertEqual(f(s, 12), \"01234…456789\")\n        self.assertEqual(f(s, 11), \"01234…56789\")\n        self.assertEqual(f(s, 10), \"0123…56789\")\n        self.assertEqual(f(s, 9) , \"0123…6789\")\n        self.assertEqual(f(s, 3) , \"0…9\")\n        self.assertEqual(f(s, 2) , \"…9\")\n\n    def test_shorten_separator(self, f=output.shorten_string):\n        s = \"01234567890123456789\"  # string of length 20\n        self.assertEqual(f(s, 20, \"|---|\"), s)\n        self.assertEqual(f(s, 19, \"|---|\"), \"0123456|---|3456789\")\n        self.assertEqual(f(s, 15, \"|---|\"), \"01234|---|56789\")\n        self.assertEqual(f(s, 10, \"|---|\"), \"01|---|789\")\n\n        self.assertEqual(f(s, 19, \"...\"), \"01234567...23456789\")\n        self.assertEqual(f(s, 19, \"..\") , \"01234567..123456789\")\n        self.assertEqual(f(s, 19, \".\")  , \"012345678.123456789\")\n        self.assertEqual(f(s, 19, \"\")   , \"0123456780123456789\")\n\n\nclass TestShortenEAW(unittest.TestCase):\n\n    def test_shorten_eaw_noop(self, f=output.shorten_string_eaw):\n        self.assertEqual(f(\"\"      , 10), \"\")\n        self.assertEqual(f(\"foobar\", 10), \"foobar\")\n\n    def test_shorten_eaw(self, f=output.shorten_string_eaw):\n        s = \"01234567890123456789\"  # 20 ascii characters\n        self.assertEqual(f(s, 30), s)\n        self.assertEqual(f(s, 25), s)\n        self.assertEqual(f(s, 20), s)\n        self.assertEqual(f(s, 19), \"012345678…123456789\")\n        self.assertEqual(f(s, 18), \"01234567…123456789\")\n        self.assertEqual(f(s, 17), \"01234567…23456789\")\n        self.assertEqual(f(s, 16), \"0123456…23456789\")\n        self.assertEqual(f(s, 15), \"0123456…3456789\")\n        self.assertEqual(f(s, 14), \"012345…3456789\")\n        self.assertEqual(f(s, 13), \"012345…456789\")\n        self.assertEqual(f(s, 12), \"01234…456789\")\n        self.assertEqual(f(s, 11), \"01234…56789\")\n        self.assertEqual(f(s, 10), \"0123…56789\")\n        self.assertEqual(f(s, 9) , \"0123…6789\")\n        self.assertEqual(f(s, 3) , \"0…9\")\n        self.assertEqual(f(s, 2) , \"…9\")\n\n    def test_shorten_eaw_wide(self, f=output.shorten_string_eaw):\n        s = \"幻想郷幻想郷幻想郷幻想郷\"  # 12 wide characters\n        self.assertEqual(f(s, 30), s)\n        self.assertEqual(f(s, 25), s)\n        self.assertEqual(f(s, 20), \"幻想郷幻…想郷幻想郷\")\n        self.assertEqual(f(s, 19), \"幻想郷幻…想郷幻想郷\")\n        self.assertEqual(f(s, 18), \"幻想郷幻…郷幻想郷\")\n        self.assertEqual(f(s, 17), \"幻想郷幻…郷幻想郷\")\n        self.assertEqual(f(s, 16), \"幻想郷…郷幻想郷\")\n        self.assertEqual(f(s, 15), \"幻想郷…郷幻想郷\")\n        self.assertEqual(f(s, 14), \"幻想郷…幻想郷\")\n        self.assertEqual(f(s, 13), \"幻想郷…幻想郷\")\n        self.assertEqual(f(s, 12), \"幻想…幻想郷\")\n        self.assertEqual(f(s, 11), \"幻想…幻想郷\")\n        self.assertEqual(f(s, 10), \"幻想…想郷\")\n        self.assertEqual(f(s, 9) , \"幻想…想郷\")\n        self.assertEqual(f(s, 3) , \"…郷\")\n\n    def test_shorten_eaw_mix(self, f=output.shorten_string_eaw):\n        s = \"幻-想-郷##幻-想-郷##幻-想-郷\"  # mixed characters\n        self.assertEqual(f(s, 28), s)\n        self.assertEqual(f(s, 25), \"幻-想-郷##幻…郷##幻-想-郷\")\n\n        self.assertEqual(f(s, 20), \"幻-想-郷#…##幻-想-郷\")\n        self.assertEqual(f(s, 19), \"幻-想-郷#…#幻-想-郷\")\n        self.assertEqual(f(s, 18), \"幻-想-郷…#幻-想-郷\")\n        self.assertEqual(f(s, 17), \"幻-想-郷…幻-想-郷\")\n        self.assertEqual(f(s, 16), \"幻-想-…#幻-想-郷\")\n        self.assertEqual(f(s, 15), \"幻-想-…幻-想-郷\")\n        self.assertEqual(f(s, 14), \"幻-想-…-想-郷\")\n        self.assertEqual(f(s, 13), \"幻-想-…-想-郷\")\n        self.assertEqual(f(s, 12), \"幻-想…-想-郷\")\n        self.assertEqual(f(s, 11), \"幻-想…想-郷\")\n        self.assertEqual(f(s, 10), \"幻-…-想-郷\")\n        self.assertEqual(f(s, 9) , \"幻-…想-郷\")\n        self.assertEqual(f(s, 3) , \"…郷\")\n\n    def test_shorten_eaw_separator(self, f=output.shorten_string_eaw):\n        s = \"01234567890123456789\"  # 20 ascii characters\n        self.assertEqual(f(s, 20, \"|---|\"), s)\n        self.assertEqual(f(s, 19, \"|---|\"), \"0123456|---|3456789\")\n        self.assertEqual(f(s, 15, \"|---|\"), \"01234|---|56789\")\n        self.assertEqual(f(s, 10, \"|---|\"), \"01|---|789\")\n\n        self.assertEqual(f(s, 19, \"...\"), \"01234567...23456789\")\n        self.assertEqual(f(s, 19, \"..\") , \"01234567..123456789\")\n        self.assertEqual(f(s, 19, \".\")  , \"012345678.123456789\")\n        self.assertEqual(f(s, 19, \"\")   , \"0123456780123456789\")\n\n    def test_shorten_eaw_separator_wide(self, f=output.shorten_string_eaw):\n        s = \"幻想郷幻想郷幻想郷幻想郷\"  # 12 wide characters\n        self.assertEqual(f(s, 24, \"|---|\"), s)\n        self.assertEqual(f(s, 19, \"|---|\"), \"幻想郷|---|郷幻想郷\")\n        self.assertEqual(f(s, 15, \"|---|\"), \"幻想|---|幻想郷\")\n        self.assertEqual(f(s, 10, \"|---|\"), \"幻|---|郷\")\n\n        self.assertEqual(f(s, 19, \"...\"), \"幻想郷幻...郷幻想郷\")\n        self.assertEqual(f(s, 19, \"..\") , \"幻想郷幻..郷幻想郷\")\n        self.assertEqual(f(s, 19, \".\")  , \"幻想郷幻.想郷幻想郷\")\n        self.assertEqual(f(s, 19, \"\")   , \"幻想郷幻想郷幻想郷\")\n\n    def test_shorten_eaw_separator_mix_(self, f=output.shorten_string_eaw):\n        s = \"幻-想-郷##幻-想-郷##幻-想-郷\"  # mixed characters\n        self.assertEqual(f(s, 30, \"|---|\"), s)\n        self.assertEqual(f(s, 19, \"|---|\"), \"幻-想-|---|幻-想-郷\")\n        self.assertEqual(f(s, 15, \"|---|\"), \"幻-想|---|想-郷\")\n        self.assertEqual(f(s, 10, \"|---|\"), \"幻|---|-郷\")\n\n        self.assertEqual(f(s, 19, \"...\"), \"幻-想-郷...幻-想-郷\")\n        self.assertEqual(f(s, 19, \"..\") , \"幻-想-郷..#幻-想-郷\")\n        self.assertEqual(f(s, 19, \".\")  , \"幻-想-郷#.#幻-想-郷\")\n        self.assertEqual(f(s, 19, \"\")   , \"幻-想-郷###幻-想-郷\")\n\n\nif __name__ == \"__main__\":\n    unittest.main()\n"
  },
  {
    "path": "test/test_path.py",
    "content": "#!/usr/bin/env python3\n# -*- coding: utf-8 -*-\n\n# Copyright 2025-2026 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nimport os\nimport sys\nimport unittest\nfrom unittest.mock import patch\n\nsys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))\nfrom gallery_dl import path, extractor, config  # noqa E402\n\nKWDICT = {\n    \"category\" : \"test\",\n    \"filename\" : \"file\",\n    \"extension\": \"ext\",\n    \"name\"     : \"test-テスト-'&>-/:~\",\n    \"ext\"      : \"txt\",\n    \"foo\"      : \"bar\",\n    \"id\"       : 123,\n}\n\n\nclass TestPath(unittest.TestCase):\n\n    def _pfmt(self, data={}, kwdict=False, extr=extractor.find(\"noop\")):\n        pathfmt = path.PathFormat(extr)\n\n        if kwdict:\n            pathfmt.set_directory({\n                **(kwdict if isinstance(kwdict, dict) else KWDICT),\n                **data,\n            })\n\n        return pathfmt\n\n    def setUp(self):\n        config.clear()\n        path.WINDOWS = False\n\n\nclass TestPathObject(TestPath):\n\n    def test_default(self):\n        pfmt = self._pfmt()\n\n        self.assertEqual(pfmt.kwdict, {})\n        self.assertEqual(pfmt.delete, False)\n        self.assertEqual(pfmt.filename, \"\")\n        self.assertEqual(pfmt.extension, \"\")\n        self.assertEqual(pfmt.directory, \"\")\n        self.assertEqual(pfmt.realdirectory, \"\")\n        self.assertEqual(pfmt.path, \"\")\n        self.assertEqual(pfmt.realpath, \"\")\n        self.assertEqual(pfmt.temppath, \"\")\n        self.assertEqual(pfmt.basedirectory, \"./gallery-dl/\")\n        self.assertEqual(pfmt.strip, \"\")\n\n        self.assertIs(pfmt.filename_conditions, None)\n        self.assertIs(pfmt.directory_conditions, None)\n\n        self.assertTrue(callable(pfmt.extension_map))\n        self.assertTrue(callable(pfmt.extension_map))\n        self.assertTrue(callable(pfmt.extension_map))\n        self.assertTrue(callable(pfmt.clean_segment))\n        self.assertTrue(callable(pfmt.clean_path))\n\n        self.assertTrue(callable(pfmt.filename_formatter))\n        for fmt in pfmt.directory_formatters:\n            self.assertTrue(callable(fmt))\n\n    def test_str(self):\n        pfmt = self._pfmt()\n        self.assertEqual(str(pfmt), pfmt.realdirectory)\n        self.assertEqual(str(pfmt), \"\")\n\n        pfmt = self._pfmt()\n        pfmt.set_directory(KWDICT)\n        pfmt.set_filename(KWDICT)\n        pfmt.build_path()\n        self.assertEqual(str(pfmt), pfmt.realpath)\n        self.assertEqual(str(pfmt), \"./gallery-dl/test/file.ext\")\n\n    def test_generate_path(self):\n        pfmt = self._pfmt(kwdict=True)\n        self.assertEqual(pfmt.generate_path([]), \"\")\n        self.assertEqual(pfmt.generate_path([\"foo\"]), \"foo\")\n        self.assertEqual(pfmt.generate_path([\"foo\", \"bar\"]), \"foo/bar\")\n        self.assertEqual(pfmt.generate_path(\n            [\"foo\", \"bar\", \"{id:A+1}\"]), \"foo/bar/124\")\n\n    @patch(\"os.sep\", \"/\")\n    @patch(\"gallery_dl.path.WINDOWS\", False)\n    def test_generate_path_unix(self):\n        pfmt = self._pfmt(kwdict=True)\n        pfmt.set_directory(KWDICT)\n        os.environ[\"_GDL_TEST\"] = \"/opt\"\n\n        self.assertEqual(pfmt.generate_path(\n            [\"{category}/foo\", \"bar\", \"{name}.{ext}\"]),\n            \"test_foo/bar/test-テスト-'&>-_:~.txt\")\n        self.assertEqual(pfmt.generate_path(\n            [\":\", \"{category}/foo\", \"bar\", \"{name}.{ext}\"]),\n            pfmt.basedirectory + \"test_foo/bar/test-テスト-'&>-_:~.txt\")\n        self.assertEqual(pfmt.generate_path(\n            [\":b\", \"{category}/foo\", \"bar\", \"{name}.{ext}\"]),\n            pfmt.basedirectory + \"test_foo/bar/test-テスト-'&>-_:~.txt\")\n        self.assertEqual(pfmt.generate_path(\n            [\":d\", \"{category}/foo\", \"bar\", \"{name}.{ext}\"]),\n            pfmt.realdirectory + \"test_foo/bar/test-テスト-'&>-_:~.txt\")\n        self.assertEqual(pfmt.generate_path(\n            [\":$_GDL_TEST\", \"{category}/foo\", \"bar\", \"{name}.{ext}\"]),\n            \"/opt/test_foo/bar/test-テスト-'&>-_:~.txt\")\n        self.assertEqual(pfmt.generate_path(\n            [\"/\", \"opt\", \"{category}/foo\", \"bar\", \"{name}.{ext}\"]),\n            \"/opt/test_foo/bar/test-テスト-'&>-_:~.txt\")\n        self.assertEqual(pfmt.generate_path(\n            [\"/opt\", \"{category}/foo\", \"bar\", \"{name}.{ext}\"]),\n            \"/opt/test_foo/bar/test-テスト-'&>-_:~.txt\")\n        with patch(\"os.path.expanduser\", return_value=\"/home/USER\"):\n            self.assertEqual(pfmt.generate_path(\n                [\":~\", \"{category}/foo\", \"bar\", \"{name}.{ext}\"]),\n                \"/home/USER/test_foo/bar/test-テスト-'&>-_:~.txt\")\n            self.assertEqual(pfmt.generate_path(\n                [\":~FOO\", \"{category}/foo\", \"bar\", \"{name}.{ext}\"]),\n                \"/home/USER/test_foo/bar/test-テスト-'&>-_:~.txt\")\n\n    @patch(\"os.sep\", \"\\\\\")\n    @patch(\"gallery_dl.path.WINDOWS\", True)\n    def test_generate_path_windows(self):\n        pfmt = self._pfmt(kwdict=True)\n        pfmt.set_directory(KWDICT)\n        os.environ[\"_GDL_TEST\"] = \"C:\\\\\"\n\n        self.assertEqual(pfmt.generate_path(\n            [\"{category}/foo\", \"bar\", \"{name}.{ext}\"]),\n            r\"test_foo\\bar\\test-テスト-'&_-__~.txt\")\n        self.assertEqual(pfmt.generate_path(\n            [\":\", \"{category}/foo\", \"bar\", \"{name}.{ext}\"]),\n            pfmt.basedirectory + r\"test_foo\\bar\\test-テスト-'&_-__~.txt\")\n        self.assertEqual(pfmt.generate_path(\n            [\":b\", \"{category}/foo\", \"bar\", \"{name}.{ext}\"]),\n            pfmt.basedirectory + r\"test_foo\\bar\\test-テスト-'&_-__~.txt\")\n        self.assertEqual(pfmt.generate_path(\n            [\":d\", \"{category}/foo\", \"bar\", \"{name}.{ext}\"]),\n            pfmt.realdirectory + r\"test_foo\\bar\\test-テスト-'&_-__~.txt\")\n        self.assertEqual(pfmt.generate_path(\n            [\":$_GDL_TEST\", \"{category}/foo\", \"bar\", \"{name}.{ext}\"]),\n            r\"C:\\test_foo\\bar\\test-テスト-'&_-__~.txt\")\n        self.assertEqual(pfmt.generate_path(\n            [\"C:\", \"{category}/foo\", \"bar\", \"{name}.{ext}\"]),\n            r\"C:\\test_foo\\bar\\test-テスト-'&_-__~.txt\")\n        self.assertEqual(pfmt.generate_path(\n            [\"C:\\\\\", \"{category}/foo\", \"bar\", \"{name}.{ext}\"]),\n            r\"C:\\test_foo\\bar\\test-テスト-'&_-__~.txt\")\n        self.assertEqual(pfmt.generate_path(\n            [\"\\\\\\\\\", \"server\", \"share\", \"{category}/foo\", \"{name}.{ext}\"]),\n            r\"\\\\server\\share\\test_foo\\test-テスト-'&_-__~.txt\")\n        self.assertEqual(pfmt.generate_path(\n            [\"\\\\\\\\server\\\\share\", \"{category}/foo\", \"bar\", \"{name}.{ext}\"]),\n            r\"\\\\server\\share\\test_foo\\bar\\test-テスト-'&_-__~.txt\")\n        self.assertEqual(pfmt.generate_path(\n            [\"\\\\\\\\server\\\\share\\\\\", \"{category}/foo\", \"bar\", \"{name}.{ext}\"]),\n            r\"\\\\server\\share\\test_foo\\bar\\test-テスト-'&_-__~.txt\")\n        with patch(\"os.path.expanduser\", return_value=r\"C:\\Users\\USER\"):\n            self.assertEqual(pfmt.generate_path(\n                [\":~\", \"{category}/foo\", \"bar\", \"{name}.{ext}\"]),\n                r\"C:\\Users\\USER\\test_foo\\bar\\test-テスト-'&_-__~.txt\")\n            self.assertEqual(pfmt.generate_path(\n                [\":~USER\", \"{category}/foo\", \"bar\", \"{name}.{ext}\"]),\n                r\"C:\\Users\\USER\\test_foo\\bar\\test-テスト-'&_-__~.txt\")\n\n\nclass TestPathOptions(TestPath):\n\n    def test_option_filename(self):\n        fname = self._pfmt().build_filename(KWDICT)\n        self.assertEqual(fname , \"file.ext\")\n\n        config.set((), \"filename\", \"foo.{foo}\")\n        fname = self._pfmt().build_filename(KWDICT)\n        self.assertEqual(fname, \"foo.bar\")\n\n        config.set((), \"filename\", {\n            \"foo == 'baz'\": \"foo\",\n            \"id % 2\"      : \"bar\",\n            \"\"            : \"baz\",\n        })\n        fname = self._pfmt().build_filename(KWDICT)\n        self.assertEqual(fname, \"bar\")\n\n    def test_option_directory(self):\n        pfmt = self._pfmt(kwdict=True)\n        self.assertEqual(pfmt.directory    , \"./gallery-dl/test/\")\n        self.assertEqual(pfmt.realdirectory, \"./gallery-dl/test/\")\n\n        config.set((), \"directory\", [\"{foo}\", \"bar\"])\n        pfmt = self._pfmt(kwdict=True)\n        self.assertEqual(pfmt.directory    , \"./gallery-dl/bar/bar/\")\n        self.assertEqual(pfmt.realdirectory, \"./gallery-dl/bar/bar/\")\n\n        config.set((), \"directory\", {\n            \"foo == 'baz'\": [\"a\", \"b\", \"c\"],\n            \"id % 2\"      : [\"odd\", \"{id}\"],\n            \"\"            : [\"{foo}\", \"bar\"],\n        })\n        pfmt = self._pfmt(kwdict=True)\n        self.assertEqual(pfmt.directory    , \"./gallery-dl/odd/123/\")\n        self.assertEqual(pfmt.realdirectory, \"./gallery-dl/odd/123/\")\n\n    def test_option_basedirectory(self):\n        config.set((), \"base-directory\", \"{foo}\")\n        pfmt = self._pfmt(kwdict=True)\n        self.assertEqual(pfmt.realdirectory, \"{foo}/test/\")\n\n        config.set((), \"base-directory\", {\n            \"foo == 'baz'\": \"bar\",\n            \"id % 2\"      : \"./odd\",\n            \"\"            : \"./default\",\n        })\n        pfmt = self._pfmt(kwdict=True)\n        self.assertEqual(pfmt.realdirectory, \"./odd/test/\")\n\n    def test_option_keywordsdefault(self):\n        config.set((), \"directory\", [\"{baz}\"])\n        config.set((), \"base-directory\", \"\")\n\n        pfmt = self._pfmt(kwdict=True)\n        self.assertEqual(pfmt.realdirectory, \"None/\")\n\n        config.set((), \"keywords-default\", \"ND\")\n        pfmt = self._pfmt(kwdict=True)\n        self.assertEqual(pfmt.realdirectory, \"ND/\")\n\n        config.set((), \"keywords-default\", \"\")\n        pfmt = self._pfmt(kwdict=True)\n        self.assertEqual(pfmt.realdirectory, \"\")\n\n    def test_option_extensionmap_default(self):\n        kwdict = KWDICT.copy()\n        pfmt = self._pfmt()\n        pfmt.set_filename(kwdict)\n        self.assertEqual(pfmt.extension, \"ext\")\n\n        pfmt.set_extension(\"jpg\")\n        self.assertEqual(pfmt.extension, \"jpg\")\n        self.assertEqual(kwdict[\"extension\"], \"jpg\")\n\n        pfmt.set_extension(\"png\")\n        self.assertEqual(pfmt.extension, \"png\")\n        self.assertEqual(kwdict[\"extension\"], \"png\")\n\n        pfmt.set_extension(\"jpeg\")\n        self.assertEqual(pfmt.extension, \"jpg\")\n        self.assertEqual(kwdict[\"extension\"], \"jpg\")\n\n        for ext, repl in path.EXTENSION_MAP.items():\n            pfmt.set_extension(ext)\n            self.assertEqual(pfmt.extension, repl)\n            self.assertEqual(kwdict[\"extension\"], repl)\n\n    def test_option_extensionmap_custom(self):\n        extmap = {\n            \"bitmap\": \"bmp\",\n            \"ping\"  : \"png\",\n            \"jiff\"  : \"gif\",\n        }\n        config.set((), \"extension-map\", extmap)\n\n        kwdict = KWDICT.copy()\n        pfmt = self._pfmt()\n        pfmt.set_filename(kwdict)\n\n        pfmt.set_extension(\"jpg\")\n        self.assertEqual(pfmt.extension, \"jpg\")\n        self.assertEqual(kwdict[\"extension\"], \"jpg\")\n\n        pfmt.set_extension(\"ping\")\n        self.assertEqual(pfmt.extension, \"png\")\n        self.assertEqual(kwdict[\"extension\"], \"png\")\n\n        for ext, repl in extmap.items():\n            pfmt.set_extension(ext)\n            self.assertEqual(pfmt.extension, repl)\n            self.assertEqual(kwdict[\"extension\"], repl)\n\n        for ext, repl in path.EXTENSION_MAP.items():\n            pfmt.set_extension(ext)\n            self.assertNotEqual(pfmt.extension, repl)\n            self.assertNotEqual(kwdict[\"extension\"], repl)\n\n    def test_option_pathrestrict(self):\n        config.set((), \"filename\", \"{name}.{ext}\")\n\n        config.set((), \"path-restrict\", \"unix\")\n        fname = self._pfmt().build_filename(KWDICT)\n        self.assertEqual(fname, \"test-テスト-'&>-_:~.txt\", \"unix\")\n\n        config.set((), \"path-restrict\", \"windows\")\n        fname = self._pfmt().build_filename(KWDICT)\n        self.assertEqual(fname, \"test-テスト-'&_-__~.txt\", \"windows\")\n\n        config.set((), \"path-restrict\", \"windows+\")\n        fname = self._pfmt().build_filename(KWDICT)\n        self.assertEqual(fname, \"test-テスト-'&＞-⧸：~.txt\", \"windows+\")\n\n        config.set((), \"path-restrict\", \"ascii\")\n        fname = self._pfmt().build_filename(KWDICT)\n        self.assertEqual(fname, \"test____________.txt\", \"ascii\")\n\n        config.set((), \"path-restrict\", \"ascii+\")\n        fname = self._pfmt().build_filename(KWDICT)\n        self.assertEqual(fname, \"test-___-'&_-__~.txt\", \"ascii+\")\n\n    def test_option_pathrestrict_custom(self):\n        config.set((), \"filename\", \"{name}.{ext}\")\n\n        config.set((), \"path-restrict\", \"ts-\")\n        fname = self._pfmt().build_filename(KWDICT)\n        self.assertEqual(fname, \"_e___テスト_'&>_/:~._x_\", \"custom str\")\n\n        config.set((), \"path-restrict\", {\n            \"t\": \"A\",\n            \"s\": \"B\",\n            \"-\": \"###\",\n            \"/\": \"|\"\n        })\n        fname = self._pfmt().build_filename(KWDICT)\n        self.assertEqual(fname, \"AeBA###テスト###'&>###|:~.AxA\", \"custom dict\")\n\n        config.set((), \"path-restrict\", {\n            \"a-z\": \"x\",\n            \"テ\": \"te\",\n            \"ス\": \"su\",\n            \"ト\": \"to\",\n        })\n        fname = self._pfmt().build_filename(KWDICT)\n        self.assertEqual(fname, \"xxxx-tesuto-'&>-/:~.xxx\", \"custom dict range\")\n\n    def test_option_pathreplace(self):\n        config.set((), \"filename\", \"{name}.{ext}\")\n\n        config.set((), \"path-restrict\", \"unix\")\n        config.set((), \"path-replace\", \"&\")\n        fname = self._pfmt().build_filename(KWDICT)\n        self.assertEqual(fname, \"test-テスト-'&>-&:~.txt\", \"&\")\n\n        config.set((), \"path-restrict\", \"windows\")\n        config.set((), \"path-replace\", \"***\")\n        fname = self._pfmt().build_filename(KWDICT)\n        self.assertEqual(fname, \"test-テスト-'&***-******~.txt\", \"***\")\n\n    def test_option_pathremove(self):\n        config.set((), \"filename\", \"{name}.{ext}\")\n\n        config.set((), \"path-remove\", \"-&/\")\n        fname = self._pfmt().build_filename(KWDICT)\n        self.assertEqual(fname, \"testテスト'>_:~.txt\")\n\n        config.set((), \"path-remove\", \"a-z0-9\")\n        fname = self._pfmt().build_filename(KWDICT)\n        self.assertEqual(fname, \"-テスト-'&>-_:~.\")\n\n    def test_option_pathstrip(self):\n        config.set((), \"directory\", [\" . {name}.{ext} . \"])\n        config.set((), \"base-directory\", \"\")\n        config.set((), \"path-restrict\", \"unix\")\n\n        config.set((), \"path-strip\", \"unix\")\n        pfmt = self._pfmt(kwdict=True)\n        self.assertEqual(\n            pfmt.realdirectory, \". test-テスト-'&>-_:~.txt ./\", \"unix\")\n\n        config.set((), \"path-strip\", \"windows\")\n        pfmt = self._pfmt(kwdict=True)\n        self.assertEqual(\n            pfmt.realdirectory, \". test-テスト-'&>-_:~.txt/\", \"windows\")\n\n        config.set((), \"path-strip\", \"txt\")\n        pfmt = self._pfmt(kwdict=True)\n        self.assertEqual(\n            pfmt.realdirectory, \". test-テスト-'&>-_:~.txt ./\", \"custom\")\n\n\nif __name__ == \"__main__\":\n    unittest.main()\n"
  },
  {
    "path": "test/test_postprocessor.py",
    "content": "#!/usr/bin/env python3\n# -*- coding: utf-8 -*-\n\n# Copyright 2019-2026 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nimport os\nimport sys\nimport unittest\nfrom unittest.mock import Mock, mock_open, patch, call\n\nimport shutil\nimport logging\nimport zipfile\nimport tempfile\nimport collections\nfrom datetime import datetime\n\nsys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))\nfrom gallery_dl import extractor, output, path, util, exception  # noqa E402\nfrom gallery_dl import postprocessor, config, archive, dt  # noqa E402\nfrom gallery_dl.postprocessor.common import PostProcessor  # noqa E402\n\n\nclass MockPostprocessorModule(Mock):\n    __postprocessor__ = \"mock\"\n\n\nclass FakeJob():\n\n    def __init__(self, extr=extractor.find(\"generic:https://example.org/\")):\n        extr.directory_fmt = (\"{category}\",)\n        self.extractor = extr\n        self.pathfmt = path.PathFormat(extr)\n        self.out = output.NullOutput()\n        self.get_logger = logging.getLogger\n        self.hooks = collections.defaultdict(list)\n        self.status = 0\n\n    def register_hooks(self, hooks, options=None):\n        for hook, callback in hooks.items():\n            self.hooks[hook].append(callback)\n\n\nclass TestPostprocessorModule(unittest.TestCase):\n\n    def setUp(self):\n        postprocessor._cache.clear()\n\n    def test_find(self):\n        for name in (postprocessor.modules):\n            if name == \"fs\":\n                name = \"filesystem\"\n            cls = postprocessor.find(name)\n            self.assertEqual(cls.__name__, f\"{name.capitalize()}PP\")\n            self.assertIs(cls.__base__, PostProcessor)\n\n        self.assertEqual(postprocessor.find(\"foo\"), None)\n        self.assertEqual(postprocessor.find(1234) , None)\n        self.assertEqual(postprocessor.find(None) , None)\n\n    @patch(\"builtins.__import__\")\n    def test_cache(self, import_module):\n        import_module.return_value = MockPostprocessorModule()\n\n        for name in (postprocessor.modules):\n            postprocessor.find(name)\n        self.assertEqual(import_module.call_count, len(postprocessor.modules))\n\n        # no new calls to import_module\n        for name in (postprocessor.modules):\n            postprocessor.find(name)\n        self.assertEqual(import_module.call_count, len(postprocessor.modules))\n\n\nclass BasePostprocessorTest(unittest.TestCase):\n\n    @classmethod\n    def setUpClass(cls):\n        cls.dir = tempfile.TemporaryDirectory()\n        config.clear()\n        config.set((), \"base-directory\", cls.dir.name)\n        cls.job = FakeJob()\n\n    @classmethod\n    def tearDownClass(cls):\n        cls.dir.cleanup()\n        config.clear()\n\n    def tearDown(self):\n        self.job.hooks.clear()\n        self.job.status = 0\n\n    def _create(self, options=None, data=None):\n        kwdict = {\"category\": \"test\", \"filename\": \"file\", \"extension\": \"ext\"}\n        if options is None:\n            options = {}\n        if data is not None:\n            kwdict.update(data)\n\n        self.pathfmt = self.job.pathfmt\n        self.pathfmt.set_directory(kwdict)\n        self.pathfmt.set_filename(kwdict)\n        self.pathfmt.build_path()\n\n        pp = postprocessor.find(self.__class__.__name__[:-4].lower())\n        return pp(self.job, options)\n\n    def _trigger(self, events=None):\n        for event in (events or (\"prepare\", \"file\")):\n            for callback in self.job.hooks[event]:\n                callback(self.pathfmt)\n\n    def _output(self, mock):\n        return \"\".join(\n            call[1][0]\n            for call in mock.mock_calls\n            if call[0].endswith(\"write\")\n        )\n\n\nclass ActionsTest(BasePostprocessorTest):\n\n    def test_raises(self):\n        self._create({\"action\": \"raise AbortExtraction foobar\"})\n\n        with self.assertRaises(exception.AbortExtraction) as cm:\n            self._trigger()\n\n        self.assertEqual(str(cm.exception), \"foobar\")\n\n    def test_print(self):\n        self._create({\"action\": \"print Hello World\"})\n\n        with patch(\"sys.stdout\") as m:\n            self._trigger()\n\n        self.assertEqual(self._output(m), \"Hello World\\n\")\n\n    def test_status(self):\n        self._create({\"action\": \"status = 123\"})\n\n        self.assertEqual(self.job.status, 0)\n        self._trigger()\n        self.assertEqual(self.job.status, 123)\n\n\nclass ClassifyTest(BasePostprocessorTest):\n\n    def test_classify_default(self):\n        pp = self._create()\n\n        self.assertEqual(pp.mapping, {\n            ext: directory\n            for directory, exts in pp.DEFAULT_MAPPING.items()\n            for ext in exts\n        })\n\n        self.assertEqual(pp.directory, \"\")\n        self._trigger((\"post\",))\n        self.assertEqual(pp.directory, self.pathfmt.directory)\n\n        self.pathfmt.set_extension(\"jpg\")\n        self._trigger((\"prepare\",))\n        self.pathfmt.build_path()\n        path = os.path.join(self.dir.name, \"test\", \"Pictures\")\n        self.assertEqual(self.pathfmt.path, f\"{path}/file.jpg\")\n        self.assertEqual(self.pathfmt.realpath, f\"{path}/file.jpg\")\n\n        self.pathfmt.set_extension(\"mp4\")\n        self._trigger((\"prepare\",))\n        self.pathfmt.build_path()\n        path = os.path.join(self.dir.name, \"test\", \"Video\")\n        self.assertEqual(self.pathfmt.path, f\"{path}/file.mp4\")\n        self.assertEqual(self.pathfmt.realpath, f\"{path}/file.mp4\")\n\n    def test_classify_noop(self):\n        pp = self._create()\n        rp = self.pathfmt.realpath\n\n        self.assertEqual(pp.directory, \"\")\n        self._trigger((\"post\",))\n        self._trigger((\"prepare\",))\n\n        self.assertEqual(pp.directory, self.pathfmt.directory)\n        self.assertEqual(self.pathfmt.path, rp)\n        self.assertEqual(self.pathfmt.realpath, rp)\n\n    def test_classify_custom(self):\n        pp = self._create({\"mapping\": {\n            \"foo/bar\": [\"foo\", \"bar\"],\n        }})\n\n        self.assertEqual(pp.mapping, {\n            \"foo\": \"foo/bar\",\n            \"bar\": \"foo/bar\",\n        })\n\n        self.assertEqual(pp.directory, \"\")\n        self._trigger((\"post\",))\n        self.assertEqual(pp.directory, self.pathfmt.directory)\n\n        self.pathfmt.set_extension(\"foo\")\n        self._trigger((\"prepare\",))\n        self.pathfmt.build_path()\n        path = os.path.join(self.dir.name, \"test\", \"foo\", \"bar\")\n        self.assertEqual(self.pathfmt.path, f\"{path}/file.foo\")\n        self.assertEqual(self.pathfmt.realpath, f\"{path}/file.foo\")\n\n\nclass DirectoryTest(BasePostprocessorTest):\n\n    def test_default(self):\n        self._create()\n\n        path = os.path.join(self.dir.name, \"test\")\n        self.assertEqual(self.pathfmt.realdirectory, f\"{path}/\")\n        self.assertEqual(self.pathfmt.realpath, f\"{path}/file.ext\")\n\n        self.pathfmt.kwdict[\"category\"] = \"custom\"\n        self._trigger()\n\n        path = os.path.join(self.dir.name, \"custom\")\n        self.assertEqual(self.pathfmt.realdirectory, f\"{path}/\")\n        self.pathfmt.build_path()\n        self.assertEqual(self.pathfmt.realpath, f\"{path}/file.ext\")\n\n\nclass ExecTest(BasePostprocessorTest):\n\n    def test_command_string(self):\n        self._create({\n            \"command\": \"echo {} {_path} {_path_unc} {_temppath} \"\n                       \"{_directory} {_directory_unc} {_filename} \"\n                       \"&& rm {};\",\n        })\n\n        with patch(\"gallery_dl.util.Popen\") as p:\n            i = Mock()\n            i.wait.return_value = 0\n            p.return_value = i\n            self._trigger((\"after\",))\n\n        p.assert_called_once_with(\n            (f\"echo \"\n             f\"{self.pathfmt.path} \"\n             f\"{self.pathfmt.path} \"\n             f\"{self.pathfmt.realpath} \"\n             f\"{self.pathfmt.temppath} \"\n             f\"{self.pathfmt.directory} \"\n             f\"{self.pathfmt.realdirectory} \"\n             f\"{self.pathfmt.filename} \"\n             f\"&& rm {self.pathfmt.path};\"),\n            shell=True,\n            creationflags=0,\n            start_new_session=False,\n            stdout=None, stderr=None,\n        )\n        i.wait.assert_called_once_with()\n\n    def test_command_list(self):\n        self._create({\n            \"command\": [\"~/script.sh\", \"{category}\",\n                        \"\\fE _directory.upper()\"],\n        })\n\n        with patch(\"gallery_dl.util.Popen\") as p:\n            i = Mock()\n            i.wait.return_value = 0\n            p.return_value = i\n            self._trigger((\"after\",))\n\n        p.assert_called_once_with(\n            [\n                os.path.expanduser(\"~/script.sh\"),\n                self.pathfmt.kwdict[\"category\"],\n                self.pathfmt.realdirectory.upper(),\n            ],\n            shell=False,\n            creationflags=0,\n            start_new_session=False,\n            stdout=None, stderr=None,\n        )\n\n    def test_command_many(self):\n        self._create({\n            \"commands\": [\n                \"echo {} {_path} {_temppath} {_directory} {_filename} \"\n                \"&& rm {};\",\n                [\"~/script.sh\", \"{category}\", \"\\fE _directory.upper()\"],\n            ]\n        })\n\n        with patch(\"gallery_dl.util.Popen\") as p:\n            i = Mock()\n            i.wait.return_value = 0\n            p.return_value = i\n            self._trigger((\"after\",))\n\n        self.assertEqual(p.call_args_list, [\n            call(\n                (f\"echo \"\n                 f\"{self.pathfmt.realpath} \"\n                 f\"{self.pathfmt.realpath} \"\n                 f\"{self.pathfmt.temppath} \"\n                 f\"{self.pathfmt.realdirectory} \"\n                 f\"{self.pathfmt.filename} \"\n                 f\"&& rm {self.pathfmt.realpath};\"),\n                shell=True,\n                creationflags=0,\n                start_new_session=False,\n                stdout=None, stderr=None,\n            ),\n            call(\n                [\n                    os.path.expanduser(\"~/script.sh\"),\n                    self.pathfmt.kwdict[\"category\"],\n                    self.pathfmt.realdirectory.upper(),\n                ],\n                shell=False,\n                creationflags=0,\n                start_new_session=False,\n                stdout=None, stderr=None,\n            ),\n        ])\n\n    def test_command_returncode(self):\n        self._create({\n            \"command\": \"echo {}\",\n        })\n\n        with patch(\"gallery_dl.util.Popen\") as p:\n            i = Mock()\n            i.wait.return_value = 123\n            p.return_value = i\n\n            with self.assertLogs() as log:\n                self._trigger((\"after\",))\n\n        msg = (f\"WARNING:postprocessor.exec:\"\n               f\"'echo {self.pathfmt.realpath}' \"\n               f\"returned with non-zero exit status (123)\")\n        self.assertEqual(log.output[0], msg)\n\n    def test_async(self):\n        self._create({\n            \"async\"  : True,\n            \"command\": \"echo {}\",\n        })\n\n        with patch(\"gallery_dl.util.Popen\") as p:\n            i = Mock()\n            p.return_value = i\n            self._trigger((\"after\",))\n\n        self.assertTrue(p.called)\n        self.assertFalse(i.wait.called)\n\n    @unittest.skipIf(util.WINDOWS, \"not POSIX\")\n    def test_session_posix(self):\n        self._create({\n            \"session\": True,\n            \"command\": [\"echo\", \"foobar\"],\n        })\n\n        with patch(\"gallery_dl.util.Popen\") as p:\n            i = Mock()\n            i.wait.return_value = 0\n            p.return_value = i\n            self._trigger((\"after\",))\n\n        p.assert_called_once_with(\n            [\"echo\", \"foobar\"],\n            shell=False,\n            creationflags=0,\n            start_new_session=True,\n            stdout=None, stderr=None,\n        )\n        i.wait.assert_called_once_with()\n\n    @unittest.skipIf(not util.WINDOWS, \"not Windows\")\n    def test_session_windows(self):\n        self._create({\n            \"session\": True,\n            \"command\": [\"echo\", \"foobar\"],\n        })\n\n        with patch(\"gallery_dl.util.Popen\") as p:\n            i = Mock()\n            i.wait.return_value = 0\n            p.return_value = i\n            self._trigger((\"after\",))\n\n        import subprocess\n        p.assert_called_once_with(\n            [\"echo\", \"foobar\"],\n            shell=False,\n            creationflags=subprocess.CREATE_NEW_PROCESS_GROUP,\n            start_new_session=False,\n            stdout=None, stderr=None,\n        )\n        i.wait.assert_called_once_with()\n\n    def test_archive(self):\n        pp = self._create({\n            \"command\": [\"echo\", \"foobar\"],\n            \"archive\": \":memory:\",\n            \"event\"  : \"finalize\",\n        })\n\n        self.assertIsInstance(pp.archive, archive.DownloadArchive)\n\n        with patch.object(pp.archive, \"add\") as m_aa, \\\n                patch.object(pp.archive, \"close\") as m_ac:\n            self._trigger((\"finalize\",))\n        pp.archive.close()\n\n        m_aa.assert_called_once_with(self.pathfmt.kwdict)\n        m_ac.assert_called_once()\n\n    def test_verbose_string(self):\n        self._create({\n            \"command\": \"echo foo bar\",\n            \"verbose\": False,\n        })\n\n        with patch(\"gallery_dl.util.Popen\") as p, \\\n                self.assertLogs(level=10) as log_info:\n            i = Mock()\n            i.wait.return_value = 123\n            p.return_value = i\n            self._trigger((\"after\",))\n\n        msg = \"DEBUG:postprocessor.exec:Running 'echo'\"\n        self.assertEqual(log_info.output[0], msg)\n        self.assertIn(\"'echo' returned with non-zero \", log_info.output[1])\n\n    def test_verbose_list(self):\n        self._create({\n            \"command\": [\"echo\", \"foo\", \"bar\"],\n            \"verbose\": False,\n        })\n\n        with patch(\"gallery_dl.util.Popen\") as p, \\\n                self.assertLogs(level=10) as log_info:\n            i = Mock()\n            i.wait.return_value = 123\n            p.return_value = i\n            self._trigger((\"after\",))\n\n        msg = \"DEBUG:postprocessor.exec:Running 'echo'\"\n        self.assertEqual(log_info.output[0], msg)\n        self.assertIn(\"'echo' returned with non-zero \", log_info.output[1])\n\n    def test_opt_success(self):\n        self._create({\n            \"command\": \"echo foo bar\",\n            \"success\": \"status = 11\",\n        })\n\n        self.assertEqual(self.job.status, 0)\n        with patch(\"gallery_dl.util.Popen\") as p:\n            p.return_value = i = Mock()\n            i.wait.return_value = 0\n            self._trigger((\"after\",))\n        self.assertEqual(self.job.status, 11)\n\n    def test_opt_error(self):\n        self._create({\n            \"command\": \"echo foo bar\",\n            \"success\": \"status = 11\",\n            \"error\"  : \"status = 23\",\n        })\n\n        self.assertEqual(self.job.status, 0)\n        with patch(\"gallery_dl.util.Popen\") as p, \\\n                self.assertLogs(level=10) as log_info:\n            p.return_value = i = Mock()\n            i.wait.return_value = 1  # non-zero exit status\n            self._trigger((\"after\",))\n        self.assertEqual(self.job.status, 23)\n        self.assertIn(\"'echo foo bar' returned with non-zero \",\n                      log_info.output[1])\n\n    def test_opt_output(self):\n        self._create({\n            \"command\": [\"echo\", \"foobar\"],\n            \"output\" : False,\n        })\n\n        with patch(\"gallery_dl.util.Popen\") as p:\n            p.return_value = i = Mock()\n            i.wait.return_value = 0\n            self._trigger((\"after\",))\n\n        import subprocess\n        p.assert_called_once_with(\n            [\"echo\", \"foobar\"],\n            shell=False,\n            stdout=subprocess.DEVNULL,\n            stderr=subprocess.DEVNULL,\n            creationflags=0,\n            start_new_session=False,\n        )\n\n\nclass HashTest(BasePostprocessorTest):\n\n    def test_default(self):\n        self._create({})\n\n        with self.pathfmt.open() as fp:\n            fp.write(b\"Foo Bar\\n\")\n\n        self._trigger()\n\n        kwdict = self.pathfmt.kwdict\n        self.assertEqual(\n            \"35c9c9c7c90ad764bae9e2623f522c24\", kwdict[\"md5\"], \"md5\")\n        self.assertEqual(\n            \"14d3d804494ef4e57d72de63e4cfee761240471a\", kwdict[\"sha1\"], \"sha1\")\n\n    def test_custom_hashes(self):\n        self._create({\"hashes\": \"sha256:a,sha512:b\"})\n\n        with self.pathfmt.open() as fp:\n            fp.write(b\"Foo Bar\\n\")\n\n        self._trigger()\n\n        kwdict = self.pathfmt.kwdict\n        self.assertEqual(\n            \"4775b55be17206445d7015a5fc7656f38a74b880670523c3b175455f885f2395\",\n            kwdict[\"a\"], \"sha256\")\n        self.assertEqual(\n            \"6028f9e6957f4ca929941318c4bba6258713fd5162f9e33bd10e1c456d252700\"\n            \"3e1095b50736c4fd1e2deea152e3c8ecd5993462a747208e4d842659935a1c62\",\n            kwdict[\"b\"], \"sha512\")\n\n    def test_custom_hashes_dict(self):\n        self._create({\"hashes\": {\"a\": \"sha256\", \"b\": \"sha512\"}})\n\n        with self.pathfmt.open() as fp:\n            fp.write(b\"Foo Bar\\n\")\n\n        self._trigger()\n\n        kwdict = self.pathfmt.kwdict\n        self.assertEqual(\n            \"4775b55be17206445d7015a5fc7656f38a74b880670523c3b175455f885f2395\",\n            kwdict[\"a\"], \"sha256\")\n        self.assertEqual(\n            \"6028f9e6957f4ca929941318c4bba6258713fd5162f9e33bd10e1c456d252700\"\n            \"3e1095b50736c4fd1e2deea152e3c8ecd5993462a747208e4d842659935a1c62\",\n            kwdict[\"b\"], \"sha512\")\n\n    def test_mode(self):\n        self._create({\"mode\": \"sha256,sha512\"})\n\n        with self.pathfmt.open() as fp:\n            fp.write(b\"Foo Bar\\n\")\n\n        self._trigger()\n\n        kwdict = self.pathfmt.kwdict\n        self.assertEqual(\n            \"4775b55be17206445d7015a5fc7656f38a74b880670523c3b175455f885f2395\",\n            kwdict[\"sha256\"], \"sha256\")\n        self.assertEqual(\n            \"6028f9e6957f4ca929941318c4bba6258713fd5162f9e33bd10e1c456d252700\"\n            \"3e1095b50736c4fd1e2deea152e3c8ecd5993462a747208e4d842659935a1c62\",\n            kwdict[\"sha512\"], \"sha512\")\n\n\nclass MetadataTest(BasePostprocessorTest):\n\n    def test_metadata_default(self):\n        pp = self._create()\n\n        # default arguments\n        self.assertEqual(pp.write    , pp._write_json)\n        self.assertEqual(pp.extension, \"json\")\n        self.assertTrue(callable(pp._json_encode))\n\n    def test_metadata_json(self):\n        pp = self._create({\n            \"mode\"      : \"json\",\n            \"extension\" : \"JSON\",\n        }, {\n            \"public\"    : \"hello ワールド\",\n            \"_private\"  : \"foo バー\",\n        })\n\n        self.assertEqual(pp.write    , pp._write_json)\n        self.assertEqual(pp.extension, \"JSON\")\n        self.assertTrue(callable(pp._json_encode))\n\n        with patch(\"builtins.open\", mock_open()) as m:\n            self._trigger()\n\n        path = f\"{self.pathfmt.realpath}.JSON\"\n        m.assert_called_once_with(path, \"w\", encoding=\"utf-8\", newline=None)\n\n        self.assertEqual(self._output(m), \"\"\"{\n    \"category\": \"test\",\n    \"filename\": \"file\",\n    \"extension\": \"ext\",\n    \"public\": \"hello ワールド\"\n}\n\"\"\")\n\n    def test_metadata_json_options(self):\n        pp = self._create({\n            \"mode\"      : \"json\",\n            \"ascii\"     : True,\n            \"sort\"      : True,\n            \"separators\": [\",\", \" : \"],\n            \"private\"   : True,\n            \"indent\"    : None,\n            \"open\"      : \"a\",\n            \"encoding\"  : \"UTF-8\",\n            \"newline\"   : \"\\r\\n\",\n            \"extension\" : \"JSON\",\n        }, {\n            \"public\"    : \"hello ワールド\",\n            \"_private\"  : \"foo バー\",\n        })\n\n        self.assertEqual(pp.write    , pp._write_json)\n        self.assertEqual(pp.extension, \"JSON\")\n        self.assertTrue(callable(pp._json_encode))\n\n        with patch(\"builtins.open\", mock_open()) as m:\n            self._trigger()\n\n        path = f\"{self.pathfmt.realpath}.JSON\"\n        m.assert_called_once_with(path, \"a\", encoding=\"UTF-8\", newline='\\r\\n')\n        # Since we mocked the call to open,\n        # we don't actually see the effect of setting newline.\n        self.assertEqual(self._output(m), \"\"\"{\\\n\"_private\" : \"foo \\\\u30d0\\\\u30fc\",\\\n\"category\" : \"test\",\\\n\"extension\" : \"ext\",\\\n\"filename\" : \"file\",\\\n\"public\" : \"hello \\\\u30ef\\\\u30fc\\\\u30eb\\\\u30c9\"}\n\"\"\")\n\n    def test_metadata_tags(self):\n        pp = self._create(\n            {\"mode\": \"tags\"},\n            {\"tags\": [\"foo\", \"bar\", \"baz\"]},\n        )\n        self.assertEqual(pp.write, pp._write_tags)\n        self.assertEqual(pp.extension, \"txt\")\n\n        with patch(\"builtins.open\", mock_open()) as m:\n            self._trigger()\n\n        path = f\"{self.pathfmt.realpath}.txt\"\n        m.assert_called_once_with(path, \"w\", encoding=\"utf-8\", newline=None)\n        self.assertEqual(self._output(m), \"foo\\nbar\\nbaz\\n\")\n\n    def test_metadata_tags_split_1(self):\n        self._create(\n            {\"mode\": \"tags\"},\n            {\"tags\": \"foo, bar, baz\"},\n        )\n        with patch(\"builtins.open\", mock_open()) as m:\n            self._trigger()\n        self.assertEqual(self._output(m), \"foo\\nbar\\nbaz\\n\")\n\n    def test_metadata_tags_split_2(self):\n        self._create(\n            {\"mode\": \"tags\"},\n            {\"tags\": \"foobar1 foobar2 foobarbaz\"},\n        )\n        with patch(\"builtins.open\", mock_open()) as m:\n            self._trigger()\n        self.assertEqual(self._output(m), \"foobar1\\nfoobar2\\nfoobarbaz\\n\")\n\n    def test_metadata_tags_tagstring(self):\n        self._create(\n            {\"mode\": \"tags\"},\n            {\"tag_string\": \"foo, bar, baz\"},\n        )\n        with patch(\"builtins.open\", mock_open()) as m:\n            self._trigger()\n        self.assertEqual(self._output(m), \"foo\\nbar\\nbaz\\n\")\n\n    def test_metadata_tags_list_of_dict(self):\n        self._create(\n            {\"mode\": \"tags\"},\n            {\"tags\": [\n                {\"g\": \"foobar1\", \"m\": \"foobar2\", \"u\": True},\n                {\"g\": None, \"m\": \"foobarbaz\", \"u\": [3, 4]},\n            ]},\n        )\n        with patch(\"builtins.open\", mock_open()) as m:\n            self._trigger()\n        self.assertEqual(self._output(m), \"foobar1\\nfoobar2\\nfoobarbaz\\n\")\n\n    def test_metadata_custom(self):\n        def test(pp_info):\n            pp = self._create(pp_info, {\"foo\": \"bar\"})\n            self.assertEqual(pp.write, pp._write_custom)\n            self.assertEqual(pp.extension, \"txt\")\n            self.assertTrue(pp._content_fmt)\n\n            with patch(\"builtins.open\", mock_open()) as m:\n                self._trigger()\n            self.assertEqual(self._output(m), \"bar\\nNone\\n\")\n            self.job.hooks.clear()\n\n        test({\"mode\": \"custom\", \"content-format\": \"{foo}\\n{missing}\\n\"})\n        test({\"mode\": \"custom\", \"content-format\": [\"{foo}\", \"{missing}\"]})\n        test({\"mode\": \"custom\", \"format\": \"{foo}\\n{missing}\\n\"})\n        test({\"format\": \"{foo}\\n{missing}\\n\"})\n\n    def test_metadata_mode_print(self):\n        self._create(\n            {\"mode\": \"print\", \"format\": \"{foo}\\n{missing}\"},\n            {\"foo\": \"bar\"},\n        )\n\n        with patch(\"sys.stdout\") as m:\n            self._trigger()\n\n        self.assertEqual(self._output(m), \"bar\\nNone\\n\")\n\n    def test_metadata_extfmt(self):\n        pp = self._create({\n            \"extension\"       : \"ignored\",\n            \"extension-format\": \"json\",\n        })\n\n        self.assertEqual(pp._filename, pp._filename_extfmt)\n\n        with patch(\"builtins.open\", mock_open()) as m:\n            self._trigger()\n\n        path = f\"{self.pathfmt.realdirectory}file.json\"\n        m.assert_called_once_with(path, \"w\", encoding=\"utf-8\", newline=None)\n\n    def test_metadata_extfmt_2(self):\n        self._create({\n            \"extension-format\": \"{extension!u}-data:{category:Res/ES/}\",\n        })\n\n        self.pathfmt.prefix = \"2.\"\n        with patch(\"builtins.open\", mock_open()) as m:\n            self._trigger()\n\n        path = f\"{self.pathfmt.realdirectory}file.2.EXT-data:tESt\"\n        m.assert_called_once_with(path, \"w\", encoding=\"utf-8\", newline=None)\n\n    def test_metadata_directory(self):\n        self._create({\n            \"directory\": \"metadata\",\n        })\n\n        with patch(\"builtins.open\", mock_open()) as m:\n            self._trigger()\n\n        path = f\"{self.pathfmt.realdirectory}metadata/file.ext.json\"\n        m.assert_called_once_with(path, \"w\", encoding=\"utf-8\", newline=None)\n\n    def test_metadata_directory_2(self):\n        self._create({\n            \"directory\"       : \"metadata////\",\n            \"extension-format\": \"json\",\n        })\n\n        with patch(\"builtins.open\", mock_open()) as m:\n            self._trigger()\n\n        path = f\"{self.pathfmt.realdirectory}metadata/file.json\"\n        m.assert_called_once_with(path, \"w\", encoding=\"utf-8\", newline=None)\n\n    def test_metadata_directory_format(self):\n        self._create(\n            {\"directory\": [\"..\", \"json\", \"\\fE str(id // 500 * 500 + 500)\"]},\n            {\"id\": 12345},\n        )\n\n        with patch(\"builtins.open\", mock_open()) as m:\n            self._trigger()\n\n        path = f\"{self.pathfmt.realdirectory}../json/12500/file.ext.json\"\n        m.assert_called_once_with(path, \"w\", encoding=\"utf-8\", newline=None)\n\n    def test_metadata_directory_empty(self):\n        self._create(\n            {\"directory\": []},\n        )\n\n        with patch(\"builtins.open\", mock_open()) as m:\n            self._trigger()\n\n        path = f\"{self.pathfmt.realdirectory}./file.ext.json\"\n        m.assert_called_once_with(path, \"w\", encoding=\"utf-8\", newline=None)\n\n    def test_metadata_basedirectory(self):\n        self._create({\"base-directory\": True})\n\n        with patch(\"builtins.open\", mock_open()) as m:\n            self._trigger()\n\n        path = f\"{self.pathfmt.basedirectory}file.ext.json\"\n        m.assert_called_once_with(path, \"w\", encoding=\"utf-8\", newline=None)\n\n    def test_metadata_basedirectory_custom(self):\n        self._create({\n            \"base-directory\": \"/home/test\",\n            \"directory\": \"meta\",\n        })\n\n        with patch(\"builtins.open\", mock_open()) as m:\n            self._trigger()\n\n        path = \"/home/test/meta/file.ext.json\"\n        m.assert_called_once_with(path, \"w\", encoding=\"utf-8\", newline=None)\n\n    def test_metadata_filename(self):\n        self._create({\n            \"filename\"        : \"{category}_{filename}_/meta/\\n\\r.data\",\n            \"extension-format\": \"json\",\n        })\n\n        with patch(\"builtins.open\", mock_open()) as m:\n            self._trigger()\n\n        path = f\"{self.pathfmt.realdirectory}test_file__meta_.data\"\n        m.assert_called_once_with(path, \"w\", encoding=\"utf-8\", newline=None)\n\n    def test_metadata_meta_path(self):\n        self._create({\n            \"metadata-path\": \"_meta_path\",\n        })\n\n        self._trigger()\n\n        self.assertEqual(self.pathfmt.kwdict[\"_meta_path\"],\n                         f\"{self.pathfmt.realpath}.json\")\n\n    def test_metadata_stdout(self):\n        self._create({\"filename\": \"-\", \"indent\": None, \"sort\": True})\n\n        with patch(\"sys.stdout\") as m:\n            self._trigger()\n\n        self.assertEqual(self._output(m), \"\"\"\\\n{\"category\": \"test\", \"extension\": \"ext\", \"filename\": \"file\"}\n\"\"\")\n\n    def test_metadata_modify(self):\n        kwdict = {\"foo\": 0, \"bar\": {\"bax\": 1, \"bay\": 2, \"baz\": 3, \"ba2\": {}}}\n        self._create({\n            \"mode\": \"modify\",\n            \"fields\": {\n                \"foo\"          : \"{filename}-{foo!s}\",\n                \"foo2\"         : \"\\fE bar['bax'] + 122\",\n                \"bar[\\\"baz\\\"]\" : \"{_now}\",\n                \"bar['ba2'][a]\": \"test\",\n            },\n        }, kwdict)\n\n        pdict = self.pathfmt.kwdict\n        self.assertIsNot(kwdict, pdict)\n        self.assertEqual(pdict[\"foo\"], kwdict[\"foo\"])\n        self.assertEqual(pdict[\"bar\"], kwdict[\"bar\"])\n\n        self._trigger()\n\n        self.assertEqual(pdict[\"foo\"] , \"file-0\")\n        self.assertEqual(pdict[\"foo2\"], 123)\n        self.assertEqual(pdict[\"bar\"][\"ba2\"][\"a\"], \"test\")\n        self.assertIsInstance(pdict[\"bar\"][\"baz\"], datetime)\n\n    def test_metadata_delete(self):\n        kwdict = {\n            \"foo\": 0,\n            \"bar\": {\n                \"bax\": 1,\n                \"bay\": 2,\n                \"baz\": {\"a\": 3, \"b\": 4},\n            },\n        }\n        self._create({\n            \"mode\": \"delete\",\n            \"fields\": [\"foo\", \"bar['bax']\", \"bar[\\\"baz\\\"][a]\"],\n        }, kwdict)\n\n        pdict = self.pathfmt.kwdict\n        self.assertIsNot(kwdict, pdict)\n\n        self.assertEqual(pdict[\"foo\"], kwdict[\"foo\"])\n        self.assertEqual(pdict[\"bar\"], kwdict[\"bar\"])\n\n        self._trigger()\n\n        self.assertNotIn(\"foo\", pdict)\n        self.assertNotIn(\"bax\", pdict[\"bar\"])\n        self.assertNotIn(\"a\", pdict[\"bar\"][\"baz\"])\n\n        # no errors for deleted/undefined fields\n        self._trigger()\n        self.assertNotIn(\"foo\", pdict)\n        self.assertNotIn(\"bax\", pdict[\"bar\"])\n        self.assertNotIn(\"a\", pdict[\"bar\"][\"baz\"])\n\n    def test_metadata_option_skip(self):\n        self._create({\"skip\": True})\n\n        with patch(\"builtins.open\", mock_open()) as m, \\\n                patch(\"os.path.exists\") as e:\n            e.return_value = True\n            self._trigger()\n\n        self.assertTrue(e.called)\n        self.assertTrue(not m.called)\n        self.assertTrue(not len(self._output(m)))\n\n        with patch(\"builtins.open\", mock_open()) as m, \\\n                patch(\"os.path.exists\") as e:\n            e.return_value = False\n            self._trigger()\n\n        self.assertTrue(e.called)\n        self.assertTrue(m.called)\n        self.assertGreater(len(self._output(m)), 0)\n\n        path = f\"{self.pathfmt.realdirectory}file.ext.json\"\n        m.assert_called_once_with(path, \"w\", encoding=\"utf-8\", newline=None)\n\n    def test_metadata_option_skip_false(self):\n        self._create({\"skip\": False})\n\n        with patch(\"builtins.open\", mock_open()) as m, \\\n                patch(\"os.path.exists\") as e:\n            self._trigger()\n\n        self.assertTrue(not e.called)\n        self.assertTrue(m.called)\n\n    def test_metadata_option_newline(self):\n        self._create({\n            \"newline\": \"\\r\\n\",\n            \"filename\"      : \"data.json\",\n            \"directory\"     : \"\",\n            \"base-directory\": self.dir.name,\n        })\n\n        self._trigger()\n\n        path = os.path.join(self.dir.name, \"data.json\")\n        with open(path, newline=\"\") as fp:\n            content = fp.read()\n\n        self.assertEqual(content, \"\"\"\\\n{\\r\\n\\\n    \"category\": \"test\",\\r\\n\\\n    \"filename\": \"file\",\\r\\n\\\n    \"extension\": \"ext\"\\r\\n\\\n}\\r\\n\\\n\"\"\")\n\n    def test_metadata_option_include(self):\n        self._create(\n            {\"include\": [\"_private\", \"filename\", \"foo\"], \"sort\": True},\n            {\"public\": \"hello ワールド\", \"_private\": \"foo バー\"},\n        )\n\n        with patch(\"builtins.open\", mock_open()) as m:\n            self._trigger()\n\n        self.assertEqual(self._output(m), \"\"\"{\n    \"_private\": \"foo バー\",\n    \"filename\": \"file\"\n}\n\"\"\")\n\n    def test_metadata_option_exclude(self):\n        self._create(\n            {\"exclude\": [\"category\", \"filename\", \"foo\"], \"sort\": True},\n            {\"public\": \"hello ワールド\", \"_private\": \"foo バー\"},\n        )\n\n        with patch(\"builtins.open\", mock_open()) as m:\n            self._trigger()\n\n        self.assertEqual(self._output(m), \"\"\"{\n    \"extension\": \"ext\",\n    \"public\": \"hello ワールド\"\n}\n\"\"\")\n\n    def test_archive(self):\n        pp = self._create({\n            \"archive\": \":memory:\",\n            \"event\"  : \"finalize\",\n        })\n\n        self.assertIsInstance(pp.archive, archive.DownloadArchive)\n\n        with patch.object(pp.archive, \"add\") as m_aa, \\\n                patch.object(pp.archive, \"close\") as m_ac:\n            self._trigger((\"finalize\",))\n        pp.archive.close()\n\n        m_aa.assert_called_once_with(self.pathfmt.kwdict)\n        m_ac.assert_called_once()\n\n\nclass MtimeTest(BasePostprocessorTest):\n\n    def test_mtime_datetime(self):\n        self._create(None, {\"date\": datetime(1980, 1, 1)})\n        self._trigger()\n        self.assertEqual(self.pathfmt.kwdict[\"_mtime_meta\"], 315532800)\n\n    def test_mtime_timestamp(self):\n        self._create(None, {\"date\": 315532800})\n        self._trigger()\n        self.assertEqual(self.pathfmt.kwdict[\"_mtime_meta\"], 315532800)\n\n    def test_mtime_none(self):\n        self._create(None, {\"date\": None})\n        self._trigger()\n        self.assertFalse(self.pathfmt.kwdict[\"_mtime_meta\"])\n\n    def test_mtime_none_dt(self):\n        self._create(None, {\"date\": dt.NONE})\n        self._trigger()\n        self.assertFalse(self.pathfmt.kwdict[\"_mtime_meta\"])\n\n    def test_mtime_undefined(self):\n        self._create(None, {})\n        self._trigger()\n        self.assertFalse(self.pathfmt.kwdict[\"_mtime_meta\"])\n\n    def test_mtime_invalid(self):\n        self._create(None, {\"date\": \"foobar\"})\n        self._trigger()\n        self.assertFalse(self.pathfmt.kwdict[\"_mtime_meta\"])\n\n    def test_mtime_key(self):\n        self._create({\"key\": \"foo\"}, {\"foo\": 315532800})\n        self._trigger()\n        self.assertEqual(self.pathfmt.kwdict[\"_mtime_meta\"], 315532800)\n\n    def test_mtime_value(self):\n        self._create({\"value\": \"{foo}\"}, {\"foo\": 315532800})\n        self._trigger()\n        self.assertEqual(self.pathfmt.kwdict[\"_mtime_meta\"], 315532800)\n\n\nclass PythonTest(BasePostprocessorTest):\n\n    def test_module(self):\n        path = os.path.join(self.dir.name, \"module.py\")\n        self._write_module(path)\n\n        sys.path.insert(0, self.dir.name)\n        try:\n            self._create({\"function\": \"module:calc\"}, {\"_value\": 123})\n        finally:\n            del sys.path[0]\n\n        self.assertNotIn(\"_result\", self.pathfmt.kwdict)\n        self._trigger()\n        self.assertEqual(self.pathfmt.kwdict[\"_result\"], 246)\n\n    def test_path(self):\n        path = os.path.join(self.dir.name, \"module.py\")\n        self._write_module(path)\n\n        self._create({\"function\": f\"{path}:calc\"}, {\"_value\": 12})\n\n        self.assertNotIn(\"_result\", self.pathfmt.kwdict)\n        self._trigger()\n        self.assertEqual(self.pathfmt.kwdict[\"_result\"], 24)\n\n    def test_eval(self):\n        self._create({\"mode\": \"eval\", \"expression\": \"abort()\"})\n\n        with self.assertRaises(exception.StopExtraction):\n            self._trigger()\n\n    def test_eval_auto(self):\n        self._create({\"expression\": \"abort()\"})\n\n        with self.assertRaises(exception.StopExtraction):\n            self._trigger()\n\n    def test_archive(self):\n        pp = self._create({\n            \"expression\": \"True\",\n            \"archive\"   : \":memory:\",\n            \"event\"     : \"finalize\",\n        })\n\n        self.assertIsInstance(pp.archive, archive.DownloadArchive)\n\n        with patch.object(pp.archive, \"add\") as m_aa, \\\n                patch.object(pp.archive, \"close\") as m_ac:\n            self._trigger((\"finalize\",))\n        pp.archive.close()\n\n        m_aa.assert_called_once_with(self.pathfmt.kwdict)\n        m_ac.assert_called_once()\n\n    def _write_module(self, path):\n        with open(path, \"w\") as fp:\n            fp.write(\"\"\"\ndef calc(kwdict):\n    kwdict[\"_result\"] = kwdict[\"_value\"] * 2\n\"\"\")\n\n\nclass RenameTest(BasePostprocessorTest):\n\n    def _prepare(self, filename):\n        path = self.pathfmt.realdirectory\n        shutil.rmtree(path, ignore_errors=True)\n        os.makedirs(path, exist_ok=True)\n\n        with open(path + filename, \"w\"):\n            pass\n\n        return path\n\n    def test_rename_from(self):\n        self._create({\"from\": \"{id}.{extension}\"}, {\"id\": 12345})\n        path = self._prepare(\"12345.ext\")\n\n        self._trigger()\n\n        self.assertEqual(os.listdir(path), [\"file.ext\"])\n\n    def test_rename_to(self):\n        self._create({\"to\": \"{id}.{extension}\"}, {\"id\": 12345})\n        path = self._prepare(\"file.ext\")\n\n        self._trigger((\"skip\",))\n\n        self.assertEqual(os.listdir(path), [\"12345.ext\"])\n\n    def test_rename_from_to(self):\n        self._create({\"from\": \"name\", \"to\": \"{id}\"}, {\"id\": 12345})\n        path = self._prepare(\"name\")\n\n        self._trigger()\n\n        self.assertEqual(os.listdir(path), [\"12345\"])\n\n    def test_rename_noopt(self):\n        with self.assertRaises(ValueError):\n            self._create({})\n\n    def test_rename_skip(self):\n        self._create({\"from\": \"{id}.{extension}\"}, {\"id\": 12345})\n        path = self._prepare(\"12345.ext\")\n        with open(f\"{path}file.ext\", \"w\"):\n            pass\n\n        with self.assertLogs(\"postprocessor.rename\", level=\"WARNING\") as cm:\n            self._trigger()\n        self.assertTrue(cm.output[0].startswith(\n            \"WARNING:postprocessor.rename:Not renaming \"\n            \"'12345.ext' to 'file.ext'\"))\n        self.assertEqual(sorted(os.listdir(path)), [\"12345.ext\", \"file.ext\"])\n\n\nclass ZipTest(BasePostprocessorTest):\n\n    def test_zip_default(self):\n        pp = self._create()\n        self.assertEqual(self.job.hooks[\"file\"][0], pp.write_fast)\n        self.assertEqual(pp.path, self.pathfmt.realdirectory[:-1])\n        self.assertEqual(pp.delete, True)\n        self.assertEqual(pp.args, (\n            f\"{pp.path}.zip\", \"a\", zipfile.ZIP_STORED, True,\n        ))\n        self.assertTrue(pp.args[0].endswith(\"/test.zip\"))\n\n    def test_zip_safe(self):\n        pp = self._create({\"mode\": \"safe\"})\n        self.assertEqual(self.job.hooks[\"file\"][0], pp.write_safe)\n        self.assertEqual(pp.path, self.pathfmt.realdirectory[:-1])\n        self.assertEqual(pp.delete, True)\n        self.assertEqual(pp.args, (\n            f\"{pp.path}.zip\", \"a\", zipfile.ZIP_STORED, True,\n        ))\n        self.assertTrue(pp.args[0].endswith(\"/test.zip\"))\n\n    def test_zip_options(self):\n        pp = self._create({\n            \"keep-files\": True,\n            \"compression\": \"zip\",\n            \"extension\": \"cbz\",\n        })\n        self.assertEqual(pp.delete, False)\n        self.assertEqual(pp.args, (\n            f\"{pp.path}.cbz\", \"a\", zipfile.ZIP_DEFLATED, True,\n        ))\n        self.assertTrue(pp.args[0].endswith(\"/test.cbz\"))\n\n    def test_zip_write(self):\n        with tempfile.NamedTemporaryFile(\"w\", dir=self.dir.name) as file:\n            pp = self._create({\"files\": [file.name, \"_info_.json\"],\n                               \"keep-files\": True})\n\n            filename = os.path.basename(file.name)\n            file.write(\"foobar\\n\")\n\n            # write dummy file with 3 different names\n            for i in range(3):\n                name = f\"file{i}.ext\"\n                self.pathfmt.temppath = file.name\n                self.pathfmt.filename = name\n\n                self._trigger()\n\n                nti = pp.zfile.NameToInfo\n                self.assertEqual(len(nti), i+2)\n                self.assertIn(name, nti)\n\n            # check file contents\n            self.assertEqual(len(nti), 4)\n            self.assertIn(\"file0.ext\", nti)\n            self.assertIn(\"file1.ext\", nti)\n            self.assertIn(\"file2.ext\", nti)\n            self.assertIn(filename, nti)\n\n            # write the last file a second time (will be skipped)\n            self._trigger()\n            self.assertEqual(len(pp.zfile.NameToInfo), 4)\n\n        # close file\n        self._trigger((\"finalize\",))\n\n        # reopen to check persistence\n        with zipfile.ZipFile(pp.zfile.filename) as file:\n            nti = file.NameToInfo\n            self.assertEqual(len(pp.zfile.NameToInfo), 4)\n            self.assertIn(\"file0.ext\", nti)\n            self.assertIn(\"file1.ext\", nti)\n            self.assertIn(\"file2.ext\", nti)\n            self.assertIn(filename, nti)\n\n        os.unlink(pp.zfile.filename)\n\n    def test_zip_write_mock(self):\n\n        def side_effect(_, name):\n            pp.zfile.NameToInfo.add(name)\n\n        pp = self._create()\n        pp.zfile = Mock()\n        pp.zfile.NameToInfo = set()\n        pp.zfile.write.side_effect = side_effect\n\n        # write 3 files\n        for i in range(3):\n            self.pathfmt.temppath = f\"{self.pathfmt.realdirectory}file.ext\"\n            self.pathfmt.filename = f\"file{i}.ext\"\n            self._trigger()\n\n        # write the last file a second time (should be skipped)\n        self._trigger()\n\n        # close file\n        self._trigger((\"finalize\",))\n\n        self.assertEqual(pp.zfile.write.call_count, 3)\n        for call_args in pp.zfile.write.call_args_list:\n            args, kwargs = call_args\n            self.assertEqual(len(args), 2)\n            self.assertEqual(len(kwargs), 0)\n            self.assertEqual(args[0], self.pathfmt.temppath)\n            self.assertRegex(args[1], r\"file\\d\\.ext\")\n        self.assertEqual(pp.zfile.close.call_count, 1)\n\n\nif __name__ == \"__main__\":\n    unittest.main()\n"
  },
  {
    "path": "test/test_results.py",
    "content": "#!/usr/bin/env python3\n# -*- coding: utf-8 -*-\n\n# Copyright 2015-2026 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nimport os\nimport sys\nimport unittest\n\nimport re\nimport json\nimport hashlib\nimport datetime\nimport collections\n\nsys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))\nfrom gallery_dl import \\\n    extractor, util, job, config, exception, formatter  # noqa E402\n\n\nRESULTS = os.environ.get(\"GDL_TEST_RESULTS\")\nif RESULTS:\n    results = util.import_file(RESULTS)\nelse:\n    from test import results\n\n\n# temporary issues, etc.\nBROKEN = {\n}\n\nCONFIG = {\n    \"cache\": {\n        \"file\": None,\n    },\n    \"downloader\": {\n        \"adjust-extensions\": False,\n        \"part\": False,\n    },\n}\n\n\nAUTH_REQUIRED = {\n    \"pixiv\",\n    \"nijie\",\n    \"horne\",\n    \"reddit\",\n    \"seiga\",\n    \"fantia\",\n    \"instagram\",\n    \"twitter\",\n    \"poipiku\",\n}\n\nAUTH_KEYS = {\n    \"username\",\n    \"cookies\",\n    \"api-key\",\n    \"client-id\",\n    \"access-token\",\n    \"refresh-token\",\n}\n\n\nclass TestExtractorResults(unittest.TestCase):\n\n    def setUp(self):\n        setup_test_config()\n\n    def tearDown(self):\n        config.clear()\n\n    @classmethod\n    def setUpClass(cls):\n        cls._skipped = []\n\n    @classmethod\n    def tearDownClass(cls):\n        if cls._skipped:\n            sys.stdout.write(\"\\n\\nSkipped tests:\\n\")\n            for url, reason in cls._skipped:\n                sys.stdout.write(f'- {url} (\"{reason}\")\\n')\n\n    def assertRange(self, value, range, msg=None):\n        if range.step > 1:\n            self.assertIn(value, range, msg=msg)\n        else:\n            self.assertLessEqual(value, range.stop, msg=msg)\n            self.assertGreaterEqual(value, range.start, msg=msg)\n\n    def assertLogEqual(self, expected, output):\n        if isinstance(expected, str):\n            expected = (expected,)\n        self.assertEqual(len(expected), len(output), \"#log/count\")\n\n        for exp, out in zip(expected, output):\n            level, name, message = out.split(\":\", 2)\n\n            if isinstance(exp, str):\n                return self.assertEqual(exp, message, \"#log\")\n\n            self.assertEqual(exp[0].lower(), level.lower(), \"#log/level\")\n            if len(exp) < 3:\n                self.assertEqual(exp[1], message, \"#log/message\")\n            else:\n                self.assertEqual(exp[1], name   , \"#log/name\")\n                self.assertEqual(exp[2], message, \"#log/message\")\n\n    def _run_test(self, result):\n        if result.get(\"#fail\"):\n            del result[\"#fail\"]\n            try:\n                self._run_test(result)\n            except AssertionError:\n                return\n            else:\n                self.fail(\"Test did not fail\")\n\n        base, cat, sub = result_categories(result)\n        result.pop(\"#comment\", None)\n        result.pop(\"#category\", None)\n        auth = result.pop(\"#auth\", None)\n\n        extr_url = extractor.find(result[\"#url\"])\n        self.assertTrue(extr_url, \"extractor by URL/find\")\n        extr_cls = extr = result[\"#class\"].from_url(result[\"#url\"])\n        self.assertTrue(extr_url, \"extractor by cls.from_url()\")\n        self.assertIs(extr_url.__class__, extr_cls.__class__)\n\n        if len(result) <= 2:\n            return  # only matching\n\n        skip = result.pop(\"#skip\", False)\n        if skip:\n            return self._skipped.append((result[\"#url\"], skip))\n\n        if auth is None:\n            auth = (cat in AUTH_REQUIRED)\n        elif not auth:\n            # auth explicitly disabled\n            for key in AUTH_KEYS:\n                config.set((), key, None)\n\n        if auth and not self._has_auth(extr, auth):\n            self._skipped.append((result[\"#url\"], \"no auth\"))\n            self.skipTest(\"no auth\")\n\n        if \"#options\" in result:\n            for key, value in result[\"#options\"].items():\n                key = key.split(\".\")\n                config.set(key[:-1], key[-1], value)\n        if \"#range\" in result:\n            config.set((), \"file-range\" , result[\"#range\"])\n            config.set((), \"child-range\", result[\"#range\"])\n\n        tjob = ResultJob(extr,\n                         content=(\"#sha1_content\" in result),\n                         format=(result.get(\"#metadata\") != \"post\"))\n\n        if \"#exception\" in result:\n            exc = result[\"#exception\"]\n            if isinstance(exc, str):\n                exc, _, msg = exc.partition(\":\")\n                exc = getattr(exception, exc, None)\n            elif isinstance(exc, tuple):\n                exc, msg = exc\n            else:\n                msg = \"\"\n            with self.assertRaises(exc, msg=\"#exception\") as cm, \\\n                    self.assertLogs() as log_info:\n                tjob.run()\n            if msg:\n                self.assertEqual(str(cm.exception), msg, msg=\"#exception/msg\")\n            if \"#log\" in result:\n                self.assertLogEqual(result[\"#log\"], log_info.output)\n            return\n\n        try:\n            if \"#log\" in result:\n                with self.assertLogs() as log_info:\n                    tjob.run()\n            else:\n                tjob.run()\n        except exception.StopExtraction:\n            pass\n        except exception.HttpError as exc:\n            exc = str(exc)\n            if re.match(r\"'5\\d\\d \", exc) or \\\n                    re.search(r\"\\bRead timed out\\b\", exc):\n                self._skipped.append((result[\"#url\"], exc))\n                self.skipTest(exc)\n            raise\n\n        if \"#log\" in result:\n            self.assertLogEqual(result[\"#log\"], log_info.output)\n\n        if result.get(\"#archive\", True):\n            self.assertEqual(\n                len(set(tjob.archive_list)),\n                len(tjob.archive_list),\n                msg=\"archive-id uniqueness\")\n\n        if tjob.queue:\n            # test '_extractor' entries\n            for url, kwdict in zip(tjob.url_list, tjob.kwdict_list):\n                if \"_extractor\" in kwdict:\n                    extr = kwdict[\"_extractor\"].from_url(url)\n                    if extr is None and not result.get(\"#extractor\", True):\n                        continue\n                    self.assertIsInstance(extr, kwdict[\"_extractor\"], msg=url)\n                    self.assertEqual(extr.url, url)\n        else:\n            # test 'extension' entries\n            for kwdict in tjob.kwdict_list:\n                self.assertIn(\"extension\", kwdict, msg=\"#extension\")\n\n        # test extraction results\n        if \"#sha1_url\" in result:\n            self.assertEqual(\n                result[\"#sha1_url\"],\n                tjob.url_hash.hexdigest(),\n                msg=\"#sha1_url\")\n\n        if \"#sha1_content\" in result:\n            expected = result[\"#sha1_content\"]\n            digest = tjob.content_hash.hexdigest()\n            if isinstance(expected, str):\n                self.assertEqual(expected, digest, msg=\"#sha1_content\")\n            else:  # iterable\n                self.assertIn(digest, expected, msg=\"#sha1_content\")\n\n        if \"#sha1_metadata\" in result:\n            self.assertEqual(\n                result[\"#sha1_metadata\"],\n                tjob.kwdict_hash.hexdigest(),\n                \"#sha1_metadata\")\n\n        if \"#count\" in result:\n            count = result[\"#count\"]\n            len_urls = len(tjob.url_list)\n            if isinstance(count, str):\n                self.assertRegex(\n                    count, r\"^ *(==|!=|<|<=|>|>=) *\\d+ *$\", msg=\"#count\")\n                expr = f\"{len_urls} {count}\"\n                self.assertTrue(eval(expr), msg=expr)\n            elif isinstance(count, range):\n                self.assertRange(len_urls, count, msg=\"#count\")\n            else:  # assume integer\n                self.assertEqual(len_urls, count, msg=\"#count\")\n\n        if \"#pattern\" in result:\n            self.assertGreater(len(tjob.url_list), 0, msg=\"#pattern\")\n            pattern = result[\"#pattern\"]\n            if isinstance(pattern, str):\n                for url in tjob.url_list:\n                    self.assertRegex(url, pattern, msg=\"#pattern\")\n            else:\n                for url, pat in zip(tjob.url_list, pattern):\n                    self.assertRegex(url, pat, msg=\"#pattern\")\n\n        if \"#results\" in result:\n            expected = result[\"#results\"]\n            if isinstance(expected, str):\n                self.assertTrue(tjob.url_list, msg=\"#results\")\n                self.assertEqual(\n                    tjob.url_list[0], expected, msg=\"#results\")\n            else:\n                self.assertSequenceEqual(\n                    tjob.url_list, expected, msg=\"#results\")\n\n        metadata = {k: v for k, v in result.items() if k[0] != \"#\"}\n        if metadata:\n            if result.get(\"#metadata\") == \"post\":\n                kwdicts = tjob.kwdict_post\n            else:\n                kwdicts = tjob.kwdict_list\n            for kwdict in kwdicts:\n                self._test_kwdict(kwdict, metadata)\n\n    def _has_auth(self, extr, auth):\n        if auth is True:\n            auth = AUTH_KEYS\n\n        if isinstance(auth, str):\n            return extr.config(auth)\n        if isinstance(auth, set):\n            return any(self._has_auth(extr, a) for a in auth)\n        if isinstance(auth, (tuple, list)):\n            return all(self._has_auth(extr, k) for k in auth)\n\n        self.fail(f\"Invalid '#auth' value: {auth!r}\")\n\n    def _test_kwdict(self, kwdict, tests, parent=None):\n        for key, test in tests.items():\n\n            if key.startswith(\"?\"):\n                key = key[1:]\n                if key not in kwdict:\n                    continue\n\n            if key.endswith(\"[*]\"):\n                key = key[:-3]\n                subtest = True\n            else:\n                subtest = False\n\n            path = f\"{parent}.{key}\" if parent else key\n\n            if key.startswith(\"!\"):\n                self.assertNotIn(key[1:], kwdict, msg=path)\n                continue\n\n            self.assertIn(key, kwdict, msg=path)\n            value = kwdict[key]\n\n            if subtest:\n                self.assertNotIsInstance(value, str, msg=path)\n                for idx, item in enumerate(value):\n                    subpath = f\"{path}[{idx}]\"\n                    self._test_kwdict_value(item, test, subpath)\n            else:\n                self._test_kwdict_value(value, test, path)\n\n    def _test_kwdict_value(self, value, test, path):\n        if isinstance(test, dict):\n            self._test_kwdict(value, test, path)\n        elif isinstance(test, type):\n            self.assertIsInstance(value, test, msg=path)\n        elif isinstance(test, range):\n            self.assertRange(value, test, msg=path)\n        elif isinstance(test, set):\n            if isinstance(value, list):\n                value = tuple(value)\n            for item in test:\n                if isinstance(item, type) and isinstance(value, item) or \\\n                        value == item:\n                    break\n            else:\n                v = type(value) if len(str(value)) > 64 else value\n                self.fail(f\"{v!r} not in {test}: {path}\")\n        elif isinstance(test, list):\n            subtest = False\n            for idx, item in enumerate(test):\n                if isinstance(item, dict):\n                    subtest = True\n                    subpath = f\"{path}[{idx}]\"\n                    try:\n                        obj = value[idx]\n                    except Exception as exc:\n                        self.fail(f\"'{exc.__class__.__name__}: {exc}' \"\n                                  f\"when accessing {subpath}\")\n                    self._test_kwdict(obj, item, subpath)\n            if not subtest:\n                self.assertEqual(test, value, msg=path)\n        elif isinstance(test, str):\n            if test.startswith(\"re:\"):\n                self.assertIsInstance(value, str, msg=path)\n                self.assertRegex(value, test[3:], msg=path)\n            elif test.startswith(\"dt:\"):\n                self.assertIsInstance(value, datetime.datetime, msg=path)\n                self.assertEqual(test[3:], str(value), msg=path)\n            elif test.startswith(\"type:\"):\n                self.assertEqual(test[5:], type(value).__name__, msg=path)\n            elif test.startswith(\"len:\"):\n                cls, _, length = test[4:].rpartition(\":\")\n                if cls:\n                    self.assertEqual(\n                        cls, type(value).__name__, msg=f\"{path}/type\")\n                try:\n                    len_value = len(value)\n                except Exception:\n                    len_value = 0\n                    for _ in value:\n                        len_value += 1\n                self.assertEqual(int(length), len_value, msg=path)\n            elif test.startswith(\"hash:\"):\n                digest = test[5:].lower()\n                msg = f\"{path} / {digest}\"\n                if digest == \"md5\":\n                    self.assertRegex(value, r\"^[0-9a-fA-F]{32}$\", msg)\n                elif digest == \"sha1\":\n                    self.assertRegex(value, r\"^[0-9a-fA-F]{40}$\", msg)\n                elif digest == \"sha256\":\n                    self.assertRegex(value, r\"^[0-9a-fA-F]{64}$\", msg)\n                elif digest == \"sha512\":\n                    self.assertRegex(value, r\"^[0-9a-fA-F]{128}$\", msg)\n            elif test.startswith(\"iso:\"):\n                iso = test[4:]\n                if iso in (\"dt\", \"datetime\", \"8601\"):\n                    msg = f\"{path} / ISO 8601\"\n                    try:\n                        dt = datetime.datetime.fromisoformat(value)\n                    except Exception as exc:\n                        self.fail(f\"Invalid datetime '{value}': {exc} {msg}\")\n                    self.assertIsInstance(dt, datetime.datetime, msg=msg)\n                elif iso in (\"lang\", \"639\", \"639-1\"):\n                    msg = f\"{path} / ISO 639-1\"\n                    self.assertIsInstance(value, str, msg=msg)\n                    self.assertRegex(value, r\"^[a-z]{2}(-\\w+)?$\", msg=msg)\n                elif iso in (\"uuid\", \"11578\", \"11578:1996\", \"4122\"):\n                    msg = f\"{path} / ISO 11578:1996\"\n                    pat = (r\"(?i)[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-\"\n                           r\"[0-9a-f]{4}-[0-9a-f]{12}\")\n                    self.assertIsInstance(value, str, msg=msg)\n                    self.assertRegex(value, pat, msg=msg)\n                else:\n                    self.fail(f\"Unsupported ISO test '{test}'\")\n            else:\n                self.assertEqual(test, value, msg=path)\n        else:\n            self.assertEqual(test, value, msg=path)\n\n\nclass ResultJob(job.DownloadJob):\n    \"\"\"Generate test-results for extractor runs\"\"\"\n\n    def __init__(self, url, parent=None, content=False, format=True):\n        job.DownloadJob.__init__(self, url, parent)\n        self.queue = False\n        self.content = content\n\n        self.format = format\n        self.url_list = []\n        self.url_hash = hashlib.sha1()\n        self.kwdict_list = []\n        self.kwdict_post = []\n        self.kwdict_hash = hashlib.sha1()\n        self.archive_list = []\n        self.archive_hash = hashlib.sha1()\n        self.content_hash = hashlib.sha1()\n\n        if content:\n            self.fileobj = TestPathfmt(self.content_hash)\n        else:\n            self._update_content = lambda url, kwdict: None\n\n    def run(self):\n        self._init()\n        self.dispatch(self.extractor)\n\n    def handle_url(self, url, kwdict, fallback=None):\n        self._update_url(url)\n        self._update_kwdict(kwdict)\n        self._update_archive(kwdict)\n        self._update_content(url, kwdict)\n        self.format_filename(kwdict)\n\n    def handle_directory(self, kwdict):\n        if self.format is not None:\n            if self.format:\n                self.format_directory = TestFormatter(\n                    \"\".join(self.extractor.directory_fmt)).format_map\n                self.format_filename = TestFormatter(\n                    self.extractor.filename_fmt).format_map\n                self.format_archive = TestFormatter(\n                    self.extractor.archive_fmt).format_map\n            else:\n                self.format_directory = \\\n                    self.format_filename = \\\n                    self.format_archive = lambda kwdict: \"\"\n            self.format = None\n\n        self._update_kwdict(kwdict, False)\n        self.format_directory(kwdict)\n\n    def handle_metadata(self, kwdict):\n        pass\n\n    def handle_queue(self, url, kwdict):\n        self.queue = True\n        self._update_url(url)\n        self._update_kwdict(kwdict)\n\n    def _update_url(self, url):\n        self.url_list.append(url)\n        self.url_hash.update(url.encode())\n\n    def _update_kwdict(self, kwdict, to_list=True):\n        if to_list:\n            self.kwdict_list.append(kwdict.copy())\n        else:\n            self.kwdict_post.append(kwdict.copy())\n        kwdict = util.filter_dict(kwdict)\n        self.kwdict_hash.update(\n            json.dumps(kwdict, sort_keys=True, default=str).encode())\n\n    def _update_archive(self, kwdict):\n        archive_id = self.format_archive(kwdict)\n        self.archive_list.append(archive_id)\n        self.archive_hash.update(archive_id.encode())\n\n    def _update_content(self, url, kwdict):\n        self.fileobj.kwdict = kwdict\n\n        downloader = self.get_downloader(url.partition(\":\")[0])\n        if downloader.download(url, self.fileobj):\n            return\n\n        for num, url in enumerate(kwdict.get(\"_fallback\") or (), 1):\n            self.log.warning(\"Trying fallback URL #%d\", num)\n            downloader = self.get_downloader(url.partition(\":\")[0])\n            if downloader.download(url, self.fileobj):\n                return\n\n\nclass TestPathfmt():\n\n    def __init__(self, hashobj):\n        self.hashobj = hashobj\n        self.path = \"\"\n        self.size = 0\n        self.kwdict = {}\n        self.extension = \"jpg\"\n\n    def __enter__(self):\n        return self\n\n    def __exit__(self, exc_type, exc_value, traceback):\n        pass\n\n    def open(self, mode):\n        self.size = 0\n        return self\n\n    def write(self, content):\n        \"\"\"Update SHA1 hash\"\"\"\n        self.size += len(content)\n        self.hashobj.update(content)\n\n    def tell(self):\n        return self.size\n\n    def part_size(self):\n        return 0\n\n\nclass TestFormatter(formatter.StringFormatter):\n\n    def _apply_simple(self, key, fmt):\n        if key == \"extension\" or \"_parse_optional.\" in repr(fmt):\n            def wrap(obj):\n                try:\n                    return fmt(obj[key])\n                except KeyError:\n                    return \"\"\n        elif \"<function identity at \" in repr(fmt):\n            def wrap(obj):\n                return \"\".join(obj[key])\n        else:\n            def wrap(obj):\n                return fmt(obj[key])\n        return wrap\n\n    def _apply(self, key, funcs, fmt):\n        if key == \"extension\" or \"_parse_optional.\" in repr(fmt):\n            def wrap(obj):\n                obj = obj[key] if key in obj else \"\"\n                for func in funcs:\n                    obj = func(obj)\n                return fmt(obj)\n        elif \"<function identity at \" in repr(fmt):\n            def wrap(obj):\n                return \"\".join(obj[key])\n        else:\n            def wrap(obj):\n                obj = obj[key]\n                for func in funcs:\n                    obj = func(obj)\n                return fmt(obj)\n        return wrap\n\n\ndef setup_test_config():\n    config._config.update(CONFIG)\n\n\ndef load_test_config():\n    try:\n        path = os.path.join(\n            os.path.dirname(os.path.dirname(__file__)),\n            \"archive\", \"config.json\")\n        with open(path) as fp:\n            CONFIG.update(json.loads(fp.read()))\n    except FileNotFoundError:\n        pass\n    except Exception as exc:\n        sys.exit(f\"Error when loading {path}: {exc.__class__.__name__}: {exc}\")\n\n\ndef result_categories(result):\n    categories = result.get(\"#category\")\n    if categories:\n        return categories\n\n    cls = result[\"#class\"]\n    return cls.basecategory, cls.category, cls.subcategory\n\n\ndef generate_tests():\n    \"\"\"Dynamically generate extractor unittests\"\"\"\n    def _generate_method(result):\n        def test(self):\n            sys.stdout.write(f\"\\n{result['#url']}\\n\")\n            if \"#comment\" in result:\n                sys.stdout.write(f\"# {result['#comment']}\\n\")\n\n            try:\n                self._run_test(result)\n            except KeyboardInterrupt as exc:\n                v = input(\"\\n[e]xit | [f]ail | [S]kip ? \").strip().lower()\n                if v in (\"e\", \"exit\"):\n                    raise\n                if v in (\"f\", \"fail\"):\n                    self.fail(\"manual test failure\")\n                else:\n                    self._skipped.append((result[\"#url\"], \"manual skip\"))\n                    self.skipTest(exc)\n        return test\n\n    # enable selective testing for direct calls\n    if __name__ == \"__main__\" and len(sys.argv) > 1:\n        category, _, subcategory = sys.argv[1].partition(\":\")\n        del sys.argv[1:]\n\n        if category.startswith(\"+\"):\n            basecategory = category[1:].lower()\n            tests = [t for t in results.all()\n                     if result_categories(t)[0].lower() == basecategory]\n        else:\n            tests = results.category(category)\n\n        if subcategory:\n            if subcategory.startswith(\"+\"):\n                url = subcategory[1:]\n                tests = [t for t in tests if url in t[\"#url\"]]\n            elif subcategory.startswith(\"~\"):\n                com = subcategory[1:]\n                tests = [t for t in tests\n                         if \"#comment\" in t and com in t[\"#comment\"].lower()]\n            else:\n                tests = [t for t in tests\n                         if result_categories(t)[-1] == subcategory]\n    else:\n        tests = results.all()\n\n    # add 'test_...' methods\n    enum = collections.defaultdict(int)\n    for result in tests:\n        base, cat, sub = result_categories(result)\n        name = f\"{cat}_{sub}\"\n        enum[name] += 1\n\n        method = _generate_method(result)\n        method.__doc__ = result[\"#url\"]\n        method.__name__ = f\"test_{name}_{enum[name]}\"\n        setattr(TestExtractorResults, method.__name__, method)\n\n\ngenerate_tests()\nif __name__ == \"__main__\":\n    load_test_config()\n    unittest.main(warnings=\"ignore\")\n"
  },
  {
    "path": "test/test_text.py",
    "content": "#!/usr/bin/env python3\n# -*- coding: utf-8 -*-\n\n# Copyright 2015-2026 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nimport os\nimport sys\nimport unittest\n\nsys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))\nfrom gallery_dl import text, util  # noqa E402\n\n\nINVALID = ((), [], {}, None, 1, 2.3)\nINVALID_ALT = ((), [], {}, None, \"\")\n\n\nclass TestText(unittest.TestCase):\n\n    def test_re(self):\n        p1 = text.re_compile(\"foo\")\n        p2 = text.re(\"foo\")\n        p3 = text.re(\"foo\")\n\n        Pattern = text.re_module.Pattern\n        self.assertIsInstance(p1, Pattern)\n        self.assertIsInstance(p2, Pattern)\n        self.assertIsInstance(p3, Pattern)\n\n        self.assertEqual(p1, p2)\n        self.assertIsNot(p1, p2)\n        self.assertIs(p2, p3)\n\n    def test_remove_html(self, f=text.remove_html):\n        result = \"Hello World.\"\n\n        # standard usage\n        self.assertEqual(f(\"\"), \"\")\n        self.assertEqual(f(\"Hello World.\"), result)\n        self.assertEqual(f(\" Hello  World.  \"), result)\n        self.assertEqual(f(\"Hello<br/>World.\"), result)\n        self.assertEqual(\n            f(\"<div><b class='a'>Hello</b><i>World.</i></div>\"), result)\n\n        # empty HTML\n        self.assertEqual(f(\"<div></div>\"), \"\")\n        self.assertEqual(f(\" <div>   </div> \"), \"\")\n\n        # malformed HTML\n        self.assertEqual(f(\"<div</div>\"), \"\")\n        self.assertEqual(f(\"<div<Hello World.</div>\"), \"\")\n\n        # invalid arguments\n        for value in INVALID:\n            self.assertEqual(f(value), \"\")\n\n    def test_split_html(self, f=text.split_html):\n        result = [\"Hello\", \"World.\"]\n        empty = []\n\n        # standard usage\n        self.assertEqual(f(\"\"), empty)\n        self.assertEqual(f(\"Hello World.\"), [\"Hello World.\"])\n        self.assertEqual(f(\" Hello  World.  \"), [\"Hello  World.\"])\n        self.assertEqual(f(\"Hello<br/>World.\"), result)\n        self.assertEqual(f(\" Hello <br/> World.  \"), result)\n        self.assertEqual(\n            f(\"<div><b class='a'>Hello</b><i>World.</i></div>\"), result)\n\n        # escaped HTML entities\n        self.assertEqual(\n            f(\"<i>&lt;foo&gt;</i> <i>&lt;bar&gt; </i>\"), [\"<foo>\", \"<bar>\"])\n\n        # empty HTML\n        self.assertEqual(f(\"<div></div>\"), empty)\n        self.assertEqual(f(\" <div>   </div> \"), empty)\n\n        # malformed HTML\n        self.assertEqual(f(\"<div</div>\"), empty)\n        self.assertEqual(f(\"<div<Hello World.</div>\"), empty)\n\n        # invalid arguments\n        for value in INVALID:\n            self.assertEqual(f(value), empty)\n\n    def test_slugify(self, f=text.slugify):\n        self.assertEqual(f(\"Hello World\"), \"hello-world\")\n        self.assertEqual(f(\"-HeLLo---World-\"), \"hello-world\")\n        self.assertEqual(f(\"_-H#e:l#l:o+\\t+W?o!rl=d-_\"), \"hello-world\")\n        self.assertEqual(f(\"_Hello_World_\"), \"hello_world\")\n\n        self.assertEqual(f(\"\"), \"\")\n        self.assertEqual(f(\"-\"), \"\")\n        self.assertEqual(f(\"--\"), \"\")\n\n        self.assertEqual(f(()), \"\")\n        self.assertEqual(f([]), \"\")\n        self.assertEqual(f({}), \"\")\n        self.assertEqual(f(None), \"none\")\n        self.assertEqual(f(1), \"1\")\n        self.assertEqual(f(2.3), \"23\")\n\n    def test_sanitize_whitespace(self, f=text.sanitize_whitespace):\n        self.assertEqual(f(\"Hello World\"), \"Hello World\")\n        self.assertEqual(f(\"Hello\\tWorld\"), \"Hello World\")\n        self.assertEqual(f(\"  Hello   World  \"), \"Hello World\")\n        self.assertEqual(f(\"\\tHello  \\n\\tWorld  \"), \"Hello World\")\n\n        self.assertEqual(f(\"\"), \"\")\n        self.assertEqual(f(\" \"), \"\")\n        self.assertEqual(f(\"      \"), \"\")\n        self.assertEqual(f(\" \\t\\n \"), \"\")\n\n    def test_ensure_http_scheme(self, f=text.ensure_http_scheme):\n        result = \"https://example.org/filename.ext\"\n\n        # standard usage\n        self.assertEqual(f(\"\"), \"\")\n        self.assertEqual(f(\"example.org/filename.ext\"), result)\n        self.assertEqual(f(\"/example.org/filename.ext\"), result)\n        self.assertEqual(f(\"//example.org/filename.ext\"), result)\n        self.assertEqual(f(\"://example.org/filename.ext\"), result)\n\n        # no change\n        self.assertEqual(f(result), result)\n        self.assertEqual(\n            f(\"http://example.org/filename.ext\"),\n            \"http://example.org/filename.ext\",\n        )\n\n        # ...\n        self.assertEqual(\n            f(\"htp://example.org/filename.ext\"),\n            \"https://htp://example.org/filename.ext\",\n        )\n\n        # invalid arguments\n        for value in INVALID_ALT:\n            self.assertEqual(f(value), value)\n\n    def test_root_from_url(self, f=text.root_from_url):\n        result = \"https://example.org\"\n        self.assertEqual(f(\"https://example.org\")     , result)\n        self.assertEqual(f(\"https://example.org/\")    , result)\n        self.assertEqual(f(\"https://example.org/path\"), result)\n        self.assertEqual(f(\"example.org/\")            , result)\n        self.assertEqual(f(\"example.org/path/\")       , result)\n\n        result = \"http://example.org\"\n        self.assertEqual(f(\"http://example.org\")      , result)\n        self.assertEqual(f(\"http://example.org/\")     , result)\n        self.assertEqual(f(\"http://example.org/path/\"), result)\n        self.assertEqual(f(\"example.org/\", \"http://\") , result)\n\n    def test_filename_from_url(self, f=text.filename_from_url):\n        result = \"filename.ext\"\n\n        # standard usage\n        self.assertEqual(f(\"\"), \"\")\n        self.assertEqual(f(\"filename.ext\"), result)\n        self.assertEqual(f(\"/filename.ext\"), result)\n        self.assertEqual(f(\"example.org/filename.ext\"), result)\n        self.assertEqual(f(\"http://example.org/v2/filename.ext\"), result)\n        self.assertEqual(\n            f(\"http://example.org/v2/filename.ext?param=value#frag\"), result)\n\n        # invalid arguments\n        for value in INVALID:\n            self.assertEqual(f(value), \"\")\n\n    def test_ext_from_url(self, f=text.ext_from_url):\n        result = \"ext\"\n\n        # standard usage\n        self.assertEqual(f(\"\"), \"\")\n        self.assertEqual(f(\"filename\"), \"\")\n        self.assertEqual(f(\"filename.ext\"), result)\n        self.assertEqual(f(\"/filename.ExT\"), result)\n        self.assertEqual(f(\"example.org/filename.ext\"), result)\n        self.assertEqual(f(\"http://example.org/v2/filename.ext\"), result)\n        self.assertEqual(\n            f(\"http://example.org/v2/filename.ext?param=value#frag\"), result)\n\n        # invalid arguments\n        for value in INVALID:\n            self.assertEqual(f(value), \"\")\n\n    def test_nameext_from_url(self, f=text.nameext_from_url):\n        empty = {\"filename\": \"\", \"extension\": \"\"}\n        result = {\"filename\": \"filename\", \"extension\": \"ext\"}\n\n        # standard usage\n        self.assertEqual(f(\"\"), empty)\n        self.assertEqual(f(\"filename.ext\"), result)\n        self.assertEqual(f(\"/filename.ExT\"), result)\n        self.assertEqual(f(\"example.org/filename.ext\"), result)\n        self.assertEqual(f(\"http://example.org/v2/filename.ext\"), result)\n        self.assertEqual(\n            f(\"http://example.org/v2/filename.ext?param=value#frag\"), result)\n        self.assertEqual(\n            f(\"http://example.org/v2/foo%202?bar&<>.ext?param=value#frag\"),\n            {\"filename\": \"foo 2\", \"extension\": \"\"},\n        )\n\n        # long \"extension\"\n        fn = \"httpswww.example.orgpath-path-path-path-path-path-path-path\"\n        self.assertEqual(f(fn), {\"filename\": fn, \"extension\": \"\"})\n\n        # invalid arguments\n        for value in INVALID:\n            self.assertEqual(f(value), empty)\n\n    def test_nameext_from_name(self, f=text.nameext_from_name):\n        self.assertEqual(\n            f(\"\"),\n            {\"filename\": \"\", \"extension\": \"\"},\n        )\n        self.assertEqual(\n            f(\"filename.ext\"),\n            {\"filename\": \"filename\", \"extension\": \"ext\"},\n        )\n        self.assertEqual(\n            f(\"foo%202?bar&<>.ext\"),\n            {\"filename\": \"foo%202?bar&<>\", \"extension\": \"ext\"},\n        )\n\n        # long \"extension\"\n        fn = \"httpswww.example.orgpath-path-path-path-path-path-path-path\"\n        self.assertEqual(f(fn), {\"filename\": fn, \"extension\": \"\"})\n\n    def test_extract(self, f=text.extract):\n        txt = \"<a><b>\"\n        self.assertEqual(f(txt, \"<\", \">\"), (\"a\" , 3))\n        self.assertEqual(f(txt, \"X\", \">\"), (None, 0))\n        self.assertEqual(f(txt, \"<\", \"X\"), (None, 0))\n\n        # 'pos' argument\n        for i in range(1, 4):\n            self.assertEqual(f(txt, \"<\", \">\", i), (\"b\", 6))\n        for i in range(4, 10):\n            self.assertEqual(f(txt, \"<\", \">\", i), (None, i))\n\n        # invalid arguments\n        for value in INVALID:\n            self.assertEqual(f(value, \"<\"  , \">\")  , (None, 0))\n            self.assertEqual(f(txt  , value, \">\")  , (None, 0))\n            self.assertEqual(f(txt  , \"<\"  , value), (None, 0))\n\n    def test_extr(self, f=text.extr):\n        txt = \"<a><b>\"\n        self.assertEqual(f(txt, \"X\", \">\"), \"\")\n        self.assertEqual(f(txt, \"<\", \"X\"), \"\")\n        self.assertEqual(f(txt, \"<\", \">\"), \"a\")\n        self.assertEqual(f(txt, \"><\", \">\"), \"b\")\n\n        # 'default' argument\n        self.assertEqual(f(txt, \"<\", \"X\", None), None)\n        self.assertEqual(f(txt, \"<\", \"X\", default=None), None)\n        self.assertEqual(f(txt, \"<\", \"X\", default=()), ())\n\n        # invalid arguments\n        for value in INVALID:\n            self.assertEqual(f(value, \"<\"  , \">\")  , \"\")\n            self.assertEqual(f(txt  , value, \">\")  , \"\")\n            self.assertEqual(f(txt  , \"<\"  , value), \"\")\n\n    def test_rextract(self, f=text.rextract):\n        txt = \"<a><b>\"\n        self.assertEqual(f(txt, \"<\", \">\"), (\"b\" , 3))\n        self.assertEqual(f(txt, \"X\", \">\"), (None, -1))\n        self.assertEqual(f(txt, \"<\", \"X\"), (None, -1))\n\n        # 'pos' argument\n        for i in range(10, 3, -1):\n            self.assertEqual(f(txt, \"<\", \">\", i), (\"b\", 3))\n        for i in range(3, 0, -1):\n            self.assertEqual(f(txt, \"<\", \">\", i), (\"a\", 0))\n\n        # invalid arguments\n        for value in INVALID:\n            self.assertEqual(f(value, \"<\"  , \">\")  , (None, -1))\n            self.assertEqual(f(txt  , value, \">\")  , (None, -1))\n            self.assertEqual(f(txt  , \"<\"  , value), (None, -1))\n\n    def test_rextr(self, f=text.rextr):\n        txt = \"<a><b>\"\n        self.assertEqual(f(txt, \"<\", \">\"), \"b\")\n        self.assertEqual(f(txt, \"X\", \">\"), \"\")\n        self.assertEqual(f(txt, \"<\", \"X\"), \"\")\n\n        # 'pos' argument\n        for i in range(10, 3, -1):\n            self.assertEqual(f(txt, \"<\", \">\", i), \"b\")\n        for i in range(3, 0, -1):\n            self.assertEqual(f(txt, \"<\", \">\", i), \"a\")\n\n        # 'default' argument\n        self.assertEqual(f(txt, \"[\", \"]\", -1, \"none\"), \"none\")\n        self.assertEqual(f(txt, \"[\", \"]\", None, \"none\"), \"none\")\n        self.assertEqual(f(txt, \"[\", \"]\", default=\"none\"), \"none\")\n\n        # invalid arguments\n        for value in INVALID:\n            self.assertEqual(f(value, \"<\"  , \">\")  , \"\")\n            self.assertEqual(f(txt  , value, \">\")  , \"\")\n            self.assertEqual(f(txt  , \"<\"  , value), \"\")\n\n    def test_extract_all(self, f=text.extract_all):\n        txt = \"[c][b][a]: xyz! [d][e\"\n\n        self.assertEqual(\n            f(txt, ()), ({}, 0))\n        self.assertEqual(\n            f(txt, ((\"C\", \"[\", \"]\"), (\"B\", \"[\", \"]\"), (\"A\", \"[\", \"]\"))),\n            ({\"A\": \"a\", \"B\": \"b\", \"C\": \"c\"}, 9),\n        )\n\n        # 'None' as field name\n        self.assertEqual(\n            f(txt, ((None, \"[\", \"]\"), (None, \"[\", \"]\"), (\"A\", \"[\", \"]\"))),\n            ({\"A\": \"a\"}, 9),\n        )\n        self.assertEqual(\n            f(txt, ((None, \"[\", \"]\"), (None, \"[\", \"]\"), (None, \"[\", \"]\"))),\n            ({}, 9),\n        )\n\n        # failed matches\n        self.assertEqual(\n            f(txt, ((\"C\", \"[\", \"]\"), (\"X\", \"X\", \"X\"), (\"B\", \"[\", \"]\"))),\n            ({\"B\": \"b\", \"C\": \"c\", \"X\": None}, 6),\n        )\n\n        # 'pos' argument\n        self.assertEqual(\n            f(txt, ((\"B\", \"[\", \"]\"), (\"A\", \"[\", \"]\")), pos=1),\n            ({\"A\": \"a\", \"B\": \"b\"}, 9),\n        )\n\n        # 'values' argument\n        self.assertEqual(\n            f(txt, ((\"C\", \"[\", \"]\"),), values={\"A\": \"a\", \"B\": \"b\"}),\n            ({\"A\": \"a\", \"B\": \"b\", \"C\": \"c\"}, 3),\n        )\n\n        vdict = {}\n        rdict, pos = f(txt, (), values=vdict)\n        self.assertIs(vdict, rdict)\n\n    def test_extract_iter(self, f=text.extract_iter):\n        txt = \"[c][b][a]: xyz! [d][e\"\n\n        def g(*args):\n            return list(f(*args))\n\n        self.assertEqual(\n            g(\"\", \"[\", \"]\"), [])\n        self.assertEqual(\n            g(\"[a]\", \"[\", \"]\"), [\"a\"])\n        self.assertEqual(\n            g(txt, \"[\", \"]\"), [\"c\", \"b\", \"a\", \"d\"])\n        self.assertEqual(\n            g(txt, \"X\", \"X\"), [])\n        self.assertEqual(\n            g(txt, \"[\", \"]\", 6), [\"a\", \"d\"])\n\n        # invalid arguments\n        for value in INVALID:\n            self.assertEqual(g(value, \"<\"  , \">\")  , [])\n            self.assertEqual(g(txt  , value, \">\")  , [])\n            self.assertEqual(g(txt  , \"<\"  , value), [])\n\n    def test_extract_from(self, f=text.extract_from):\n        txt = \"[c][b][a]: xyz! [d][e\"\n\n        e = f(txt)\n        self.assertEqual(e(\"[\", \"]\"), \"c\")\n        self.assertEqual(e(\"[\", \"]\"), \"b\")\n        self.assertEqual(e(\"[\", \"]\"), \"a\")\n        self.assertEqual(e(\"[\", \"]\"), \"d\")\n        self.assertEqual(e(\"[\", \"]\"), \"\")\n        self.assertEqual(e(\"[\", \"]\"), \"\")\n\n        e = f(txt, pos=6, default=\"END\")\n        self.assertEqual(e(\"[\", \"]\"), \"a\")\n        self.assertEqual(e(\"[\", \"]\"), \"d\")\n        self.assertEqual(e(\"[\", \"]\"), \"END\")\n        self.assertEqual(e(\"[\", \"]\"), \"END\")\n\n    def test_extract_urls(self, f=text.extract_urls):\n        txt = \"\"\n        self.assertEqual(f(txt), [])\n\n        txt = \"<p>foo </p> &amp; bar <p> </p>\"\n        self.assertEqual(f(txt), [])\n\n        txt = \"\"\"<p>\n  <a href=\"http://www.example.com\">Lorem ipsum dolor sit amet</a>.\n  Duis aute irure <a href=\"http://blog.example.org/lorem?foo=bar\">\n  http://blog.example.org</a>.\n</p>\"\"\"\n        self.assertEqual(f(txt), [\"http://www.example.com\",\n                                  \"http://blog.example.org/lorem?foo=bar\",\n                                  \"http://blog.example.org\"])\n\n    def test_parse_hex_escapes(self, f=text.parse_hex_escapes):\n        self.assertEqual(f(\"\"), \"\")\n        self.assertEqual(f(\"foobar\"), \"foobar\")\n        self.assertEqual(f(\"foo bar\"), \"foo bar\")\n        self.assertEqual(f(\"foo\\\\x20bar\"), \"foo bar\")\n        self.assertEqual(f(\"foo\\\\x20\\\\x2f\\\\x20bar\"), \"foo / bar\")\n        self.assertEqual(f(\"foo\\\\x1zar\"), \"foo\\\\x1zar\")\n        self.assertEqual(\n            f(\"\\\\x20foo\\\\x20\\\\x2F bar\\xff\"),\n            \" foo / barÿ\",\n        )\n\n    def test_parse_unicode_escapes(self, f=text.parse_unicode_escapes):\n        self.assertEqual(f(\"\"), \"\")\n        self.assertEqual(f(\"foobar\"), \"foobar\")\n        self.assertEqual(f(\"foo’bar\"), \"foo’bar\")\n        self.assertEqual(f(\"foo\\\\u2019bar\"), \"foo’bar\")\n        self.assertEqual(f(\"foo\\\\u201bar\"), \"foo‛ar\")\n        self.assertEqual(f(\"foo\\\\u201zar\"), \"foo\\\\u201zar\")\n        self.assertEqual(\n            f(\"\\\\u2018foo\\\\u2019\\\\u2020bar\\\\u00ff\"),\n            \"‘foo’†barÿ\",\n        )\n\n    def test_parse_bytes(self, f=text.parse_bytes):\n        self.assertEqual(f(0), 0)\n        self.assertEqual(f(50), 50)\n        self.assertEqual(f(\"0\"), 0)\n        self.assertEqual(f(\"50\"), 50)\n        self.assertEqual(f(\"50k\"), 50 * 1024**1)\n        self.assertEqual(f(\"50m\"), 50 * 1024**2)\n        self.assertEqual(f(\"50g\"), 50 * 1024**3)\n        self.assertEqual(f(\"50t\"), 50 * 1024**4)\n        self.assertEqual(f(\"50p\"), 50 * 1024**5)\n        self.assertEqual(f(\" 50p \"), 50 * 1024**5)\n\n        # fractions\n        self.assertEqual(f(123.456), 123)\n        self.assertEqual(f(\"123.456\"), 123)\n        self.assertEqual(f(\"123.567\"), 124)\n        self.assertEqual(f(\" 123.89 \"), 124)\n        self.assertEqual(f(\"0.5M\"), round(0.5 * 1024**2))\n\n        # invalid arguments\n        for value in INVALID_ALT:\n            self.assertEqual(f(value), 0)\n        self.assertEqual(f(\"NaN\"), 0)\n        self.assertEqual(f(\"invalid\"), 0)\n        self.assertEqual(f(\" 123 kb \"), 0)\n\n    def test_parse_int(self, f=text.parse_int):\n        self.assertEqual(f(0), 0)\n        self.assertEqual(f(\"0\"), 0)\n        self.assertEqual(f(123), 123)\n        self.assertEqual(f(\"123\"), 123)\n\n        # invalid arguments\n        for value in INVALID_ALT:\n            self.assertEqual(f(value), 0)\n        self.assertEqual(f(\"123.456\"), 0)\n        self.assertEqual(f(\"zzz\"), 0)\n        self.assertEqual(f([1, 2, 3]), 0)\n        self.assertEqual(f({1: 2, 3: 4}), 0)\n\n        # 'default' argument\n        default = \"default\"\n        for value in INVALID_ALT:\n            self.assertEqual(f(value, default), default)\n        self.assertEqual(f(\"zzz\", default), default)\n\n    def test_parse_float(self, f=text.parse_float):\n        self.assertEqual(f(0), 0.0)\n        self.assertEqual(f(\"0\"), 0.0)\n        self.assertEqual(f(123), 123.0)\n        self.assertEqual(f(\"123\"), 123.0)\n        self.assertEqual(f(123.456), 123.456)\n        self.assertEqual(f(\"123.456\"), 123.456)\n\n        # invalid arguments\n        for value in INVALID_ALT:\n            self.assertEqual(f(value), 0.0)\n        self.assertEqual(f(\"zzz\"), 0.0)\n        self.assertEqual(f([1, 2, 3]), 0.0)\n        self.assertEqual(f({1: 2, 3: 4}), 0.0)\n\n        # 'default' argument\n        default = \"default\"\n        for value in INVALID_ALT:\n            self.assertEqual(f(value, default), default)\n        self.assertEqual(f(\"zzz\", default), default)\n\n    def test_parse_query(self, f=text.parse_query):\n        # standard usage\n        self.assertEqual(f(\"\"), {})\n        self.assertEqual(f(\"foo=1\"), {\"foo\": \"1\"})\n        self.assertEqual(f(\"foo=1&bar=2\"), {\"foo\": \"1\", \"bar\": \"2\"})\n\n        # missing value\n        self.assertEqual(f(\"bar\"), {})\n        self.assertEqual(f(\"bar=\"), {\"bar\": \"\"})\n        self.assertEqual(f(\"bar\", empty=True), {\"bar\": \"\"})\n        self.assertEqual(f(\"foo=1&bar\"), {\"foo\": \"1\"})\n        self.assertEqual(f(\"foo=1&bar=\"), {\"foo\": \"1\", \"bar\": \"\"})\n        self.assertEqual(f(\"foo=1&bar\", True), {\"foo\": \"1\", \"bar\": \"\"})\n        self.assertEqual(f(\"foo=1&bar&baz=3\"), {\"foo\": \"1\", \"baz\": \"3\"})\n        self.assertEqual(f(\"foo=1&bar=&baz=3\"),\n                         {\"foo\": \"1\", \"bar\": \"\", \"baz\": \"3\"})\n        self.assertEqual(f(\"foo=1&bar&baz=3\", True),\n                         {\"foo\": \"1\", \"bar\": \"\", \"baz\": \"3\"})\n\n        # keys with identical names\n        self.assertEqual(f(\"foo=1&foo=2\"), {\"foo\": \"1\"})\n        self.assertEqual(\n            f(\"foo=1&bar=2&foo=3&bar=4\"),\n            {\"foo\": \"1\", \"bar\": \"2\"},\n        )\n\n        # invalid arguments\n        for value in INVALID:\n            self.assertEqual(f(value), {})\n\n    def test_parse_query_list(self, f=text.parse_query_list):\n        # standard usage\n        self.assertEqual(f(\"\"), {})\n        self.assertEqual(f(\"foo=1\"), {\"foo\": \"1\"})\n        self.assertEqual(f(\"foo=1&bar=2\"), {\"foo\": \"1\", \"bar\": \"2\"})\n        self.assertEqual(f(\"%C3%A4%26=%E3%81%82%E3%81%A8&%23=%3F\"),\n                         {\"ä&\": \"あと\", \"#\": \"?\"})\n\n        # missing value\n        self.assertEqual(f(\"bar\"), {})\n        self.assertEqual(f(\"foo=1&bar\"), {\"foo\": \"1\"})\n        self.assertEqual(f(\"foo=1&bar&baz=3\"), {\"foo\": \"1\", \"baz\": \"3\"})\n\n        # keys with identical names\n        self.assertEqual(f(\"foo=1&foo=2\", (\"foo\",)), {\"foo\": [\"1\", \"2\"]})\n        self.assertEqual(\n            f(\"foo=1&bar=2&foo=3&bar=4&foo=5\", {\"foo\", \"baz\"}),\n            {\"foo\": [\"1\", \"3\", \"5\"], \"bar\": \"2\"},\n        )\n\n        # invalid arguments\n        for value in INVALID:\n            self.assertEqual(f(value), {})\n\n    def test_build_query(self, f=text.build_query):\n        # standard usage\n        self.assertEqual(f({}), \"\")\n        self.assertEqual(f({\"foo\": \"1\"}), \"foo=1\")\n        self.assertEqual(f({\"foo\": \"1\", \"bar\": \"2\"}), \"foo=1&bar=2\")\n\n        # missing value\n        self.assertEqual(f({\"bar\": \"\"}), \"bar=\")\n        self.assertEqual(f({\"foo\": \"1\", \"bar\": \"\"}), \"foo=1&bar=\")\n        self.assertEqual(f({\"foo\": \"1\", \"bar\": \"\", \"baz\": \"3\"}),\n                         \"foo=1&bar=&baz=3\")\n\n        self.assertEqual(f({\"ä&\": \"あと\", \"#\": \"?\"}),\n                         \"%C3%A4%26=%E3%81%82%E3%81%A8&%23=%3F\")\n\n\nif __name__ == \"__main__\":\n    unittest.main()\n"
  },
  {
    "path": "test/test_util.py",
    "content": "#!/usr/bin/env python3\n# -*- coding: utf-8 -*-\n\n# Copyright 2015-2026 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nimport os\nimport sys\nimport unittest\nfrom unittest.mock import Mock, patch\n\nimport io\nimport time\nimport random\nimport string\nimport datetime\nimport platform\nimport tempfile\nimport itertools\nimport http.cookiejar\n\nsys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))\nfrom gallery_dl import util, text, dt, exception  # noqa E402\n\n\nclass TestRange(unittest.TestCase):\n\n    def test_parse_empty(self):\n        f = util.predicate_range_parse\n\n        self.assertEqual(f(\"\"), [])\n        self.assertEqual(f([]), [])\n\n    def test_parse_digit(self):\n        f = util.predicate_range_parse\n\n        self.assertEqual(f(2), [range(2, 3)])\n        self.assertEqual(f(\"2\"), [range(2, 3)])\n\n        self.assertEqual(\n            f(\"2, 3, 4\"),\n            [range(2, 3),\n             range(3, 4),\n             range(4, 5)],\n        )\n        self.assertEqual(\n            f([\"2\", \"3\", \"4\"]),\n            [range(2, 3),\n             range(3, 4),\n             range(4, 5)],\n        )\n\n    def test_parse_range(self):\n        f = util.predicate_range_parse\n\n        self.assertEqual(f(\"1-2\"), [range(1, 3)])\n        self.assertEqual(f(\"2-\"), [range(2, sys.maxsize)])\n        self.assertEqual(f(\"-3\"), [range(1, 4)])\n        self.assertEqual(f(\"-\"), [range(1, sys.maxsize)])\n\n        self.assertEqual(\n            f(\"-2,4,6-8,10-\"),\n            [range(1, 3),\n             range(4, 5),\n             range(6, 9),\n             range(10, sys.maxsize)],\n        )\n        self.assertEqual(\n            f(\" - 3 , 4-  4, 2-6\"),\n            [range(1, 4),\n             range(4, 5),\n             range(2, 7)],\n        )\n\n    def test_parse_slice(self):\n        f = util.predicate_range_parse\n\n        self.assertEqual(f(\"2:4\")  , [range(2, 4)])\n        self.assertEqual(f(\"3::\")  , [range(3, sys.maxsize)])\n        self.assertEqual(f(\":4:\")  , [range(1, 4)])\n        self.assertEqual(f(\"::5\")  , [range(1, sys.maxsize, 5)])\n        self.assertEqual(f(\"::\")   , [range(1, sys.maxsize)])\n        self.assertEqual(f(\"2:3:4\"), [range(2, 3, 4)])\n\n        self.assertEqual(\n            f(\"2:4, 4:, :4, :4:, ::4\"),\n            [range(2, 4),\n             range(4, sys.maxsize),\n             range(1, 4),\n             range(1, 4),\n             range(1, sys.maxsize, 4)],\n        )\n        self.assertEqual(\n            f(\" : 3 , 4:  4, 2:6\"),\n            [range(1, 3),\n             range(4, 4),\n             range(2, 6)],\n        )\n\n\nclass TestPredicate(unittest.TestCase):\n\n    def assertDate(self, expected, dt_string):\n        kwdict = {\"date\": dt.parse_iso(dt_string)} if dt_string else {}\n        self.assertEqual(bool(expected), self.pred(\"\", kwdict), msg=dt_string)\n\n    def test_predicate_range(self):\n        dummy = None\n\n        pred = util.predicate_range(\" - 3 , 4-  4, 2-6\")\n        for i in range(6):\n            self.assertTrue(pred(dummy, dummy))\n        with self.assertRaises(exception.StopExtraction):\n            pred(dummy, dummy)\n\n        pred = util.predicate_range(\"1, 3, 5\")\n        self.assertTrue(pred(dummy, dummy))\n        self.assertFalse(pred(dummy, dummy))\n        self.assertTrue(pred(dummy, dummy))\n        self.assertFalse(pred(dummy, dummy))\n        self.assertTrue(pred(dummy, dummy))\n        with self.assertRaises(exception.StopExtraction):\n            pred(dummy, dummy)\n\n        pred = util.predicate_range(\"\")\n        with self.assertRaises(exception.StopExtraction):\n            pred(dummy, dummy)\n\n    def test_predicate_unique(self):\n        dummy = None\n        pred = util.predicate_unique()\n\n        # no duplicates\n        self.assertTrue(pred(\"1\", dummy))\n        self.assertTrue(pred(\"2\", dummy))\n        self.assertFalse(pred(\"1\", dummy))\n        self.assertFalse(pred(\"2\", dummy))\n        self.assertTrue(pred(\"3\", dummy))\n        self.assertFalse(pred(\"3\", dummy))\n\n        # duplicates for \"text:\"\n        self.assertTrue(pred(\"text:123\", dummy))\n        self.assertTrue(pred(\"text:123\", dummy))\n        self.assertTrue(pred(\"text:123\", dummy))\n\n    def test_predicate_filter(self):\n        url = \"\"\n\n        pred = util.predicate_filter(\"a < 3\")\n        self.assertTrue(pred(url, {\"a\": 2}))\n        self.assertFalse(pred(url, {\"a\": 3}))\n\n        with self.assertRaises(SyntaxError):\n            util.predicate_filter(\"(\")\n\n        self.assertFalse(\n            util.predicate_filter(\"a > 1\")(url, {\"a\": None}))\n        self.assertFalse(\n            util.predicate_filter(\"b > 1\")(url, {\"a\": 2}))\n\n        pred = util.predicate_filter([\"a < 3\", \"b < 4\", \"c < 5\"])\n        self.assertTrue(pred(url, {\"a\": 2, \"b\": 3, \"c\": 4}))\n        self.assertFalse(pred(url, {\"a\": 3, \"b\": 3, \"c\": 4}))\n        self.assertFalse(pred(url, {\"a\": 2, \"b\": 4, \"c\": 4}))\n        self.assertFalse(pred(url, {\"a\": 2, \"b\": 3, \"c\": 5}))\n\n        self.assertFalse(pred(url, {\"a\": 2}))\n\n        pred = util.predicate_filter(\"re.search(r'.+', url)\")\n        self.assertTrue(pred(url, {\"url\": \"https://example.org/\"}))\n        self.assertFalse(pred(url, {\"url\": \"\"}))\n\n    def test_predicate_tags(self):\n        url = \"\"\n\n        pred = util.predicate_tags(\"\")\n        self.assertTrue(pred(url, {}))\n        self.assertTrue(pred(url, {\"a\": 3}))\n        self.assertTrue(pred(url, {\"tags\": []}))\n        self.assertTrue(pred(url, {\"tags\": [\"foo\", \"bar\"]}))\n        self.assertTrue(pred(url, {\"tags\": [\"foo\", \"bar\", \"baz\"]}))\n\n        pred = util.predicate_tags(\"baz\")\n        self.assertTrue(pred(url, {}))\n        self.assertTrue(pred(url, {\"a\": 3}))\n        self.assertTrue(pred(url, {\"tags\": []}))\n        self.assertTrue(pred(url, {\"tags\": [\"foo\", \"bar\"]}))\n        self.assertFalse(pred(url, {\"tags\": [\"foo\", \"bar\", \"baz\"]}))\n\n        pred = util.predicate_tags(\" t1 , t2,t3,     T4  \")\n        self.assertTrue(pred(url, {\"tags\": [\"foo\", \"bar\"]}))\n        self.assertFalse(pred(url, {\"tags\": [\"foo\", \"t4\", \"bar\"]}))\n        self.assertFalse(pred(url, {\"tags\": [\"t3\", \"t2\", \"t1\"]}))\n\n        pred = util.predicate_tags(\" t1 , t2,t3,     T4  \")\n        self.assertTrue(pred(url, {\"tags\": \"foo, bar, baz\"}))\n        self.assertFalse(pred(url, {\"tags\": \"foo, t4, baz\"}))\n        self.assertFalse(pred(url, {\"tags\": \"t3, t2, t1\"}))\n        self.assertFalse(pred(url, {\"tags\": \"t3 abcde t2 xyz t1\"}))\n\n    def test_predicate_tags_negate(self):\n        url = \"\"\n\n        pred = util.predicate_tags(\"\", negate=True)\n        self.assertTrue(pred(url, {}))\n        self.assertTrue(pred(url, {\"a\": 3}))\n        self.assertTrue(pred(url, {\"tags\": []}))\n        self.assertFalse(pred(url, {\"tags\": [\"foo\", \"bar\"]}))\n        self.assertFalse(pred(url, {\"tags\": [\"foo\", \"bar\", \"baz\"]}))\n\n        pred = util.predicate_tags(\"baz\", negate=True)\n        self.assertTrue(pred(url, {}))\n        self.assertTrue(pred(url, {\"a\": 3}))\n        self.assertTrue(pred(url, {\"tags\": []}))\n        self.assertFalse(pred(url, {\"tags\": [\"foo\", \"bar\"]}))\n        self.assertTrue(pred(url, {\"tags\": [\"foo\", \"bar\", \"baz\"]}))\n\n        pred = util.predicate_tags(\" t1 , t2,t3,     T4  \", negate=True)\n        self.assertFalse(pred(url, {\"tags\": [\"foo\", \"bar\"]}))\n        self.assertTrue(pred(url, {\"tags\": [\"foo\", \"t4\", \"bar\"]}))\n        self.assertTrue(pred(url, {\"tags\": [\"t3\", \"t2\", \"t1\"]}))\n\n        pred = util.predicate_tags(\" t1 , t2,t3,     T4  \", negate=True)\n        self.assertFalse(pred(url, {\"tags\": \"foo, bar, baz\"}))\n        self.assertTrue(pred(url, {\"tags\": \"foo, t4, baz\"}))\n        self.assertTrue(pred(url, {\"tags\": \"t3, t2, t1\"}))\n        self.assertTrue(pred(url, {\"tags\": \"t3 abcde t2 xyz t1\"}))\n\n    def test_predicate_tags_file(self):\n        url = \"\"\n\n        with tempfile.NamedTemporaryFile() as tmp:\n            pred = util.predicate_tags(tmp.name)\n            self.assertTrue(pred(url, {}))\n            self.assertTrue(pred(url, {\"a\": 3}))\n            self.assertTrue(pred(url, {\"tags\": []}))\n            self.assertTrue(pred(url, {\"tags\": [\"foo\", \"bar\"]}))\n            self.assertTrue(pred(url, {\"tags\": [\"foo\", \"bar\", \"baz\"]}))\n\n            tmp.write(b\"baz\")\n            tmp.flush()\n            pred = util.predicate_tags(tmp.name)\n            self.assertTrue(pred(url, {}))\n            self.assertTrue(pred(url, {\"a\": 3}))\n            self.assertTrue(pred(url, {\"tags\": []}))\n            self.assertTrue(pred(url, {\"tags\": [\"foo\", \"bar\"]}))\n            self.assertFalse(pred(url, {\"tags\": [\"foo\", \"bar\", \"baz\"]}))\n\n            tmp.seek(0)\n            tmp.write(b\" t1 \\n t2\\nt3\\n\\n\\n     T4  \\n\")\n            tmp.flush()\n            pred = util.predicate_tags(tmp.name)\n            self.assertTrue(pred(url, {\"tags\": [\"foo\", \"bar\"]}))\n            self.assertFalse(pred(url, {\"tags\": [\"foo\", \"t4\", \"bar\"]}))\n            self.assertFalse(pred(url, {\"tags\": [\"t3\", \"t2\", \"t1\"]}))\n\n            tmp.seek(0)\n            tmp.write(b\" t1 \\n t2\\nt3\\n\\n\\n     T4  \\n\")\n            tmp.flush()\n            pred = util.predicate_tags(tmp.name)\n            self.assertTrue(pred(url, {\"tags\": \"foo, bar, baz\"}))\n            self.assertFalse(pred(url, {\"tags\": \"foo, t4, baz\"}))\n            self.assertFalse(pred(url, {\"tags\": \"t3, t2, t1\"}))\n            self.assertFalse(pred(url, {\"tags\": \"t3 abcde t2 xyz t1\"}))\n\n    def test_predicate_date(self):\n        self.pred = util.predicate_date(\n            before=dt.parse_iso(\"2021-11-11\"),\n            after=dt.parse_iso(\"2020-10-10\"))\n        self.assertTrue(callable(self.pred))\n        self.assertDate(1, \"\")\n        self.assertDate(0, \"2021-11-11 12:34:56\")\n        self.assertDate(0, \"2021-11-11\")\n        self.assertDate(1, \"2021-11-10 23:59:59\")\n        self.assertDate(1, \"2020-10-10 12:34:56\")\n        with self.assertRaises(exception.StopExtraction):\n            self.assertDate(0, \"2020-10-10\")\n        with self.assertRaises(exception.StopExtraction):\n            self.assertDate(0, \"2020-10-09\")\n\n        func = Mock(return_value=True)\n        self.pred = util.predicate_date(\n            before=dt.parse_iso(\"2021-11-11\"),\n            after=dt.parse_iso(\"2020-10-10\"),\n            skip=func)\n        func.assert_called_with(dt.datetime(2021, 11, 11))\n        self.assertTrue(callable(self.pred))\n        self.assertIsNot(self.pred, util.true)\n\n    def test_predicate_date2(self):\n        # 'after' > 'before'\n        self.pred = util.predicate_date(\n            after=dt.parse_iso(\"2021-11-11\"),\n            before=dt.parse_iso(\"2020-10-10\"))\n        self.assertTrue(callable(self.pred))\n        self.assertDate(1, \"\")\n        self.assertDate(1, \"2021-12-12 12:34:56\")\n        self.assertDate(1, \"2021-11-11 12:34:56\")\n        with self.assertRaises(exception.StopExtraction):\n            self.assertDate(0, \"2021-11-11\")\n\n        func = Mock(return_value=True)\n        self.pred = util.predicate_date(\n            after=dt.parse_iso(\"2021-11-11\"),\n            before=dt.parse_iso(\"2020-10-10\"),\n            skip=func)\n        func.assert_not_called()\n        self.assertTrue(callable(self.pred))\n        self.assertIsNot(self.pred, util.true)\n\n    def test_predicate_date_before(self):\n        self.pred = util.predicate_date(dt.parse_iso(\"2020-10-10\"))\n        self.assertTrue(callable(self.pred))\n\n        self.assertDate(1, \"\")\n        self.assertDate(0, \"2022-11-11\")\n        self.assertDate(0, \"2020-10-10 12:34:56\")\n        self.assertDate(0, \"2020-10-10\")\n        self.assertDate(1, \"2020-10-09 12:34:56\")\n        self.assertDate(1, \"2020-10-09\")\n\n        func = Mock(return_value=True)\n        pred = util.predicate_date(dt.parse_iso(\"2020-10-10\"), skip=func)\n        func.assert_called_with(dt.datetime(2020, 10, 10))\n        self.assertTrue(callable(pred))\n        self.assertIs(pred, util.true)\n\n        func = Mock(return_value=None)\n        pred = util.predicate_date(dt.parse_iso(\"2020-10-10\"), skip=func)\n        func.assert_called_with(dt.datetime(2020, 10, 10))\n        self.assertTrue(callable(pred))\n        self.assertIsNot(pred, util.true)\n\n    def test_predicate_date_after(self):\n        self.pred = util.predicate_date(None, dt.parse_iso(\"2020-10-10\"))\n        self.assertTrue(callable(self.pred))\n\n        self.assertDate(1, \"\")\n        self.assertDate(1, \"2022-11-11\")\n        self.assertDate(1, \"2020-10-10 12:34:56\")\n        with self.assertRaises(exception.StopExtraction):\n            self.assertDate(0, \"2020-10-10\")\n        with self.assertRaises(exception.StopExtraction):\n            self.assertDate(0, \"2020-10-09 12:34:56\")\n        with self.assertRaises(exception.StopExtraction):\n            self.assertDate(0, \"2020-10-09\")\n\n    def test_predicate_build(self):\n        pred = util.predicate_build([])\n        self.assertIsInstance(pred, type(lambda: True))\n\n        pred = util.predicate_build([util.predicate_unique()])\n        self.assertTrue(callable(pred))\n        self.assertIn(\"predicate_unique.\", repr(pred))\n\n        pred = util.predicate_build([util.predicate_unique(),\n                                     util.predicate_unique()])\n        self.assertTrue(callable(pred))\n        self.assertIn(\"predicate_build.\", repr(pred))\n        self.assertIn(\".chain\", repr(pred))\n\n\nclass TestISO639_1(unittest.TestCase):\n\n    def test_code_to_language(self):\n        d = \"default\"\n        self._run_test(util.code_to_language, {\n            (\"en\",): \"English\",\n            (\"FR\",): \"French\",\n            (\"ja\",): \"Japanese\",\n            (\"xx\",): None,\n            (\"\"  ,): None,\n            (None,): None,\n            (\"en\", d): \"English\",\n            (\"FR\", d): \"French\",\n            (\"xx\", d): d,\n            (\"\"  , d): d,\n            (None, d): d,\n        })\n\n    def test_language_to_code(self):\n        d = \"default\"\n        self._run_test(util.language_to_code, {\n            (\"English\",): \"en\",\n            (\"fRENch\",): \"fr\",\n            (\"Japanese\",): \"ja\",\n            (\"xx\",): None,\n            (\"\"  ,): None,\n            (None,): None,\n            (\"English\", d): \"en\",\n            (\"fRENch\", d): \"fr\",\n            (\"xx\", d): d,\n            (\"\"  , d): d,\n            (None, d): d,\n        })\n\n    def _run_test(self, func, tests):\n        for args, result in tests.items():\n            self.assertEqual(func(*args), result)\n\n\nclass TestCookiesTxt(unittest.TestCase):\n\n    def test_cookiestxt_load(self):\n\n        def _assert(content, expected):\n            cookies = util.cookiestxt_load(io.StringIO(content, None))\n            for c, e in zip(cookies, expected):\n                self.assertEqual(c.__dict__, e.__dict__)\n\n        _assert(\"\", [])\n        _assert(\"\\n\\n\\n\", [])\n        _assert(\"$ Comment\", [])\n        _assert(\"# Comment\", [])\n        _assert(\" # Comment \\n\\n $ Comment \", [])\n        _assert(\n            \".example.org\\tTRUE\\t/\\tTRUE\\t0\\tname\\tvalue\",\n            [self._cookie(\"name\", \"value\", \".example.org\")],\n        )\n        _assert(\n            \".example.org\\tTRUE\\t/\\tTRUE\\t\\tname\\t\",\n            [self._cookie(\"name\", \"\", \".example.org\")],\n        )\n        _assert(\n            \"\\tTRUE\\t/\\tTRUE\\t\\tname\\t\",\n            [self._cookie(\"name\", \"\", \"\")],\n        )\n        _assert(\n            \"# Netscape HTTP Cookie File\\n\"\n            \"\\n\"\n            \"# default\\n\"\n            \".example.org\tTRUE\t/\tFALSE\t0\tn1\tv1\\n\"\n            \".example.org\tTRUE\t/\tTRUE\t2145945600\tn2\tv2\\n\"\n            \".example.org\tTRUE\t/path\tFALSE\t0\t\tn3\\n\"\n            \"\\n\"\n            \"  # # extra # #  \\n\"\n            \"www.example.org\tFALSE\t/\tFALSE\t\tn4\t\\n\"\n            \"www.example.org\tFALSE\t/path\tFALSE\t100\tn5\tv5\\n\",\n            [\n                self._cookie(\n                    \"n1\", \"v1\", \".example.org\", True, \"/\", False),\n                self._cookie(\n                    \"n2\", \"v2\", \".example.org\", True, \"/\", True, 2145945600),\n                self._cookie(\n                    \"n3\", None, \".example.org\", True, \"/path\", False),\n                self._cookie(\n                    \"n4\", \"\"  , \"www.example.org\", False, \"/\", False),\n                self._cookie(\n                    \"n5\", \"v5\", \"www.example.org\", False, \"/path\", False, 100),\n            ],\n        )\n\n        with self.assertRaises(ValueError):\n            util.cookiestxt_load(\"example.org\\tTRUE\\t/\\tTRUE\\t0\\tname\")\n\n    def test_cookiestxt_store(self):\n\n        def _assert(cookies, expected):\n            fp = io.StringIO(newline=None)\n            util.cookiestxt_store(fp, cookies)\n            self.assertMultiLineEqual(fp.getvalue(), expected)\n\n        _assert([], \"# Netscape HTTP Cookie File\\n\\n\")\n        _assert(\n            [self._cookie(\"name\", \"value\", \".example.org\")],\n            \"# Netscape HTTP Cookie File\\n\\n\"\n            \".example.org\\tTRUE\\t/\\tTRUE\\t0\\tname\\tvalue\\n\",\n        )\n        _assert(\n            [\n                self._cookie(\n                    \"n1\", \"v1\", \".example.org\", True, \"/\", False),\n                self._cookie(\n                    \"n2\", \"v2\", \".example.org\", True, \"/\", True, 2145945600),\n                self._cookie(\n                    \"n3\", None, \".example.org\", True, \"/path\", False),\n                self._cookie(\n                    \"n4\", \"\"  , \"www.example.org\", False, \"/\", False),\n                self._cookie(\n                    \"n5\", \"v5\", \"www.example.org\", False, \"/path\", False, 100),\n                self._cookie(\n                    \"n6\", \"v6\", \"\", False),\n            ],\n            \"# Netscape HTTP Cookie File\\n\"\n            \"\\n\"\n            \".example.org\tTRUE\t/\tFALSE\t0\tn1\tv1\\n\"\n            \".example.org\tTRUE\t/\tTRUE\t2145945600\tn2\tv2\\n\"\n            \".example.org\tTRUE\t/path\tFALSE\t0\t\tn3\\n\"\n            \"www.example.org\tFALSE\t/\tFALSE\t0\tn4\t\\n\"\n            \"www.example.org\tFALSE\t/path\tFALSE\t100\tn5\tv5\\n\",\n        )\n\n    def _cookie(self, name, value, domain, domain_specified=True,\n                path=\"/\", secure=True, expires=None):\n        return http.cookiejar.Cookie(\n            0, name, value, None, False,\n            domain, domain_specified, domain.startswith(\".\"),\n            path, False, secure, expires, False, None, None, {},\n        )\n\n\nclass TestCompileExpression(unittest.TestCase):\n\n    def test_compile_expression(self):\n        expr = util.compile_expression(\"1 + 2 * 3\")\n        self.assertEqual(expr(), 7)\n        self.assertEqual(expr({\"a\": 1, \"b\": 2, \"c\": 3}), 7)\n        self.assertEqual(expr({\"a\": 9, \"b\": 9, \"c\": 9}), 7)\n\n        expr = util.compile_expression(\"a + b * c\")\n        self.assertEqual(expr({\"a\": 1, \"b\": 2, \"c\": 3}), 7)\n        self.assertEqual(expr({\"a\": 9, \"b\": 9, \"c\": 9}), 90)\n\n        with self.assertRaises(SyntaxError):\n            util.compile_expression(\"\")\n        with self.assertRaises(SyntaxError):\n            util.compile_expression(\"x++\")\n\n        expr = util.compile_expression(\"1 and abort()\")\n        with self.assertRaises(exception.StopExtraction):\n            expr()\n\n    def test_compile_expression_raw(self):\n        expr = util.compile_expression_raw(\"a + b * c\")\n        with self.assertRaises(NameError):\n            expr()\n        with self.assertRaises(NameError):\n            expr({\"a\": 2})\n\n        expr = util.compile_expression_raw(\"int.param\")\n        with self.assertRaises(AttributeError):\n            expr({\"a\": 2})\n\n    def test_compile_expression_tryexcept(self):\n        expr = util.compile_expression_tryexcept(\"a + b * c\")\n        self.assertIs(expr(), util.NONE)\n        self.assertIs(expr({\"a\": 2}), util.NONE)\n\n        expr = util.compile_expression_tryexcept(\"int.param\")\n        self.assertIs(expr({\"a\": 2}), util.NONE)\n\n    def test_compile_expression_defaultdict(self):\n        expr = util.compile_expression_defaultdict(\"a + b * c\")\n        self.assertIs(expr(), util.NONE)\n        self.assertIs(expr({\"a\": 2}), util.NONE)\n\n        expr = util.compile_expression_defaultdict(\"int.param\")\n        with self.assertRaises(AttributeError):\n            expr({\"a\": 2})\n\n    def test_compile_filter(self):\n        expr = util.compile_filter(\"a + b * c\")\n        self.assertEqual(expr({\"a\": 1, \"b\": 2, \"c\": 3}), 7)\n        self.assertEqual(expr({\"a\": 9, \"b\": 9, \"c\": 9}), 90)\n\n        expr = util.compile_filter([\"a % 2 == 0\", \"b % 3 == 0\", \"c % 5 == 0\"])\n        self.assertTrue(expr({\"a\": 4, \"b\": 6, \"c\": 10}))\n        self.assertFalse(expr({\"a\": 1, \"b\": 2, \"c\": 3}))\n\n    def test_custom_globals(self):\n        value = {\"v\": \"foobar\"}\n        result = \"8843d7f92416211de9ebb963ff4ce28125932878\"\n\n        expr = util.compile_expression(\"hash_sha1(v)\")\n        self.assertEqual(expr(value), result)\n\n        expr = util.compile_expression(\"hs(v)\", globals={\"hs\": util.sha1})\n        self.assertEqual(expr(value), result)\n\n        with tempfile.TemporaryDirectory() as path:\n            file = f\"{path}/module_sha1.py\"\n            with open(file, \"w\") as fp:\n                fp.write(\"\"\"\nimport hashlib\ndef hash(value):\n    return hashlib.sha1(value.encode()).hexdigest()\n\"\"\")\n            module = util.import_file(file)\n\n        expr = util.compile_expression(\"hash(v)\", globals=module.__dict__)\n        self.assertEqual(expr(value), result)\n\n        GLOBALS_ORIG = util.GLOBALS\n        try:\n            util.GLOBALS = module.__dict__\n            expr = util.compile_expression(\"hash(v)\")\n        finally:\n            util.GLOBALS = GLOBALS_ORIG\n        self.assertEqual(expr(value), result)\n\n\nclass TestOther(unittest.TestCase):\n\n    def test_bencode(self):\n        self.assertEqual(util.bencode(0), \"\")\n        self.assertEqual(util.bencode(123), \"123\")\n        self.assertEqual(util.bencode(123, \"01\"), \"1111011\")\n        self.assertEqual(util.bencode(123, \"BA\"), \"AAAABAA\")\n\n    def test_bdecode(self):\n        self.assertEqual(util.bdecode(\"\"), 0)\n        self.assertEqual(util.bdecode(\"123\"), 123)\n        self.assertEqual(util.bdecode(\"1111011\", \"01\"), 123)\n        self.assertEqual(util.bdecode(\"AAAABAA\", \"BA\"), 123)\n\n    def test_bencode_bdecode(self):\n        for _ in range(100):\n            value = random.randint(0, 1000000)\n            for alphabet in (\"01\", \"0123456789\", string.ascii_letters):\n                result = util.bdecode(util.bencode(value, alphabet), alphabet)\n                self.assertEqual(result, value)\n\n    def test_b36encode(self, f=util.b36encode):\n        self.assertEqual(f(0), \"\")\n        self.assertEqual(f(16), \"g\")\n        self.assertEqual(f(37), \"11\")\n        self.assertEqual(f(123), \"3f\")\n        self.assertEqual(f(1234567890), \"kf12oi\")\n\n    def test_b36decode(self, f=util.b36decode):\n        self.assertEqual(f(\"\"), 0)\n        self.assertEqual(f(\"g\"), 16)\n        self.assertEqual(f(\"11\"), 37)\n        self.assertEqual(f(\"3f\"), 123)\n        self.assertEqual(f(\"kf12oi\"), 1234567890)\n\n    def test_advance(self):\n        items = range(5)\n\n        self.assertCountEqual(\n            util.advance(items, 0), items)\n        self.assertCountEqual(\n            util.advance(items, 3), range(3, 5))\n        self.assertCountEqual(\n            util.advance(items, 9), [])\n        self.assertCountEqual(\n            util.advance(util.advance(items, 1), 2), range(3, 5))\n\n    def test_unique(self):\n        self.assertSequenceEqual(\n            list(util.unique(\"\")), \"\")\n        self.assertSequenceEqual(\n            list(util.unique(\"AABBCC\")), \"ABC\")\n        self.assertSequenceEqual(\n            list(util.unique(\"ABABABCAABBCC\")), \"ABC\")\n        self.assertSequenceEqual(\n            list(util.unique([1, 2, 1, 3, 2, 1])), [1, 2, 3])\n\n    def test_unique_sequence(self):\n        self.assertSequenceEqual(\n            list(util.unique_sequence(\"\")), \"\")\n        self.assertSequenceEqual(\n            list(util.unique_sequence(\"AABBCC\")), \"ABC\")\n        self.assertSequenceEqual(\n            list(util.unique_sequence(\"ABABABCAABBCC\")), \"ABABABCABC\")\n        self.assertSequenceEqual(\n            list(util.unique_sequence([1, 2, 1, 3, 2, 1])), [1, 2, 1, 3, 2, 1])\n\n    def test_contains(self):\n        c = [1, \"2\", 3, 4, \"5\", \"foo\"]\n        self.assertTrue(util.contains(c, 1))\n        self.assertTrue(util.contains(c, \"foo\"))\n        self.assertTrue(util.contains(c, [1, 3, \"5\"]))\n        self.assertTrue(util.contains(c, [\"a\", \"b\", \"5\"]))\n        self.assertFalse(util.contains(c, \"bar\"))\n        self.assertFalse(util.contains(c, [2, 5, \"bar\"]))\n\n        s = \"1 2 3 asd qwe y(+)c f(+)(-) bar\"\n        self.assertTrue(util.contains(s, \"y(+)c\"))\n        self.assertTrue(util.contains(s, [\"asd\", \"qwe\", \"yxc\"]))\n        self.assertTrue(util.contains(s, [\"sdf\", \"dfg\", \"qwe\"]))\n        self.assertFalse(util.contains(s, \"tag1\"))\n        self.assertFalse(util.contains(s, [\"tag1\", \"tag2\", \"tag3\"]))\n\n        self.assertTrue(util.contains(s, \"(+)\", \"\"))\n        self.assertTrue(util.contains(s, [\"(-)\", \"(+)\"], \"\"))\n        self.assertTrue(util.contains(s, \"(+)\", 0))\n        self.assertTrue(util.contains(s, \"(+)\", False))\n\n        self.assertFalse(util.contains(s, \"(+)\", None))\n        self.assertTrue(util.contains(s, \"y(+)c\", None))\n        self.assertTrue(util.contains(s, [\"(-)\", \"(+)\", \"bar\"], None))\n\n        s = \"1, 2, 3, asd, qwe, y(+)c, f(+)(-), bar\"\n        self.assertTrue(util.contains(s, \"y(+)c\", \", \"))\n        self.assertTrue(util.contains(s, [\"sdf\", \"dfg\", \"qwe\"], \", \"))\n        self.assertFalse(util.contains(s, \"tag1\", \", \"))\n\n    def test_raises(self):\n        func = util.raises(Exception)\n        with self.assertRaises(Exception):\n            func()\n\n        func = util.raises(ValueError)\n        with self.assertRaises(ValueError):\n            func(1)\n        with self.assertRaises(ValueError):\n            func(2)\n        with self.assertRaises(ValueError):\n            func(3)\n\n    def test_identity(self):\n        for value in (123, \"foo\", [1, 2, 3], (1, 2, 3), {1: 2}, None):\n            self.assertIs(util.identity(value), value)\n\n    def test_noop(self):\n        self.assertEqual(util.noop(), None)\n        self.assertEqual(util.noop(...), None)\n\n    def test_md5(self):\n        self.assertEqual(util.md5(b\"\"),\n                         \"d41d8cd98f00b204e9800998ecf8427e\")\n        self.assertEqual(util.md5(b\"hello\"),\n                         \"5d41402abc4b2a76b9719d911017c592\")\n\n        self.assertEqual(util.md5(\"\"),\n                         \"d41d8cd98f00b204e9800998ecf8427e\")\n        self.assertEqual(util.md5(\"hello\"),\n                         \"5d41402abc4b2a76b9719d911017c592\")\n        self.assertEqual(util.md5(\"ワルド\"),\n                         \"051f29cd6c942cf110a0ccc5729871d2\")\n\n        self.assertEqual(util.md5(0),\n                         \"d41d8cd98f00b204e9800998ecf8427e\")\n        self.assertEqual(util.md5(()),\n                         \"d41d8cd98f00b204e9800998ecf8427e\")\n        self.assertEqual(util.md5(None),\n                         \"d41d8cd98f00b204e9800998ecf8427e\")\n\n    def test_sha1(self):\n        self.assertEqual(util.sha1(b\"\"),\n                         \"da39a3ee5e6b4b0d3255bfef95601890afd80709\")\n        self.assertEqual(util.sha1(b\"hello\"),\n                         \"aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d\")\n\n        self.assertEqual(util.sha1(\"\"),\n                         \"da39a3ee5e6b4b0d3255bfef95601890afd80709\")\n        self.assertEqual(util.sha1(\"hello\"),\n                         \"aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d\")\n        self.assertEqual(util.sha1(\"ワルド\"),\n                         \"0cbe319081aa0e9298448ec2bb16df8c494aa04e\")\n\n        self.assertEqual(util.sha1(0),\n                         \"da39a3ee5e6b4b0d3255bfef95601890afd80709\")\n        self.assertEqual(util.sha1(()),\n                         \"da39a3ee5e6b4b0d3255bfef95601890afd80709\")\n        self.assertEqual(util.sha1(None),\n                         \"da39a3ee5e6b4b0d3255bfef95601890afd80709\")\n\n    def test_import_file(self):\n        module = util.import_file(\"datetime\")\n        self.assertIs(module, datetime)\n\n        with tempfile.TemporaryDirectory() as path:\n            file = f\"{path}/module_test.py\"\n            with open(file, \"w\") as fp:\n                fp.write(\"\"\"\nimport datetime\nkey = \"foobar\"\nvalue = 123\n\"\"\")\n            module = util.import_file(file)\n\n        self.assertEqual(module.__name__, \"module_test\")\n        self.assertEqual(module.key, \"foobar\")\n        self.assertEqual(module.value, 123)\n        self.assertIs(module.datetime, datetime)\n\n    def test_build_selection_func(self, f=util.build_selection_func):\n\n        def test_single(df, v, type=None):\n            for _ in range(10):\n                self.assertEqual(df(), v)\n                if type is not None:\n                    self.assertIsInstance(df(), type)\n\n        def test_range(df, lower, upper, type=None):\n            for __ in range(10):\n                v = df()\n                self.assertGreaterEqual(v, lower)\n                self.assertLessEqual(v, upper)\n                if type is not None:\n                    self.assertIsInstance(v, type)\n\n        for v in (0, 0.0, \"\", None, (), []):\n            self.assertIsNone(f(v))\n\n        for v in (0, 0.0, \"\", None, (), []):\n            test_single(f(v, 1.0), 1.0)\n\n        test_single(f(3)       , 3  , float)\n        test_single(f(3.0)     , 3.0, float)\n        test_single(f(\"3\")     , 3  , float)\n        test_single(f(\"3.0-\")  , 3  , float)\n        test_single(f(\"  3  -\"), 3  , float)\n\n        test_range(f((2, 4))       , 2, 4, float)\n        test_range(f([2.0, 4.0])   , 2, 4, float)\n        test_range(f(\"2-4\")        , 2, 4, float)\n        test_range(f(\"  2.0  - 4 \"), 2, 4, float)\n\n        pb = text.parse_bytes\n        test_single(f(\"3\", 0, pb)     , 3, int)\n        test_single(f(\"3.0-\", 0, pb)  , 3, int)\n        test_single(f(\"  3  -\", 0, pb), 3, int)\n        test_range(f(\"2k-4k\", 0, pb)        , 2048, 4096, int)\n        test_range(f(\"  2.0k  - 4k \", 0, pb), 2048, 4096, int)\n\n    def test_build_duration_func_ex(self, f=util.build_duration_func_ex):\n\n        def test(v, a, b=None):\n            df = f(v)\n            if \"=\" in v:\n                if b is None:\n                    for n, a in enumerate(a, 1):\n                        self.assertEqual(df(n), a)\n                else:\n                    for n, (a, b) in enumerate(zip(a, b), 1):\n                        v = df(n)\n                        self.assertGreaterEqual(v, a)\n                        self.assertLessEqual(v, b)\n            else:\n                if b is None:\n                    for n in range(10):\n                        self.assertEqual(df(n), a)\n                else:\n                    for n in range(10):\n                        v = df(n)\n                        self.assertGreaterEqual(v, a)\n                        self.assertLessEqual(v, b)\n\n        for v in (0, 0.0, \"\", None, (), []):\n            self.assertIsNone(f(v))\n\n        test(\"3\", 3.0)\n        test(\"3-5\", 3.0, 5.0)\n\n        test(\"lin=3\"     , ( 3, 6, 9, 12, 15))   # noqa E201\n        test(\"lin:8=3\"  , (11, 14, 17, 20, 23))\n        test(\"lin:8:20=5\", (13, 18, 20, 20, 20))\n        test(\"lin=2-5\"     ,\n             (2,  4,  6,  8, 10),  # noqa E241\n             (5, 10, 15, 20, 25))\n        test(\"lin:8=2-5\"   ,\n             (10, 12, 14, 16, 18),\n             (13, 18, 23, 28, 33))\n        test(\"lin:8:20=2-5\",\n             (10, 12, 14, 16, 18),\n             (13, 18, 20, 20, 20))\n\n        test(\"exp=3\"       , (3*1, 3*2, 3*4, 3*8, 3*16))\n        test(\"exp:1.5=3\"   , (3*1, 3*1.5, 3*1.5**2, 3*1.5**3))\n        test(\"exp::20:40=3\", (23, 26, 32, 40, 40))\n        test(\"exp=2-4\"    ,\n             (2*1, 2*2, 2*4, 2*8, 2*16),\n             (4*1, 4*2, 4*4, 4*8, 4*16))\n        test(\"exp:3=2-4\"  ,\n             (2*1, 2*3, 2*9, 2*27, 2*81),\n             (4*1, 4*3, 4*9, 4*27, 4*81))\n        test(\"exp:3::40=2-4\",\n             (2*1, 2*3, 2*9, 40, 40),\n             (4*1, 4*3,  40, 40, 40))  # noqa E241\n\n    def test_extractor_filter(self):\n        # empty\n        func = util.build_extractor_filter(\"\")\n        self.assertEqual(func(TestExtractor)      , True)\n        self.assertEqual(func(TestExtractorParent), True)\n        self.assertEqual(func(TestExtractorAlt)   , True)\n\n        # category\n        func = util.build_extractor_filter(\"test_category\")\n        self.assertEqual(func(TestExtractor)      , False)\n        self.assertEqual(func(TestExtractorParent), False)\n        self.assertEqual(func(TestExtractorAlt)   , True)\n\n        # subcategory\n        func = util.build_extractor_filter(\"*:test_subcategory\")\n        self.assertEqual(func(TestExtractor)      , False)\n        self.assertEqual(func(TestExtractorParent), True)\n        self.assertEqual(func(TestExtractorAlt)   , False)\n\n        # basecategory\n        func = util.build_extractor_filter(\"test_basecategory\")\n        self.assertEqual(func(TestExtractor)      , False)\n        self.assertEqual(func(TestExtractorParent), False)\n        self.assertEqual(func(TestExtractorAlt)   , False)\n\n        # category-subcategory pair\n        func = util.build_extractor_filter(\"test_category:test_subcategory\")\n        self.assertEqual(func(TestExtractor)      , False)\n        self.assertEqual(func(TestExtractorParent), True)\n        self.assertEqual(func(TestExtractorAlt)   , True)\n\n        # combination\n        func = util.build_extractor_filter(\n            [\"test_category\", \"*:test_subcategory\"])\n        self.assertEqual(func(TestExtractor)      , False)\n        self.assertEqual(func(TestExtractorParent), False)\n        self.assertEqual(func(TestExtractorAlt)   , False)\n\n        # whitelist\n        func = util.build_extractor_filter(\n            \"test_category:test_subcategory\", negate=False)\n        self.assertEqual(func(TestExtractor)      , True)\n        self.assertEqual(func(TestExtractorParent), False)\n        self.assertEqual(func(TestExtractorAlt)   , False)\n\n        func = util.build_extractor_filter(\n            [\"test_category:test_subcategory\", \"*:test_subcategory_parent\"],\n            negate=False)\n        self.assertEqual(func(TestExtractor)      , True)\n        self.assertEqual(func(TestExtractorParent), True)\n        self.assertEqual(func(TestExtractorAlt)   , False)\n\n    def test_generate_token(self):\n        tokens = set()\n        for _ in range(100):\n            token = util.generate_token()\n            tokens.add(token)\n            self.assertEqual(len(token), 16 * 2)\n            self.assertRegex(token, r\"^[0-9a-f]+$\")\n        self.assertGreaterEqual(len(tokens), 99)\n\n        token = util.generate_token(80)\n        self.assertEqual(len(token), 80 * 2)\n        self.assertRegex(token, r\"^[0-9a-f]+$\")\n\n    def test_format_value(self):\n        self.assertEqual(util.format_value(0)         , \"0\")\n        self.assertEqual(util.format_value(1)         , \"1\")\n        self.assertEqual(util.format_value(12)        , \"12\")\n        self.assertEqual(util.format_value(123)       , \"123\")\n        self.assertEqual(util.format_value(1234)      , \"1.23k\")\n        self.assertEqual(util.format_value(12345)     , \"12.34k\")\n        self.assertEqual(util.format_value(123456)    , \"123.45k\")\n        self.assertEqual(util.format_value(1234567)   , \"1.23M\")\n        self.assertEqual(util.format_value(12345678)  , \"12.34M\")\n        self.assertEqual(util.format_value(123456789) , \"123.45M\")\n        self.assertEqual(util.format_value(1234567890), \"1.23G\")\n\n    def test_combine_dict(self):\n        self.assertEqual(\n            util.combine_dict({}, {}),\n            {})\n        self.assertEqual(\n            util.combine_dict({1: 1, 2: 2}, {2: 4, 4: 8}),\n            {1: 1, 2: 4, 4: 8})\n        self.assertEqual(\n            util.combine_dict(\n                {1: {11: 22, 12: 24}, 2: {13: 26, 14: 28}},\n                {1: {11: 33, 13: 39}, 2: \"str\"}),\n            {1: {11: 33, 12: 24, 13: 39}, 2: \"str\"})\n        self.assertEqual(\n            util.combine_dict(\n                {1: {2: {3: {4: {\"1\": \"a\", \"2\": \"b\"}}}}},\n                {1: {2: {3: {4: {\"1\": \"A\", \"3\": \"C\"}}}}}),\n            {1: {2: {3: {4: {\"1\": \"A\", \"2\": \"b\", \"3\": \"C\"}}}}})\n\n    def test_transform_dict(self):\n        d = {}\n        util.transform_dict(d, str)\n        self.assertEqual(d, {})\n\n        d = {1: 123, 2: \"123\", 3: True, 4: None}\n        util.transform_dict(d, str)\n        self.assertEqual(\n            d, {1: \"123\", 2: \"123\", 3: \"True\", 4: \"None\"})\n\n        d = {1: 123, 2: \"123\", 3: \"foo\", 4: {11: 321, 12: \"321\", 13: \"bar\"}}\n        util.transform_dict(d, text.parse_int)\n        self.assertEqual(\n            d, {1: 123, 2: 123, 3: 0, 4: {11: 321, 12: 321, 13: 0}})\n\n    def test_filter_dict(self):\n        d = {}\n        r = util.filter_dict(d)\n        self.assertEqual(r, d)\n        self.assertIsNot(r, d)\n\n        d = {\"foo\": 123, \"bar\": [], \"baz\": None}\n        r = util.filter_dict(d)\n        self.assertEqual(r, d)\n        self.assertIsNot(r, d)\n\n        d = {\"foo\": 123, \"_bar\": [], \"__baz__\": None}\n        r = util.filter_dict(d)\n        self.assertEqual(r, {\"foo\": 123})\n\n    def test_enumerate_reversed(self):\n\n        seq = [11, 22, 33]\n        result = [(3, 33), (2, 22), (1, 11)]\n\n        def gen():\n            for i in seq:\n                yield i\n\n        def gen_2():\n            yield from seq\n\n        def assertEqual(it1, it2):\n            ae = self.assertEqual\n            for i1, i2 in itertools.zip_longest(it1, it2):\n                ae(i1, i2)\n\n        assertEqual(\n            util.enumerate_reversed(seq), [(2, 33), (1, 22), (0, 11)])\n        assertEqual(\n            util.enumerate_reversed(seq, 1), result)\n        assertEqual(\n            util.enumerate_reversed(seq, 2), [(4, 33), (3, 22), (2, 11)])\n\n        assertEqual(\n            util.enumerate_reversed(gen(), 0, len(seq)),\n            [(2, 33), (1, 22), (0, 11)])\n        assertEqual(\n            util.enumerate_reversed(gen(), 1, len(seq)), result)\n        assertEqual(\n            util.enumerate_reversed(gen_2(), 1, len(seq)), result)\n        assertEqual(\n            util.enumerate_reversed(gen_2(), 2, len(seq)),\n            [(4, 33), (3, 22), (2, 11)])\n\n    def test_number_to_string(self, f=util.number_to_string):\n        self.assertEqual(f(1)     , \"1\")\n        self.assertEqual(f(1.0)   , \"1.0\")\n        self.assertEqual(f(\"1.0\") , \"1.0\")\n        self.assertEqual(f([1])   , [1])\n        self.assertEqual(f({1: 2}), {1: 2})\n        self.assertEqual(f(True)  , True)\n        self.assertEqual(f(None)  , None)\n\n    def test_to_string(self, f=util.to_string):\n        self.assertEqual(f(1)    , \"1\")\n        self.assertEqual(f(1.0)  , \"1.0\")\n        self.assertEqual(f(\"1.0\"), \"1.0\")\n\n        self.assertEqual(f(\"\")   , \"\")\n        self.assertEqual(f(None) , \"\")\n        self.assertEqual(f(0)    , \"\")\n\n        self.assertEqual(f([\"a\"]), \"a\")\n        self.assertEqual(f([1])  , \"1\")\n        self.assertEqual(f([\"a\", \"b\", \"c\"]), \"a, b, c\")\n        self.assertEqual(f([1, 2, 3]), \"1, 2, 3\")\n\n    def test_universal_none(self):\n        obj = util.NONE\n\n        self.assertFalse(obj)\n        self.assertEqual(obj, obj)\n        self.assertEqual(obj, None)\n        self.assertNotEqual(obj, False)\n        self.assertNotEqual(obj, 0)\n        self.assertNotEqual(obj, \"\")\n\n        self.assertEqual(len(obj), 0)\n        self.assertEqual(int(obj), 0)\n        self.assertEqual(hash(obj), 0)\n\n        self.assertEqual(str(obj), str(None))\n        self.assertEqual(repr(obj), repr(None))\n        self.assertEqual(format(obj), str(None))\n        self.assertEqual(format(obj, \"%F\"), str(None))\n\n        self.assertIs(obj.attr, obj)\n        self.assertIs(obj[\"key\"], obj)\n        self.assertIs(obj(), obj)\n        self.assertIs(obj(1, \"a\"), obj)\n        self.assertIs(obj(foo=\"bar\"), obj)\n        self.assertIs(iter(obj), obj)\n        self.assertEqual(util.json_dumps(obj), \"null\")\n\n        self.assertLess(obj, \"foo\")\n        self.assertLessEqual(obj, None)\n        self.assertTrue(obj == obj)\n        self.assertFalse(obj == 0)\n        self.assertFalse(obj != obj)\n        self.assertGreater(123, obj)\n        self.assertGreaterEqual(1.23, obj)\n\n        self.assertEqual(obj + 123, obj)\n        self.assertEqual(obj - 123, obj)\n        self.assertEqual(obj * 123, obj)\n        #  self.assertEqual(obj @ 123, obj)\n        self.assertEqual(obj / 123, obj)\n        self.assertEqual(obj // 123, obj)\n        self.assertEqual(obj % 123, obj)\n\n        self.assertEqual(123 + obj, obj)\n        self.assertEqual(123 - obj, obj)\n        self.assertEqual(123 * obj, obj)\n        #  self.assertEqual(123 @ obj, obj)\n        self.assertEqual(123 / obj, obj)\n        self.assertEqual(123 // obj, obj)\n        self.assertEqual(123 % obj, obj)\n\n        self.assertEqual(obj << 123, obj)\n        self.assertEqual(obj >> 123, obj)\n        self.assertEqual(obj & 123, obj)\n        self.assertEqual(obj ^ 123, obj)\n        self.assertEqual(obj | 123, obj)\n\n        self.assertEqual(123 << obj, obj)\n        self.assertEqual(123 >> obj, obj)\n        self.assertEqual(123 & obj, obj)\n        self.assertEqual(123 ^ obj, obj)\n        self.assertEqual(123 | obj, obj)\n\n        self.assertEqual(-obj, obj)\n        self.assertEqual(+obj, obj)\n        self.assertEqual(~obj, obj)\n        self.assertEqual(abs(obj), obj)\n\n        mapping = {}\n        mapping[obj] = 123\n        self.assertIn(obj, mapping)\n        self.assertEqual(mapping[obj], 123)\n\n        array = [1, 2, 3]\n        self.assertEqual(array[obj], 1)\n\n        if platform.python_implementation().lower() == \"cpython\":\n            self.assertTrue(time.localtime(obj))\n\n        i = 0\n        for _ in obj:\n            i += 1\n        self.assertEqual(i, 0)\n\n    def test_HTTPBasicAuth(self, f=util.HTTPBasicAuth):\n        class Request:\n            headers = {}\n        request = Request()\n\n        auth = f(\"\", \"\")\n        auth(request)\n        self.assertEqual(request.headers[\"Authorization\"],\n                         b\"Basic Og==\")\n\n        f(\"foo\", \"bar\")(request)\n        self.assertEqual(request.headers[\"Authorization\"],\n                         b\"Basic Zm9vOmJhcg==\")\n\n        f(\"ewsxcvbhnjtr\",\n          \"RVXQ4i9Ju5ypi86VGJ8MqhDYpDKluS0sxiSRBAG7ymB3Imok\")(request)\n        self.assertEqual(request.headers[\"Authorization\"],\n                         b\"Basic ZXdzeGN2YmhuanRyOlJWWFE0aTlKdTV5cGk4NlZHSjhNc\"\n                         b\"WhEWXBES2x1UzBzeGlTUkJBRzd5bUIzSW1vaw==\")\n\n    def test_module_proxy(self):\n        proxy = util.ModuleProxy()\n\n        self.assertIs(proxy.os, os)\n        self.assertIs(proxy.os.path, os.path)\n        self.assertIs(proxy[\"os\"], os)\n        self.assertIs(proxy[\"os.path\"], os.path)\n        self.assertIs(proxy[\"os\"].path, os.path)\n\n        self.assertIs(proxy.abcdefghi, util.NONE)\n        self.assertIs(proxy[\"abcdefghi\"], util.NONE)\n        self.assertIs(proxy[\"abc.def.ghi\"], util.NONE)\n        self.assertIs(proxy[\"os.path2\"], util.NONE)\n\n    def test_lazy_prompt(self):\n        prompt = util.LazyPrompt()\n\n        with patch(\"getpass.getpass\") as p:\n            p.return_value = \"***\"\n            result = str(prompt)\n\n        self.assertEqual(result, \"***\")\n        p.assert_called_once_with()\n\n    def test_null_context(self):\n        with util.NullContext():\n            pass\n\n        with util.NullContext() as ctx:\n            self.assertIs(ctx, None)\n\n        try:\n            with util.NullContext() as ctx:\n                exc_orig = ValueError()\n                raise exc_orig\n        except ValueError as exc:\n            self.assertIs(exc, exc_orig)\n\n    def test_null_response(self):\n        response = util.NullResponse(\"https://example.org\")\n\n        self.assertEqual(response.url, \"https://example.org\")\n        self.assertEqual(response.status_code, 900)\n        self.assertEqual(response.reason, \"\")\n        self.assertEqual(response.text, \"\")\n        self.assertEqual(response.content, b\"\")\n        self.assertEqual(response.json(), {})\n\n        self.assertFalse(response.ok)\n        self.assertFalse(response.is_redirect)\n        self.assertFalse(response.is_permanent_redirect)\n        self.assertFalse(response.history)\n\n        self.assertEqual(response.encoding, \"utf-8\")\n        self.assertEqual(response.apparent_encoding, \"utf-8\")\n        self.assertEqual(response.cookies.get(\"foo\"), None)\n        self.assertEqual(response.headers.get(\"foo\"), None)\n        self.assertEqual(response.links.get(\"next\"), None)\n        self.assertEqual(response.close(), None)\n\n        with response as ctx:\n            self.assertIs(response, ctx)\n\n\nclass TestExtractor():\n    category = \"test_category\"\n    subcategory = \"test_subcategory\"\n    basecategory = \"test_basecategory\"\n\n\nclass TestExtractorParent(TestExtractor):\n    category = \"test_category\"\n    subcategory = \"test_subcategory_parent\"\n\n\nclass TestExtractorAlt(TestExtractor):\n    category = \"test_category_alt\"\n    subcategory = \"test_subcategory\"\n\n\nif __name__ == \"__main__\":\n    unittest.main()\n"
  },
  {
    "path": "test/test_ytdl.py",
    "content": "#!/usr/bin/env python3\n# -*- coding: utf-8 -*-\n\n# Copyright 2022-2025 Mike Fährmann\n#\n# This program is free software; you can redistribute it and/or modify\n# it under the terms of the GNU General Public License version 2 as\n# published by the Free Software Foundation.\n\nimport os\nimport sys\nimport unittest\n\nsys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))\nfrom gallery_dl import ytdl, util, config, exception  # noqa E402\nfrom gallery_dl.extractor.ytdl import YoutubeDLExtractor  # noqa E402\n\n\nclass Test_CommandlineArguments(unittest.TestCase):\n    module_name = \"youtube_dl\"\n\n    @classmethod\n    def setUpClass(cls):\n        try:\n            cls.module = __import__(cls.module_name)\n        except (ImportError, SyntaxError):\n            raise unittest.SkipTest(\n                f\"cannot import module '{cls.module_name}'\")\n        cls.default = ytdl.parse_command_line(cls.module, [])\n        cls.ytdlp = hasattr(cls.module, \"cookies\")\n\n    def test_ignore_errors(self):\n        self._(\"--ignore-errors\" , \"ignoreerrors\", True)\n        self._(\"--abort-on-error\", \"ignoreerrors\", False)\n\n    def test_default_search(self):\n        self._([\"--default-search\", \"foo\"] , \"default_search\", \"foo\")\n\n    def test_mark_watched(self):\n        self._(\"--mark-watched\"   , \"mark_watched\", True)\n        self._(\"--no-mark-watched\", \"mark_watched\", False)\n\n    def test_proxy(self):\n        self._([\"--proxy\", \"socks5://127.0.0.1:1080/\"],\n               \"proxy\", \"socks5://127.0.0.1:1080/\")\n        self._([\"--geo-verification-proxy\", \"127.0.0.1\"],\n               \"geo_verification_proxy\", \"127.0.0.1\")\n\n    def test_network_options(self):\n        self._([\"--socket-timeout\", \"3.5\"],\n               \"socket_timeout\", 3.5)\n        self._([\"--source-address\", \"127.0.0.1\"],\n               \"source_address\", \"127.0.0.1\")\n        self._(\"-4\"          , \"source_address\", \"0.0.0.0\")\n        self._(\"--force-ipv4\", \"source_address\", \"0.0.0.0\")\n        self._(\"-6\"          , \"source_address\", \"::\")\n        self._(\"--force-ipv6\", \"source_address\", \"::\")\n\n    def test_thumbnail_options(self):\n        self._(\"--write-thumbnail\", \"writethumbnail\", True)\n        self._(\"--write-all-thumbnails\", \"write_all_thumbnails\", True)\n\n    def test_authentication_options(self):\n        self._([\"-u\"        , \"foo\"], \"username\", \"foo\")\n        self._([\"--username\", \"foo\"], \"username\", \"foo\")\n\n        self._([\"-p\"        , \"bar\"], \"password\", \"bar\")\n        self._([\"--password\", \"bar\"], \"password\", \"bar\")\n\n        self._([\"--ap-mso\"     , \"mso\"], \"ap_mso\", \"mso\")\n        self._([\"--ap-username\", \"foo\"], \"ap_username\", \"foo\")\n        self._([\"--ap-password\", \"bar\"], \"ap_password\", \"bar\")\n\n        self._([\"-2\"         , \"pass\"], \"twofactor\", \"pass\")\n        self._([\"--twofactor\", \"pass\"], \"twofactor\", \"pass\")\n\n        self._([\"--video-password\", \"pass\"], \"videopassword\", \"pass\")\n\n        self._(\"-n\"     , \"usenetrc\", True)\n        self._(\"--netrc\", \"usenetrc\", True)\n\n    def test_subtitle_options(self):\n        self._(\"--write-sub\"     , \"writesubtitles\"   , True)\n        self._(\"--write-auto-sub\", \"writeautomaticsub\", True)\n\n        self._([\"--sub-format\", \"best\"], \"subtitlesformat\", \"best\")\n        self._([\"--sub-langs\", \"en,ru\"], \"subtitleslangs\", [\"en\", \"ru\"])\n\n    def test_retries(self):\n        inf = float(\"inf\")\n\n        self._([\"--retries\", \"5\"], \"retries\", 5)\n        self._([\"--retries\", \"inf\"], \"retries\", inf)\n        self._([\"--retries\", \"infinite\"], \"retries\", inf)\n        self._([\"--fragment-retries\", \"8\"], \"fragment_retries\", 8)\n        self._([\"--fragment-retries\", \"inf\"], \"fragment_retries\", inf)\n        self._([\"--fragment-retries\", \"infinite\"], \"fragment_retries\", inf)\n\n    def test_geo_bypass(self):\n        self._(\"--geo-bypass\", \"geo_bypass\", True)\n        self._(\"--no-geo-bypass\", \"geo_bypass\", False)\n        self._([\"--geo-bypass-country\", \"EN\"], \"geo_bypass_country\", \"EN\")\n        self._([\"--geo-bypass-ip-block\", \"198.51.100.14/24\"],\n               \"geo_bypass_ip_block\", \"198.51.100.14/24\")\n\n    def test_headers(self):\n        try:\n            headers = self.module.utils.networking.std_headers\n        except AttributeError:\n            headers = self.module.std_headers\n\n        self.assertNotEqual(headers[\"User-Agent\"], \"Foo/1.0\")\n        self._([\"--user-agent\", \"Foo/1.0\"])\n        self.assertEqual(headers[\"User-Agent\"], \"Foo/1.0\")\n\n        self.assertNotIn(\"Referer\", headers)\n        self._([\"--referer\", \"http://example.org/\"])\n        self.assertEqual(headers[\"Referer\"], \"http://example.org/\")\n\n        self.assertNotEqual(headers[\"Accept\"], \"*/*\")\n        self.assertNotIn(\"DNT\", headers)\n        self._([\n            \"--add-header\", \"accept:*/*\",\n            \"--add-header\", \"dnt:1\",\n        ])\n        self.assertEqual(headers[\"accept\"], \"*/*\")\n        self.assertEqual(headers[\"dnt\"], \"1\")\n\n    def test_extract_audio(self):\n        opts = self._([\"--extract-audio\"])\n        self.assertEqual(opts[\"postprocessors\"][0], {\n            \"key\": \"FFmpegExtractAudio\",\n            \"preferredcodec\": \"best\",\n            \"preferredquality\": \"5\",\n            \"nopostoverwrites\": False,\n        })\n\n        opts = self._([\n            \"--extract-audio\",\n            \"--audio-format\", \"opus\",\n            \"--audio-quality\", \"9\",\n            \"--no-post-overwrites\",\n        ])\n        self.assertEqual(opts[\"postprocessors\"][0], {\n            \"key\": \"FFmpegExtractAudio\",\n            \"preferredcodec\": \"opus\",\n            \"preferredquality\": \"9\",\n            \"nopostoverwrites\": True,\n        })\n\n    def test_recode_video(self):\n        opts = self._([\"--recode-video\", \" mkv \"])\n        self.assertEqual(opts[\"postprocessors\"][0], {\n            \"key\": \"FFmpegVideoConvertor\",\n            \"preferedformat\": \"mkv\",\n        })\n\n    def test_subs(self):\n        opts = self._([\"--convert-subs\", \"srt\"])\n        conv = {\"key\": \"FFmpegSubtitlesConvertor\", \"format\": \"srt\"}\n        if self.ytdlp:\n            conv[\"when\"] = \"before_dl\"\n        self.assertEqual(opts[\"postprocessors\"][0], conv)\n\n    def test_embed(self):\n        subs = {\"key\": \"FFmpegEmbedSubtitle\"}\n        thumb = {\"key\": \"EmbedThumbnail\", \"already_have_thumbnail\": False}\n        if self.ytdlp:\n            subs[\"already_have_subtitle\"] = False\n\n        opts = self._([\"--embed-subs\", \"--embed-thumbnail\"])\n        self.assertEqual(opts[\"postprocessors\"][:2], [subs, thumb])\n\n        thumb[\"already_have_thumbnail\"] = True\n        if self.ytdlp:\n            subs[\"already_have_subtitle\"] = True\n            thumb[\"already_have_thumbnail\"] = \"all\"\n\n        opts = self._([\n            \"--embed-thumbnail\",\n            \"--embed-subs\",\n            \"--write-sub\",\n            \"--write-all-thumbnails\",\n        ])\n        self.assertEqual(opts[\"postprocessors\"][:2], [subs, thumb])\n\n    def test_metadata(self):\n        opts = self._(\"--add-metadata\")\n        self.assertEqual(opts[\"postprocessors\"][0], {\"key\": \"FFmpegMetadata\"})\n\n    def test_metadata_from_title(self):\n        opts = self._([\"--metadata-from-title\", \"%(artist)s - %(title)s\"])\n        self.assertEqual(opts[\"postprocessors\"][0], {\n            \"key\": \"MetadataFromTitle\",\n            \"titleformat\": \"%(artist)s - %(title)s\",\n        })\n\n    def test_xattr(self):\n        opts = self._(\"--xattrs\")\n        self.assertEqual(opts[\"postprocessors\"][0], {\"key\": \"XAttrMetadata\"})\n\n    def test_noop(self):\n        cmdline = [\n            \"--update\",\n            \"--dump-user-agent\",\n            \"-F\",\n            \"--list-formats\",\n            \"--list-extractors\",\n            \"--list-thumbnails\",\n            \"--list-subs\",\n            \"--ap-list-mso\",\n            \"--extractor-descriptions\",\n            \"--ignore-config\",\n        ]\n\n        if not self.ytdlp:\n            cmdline.extend((\n                \"--dump-json\",\n                \"--dump-single-json\",\n                \"--config-location\", \"~\",\n            ))\n\n        result = self._(cmdline)\n        result[\"daterange\"] = self.default[\"daterange\"]\n        self.assertEqual(result, self.default)\n\n    def _(self, cmdline, option=util.SENTINEL, expected=None):\n        if isinstance(cmdline, str):\n            cmdline = [cmdline]\n        result = ytdl.parse_command_line(self.module, cmdline)\n        if option is not util.SENTINEL:\n            self.assertEqual(result[option], expected, option)\n        return result\n\n\nclass Test_CommandlineArguments_YtDlp(Test_CommandlineArguments):\n    module_name = \"yt_dlp\"\n\n    def test_retries_extractor(self):\n        inf = float(\"inf\")\n\n        self._([\"--extractor-retries\", \"5\"], \"extractor_retries\", 5)\n        self._([\"--extractor-retries\", \"inf\"], \"extractor_retries\", inf)\n        self._([\"--extractor-retries\", \"infinite\"], \"extractor_retries\", inf)\n\n    def test_remuxs_video(self):\n        opts = self._([\"--remux-video\", \" mkv \"])\n        self.assertEqual(opts[\"postprocessors\"][0], {\n            \"key\": \"FFmpegVideoRemuxer\",\n            \"preferedformat\": \"mkv\",\n        })\n\n    def test_metadata(self):\n        opts = self._([\"--embed-metadata\",\n                       \"--no-embed-chapters\",\n                       \"--embed-info-json\"])\n        self.assertEqual(opts[\"postprocessors\"][0], {\n            \"key\": \"FFmpegMetadata\",\n            \"add_chapters\": False,\n            \"add_metadata\": True,\n            \"add_infojson\": True,\n        })\n\n    def test_metadata_from_title(self):\n        opts = self._([\"--metadata-from-title\", \"%(artist)s - %(title)s\"])\n        self.assertEqual(opts[\"postprocessors\"][0], {\n            \"key\"    : \"MetadataParser\",\n            \"when\"   : \"pre_process\",\n            \"actions\": [self.module.MetadataFromFieldPP.to_action(\n                \"title:%(artist)s - %(title)s\")],\n        })\n\n    def test_geo_bypass(self):\n        try:\n            ytdl.parse_command_line(self.module, [\"--xff\", \"default\"])\n        except Exception:\n            # before --xff (c16644642)\n            return Test_CommandlineArguments.test_geo_bypass(self)\n\n        self._([\"--xff\", \"default\"],\n               \"geo_bypass\", \"default\")\n        self._([\"--xff\", \"never\"],\n               \"geo_bypass\", \"never\")\n        self._([\"--xff\", \"EN\"],\n               \"geo_bypass\", \"EN\")\n        self._([\"--xff\", \"198.51.100.14/24\"],\n               \"geo_bypass\", \"198.51.100.14/24\")\n\n        self._(\"--geo-bypass\",\n               \"geo_bypass\", \"default\")\n        self._(\"--no-geo-bypass\",\n               \"geo_bypass\", \"never\")\n        self._([\"--geo-bypass-country\", \"EN\"],\n               \"geo_bypass\", \"EN\")\n        self._([\"--geo-bypass-ip-block\", \"198.51.100.14/24\"],\n               \"geo_bypass\", \"198.51.100.14/24\")\n\n    def test_cookiesfrombrowser(self):\n        self._([\"--cookies-from-browser\", \"firefox\"],\n               \"cookiesfrombrowser\", (\"firefox\", None, None, None))\n        self._([\"--cookies-from-browser\", \"firefox:profile\"],\n               \"cookiesfrombrowser\", (\"firefox\", \"profile\", None, None))\n        self._([\"--cookies-from-browser\", \"firefox+keyring\"],\n               \"cookiesfrombrowser\", (\"firefox\", None, \"KEYRING\", None))\n        self._([\"--cookies-from-browser\", \"firefox::container\"],\n               \"cookiesfrombrowser\", (\"firefox\", None, None, \"container\"))\n        self._([\"--cookies-from-browser\",\n                \"firefox+keyring:profile::container\"],\n               \"cookiesfrombrowser\",\n               (\"firefox\", \"profile\", \"KEYRING\", \"container\"))\n\n\nclass MockYtdlModule:\n    class utils:\n        class YoutubeDLError(Exception):\n            pass\n\n\nclass MockYtdlInstance:\n    def __init__(self, ignoreerrors):\n        self.params = {\"ignoreerrors\": ignoreerrors}\n\n\nclass Test_YoutubeDLExtractor(unittest.TestCase):\n    def setUp(self):\n        class ExtractorMock(YoutubeDLExtractor):\n            def __init__(self):\n                self.exc = exception\n        self.extr = ExtractorMock.__new__(ExtractorMock)\n        self.extr.exc = exception\n\n    def test_process_entries_restart_propagation(self):\n        ytdl_module = MockYtdlModule()\n        ytdl_instance = MockYtdlInstance(ignoreerrors=False)\n\n        def mock_extract_info(url, dl, ie_key):\n            raise exception.RestartExtraction()\n        ytdl_instance.extract_info = mock_extract_info\n\n        entries = [{\"_type\": \"url\", \"url\": \"test_url\"}]\n        with self.assertRaises(exception.RestartExtraction):\n            list(self.extr._process_entries(\n                ytdl_module, ytdl_instance, entries))\n\n    def test_process_entries_ignoreerrors_true(self):\n        ytdl_module = MockYtdlModule()\n        ytdl_instance = MockYtdlInstance(ignoreerrors=True)\n\n        def mock_extract_info(url, dl, ie_key):\n            raise ytdl_module.utils.YoutubeDLError(\"Some Error\")\n        ytdl_instance.extract_info = mock_extract_info\n\n        entries = [\n            {\"_type\": \"url\", \"url\": \"test_url1\"},\n            {\"_type\": \"url\", \"url\": \"test_url2\"}\n        ]\n        results = list(self.extr._process_entries(\n            ytdl_module, ytdl_instance, entries))\n        self.assertEqual(results, [])\n\n    def test_process_entries_ignoreerrors_false(self):\n        ytdl_module = MockYtdlModule()\n        ytdl_instance = MockYtdlInstance(ignoreerrors=False)\n\n        def mock_extract_info(url, dl, ie_key):\n            raise ytdl_module.utils.YoutubeDLError(\"Some Error\")\n        ytdl_instance.extract_info = mock_extract_info\n\n        entries = [{\"_type\": \"url\", \"url\": \"test_url\"}]\n        with self.assertRaises(exception.AbortExtraction):\n            list(self.extr._process_entries(\n                ytdl_module, ytdl_instance, entries))\n\n\nif __name__ == \"__main__\":\n    unittest.main(warnings=\"ignore\")\n"
  }
]