devart-dl 4.2.0__tar.gz → 4.3.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.2.0/devart_dl.egg-info → devart_dl-4.3.0}/PKG-INFO +5 -1
- {devart_dl-4.2.0 → devart_dl-4.3.0}/README.en.md +8 -1
- {devart_dl-4.2.0 → devart_dl-4.3.0}/README.md +4 -0
- {devart_dl-4.2.0 → devart_dl-4.3.0}/da_downloader/__init__.py +4 -2
- {devart_dl-4.2.0 → devart_dl-4.3.0}/da_downloader/cli.py +37 -1
- {devart_dl-4.2.0 → devart_dl-4.3.0}/da_downloader/download.py +68 -12
- {devart_dl-4.2.0 → devart_dl-4.3.0}/da_downloader/models.py +97 -0
- {devart_dl-4.2.0 → devart_dl-4.3.0}/da_downloader/provider.py +52 -1
- {devart_dl-4.2.0 → devart_dl-4.3.0/devart_dl.egg-info}/PKG-INFO +5 -1
- {devart_dl-4.2.0 → devart_dl-4.3.0}/devart_dl.egg-info/SOURCES.txt +1 -0
- {devart_dl-4.2.0 → devart_dl-4.3.0}/pyproject.toml +1 -1
- {devart_dl-4.2.0 → devart_dl-4.3.0}/tests/test_cli.py +26 -0
- devart_dl-4.3.0/tests/test_conformance.py +27 -0
- {devart_dl-4.2.0 → devart_dl-4.3.0}/tests/test_download.py +76 -0
- {devart_dl-4.2.0 → devart_dl-4.3.0}/tests/test_provider.py +88 -2
- {devart_dl-4.2.0 → devart_dl-4.3.0}/.env.example +0 -0
- {devart_dl-4.2.0 → devart_dl-4.3.0}/LICENSE +0 -0
- {devart_dl-4.2.0 → devart_dl-4.3.0}/MANIFEST.in +0 -0
- {devart_dl-4.2.0 → devart_dl-4.3.0}/bin/devart-dl +0 -0
- {devart_dl-4.2.0 → devart_dl-4.3.0}/bin/install.sh +0 -0
- {devart_dl-4.2.0 → devart_dl-4.3.0}/da_downloader/api.py +0 -0
- {devart_dl-4.2.0 → devart_dl-4.3.0}/da_downloader/archive.py +0 -0
- {devart_dl-4.2.0 → devart_dl-4.3.0}/da_downloader/auth.py +0 -0
- {devart_dl-4.2.0 → devart_dl-4.3.0}/da_downloader/da_cookie_login.mjs +0 -0
- {devart_dl-4.2.0 → devart_dl-4.3.0}/da_downloader/errors.py +0 -0
- {devart_dl-4.2.0 → devart_dl-4.3.0}/da_downloader/http.py +0 -0
- {devart_dl-4.2.0 → devart_dl-4.3.0}/da_downloader/manager.py +0 -0
- {devart_dl-4.2.0 → devart_dl-4.3.0}/da_downloader/oauth.py +0 -0
- {devart_dl-4.2.0 → devart_dl-4.3.0}/da_downloader/official_api.py +0 -0
- {devart_dl-4.2.0 → devart_dl-4.3.0}/da_downloader/path.py +0 -0
- {devart_dl-4.2.0 → devart_dl-4.3.0}/da_downloader/targets.py +0 -0
- {devart_dl-4.2.0 → devart_dl-4.3.0}/devart_dl.egg-info/dependency_links.txt +0 -0
- {devart_dl-4.2.0 → devart_dl-4.3.0}/devart_dl.egg-info/entry_points.txt +0 -0
- {devart_dl-4.2.0 → devart_dl-4.3.0}/devart_dl.egg-info/requires.txt +0 -0
- {devart_dl-4.2.0 → devart_dl-4.3.0}/devart_dl.egg-info/top_level.txt +0 -0
- {devart_dl-4.2.0 → devart_dl-4.3.0}/requirements.txt +0 -0
- {devart_dl-4.2.0 → devart_dl-4.3.0}/setup.cfg +0 -0
- {devart_dl-4.2.0 → devart_dl-4.3.0}/setup.py +0 -0
- {devart_dl-4.2.0 → devart_dl-4.3.0}/tests/test_api.py +0 -0
- {devart_dl-4.2.0 → devart_dl-4.3.0}/tests/test_archive.py +0 -0
- {devart_dl-4.2.0 → devart_dl-4.3.0}/tests/test_http_downloader.py +0 -0
- {devart_dl-4.2.0 → devart_dl-4.3.0}/tests/test_manager.py +0 -0
- {devart_dl-4.2.0 → devart_dl-4.3.0}/tests/test_official_provider.py +0 -0
- {devart_dl-4.2.0 → devart_dl-4.3.0}/tests/test_path.py +0 -0
- {devart_dl-4.2.0 → devart_dl-4.3.0}/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
|
+
Version: 4.3.0
|
|
4
4
|
Summary: 专业的 DeviantArt 作品批量下载工具 - Professional DeviantArt batch downloader with anti-ban features
|
|
5
5
|
Author: DeviantArt Downloader Team
|
|
6
6
|
License-Expression: MIT
|
|
@@ -61,6 +61,9 @@ devart-dl https://www.deviantart.com/username/gallery
|
|
|
61
61
|
# 收藏夹 / 标签
|
|
62
62
|
devart-dl https://www.deviantart.com/username/favourites
|
|
63
63
|
devart-dl https://www.deviantart.com/tag/landscape
|
|
64
|
+
|
|
65
|
+
# 解析为 JSON 描述(不下载文件)
|
|
66
|
+
devart-dl resolve https://www.deviantart.com/username/art/title-123456
|
|
64
67
|
```
|
|
65
68
|
|
|
66
69
|
首次下载前建议使用 OAuth 登录:
|
|
@@ -88,6 +91,7 @@ devart-dl login browser # 打开你的 Chrome 登录,自动保存 Cookie,
|
|
|
88
91
|
- **下载档案**:`--archive` 用 SQLite 记住已下载,跨会话自动跳过。
|
|
89
92
|
- **路径模板 + 元数据 sidecar**:`--directory`/`--filename` 模板与 `--write-info-json`。
|
|
90
93
|
- **代理与降级**:支持 `--proxy`;未登录 OAuth 时自动回退 Cookie。
|
|
94
|
+
- **Resolve 模式**:`devart-dl resolve URL` 输出 `ResolvedDeviation`/`MediaAsset` 的 JSON 契约,不写文件。
|
|
91
95
|
|
|
92
96
|
完整的安装、登录认证、下载模式、配置参数、命令速查、Python 调用与常见问题,请看**文档站点**:
|
|
93
97
|
|
|
@@ -50,6 +50,9 @@ devart-dl https://www.deviantart.com/username/favourites
|
|
|
50
50
|
|
|
51
51
|
# Tag
|
|
52
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
|
|
53
56
|
```
|
|
54
57
|
|
|
55
58
|
### Login (OAuth recommended)
|
|
@@ -66,6 +69,10 @@ Register a **Public** OAuth app at [deviantart.com/developers](https://www.devia
|
|
|
66
69
|
|
|
67
70
|
## Main Features
|
|
68
71
|
|
|
72
|
+
### Resolve Mode
|
|
73
|
+
|
|
74
|
+
`devart-dl resolve URL` emits a machine-consumable `ResolvedDeviation` + `MediaAsset[]` JSON contract without writing any file.
|
|
75
|
+
|
|
69
76
|
### Download Modes
|
|
70
77
|
|
|
71
78
|
URL-first is the simplest entry; explicit subcommands are also kept:
|
|
@@ -86,7 +93,7 @@ devart-dl search all "digital art"
|
|
|
86
93
|
devart-dl fav username folder_id
|
|
87
94
|
```
|
|
88
95
|
|
|
89
|
-
> Note: explicit subcommands keep the old usage for compatibility. The official API has no search endpoint, so `search` uses the cookie
|
|
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".
|
|
90
97
|
|
|
91
98
|
### Authentication
|
|
92
99
|
|
|
@@ -27,6 +27,9 @@ devart-dl https://www.deviantart.com/username/gallery
|
|
|
27
27
|
# 收藏夹 / 标签
|
|
28
28
|
devart-dl https://www.deviantart.com/username/favourites
|
|
29
29
|
devart-dl https://www.deviantart.com/tag/landscape
|
|
30
|
+
|
|
31
|
+
# 解析为 JSON 描述(不下载文件)
|
|
32
|
+
devart-dl resolve https://www.deviantart.com/username/art/title-123456
|
|
30
33
|
```
|
|
31
34
|
|
|
32
35
|
首次下载前建议使用 OAuth 登录:
|
|
@@ -54,6 +57,7 @@ devart-dl login browser # 打开你的 Chrome 登录,自动保存 Cookie,
|
|
|
54
57
|
- **下载档案**:`--archive` 用 SQLite 记住已下载,跨会话自动跳过。
|
|
55
58
|
- **路径模板 + 元数据 sidecar**:`--directory`/`--filename` 模板与 `--write-info-json`。
|
|
56
59
|
- **代理与降级**:支持 `--proxy`;未登录 OAuth 时自动回退 Cookie。
|
|
60
|
+
- **Resolve 模式**:`devart-dl resolve URL` 输出 `ResolvedDeviation`/`MediaAsset` 的 JSON 契约,不写文件。
|
|
57
61
|
|
|
58
62
|
完整的安装、登录认证、下载模式、配置参数、命令速查、Python 调用与常见问题,请看**文档站点**:
|
|
59
63
|
|
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
"""DeviantArt Downloader — a reliable, focused downloader and archival CLI."""
|
|
2
2
|
|
|
3
|
-
__version__ = "4.0
|
|
3
|
+
__version__ = "4.3.0"
|
|
4
4
|
__author__ = "DeviantArt Downloader Team"
|
|
5
5
|
|
|
6
6
|
from .download import Downloader, build_downloader, normalize_quality
|
|
7
7
|
from .errors import DeviantArtError
|
|
8
|
-
from .models import DownloadItem
|
|
8
|
+
from .models import DownloadItem, MediaAsset, ResolvedDeviation
|
|
9
9
|
|
|
10
10
|
__all__ = [
|
|
11
11
|
"Downloader",
|
|
12
12
|
"build_downloader",
|
|
13
13
|
"normalize_quality",
|
|
14
14
|
"DownloadItem",
|
|
15
|
+
"MediaAsset",
|
|
16
|
+
"ResolvedDeviation",
|
|
15
17
|
"DeviantArtError",
|
|
16
18
|
]
|
|
@@ -37,6 +37,7 @@ _HELP = """DeviantArt Downloader
|
|
|
37
37
|
|
|
38
38
|
Usage:
|
|
39
39
|
devart-dl URL [options] # URL-first: artwork/gallery/favourites/tag
|
|
40
|
+
devart-dl resolve URL [options] # resolve to work/media JSON, download nothing
|
|
40
41
|
devart-dl url URL [options]
|
|
41
42
|
devart-dl artist USERNAME [options]
|
|
42
43
|
devart-dl gallery USERNAME [GALLERY_ID] [options]
|
|
@@ -62,7 +63,7 @@ Run `devart-dl login oauth --help` for OAuth details.
|
|
|
62
63
|
"""
|
|
63
64
|
|
|
64
65
|
_KNOWN_COMMANDS = frozenset(
|
|
65
|
-
{"help", "url", "artist", "gallery", "search", "fav", "login", "whoami", "logout"}
|
|
66
|
+
{"help", "url", "artist", "gallery", "search", "fav", "resolve", "login", "whoami", "logout"}
|
|
66
67
|
)
|
|
67
68
|
_REMOVED_COMMANDS = frozenset({"anti-ban", "test"})
|
|
68
69
|
_UUID_RE = re.compile(
|
|
@@ -199,6 +200,39 @@ def _run_download(arguments: Sequence[str]) -> int:
|
|
|
199
200
|
)
|
|
200
201
|
|
|
201
202
|
|
|
203
|
+
|
|
204
|
+
def _run_resolve(arguments: Sequence[str]) -> int:
|
|
205
|
+
parser = argparse.ArgumentParser(
|
|
206
|
+
prog="devart-dl resolve",
|
|
207
|
+
description="Resolve a DeviantArt target to work/media JSON (download nothing)",
|
|
208
|
+
)
|
|
209
|
+
parser.add_argument("target", help="DeviantArt URL or artwork id")
|
|
210
|
+
parser.add_argument("--cookies", default=None)
|
|
211
|
+
parser.add_argument("--quality", default="best")
|
|
212
|
+
parser.add_argument("--proxy", default=None)
|
|
213
|
+
parser.add_argument("--timeout", type=float, default=60.0)
|
|
214
|
+
parser.add_argument("--retries", type=int, default=3)
|
|
215
|
+
parser.add_argument("--limit", type=int, default=24)
|
|
216
|
+
args = parser.parse_args(list(arguments))
|
|
217
|
+
try:
|
|
218
|
+
downloader = build_downloader(
|
|
219
|
+
destination=Path("."),
|
|
220
|
+
cookies=_load_cookies(args.cookies),
|
|
221
|
+
quality=args.quality,
|
|
222
|
+
proxy=args.proxy,
|
|
223
|
+
timeout=args.timeout,
|
|
224
|
+
retries=args.retries,
|
|
225
|
+
limit=args.limit,
|
|
226
|
+
)
|
|
227
|
+
works = downloader.resolve(args.target)
|
|
228
|
+
except (ValueError, DeviantArtError) as exc:
|
|
229
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
230
|
+
return 1
|
|
231
|
+
payload = works[0].to_dict() if len(works) == 1 else {"works": [w.to_dict() for w in works]}
|
|
232
|
+
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
233
|
+
return 0
|
|
234
|
+
|
|
235
|
+
|
|
202
236
|
def _run_subcommand(command: str, rest: Sequence[str]) -> int:
|
|
203
237
|
positionals: list[str] = []
|
|
204
238
|
options: dict[str, str] = {}
|
|
@@ -406,6 +440,8 @@ def main(argv: Sequence[str] | None = None) -> int:
|
|
|
406
440
|
return 0
|
|
407
441
|
if command in {"url", "artist", "gallery", "search", "fav"}:
|
|
408
442
|
return _run_subcommand(command, rest)
|
|
443
|
+
if command == "resolve":
|
|
444
|
+
return _run_resolve(rest)
|
|
409
445
|
if command == "login":
|
|
410
446
|
if not rest or rest[0] in {"-h", "--help", "help"}:
|
|
411
447
|
print("Usage: devart-dl login oauth|browser|interactive|clear")
|
|
@@ -15,10 +15,16 @@ from .api import DeviantArtAPI
|
|
|
15
15
|
from .archive import DownloadArchive
|
|
16
16
|
from .http import HttpDownloader
|
|
17
17
|
from .manager import DownloadManager, DownloadOutcome
|
|
18
|
+
from .models import ResolvedDeviation, make_media_asset
|
|
18
19
|
from .oauth import OAuthSession
|
|
19
20
|
from .official_api import OfficialApiClient
|
|
20
21
|
from .path import PathFormatter
|
|
21
|
-
from .provider import
|
|
22
|
+
from .provider import (
|
|
23
|
+
CompositeProvider,
|
|
24
|
+
DeviantArtProvider,
|
|
25
|
+
OfficialProvider,
|
|
26
|
+
WebProvider,
|
|
27
|
+
)
|
|
22
28
|
from .targets import DownloadTarget, TargetParser
|
|
23
29
|
|
|
24
30
|
# User-facing quality names (new) and the legacy o/f/p shorthand.
|
|
@@ -50,6 +56,50 @@ class Downloader:
|
|
|
50
56
|
parsed = TargetParser.parse(target) if isinstance(target, str) else target
|
|
51
57
|
return [self.manager.run(item) for item in self.provider.resolve(parsed)]
|
|
52
58
|
|
|
59
|
+
def resolve(self, target: str | DownloadTarget) -> list[ResolvedDeviation]:
|
|
60
|
+
"""Resolve a target to DeviantArt works and media assets (no download)."""
|
|
61
|
+
parsed = TargetParser.parse(target) if isinstance(target, str) else target
|
|
62
|
+
groups: dict[str, dict] = {}
|
|
63
|
+
order: list[str] = []
|
|
64
|
+
for item in self.provider.resolve(parsed):
|
|
65
|
+
extra = item.metadata.get("index") if item.metadata else None
|
|
66
|
+
base = str(item.artwork_id)
|
|
67
|
+
if extra is not None:
|
|
68
|
+
suffix = f"-{extra}"
|
|
69
|
+
if base.endswith(suffix):
|
|
70
|
+
base = base[: -len(suffix)]
|
|
71
|
+
if base not in groups:
|
|
72
|
+
groups[base] = {
|
|
73
|
+
"title": item.title,
|
|
74
|
+
"author": item.author,
|
|
75
|
+
"mature": item.mature,
|
|
76
|
+
"url": item.url,
|
|
77
|
+
"assets": [],
|
|
78
|
+
}
|
|
79
|
+
order.append(base)
|
|
80
|
+
index = int(extra) if extra is not None else len(groups[base]["assets"])
|
|
81
|
+
groups[base]["assets"].append(
|
|
82
|
+
make_media_asset(
|
|
83
|
+
base,
|
|
84
|
+
index,
|
|
85
|
+
item.media_url,
|
|
86
|
+
extension=item.extension,
|
|
87
|
+
mature=item.mature,
|
|
88
|
+
)
|
|
89
|
+
)
|
|
90
|
+
return [
|
|
91
|
+
ResolvedDeviation(
|
|
92
|
+
id=key,
|
|
93
|
+
title=groups[key]["title"],
|
|
94
|
+
author=groups[key]["author"],
|
|
95
|
+
mature=groups[key]["mature"],
|
|
96
|
+
url=groups[key]["url"],
|
|
97
|
+
media=tuple(groups[key]["assets"]),
|
|
98
|
+
)
|
|
99
|
+
for key in order
|
|
100
|
+
]
|
|
101
|
+
|
|
102
|
+
|
|
53
103
|
|
|
54
104
|
def build_downloader(
|
|
55
105
|
*,
|
|
@@ -70,23 +120,20 @@ def build_downloader(
|
|
|
70
120
|
) -> Downloader:
|
|
71
121
|
"""Assemble a wired ``Downloader`` from plain configuration values.
|
|
72
122
|
|
|
73
|
-
|
|
74
|
-
|
|
123
|
+
OAuth and cookies are capabilities, not a global provider mode: the
|
|
124
|
+
official API is preferred where it works and the web session fills the
|
|
125
|
+
capabilities it cannot cover (notably search).
|
|
75
126
|
"""
|
|
76
127
|
normalized_quality = normalize_quality(quality)
|
|
77
128
|
proxies = {"http": proxy, "https": proxy} if proxy else {}
|
|
78
129
|
|
|
79
130
|
oauth_session = OAuthSession.from_store()
|
|
131
|
+
official = web = None
|
|
132
|
+
api = None
|
|
80
133
|
if oauth_session is not None:
|
|
81
134
|
client = OfficialApiClient(oauth_session, timeout=timeout)
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
)
|
|
85
|
-
# Official media URLs are signed CDN links and need no cookies.
|
|
86
|
-
http_session = requests.Session()
|
|
87
|
-
if proxies:
|
|
88
|
-
http_session.proxies.update(proxies)
|
|
89
|
-
else:
|
|
135
|
+
official = OfficialProvider(client, quality=normalized_quality, limit=limit)
|
|
136
|
+
if cookies or oauth_session is None:
|
|
90
137
|
headers = {
|
|
91
138
|
"accept": "application/json, text/plain, */*",
|
|
92
139
|
"user-agent": (
|
|
@@ -99,8 +146,17 @@ def build_downloader(
|
|
|
99
146
|
api = DeviantArtAPI(
|
|
100
147
|
headers, proxies, retry_delay=int(retry_delay), max_retries=retries
|
|
101
148
|
)
|
|
102
|
-
|
|
149
|
+
web = WebProvider(api, quality=normalized_quality, limit=limit)
|
|
150
|
+
|
|
151
|
+
provider: DeviantArtProvider = CompositeProvider(official, web)
|
|
152
|
+
# The web session's cookies are harmless on signed official CDN links and
|
|
153
|
+
# required for web-sourced media (search/gallery fallback), so prefer it.
|
|
154
|
+
if api is not None:
|
|
103
155
|
http_session = api.session
|
|
156
|
+
else:
|
|
157
|
+
http_session = requests.Session()
|
|
158
|
+
if proxies:
|
|
159
|
+
http_session.proxies.update(proxies)
|
|
104
160
|
|
|
105
161
|
http = HttpDownloader(
|
|
106
162
|
session=http_session,
|
|
@@ -113,3 +113,100 @@ class DownloadItem:
|
|
|
113
113
|
published_at: Optional[datetime] = None
|
|
114
114
|
mature: bool = False
|
|
115
115
|
metadata: Mapping[str, Any] = field(default_factory=dict)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
_MIME_BY_EXT = {
|
|
119
|
+
"jpg": "image/jpeg",
|
|
120
|
+
"jpeg": "image/jpeg",
|
|
121
|
+
"png": "image/png",
|
|
122
|
+
"gif": "image/gif",
|
|
123
|
+
"webp": "image/webp",
|
|
124
|
+
"bmp": "image/bmp",
|
|
125
|
+
"svg": "image/svg+xml",
|
|
126
|
+
"mp4": "video/mp4",
|
|
127
|
+
"webm": "video/webm",
|
|
128
|
+
"mov": "video/quicktime",
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
_VIDEO_EXTS = frozenset({"mp4", "webm", "mov", "m4v"})
|
|
132
|
+
|
|
133
|
+
_DEFAULT_MIME = {"image": "image/jpeg", "video": "video/mp4"}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@dataclass(frozen=True)
|
|
137
|
+
class MediaAsset:
|
|
138
|
+
"""One resolute media file of a DeviantArt work (``deviantart:<uuid>:p<n>``)."""
|
|
139
|
+
|
|
140
|
+
id: str
|
|
141
|
+
deviation_id: str
|
|
142
|
+
index: int
|
|
143
|
+
kind: str # "image" | "video"
|
|
144
|
+
source_url: str
|
|
145
|
+
extension: str | None = None
|
|
146
|
+
mime_type: str | None = None
|
|
147
|
+
width: int | None = None
|
|
148
|
+
height: int | None = None
|
|
149
|
+
mature: bool = False
|
|
150
|
+
|
|
151
|
+
def to_dict(self) -> dict:
|
|
152
|
+
data: dict = {
|
|
153
|
+
"id": self.id,
|
|
154
|
+
"kind": self.kind,
|
|
155
|
+
"sourceUrl": self.source_url,
|
|
156
|
+
}
|
|
157
|
+
for key, value in (
|
|
158
|
+
("extension", self.extension),
|
|
159
|
+
("mimeType", self.mime_type),
|
|
160
|
+
("width", self.width),
|
|
161
|
+
("height", self.height),
|
|
162
|
+
("mature", self.mature),
|
|
163
|
+
):
|
|
164
|
+
if value is not None:
|
|
165
|
+
data[key] = value
|
|
166
|
+
return data
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@dataclass(frozen=True)
|
|
170
|
+
class ResolvedDeviation:
|
|
171
|
+
"""A DeviantArt work plus its media assets, independent of downloading."""
|
|
172
|
+
|
|
173
|
+
id: str
|
|
174
|
+
title: str
|
|
175
|
+
author: str
|
|
176
|
+
mature: bool
|
|
177
|
+
media: tuple[MediaAsset, ...] = ()
|
|
178
|
+
url: str = ""
|
|
179
|
+
|
|
180
|
+
def to_dict(self) -> dict:
|
|
181
|
+
return {
|
|
182
|
+
"work": {
|
|
183
|
+
"id": self.id,
|
|
184
|
+
"title": self.title,
|
|
185
|
+
"author": self.author,
|
|
186
|
+
"url": self.url,
|
|
187
|
+
"mature": self.mature,
|
|
188
|
+
},
|
|
189
|
+
"media": [asset.to_dict() for asset in self.media],
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def make_media_asset(
|
|
194
|
+
deviation_id: str,
|
|
195
|
+
index: int,
|
|
196
|
+
source_url: str,
|
|
197
|
+
*,
|
|
198
|
+
extension: str | None = None,
|
|
199
|
+
mature: bool = False,
|
|
200
|
+
) -> MediaAsset:
|
|
201
|
+
ext = (extension or "").lstrip(".").lower() or None
|
|
202
|
+
kind = "video" if ext in _VIDEO_EXTS else "image"
|
|
203
|
+
return MediaAsset(
|
|
204
|
+
id=f"deviantart:{deviation_id}:p{index}",
|
|
205
|
+
deviation_id=deviation_id,
|
|
206
|
+
index=index,
|
|
207
|
+
kind=kind,
|
|
208
|
+
source_url=source_url,
|
|
209
|
+
extension=ext,
|
|
210
|
+
mime_type=_MIME_BY_EXT.get(ext) or _DEFAULT_MIME[kind],
|
|
211
|
+
mature=mature,
|
|
212
|
+
)
|
|
@@ -297,4 +297,55 @@ def _parse_published(value: Any) -> Optional[datetime]:
|
|
|
297
297
|
return datetime.fromtimestamp(timestamp, tz=timezone.utc)
|
|
298
298
|
|
|
299
299
|
|
|
300
|
-
|
|
300
|
+
class CompositeProvider:
|
|
301
|
+
"""Routes each target to the provider that can actually serve it.
|
|
302
|
+
|
|
303
|
+
OAuth and cookies are capabilities, not global provider modes: search
|
|
304
|
+
always uses the web session, artwork/tag use the official API, and
|
|
305
|
+
gallery/favorites prefer the official API with the web session as a
|
|
306
|
+
fallback when the official path is unavailable.
|
|
307
|
+
"""
|
|
308
|
+
|
|
309
|
+
def __init__(
|
|
310
|
+
self,
|
|
311
|
+
official: DeviantArtProvider | None = None,
|
|
312
|
+
web: DeviantArtProvider | None = None,
|
|
313
|
+
) -> None:
|
|
314
|
+
if official is None and web is None:
|
|
315
|
+
raise ValueError("at least one provider is required")
|
|
316
|
+
self.official = official
|
|
317
|
+
self.web = web
|
|
318
|
+
|
|
319
|
+
def resolve(self, target: DownloadTarget) -> Iterator[DownloadItem]:
|
|
320
|
+
kind = target.kind
|
|
321
|
+
if kind == TargetKind.SEARCH:
|
|
322
|
+
if self.web is None:
|
|
323
|
+
raise MediaUnavailableError(
|
|
324
|
+
"search requires a web/cookie session "
|
|
325
|
+
"(`devart-dl login browser`/`interactive` or --cookies)"
|
|
326
|
+
)
|
|
327
|
+
yield from self.web.resolve(target)
|
|
328
|
+
return
|
|
329
|
+
if kind in (TargetKind.ARTWORK, TargetKind.TAG):
|
|
330
|
+
if self.official is None:
|
|
331
|
+
raise MediaUnavailableError(
|
|
332
|
+
"this target requires the official API "
|
|
333
|
+
"(`devart-dl login oauth`)"
|
|
334
|
+
)
|
|
335
|
+
yield from self.official.resolve(target)
|
|
336
|
+
return
|
|
337
|
+
if self.official is not None:
|
|
338
|
+
try:
|
|
339
|
+
yield from self.official.resolve(target)
|
|
340
|
+
except MediaUnavailableError:
|
|
341
|
+
if self.web is not None:
|
|
342
|
+
yield from self.web.resolve(target)
|
|
343
|
+
return
|
|
344
|
+
raise
|
|
345
|
+
elif self.web is not None:
|
|
346
|
+
yield from self.web.resolve(target)
|
|
347
|
+
else:
|
|
348
|
+
raise MediaUnavailableError("no provider can resolve this target")
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
__all__ = ["CompositeProvider", "DeviantArtProvider", "OfficialProvider", "WebProvider"]
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: devart-dl
|
|
3
|
-
Version: 4.
|
|
3
|
+
Version: 4.3.0
|
|
4
4
|
Summary: 专业的 DeviantArt 作品批量下载工具 - Professional DeviantArt batch downloader with anti-ban features
|
|
5
5
|
Author: DeviantArt Downloader Team
|
|
6
6
|
License-Expression: MIT
|
|
@@ -61,6 +61,9 @@ devart-dl https://www.deviantart.com/username/gallery
|
|
|
61
61
|
# 收藏夹 / 标签
|
|
62
62
|
devart-dl https://www.deviantart.com/username/favourites
|
|
63
63
|
devart-dl https://www.deviantart.com/tag/landscape
|
|
64
|
+
|
|
65
|
+
# 解析为 JSON 描述(不下载文件)
|
|
66
|
+
devart-dl resolve https://www.deviantart.com/username/art/title-123456
|
|
64
67
|
```
|
|
65
68
|
|
|
66
69
|
首次下载前建议使用 OAuth 登录:
|
|
@@ -88,6 +91,7 @@ devart-dl login browser # 打开你的 Chrome 登录,自动保存 Cookie,
|
|
|
88
91
|
- **下载档案**:`--archive` 用 SQLite 记住已下载,跨会话自动跳过。
|
|
89
92
|
- **路径模板 + 元数据 sidecar**:`--directory`/`--filename` 模板与 `--write-info-json`。
|
|
90
93
|
- **代理与降级**:支持 `--proxy`;未登录 OAuth 时自动回退 Cookie。
|
|
94
|
+
- **Resolve 模式**:`devart-dl resolve URL` 输出 `ResolvedDeviation`/`MediaAsset` 的 JSON 契约,不写文件。
|
|
91
95
|
|
|
92
96
|
完整的安装、登录认证、下载模式、配置参数、命令速查、Python 调用与常见问题,请看**文档站点**:
|
|
93
97
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "devart-dl"
|
|
7
|
-
version = "4.
|
|
7
|
+
version = "4.3.0"
|
|
8
8
|
description = "专业的 DeviantArt 作品批量下载工具 - Professional DeviantArt batch downloader with anti-ban features"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -97,3 +97,29 @@ def test_run_download_dry_run_summary(monkeypatch, capsys, tmp_path) -> None:
|
|
|
97
97
|
assert rc == 0
|
|
98
98
|
assert "dry-run: would download 1, skip 1, fail 0" in capsys.readouterr().out
|
|
99
99
|
assert built["dry_run"] is True
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def test_resolve_command_outputs_single_work_json(monkeypatch, capsys) -> None:
|
|
103
|
+
from da_downloader.models import MediaAsset, ResolvedDeviation
|
|
104
|
+
|
|
105
|
+
class FakeResolver:
|
|
106
|
+
def resolve(self, target):
|
|
107
|
+
return [
|
|
108
|
+
ResolvedDeviation(
|
|
109
|
+
id="u1",
|
|
110
|
+
title="Art",
|
|
111
|
+
author="alice",
|
|
112
|
+
mature=False,
|
|
113
|
+
url="https://da.test/x-1",
|
|
114
|
+
media=(MediaAsset("deviantart:u1:p0", "u1", 0, "image", "https://img.test/1.jpg"),),
|
|
115
|
+
)
|
|
116
|
+
]
|
|
117
|
+
|
|
118
|
+
monkeypatch.setattr(cli, "build_downloader", lambda **kw: FakeResolver())
|
|
119
|
+
monkeypatch.setattr(cli, "_load_cookies", lambda p: "")
|
|
120
|
+
|
|
121
|
+
assert cli.main(["resolve", "https://www.deviantart.com/alice/art/x-123"]) == 0
|
|
122
|
+
out = capsys.readouterr().out
|
|
123
|
+
assert '"work"' in out
|
|
124
|
+
assert '"media"' in out
|
|
125
|
+
assert "deviantart:u1:p0" in out
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Cross-repo conformance: consume the shared sanitized DeviantArt fixture.
|
|
2
|
+
|
|
3
|
+
The same JSON is checked by the deviantart-downloader Python parser and the
|
|
4
|
+
DeviantDrop Node normalizer, so both sides keep agreeing on core source
|
|
5
|
+
semantics (uuid/title/author/mature) even as implementations diverge.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from da_downloader.models import Deviation
|
|
14
|
+
|
|
15
|
+
FIXTURE = Path(__file__).parent / "fixtures" / "deviantart_deviation.json"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_shared_fixture_parses_to_consistent_core() -> None:
|
|
19
|
+
data = json.loads(FIXTURE.read_text(encoding="utf-8"))
|
|
20
|
+
deviation = Deviation.from_api_response(data)
|
|
21
|
+
|
|
22
|
+
assert deviation.deviation_id == "913624585"
|
|
23
|
+
assert deviation.title == "Conformance Sample"
|
|
24
|
+
assert deviation.author == "conformance"
|
|
25
|
+
assert deviation.is_mature is False
|
|
26
|
+
assert deviation.is_downloadable is False
|
|
27
|
+
assert data["extended"]["deviationUuid"] == "11111111-2222-3333-4444-555555555555"
|
|
@@ -91,3 +91,79 @@ def test_downloader_parses_target(tmp_path: Path) -> None:
|
|
|
91
91
|
|
|
92
92
|
assert provider.targets[0].kind == TargetKind.GALLERY
|
|
93
93
|
assert provider.targets[0].username == "alice"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_build_downloader_wires_capability_router_with_oauth_and_cookies(
|
|
97
|
+
tmp_path: Path, monkeypatch
|
|
98
|
+
) -> None:
|
|
99
|
+
from da_downloader.download import build_downloader
|
|
100
|
+
from da_downloader.provider import CompositeProvider, OfficialProvider, WebProvider
|
|
101
|
+
|
|
102
|
+
class FakeSession:
|
|
103
|
+
def authorization_header(self, force=False):
|
|
104
|
+
return "Bearer x"
|
|
105
|
+
|
|
106
|
+
monkeypatch.setattr(
|
|
107
|
+
"da_downloader.download.OAuthSession.from_store", classmethod(lambda cls: FakeSession())
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
downloader = build_downloader(
|
|
111
|
+
destination=tmp_path, cookies="auth=token; auth_secure=secret"
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
router = downloader.provider
|
|
115
|
+
assert isinstance(router, CompositeProvider)
|
|
116
|
+
assert router.official is not None and isinstance(router.official, OfficialProvider)
|
|
117
|
+
assert router.web is not None and isinstance(router.web, WebProvider)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def test_build_downloader_without_oauth_uses_composite_web_only(
|
|
121
|
+
tmp_path: Path, monkeypatch
|
|
122
|
+
) -> None:
|
|
123
|
+
from da_downloader.download import build_downloader
|
|
124
|
+
from da_downloader.provider import CompositeProvider, WebProvider
|
|
125
|
+
|
|
126
|
+
monkeypatch.setattr(
|
|
127
|
+
"da_downloader.download.OAuthSession.from_store", classmethod(lambda cls: None)
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
downloader = build_downloader(destination=tmp_path)
|
|
131
|
+
|
|
132
|
+
router = downloader.provider
|
|
133
|
+
assert isinstance(router, CompositeProvider)
|
|
134
|
+
assert router.official is None
|
|
135
|
+
assert isinstance(router.web, WebProvider)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def test_resolve_groups_additional_media_into_one_work() -> None:
|
|
139
|
+
main = DownloadItem(
|
|
140
|
+
artwork_id="uuid-1",
|
|
141
|
+
url="https://www.deviantart.com/alice/art/x-1",
|
|
142
|
+
title="Art",
|
|
143
|
+
author="alice",
|
|
144
|
+
media_url="https://images.test/1.jpg",
|
|
145
|
+
extension="jpg",
|
|
146
|
+
mature=True,
|
|
147
|
+
)
|
|
148
|
+
extra = DownloadItem(
|
|
149
|
+
artwork_id="uuid-1-1",
|
|
150
|
+
url=main.url,
|
|
151
|
+
title=main.title,
|
|
152
|
+
author=main.author,
|
|
153
|
+
media_url="https://images.test/2.png",
|
|
154
|
+
extension="png",
|
|
155
|
+
metadata={"index": 1},
|
|
156
|
+
)
|
|
157
|
+
downloader = Downloader(FakeProvider([main, extra]), manager=None)
|
|
158
|
+
|
|
159
|
+
works = downloader.resolve("https://www.deviantart.com/alice/art/x-123")
|
|
160
|
+
|
|
161
|
+
assert len(works) == 1
|
|
162
|
+
work = works[0]
|
|
163
|
+
assert work.id == "uuid-1"
|
|
164
|
+
assert work.mature is True
|
|
165
|
+
assert [a.id for a in work.media] == ["deviantart:uuid-1:p0", "deviantart:uuid-1:p1"]
|
|
166
|
+
data = work.to_dict()
|
|
167
|
+
assert data["work"]["title"] == "Art"
|
|
168
|
+
assert data["media"][1]["sourceUrl"] == "https://images.test/2.png"
|
|
169
|
+
assert data["media"][1]["mimeType"] == "image/png"
|
|
@@ -6,8 +6,8 @@ import pytest
|
|
|
6
6
|
|
|
7
7
|
from da_downloader.api import ActionType
|
|
8
8
|
from da_downloader.errors import MediaUnavailableError
|
|
9
|
-
from da_downloader.models import Deviation
|
|
10
|
-
from da_downloader.provider import WebProvider
|
|
9
|
+
from da_downloader.models import Deviation, DownloadItem
|
|
10
|
+
from da_downloader.provider import CompositeProvider, WebProvider
|
|
11
11
|
from da_downloader.targets import TargetParser
|
|
12
12
|
|
|
13
13
|
|
|
@@ -124,3 +124,89 @@ def test_search_target_builds_search_action() -> None:
|
|
|
124
124
|
action, username, query, folder = provider.api.build_calls[0]
|
|
125
125
|
assert action == ActionType.SEARCH
|
|
126
126
|
assert query == "digital art"
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class RecordingProvider:
|
|
130
|
+
def __init__(self, items=None, error=None):
|
|
131
|
+
self.items = list(items or [])
|
|
132
|
+
self.error = error
|
|
133
|
+
self.calls = []
|
|
134
|
+
|
|
135
|
+
def resolve(self, target):
|
|
136
|
+
self.calls.append(target)
|
|
137
|
+
if self.error:
|
|
138
|
+
raise self.error
|
|
139
|
+
yield from self.items
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def test_composite_search_routes_to_web_even_with_official() -> None:
|
|
143
|
+
web = make_provider(
|
|
144
|
+
pages=[([make_deviation()], False, 1, "")],
|
|
145
|
+
media_urls={"123": "https://images.test/123.jpg"},
|
|
146
|
+
)
|
|
147
|
+
official = RecordingProvider(items=[])
|
|
148
|
+
router = CompositeProvider(official=official, web=web)
|
|
149
|
+
|
|
150
|
+
items = list(router.resolve(TargetParser.parse("https://www.deviantart.com/search?q=cat")))
|
|
151
|
+
|
|
152
|
+
assert len(items) == 1
|
|
153
|
+
assert items[0].artwork_id == "123"
|
|
154
|
+
assert official.calls == []
|
|
155
|
+
assert len(web.api.build_calls) == 1
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def test_composite_artwork_routes_to_official() -> None:
|
|
159
|
+
official = RecordingProvider(items=[DownloadItem("u1", "https://da.test/x-1", "A", "alice", "https://img.test/1.jpg")])
|
|
160
|
+
web = RecordingProvider(items=[])
|
|
161
|
+
router = CompositeProvider(official=official, web=web)
|
|
162
|
+
|
|
163
|
+
list(router.resolve(TargetParser.parse("https://www.deviantart.com/alice/art/x-123456")))
|
|
164
|
+
|
|
165
|
+
assert len(official.calls) == 1
|
|
166
|
+
assert web.calls == []
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def test_composite_gallery_prefers_official() -> None:
|
|
170
|
+
official = RecordingProvider(items=[DownloadItem("u1", "https://da.test/1", "A", "alice", "https://img.test/1.jpg")])
|
|
171
|
+
web = RecordingProvider(items=[])
|
|
172
|
+
router = CompositeProvider(official=official, web=web)
|
|
173
|
+
|
|
174
|
+
items = list(router.resolve(TargetParser.parse("https://www.deviantart.com/alice/gallery")))
|
|
175
|
+
|
|
176
|
+
assert len(items) == 1
|
|
177
|
+
assert len(official.calls) == 1
|
|
178
|
+
assert web.calls == []
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def test_composite_gallery_falls_back_to_web_when_official_unavailable() -> None:
|
|
182
|
+
web = make_provider(
|
|
183
|
+
pages=[([make_deviation()], False, 1, "")],
|
|
184
|
+
media_urls={"123": "https://images.test/123.jpg"},
|
|
185
|
+
)
|
|
186
|
+
official = RecordingProvider(error=MediaUnavailableError("nope"))
|
|
187
|
+
router = CompositeProvider(official=official, web=web)
|
|
188
|
+
|
|
189
|
+
items = list(router.resolve(TargetParser.parse("https://www.deviantart.com/alice/gallery")))
|
|
190
|
+
|
|
191
|
+
assert items[0].artwork_id == "123"
|
|
192
|
+
assert len(official.calls) == 1
|
|
193
|
+
assert len(web.api.build_calls) == 1
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def test_composite_gallery_with_only_web_uses_web() -> None:
|
|
197
|
+
web = make_provider(
|
|
198
|
+
pages=[([make_deviation()], False, 1, "")],
|
|
199
|
+
media_urls={"123": "https://images.test/123.jpg"},
|
|
200
|
+
)
|
|
201
|
+
router = CompositeProvider(official=None, web=web)
|
|
202
|
+
|
|
203
|
+
items = list(router.resolve(TargetParser.parse("https://www.deviantart.com/alice/gallery")))
|
|
204
|
+
|
|
205
|
+
assert items[0].artwork_id == "123"
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def test_composite_tag_without_official_requires_official() -> None:
|
|
209
|
+
web = RecordingProvider(items=[])
|
|
210
|
+
router = CompositeProvider(official=None, web=web)
|
|
211
|
+
with pytest.raises(MediaUnavailableError):
|
|
212
|
+
list(router.resolve(TargetParser.parse("https://www.deviantart.com/tag/landscape")))
|
|
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
|