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.
- {devart_dl-4.4.0/devart_dl.egg-info → devart_dl-4.4.2}/PKG-INFO +6 -1
- {devart_dl-4.4.0 → devart_dl-4.4.2}/README.md +5 -0
- devart_dl-4.4.2/da_downloader/auth.py +342 -0
- {devart_dl-4.4.0 → devart_dl-4.4.2}/da_downloader/cli.py +16 -4
- {devart_dl-4.4.0 → devart_dl-4.4.2/devart_dl.egg-info}/PKG-INFO +6 -1
- {devart_dl-4.4.0 → devart_dl-4.4.2}/devart_dl.egg-info/SOURCES.txt +2 -1
- {devart_dl-4.4.0 → devart_dl-4.4.2}/pyproject.toml +1 -1
- devart_dl-4.4.2/tests/test_web_session_verify.py +219 -0
- devart_dl-4.4.0/da_downloader/auth.py +0 -169
- {devart_dl-4.4.0 → devart_dl-4.4.2}/.env.example +0 -0
- {devart_dl-4.4.0 → devart_dl-4.4.2}/LICENSE +0 -0
- {devart_dl-4.4.0 → devart_dl-4.4.2}/MANIFEST.in +0 -0
- {devart_dl-4.4.0 → devart_dl-4.4.2}/README.en.md +0 -0
- {devart_dl-4.4.0 → devart_dl-4.4.2}/bin/devart-dl +0 -0
- {devart_dl-4.4.0 → devart_dl-4.4.2}/bin/install.sh +0 -0
- {devart_dl-4.4.0 → devart_dl-4.4.2}/da_downloader/__init__.py +0 -0
- {devart_dl-4.4.0 → devart_dl-4.4.2}/da_downloader/api.py +0 -0
- {devart_dl-4.4.0 → devart_dl-4.4.2}/da_downloader/archive.py +0 -0
- {devart_dl-4.4.0 → devart_dl-4.4.2}/da_downloader/da_cookie_login.mjs +0 -0
- {devart_dl-4.4.0 → devart_dl-4.4.2}/da_downloader/download.py +0 -0
- {devart_dl-4.4.0 → devart_dl-4.4.2}/da_downloader/errors.py +0 -0
- {devart_dl-4.4.0 → devart_dl-4.4.2}/da_downloader/http.py +0 -0
- {devart_dl-4.4.0 → devart_dl-4.4.2}/da_downloader/manager.py +0 -0
- {devart_dl-4.4.0 → devart_dl-4.4.2}/da_downloader/models.py +0 -0
- {devart_dl-4.4.0 → devart_dl-4.4.2}/da_downloader/oauth.py +0 -0
- {devart_dl-4.4.0 → devart_dl-4.4.2}/da_downloader/official_api.py +0 -0
- {devart_dl-4.4.0 → devart_dl-4.4.2}/da_downloader/path.py +0 -0
- {devart_dl-4.4.0 → devart_dl-4.4.2}/da_downloader/provider.py +0 -0
- {devart_dl-4.4.0 → devart_dl-4.4.2}/da_downloader/targets.py +0 -0
- {devart_dl-4.4.0 → devart_dl-4.4.2}/devart_dl.egg-info/dependency_links.txt +0 -0
- {devart_dl-4.4.0 → devart_dl-4.4.2}/devart_dl.egg-info/entry_points.txt +0 -0
- {devart_dl-4.4.0 → devart_dl-4.4.2}/devart_dl.egg-info/requires.txt +0 -0
- {devart_dl-4.4.0 → devart_dl-4.4.2}/devart_dl.egg-info/top_level.txt +0 -0
- {devart_dl-4.4.0 → devart_dl-4.4.2}/requirements.txt +0 -0
- {devart_dl-4.4.0 → devart_dl-4.4.2}/setup.cfg +0 -0
- {devart_dl-4.4.0 → devart_dl-4.4.2}/setup.py +0 -0
- {devart_dl-4.4.0 → devart_dl-4.4.2}/tests/test_api.py +0 -0
- {devart_dl-4.4.0 → devart_dl-4.4.2}/tests/test_archive.py +0 -0
- {devart_dl-4.4.0 → devart_dl-4.4.2}/tests/test_cli.py +0 -0
- {devart_dl-4.4.0 → devart_dl-4.4.2}/tests/test_conformance.py +0 -0
- {devart_dl-4.4.0 → devart_dl-4.4.2}/tests/test_download.py +0 -0
- {devart_dl-4.4.0 → devart_dl-4.4.2}/tests/test_http_downloader.py +0 -0
- {devart_dl-4.4.0 → devart_dl-4.4.2}/tests/test_manager.py +0 -0
- {devart_dl-4.4.0 → devart_dl-4.4.2}/tests/test_official_provider.py +0 -0
- {devart_dl-4.4.0 → devart_dl-4.4.2}/tests/test_path.py +0 -0
- {devart_dl-4.4.0 → devart_dl-4.4.2}/tests/test_provider.py +0 -0
- {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.
|
|
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=
|
|
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.
|
|
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)。
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "devart-dl"
|
|
7
|
-
version = "4.4.
|
|
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
|
|
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
|