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.
- {devart_dl-4.0.2/devart_dl.egg-info → devart_dl-4.0.4}/PKG-INFO +1 -1
- {devart_dl-4.0.2 → devart_dl-4.0.4}/da_downloader/official_api.py +92 -1
- {devart_dl-4.0.2 → devart_dl-4.0.4}/da_downloader/provider.py +32 -4
- {devart_dl-4.0.2 → devart_dl-4.0.4/devart_dl.egg-info}/PKG-INFO +1 -1
- {devart_dl-4.0.2 → devart_dl-4.0.4}/pyproject.toml +1 -1
- {devart_dl-4.0.2 → devart_dl-4.0.4}/tests/test_official_provider.py +30 -3
- {devart_dl-4.0.2 → devart_dl-4.0.4}/.env.example +0 -0
- {devart_dl-4.0.2 → devart_dl-4.0.4}/LICENSE +0 -0
- {devart_dl-4.0.2 → devart_dl-4.0.4}/MANIFEST.in +0 -0
- {devart_dl-4.0.2 → devart_dl-4.0.4}/README.md +0 -0
- {devart_dl-4.0.2 → devart_dl-4.0.4}/README_EN.md +0 -0
- {devart_dl-4.0.2 → devart_dl-4.0.4}/bin/devart-dl +0 -0
- {devart_dl-4.0.2 → devart_dl-4.0.4}/bin/install.sh +0 -0
- {devart_dl-4.0.2 → devart_dl-4.0.4}/da_downloader/__init__.py +0 -0
- {devart_dl-4.0.2 → devart_dl-4.0.4}/da_downloader/api.py +0 -0
- {devart_dl-4.0.2 → devart_dl-4.0.4}/da_downloader/archive.py +0 -0
- {devart_dl-4.0.2 → devart_dl-4.0.4}/da_downloader/auth.py +0 -0
- {devart_dl-4.0.2 → devart_dl-4.0.4}/da_downloader/cli.py +0 -0
- {devart_dl-4.0.2 → devart_dl-4.0.4}/da_downloader/download.py +0 -0
- {devart_dl-4.0.2 → devart_dl-4.0.4}/da_downloader/errors.py +0 -0
- {devart_dl-4.0.2 → devart_dl-4.0.4}/da_downloader/http.py +0 -0
- {devart_dl-4.0.2 → devart_dl-4.0.4}/da_downloader/manager.py +0 -0
- {devart_dl-4.0.2 → devart_dl-4.0.4}/da_downloader/models.py +0 -0
- {devart_dl-4.0.2 → devart_dl-4.0.4}/da_downloader/oauth.py +0 -0
- {devart_dl-4.0.2 → devart_dl-4.0.4}/da_downloader/path.py +0 -0
- {devart_dl-4.0.2 → devart_dl-4.0.4}/da_downloader/targets.py +0 -0
- {devart_dl-4.0.2 → devart_dl-4.0.4}/devart_dl.egg-info/SOURCES.txt +0 -0
- {devart_dl-4.0.2 → devart_dl-4.0.4}/devart_dl.egg-info/dependency_links.txt +0 -0
- {devart_dl-4.0.2 → devart_dl-4.0.4}/devart_dl.egg-info/entry_points.txt +0 -0
- {devart_dl-4.0.2 → devart_dl-4.0.4}/devart_dl.egg-info/requires.txt +0 -0
- {devart_dl-4.0.2 → devart_dl-4.0.4}/devart_dl.egg-info/top_level.txt +0 -0
- {devart_dl-4.0.2 → devart_dl-4.0.4}/requirements.txt +0 -0
- {devart_dl-4.0.2 → devart_dl-4.0.4}/setup.cfg +0 -0
- {devart_dl-4.0.2 → devart_dl-4.0.4}/setup.py +0 -0
- {devart_dl-4.0.2 → devart_dl-4.0.4}/tests/test_api.py +0 -0
- {devart_dl-4.0.2 → devart_dl-4.0.4}/tests/test_archive.py +0 -0
- {devart_dl-4.0.2 → devart_dl-4.0.4}/tests/test_cli.py +0 -0
- {devart_dl-4.0.2 → devart_dl-4.0.4}/tests/test_download.py +0 -0
- {devart_dl-4.0.2 → devart_dl-4.0.4}/tests/test_http_downloader.py +0 -0
- {devart_dl-4.0.2 → devart_dl-4.0.4}/tests/test_manager.py +0 -0
- {devart_dl-4.0.2 → devart_dl-4.0.4}/tests/test_path.py +0 -0
- {devart_dl-4.0.2 → devart_dl-4.0.4}/tests/test_provider.py +0 -0
- {devart_dl-4.0.2 → devart_dl-4.0.4}/tests/test_targets.py +0 -0
|
@@ -251,4 +251,95 @@ def resolve_uuid(
|
|
|
251
251
|
return uuid
|
|
252
252
|
|
|
253
253
|
|
|
254
|
-
|
|
254
|
+
|
|
255
|
+
def deviation_init(
|
|
256
|
+
identifier: str,
|
|
257
|
+
username: Optional[str] = None,
|
|
258
|
+
timeout: int = 30,
|
|
259
|
+
session: Optional[requests.Session] = None,
|
|
260
|
+
) -> dict:
|
|
261
|
+
"""Fetch the full public ``_puppy/dadeviation/init`` payload (numeric or
|
|
262
|
+
fav.me ids). Mirrors DAKit/DAViewer: multimedia pages surface under
|
|
263
|
+
``deviation.extended.additionalMedia`` here, which the official API no
|
|
264
|
+
longer returns.
|
|
265
|
+
"""
|
|
266
|
+
identifier = identifier.strip()
|
|
267
|
+
if not identifier:
|
|
268
|
+
raise ParseError("the URL does not contain an artwork id")
|
|
269
|
+
http = session or requests.Session()
|
|
270
|
+
if not identifier.isdigit():
|
|
271
|
+
identifier = _resolve_short_id(identifier, http, timeout)
|
|
272
|
+
try:
|
|
273
|
+
home = http.get(
|
|
274
|
+
WEB_BASE,
|
|
275
|
+
headers={"User-Agent": USER_AGENT, "Accept": "text/html"},
|
|
276
|
+
timeout=timeout,
|
|
277
|
+
)
|
|
278
|
+
home.raise_for_status()
|
|
279
|
+
match = re.search(r"window\.__CSRF_TOKEN__ = '([^']*)'", home.text)
|
|
280
|
+
if not match or not match.group(1):
|
|
281
|
+
raise ParseError("could not read the DeviantArt CSRF token")
|
|
282
|
+
csrf = match.group(1)
|
|
283
|
+
query = {
|
|
284
|
+
"deviationid": identifier,
|
|
285
|
+
# 该接口自 2026 年起把 type 列为必填枚举(art/journal),缺失返回 400。
|
|
286
|
+
"type": "art",
|
|
287
|
+
"include_session": "false",
|
|
288
|
+
"csrf_token": csrf,
|
|
289
|
+
"mature_content": True,
|
|
290
|
+
}
|
|
291
|
+
if username:
|
|
292
|
+
query["username"] = username
|
|
293
|
+
response = http.get(
|
|
294
|
+
f"{WEB_BASE}/_puppy/dadeviation/init",
|
|
295
|
+
params=query,
|
|
296
|
+
headers={"User-Agent": USER_AGENT, "Accept": "application/json"},
|
|
297
|
+
timeout=timeout,
|
|
298
|
+
)
|
|
299
|
+
response.raise_for_status()
|
|
300
|
+
data = response.json()
|
|
301
|
+
except requests.RequestException as error:
|
|
302
|
+
raise NetworkError(f"could not reach the resolution endpoint: {error}") from error
|
|
303
|
+
except ValueError as error:
|
|
304
|
+
raise ParseError("the resolution endpoint returned invalid JSON") from error
|
|
305
|
+
if not isinstance(data, dict) or not isinstance(data.get("deviation"), dict):
|
|
306
|
+
raise ParseError("the resolution endpoint returned an unexpected payload")
|
|
307
|
+
return data
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def deviation_uuid(init_data: dict) -> str:
|
|
311
|
+
"""Read the UUID out of an init payload."""
|
|
312
|
+
extended = init_data.get("deviation", {}).get("extended") or {}
|
|
313
|
+
uuid = extended.get("deviationUuid")
|
|
314
|
+
if not isinstance(uuid, str) or not uuid:
|
|
315
|
+
raise MediaUnavailableError("could not resolve the artwork to a UUID")
|
|
316
|
+
return uuid
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def additional_media_urls(init_data: dict) -> list:
|
|
320
|
+
"""Original-file URLs of a multimedia deviation's extra pages
|
|
321
|
+
(``deviation.extended.additionalMedia``, each entry nests its Wix
|
|
322
|
+
descriptor under ``media``). Prefers the raw ``baseUri`` file + token.
|
|
323
|
+
"""
|
|
324
|
+
extended = init_data.get("deviation", {}).get("extended") or {}
|
|
325
|
+
entries = extended.get("additionalMedia") or []
|
|
326
|
+
urls: list = []
|
|
327
|
+
for entry in entries:
|
|
328
|
+
if not isinstance(entry, dict):
|
|
329
|
+
continue
|
|
330
|
+
media = entry.get("media")
|
|
331
|
+
if not isinstance(media, dict):
|
|
332
|
+
continue
|
|
333
|
+
base = media.get("baseUri")
|
|
334
|
+
if not isinstance(base, str) or not base:
|
|
335
|
+
continue
|
|
336
|
+
raw_token = media.get("token")
|
|
337
|
+
token = raw_token[0] if isinstance(raw_token, list) and raw_token else raw_token
|
|
338
|
+
if token:
|
|
339
|
+
urls.append(f"{base}{'&' if '?' in base else '?'}token={token}")
|
|
340
|
+
else:
|
|
341
|
+
urls.append(base)
|
|
342
|
+
return urls
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
__all__ = ["OfficialApiClient", "OriginalDownload", "resolve_uuid", "deviation_init", "deviation_uuid", "additional_media_urls"]
|
|
@@ -14,7 +14,12 @@ from typing import Any, Callable, Iterator, Optional, Protocol
|
|
|
14
14
|
from .api import ActionType, DeviantArtAPI
|
|
15
15
|
from .errors import MediaUnavailableError, ParseError
|
|
16
16
|
from .models import Deviation, DownloadItem
|
|
17
|
-
from .official_api import
|
|
17
|
+
from .official_api import (
|
|
18
|
+
OfficialApiClient,
|
|
19
|
+
additional_media_urls,
|
|
20
|
+
deviation_init,
|
|
21
|
+
deviation_uuid,
|
|
22
|
+
)
|
|
18
23
|
from .targets import DownloadTarget, TargetKind
|
|
19
24
|
|
|
20
25
|
logger = logging.getLogger(__name__)
|
|
@@ -158,7 +163,7 @@ class OfficialProvider:
|
|
|
158
163
|
def resolve(self, target: DownloadTarget) -> Iterator[DownloadItem]:
|
|
159
164
|
kind = target.kind
|
|
160
165
|
if kind == TargetKind.ARTWORK:
|
|
161
|
-
yield self.
|
|
166
|
+
yield from self._artwork_items(target)
|
|
162
167
|
return
|
|
163
168
|
if kind == TargetKind.SEARCH:
|
|
164
169
|
raise MediaUnavailableError(
|
|
@@ -191,8 +196,31 @@ class OfficialProvider:
|
|
|
191
196
|
yield from self._paginate(fetch)
|
|
192
197
|
|
|
193
198
|
def _artwork(self, target: DownloadTarget) -> DownloadItem:
|
|
194
|
-
|
|
195
|
-
return
|
|
199
|
+
# 兼容旧的单返回值调用(resolve 里已改用 _artwork_items)
|
|
200
|
+
return next(self._artwork_items(target))
|
|
201
|
+
|
|
202
|
+
def _artwork_items(self, target: DownloadTarget) -> Iterator[DownloadItem]:
|
|
203
|
+
"""单作品下载。多文件作品的附加画面来自网页 init 响应
|
|
204
|
+
(deviation.extended.additionalMedia,官方 API 不再返回),逐个产出下载项;
|
|
205
|
+
没有附加画面时只下主图(行为与以前一致)。"""
|
|
206
|
+
identifier = target.identifier or ""
|
|
207
|
+
init_data = deviation_init(identifier, target.username)
|
|
208
|
+
uuid = deviation_uuid(init_data)
|
|
209
|
+
main = self._item(self.client.deviation(uuid))
|
|
210
|
+
yield main
|
|
211
|
+
for index, url in enumerate(additional_media_urls(init_data), start=1):
|
|
212
|
+
item = DownloadItem(
|
|
213
|
+
artwork_id=f"{main.artwork_id}-{index}",
|
|
214
|
+
url=main.url,
|
|
215
|
+
title=main.title,
|
|
216
|
+
author=main.author,
|
|
217
|
+
media_url=url,
|
|
218
|
+
extension=_ext_from_url(url),
|
|
219
|
+
published_at=main.published_at,
|
|
220
|
+
mature=main.mature,
|
|
221
|
+
metadata={**main.metadata, "index": index},
|
|
222
|
+
)
|
|
223
|
+
yield item
|
|
196
224
|
|
|
197
225
|
def _paginate(self, fetch: Callable[[int], dict]) -> Iterator[DownloadItem]:
|
|
198
226
|
offset = 0
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "devart-dl"
|
|
7
|
-
version = "4.0.
|
|
7
|
+
version = "4.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, "
|
|
61
|
+
monkeypatch.setattr(provider_mod, "deviation_init", lambda identifier, username=None, **_: {"deviation": {"extended": {"deviationUuid": "uuid-1", "additionalMedia": []}}})
|
|
62
62
|
client = FakeClient()
|
|
63
63
|
client.deviations["uuid-1"] = make_deviation()
|
|
64
64
|
client.originals["uuid-1"] = OriginalDownload(
|
|
@@ -77,8 +77,35 @@ def test_artwork_original(monkeypatch) -> None:
|
|
|
77
77
|
assert items[0].published_at == datetime(2015, 3, 30, 21, 21, 4, tzinfo=timezone.utc)
|
|
78
78
|
|
|
79
79
|
|
|
80
|
+
def test_artwork_multimedia_yields_every_file(monkeypatch) -> None:
|
|
81
|
+
init = {
|
|
82
|
+
"deviation": {
|
|
83
|
+
"extended": {
|
|
84
|
+
"deviationUuid": "uuid-1",
|
|
85
|
+
"additionalMedia": [
|
|
86
|
+
{"fileId": 2, "media": {"baseUri": "https://media.test/p2.jpg", "token": ["t2"]}},
|
|
87
|
+
{"fileId": 3, "media": {"baseUri": "https://media.test/p3.png", "token": ["t3"]}},
|
|
88
|
+
],
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
monkeypatch.setattr(provider_mod, "deviation_init", lambda identifier, username=None, **_: init)
|
|
93
|
+
client = FakeClient()
|
|
94
|
+
client.deviations["uuid-1"] = make_deviation()
|
|
95
|
+
provider = OfficialProvider(client, quality="f")
|
|
96
|
+
|
|
97
|
+
items = list(provider.resolve(TargetParser.parse("https://www.deviantart.com/alice/art/x-123456")))
|
|
98
|
+
|
|
99
|
+
assert len(items) == 3 # 主图 + 两张附加
|
|
100
|
+
assert items[0].media_url == "https://images.test/full.jpg"
|
|
101
|
+
assert items[1].media_url == "https://media.test/p2.jpg?token=t2"
|
|
102
|
+
assert items[1].artwork_id == "uuid-1-1"
|
|
103
|
+
assert items[2].media_url == "https://media.test/p3.png?token=t3"
|
|
104
|
+
assert items[2].extension == "png"
|
|
105
|
+
|
|
106
|
+
|
|
80
107
|
def test_artwork_best_uses_content(monkeypatch) -> None:
|
|
81
|
-
monkeypatch.setattr(provider_mod, "
|
|
108
|
+
monkeypatch.setattr(provider_mod, "deviation_init", lambda identifier, username=None, **_: {"deviation": {"extended": {"deviationUuid": "uuid-1", "additionalMedia": []}}})
|
|
82
109
|
client = FakeClient()
|
|
83
110
|
client.deviations["uuid-1"] = make_deviation()
|
|
84
111
|
provider = OfficialProvider(client, quality="f")
|
|
@@ -111,7 +138,7 @@ def test_gallery_paginates(monkeypatch) -> None:
|
|
|
111
138
|
|
|
112
139
|
|
|
113
140
|
def test_no_media_raises(monkeypatch) -> None:
|
|
114
|
-
monkeypatch.setattr(provider_mod, "
|
|
141
|
+
monkeypatch.setattr(provider_mod, "deviation_init", lambda identifier, username=None, **_: {"deviation": {"extended": {"deviationUuid": "uuid-1", "additionalMedia": []}}})
|
|
115
142
|
client = FakeClient()
|
|
116
143
|
client.deviations["uuid-1"] = {
|
|
117
144
|
"deviationid": "uuid-1",
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|