devart-dl 4.3.0__tar.gz → 4.3.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. {devart_dl-4.3.0/devart_dl.egg-info → devart_dl-4.3.2}/PKG-INFO +16 -5
  2. devart_dl-4.3.2/README.en.md +87 -0
  3. {devart_dl-4.3.0 → devart_dl-4.3.2}/README.md +15 -4
  4. {devart_dl-4.3.0 → devart_dl-4.3.2}/da_downloader/cli.py +1 -1
  5. {devart_dl-4.3.0 → devart_dl-4.3.2}/da_downloader/official_api.py +56 -16
  6. {devart_dl-4.3.0 → devart_dl-4.3.2}/da_downloader/provider.py +9 -1
  7. {devart_dl-4.3.0 → devart_dl-4.3.2/devart_dl.egg-info}/PKG-INFO +16 -5
  8. {devart_dl-4.3.0 → devart_dl-4.3.2}/pyproject.toml +1 -1
  9. {devart_dl-4.3.0 → devart_dl-4.3.2}/tests/test_official_provider.py +122 -0
  10. {devart_dl-4.3.0 → devart_dl-4.3.2}/tests/test_provider.py +14 -0
  11. devart_dl-4.3.0/README.en.md +0 -289
  12. {devart_dl-4.3.0 → devart_dl-4.3.2}/.env.example +0 -0
  13. {devart_dl-4.3.0 → devart_dl-4.3.2}/LICENSE +0 -0
  14. {devart_dl-4.3.0 → devart_dl-4.3.2}/MANIFEST.in +0 -0
  15. {devart_dl-4.3.0 → devart_dl-4.3.2}/bin/devart-dl +0 -0
  16. {devart_dl-4.3.0 → devart_dl-4.3.2}/bin/install.sh +0 -0
  17. {devart_dl-4.3.0 → devart_dl-4.3.2}/da_downloader/__init__.py +0 -0
  18. {devart_dl-4.3.0 → devart_dl-4.3.2}/da_downloader/api.py +0 -0
  19. {devart_dl-4.3.0 → devart_dl-4.3.2}/da_downloader/archive.py +0 -0
  20. {devart_dl-4.3.0 → devart_dl-4.3.2}/da_downloader/auth.py +0 -0
  21. {devart_dl-4.3.0 → devart_dl-4.3.2}/da_downloader/da_cookie_login.mjs +0 -0
  22. {devart_dl-4.3.0 → devart_dl-4.3.2}/da_downloader/download.py +0 -0
  23. {devart_dl-4.3.0 → devart_dl-4.3.2}/da_downloader/errors.py +0 -0
  24. {devart_dl-4.3.0 → devart_dl-4.3.2}/da_downloader/http.py +0 -0
  25. {devart_dl-4.3.0 → devart_dl-4.3.2}/da_downloader/manager.py +0 -0
  26. {devart_dl-4.3.0 → devart_dl-4.3.2}/da_downloader/models.py +0 -0
  27. {devart_dl-4.3.0 → devart_dl-4.3.2}/da_downloader/oauth.py +0 -0
  28. {devart_dl-4.3.0 → devart_dl-4.3.2}/da_downloader/path.py +0 -0
  29. {devart_dl-4.3.0 → devart_dl-4.3.2}/da_downloader/targets.py +0 -0
  30. {devart_dl-4.3.0 → devart_dl-4.3.2}/devart_dl.egg-info/SOURCES.txt +0 -0
  31. {devart_dl-4.3.0 → devart_dl-4.3.2}/devart_dl.egg-info/dependency_links.txt +0 -0
  32. {devart_dl-4.3.0 → devart_dl-4.3.2}/devart_dl.egg-info/entry_points.txt +0 -0
  33. {devart_dl-4.3.0 → devart_dl-4.3.2}/devart_dl.egg-info/requires.txt +0 -0
  34. {devart_dl-4.3.0 → devart_dl-4.3.2}/devart_dl.egg-info/top_level.txt +0 -0
  35. {devart_dl-4.3.0 → devart_dl-4.3.2}/requirements.txt +0 -0
  36. {devart_dl-4.3.0 → devart_dl-4.3.2}/setup.cfg +0 -0
  37. {devart_dl-4.3.0 → devart_dl-4.3.2}/setup.py +0 -0
  38. {devart_dl-4.3.0 → devart_dl-4.3.2}/tests/test_api.py +0 -0
  39. {devart_dl-4.3.0 → devart_dl-4.3.2}/tests/test_archive.py +0 -0
  40. {devart_dl-4.3.0 → devart_dl-4.3.2}/tests/test_cli.py +0 -0
  41. {devart_dl-4.3.0 → devart_dl-4.3.2}/tests/test_conformance.py +0 -0
  42. {devart_dl-4.3.0 → devart_dl-4.3.2}/tests/test_download.py +0 -0
  43. {devart_dl-4.3.0 → devart_dl-4.3.2}/tests/test_http_downloader.py +0 -0
  44. {devart_dl-4.3.0 → devart_dl-4.3.2}/tests/test_manager.py +0 -0
  45. {devart_dl-4.3.0 → devart_dl-4.3.2}/tests/test_path.py +0 -0
  46. {devart_dl-4.3.0 → devart_dl-4.3.2}/tests/test_targets.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devart-dl
3
- Version: 4.3.0
3
+ Version: 4.3.2
4
4
  Summary: 专业的 DeviantArt 作品批量下载工具 - Professional DeviantArt batch downloader with anti-ban features
5
5
  Author: DeviantArt Downloader Team
6
6
  License-Expression: MIT
@@ -34,9 +34,11 @@ Dynamic: license-file
34
34
 
35
35
  # DeviantArt Downloader
36
36
 
37
- 一个可靠、聚焦的 DeviantArt 下载 / 归档命令行工具。
37
+ **语言 / Language:** 中文 · [English](README.en.md)
38
38
 
39
- **语言 / Language:** 中文 · [English](README.en.md) · [完整文档](https://redtidev1918.github.io/deviantart-downloader/) · [更新日志](CHANGELOG.md)
39
+ > **一个可靠、聚焦的 DeviantArt 下载 / 归档命令行工具。**
40
+
41
+ [完整文档](https://redtidev1918.github.io/deviantart-downloader/) · [更新日志](CHANGELOG.md)
40
42
 
41
43
  [![CI](https://github.com/redtidev1918/deviantart-downloader/actions/workflows/ci.yml/badge.svg)](https://github.com/redtidev1918/deviantart-downloader/actions/workflows/ci.yml)
42
44
  [![PyPI](https://img.shields.io/pypi/v/devart-dl.svg)](https://pypi.org/project/devart-dl/)
@@ -93,9 +95,18 @@ devart-dl login browser # 打开你的 Chrome 登录,自动保存 Cookie,
93
95
  - **代理与降级**:支持 `--proxy`;未登录 OAuth 时自动回退 Cookie。
94
96
  - **Resolve 模式**:`devart-dl resolve URL` 输出 `ResolvedDeviation`/`MediaAsset` 的 JSON 契约,不写文件。
95
97
 
96
- 完整的安装、登录认证、下载模式、配置参数、命令速查、Python 调用与常见问题,请看**文档站点**:
98
+ ## 文档
99
+
100
+ | 你想做什么 | 文档 |
101
+ | --- | --- |
102
+ | 下载安装包 | [下载页](https://redtidev1918.github.io/deviantart-downloader/download.md) |
103
+ | 登录与认证 | [登录与认证](docs/LOGIN.md) |
104
+ | 命令、配置与 Python 调用 | [文档站](https://redtidev1918.github.io/deviantart-downloader/) |
105
+
106
+ ## 致谢
97
107
 
98
- https://redtidev1918.github.io/deviantart-downloader/
108
+ - [requests](https://github.com/psf/requests):HTTP 客户端。
109
+ - 登录与归档目录的实现与 [gallery-dl](https://github.com/mikf/gallery-dl) 交叉核对过。
99
110
 
100
111
  ## 许可证与免责声明
101
112
 
@@ -0,0 +1,87 @@
1
+ # DeviantArt Downloader
2
+
3
+ **Language / 语言:** [中文](README.md) · English
4
+
5
+ > **A reliable, focused DeviantArt downloader and archival CLI.**
6
+
7
+ 📖 [Full documentation](https://redtidev1918.github.io/deviantart-downloader/) · [Changelog](CHANGELOG.md)
8
+
9
+ [![CI](https://github.com/redtidev1918/deviantart-downloader/actions/workflows/ci.yml/badge.svg)](https://github.com/redtidev1918/deviantart-downloader/actions/workflows/ci.yml)
10
+ [![PyPI](https://img.shields.io/pypi/v/devart-dl.svg)](https://pypi.org/project/devart-dl/)
11
+ [![Python](https://img.shields.io/pypi/pyversions/devart-dl.svg)](https://pypi.org/project/devart-dl/)
12
+ [![Downloads](https://img.shields.io/pypi/dm/devart-dl.svg)](https://pypi.org/project/devart-dl/)
13
+ [![License](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE)
14
+
15
+ See [CHANGELOG.md](CHANGELOG.md) for release notes.
16
+
17
+ ---
18
+
19
+ ## Core Features
20
+
21
+ - **Official API first (OAuth)** — log in once to download originals; no cookie export, no anti-ban delays.
22
+ - **URL-first** — `devart-dl URL` downloads artworks / galleries / favourites / tags / fav.me links directly.
23
+ - **Reliable downloads** — HTTP Range resume, automatic retry, 429 backoff, HTML/empty-file validation, atomic finalize.
24
+ - **Download archive** — `--archive` remembers what you've downloaded in SQLite, skipping it across sessions.
25
+ - **Path templates** — `--directory` / `--filename` support `{id}` `{title}` `{author}` `{published}` `{ext}` and more, with safe sanitization.
26
+ - **Metadata sidecars** — `--write-info-json` writes a `.json` next to each file.
27
+ - **Proxy & fallback** — `--proxy` or proxy environment variables; falls back to cookies when not logged in via OAuth.
28
+
29
+ ---
30
+
31
+ ## Quick Start
32
+
33
+ ### Installation
34
+
35
+ Requires Python 3.10 or newer.
36
+
37
+ ```bash
38
+ pip install devart-dl
39
+ ```
40
+
41
+ ### Basic Usage (URL-first)
42
+
43
+ ```bash
44
+ # Single artwork (also fav.me links and bare artwork ids)
45
+ devart-dl https://www.deviantart.com/username/art/title-123456
46
+
47
+ # Gallery / all works by an artist
48
+ devart-dl https://www.deviantart.com/username/gallery
49
+
50
+ # Favourites
51
+ devart-dl https://www.deviantart.com/username/favourites
52
+
53
+ # Tag
54
+ devart-dl https://www.deviantart.com/tag/landscape
55
+
56
+ # Resolve to JSON descriptors (download nothing)
57
+ devart-dl resolve https://www.deviantart.com/username/art/title-123456
58
+ ```
59
+
60
+ ### Login (OAuth recommended)
61
+
62
+ ```bash
63
+ devart-dl login oauth --client-id YOUR_PUBLIC_CLIENT_ID
64
+ devart-dl whoami # verify login
65
+ devart-dl logout # revoke the token
66
+ ```
67
+
68
+ Register a **Public** OAuth app at [deviantart.com/developers](https://www.deviantart.com/developers/) and whitelist `http://127.0.0.1:8765/callback`. Login happens in the browser — no password or `client_secret` is ever given to the CLI.
69
+
70
+ ---
71
+
72
+ ## Documentation
73
+
74
+ The full install, sign-in, download modes, configuration, command reference, Python API and FAQ live on the [docs site](https://redtidev1918.github.io/deviantart-downloader/).
75
+
76
+ ## Acknowledgements
77
+
78
+ - [requests](https://github.com/psf/requests) — the HTTP client.
79
+ - DevTools-free login and archive layout were cross-checked against [gallery-dl](https://github.com/mikf/gallery-dl).
80
+
81
+ ## License
82
+
83
+ MIT License — see [LICENSE](LICENSE).
84
+
85
+ **Important disclaimer:** this tool is for personal learning and research only.
86
+ Respect DeviantArt's terms of service and copyright. Do not use it commercially
87
+ or to overload their servers. The author is not responsible for any misuse.
@@ -1,8 +1,10 @@
1
1
  # DeviantArt Downloader
2
2
 
3
- 一个可靠、聚焦的 DeviantArt 下载 / 归档命令行工具。
3
+ **语言 / Language:** 中文 · [English](README.en.md)
4
4
 
5
- **语言 / Language:** 中文 · [English](README.en.md) · [完整文档](https://redtidev1918.github.io/deviantart-downloader/) · [更新日志](CHANGELOG.md)
5
+ > **一个可靠、聚焦的 DeviantArt 下载 / 归档命令行工具。**
6
+
7
+ [完整文档](https://redtidev1918.github.io/deviantart-downloader/) · [更新日志](CHANGELOG.md)
6
8
 
7
9
  [![CI](https://github.com/redtidev1918/deviantart-downloader/actions/workflows/ci.yml/badge.svg)](https://github.com/redtidev1918/deviantart-downloader/actions/workflows/ci.yml)
8
10
  [![PyPI](https://img.shields.io/pypi/v/devart-dl.svg)](https://pypi.org/project/devart-dl/)
@@ -59,9 +61,18 @@ devart-dl login browser # 打开你的 Chrome 登录,自动保存 Cookie,
59
61
  - **代理与降级**:支持 `--proxy`;未登录 OAuth 时自动回退 Cookie。
60
62
  - **Resolve 模式**:`devart-dl resolve URL` 输出 `ResolvedDeviation`/`MediaAsset` 的 JSON 契约,不写文件。
61
63
 
62
- 完整的安装、登录认证、下载模式、配置参数、命令速查、Python 调用与常见问题,请看**文档站点**:
64
+ ## 文档
65
+
66
+ | 你想做什么 | 文档 |
67
+ | --- | --- |
68
+ | 下载安装包 | [下载页](https://redtidev1918.github.io/deviantart-downloader/download.md) |
69
+ | 登录与认证 | [登录与认证](docs/LOGIN.md) |
70
+ | 命令、配置与 Python 调用 | [文档站](https://redtidev1918.github.io/deviantart-downloader/) |
71
+
72
+ ## 致谢
63
73
 
64
- https://redtidev1918.github.io/deviantart-downloader/
74
+ - [requests](https://github.com/psf/requests):HTTP 客户端。
75
+ - 登录与归档目录的实现与 [gallery-dl](https://github.com/mikf/gallery-dl) 交叉核对过。
65
76
 
66
77
  ## 许可证与免责声明
67
78
 
@@ -76,7 +76,7 @@ def _version() -> str:
76
76
  try:
77
77
  return version("devart-dl")
78
78
  except PackageNotFoundError:
79
- return "4.0.1"
79
+ return "4.3.0"
80
80
 
81
81
 
82
82
  def _looks_like_target(value: str) -> bool:
@@ -319,27 +319,67 @@ def deviation_uuid(init_data: dict) -> str:
319
319
  def additional_media_urls(init_data: dict) -> list:
320
320
  """Original-file URLs of a multimedia deviation's extra pages
321
321
  (``deviation.extended.additionalMedia``, each entry nests its Wix
322
- descriptor under ``media``). Prefers the raw ``baseUri`` file + token.
322
+ descriptor under ``media``). Mirrors DeviantDrop's pick order: a playable
323
+ video ``types[].b`` (best quality) wins over ``baseUri`` — a video page's
324
+ baseUri is its poster — then the ``baseUri`` file itself, then a
325
+ fullview template. A page whose only representation is a small preview is
326
+ skipped rather than saved as a fake original.
327
+
328
+ Mature pages the server still returns with a ``blur_`` censored image are
329
+ skipped individually; one blocked page does not discard the others.
323
330
  """
324
- extended = init_data.get("deviation", {}).get("extended") or {}
325
- entries = extended.get("additionalMedia") or []
326
331
  urls: list = []
327
- for entry in entries:
328
- if not isinstance(entry, dict):
329
- continue
330
- media = entry.get("media")
332
+ for entry in (init_data.get("deviation", {}).get("extended") or {}).get(
333
+ "additionalMedia"
334
+ ) or []:
335
+ media = entry.get("media") if isinstance(entry, dict) else None
331
336
  if not isinstance(media, dict):
332
337
  continue
333
- base = media.get("baseUri")
334
- if not isinstance(base, str) or not base:
335
- continue
336
- raw_token = media.get("token")
337
- token = raw_token[0] if isinstance(raw_token, list) and raw_token else raw_token
338
- if token:
339
- urls.append(f"{base}{'&' if '?' in base else '?'}token={token}")
340
- else:
341
- urls.append(base)
338
+ url = _descriptor_media_url(media)
339
+ if url and "blur_" not in url:
340
+ urls.append(url)
342
341
  return urls
343
342
 
344
343
 
344
+ _FILE_EXT_RE = re.compile(
345
+ r"\.(?:png|jpe?g|gif|webp|avif|mp4|m4v|webm|mov|mkv)$", re.IGNORECASE
346
+ )
347
+ _VIDEO_QUALITY_RANK = {"1080p": 4, "720p": 3, "480p": 2, "360p": 1}
348
+
349
+
350
+ def _descriptor_media_url(media: dict) -> Optional[str]:
351
+ types = media.get("types")
352
+ types = types if isinstance(types, list) else []
353
+ videos = [
354
+ item
355
+ for item in types
356
+ if isinstance(item, dict) and item.get("t") == "video" and item.get("b")
357
+ ]
358
+ if videos:
359
+ best = max(videos, key=lambda item: _VIDEO_QUALITY_RANK.get(item.get("q"), 0))
360
+ return _append_token(str(best["b"]), media.get("token"))
361
+ base = media.get("baseUri")
362
+ if isinstance(base, str) and base and _FILE_EXT_RE.search(base.split("?", 1)[0]):
363
+ return _append_token(base, media.get("token"))
364
+ fullview = next(
365
+ (item for item in types if isinstance(item, dict) and item.get("t") == "fullview"),
366
+ None,
367
+ )
368
+ if fullview:
369
+ if fullview.get("b"):
370
+ return _append_token(str(fullview["b"]), media.get("token"))
371
+ if fullview.get("c") and isinstance(base, str) and base:
372
+ pretty = media.get("prettyName") or "image"
373
+ url = base + str(fullview["c"]).replace("<prettyName>", pretty)
374
+ return _append_token(url, media.get("token"))
375
+ return None
376
+
377
+
378
+ def _append_token(url: str, raw_token: Any) -> str:
379
+ token = raw_token[0] if isinstance(raw_token, list) and raw_token else raw_token
380
+ if not token:
381
+ return url
382
+ return f"{url}{'&' if '?' in url else '?'}token={token}"
383
+
384
+
345
385
  __all__ = ["OfficialApiClient", "OriginalDownload", "resolve_uuid", "deviation_init", "deviation_uuid", "additional_media_urls"]
@@ -13,6 +13,7 @@ from typing import Any, Callable, Iterator, Optional, Protocol
13
13
 
14
14
  from .api import ActionType, DeviantArtAPI
15
15
  from .errors import MediaUnavailableError, ParseError
16
+ from .errors import AuthenticationError, NetworkError
16
17
  from .models import Deviation, DownloadItem
17
18
  from .official_api import (
18
19
  OfficialApiClient,
@@ -262,6 +263,13 @@ class OfficialProvider:
262
263
  ext = _ext_from_url(original.filename)
263
264
  return original.url, ext
264
265
  content = deviation.get("content") or {}
266
+ # `content.src` is a poster for video works; only videos[].src is playable.
267
+ videos = deviation.get("videos") or []
268
+ if isinstance(videos, list):
269
+ for video in videos:
270
+ src = video.get("src") if isinstance(video, dict) else None
271
+ if src:
272
+ return str(src), _ext_from_url(str(src))
265
273
  if content.get("src"):
266
274
  return str(content["src"]), _ext_from_url(str(content["src"]))
267
275
  preview = deviation.get("preview") or {}
@@ -337,7 +345,7 @@ class CompositeProvider:
337
345
  if self.official is not None:
338
346
  try:
339
347
  yield from self.official.resolve(target)
340
- except MediaUnavailableError:
348
+ except (MediaUnavailableError, NetworkError, AuthenticationError):
341
349
  if self.web is not None:
342
350
  yield from self.web.resolve(target)
343
351
  return
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devart-dl
3
- Version: 4.3.0
3
+ Version: 4.3.2
4
4
  Summary: 专业的 DeviantArt 作品批量下载工具 - Professional DeviantArt batch downloader with anti-ban features
5
5
  Author: DeviantArt Downloader Team
6
6
  License-Expression: MIT
@@ -34,9 +34,11 @@ Dynamic: license-file
34
34
 
35
35
  # DeviantArt Downloader
36
36
 
37
- 一个可靠、聚焦的 DeviantArt 下载 / 归档命令行工具。
37
+ **语言 / Language:** 中文 · [English](README.en.md)
38
38
 
39
- **语言 / Language:** 中文 · [English](README.en.md) · [完整文档](https://redtidev1918.github.io/deviantart-downloader/) · [更新日志](CHANGELOG.md)
39
+ > **一个可靠、聚焦的 DeviantArt 下载 / 归档命令行工具。**
40
+
41
+ [完整文档](https://redtidev1918.github.io/deviantart-downloader/) · [更新日志](CHANGELOG.md)
40
42
 
41
43
  [![CI](https://github.com/redtidev1918/deviantart-downloader/actions/workflows/ci.yml/badge.svg)](https://github.com/redtidev1918/deviantart-downloader/actions/workflows/ci.yml)
42
44
  [![PyPI](https://img.shields.io/pypi/v/devart-dl.svg)](https://pypi.org/project/devart-dl/)
@@ -93,9 +95,18 @@ devart-dl login browser # 打开你的 Chrome 登录,自动保存 Cookie,
93
95
  - **代理与降级**:支持 `--proxy`;未登录 OAuth 时自动回退 Cookie。
94
96
  - **Resolve 模式**:`devart-dl resolve URL` 输出 `ResolvedDeviation`/`MediaAsset` 的 JSON 契约,不写文件。
95
97
 
96
- 完整的安装、登录认证、下载模式、配置参数、命令速查、Python 调用与常见问题,请看**文档站点**:
98
+ ## 文档
99
+
100
+ | 你想做什么 | 文档 |
101
+ | --- | --- |
102
+ | 下载安装包 | [下载页](https://redtidev1918.github.io/deviantart-downloader/download.md) |
103
+ | 登录与认证 | [登录与认证](docs/LOGIN.md) |
104
+ | 命令、配置与 Python 调用 | [文档站](https://redtidev1918.github.io/deviantart-downloader/) |
105
+
106
+ ## 致谢
97
107
 
98
- https://redtidev1918.github.io/deviantart-downloader/
108
+ - [requests](https://github.com/psf/requests):HTTP 客户端。
109
+ - 登录与归档目录的实现与 [gallery-dl](https://github.com/mikf/gallery-dl) 交叉核对过。
99
110
 
100
111
  ## 许可证与免责声明
101
112
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "devart-dl"
7
- version = "4.3.0"
7
+ version = "4.3.2"
8
8
  description = "专业的 DeviantArt 作品批量下载工具 - Professional DeviantArt batch downloader with anti-ban features"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -104,6 +104,113 @@ def test_artwork_multimedia_yields_every_file(monkeypatch) -> None:
104
104
  assert items[2].extension == "png"
105
105
 
106
106
 
107
+ def test_mature_blurred_extra_pages_are_skipped(monkeypatch) -> None:
108
+ init = {
109
+ "deviation": {
110
+ "extended": {
111
+ "deviationUuid": "uuid-1",
112
+ "additionalMedia": [
113
+ {"media": {"baseUri": "https://media.test/blur_page2.jpg", "token": "t"}},
114
+ {"media": {"baseUri": "https://media.test/page3.png", "token": "t"}},
115
+ ],
116
+ }
117
+ }
118
+ }
119
+ monkeypatch.setattr(provider_mod, "deviation_init", lambda identifier, username=None, **_: init)
120
+ client = FakeClient()
121
+ client.deviations["uuid-1"] = make_deviation()
122
+ provider = OfficialProvider(client, quality="f")
123
+
124
+ items = list(provider.resolve(TargetParser.parse("123456")))
125
+
126
+ assert [item.media_url for item in items] == [
127
+ "https://images.test/full.jpg",
128
+ "https://media.test/page3.png?token=t",
129
+ ]
130
+
131
+
132
+ def test_extra_video_page_uses_playable_source_not_poster(monkeypatch) -> None:
133
+ init = {
134
+ "deviation": {
135
+ "extended": {
136
+ "deviationUuid": "uuid-1",
137
+ "additionalMedia": [
138
+ {"media": {
139
+ "baseUri": "https://media.test/page2.jpg",
140
+ "types": [
141
+ {"t": "video", "q": "480p", "b": "https://videos.test/p2-480.mp4"},
142
+ {"t": "video", "q": "1080p", "b": "https://videos.test/p2-1080.mp4"},
143
+ ],
144
+ "token": "t",
145
+ }},
146
+ ],
147
+ }
148
+ }
149
+ }
150
+ monkeypatch.setattr(provider_mod, "deviation_init", lambda identifier, username=None, **_: init)
151
+ client = FakeClient()
152
+ client.deviations["uuid-1"] = make_deviation()
153
+ provider = OfficialProvider(client, quality="f")
154
+
155
+ items = list(provider.resolve(TargetParser.parse("123456")))
156
+
157
+ assert items[1].media_url == "https://videos.test/p2-1080.mp4?token=t"
158
+ assert items[1].extension == "mp4"
159
+
160
+
161
+ def test_extra_page_uses_fullview_template_when_baseuri_is_not_a_file(monkeypatch) -> None:
162
+ init = {
163
+ "deviation": {
164
+ "extended": {
165
+ "deviationUuid": "uuid-1",
166
+ "additionalMedia": [
167
+ {"media": {
168
+ "baseUri": "https://wixmp.test/hashed",
169
+ "fullview": True,
170
+ "types": [{"t": "fullview", "c": "/f/<prettyName>.jpg"}],
171
+ "prettyName": "page2",
172
+ "token": "t",
173
+ }},
174
+ ],
175
+ }
176
+ }
177
+ }
178
+ monkeypatch.setattr(provider_mod, "deviation_init", lambda identifier, username=None, **_: init)
179
+ client = FakeClient()
180
+ client.deviations["uuid-1"] = make_deviation()
181
+ provider = OfficialProvider(client, quality="f")
182
+
183
+ items = list(provider.resolve(TargetParser.parse("123456")))
184
+
185
+ assert items[1].media_url == "https://wixmp.test/hashed/f/page2.jpg?token=t"
186
+
187
+
188
+ def test_preview_only_extra_page_is_skipped_not_downgraded(monkeypatch) -> None:
189
+ init = {
190
+ "deviation": {
191
+ "extended": {
192
+ "deviationUuid": "uuid-1",
193
+ "additionalMedia": [
194
+ {"media": {
195
+ "baseUri": "https://wixmp.test/hashed",
196
+ "types": [{"t": "preview", "c": "/f/<prettyName>.jpg"}],
197
+ "prettyName": "page2",
198
+ "token": "t",
199
+ }},
200
+ ],
201
+ }
202
+ }
203
+ }
204
+ monkeypatch.setattr(provider_mod, "deviation_init", lambda identifier, username=None, **_: init)
205
+ client = FakeClient()
206
+ client.deviations["uuid-1"] = make_deviation()
207
+ provider = OfficialProvider(client, quality="f")
208
+
209
+ items = list(provider.resolve(TargetParser.parse("123456")))
210
+
211
+ assert [item.media_url for item in items] == ["https://images.test/full.jpg"]
212
+
213
+
107
214
  def test_artwork_best_uses_content(monkeypatch) -> None:
108
215
  monkeypatch.setattr(provider_mod, "deviation_init", lambda identifier, username=None, **_: {"deviation": {"extended": {"deviationUuid": "uuid-1", "additionalMedia": []}}})
109
216
  client = FakeClient()
@@ -116,6 +223,21 @@ def test_artwork_best_uses_content(monkeypatch) -> None:
116
223
  assert client.originals == {} # never called the download endpoint
117
224
 
118
225
 
226
+ def test_artwork_video_uses_playable_source_not_poster(monkeypatch) -> None:
227
+ monkeypatch.setattr(provider_mod, "deviation_init", lambda identifier, username=None, **_: {"deviation": {"extended": {"deviationUuid": "uuid-1", "additionalMedia": []}}})
228
+ client = FakeClient()
229
+ client.deviations["uuid-1"] = {
230
+ **make_deviation(content_src="https://images.test/poster.jpg"),
231
+ "videos": [{"src": "https://videos.test/full.mp4"}],
232
+ }
233
+ provider = OfficialProvider(client, quality="f")
234
+
235
+ items = list(provider.resolve(TargetParser.parse("123456")))
236
+
237
+ assert items[0].media_url == "https://videos.test/full.mp4"
238
+ assert items[0].extension == "mp4"
239
+
240
+
119
241
  def test_gallery_paginates(monkeypatch) -> None:
120
242
  client = FakeClient()
121
243
  client.pages[("gallery_all", "alice", 0)] = {
@@ -6,6 +6,7 @@ import pytest
6
6
 
7
7
  from da_downloader.api import ActionType
8
8
  from da_downloader.errors import MediaUnavailableError
9
+ from da_downloader.errors import NetworkError
9
10
  from da_downloader.models import Deviation, DownloadItem
10
11
  from da_downloader.provider import CompositeProvider, WebProvider
11
12
  from da_downloader.targets import TargetParser
@@ -193,6 +194,19 @@ def test_composite_gallery_falls_back_to_web_when_official_unavailable() -> None
193
194
  assert len(web.api.build_calls) == 1
194
195
 
195
196
 
197
+ def test_composite_gallery_falls_back_to_web_when_official_network_fails() -> None:
198
+ web = make_provider(
199
+ pages=[([make_deviation()], False, 1, "")],
200
+ media_urls={"123": "https://images.test/123.jpg"},
201
+ )
202
+ official = RecordingProvider(error=NetworkError("API down"))
203
+ router = CompositeProvider(official=official, web=web)
204
+
205
+ items = list(router.resolve(TargetParser.parse("https://www.deviantart.com/alice/gallery")))
206
+
207
+ assert items[0].artwork_id == "123"
208
+
209
+
196
210
  def test_composite_gallery_with_only_web_uses_web() -> None:
197
211
  web = make_provider(
198
212
  pages=[([make_deviation()], False, 1, "")],
@@ -1,289 +0,0 @@
1
- # DeviantArt Downloader
2
-
3
- A reliable, focused DeviantArt downloader and archival CLI.
4
-
5
- **Language / 语言:** [中文](README.md) · English · [Full documentation](https://redtidev1918.github.io/deviantart-downloader/) · [Changelog](CHANGELOG.md)
6
-
7
- [![CI](https://github.com/redtidev1918/deviantart-downloader/actions/workflows/ci.yml/badge.svg)](https://github.com/redtidev1918/deviantart-downloader/actions/workflows/ci.yml)
8
- [![PyPI](https://img.shields.io/pypi/v/devart-dl.svg)](https://pypi.org/project/devart-dl/)
9
- [![Python](https://img.shields.io/pypi/pyversions/devart-dl.svg)](https://pypi.org/project/devart-dl/)
10
- [![Downloads](https://img.shields.io/pypi/dm/devart-dl.svg)](https://pypi.org/project/devart-dl/)
11
- [![License](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE)
12
-
13
- See [CHANGELOG.md](CHANGELOG.md) for release notes.
14
-
15
- ---
16
-
17
- ## Core Features
18
-
19
- - **Official API first (OAuth)** — log in once to download originals; no cookie export, no anti-ban delays.
20
- - **URL-first** — `devart-dl URL` downloads artworks / galleries / favourites / tags / fav.me links directly.
21
- - **Reliable downloads** — HTTP Range resume, automatic retry, 429 backoff, HTML/empty-file validation, atomic finalize.
22
- - **Download archive** — `--archive` remembers what you've downloaded in SQLite, skipping it across sessions.
23
- - **Path templates** — `--directory` / `--filename` support `{id}` `{title}` `{author}` `{published}` `{ext}` and more, with safe sanitization.
24
- - **Metadata sidecars** — `--write-info-json` writes a `.json` next to each file.
25
- - **Proxy & fallback** — `--proxy` or proxy environment variables; falls back to cookies when not logged in via OAuth.
26
-
27
- ---
28
-
29
- ## Quick Start
30
-
31
- ### Installation
32
-
33
- Requires Python 3.10 or newer.
34
-
35
- ```bash
36
- pip install devart-dl
37
- ```
38
-
39
- ### Basic Usage (URL-first)
40
-
41
- ```bash
42
- # Single artwork (also fav.me links and bare artwork ids)
43
- devart-dl https://www.deviantart.com/username/art/title-123456
44
-
45
- # Gallery / all works by an artist
46
- devart-dl https://www.deviantart.com/username/gallery
47
-
48
- # Favourites
49
- devart-dl https://www.deviantart.com/username/favourites
50
-
51
- # Tag
52
- devart-dl https://www.deviantart.com/tag/landscape
53
-
54
- # Resolve to JSON descriptors (download nothing)
55
- devart-dl resolve https://www.deviantart.com/username/art/title-123456
56
- ```
57
-
58
- ### Login (OAuth recommended)
59
-
60
- ```bash
61
- devart-dl login oauth --client-id YOUR_PUBLIC_CLIENT_ID
62
- devart-dl whoami # verify login
63
- devart-dl logout # revoke the token
64
- ```
65
-
66
- Register a **Public** OAuth app at [deviantart.com/developers](https://www.deviantart.com/developers/) and whitelist `http://127.0.0.1:8765/callback`. Login happens in the browser — no password or `client_secret` is ever given to the CLI.
67
-
68
- ---
69
-
70
- ## Main Features
71
-
72
- ### Resolve Mode
73
-
74
- `devart-dl resolve URL` emits a machine-consumable `ResolvedDeviation` + `MediaAsset[]` JSON contract without writing any file.
75
-
76
- ### Download Modes
77
-
78
- URL-first is the simplest entry; explicit subcommands are also kept:
79
-
80
- ```bash
81
- # URL-first
82
- devart-dl https://www.deviantart.com/username/art/title-123456
83
- devart-dl https://www.deviantart.com/username/gallery
84
- devart-dl https://www.deviantart.com/username/gallery/12345 # specific folder
85
- devart-dl https://www.deviantart.com/username/favourites
86
- devart-dl https://www.deviantart.com/tag/landscape
87
-
88
- # Explicit subcommands
89
- devart-dl url https://www.deviantart.com/username/art/title-123456
90
- devart-dl artist username
91
- devart-dl gallery username [gallery_id]
92
- devart-dl search all "digital art"
93
- devart-dl fav username folder_id
94
- ```
95
-
96
- > Note: explicit subcommands keep the old usage for compatibility. The official API has no search endpoint, so `search` always uses the web/cookie path. OAuth and cookies are capabilities that compose — OAuth gives the official API and cookie gives search — instead of one global "login mode".
97
-
98
- ### Authentication
99
-
100
- **Official API (OAuth) first**, cookies as the fallback.
101
-
102
- 1. **OAuth login (recommended)**
103
-
104
- ```bash
105
- devart-dl login oauth --client-id YOUR_PUBLIC_CLIENT_ID
106
- ```
107
-
108
- - Register a **Public** OAuth app and whitelist `http://127.0.0.1:8765/callback` exactly.
109
- - Login completes in the browser; tokens are stored at `~/.deviantart_dl/oauth.json` (0600) and auto-refresh.
110
- - Download commands then use the official API automatically; originals come from the official download endpoint.
111
-
112
- 2. **Cookie login — one-click browser (recommended for cookies)**
113
-
114
- ```bash
115
- devart-dl login browser
116
- ```
117
-
118
- A Chrome window opens to the DeviantArt login page; just log in there. The
119
- helper drives your **real** Chrome via the DevTools Protocol and captures the
120
- web cookies (`auth` / `auth_secure` / `userinfo`) automatically — no F12 copy.
121
- It needs Node.js (>= 22) and Chrome, and saves the session to
122
- `~/.deviantart_dl/session.json` (0600). Logging in on the real domain is
123
- required because the DeviantArt login page is behind an AWS WAF bot check that
124
- blocks headless/proxied browsers; this is also the only way to capture the
125
- HttpOnly `auth_secure` cookie.
126
-
127
- 3. **Cookie login — manual paste (fallback)**
128
-
129
- ```bash
130
- devart-dl login interactive # paste a cookie interactively
131
- ```
132
-
133
- Or create a `cookies.txt`, or set the `DEVIANTART_COOKIES` environment variable. The session is saved at `~/.deviantart_dl/session.json`.
134
-
135
- ### Download Archive & Resume
136
-
137
- ```bash
138
- # SQLite archive: skip already-downloaded items across sessions and directories
139
- devart-dl https://www.deviantart.com/username/gallery --archive ~/.devart-dl/archive.sqlite
140
-
141
- # Interrupted downloads resume from the .part file — no full re-download
142
- ```
143
-
144
- ### Filename / Directory Templates
145
-
146
- ```bash
147
- devart-dl URL \
148
- --directory "{author}/{published:%Y-%m}" \
149
- --filename "{id}_{title}.{ext}"
150
- ```
151
-
152
- Fields: `{id}` `{title}` `{author}` `{username}` `{published}` `{filename}` `{ext}` `{index}`. Paths are sanitized (illegal characters, Windows reserved names, `..` traversal, overly long names) so results always stay inside the download root.
153
-
154
- ### Metadata Sidecar
155
-
156
- ```bash
157
- devart-dl URL --write-info-json
158
- # produces artwork.jpg plus artwork.json (title, author, URL, published, mature, media URL, …)
159
- ```
160
-
161
- ---
162
-
163
- ## Configuration Options
164
-
165
- ### Common Options
166
-
167
- | Option | Description | Default |
168
- |--------|-------------|---------|
169
- | `-d, --dest` | Download root directory | `./Downloads` |
170
- | `--directory` | Directory template (e.g. `{author}/{published:%Y}`) | `{author}` |
171
- | `--filename` | Filename template (e.g. `{id}_{title}.{ext}`) | `{id}_{title}.{ext}` |
172
- | `--quality` | `original` (login) / `best` (default) / `preview`; legacy `o/f/p` still accepted | `best` |
173
- | `--archive` | SQLite download archive path | none |
174
- | `--write-info-json` | Write a metadata sidecar | off |
175
- | `--dry-run` | Plan only: resolve and report what would be downloaded, write nothing | off |
176
- | `--overwrite` | Replace existing files | off (skip) |
177
- | `--cookies` | Cookie file path | auto-load session |
178
- | `--proxy` | Proxy URL | auto from environment |
179
- | `--timeout` | Request timeout (seconds) | `60` |
180
- | `--retries` | Max retries | `3` |
181
- | `--limit` | Items per page | `24` |
182
-
183
- ### Output Options
184
-
185
- - `-v, --verbose`: show details (including skip reasons).
186
- - `-q, --quiet`: only the summary and errors.
187
-
188
- ---
189
-
190
- ## Command Reference
191
-
192
- ```bash
193
- # URL-first (recommended)
194
- devart-dl <URL>
195
-
196
- # Explicit subcommands
197
- devart-dl url <artwork_url>
198
- devart-dl artist <username>
199
- devart-dl gallery <username> [gallery_id]
200
- devart-dl search <username|all> <query>
201
- devart-dl fav <username> <folder_id>
202
-
203
- # Login / session
204
- devart-dl login oauth --client-id <ID> # OAuth login (recommended)
205
- devart-dl login browser # one-click cookie via your Chrome
206
- devart-dl login interactive # cookie login (manual paste)
207
- devart-dl whoami # show login status
208
- devart-dl logout # revoke token and clear local session
209
-
210
- # Other
211
- devart-dl version
212
- ```
213
-
214
- ---
215
-
216
- ## Advanced Usage
217
-
218
- ### Proxy
219
-
220
- ```bash
221
- # Command-line
222
- devart-dl URL --proxy http://127.0.0.1:7890
223
-
224
- # Environment variables (HTTP/HTTPS)
225
- export HTTP_PROXY=http://127.0.0.1:7890
226
- export HTTPS_PROXY=http://127.0.0.1:7890
227
- export ALL_PROXY=http://127.0.0.1:7890
228
- ```
229
-
230
- ### Python API
231
-
232
- ```python
233
- from pathlib import Path
234
- from da_downloader import Downloader
235
- from da_downloader.download import build_downloader
236
-
237
- downloader = build_downloader(
238
- destination=Path('downloads'),
239
- archive=Path('archive.sqlite'), # optional: skip already-downloaded
240
- quality='best', # original / best / preview
241
- write_info_json=True, # write a metadata .json next to each file
242
- )
243
-
244
- results = downloader.download('https://www.deviantart.com/username/gallery')
245
- for r in results:
246
- print(r.status, r.path)
247
- ```
248
-
249
- When logged in via `devart-dl login oauth`, the official API is used automatically; otherwise it falls back to cookies.
250
-
251
- ---
252
-
253
- ## FAQ
254
-
255
- **Q: Downloads are incomplete / skip some artworks?**
256
- **A:** Upgrade to **v3.4.0+**. Older versions fetched only the Featured folder;
257
- v3.3.1 walks all gallery folders, and v3.4.0 reports parsing/network failures
258
- loudly and resumes interrupted downloads via Range.
259
-
260
- **Q: ModuleNotFoundError after pip install?**
261
- **A:** Upgrade to **v3.4.0+** (`pip install -U devart-dl`); the console entry point is fixed.
262
-
263
- **Q: Got a 403 Forbidden error?**
264
- **A:** Usually not logged in or an expired cookie. Prefer `devart-dl login oauth`
265
- (official API); otherwise `devart-dl login browser` for a one-click cookie
266
- (`devart-dl login interactive` to paste one manually).
267
-
268
- **Q: Got a 429 Too Many Requests?**
269
- **A:** The official API (OAuth) usually avoids this; the cookie path retries with
270
- backoff. If it persists, pause for a while.
271
-
272
- **Q: Downloads are slow?**
273
- **A:** Use a proxy (`--proxy`), or lower the quality (`--quality preview`).
274
-
275
- **Q: How do I export a cookie?**
276
- **A:** Easiest is `devart-dl login browser` — it opens your own Chrome and
277
- captures the cookie automatically. Otherwise open Chrome/Edge DevTools (F12) →
278
- Application → Cookies, copy the `auth` and `auth_secure` fields, and paste them
279
- via `devart-dl login interactive`.
280
-
281
- ---
282
-
283
- ## License
284
-
285
- MIT License — see [LICENSE](LICENSE).
286
-
287
- **Important disclaimer:** this tool is for personal learning and research only.
288
- Respect DeviantArt's terms of service and copyright. Do not use it commercially
289
- or to overload their servers. The author is not responsible for any misuse.
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes