devart-dl 4.0.2__tar.gz → 4.1.0__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 (44) hide show
  1. {devart_dl-4.0.2 → devart_dl-4.1.0}/MANIFEST.in +1 -0
  2. {devart_dl-4.0.2/devart_dl.egg-info → devart_dl-4.1.0}/PKG-INFO +1 -1
  3. {devart_dl-4.0.2 → devart_dl-4.1.0}/da_downloader/cli.py +34 -2
  4. devart_dl-4.1.0/da_downloader/da_cookie_login.mjs +137 -0
  5. {devart_dl-4.0.2 → devart_dl-4.1.0}/da_downloader/official_api.py +92 -1
  6. {devart_dl-4.0.2 → devart_dl-4.1.0}/da_downloader/provider.py +32 -4
  7. {devart_dl-4.0.2 → devart_dl-4.1.0/devart_dl.egg-info}/PKG-INFO +1 -1
  8. {devart_dl-4.0.2 → devart_dl-4.1.0}/devart_dl.egg-info/SOURCES.txt +1 -0
  9. {devart_dl-4.0.2 → devart_dl-4.1.0}/pyproject.toml +4 -1
  10. {devart_dl-4.0.2 → devart_dl-4.1.0}/tests/test_official_provider.py +30 -3
  11. {devart_dl-4.0.2 → devart_dl-4.1.0}/.env.example +0 -0
  12. {devart_dl-4.0.2 → devart_dl-4.1.0}/LICENSE +0 -0
  13. {devart_dl-4.0.2 → devart_dl-4.1.0}/README.md +0 -0
  14. {devart_dl-4.0.2 → devart_dl-4.1.0}/README_EN.md +0 -0
  15. {devart_dl-4.0.2 → devart_dl-4.1.0}/bin/devart-dl +0 -0
  16. {devart_dl-4.0.2 → devart_dl-4.1.0}/bin/install.sh +0 -0
  17. {devart_dl-4.0.2 → devart_dl-4.1.0}/da_downloader/__init__.py +0 -0
  18. {devart_dl-4.0.2 → devart_dl-4.1.0}/da_downloader/api.py +0 -0
  19. {devart_dl-4.0.2 → devart_dl-4.1.0}/da_downloader/archive.py +0 -0
  20. {devart_dl-4.0.2 → devart_dl-4.1.0}/da_downloader/auth.py +0 -0
  21. {devart_dl-4.0.2 → devart_dl-4.1.0}/da_downloader/download.py +0 -0
  22. {devart_dl-4.0.2 → devart_dl-4.1.0}/da_downloader/errors.py +0 -0
  23. {devart_dl-4.0.2 → devart_dl-4.1.0}/da_downloader/http.py +0 -0
  24. {devart_dl-4.0.2 → devart_dl-4.1.0}/da_downloader/manager.py +0 -0
  25. {devart_dl-4.0.2 → devart_dl-4.1.0}/da_downloader/models.py +0 -0
  26. {devart_dl-4.0.2 → devart_dl-4.1.0}/da_downloader/oauth.py +0 -0
  27. {devart_dl-4.0.2 → devart_dl-4.1.0}/da_downloader/path.py +0 -0
  28. {devart_dl-4.0.2 → devart_dl-4.1.0}/da_downloader/targets.py +0 -0
  29. {devart_dl-4.0.2 → devart_dl-4.1.0}/devart_dl.egg-info/dependency_links.txt +0 -0
  30. {devart_dl-4.0.2 → devart_dl-4.1.0}/devart_dl.egg-info/entry_points.txt +0 -0
  31. {devart_dl-4.0.2 → devart_dl-4.1.0}/devart_dl.egg-info/requires.txt +0 -0
  32. {devart_dl-4.0.2 → devart_dl-4.1.0}/devart_dl.egg-info/top_level.txt +0 -0
  33. {devart_dl-4.0.2 → devart_dl-4.1.0}/requirements.txt +0 -0
  34. {devart_dl-4.0.2 → devart_dl-4.1.0}/setup.cfg +0 -0
  35. {devart_dl-4.0.2 → devart_dl-4.1.0}/setup.py +0 -0
  36. {devart_dl-4.0.2 → devart_dl-4.1.0}/tests/test_api.py +0 -0
  37. {devart_dl-4.0.2 → devart_dl-4.1.0}/tests/test_archive.py +0 -0
  38. {devart_dl-4.0.2 → devart_dl-4.1.0}/tests/test_cli.py +0 -0
  39. {devart_dl-4.0.2 → devart_dl-4.1.0}/tests/test_download.py +0 -0
  40. {devart_dl-4.0.2 → devart_dl-4.1.0}/tests/test_http_downloader.py +0 -0
  41. {devart_dl-4.0.2 → devart_dl-4.1.0}/tests/test_manager.py +0 -0
  42. {devart_dl-4.0.2 → devart_dl-4.1.0}/tests/test_path.py +0 -0
  43. {devart_dl-4.0.2 → devart_dl-4.1.0}/tests/test_provider.py +0 -0
  44. {devart_dl-4.0.2 → devart_dl-4.1.0}/tests/test_targets.py +0 -0
@@ -9,3 +9,4 @@ include requirements.txt
9
9
 
10
10
  # Include non-Python support files and source-tree scripts
11
11
  recursive-include bin *
12
+ include da_downloader/*.mjs
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devart-dl
3
- Version: 4.0.2
3
+ Version: 4.1.0
4
4
  Summary: 专业的 DeviantArt 作品批量下载工具 - Professional DeviantArt batch downloader with anti-ban features
5
5
  Author: DeviantArt Downloader Team
6
6
  License-Expression: MIT
@@ -11,6 +11,8 @@ import argparse
11
11
  import json
12
12
  import os
13
13
  import re
14
+ import shutil
15
+ import subprocess
14
16
  import sys
15
17
  from datetime import datetime
16
18
  from importlib.metadata import PackageNotFoundError, version
@@ -40,7 +42,7 @@ Usage:
40
42
  devart-dl gallery USERNAME [GALLERY_ID] [options]
41
43
  devart-dl search USERNAME_OR_ALL QUERY [options]
42
44
  devart-dl fav USERNAME [FOLDER_ID] [options]
43
- devart-dl login oauth|interactive
45
+ devart-dl login oauth|browser|interactive
44
46
  devart-dl whoami | logout
45
47
  devart-dl version
46
48
 
@@ -281,6 +283,33 @@ def _oauth_login(arguments: Sequence[str]) -> int:
281
283
  return 0
282
284
 
283
285
 
286
+ def _login_browser(arguments: Sequence[str]) -> int:
287
+ """One-click cookie login: drive the user's real Chrome via CDP.
288
+
289
+ Avoids the manual F12 cookie copy and, importantly, runs the login on the
290
+ real DeviantArt domain so the AWS WAF challenge passes (a server-side or
291
+ proxied headless browser is blocked). Requires Node >= 22 and Chrome.
292
+ Falls back with instructions if they are unavailable.
293
+ """
294
+ if not shutil.which("node"):
295
+ print(
296
+ "browser login needs Node.js (>= 22) to drive Chrome.\n"
297
+ "Install Node, or use `devart-dl login interactive` to paste a cookie instead.",
298
+ file=sys.stderr,
299
+ )
300
+ return 1
301
+ script = Path(__file__).resolve().parent / "da_cookie_login.mjs"
302
+ if not script.exists():
303
+ print(f"login helper not found: {script}", file=sys.stderr)
304
+ return 1
305
+ print("Opening Chrome — log in to DeviantArt in the window that appears…")
306
+ try:
307
+ return subprocess.call(["node", str(script)])
308
+ except KeyboardInterrupt:
309
+ print("\ncancelled", file=sys.stderr)
310
+ return 130
311
+
312
+
284
313
  def _login_interactive(arguments: Sequence[str]) -> int:
285
314
  print("Paste your DeviantArt cookie (F12 → Application → Cookies → copy auth & auth_secure):")
286
315
  cookies = input().strip()
@@ -368,14 +397,17 @@ def main(argv: Sequence[str] | None = None) -> int:
368
397
  return _run_subcommand(command, rest)
369
398
  if command == "login":
370
399
  if not rest or rest[0] in {"-h", "--help", "help"}:
371
- print("Usage: devart-dl login oauth|interactive|clear")
400
+ print("Usage: devart-dl login oauth|browser|interactive|clear")
372
401
  print(" oauth log in via the official API (recommended)")
402
+ print(" browser one-click: log in with your own Chrome, cookie auto-saved")
373
403
  print(" interactive paste a cookie (fallback)")
374
404
  print(" clear clear saved login sessions")
375
405
  return 0
376
406
  login_command, *login_args = rest
377
407
  if login_command == "oauth":
378
408
  return _oauth_login(login_args)
409
+ if login_command == "browser":
410
+ return _login_browser(login_args)
379
411
  if login_command == "interactive":
380
412
  return _login_interactive(login_args)
381
413
  if login_command == "clear":
@@ -0,0 +1,137 @@
1
+ #!/usr/bin/env node
2
+ // 一键获取 DeviantArt 网页登录 Cookie(成熟/NSFW 作品下载需要登录会话)。
3
+ //
4
+ // 为什么这么做:DeviantArt 登录页是 AWS WAF 人机校验 SPA,服务器端 fetch / 无头代理
5
+ // 会被挡(detectIp / validateHostname),令牌也绑定浏览器自身环境。正确做法是让你
6
+ // 本机的真实 Chrome 在「真实 DeviantArt 域」登录,脚本用 Chrome DevTools Protocol
7
+ // 从网络层读出登录后的网页 Cookie(auth / auth_secure / userinfo),写到本下载器
8
+ // 约定的 ~/.deviantart_dl/session.json,之后 `devart-dl` 下载成熟作品自动带上。
9
+ //
10
+ // 用法:
11
+ // node scripts/da-cookie-login.mjs
12
+ //
13
+ // 依赖:本机已装 Google Chrome / Edge;Node >= 22(自带 WebSocket / fetch)。不装任何 npm 包。
14
+ // 参考:与 deviantdrop 一键登录同思路;只抓网页 Cookie,不碰 OAuth。
15
+
16
+ import { spawn } from "node:child_process";
17
+ import { setTimeout as sleep } from "node:timers/promises";
18
+ import { homedir, platform } from "node:os";
19
+ import { join } from "node:path";
20
+ import { mkdirSync, existsSync, writeFileSync, chmodSync } from "node:fs";
21
+
22
+ const SESSION_COOKIES = ["auth", "auth_secure", "userinfo"];
23
+ const DEBUG_PORT = 9334;
24
+ const TIMEOUT = Number(process.env.DA_LOGIN_TIMEOUT_MS) || 10 * 60 * 1000;
25
+ const PROFILE_DIR = join(homedir(), ".config", "da-cookie-login", "chrome-profile");
26
+
27
+ function chromePath() {
28
+ const p = platform();
29
+ const candidates = p === "darwin"
30
+ ? ["/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
31
+ "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
32
+ "/Applications/Chromium.app/Contents/MacOS/Chromium"]
33
+ : p === "win32"
34
+ ? [join(process.env.PROGRAMFILES || "", "Google/Chrome/Application/chrome.exe"),
35
+ join(process.env.LOCALAPPDATA || "", "Google/Chrome/Application/chrome.exe")]
36
+ : ["/usr/bin/google-chrome", "/usr/bin/google-chrome-stable", "/usr/bin/chromium",
37
+ "/usr/bin/microsoft-edge", "/snap/bin/chromium"];
38
+ const found = candidates.find((c) => existsSync(c));
39
+ if (!found) throw new Error("找不到 Chrome/Edge,请先安装 Google Chrome。");
40
+ return found;
41
+ }
42
+
43
+ function sessionFile() {
44
+ return join(homedir(), ".deviantart_dl", "session.json");
45
+ }
46
+
47
+ async function main() {
48
+ mkdirSync(PROFILE_DIR, { recursive: true });
49
+ const isWin = platform() === "win32";
50
+ const chrome = spawn(chromePath(), [
51
+ `--remote-debugging-port=${DEBUG_PORT}`,
52
+ `--user-data-dir=${PROFILE_DIR}`,
53
+ "--no-first-run", "--no-default-browser-check",
54
+ "https://www.deviantart.com/users/login",
55
+ ], { stdio: ["ignore", "ignore", "ignore"], detached: !isWin });
56
+
57
+ try {
58
+ // 等 DevTools 就绪
59
+ let target = null;
60
+ for (let i = 0; i < 40; i++) {
61
+ try {
62
+ const r = await fetch(`http://127.0.0.1:${DEBUG_PORT}/json/list`);
63
+ const tabs = await r.json();
64
+ target = tabs.find((t) => t.type === "page" && t.webSocketDebuggerUrl);
65
+ if (target) break;
66
+ } catch { /* not ready */ }
67
+ await sleep(500);
68
+ }
69
+ if (!target) throw new Error("Chrome 远程调试端口未就绪。");
70
+
71
+ const ws = new WebSocket(target.webSocketDebuggerUrl);
72
+ await new Promise((resolve, reject) => { ws.onopen = resolve; ws.onerror = reject; });
73
+ let msgId = 0;
74
+ const pending = new Map();
75
+ const send = (method, params = {}) => new Promise((resolve, reject) => {
76
+ const id = ++msgId;
77
+ pending.set(id, { resolve, reject });
78
+ ws.send(JSON.stringify({ id, method, params }));
79
+ });
80
+ ws.onmessage = (ev) => {
81
+ const msg = JSON.parse(ev.data);
82
+ if (msg.id && pending.has(msg.id)) {
83
+ const { resolve, reject } = pending.get(msg.id);
84
+ pending.delete(msg.id);
85
+ msg.error ? reject(new Error(msg.error.message)) : resolve(msg.result);
86
+ }
87
+ };
88
+ await send("Network.enable");
89
+
90
+ console.log("\n即将打开 Chrome 进入 DeviantArt 登录页。请在浏览器里登录你的账号。");
91
+ console.log("登录成功后脚本自动捕获 Cookie(最多 10 分钟)。\n");
92
+
93
+ const readSession = async () => {
94
+ const ctx = await send("Network.getCookies", { urls: ["https://www.deviantart.com/"] });
95
+ let jar = ctx.cookies || [];
96
+ if (!SESSION_COOKIES.every((n) => jar.some((c) => c.name === n))) {
97
+ const all = (await send("Network.getAllCookies")).cookies || [];
98
+ jar = all.filter((c) => /(^|\.)deviantart\.com$/i.test(c.domain));
99
+ }
100
+ const map = {};
101
+ for (const c of jar) if (SESSION_COOKIES.includes(c.name)) map[c.name] = c.value;
102
+ const has = SESSION_COOKIES.every((n) => map[n]);
103
+ const cookies = SESSION_COOKIES.filter((n) => map[n]).map((n) => `${n}=${map[n]}`).join("; ");
104
+ return { has, cookies };
105
+ };
106
+
107
+ const t0 = Date.now();
108
+ let lastHint = "";
109
+ while (Date.now() - t0 < TIMEOUT) {
110
+ await sleep(1500);
111
+ const { has, cookies } = await readSession();
112
+ const hint = has ? "已检测到登录 Cookie,正在保存…" : "等待你在页面里登录…";
113
+ if (hint !== lastHint) { console.log(hint); lastHint = hint; }
114
+ if (has) {
115
+ const path = sessionFile();
116
+ mkdirSync(join(homedir(), ".deviantart_dl"), { recursive: true });
117
+ const payload = {
118
+ cookies,
119
+ saved_at: new Date().toISOString(),
120
+ method: "cdp-one-click",
121
+ };
122
+ writeFileSync(path, JSON.stringify(payload, null, 2), { encoding: "utf8", mode: 0o600 });
123
+ try { chmodSync(path, 0o600); } catch { /* best effort */ }
124
+ ws.close();
125
+ console.log(`\n✅ Cookie 已保存到 ${path}`);
126
+ console.log("现在可以直接用 devart-dl 下载成熟/NSFW 作品(会自动带上登录会话)。");
127
+ return;
128
+ }
129
+ }
130
+ ws.close();
131
+ throw new Error("登录超时,请重新运行并在 10 分钟内完成登录。");
132
+ } finally {
133
+ try { isWin ? chrome.kill() : process.kill(-chrome.pid); } catch { /* cleanup */ }
134
+ }
135
+ }
136
+
137
+ main().catch((e) => { console.error("\n❌ 获取 Cookie 失败:", e.message); process.exit(1); });
@@ -251,4 +251,95 @@ def resolve_uuid(
251
251
  return uuid
252
252
 
253
253
 
254
- __all__ = ["OfficialApiClient", "OriginalDownload", "resolve_uuid"]
254
+
255
+ def deviation_init(
256
+ identifier: str,
257
+ username: Optional[str] = None,
258
+ timeout: int = 30,
259
+ session: Optional[requests.Session] = None,
260
+ ) -> dict:
261
+ """Fetch the full public ``_puppy/dadeviation/init`` payload (numeric or
262
+ fav.me ids). Mirrors DAKit/DAViewer: multimedia pages surface under
263
+ ``deviation.extended.additionalMedia`` here, which the official API no
264
+ longer returns.
265
+ """
266
+ identifier = identifier.strip()
267
+ if not identifier:
268
+ raise ParseError("the URL does not contain an artwork id")
269
+ http = session or requests.Session()
270
+ if not identifier.isdigit():
271
+ identifier = _resolve_short_id(identifier, http, timeout)
272
+ try:
273
+ home = http.get(
274
+ WEB_BASE,
275
+ headers={"User-Agent": USER_AGENT, "Accept": "text/html"},
276
+ timeout=timeout,
277
+ )
278
+ home.raise_for_status()
279
+ match = re.search(r"window\.__CSRF_TOKEN__ = '([^']*)'", home.text)
280
+ if not match or not match.group(1):
281
+ raise ParseError("could not read the DeviantArt CSRF token")
282
+ csrf = match.group(1)
283
+ query = {
284
+ "deviationid": identifier,
285
+ # 该接口自 2026 年起把 type 列为必填枚举(art/journal),缺失返回 400。
286
+ "type": "art",
287
+ "include_session": "false",
288
+ "csrf_token": csrf,
289
+ "mature_content": True,
290
+ }
291
+ if username:
292
+ query["username"] = username
293
+ response = http.get(
294
+ f"{WEB_BASE}/_puppy/dadeviation/init",
295
+ params=query,
296
+ headers={"User-Agent": USER_AGENT, "Accept": "application/json"},
297
+ timeout=timeout,
298
+ )
299
+ response.raise_for_status()
300
+ data = response.json()
301
+ except requests.RequestException as error:
302
+ raise NetworkError(f"could not reach the resolution endpoint: {error}") from error
303
+ except ValueError as error:
304
+ raise ParseError("the resolution endpoint returned invalid JSON") from error
305
+ if not isinstance(data, dict) or not isinstance(data.get("deviation"), dict):
306
+ raise ParseError("the resolution endpoint returned an unexpected payload")
307
+ return data
308
+
309
+
310
+ def deviation_uuid(init_data: dict) -> str:
311
+ """Read the UUID out of an init payload."""
312
+ extended = init_data.get("deviation", {}).get("extended") or {}
313
+ uuid = extended.get("deviationUuid")
314
+ if not isinstance(uuid, str) or not uuid:
315
+ raise MediaUnavailableError("could not resolve the artwork to a UUID")
316
+ return uuid
317
+
318
+
319
+ def additional_media_urls(init_data: dict) -> list:
320
+ """Original-file URLs of a multimedia deviation's extra pages
321
+ (``deviation.extended.additionalMedia``, each entry nests its Wix
322
+ descriptor under ``media``). Prefers the raw ``baseUri`` file + token.
323
+ """
324
+ extended = init_data.get("deviation", {}).get("extended") or {}
325
+ entries = extended.get("additionalMedia") or []
326
+ urls: list = []
327
+ for entry in entries:
328
+ if not isinstance(entry, dict):
329
+ continue
330
+ media = entry.get("media")
331
+ if not isinstance(media, dict):
332
+ 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)
342
+ return urls
343
+
344
+
345
+ __all__ = ["OfficialApiClient", "OriginalDownload", "resolve_uuid", "deviation_init", "deviation_uuid", "additional_media_urls"]
@@ -14,7 +14,12 @@ from typing import Any, Callable, Iterator, Optional, Protocol
14
14
  from .api import ActionType, DeviantArtAPI
15
15
  from .errors import MediaUnavailableError, ParseError
16
16
  from .models import Deviation, DownloadItem
17
- from .official_api import OfficialApiClient, resolve_uuid
17
+ from .official_api import (
18
+ OfficialApiClient,
19
+ additional_media_urls,
20
+ deviation_init,
21
+ deviation_uuid,
22
+ )
18
23
  from .targets import DownloadTarget, TargetKind
19
24
 
20
25
  logger = logging.getLogger(__name__)
@@ -158,7 +163,7 @@ class OfficialProvider:
158
163
  def resolve(self, target: DownloadTarget) -> Iterator[DownloadItem]:
159
164
  kind = target.kind
160
165
  if kind == TargetKind.ARTWORK:
161
- yield self._artwork(target)
166
+ yield from self._artwork_items(target)
162
167
  return
163
168
  if kind == TargetKind.SEARCH:
164
169
  raise MediaUnavailableError(
@@ -191,8 +196,31 @@ class OfficialProvider:
191
196
  yield from self._paginate(fetch)
192
197
 
193
198
  def _artwork(self, target: DownloadTarget) -> DownloadItem:
194
- uuid = resolve_uuid(target.identifier or "", target.username)
195
- return self._item(self.client.deviation(uuid))
199
+ # 兼容旧的单返回值调用(resolve 里已改用 _artwork_items)
200
+ return next(self._artwork_items(target))
201
+
202
+ def _artwork_items(self, target: DownloadTarget) -> Iterator[DownloadItem]:
203
+ """单作品下载。多文件作品的附加画面来自网页 init 响应
204
+ (deviation.extended.additionalMedia,官方 API 不再返回),逐个产出下载项;
205
+ 没有附加画面时只下主图(行为与以前一致)。"""
206
+ identifier = target.identifier or ""
207
+ init_data = deviation_init(identifier, target.username)
208
+ uuid = deviation_uuid(init_data)
209
+ main = self._item(self.client.deviation(uuid))
210
+ yield main
211
+ for index, url in enumerate(additional_media_urls(init_data), start=1):
212
+ item = DownloadItem(
213
+ artwork_id=f"{main.artwork_id}-{index}",
214
+ url=main.url,
215
+ title=main.title,
216
+ author=main.author,
217
+ media_url=url,
218
+ extension=_ext_from_url(url),
219
+ published_at=main.published_at,
220
+ mature=main.mature,
221
+ metadata={**main.metadata, "index": index},
222
+ )
223
+ yield item
196
224
 
197
225
  def _paginate(self, fetch: Callable[[int], dict]) -> Iterator[DownloadItem]:
198
226
  offset = 0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devart-dl
3
- Version: 4.0.2
3
+ Version: 4.1.0
4
4
  Summary: 专业的 DeviantArt 作品批量下载工具 - Professional DeviantArt batch downloader with anti-ban features
5
5
  Author: DeviantArt Downloader Team
6
6
  License-Expression: MIT
@@ -13,6 +13,7 @@ da_downloader/api.py
13
13
  da_downloader/archive.py
14
14
  da_downloader/auth.py
15
15
  da_downloader/cli.py
16
+ da_downloader/da_cookie_login.mjs
16
17
  da_downloader/download.py
17
18
  da_downloader/errors.py
18
19
  da_downloader/http.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "devart-dl"
7
- version = "4.0.2"
7
+ version = "4.1.0"
8
8
  description = "专业的 DeviantArt 作品批量下载工具 - Professional DeviantArt batch downloader with anti-ban features"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -63,6 +63,9 @@ da-dl = "da_downloader.cli:main"
63
63
  [tool.setuptools.packages.find]
64
64
  include = ["da_downloader*"]
65
65
 
66
+ [tool.setuptools.package-data]
67
+ da_downloader = ["*.mjs"]
68
+
66
69
  # Black configuration
67
70
  [tool.black]
68
71
  line-length = 100
@@ -58,7 +58,7 @@ class FakeClient:
58
58
 
59
59
 
60
60
  def test_artwork_original(monkeypatch) -> None:
61
- monkeypatch.setattr(provider_mod, "resolve_uuid", lambda identifier, username=None: "uuid-1")
61
+ monkeypatch.setattr(provider_mod, "deviation_init", lambda identifier, username=None, **_: {"deviation": {"extended": {"deviationUuid": "uuid-1", "additionalMedia": []}}})
62
62
  client = FakeClient()
63
63
  client.deviations["uuid-1"] = make_deviation()
64
64
  client.originals["uuid-1"] = OriginalDownload(
@@ -77,8 +77,35 @@ def test_artwork_original(monkeypatch) -> None:
77
77
  assert items[0].published_at == datetime(2015, 3, 30, 21, 21, 4, tzinfo=timezone.utc)
78
78
 
79
79
 
80
+ def test_artwork_multimedia_yields_every_file(monkeypatch) -> None:
81
+ init = {
82
+ "deviation": {
83
+ "extended": {
84
+ "deviationUuid": "uuid-1",
85
+ "additionalMedia": [
86
+ {"fileId": 2, "media": {"baseUri": "https://media.test/p2.jpg", "token": ["t2"]}},
87
+ {"fileId": 3, "media": {"baseUri": "https://media.test/p3.png", "token": ["t3"]}},
88
+ ],
89
+ }
90
+ }
91
+ }
92
+ monkeypatch.setattr(provider_mod, "deviation_init", lambda identifier, username=None, **_: init)
93
+ client = FakeClient()
94
+ client.deviations["uuid-1"] = make_deviation()
95
+ provider = OfficialProvider(client, quality="f")
96
+
97
+ items = list(provider.resolve(TargetParser.parse("https://www.deviantart.com/alice/art/x-123456")))
98
+
99
+ assert len(items) == 3 # 主图 + 两张附加
100
+ assert items[0].media_url == "https://images.test/full.jpg"
101
+ assert items[1].media_url == "https://media.test/p2.jpg?token=t2"
102
+ assert items[1].artwork_id == "uuid-1-1"
103
+ assert items[2].media_url == "https://media.test/p3.png?token=t3"
104
+ assert items[2].extension == "png"
105
+
106
+
80
107
  def test_artwork_best_uses_content(monkeypatch) -> None:
81
- monkeypatch.setattr(provider_mod, "resolve_uuid", lambda identifier, username=None: "uuid-1")
108
+ monkeypatch.setattr(provider_mod, "deviation_init", lambda identifier, username=None, **_: {"deviation": {"extended": {"deviationUuid": "uuid-1", "additionalMedia": []}}})
82
109
  client = FakeClient()
83
110
  client.deviations["uuid-1"] = make_deviation()
84
111
  provider = OfficialProvider(client, quality="f")
@@ -111,7 +138,7 @@ def test_gallery_paginates(monkeypatch) -> None:
111
138
 
112
139
 
113
140
  def test_no_media_raises(monkeypatch) -> None:
114
- monkeypatch.setattr(provider_mod, "resolve_uuid", lambda identifier, username=None: "uuid-1")
141
+ monkeypatch.setattr(provider_mod, "deviation_init", lambda identifier, username=None, **_: {"deviation": {"extended": {"deviationUuid": "uuid-1", "additionalMedia": []}}})
115
142
  client = FakeClient()
116
143
  client.deviations["uuid-1"] = {
117
144
  "deviationid": "uuid-1",
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
File without changes