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.
Files changed (45) hide show
  1. {devart_dl-4.2.0/devart_dl.egg-info → devart_dl-4.3.0}/PKG-INFO +5 -1
  2. {devart_dl-4.2.0 → devart_dl-4.3.0}/README.en.md +8 -1
  3. {devart_dl-4.2.0 → devart_dl-4.3.0}/README.md +4 -0
  4. {devart_dl-4.2.0 → devart_dl-4.3.0}/da_downloader/__init__.py +4 -2
  5. {devart_dl-4.2.0 → devart_dl-4.3.0}/da_downloader/cli.py +37 -1
  6. {devart_dl-4.2.0 → devart_dl-4.3.0}/da_downloader/download.py +68 -12
  7. {devart_dl-4.2.0 → devart_dl-4.3.0}/da_downloader/models.py +97 -0
  8. {devart_dl-4.2.0 → devart_dl-4.3.0}/da_downloader/provider.py +52 -1
  9. {devart_dl-4.2.0 → devart_dl-4.3.0/devart_dl.egg-info}/PKG-INFO +5 -1
  10. {devart_dl-4.2.0 → devart_dl-4.3.0}/devart_dl.egg-info/SOURCES.txt +1 -0
  11. {devart_dl-4.2.0 → devart_dl-4.3.0}/pyproject.toml +1 -1
  12. {devart_dl-4.2.0 → devart_dl-4.3.0}/tests/test_cli.py +26 -0
  13. devart_dl-4.3.0/tests/test_conformance.py +27 -0
  14. {devart_dl-4.2.0 → devart_dl-4.3.0}/tests/test_download.py +76 -0
  15. {devart_dl-4.2.0 → devart_dl-4.3.0}/tests/test_provider.py +88 -2
  16. {devart_dl-4.2.0 → devart_dl-4.3.0}/.env.example +0 -0
  17. {devart_dl-4.2.0 → devart_dl-4.3.0}/LICENSE +0 -0
  18. {devart_dl-4.2.0 → devart_dl-4.3.0}/MANIFEST.in +0 -0
  19. {devart_dl-4.2.0 → devart_dl-4.3.0}/bin/devart-dl +0 -0
  20. {devart_dl-4.2.0 → devart_dl-4.3.0}/bin/install.sh +0 -0
  21. {devart_dl-4.2.0 → devart_dl-4.3.0}/da_downloader/api.py +0 -0
  22. {devart_dl-4.2.0 → devart_dl-4.3.0}/da_downloader/archive.py +0 -0
  23. {devart_dl-4.2.0 → devart_dl-4.3.0}/da_downloader/auth.py +0 -0
  24. {devart_dl-4.2.0 → devart_dl-4.3.0}/da_downloader/da_cookie_login.mjs +0 -0
  25. {devart_dl-4.2.0 → devart_dl-4.3.0}/da_downloader/errors.py +0 -0
  26. {devart_dl-4.2.0 → devart_dl-4.3.0}/da_downloader/http.py +0 -0
  27. {devart_dl-4.2.0 → devart_dl-4.3.0}/da_downloader/manager.py +0 -0
  28. {devart_dl-4.2.0 → devart_dl-4.3.0}/da_downloader/oauth.py +0 -0
  29. {devart_dl-4.2.0 → devart_dl-4.3.0}/da_downloader/official_api.py +0 -0
  30. {devart_dl-4.2.0 → devart_dl-4.3.0}/da_downloader/path.py +0 -0
  31. {devart_dl-4.2.0 → devart_dl-4.3.0}/da_downloader/targets.py +0 -0
  32. {devart_dl-4.2.0 → devart_dl-4.3.0}/devart_dl.egg-info/dependency_links.txt +0 -0
  33. {devart_dl-4.2.0 → devart_dl-4.3.0}/devart_dl.egg-info/entry_points.txt +0 -0
  34. {devart_dl-4.2.0 → devart_dl-4.3.0}/devart_dl.egg-info/requires.txt +0 -0
  35. {devart_dl-4.2.0 → devart_dl-4.3.0}/devart_dl.egg-info/top_level.txt +0 -0
  36. {devart_dl-4.2.0 → devart_dl-4.3.0}/requirements.txt +0 -0
  37. {devart_dl-4.2.0 → devart_dl-4.3.0}/setup.cfg +0 -0
  38. {devart_dl-4.2.0 → devart_dl-4.3.0}/setup.py +0 -0
  39. {devart_dl-4.2.0 → devart_dl-4.3.0}/tests/test_api.py +0 -0
  40. {devart_dl-4.2.0 → devart_dl-4.3.0}/tests/test_archive.py +0 -0
  41. {devart_dl-4.2.0 → devart_dl-4.3.0}/tests/test_http_downloader.py +0 -0
  42. {devart_dl-4.2.0 → devart_dl-4.3.0}/tests/test_manager.py +0 -0
  43. {devart_dl-4.2.0 → devart_dl-4.3.0}/tests/test_official_provider.py +0 -0
  44. {devart_dl-4.2.0 → devart_dl-4.3.0}/tests/test_path.py +0 -0
  45. {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.2.0
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 fallback.
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.1"
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 DeviantArtProvider, OfficialProvider, WebProvider
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
- When an OAuth session exists it is preferred (official API); otherwise the
74
- cookie-based web provider is used as the fallback.
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
- provider: DeviantArtProvider = OfficialProvider(
83
- client, quality=normalized_quality, limit=limit
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
- provider = WebProvider(api, quality=normalized_quality, limit=limit)
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
- __all__ = ["DeviantArtProvider", "OfficialProvider", "WebProvider"]
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.2.0
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
 
@@ -33,6 +33,7 @@ devart_dl.egg-info/top_level.txt
33
33
  tests/test_api.py
34
34
  tests/test_archive.py
35
35
  tests/test_cli.py
36
+ tests/test_conformance.py
36
37
  tests/test_download.py
37
38
  tests/test_http_downloader.py
38
39
  tests/test_manager.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "devart-dl"
7
- version = "4.2.0"
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