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.
- {devart_dl-4.0.2 → devart_dl-4.1.0}/MANIFEST.in +1 -0
- {devart_dl-4.0.2/devart_dl.egg-info → devart_dl-4.1.0}/PKG-INFO +1 -1
- {devart_dl-4.0.2 → devart_dl-4.1.0}/da_downloader/cli.py +34 -2
- devart_dl-4.1.0/da_downloader/da_cookie_login.mjs +137 -0
- {devart_dl-4.0.2 → devart_dl-4.1.0}/da_downloader/official_api.py +92 -1
- {devart_dl-4.0.2 → devart_dl-4.1.0}/da_downloader/provider.py +32 -4
- {devart_dl-4.0.2 → devart_dl-4.1.0/devart_dl.egg-info}/PKG-INFO +1 -1
- {devart_dl-4.0.2 → devart_dl-4.1.0}/devart_dl.egg-info/SOURCES.txt +1 -0
- {devart_dl-4.0.2 → devart_dl-4.1.0}/pyproject.toml +4 -1
- {devart_dl-4.0.2 → devart_dl-4.1.0}/tests/test_official_provider.py +30 -3
- {devart_dl-4.0.2 → devart_dl-4.1.0}/.env.example +0 -0
- {devart_dl-4.0.2 → devart_dl-4.1.0}/LICENSE +0 -0
- {devart_dl-4.0.2 → devart_dl-4.1.0}/README.md +0 -0
- {devart_dl-4.0.2 → devart_dl-4.1.0}/README_EN.md +0 -0
- {devart_dl-4.0.2 → devart_dl-4.1.0}/bin/devart-dl +0 -0
- {devart_dl-4.0.2 → devart_dl-4.1.0}/bin/install.sh +0 -0
- {devart_dl-4.0.2 → devart_dl-4.1.0}/da_downloader/__init__.py +0 -0
- {devart_dl-4.0.2 → devart_dl-4.1.0}/da_downloader/api.py +0 -0
- {devart_dl-4.0.2 → devart_dl-4.1.0}/da_downloader/archive.py +0 -0
- {devart_dl-4.0.2 → devart_dl-4.1.0}/da_downloader/auth.py +0 -0
- {devart_dl-4.0.2 → devart_dl-4.1.0}/da_downloader/download.py +0 -0
- {devart_dl-4.0.2 → devart_dl-4.1.0}/da_downloader/errors.py +0 -0
- {devart_dl-4.0.2 → devart_dl-4.1.0}/da_downloader/http.py +0 -0
- {devart_dl-4.0.2 → devart_dl-4.1.0}/da_downloader/manager.py +0 -0
- {devart_dl-4.0.2 → devart_dl-4.1.0}/da_downloader/models.py +0 -0
- {devart_dl-4.0.2 → devart_dl-4.1.0}/da_downloader/oauth.py +0 -0
- {devart_dl-4.0.2 → devart_dl-4.1.0}/da_downloader/path.py +0 -0
- {devart_dl-4.0.2 → devart_dl-4.1.0}/da_downloader/targets.py +0 -0
- {devart_dl-4.0.2 → devart_dl-4.1.0}/devart_dl.egg-info/dependency_links.txt +0 -0
- {devart_dl-4.0.2 → devart_dl-4.1.0}/devart_dl.egg-info/entry_points.txt +0 -0
- {devart_dl-4.0.2 → devart_dl-4.1.0}/devart_dl.egg-info/requires.txt +0 -0
- {devart_dl-4.0.2 → devart_dl-4.1.0}/devart_dl.egg-info/top_level.txt +0 -0
- {devart_dl-4.0.2 → devart_dl-4.1.0}/requirements.txt +0 -0
- {devart_dl-4.0.2 → devart_dl-4.1.0}/setup.cfg +0 -0
- {devart_dl-4.0.2 → devart_dl-4.1.0}/setup.py +0 -0
- {devart_dl-4.0.2 → devart_dl-4.1.0}/tests/test_api.py +0 -0
- {devart_dl-4.0.2 → devart_dl-4.1.0}/tests/test_archive.py +0 -0
- {devart_dl-4.0.2 → devart_dl-4.1.0}/tests/test_cli.py +0 -0
- {devart_dl-4.0.2 → devart_dl-4.1.0}/tests/test_download.py +0 -0
- {devart_dl-4.0.2 → devart_dl-4.1.0}/tests/test_http_downloader.py +0 -0
- {devart_dl-4.0.2 → devart_dl-4.1.0}/tests/test_manager.py +0 -0
- {devart_dl-4.0.2 → devart_dl-4.1.0}/tests/test_path.py +0 -0
- {devart_dl-4.0.2 → devart_dl-4.1.0}/tests/test_provider.py +0 -0
- {devart_dl-4.0.2 → devart_dl-4.1.0}/tests/test_targets.py +0 -0
|
@@ -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
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
195
|
-
return
|
|
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
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "devart-dl"
|
|
7
|
-
version = "4.0
|
|
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, "
|
|
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, "
|
|
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, "
|
|
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
|
|
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
|
|
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
|