devart-dl 4.0.2__tar.gz → 4.0.4__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 (43) hide show
  1. {devart_dl-4.0.2/devart_dl.egg-info → devart_dl-4.0.4}/PKG-INFO +1 -1
  2. {devart_dl-4.0.2 → devart_dl-4.0.4}/da_downloader/official_api.py +92 -1
  3. {devart_dl-4.0.2 → devart_dl-4.0.4}/da_downloader/provider.py +32 -4
  4. {devart_dl-4.0.2 → devart_dl-4.0.4/devart_dl.egg-info}/PKG-INFO +1 -1
  5. {devart_dl-4.0.2 → devart_dl-4.0.4}/pyproject.toml +1 -1
  6. {devart_dl-4.0.2 → devart_dl-4.0.4}/tests/test_official_provider.py +30 -3
  7. {devart_dl-4.0.2 → devart_dl-4.0.4}/.env.example +0 -0
  8. {devart_dl-4.0.2 → devart_dl-4.0.4}/LICENSE +0 -0
  9. {devart_dl-4.0.2 → devart_dl-4.0.4}/MANIFEST.in +0 -0
  10. {devart_dl-4.0.2 → devart_dl-4.0.4}/README.md +0 -0
  11. {devart_dl-4.0.2 → devart_dl-4.0.4}/README_EN.md +0 -0
  12. {devart_dl-4.0.2 → devart_dl-4.0.4}/bin/devart-dl +0 -0
  13. {devart_dl-4.0.2 → devart_dl-4.0.4}/bin/install.sh +0 -0
  14. {devart_dl-4.0.2 → devart_dl-4.0.4}/da_downloader/__init__.py +0 -0
  15. {devart_dl-4.0.2 → devart_dl-4.0.4}/da_downloader/api.py +0 -0
  16. {devart_dl-4.0.2 → devart_dl-4.0.4}/da_downloader/archive.py +0 -0
  17. {devart_dl-4.0.2 → devart_dl-4.0.4}/da_downloader/auth.py +0 -0
  18. {devart_dl-4.0.2 → devart_dl-4.0.4}/da_downloader/cli.py +0 -0
  19. {devart_dl-4.0.2 → devart_dl-4.0.4}/da_downloader/download.py +0 -0
  20. {devart_dl-4.0.2 → devart_dl-4.0.4}/da_downloader/errors.py +0 -0
  21. {devart_dl-4.0.2 → devart_dl-4.0.4}/da_downloader/http.py +0 -0
  22. {devart_dl-4.0.2 → devart_dl-4.0.4}/da_downloader/manager.py +0 -0
  23. {devart_dl-4.0.2 → devart_dl-4.0.4}/da_downloader/models.py +0 -0
  24. {devart_dl-4.0.2 → devart_dl-4.0.4}/da_downloader/oauth.py +0 -0
  25. {devart_dl-4.0.2 → devart_dl-4.0.4}/da_downloader/path.py +0 -0
  26. {devart_dl-4.0.2 → devart_dl-4.0.4}/da_downloader/targets.py +0 -0
  27. {devart_dl-4.0.2 → devart_dl-4.0.4}/devart_dl.egg-info/SOURCES.txt +0 -0
  28. {devart_dl-4.0.2 → devart_dl-4.0.4}/devart_dl.egg-info/dependency_links.txt +0 -0
  29. {devart_dl-4.0.2 → devart_dl-4.0.4}/devart_dl.egg-info/entry_points.txt +0 -0
  30. {devart_dl-4.0.2 → devart_dl-4.0.4}/devart_dl.egg-info/requires.txt +0 -0
  31. {devart_dl-4.0.2 → devart_dl-4.0.4}/devart_dl.egg-info/top_level.txt +0 -0
  32. {devart_dl-4.0.2 → devart_dl-4.0.4}/requirements.txt +0 -0
  33. {devart_dl-4.0.2 → devart_dl-4.0.4}/setup.cfg +0 -0
  34. {devart_dl-4.0.2 → devart_dl-4.0.4}/setup.py +0 -0
  35. {devart_dl-4.0.2 → devart_dl-4.0.4}/tests/test_api.py +0 -0
  36. {devart_dl-4.0.2 → devart_dl-4.0.4}/tests/test_archive.py +0 -0
  37. {devart_dl-4.0.2 → devart_dl-4.0.4}/tests/test_cli.py +0 -0
  38. {devart_dl-4.0.2 → devart_dl-4.0.4}/tests/test_download.py +0 -0
  39. {devart_dl-4.0.2 → devart_dl-4.0.4}/tests/test_http_downloader.py +0 -0
  40. {devart_dl-4.0.2 → devart_dl-4.0.4}/tests/test_manager.py +0 -0
  41. {devart_dl-4.0.2 → devart_dl-4.0.4}/tests/test_path.py +0 -0
  42. {devart_dl-4.0.2 → devart_dl-4.0.4}/tests/test_provider.py +0 -0
  43. {devart_dl-4.0.2 → devart_dl-4.0.4}/tests/test_targets.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devart-dl
3
- Version: 4.0.2
3
+ Version: 4.0.4
4
4
  Summary: 专业的 DeviantArt 作品批量下载工具 - Professional DeviantArt batch downloader with anti-ban features
5
5
  Author: DeviantArt Downloader Team
6
6
  License-Expression: MIT
@@ -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.0.4
4
4
  Summary: 专业的 DeviantArt 作品批量下载工具 - Professional DeviantArt batch downloader with anti-ban features
5
5
  Author: DeviantArt Downloader Team
6
6
  License-Expression: MIT
@@ -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.0.4"
8
8
  description = "专业的 DeviantArt 作品批量下载工具 - Professional DeviantArt batch downloader with anti-ban features"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -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
File without changes