devart-dl 4.4.0__tar.gz → 4.4.2__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 (47) hide show
  1. {devart_dl-4.4.0/devart_dl.egg-info → devart_dl-4.4.2}/PKG-INFO +6 -1
  2. {devart_dl-4.4.0 → devart_dl-4.4.2}/README.md +5 -0
  3. devart_dl-4.4.2/da_downloader/auth.py +342 -0
  4. {devart_dl-4.4.0 → devart_dl-4.4.2}/da_downloader/cli.py +16 -4
  5. {devart_dl-4.4.0 → devart_dl-4.4.2/devart_dl.egg-info}/PKG-INFO +6 -1
  6. {devart_dl-4.4.0 → devart_dl-4.4.2}/devart_dl.egg-info/SOURCES.txt +2 -1
  7. {devart_dl-4.4.0 → devart_dl-4.4.2}/pyproject.toml +1 -1
  8. devart_dl-4.4.2/tests/test_web_session_verify.py +219 -0
  9. devart_dl-4.4.0/da_downloader/auth.py +0 -169
  10. {devart_dl-4.4.0 → devart_dl-4.4.2}/.env.example +0 -0
  11. {devart_dl-4.4.0 → devart_dl-4.4.2}/LICENSE +0 -0
  12. {devart_dl-4.4.0 → devart_dl-4.4.2}/MANIFEST.in +0 -0
  13. {devart_dl-4.4.0 → devart_dl-4.4.2}/README.en.md +0 -0
  14. {devart_dl-4.4.0 → devart_dl-4.4.2}/bin/devart-dl +0 -0
  15. {devart_dl-4.4.0 → devart_dl-4.4.2}/bin/install.sh +0 -0
  16. {devart_dl-4.4.0 → devart_dl-4.4.2}/da_downloader/__init__.py +0 -0
  17. {devart_dl-4.4.0 → devart_dl-4.4.2}/da_downloader/api.py +0 -0
  18. {devart_dl-4.4.0 → devart_dl-4.4.2}/da_downloader/archive.py +0 -0
  19. {devart_dl-4.4.0 → devart_dl-4.4.2}/da_downloader/da_cookie_login.mjs +0 -0
  20. {devart_dl-4.4.0 → devart_dl-4.4.2}/da_downloader/download.py +0 -0
  21. {devart_dl-4.4.0 → devart_dl-4.4.2}/da_downloader/errors.py +0 -0
  22. {devart_dl-4.4.0 → devart_dl-4.4.2}/da_downloader/http.py +0 -0
  23. {devart_dl-4.4.0 → devart_dl-4.4.2}/da_downloader/manager.py +0 -0
  24. {devart_dl-4.4.0 → devart_dl-4.4.2}/da_downloader/models.py +0 -0
  25. {devart_dl-4.4.0 → devart_dl-4.4.2}/da_downloader/oauth.py +0 -0
  26. {devart_dl-4.4.0 → devart_dl-4.4.2}/da_downloader/official_api.py +0 -0
  27. {devart_dl-4.4.0 → devart_dl-4.4.2}/da_downloader/path.py +0 -0
  28. {devart_dl-4.4.0 → devart_dl-4.4.2}/da_downloader/provider.py +0 -0
  29. {devart_dl-4.4.0 → devart_dl-4.4.2}/da_downloader/targets.py +0 -0
  30. {devart_dl-4.4.0 → devart_dl-4.4.2}/devart_dl.egg-info/dependency_links.txt +0 -0
  31. {devart_dl-4.4.0 → devart_dl-4.4.2}/devart_dl.egg-info/entry_points.txt +0 -0
  32. {devart_dl-4.4.0 → devart_dl-4.4.2}/devart_dl.egg-info/requires.txt +0 -0
  33. {devart_dl-4.4.0 → devart_dl-4.4.2}/devart_dl.egg-info/top_level.txt +0 -0
  34. {devart_dl-4.4.0 → devart_dl-4.4.2}/requirements.txt +0 -0
  35. {devart_dl-4.4.0 → devart_dl-4.4.2}/setup.cfg +0 -0
  36. {devart_dl-4.4.0 → devart_dl-4.4.2}/setup.py +0 -0
  37. {devart_dl-4.4.0 → devart_dl-4.4.2}/tests/test_api.py +0 -0
  38. {devart_dl-4.4.0 → devart_dl-4.4.2}/tests/test_archive.py +0 -0
  39. {devart_dl-4.4.0 → devart_dl-4.4.2}/tests/test_cli.py +0 -0
  40. {devart_dl-4.4.0 → devart_dl-4.4.2}/tests/test_conformance.py +0 -0
  41. {devart_dl-4.4.0 → devart_dl-4.4.2}/tests/test_download.py +0 -0
  42. {devart_dl-4.4.0 → devart_dl-4.4.2}/tests/test_http_downloader.py +0 -0
  43. {devart_dl-4.4.0 → devart_dl-4.4.2}/tests/test_manager.py +0 -0
  44. {devart_dl-4.4.0 → devart_dl-4.4.2}/tests/test_official_provider.py +0 -0
  45. {devart_dl-4.4.0 → devart_dl-4.4.2}/tests/test_path.py +0 -0
  46. {devart_dl-4.4.0 → devart_dl-4.4.2}/tests/test_provider.py +0 -0
  47. {devart_dl-4.4.0 → devart_dl-4.4.2}/tests/test_targets.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devart-dl
3
- Version: 4.4.0
3
+ Version: 4.4.2
4
4
  Summary: 专业的 DeviantArt 作品批量下载工具 - Professional DeviantArt batch downloader with anti-ban features
5
5
  Author: DeviantArt Downloader Team
6
6
  License-Expression: MIT
@@ -81,6 +81,11 @@ devart-dl whoami
81
81
  devart-dl login browser # 打开你的 Chrome 登录,自动保存 Cookie,无需 F12 复制
82
82
  ```
83
83
 
84
+ 下载或 resolve 遇到 401/403 时,工具会用同一代理与浏览器 UA 核对网页会话,
85
+ 区分会话已过期、证据暂不可用和已登录但作品访问被拒。网络失败或页面结构变化
86
+ 不会当作登出,也不会清除 Cookie;只有明确的未登录证据才提示刷新登录。
87
+ 这一步只诊断错误,不自动重试下载,也不保证已登录账号有权访问所有作品。
88
+
84
89
  完整图文式步骤见 **[登录与认证教程](https://redtidev1918.github.io/deviantart-downloader/#/LOGIN)**。
85
90
 
86
91
  > 普通用户无需手动下载 GitHub Release 里的 `.whl`、`.tar.gz` 或 Source code,运行上面的 `pip install` 即可。文件区别和 SHA256 校验方法见[完整文档](https://redtidev1918.github.io/deviantart-downloader/#/?id=github-release-%e9%87%8c%e7%9a%84%e6%96%87%e4%bb%b6%e6%98%af%e4%bb%80%e4%b9%88)。
@@ -47,6 +47,11 @@ devart-dl whoami
47
47
  devart-dl login browser # 打开你的 Chrome 登录,自动保存 Cookie,无需 F12 复制
48
48
  ```
49
49
 
50
+ 下载或 resolve 遇到 401/403 时,工具会用同一代理与浏览器 UA 核对网页会话,
51
+ 区分会话已过期、证据暂不可用和已登录但作品访问被拒。网络失败或页面结构变化
52
+ 不会当作登出,也不会清除 Cookie;只有明确的未登录证据才提示刷新登录。
53
+ 这一步只诊断错误,不自动重试下载,也不保证已登录账号有权访问所有作品。
54
+
50
55
  完整图文式步骤见 **[登录与认证教程](https://redtidev1918.github.io/deviantart-downloader/#/LOGIN)**。
51
56
 
52
57
  > 普通用户无需手动下载 GitHub Release 里的 `.whl`、`.tar.gz` 或 Source code,运行上面的 `pip install` 即可。文件区别和 SHA256 校验方法见[完整文档](https://redtidev1918.github.io/deviantart-downloader/#/?id=github-release-%e9%87%8c%e7%9a%84%e6%96%87%e4%bb%b6%e6%98%af%e4%bb%80%e4%b9%88)。
@@ -0,0 +1,342 @@
1
+ """认证和 Cookie 管理模块
2
+
3
+ 网页会话验证逻辑移植自 DAViewer `lib/core/auth/web_session_verifier.dart`
4
+ (生产首页证据验证,2026-10 DAViewer 会话恢复工程):首页
5
+ ``window.__INITIAL_STATE__`` 的 redux 风格键 ``@@publicSession`` 声明
6
+ ``isLoggedIn`` 与 ``user.username``。三态判定:
7
+
8
+ - ``SIGNED_IN``:服务端确认登录且给出用户名;
9
+ - ``ANONYMOUS``:服务端明确声明未登录(``isLoggedIn: false`` 或
10
+ ``anonymous`` 身份);
11
+ - ``UNAVAILABLE``:网络失败 / 页面结构变化 / 证据缺失——绝不能与
12
+ "未登录"混为一谈。
13
+
14
+ ``isLoggedIn: true`` 但读不到用户名时保持未验证,不猜测。
15
+ """
16
+
17
+ import enum
18
+ import json
19
+ import logging
20
+ import os
21
+ from dataclasses import dataclass
22
+ from typing import Optional
23
+
24
+ import requests
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+ # 与 download.py 的 web provider 使用同一浏览器 UA(会话验证请求的 UA
29
+ # 必须与实际带 Cookie 抓取的请求一致,否则验证的不是同一个会话)。
30
+ _WEB_USER_AGENT = (
31
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
32
+ "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120 Safari/537.36"
33
+ )
34
+ _HOME_URL = "https://www.deviantart.com/"
35
+ _INITIAL_STATE_MARKER = "window.__INITIAL_STATE__ = JSON.parse("
36
+
37
+
38
+ class WebSessionState(enum.Enum):
39
+ """首页对当前 Cookie 会话的三态判定(服务端答案,不是本地猜测)。"""
40
+
41
+ SIGNED_IN = "signed_in"
42
+ ANONYMOUS = "anonymous"
43
+ UNAVAILABLE = "unavailable"
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class WebSessionVerification:
48
+ state: WebSessionState
49
+ username: str = ""
50
+
51
+ @property
52
+ def is_signed_in(self) -> bool:
53
+ return self.state == WebSessionState.SIGNED_IN
54
+
55
+
56
+ _JS_ESCAPES = {
57
+ '"': '"',
58
+ "'": "'",
59
+ "\\": "\\",
60
+ "/": "/",
61
+ "b": "\b",
62
+ "f": "\f",
63
+ "n": "\n",
64
+ "r": "\r",
65
+ "t": "\t",
66
+ }
67
+
68
+
69
+ def _json_parse_assignment(html: str, marker: str) -> Optional[object]:
70
+ """解析 ``JSON.parse('<json>')`` 形式的 JS 赋值,返回解析后的对象。
71
+
72
+ 引号内是 JS 字符串字面量(含 ``\\"`` 等 JS 转义),先做 JS 反转义再
73
+ ``json.loads``。找不到 marker 或无法解析时返回 ``None``。
74
+ """
75
+ idx = html.find(marker)
76
+ if idx == -1:
77
+ return None
78
+ rest = html[idx + len(marker) :].lstrip()
79
+ if not rest or rest[0] not in ("'", '"'):
80
+ return None
81
+ quote = rest[0]
82
+ out: list[str] = []
83
+ i = 1
84
+ length = len(rest)
85
+ while i < length:
86
+ ch = rest[i]
87
+ if ch == "\\" and i + 1 < length:
88
+ nxt = rest[i + 1]
89
+ if nxt == "u" and i + 6 <= length:
90
+ hex4 = rest[i + 2 : i + 6]
91
+ try:
92
+ out.append(chr(int(hex4, 16)))
93
+ i += 6
94
+ continue
95
+ except ValueError:
96
+ pass
97
+ out.append(_JS_ESCAPES.get(nxt, nxt))
98
+ i += 2
99
+ continue
100
+ if ch == quote:
101
+ try:
102
+ value: object = json.loads("".join(out))
103
+ return value
104
+ except json.JSONDecodeError:
105
+ return None
106
+ out.append(ch)
107
+ i += 1
108
+ return None
109
+
110
+
111
+ class AuthManager:
112
+ """认证管理器 - 处理 Cookie 和登录状态"""
113
+
114
+ def __init__(self, cookies_path: str = "cookies.txt"):
115
+ self.cookies_path = cookies_path
116
+ self.cookies: str = ""
117
+ self.is_logged_in: bool = False
118
+
119
+ def load_cookies(self) -> str:
120
+ """加载 Cookie 文件(支持 cookies.txt 和会话文件)"""
121
+ import json
122
+ from pathlib import Path
123
+
124
+ # 1. 尝试从会话文件加载
125
+ session_file = Path.home() / ".deviantart_dl" / "session.json"
126
+ if session_file.exists():
127
+ try:
128
+ with open(session_file, "r", encoding="utf-8") as f:
129
+ session_data = json.load(f)
130
+ cookies = str(session_data.get("cookies", ""))
131
+ if cookies:
132
+ logger.info(f"✓ Loaded cookies from session file: {session_file}")
133
+ self.cookies = cookies
134
+ return cookies
135
+ except Exception as e:
136
+ logger.debug(f"Failed to load session file: {e}")
137
+
138
+ # 2. 尝试从 cookies.txt 加载
139
+ if not os.path.isfile(self.cookies_path):
140
+ logger.info(
141
+ f"No cookie file found at {self.cookies_path} (optional for public content)"
142
+ )
143
+ return ""
144
+
145
+ try:
146
+ with open(self.cookies_path, "r", encoding="utf-8") as f:
147
+ cookies = f.read().strip()
148
+
149
+ # 移除注释行
150
+ lines = [
151
+ line.strip()
152
+ for line in cookies.split("\n")
153
+ if line.strip() and not line.strip().startswith("#")
154
+ ]
155
+ cookies = " ".join(lines)
156
+
157
+ if cookies:
158
+ logger.info(f"✓ Loaded cookies from: {self.cookies_path}")
159
+ self.cookies = cookies
160
+ return cookies
161
+ else:
162
+ logger.warning(f"Cookie file is empty: {self.cookies_path}")
163
+ return ""
164
+ except Exception as e:
165
+ logger.error(f"Error loading cookies: {e}")
166
+ return ""
167
+
168
+ def validate_cookies(self, cookies: Optional[str] = None) -> bool:
169
+ """验证 Cookie 是否包含认证信息"""
170
+ if cookies is None:
171
+ cookies = self.cookies
172
+
173
+ if not cookies:
174
+ return False
175
+
176
+ # 检查关键的认证 Cookie
177
+ auth_indicators = ["auth=", "auth_secure=", "userinfo="]
178
+ has_auth = any(indicator in cookies for indicator in auth_indicators)
179
+
180
+ if has_auth:
181
+ logger.info("✓ Cookies appear valid (contains auth tokens)")
182
+ else:
183
+ logger.warning("⚠ Cookies may be invalid or incomplete")
184
+
185
+ return has_auth
186
+
187
+ def verify_web_session(
188
+ self,
189
+ cookies: Optional[str] = None,
190
+ proxies: Optional[dict] = None,
191
+ timeout: float = 10.0,
192
+ ) -> WebSessionVerification:
193
+ """向 DeviantArt 首页确认当前 Cookie 会话(服务端答案,三态)。
194
+
195
+ 网络失败、非 200、页面结构变化一律 ``UNAVAILABLE``,绝不误判为
196
+ 未登录。仅在服务端明确声明未登录时返回 ``ANONYMOUS``。
197
+ """
198
+ if cookies is None:
199
+ cookies = self.cookies
200
+ headers = {
201
+ "Accept": "text/html,application/xhtml+xml",
202
+ "User-Agent": _WEB_USER_AGENT,
203
+ }
204
+ if cookies:
205
+ headers["Cookie"] = cookies
206
+ try:
207
+ response = requests.get(_HOME_URL, headers=headers, proxies=proxies, timeout=timeout)
208
+ except requests.RequestException as exc:
209
+ logger.debug(f"web session verify network failure: {exc}")
210
+ return WebSessionVerification(WebSessionState.UNAVAILABLE)
211
+ if response.status_code != 200:
212
+ logger.info("web session verify: HTTP %d -> unavailable", response.status_code)
213
+ return WebSessionVerification(WebSessionState.UNAVAILABLE)
214
+ username = self.page_username_from_initial_state(response.text)
215
+ if username == "anonymous":
216
+ verdict = WebSessionVerification(WebSessionState.ANONYMOUS)
217
+ elif username:
218
+ verdict = WebSessionVerification(WebSessionState.SIGNED_IN, username)
219
+ else:
220
+ verdict = WebSessionVerification(WebSessionState.UNAVAILABLE)
221
+ if verdict.state != WebSessionState.UNAVAILABLE:
222
+ self.is_logged_in = verdict.is_signed_in
223
+ logger.info("web session verify: %s", verdict.state.value)
224
+ return verdict
225
+
226
+ @staticmethod
227
+ def page_username_from_initial_state(html: str) -> Optional[str]:
228
+ """从首页 ``__INITIAL_STATE__`` 读取服务端渲染的用户名。
229
+
230
+ 返回 ``None`` 表示证据缺失或无法解析;``'anonymous'`` 表示服务端
231
+ 明确声明未登录(``isLoggedIn: false`` 或匿名身份);其余为已登录
232
+ 用户名。``isLoggedIn: true`` 但无用户名保持 ``None``,不猜测。
233
+ """
234
+ state = _json_parse_assignment(html, _INITIAL_STATE_MARKER)
235
+ if not isinstance(state, dict):
236
+ return None
237
+ session = state.get("@@publicSession") or state.get("@publicSession")
238
+ if not isinstance(session, dict):
239
+ return None
240
+ user = session.get("user")
241
+ raw_username = user.get("username") if isinstance(user, dict) else None
242
+ username = raw_username.strip() if isinstance(raw_username, str) else None
243
+ if session.get("isLoggedIn") is False:
244
+ return "anonymous"
245
+ return username
246
+
247
+ def reload_cookies(self) -> str:
248
+ """重新加载 Cookie"""
249
+ logger.info("Reloading cookies...")
250
+ return self.load_cookies()
251
+
252
+ @staticmethod
253
+ def show_login_guide() -> None:
254
+ """显示登录指南"""
255
+ guide = """
256
+ ╔══════════════════════════════════════════════════════════════════════╗
257
+ ║ HOW TO GET DEVIANTART COOKIES ║
258
+ ╚══════════════════════════════════════════════════════════════════════╝
259
+
260
+ 1. Open DeviantArt in your browser and LOGIN to your account
261
+ → https://www.deviantart.com
262
+
263
+ 2. Open Browser Developer Tools:
264
+ • Chrome/Edge: Press F12 or Ctrl+Shift+I (Cmd+Option+I on Mac)
265
+ • Firefox: Press F12 or Ctrl+Shift+I (Cmd+Option+I on Mac)
266
+
267
+ 3. Go to "Application" tab (Chrome) or "Storage" tab (Firefox)
268
+
269
+ 4. In the left sidebar, expand "Cookies" and click on:
270
+ → https://www.deviantart.com
271
+
272
+ 5. Copy ALL cookie values in this format:
273
+ cookie1=value1; cookie2=value2; cookie3=value3
274
+
275
+ Important cookies to include:
276
+ • auth
277
+ • auth_secure
278
+ • userinfo
279
+
280
+ 6. Paste the copied cookies into a file named "cookies.txt"
281
+ in the same folder as this script
282
+
283
+ Alternatively, you can use browser extensions like:
284
+ • "EditThisCookie" (Chrome)
285
+ • "Cookie-Editor" (Firefox/Chrome)
286
+
287
+ """
288
+ print(guide)
289
+
290
+ def prompt_for_cookies(self, require_auth: bool = False) -> bool:
291
+ """提示用户配置 Cookie"""
292
+ if not self.cookies:
293
+ print("! Running without cookies - only public content accessible")
294
+
295
+ if require_auth:
296
+ print("\n⚠ WARNING: This operation requires login!")
297
+ answer = input("Show login guide? (y/n): ").lower()
298
+ if answer in ["y", "yes"]:
299
+ self.show_login_guide()
300
+ input("\nPress Enter after setting up cookies, or Ctrl+C to exit...")
301
+ self.cookies = self.reload_cookies()
302
+ return bool(self.cookies)
303
+
304
+ elif not self.validate_cookies():
305
+ answer = input("Do you want to see the cookie setup guide? (y/n): ").lower()
306
+ if answer in ["y", "yes"]:
307
+ self.show_login_guide()
308
+
309
+ return bool(self.cookies)
310
+
311
+
312
+ def diagnose_authentication_failure(
313
+ cookies: str,
314
+ proxy: Optional[str] = None,
315
+ timeout: float = 10.0,
316
+ ) -> Optional[str]:
317
+ """401/403 之后的会话诊断:给出明确原因,而不是让用户猜。
318
+
319
+ 返回面向用户的英文提示(与 CLI 其余错误文案一致),无法诊断时返回
320
+ ``None``。绝不静默发起额外网络请求重试下载本身。
321
+ """
322
+ proxies = {"http": proxy, "https": proxy} if proxy else {}
323
+ if not cookies:
324
+ return (
325
+ "hint: no cookies were provided; mature/NSFW works need a login "
326
+ "session - run `devart-dl login browser`"
327
+ )
328
+ verdict = AuthManager().verify_web_session(cookies=cookies, proxies=proxies, timeout=timeout)
329
+ if verdict.state == WebSessionState.ANONYMOUS:
330
+ return (
331
+ "hint: the DeviantArt session has expired - run "
332
+ "`devart-dl login browser` to refresh it"
333
+ )
334
+ if verdict.state == WebSessionState.UNAVAILABLE:
335
+ return (
336
+ "hint: could not confirm the session state (network or page "
337
+ "challenge/change); stored cookies were not cleared"
338
+ )
339
+ return (
340
+ f"hint: the session is valid (signed in as {verdict.username}); "
341
+ "the request was denied, which may involve item permissions or a service challenge"
342
+ )
@@ -19,9 +19,9 @@ from importlib.metadata import PackageNotFoundError, version
19
19
  from pathlib import Path
20
20
  from typing import Optional, Sequence
21
21
 
22
- from .auth import AuthManager
22
+ from .auth import AuthManager, diagnose_authentication_failure
23
23
  from .download import build_downloader
24
- from .errors import DeviantArtError
24
+ from .errors import AuthenticationError, DeviantArtError
25
25
  from .oauth import (
26
26
  OAuthConfig,
27
27
  OAuthError,
@@ -138,6 +138,12 @@ def _execute(
138
138
  limit=limit,
139
139
  )
140
140
  outcomes = downloader.download(target)
141
+ except AuthenticationError as exc:
142
+ print(f"error: {exc}", file=sys.stderr)
143
+ hint = diagnose_authentication_failure(cookies, proxy=proxy, timeout=timeout)
144
+ if hint:
145
+ print(hint, file=sys.stderr)
146
+ return 1
141
147
  except (ValueError, DeviantArtError) as exc:
142
148
  print(f"error: {exc}", file=sys.stderr)
143
149
  return 1
@@ -200,7 +206,6 @@ def _run_download(arguments: Sequence[str]) -> int:
200
206
  )
201
207
 
202
208
 
203
-
204
209
  def _run_resolve(arguments: Sequence[str]) -> int:
205
210
  parser = argparse.ArgumentParser(
206
211
  prog="devart-dl resolve",
@@ -215,9 +220,10 @@ def _run_resolve(arguments: Sequence[str]) -> int:
215
220
  parser.add_argument("--limit", type=int, default=24)
216
221
  args = parser.parse_args(list(arguments))
217
222
  try:
223
+ cookies = _load_cookies(args.cookies)
218
224
  downloader = build_downloader(
219
225
  destination=Path("."),
220
- cookies=_load_cookies(args.cookies),
226
+ cookies=cookies,
221
227
  quality=args.quality,
222
228
  proxy=args.proxy,
223
229
  timeout=args.timeout,
@@ -225,6 +231,12 @@ def _run_resolve(arguments: Sequence[str]) -> int:
225
231
  limit=args.limit,
226
232
  )
227
233
  works = downloader.resolve(args.target)
234
+ except AuthenticationError as exc:
235
+ print(f"error: {exc}", file=sys.stderr)
236
+ hint = diagnose_authentication_failure(cookies, proxy=args.proxy, timeout=args.timeout)
237
+ if hint:
238
+ print(hint, file=sys.stderr)
239
+ return 1
228
240
  except (ValueError, DeviantArtError) as exc:
229
241
  print(f"error: {exc}", file=sys.stderr)
230
242
  return 1
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devart-dl
3
- Version: 4.4.0
3
+ Version: 4.4.2
4
4
  Summary: 专业的 DeviantArt 作品批量下载工具 - Professional DeviantArt batch downloader with anti-ban features
5
5
  Author: DeviantArt Downloader Team
6
6
  License-Expression: MIT
@@ -81,6 +81,11 @@ devart-dl whoami
81
81
  devart-dl login browser # 打开你的 Chrome 登录,自动保存 Cookie,无需 F12 复制
82
82
  ```
83
83
 
84
+ 下载或 resolve 遇到 401/403 时,工具会用同一代理与浏览器 UA 核对网页会话,
85
+ 区分会话已过期、证据暂不可用和已登录但作品访问被拒。网络失败或页面结构变化
86
+ 不会当作登出,也不会清除 Cookie;只有明确的未登录证据才提示刷新登录。
87
+ 这一步只诊断错误,不自动重试下载,也不保证已登录账号有权访问所有作品。
88
+
84
89
  完整图文式步骤见 **[登录与认证教程](https://redtidev1918.github.io/deviantart-downloader/#/LOGIN)**。
85
90
 
86
91
  > 普通用户无需手动下载 GitHub Release 里的 `.whl`、`.tar.gz` 或 Source code,运行上面的 `pip install` 即可。文件区别和 SHA256 校验方法见[完整文档](https://redtidev1918.github.io/deviantart-downloader/#/?id=github-release-%e9%87%8c%e7%9a%84%e6%96%87%e4%bb%b6%e6%98%af%e4%bb%80%e4%b9%88)。
@@ -40,4 +40,5 @@ tests/test_manager.py
40
40
  tests/test_official_provider.py
41
41
  tests/test_path.py
42
42
  tests/test_provider.py
43
- tests/test_targets.py
43
+ tests/test_targets.py
44
+ tests/test_web_session_verify.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "devart-dl"
7
- version = "4.4.0"
7
+ version = "4.4.2"
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,219 @@
1
+ """Web session verification tests (tri-state verdict).
2
+
3
+ Fixture origin: fixtures/deviantart-home-evidence.md records the independent
4
+ 2026-10-13 anonymous home capture. Signed-in/malformed samples are synthetic
5
+ mutations for policy checks; they do not prove real authenticated access.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from unittest.mock import patch
11
+ from pathlib import Path
12
+
13
+ import pytest
14
+ import requests
15
+
16
+ from da_downloader.auth import (
17
+ AuthManager,
18
+ WebSessionState,
19
+ diagnose_authentication_failure,
20
+ )
21
+
22
+ # --- page_username_from_initial_state (ported production evidence) ---
23
+
24
+
25
+ def test_parse_accepts_javascript_escapes() -> None:
26
+ html = r"""<script>window.__INITIAL_STATE__ = JSON.parse("{\"@@publicSession\":{\"isLoggedIn\":true,\"user\":{\"username\":\"artist\"}},\"title\":\"artist\'s work\"}");</script>"""
27
+ assert AuthManager.page_username_from_initial_state(html) == "artist"
28
+
29
+
30
+ def test_single_quoted_initializer_keeps_anonymous_distinct_from_missing() -> None:
31
+ html = (
32
+ """<script>window.__INITIAL_STATE__ = JSON.parse('"""
33
+ """{"@@publicSession":{"isLoggedIn":false,"user":{"username":"anonymous"}}}"""
34
+ """');</script>"""
35
+ )
36
+ assert AuthManager.page_username_from_initial_state(html) == "anonymous"
37
+ assert AuthManager.page_username_from_initial_state("<html/>") is None
38
+
39
+
40
+ def test_legacy_single_at_spelling_still_parses() -> None:
41
+ html = r"""<script>window.__INITIAL_STATE__ = JSON.parse("{\"@publicSession\":{\"user\":{\"username\":\"artist\"}}}");</script>"""
42
+ assert AuthManager.page_username_from_initial_state(html) == "artist"
43
+
44
+
45
+ def test_is_logged_in_false_without_username_is_a_signed_out_statement() -> None:
46
+ html = r"""<script>window.__INITIAL_STATE__ = JSON.parse("{\"@@publicSession\":{\"isLoggedIn\":false}}");</script>"""
47
+ assert AuthManager.page_username_from_initial_state(html) == "anonymous"
48
+
49
+
50
+ def test_is_logged_in_true_without_username_stays_unverified() -> None:
51
+ html = r"""<script>window.__INITIAL_STATE__ = JSON.parse("{\"@@publicSession\":{\"isLoggedIn\":true}}");</script>"""
52
+ assert AuthManager.page_username_from_initial_state(html) is None
53
+
54
+
55
+ @pytest.mark.parametrize(
56
+ "fixture",
57
+ [
58
+ 'window.__INITIAL_STATE__ = JSON.parse("broken");',
59
+ 'window.__INITIAL_STATE__ = JSON.parse("{}");',
60
+ 'window.__INITIAL_STATE__ = JSON.parse("{\\"@@publicSession\\":{}}");',
61
+ ],
62
+ )
63
+ def test_incomplete_or_malformed_state_is_none(fixture: str) -> None:
64
+ assert AuthManager.page_username_from_initial_state(fixture) is None
65
+
66
+
67
+ # --- verify_web_session (tri-state over a mocked home page) ---
68
+
69
+
70
+ class _FakeResponse:
71
+ def __init__(self, status_code: int, text: str) -> None:
72
+ self.status_code = status_code
73
+ self.text = text
74
+
75
+
76
+ def _verify(html: str = "", status: int = 200, exc: Exception | None = None):
77
+ manager = AuthManager()
78
+
79
+ def fake_get(url, **kwargs):
80
+ assert url == "https://www.deviantart.com/"
81
+ assert kwargs["headers"]["User-Agent"].startswith("Mozilla/5.0")
82
+ assert kwargs["headers"]["Cookie"] == "auth=x"
83
+ if exc is not None:
84
+ raise exc
85
+ return _FakeResponse(status, html)
86
+
87
+ with patch("da_downloader.auth.requests.get", side_effect=fake_get):
88
+ return manager, manager.verify_web_session(cookies="auth=x")
89
+
90
+
91
+ _SIGNED_IN_HTML = (
92
+ r"<script>window.__INITIAL_STATE__ = JSON.parse("
93
+ r'"{\"@@publicSession\":{\"isLoggedIn\":true,\"user\":{\"username\":\"alice\"}}}");</script>'
94
+ )
95
+ _ANONYMOUS_HTML = (Path(__file__).parent / "fixtures" / "deviantart-home-anonymous.html").read_text(
96
+ encoding="utf-8"
97
+ )
98
+
99
+
100
+ def test_verify_signed_in_with_username() -> None:
101
+ manager, verdict = _verify(_SIGNED_IN_HTML)
102
+ assert verdict.state == WebSessionState.SIGNED_IN
103
+ assert verdict.username == "alice"
104
+ assert manager.is_logged_in is True
105
+
106
+
107
+ def test_verify_anonymous_is_a_server_statement() -> None:
108
+ manager, verdict = _verify(_ANONYMOUS_HTML)
109
+ assert verdict.state == WebSessionState.ANONYMOUS
110
+ assert manager.is_logged_in is False
111
+
112
+
113
+ def test_verify_network_failure_is_unavailable_not_anonymous() -> None:
114
+ manager, verdict = _verify(exc=requests.ConnectionError("boom"))
115
+ assert verdict.state == WebSessionState.UNAVAILABLE
116
+ assert manager.is_logged_in is False
117
+
118
+
119
+ @pytest.mark.parametrize("status", [202, 403, 500])
120
+ def test_verify_non_200_is_unavailable(status: int) -> None:
121
+ _, verdict = _verify("challenge", status=status)
122
+ assert verdict.state == WebSessionState.UNAVAILABLE
123
+
124
+
125
+ def test_verify_missing_marker_is_unavailable() -> None:
126
+ _, verdict = _verify("<html>waf challenge</html>")
127
+ assert verdict.state == WebSessionState.UNAVAILABLE
128
+
129
+
130
+ def test_unavailable_page_does_not_erase_prior_identity() -> None:
131
+ manager = AuthManager()
132
+ manager.is_logged_in = True
133
+ with patch("da_downloader.auth.requests.get", return_value=_FakeResponse(200, "challenge")):
134
+ assert manager.verify_web_session().state == WebSessionState.UNAVAILABLE
135
+ assert manager.is_logged_in is True
136
+
137
+
138
+ # --- diagnose_authentication_failure (401/403 hint text) ---
139
+
140
+
141
+ def test_diagnose_without_cookies_suggests_login() -> None:
142
+ hint = diagnose_authentication_failure("")
143
+ assert hint is not None
144
+ assert "login browser" in hint
145
+
146
+
147
+ def test_diagnose_expired_session(monkeypatch) -> None:
148
+ monkeypatch.setattr(
149
+ AuthManager,
150
+ "verify_web_session",
151
+ lambda self, cookies, proxies, timeout: type(
152
+ "V", (), {"state": WebSessionState.ANONYMOUS, "username": ""}
153
+ )(),
154
+ )
155
+ hint = diagnose_authentication_failure("auth=old")
156
+ assert hint is not None
157
+ assert "expired" in hint
158
+
159
+
160
+ def test_diagnose_unavailable_session(monkeypatch) -> None:
161
+ monkeypatch.setattr(
162
+ AuthManager,
163
+ "verify_web_session",
164
+ lambda self, cookies, proxies, timeout: type(
165
+ "V", (), {"state": WebSessionState.UNAVAILABLE, "username": ""}
166
+ )(),
167
+ )
168
+ hint = diagnose_authentication_failure("auth=old")
169
+ assert hint is not None
170
+ assert "could not confirm" in hint
171
+
172
+
173
+ def test_diagnose_valid_session_points_at_the_item(monkeypatch) -> None:
174
+ monkeypatch.setattr(
175
+ AuthManager,
176
+ "verify_web_session",
177
+ lambda self, cookies, proxies, timeout: type(
178
+ "V", (), {"state": WebSessionState.SIGNED_IN, "username": "alice"}
179
+ )(),
180
+ )
181
+ hint = diagnose_authentication_failure("auth=ok")
182
+ assert hint is not None
183
+ assert "signed in as alice" in hint
184
+ assert "denied" in hint
185
+
186
+
187
+ # --- CLI integration: AuthenticationError prints the diagnosis ---
188
+
189
+
190
+ def test_run_download_authentication_error_prints_hint(monkeypatch, capsys, tmp_path) -> None:
191
+ from da_downloader.errors import AuthenticationError
192
+
193
+ def boom(**kwargs):
194
+ raise AuthenticationError("HTTP 403 forbidden: https://example/x")
195
+
196
+ monkeypatch.setattr(cli_module(), "build_downloader", boom)
197
+ monkeypatch.setattr(cli_module(), "_load_cookies", lambda p: "auth=x")
198
+ hints: list = []
199
+ monkeypatch.setattr(
200
+ cli_module(),
201
+ "diagnose_authentication_failure",
202
+ lambda *a, **kw: hints.append((a, kw)) or "hint: session expired",
203
+ )
204
+
205
+ rc = cli_module()._run_download(
206
+ ["https://www.deviantart.com/alice/gallery", "--dest", str(tmp_path)]
207
+ )
208
+
209
+ assert rc == 1
210
+ err = capsys.readouterr().err
211
+ assert "error: HTTP 403 forbidden" in err
212
+ assert "hint: session expired" in err
213
+ assert hints and hints[0][0][0] == "auth=x"
214
+
215
+
216
+ def cli_module():
217
+ from da_downloader import cli
218
+
219
+ return cli
@@ -1,169 +0,0 @@
1
- """认证和 Cookie 管理模块"""
2
-
3
- import os
4
- import logging
5
- from typing import Optional
6
- import requests
7
-
8
-
9
- logger = logging.getLogger(__name__)
10
-
11
-
12
- class AuthManager:
13
- """认证管理器 - 处理 Cookie 和登录状态"""
14
-
15
- def __init__(self, cookies_path: str = "cookies.txt"):
16
- self.cookies_path = cookies_path
17
- self.cookies: str = ""
18
- self.is_logged_in: bool = False
19
-
20
- def load_cookies(self) -> str:
21
- """加载 Cookie 文件(支持 cookies.txt 和会话文件)"""
22
- import json
23
- from pathlib import Path
24
-
25
- # 1. 尝试从会话文件加载
26
- session_file = Path.home() / '.deviantart_dl' / 'session.json'
27
- if session_file.exists():
28
- try:
29
- with open(session_file, 'r', encoding='utf-8') as f:
30
- session_data = json.load(f)
31
- cookies = str(session_data.get('cookies', ''))
32
- if cookies:
33
- logger.info(f"✓ Loaded cookies from session file: {session_file}")
34
- self.cookies = cookies
35
- return cookies
36
- except Exception as e:
37
- logger.debug(f"Failed to load session file: {e}")
38
-
39
- # 2. 尝试从 cookies.txt 加载
40
- if not os.path.isfile(self.cookies_path):
41
- logger.info(f"No cookie file found at {self.cookies_path} (optional for public content)")
42
- return ""
43
-
44
- try:
45
- with open(self.cookies_path, 'r', encoding='utf-8') as f:
46
- cookies = f.read().strip()
47
-
48
- # 移除注释行
49
- lines = [line.strip() for line in cookies.split('\n')
50
- if line.strip() and not line.strip().startswith('#')]
51
- cookies = ' '.join(lines)
52
-
53
- if cookies:
54
- logger.info(f"✓ Loaded cookies from: {self.cookies_path}")
55
- self.cookies = cookies
56
- return cookies
57
- else:
58
- logger.warning(f"Cookie file is empty: {self.cookies_path}")
59
- return ""
60
- except Exception as e:
61
- logger.error(f"Error loading cookies: {e}")
62
- return ""
63
-
64
- def validate_cookies(self, cookies: Optional[str] = None) -> bool:
65
- """验证 Cookie 是否包含认证信息"""
66
- if cookies is None:
67
- cookies = self.cookies
68
-
69
- if not cookies:
70
- return False
71
-
72
- # 检查关键的认证 Cookie
73
- auth_indicators = ['auth=', 'auth_secure=', 'userinfo=']
74
- has_auth = any(indicator in cookies for indicator in auth_indicators)
75
-
76
- if has_auth:
77
- logger.info("✓ Cookies appear valid (contains auth tokens)")
78
- else:
79
- logger.warning("⚠ Cookies may be invalid or incomplete")
80
-
81
- return has_auth
82
-
83
- def check_login_status(self, headers: dict, proxies: dict) -> bool:
84
- """检查登录状态"""
85
- try:
86
- response = requests.get(
87
- 'https://www.deviantart.com',
88
- headers=headers,
89
- proxies=proxies,
90
- timeout=10
91
- )
92
-
93
- if 'window.__CSRF_TOKEN__' in response.text:
94
- if 'data-userid' in response.text or '"isLoggedIn":true' in response.text:
95
- logger.info("✓ Successfully logged in!")
96
- self.is_logged_in = True
97
- return True
98
- else:
99
- logger.info("! Not logged in (public access only)")
100
- self.is_logged_in = False
101
- return False
102
- return False
103
- except Exception as e:
104
- logger.warning(f"Could not verify login status: {e}")
105
- return False
106
-
107
- def reload_cookies(self) -> str:
108
- """重新加载 Cookie"""
109
- logger.info("Reloading cookies...")
110
- return self.load_cookies()
111
-
112
- @staticmethod
113
- def show_login_guide() -> None:
114
- """显示登录指南"""
115
- guide = '''
116
- ╔══════════════════════════════════════════════════════════════════════╗
117
- ║ HOW TO GET DEVIANTART COOKIES ║
118
- ╚══════════════════════════════════════════════════════════════════════╝
119
-
120
- 1. Open DeviantArt in your browser and LOGIN to your account
121
- → https://www.deviantart.com
122
-
123
- 2. Open Browser Developer Tools:
124
- • Chrome/Edge: Press F12 or Ctrl+Shift+I (Cmd+Option+I on Mac)
125
- • Firefox: Press F12 or Ctrl+Shift+I (Cmd+Option+I on Mac)
126
-
127
- 3. Go to "Application" tab (Chrome) or "Storage" tab (Firefox)
128
-
129
- 4. In the left sidebar, expand "Cookies" and click on:
130
- → https://www.deviantart.com
131
-
132
- 5. Copy ALL cookie values in this format:
133
- cookie1=value1; cookie2=value2; cookie3=value3
134
-
135
- Important cookies to include:
136
- • auth
137
- • auth_secure
138
- • userinfo
139
-
140
- 6. Paste the copied cookies into a file named "cookies.txt"
141
- in the same folder as this script
142
-
143
- Alternatively, you can use browser extensions like:
144
- • "EditThisCookie" (Chrome)
145
- • "Cookie-Editor" (Firefox/Chrome)
146
-
147
- '''
148
- print(guide)
149
-
150
- def prompt_for_cookies(self, require_auth: bool = False) -> bool:
151
- """提示用户配置 Cookie"""
152
- if not self.cookies:
153
- print("! Running without cookies - only public content accessible")
154
-
155
- if require_auth:
156
- print("\n⚠ WARNING: This operation requires login!")
157
- answer = input("Show login guide? (y/n): ").lower()
158
- if answer in ['y', 'yes']:
159
- self.show_login_guide()
160
- input("\nPress Enter after setting up cookies, or Ctrl+C to exit...")
161
- self.cookies = self.reload_cookies()
162
- return bool(self.cookies)
163
-
164
- elif not self.validate_cookies():
165
- answer = input("Do you want to see the cookie setup guide? (y/n): ").lower()
166
- if answer in ['y', 'yes']:
167
- self.show_login_guide()
168
-
169
- return bool(self.cookies)
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