devart-dl 4.4.2__tar.gz → 4.4.3__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 (48) hide show
  1. {devart_dl-4.4.2/devart_dl.egg-info → devart_dl-4.4.3}/PKG-INFO +1 -1
  2. {devart_dl-4.4.2 → devart_dl-4.4.3}/da_downloader/api.py +10 -11
  3. {devart_dl-4.4.2 → devart_dl-4.4.3}/da_downloader/download.py +1 -0
  4. {devart_dl-4.4.2 → devart_dl-4.4.3}/da_downloader/models.py +4 -0
  5. {devart_dl-4.4.2 → devart_dl-4.4.3}/da_downloader/official_api.py +12 -4
  6. {devart_dl-4.4.2 → devart_dl-4.4.3}/da_downloader/provider.py +14 -2
  7. devart_dl-4.4.3/da_downloader/wix_media.py +55 -0
  8. {devart_dl-4.4.2 → devart_dl-4.4.3/devart_dl.egg-info}/PKG-INFO +1 -1
  9. {devart_dl-4.4.2 → devart_dl-4.4.3}/devart_dl.egg-info/SOURCES.txt +2 -0
  10. {devart_dl-4.4.2 → devart_dl-4.4.3}/pyproject.toml +1 -1
  11. devart_dl-4.4.3/tests/test_bounded_media.py +104 -0
  12. {devart_dl-4.4.2 → devart_dl-4.4.3}/.env.example +0 -0
  13. {devart_dl-4.4.2 → devart_dl-4.4.3}/LICENSE +0 -0
  14. {devart_dl-4.4.2 → devart_dl-4.4.3}/MANIFEST.in +0 -0
  15. {devart_dl-4.4.2 → devart_dl-4.4.3}/README.en.md +0 -0
  16. {devart_dl-4.4.2 → devart_dl-4.4.3}/README.md +0 -0
  17. {devart_dl-4.4.2 → devart_dl-4.4.3}/bin/devart-dl +0 -0
  18. {devart_dl-4.4.2 → devart_dl-4.4.3}/bin/install.sh +0 -0
  19. {devart_dl-4.4.2 → devart_dl-4.4.3}/da_downloader/__init__.py +0 -0
  20. {devart_dl-4.4.2 → devart_dl-4.4.3}/da_downloader/archive.py +0 -0
  21. {devart_dl-4.4.2 → devart_dl-4.4.3}/da_downloader/auth.py +0 -0
  22. {devart_dl-4.4.2 → devart_dl-4.4.3}/da_downloader/cli.py +0 -0
  23. {devart_dl-4.4.2 → devart_dl-4.4.3}/da_downloader/da_cookie_login.mjs +0 -0
  24. {devart_dl-4.4.2 → devart_dl-4.4.3}/da_downloader/errors.py +0 -0
  25. {devart_dl-4.4.2 → devart_dl-4.4.3}/da_downloader/http.py +0 -0
  26. {devart_dl-4.4.2 → devart_dl-4.4.3}/da_downloader/manager.py +0 -0
  27. {devart_dl-4.4.2 → devart_dl-4.4.3}/da_downloader/oauth.py +0 -0
  28. {devart_dl-4.4.2 → devart_dl-4.4.3}/da_downloader/path.py +0 -0
  29. {devart_dl-4.4.2 → devart_dl-4.4.3}/da_downloader/targets.py +0 -0
  30. {devart_dl-4.4.2 → devart_dl-4.4.3}/devart_dl.egg-info/dependency_links.txt +0 -0
  31. {devart_dl-4.4.2 → devart_dl-4.4.3}/devart_dl.egg-info/entry_points.txt +0 -0
  32. {devart_dl-4.4.2 → devart_dl-4.4.3}/devart_dl.egg-info/requires.txt +0 -0
  33. {devart_dl-4.4.2 → devart_dl-4.4.3}/devart_dl.egg-info/top_level.txt +0 -0
  34. {devart_dl-4.4.2 → devart_dl-4.4.3}/requirements.txt +0 -0
  35. {devart_dl-4.4.2 → devart_dl-4.4.3}/setup.cfg +0 -0
  36. {devart_dl-4.4.2 → devart_dl-4.4.3}/setup.py +0 -0
  37. {devart_dl-4.4.2 → devart_dl-4.4.3}/tests/test_api.py +0 -0
  38. {devart_dl-4.4.2 → devart_dl-4.4.3}/tests/test_archive.py +0 -0
  39. {devart_dl-4.4.2 → devart_dl-4.4.3}/tests/test_cli.py +0 -0
  40. {devart_dl-4.4.2 → devart_dl-4.4.3}/tests/test_conformance.py +0 -0
  41. {devart_dl-4.4.2 → devart_dl-4.4.3}/tests/test_download.py +0 -0
  42. {devart_dl-4.4.2 → devart_dl-4.4.3}/tests/test_http_downloader.py +0 -0
  43. {devart_dl-4.4.2 → devart_dl-4.4.3}/tests/test_manager.py +0 -0
  44. {devart_dl-4.4.2 → devart_dl-4.4.3}/tests/test_official_provider.py +0 -0
  45. {devart_dl-4.4.2 → devart_dl-4.4.3}/tests/test_path.py +0 -0
  46. {devart_dl-4.4.2 → devart_dl-4.4.3}/tests/test_provider.py +0 -0
  47. {devart_dl-4.4.2 → devart_dl-4.4.3}/tests/test_targets.py +0 -0
  48. {devart_dl-4.4.2 → devart_dl-4.4.3}/tests/test_web_session_verify.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devart-dl
3
- Version: 4.4.2
3
+ Version: 4.4.3
4
4
  Summary: 专业的 DeviantArt 作品批量下载工具 - Professional DeviantArt batch downloader with anti-ban features
5
5
  Author: DeviantArt Downloader Team
6
6
  License-Expression: MIT
@@ -12,6 +12,7 @@ import requests
12
12
  from requests.exceptions import RequestException
13
13
 
14
14
  from .models import Deviation, ActionType
15
+ from .wix_media import append_media_token, bounded_fullview_url
15
16
 
16
17
  logger = logging.getLogger(__name__)
17
18
 
@@ -324,9 +325,13 @@ class DeviantArtAPI:
324
325
  None,
325
326
  )
326
327
 
327
- # 现代 DA 的 baseUri 本身就是原始文件(以图片/视频扩展名结尾):
328
- # 直接 baseUri + token 取原图即可。别拿它拼 fullview 的 /v1/fit|fill
329
- # 变体模板——那些 URL 即使带 token 也可能返回 400/404(2026-09 实测)。
328
+ bounded = bounded_fullview_url(media)
329
+ if bounded:
330
+ logger.warning("Using DA fullview display image, not the uploaded original")
331
+ return bounded
332
+
333
+ # Preserve bare files for unrestricted/unknown signatures and GIFs.
334
+ # An extension alone does not establish that a bounded token permits it.
330
335
  def _is_file_url(u: str) -> bool:
331
336
  path = str(u).split('?', 1)[0].rstrip('/')
332
337
  return bool(re.search(r'\.(?:png|jpe?g|gif|webp|mp4|m4v)$', path, re.I))
@@ -344,10 +349,7 @@ class DeviantArtAPI:
344
349
  logger.warning("Full view not found, using base URI")
345
350
  url = base_uri
346
351
 
347
- if token:
348
- url += f"{'&' if '?' in url else '?'}token={token}"
349
-
350
- return url
352
+ return append_media_token(url, token)
351
353
 
352
354
  def _get_preview_url(self, media: Dict, base_uri: str,
353
355
  token: str, pretty_name: str) -> Optional[str]:
@@ -368,7 +370,4 @@ class DeviantArtAPI:
368
370
 
369
371
  url = base_uri + str(preview['c']).replace('<prettyName>', pretty_name)
370
372
 
371
- if token:
372
- url += f"{'&' if '?' in url else '?'}token={token}"
373
-
374
- return url
373
+ return append_media_token(url, token)
@@ -85,6 +85,7 @@ class Downloader:
85
85
  item.media_url,
86
86
  extension=item.extension,
87
87
  mature=item.mature,
88
+ representation=item.metadata.get("representation"),
88
89
  )
89
90
  )
90
91
  return [
@@ -220,6 +220,7 @@ class MediaAsset:
220
220
  width: int | None = None
221
221
  height: int | None = None
222
222
  mature: bool = False
223
+ representation: str | None = None
223
224
 
224
225
  def to_dict(self) -> dict:
225
226
  data: dict = {
@@ -233,6 +234,7 @@ class MediaAsset:
233
234
  ("width", self.width),
234
235
  ("height", self.height),
235
236
  ("mature", self.mature),
237
+ ("representation", self.representation),
236
238
  ):
237
239
  if value is not None:
238
240
  data[key] = value
@@ -270,6 +272,7 @@ def make_media_asset(
270
272
  *,
271
273
  extension: str | None = None,
272
274
  mature: bool = False,
275
+ representation: str | None = None,
273
276
  ) -> MediaAsset:
274
277
  ext = (extension or "").lstrip(".").lower() or None
275
278
  if ext == "txt":
@@ -285,4 +288,5 @@ def make_media_asset(
285
288
  extension=ext,
286
289
  mime_type=_MIME_BY_EXT.get(ext) or _DEFAULT_MIME[kind],
287
290
  mature=mature,
291
+ representation=representation,
288
292
  )
@@ -24,6 +24,7 @@ from .errors import (
24
24
  ParseError,
25
25
  )
26
26
  from .oauth import OAuthSession
27
+ from .wix_media import append_media_token, bounded_fullview_url
27
28
 
28
29
  logger = logging.getLogger(__name__)
29
30
 
@@ -337,12 +338,13 @@ def is_blurred(url: str) -> bool:
337
338
  return "blur_" in url
338
339
 
339
340
  def additional_media_urls(init_data: dict) -> list:
340
- """Original-file URLs of a multimedia deviation's extra pages
341
+ """Media URLs of a multimedia deviation's extra pages
341
342
  (``deviation.extended.additionalMedia``, each entry nests its Wix
342
343
  descriptor under ``media``). Mirrors DeviantDrop's pick order: a playable
343
344
  video ``types[].b`` (best quality) wins over ``baseUri`` — a video page's
344
- baseUri is its poster — then the ``baseUri`` file itself, then a
345
- fullview template. A page whose only representation is a small preview is
345
+ baseUri is its poster — then a verified bounded fullview or the bare file.
346
+ A fullview is a display representation, not an uploaded original.
347
+ A page whose only representation is a small preview is
346
348
  skipped rather than saved as a fake original.
347
349
 
348
350
  Mature pages the server still returns with a ``blur_`` censored image are
@@ -378,6 +380,12 @@ def _descriptor_media_url(media: dict) -> Optional[str]:
378
380
  if videos:
379
381
  best = max(videos, key=lambda item: _VIDEO_QUALITY_RANK.get(item.get("q"), 0))
380
382
  return _append_token(str(best["b"]), media.get("token"))
383
+ bounded = bounded_fullview_url(media)
384
+ if bounded:
385
+ logger.warning(
386
+ "Using DA fullview display image for an additional page, not the uploaded original"
387
+ )
388
+ return bounded
381
389
  base = media.get("baseUri")
382
390
  if isinstance(base, str) and base and _FILE_EXT_RE.search(base.split("?", 1)[0]):
383
391
  return _append_token(base, media.get("token"))
@@ -399,7 +407,7 @@ def _append_token(url: str, raw_token: Any) -> str:
399
407
  token = raw_token[0] if isinstance(raw_token, list) and raw_token else raw_token
400
408
  if not token:
401
409
  return url
402
- return f"{url}{'&' if '?' in url else '?'}token={token}"
410
+ return append_media_token(url, token)
403
411
 
404
412
 
405
413
  __all__ = ["OfficialApiClient", "OriginalDownload", "resolve_uuid", "deviation_init", "deviation_uuid", "additional_media_urls"]
@@ -15,6 +15,7 @@ from .api import ActionType, DeviantArtAPI
15
15
  from .errors import MediaUnavailableError, ParseError
16
16
  from .errors import AuthenticationError, NetworkError
17
17
  from .models import Deviation, DownloadItem, literature_text
18
+ from .wix_media import bounded_fullview_url
18
19
  from .official_api import (
19
20
  OfficialApiClient,
20
21
  additional_media_urls,
@@ -152,7 +153,10 @@ class WebProvider:
152
153
  media_url=media_url,
153
154
  extension=_extension(deviation, media_url),
154
155
  mature=deviation.is_mature,
155
- metadata={"quality": self.quality},
156
+ metadata={"quality": self.quality, **(
157
+ {"representation": "fullview", "original_available": False}
158
+ if media_url == bounded_fullview_url(deviation.media or {}) else {}
159
+ )},
156
160
  )
157
161
 
158
162
 
@@ -251,6 +255,11 @@ class OfficialProvider:
251
255
  return
252
256
  main = self._item(self.client.deviation(uuid))
253
257
  yield main
258
+ bounded_urls = {
259
+ bounded_fullview_url(entry.get("media") or {})
260
+ for entry in ((init_data.get("deviation", {}).get("extended") or {}).get("additionalMedia") or [])
261
+ if isinstance(entry, dict) and isinstance(entry.get("media"), dict)
262
+ }
254
263
  for index, url in enumerate(additional_media_urls(init_data), start=1):
255
264
  item = DownloadItem(
256
265
  artwork_id=f"{main.artwork_id}-{index}",
@@ -261,7 +270,10 @@ class OfficialProvider:
261
270
  extension=_ext_from_url(url),
262
271
  published_at=main.published_at,
263
272
  mature=main.mature,
264
- metadata={**main.metadata, "index": index},
273
+ metadata={**main.metadata, "index": index, **(
274
+ {"representation": "fullview", "original_available": False}
275
+ if url in bounded_urls else {}
276
+ )},
265
277
  )
266
278
  yield item
267
279
 
@@ -0,0 +1,55 @@
1
+ """Bounded Wix URL selection, shared with DeviantDrop's verified 2026-10-29 fix.
2
+
3
+ See tests/fixtures/deviantart-bounded-media-evidence.md. Like DAViewer/DAKit,
4
+ keep display representations distinct from uploaded originals. Token payloads
5
+ are selection hints only, never proof of authentication or permission.
6
+ """
7
+
8
+ import base64
9
+ import json
10
+ import re
11
+ from urllib.parse import parse_qsl, urlsplit
12
+
13
+
14
+ def append_media_token(url: str, token: str) -> str:
15
+ if not token or any(key == "token" for key, _ in parse_qsl(urlsplit(url).query, keep_blank_values=True)):
16
+ return url
17
+ return f"{url}{'&' if '?' in url else '?'}token={token}"
18
+
19
+
20
+ def bounded_fullview_url(media: dict) -> str | None:
21
+ """Return the supplied fullview only for a bounded static-image signature."""
22
+ try:
23
+ base = media.get("baseUri", "")
24
+ path = urlsplit(base).path
25
+ if not re.search(r"\.(?:png|jpe?g|webp|avif)$", path, re.I):
26
+ return None
27
+ tokens = media.get("token")
28
+ token = tokens[0] if isinstance(tokens, list) else tokens
29
+ payload = token.split(".")[1]
30
+ claims = json.loads(base64.urlsafe_b64decode(payload + "=" * (-len(payload) % 4)))
31
+ groups = claims.get("obj")
32
+ if not isinstance(groups, list) or not any(
33
+ isinstance(group, list) and any(
34
+ isinstance(rule, dict) and rule.get("path") == path and any(
35
+ isinstance(rule.get(key), str) and re.fullmatch(r"<=\d+", rule[key])
36
+ for key in ("width", "height")
37
+ ) for rule in group
38
+ ) for group in groups
39
+ ):
40
+ return None
41
+ types = media.get("types") or []
42
+ # Video sources take precedence over static posters.
43
+ if any(isinstance(t, dict) and t.get("t") == "video" and t.get("b") for t in types):
44
+ return None
45
+ full = next((t for t in types if isinstance(t, dict) and t.get("t") == "fullview"), {})
46
+ if full.get("r") not in (None, 0):
47
+ return None # Only index 0 was independently verified.
48
+ url = full.get("b")
49
+ if not url and full.get("c"):
50
+ url = base + full["c"].replace("<prettyName>", media.get("prettyName") or "image")
51
+ if not url:
52
+ return None
53
+ return append_media_token(url, token)
54
+ except (ValueError, TypeError, AttributeError, IndexError, KeyError):
55
+ return None
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devart-dl
3
- Version: 4.4.2
3
+ Version: 4.4.3
4
4
  Summary: 专业的 DeviantArt 作品批量下载工具 - Professional DeviantArt batch downloader with anti-ban features
5
5
  Author: DeviantArt Downloader Team
6
6
  License-Expression: MIT
@@ -24,6 +24,7 @@ da_downloader/official_api.py
24
24
  da_downloader/path.py
25
25
  da_downloader/provider.py
26
26
  da_downloader/targets.py
27
+ da_downloader/wix_media.py
27
28
  devart_dl.egg-info/PKG-INFO
28
29
  devart_dl.egg-info/SOURCES.txt
29
30
  devart_dl.egg-info/dependency_links.txt
@@ -32,6 +33,7 @@ devart_dl.egg-info/requires.txt
32
33
  devart_dl.egg-info/top_level.txt
33
34
  tests/test_api.py
34
35
  tests/test_archive.py
36
+ tests/test_bounded_media.py
35
37
  tests/test_cli.py
36
38
  tests/test_conformance.py
37
39
  tests/test_download.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "devart-dl"
7
- version = "4.4.2"
7
+ version = "4.4.3"
8
8
  description = "专业的 DeviantArt 作品批量下载工具 - Professional DeviantArt batch downloader with anti-ban features"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -0,0 +1,104 @@
1
+ """Real captured CDN 403 regression; provenance is beside the fixture."""
2
+
3
+ import base64
4
+ import copy
5
+ import json
6
+ from pathlib import Path
7
+ from types import SimpleNamespace
8
+
9
+ import pytest
10
+
11
+ from da_downloader.api import DeviantArtAPI
12
+ from da_downloader.models import Deviation, DownloadItem, make_media_asset
13
+ from da_downloader.official_api import _descriptor_media_url, additional_media_urls
14
+ from da_downloader.provider import OfficialProvider, WebProvider
15
+ from da_downloader.targets import TargetParser
16
+ from da_downloader.wix_media import append_media_token, bounded_fullview_url
17
+
18
+
19
+ def test_existing_query_and_signature_are_preserved():
20
+ assert append_media_token("https://example.test/a.jpg?download=1", "fresh") == "https://example.test/a.jpg?download=1&token=fresh"
21
+ signed = "https://example.test/a.jpg?download=1&token=existing"
22
+ assert append_media_token(signed, "fresh") == signed
23
+
24
+ SAMPLES = json.loads((Path(__file__).parent / "fixtures/deviantart-bounded-media.json").read_text(encoding="utf-8-sig"))["samples"]
25
+ DESCRIPTORS = [m for d in SAMPLES for m in [d["media"], *[e["media"] for e in d["extended"]["additionalMedia"]]]]
26
+
27
+
28
+ @pytest.mark.parametrize("media", DESCRIPTORS)
29
+ def test_captured_bounded_media_selects_fullview_in_both_paths(media, caplog):
30
+ full = next(t for t in media["types"] if t["t"] == "fullview")
31
+ expected = media["baseUri"] + full["c"].replace("<prettyName>", media["prettyName"]) + "?token=" + media["token"][0]
32
+ api = DeviantArtAPI({}, {})
33
+ assert api._get_full_view_url(media, media["baseUri"], media["token"][0], media["prettyName"]) == expected
34
+ assert _descriptor_media_url(media) == expected
35
+ assert "not the uploaded original" in caplog.text
36
+
37
+
38
+ def test_extra_page_is_not_lost_or_replaced_by_small_preview():
39
+ result = additional_media_urls({"deviation": SAMPLES[0]})
40
+ assert len(result) == 1
41
+ assert "-fullview.jpg?token=" in result[0]
42
+ assert "-pre.jpg" not in result[0]
43
+
44
+
45
+ @pytest.mark.parametrize("token", [None, [], "opaque", "a.bad.c"])
46
+ def test_unknown_token_preserves_original_selection(token):
47
+ media = {**SAMPLES[1]["media"], "token": token}
48
+ assert bounded_fullview_url(media) is None
49
+
50
+
51
+ def test_unbounded_original_gif_and_video_stay_unchanged():
52
+ media = copy.deepcopy(SAMPLES[1]["media"])
53
+ payload = media["token"][0].split(".")[1]
54
+ claims = json.loads(base64.urlsafe_b64decode(payload + "=" * (-len(payload) % 4)))
55
+ claims["obj"][0][0].pop("width")
56
+ claims["obj"][0][0].pop("height")
57
+ payload = base64.urlsafe_b64encode(json.dumps(claims).encode()).decode().rstrip("=")
58
+ media["token"] = [f"test.{payload}.REDACTED"]
59
+ assert bounded_fullview_url(media) is None
60
+ assert _descriptor_media_url(media).startswith(media["baseUri"] + "?token=")
61
+ gif = {**media, "baseUri": media["baseUri"].replace(".jpg", ".gif")}
62
+ assert _descriptor_media_url(gif).startswith(gif["baseUri"] + "?token=")
63
+ video = {**SAMPLES[1]["media"], "types": [{"t": "video", "b": "https://example.test/movie.mp4", "q": "1080p"}]}
64
+ assert _descriptor_media_url(video).startswith("https://example.test/movie.mp4")
65
+
66
+
67
+ def test_no_fullview_does_not_substitute_preview():
68
+ media = copy.deepcopy(SAMPLES[1]["media"])
69
+ media["types"] = [t for t in media["types"] if t["t"] != "fullview"]
70
+ assert bounded_fullview_url(media) is None
71
+ assert _descriptor_media_url(media).startswith(media["baseUri"] + "?token=")
72
+
73
+
74
+ def test_original_endpoint_still_wins_and_fullview_metadata_is_explicit(monkeypatch):
75
+ media = SAMPLES[1]["media"]
76
+ deviation = Deviation(deviation_id="1403090181", title="Untitled", author="lol1729", url="https://www.deviantart.com/lol1729/art/Untitled-1403090181", media=media, is_downloadable=True, is_mature=False, deviation_type="image")
77
+ api = DeviantArtAPI({}, {})
78
+ monkeypatch.setattr(api, "_get_original_download_url", lambda _: "https://example.test/original.jpg")
79
+ assert api.get_download_url(deviation, "o") == "https://example.test/original.jpg"
80
+ item = WebProvider(api, quality="f")._to_item(deviation)
81
+ assert item.metadata["representation"] == "fullview"
82
+ assert item.metadata["original_available"] is False
83
+ assert item.extension == "jpg"
84
+ asset = make_media_asset(item.artwork_id, 0, item.media_url, extension=item.extension, representation=item.metadata["representation"])
85
+ assert asset.to_dict()["representation"] == "fullview"
86
+
87
+
88
+ def test_additional_page_metadata_does_not_claim_the_main_original_quality(monkeypatch):
89
+ import da_downloader.provider as provider_mod
90
+
91
+ sample = SAMPLES[0]
92
+ uuid = sample["extended"]["deviationUuid"]
93
+ monkeypatch.setattr(provider_mod, "deviation_init", lambda *a, **kw: {"deviation": sample})
94
+ provider = OfficialProvider(SimpleNamespace(deviation=lambda _: {}), quality="o")
95
+ main = DownloadItem(artwork_id=uuid, url="https://example.test/art", title="Work", author="Artist", media_url="https://example.test/original.png", extension="png", metadata={"quality": "o"})
96
+ monkeypatch.setattr(provider, "_item", lambda _: main)
97
+ items = list(provider.resolve(TargetParser.parse("1402511705")))
98
+ assert len(items) == 2
99
+ assert items[0] is main
100
+ assert "representation" not in main.metadata
101
+ assert items[1].artwork_id == uuid + "-1"
102
+ assert items[1].metadata["representation"] == "fullview"
103
+ assert items[1].metadata["original_available"] is False
104
+ assert items[1].extension == "jpg"
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