Skip to content

Commit 2efcd89

Browse files
authored
fix: 修复私有资源下载鉴权与镜像重试 (#79)
* fix: 为私有资源请求附加访问令牌 * feat: 支持私有下载镜像自动重试 * fix: 完善私有资源鉴权错误提示
1 parent 3aafb97 commit 2efcd89

3 files changed

Lines changed: 216 additions & 10 deletions

File tree

src/tchmaterial_parser/network.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,5 +11,6 @@
1111
"Origin": "https://basic.smartedu.cn",
1212
"Referer": "https://basic.smartedu.cn/",
1313
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36",
14-
"X-ND-AUTH": 'MAC id="0",nonce="0",mac="0"', # “MAC id” 即为 Access Token,“nonce” 和 “mac” 不可缺省但可为任意非空值
14+
# 保留旧版匿名请求格式;设置 Token 后,私有 CDN 的主要鉴权入口由下载 URL 的 accessToken 参数提供。
15+
"X-ND-AUTH": 'MAC id="0",nonce="0",mac="0"',
1516
}

src/tchmaterial_parser/ui/download_panel.py

Lines changed: 102 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,13 @@
22
# 下载面板:解析并复制直链、下载资源文件与进度反馈
33
# 本模块持有与下载相关的几个控件句柄,因此这些控件的读写不必跨模块
44

5-
import os, traceback
5+
import os, re, traceback
66
import tkinter as tk
77
from tkinter import ttk, messagebox, filedialog
8+
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
9+
from xml.etree import ElementTree
10+
11+
from requests import RequestException
812

913
from .runtime import thread_it, ui_call
1014
from .. import config
@@ -14,6 +18,96 @@
1418
from ..platform_utils import print_error
1519

1620
download_states: list[dict] = [] # 初始化下载状态
21+
PRIVATE_DOWNLOAD_HOSTS = tuple(f"r{index}-ndr-private.ykt.cbern.com.cn" for index in range(1, 4))
22+
23+
def redact_access_token(text: str) -> str:
24+
"""隐藏 URL 查询参数里的 Token,防止网络异常把凭据带入日志或弹窗。"""
25+
return re.sub(r"([?&]accessToken=)[^&\s'\"]+", r"\1<已隐藏>", text, flags=re.IGNORECASE)
26+
27+
def authenticated_download_url(url: str) -> str:
28+
"""仅在真正发起请求时为私有资源附加 Token,避免把凭据写入状态或错误信息。"""
29+
parts = urlsplit(url)
30+
hostname = parts.hostname or ""
31+
if not config.access_token or not hostname.endswith("-ndr-private.ykt.cbern.com.cn"):
32+
return url
33+
34+
query = [(name, value) for name, value in parse_qsl(parts.query, keep_blank_values=True) if name != "accessToken"]
35+
query.append(("accessToken", config.access_token))
36+
return urlunsplit((parts.scheme, parts.netloc, parts.path, urlencode(query), parts.fragment))
37+
38+
def download_mirror_urls(url: str) -> list[str]:
39+
"""按原地址优先的顺序生成私有 CDN 镜像,普通下载地址保持不变。"""
40+
parts = urlsplit(url)
41+
hostname = parts.hostname or ""
42+
if hostname not in PRIVATE_DOWNLOAD_HOSTS:
43+
return [url]
44+
45+
ordered_hosts = [hostname, *(host for host in PRIVATE_DOWNLOAD_HOSTS if host != hostname)]
46+
return [urlunsplit((parts.scheme, host, parts.path, parts.query, parts.fragment)) for host in ordered_hosts]
47+
48+
def request_download(url: str):
49+
"""请求资源并在镜像出错时自动切换,返回最终响应和已尝试的无凭据地址。"""
50+
attempted_urls: list[str] = []
51+
last_response = None
52+
last_exception: RequestException | None = None
53+
54+
for candidate_url in download_mirror_urls(url):
55+
attempted_urls.append(candidate_url)
56+
try:
57+
response = session.get(authenticated_download_url(candidate_url), headers=headers, stream=True)
58+
except RequestException as e:
59+
last_exception = e
60+
continue
61+
62+
if response.ok:
63+
if last_response is not None:
64+
last_response.close()
65+
return response, attempted_urls
66+
67+
if last_response is not None:
68+
last_response.close()
69+
last_response = response
70+
71+
# 认证失败通常与镜像无关,立即返回以免重复请求。
72+
if response.status_code in (401, 403):
73+
break
74+
75+
if last_response is not None:
76+
return last_response, attempted_urls
77+
if last_exception is not None:
78+
# requests 的异常文字通常包含完整请求 URL,此处重新包装以清除查询参数中的 Token。
79+
raise RuntimeError(redact_access_token(str(last_exception))) from None
80+
raise RuntimeError("没有可用的下载地址")
81+
82+
def storage_error_code(response) -> str | None:
83+
"""读取对象存储返回的 XML 错误码;非 XML 响应保持原有通用提示。"""
84+
try:
85+
root = ElementTree.fromstring(response.content)
86+
return root.findtext("Code")
87+
except (AttributeError, ElementTree.ParseError, TypeError):
88+
return None
89+
90+
def download_failure_reason(response, attempted_urls: list[str]) -> str:
91+
status_code = response.status_code
92+
error_code = storage_error_code(response)
93+
reason = f"服务器返回 HTTP 状态码 {status_code}"
94+
if error_code:
95+
reason += f"({error_code})"
96+
97+
if status_code in (401, 403):
98+
if config.access_token:
99+
reason += ",Access Token 可能已过期或无效,请重新设置"
100+
else:
101+
reason += ",该资源需要有效的 Access Token,请先设置"
102+
elif status_code == 400 and error_code == "InvalidArgument":
103+
if config.access_token:
104+
reason += ",私有资源鉴权失败,Access Token 可能已过期或无效,请重新设置"
105+
else:
106+
reason += ",该私有资源需要有效的 Access Token,请先设置"
107+
108+
if len(attempted_urls) > 1:
109+
reason += f",已尝试 {len(attempted_urls)} 个下载镜像"
110+
return reason
17111

18112
def bind_widgets(text: tk.Text, bookmark: tk.BooleanVar, button: ttk.Button, progress_bar: ttk.Progressbar, label: ttk.Label) -> None: # 由 app.py 在创建控件后写入
19113
global url_text, bookmark_var, download_btn, download_progress_bar, progress_label
@@ -115,12 +209,13 @@ def download_file(url: str, save_path: str, chapters: list[dict] | None = None)
115209
download_states.append(current_state)
116210
temp_path = f"{save_path}.tmp"
117211

212+
response = None
118213
try:
119-
response = session.get(url, headers=headers, stream=True)
214+
response, attempted_urls = request_download(url)
120215

121216
if not response.ok: # 服务器返回表示错误的 HTTP 状态码
122217
current_state["finished"] = True
123-
current_state["failed_reason"] = f"服务器返回 HTTP 状态码 {response.status_code}" + (",Access Token 可能已过期或无效,请重新设置" if response.status_code in (401, 403) else "")
218+
current_state["failed_reason"] = download_failure_reason(response, attempted_urls)
124219
else:
125220
current_state["total_size"] = int(response.headers.get("Content-Length", 0))
126221

@@ -161,11 +256,14 @@ def download_file(url: str, save_path: str, chapters: list[dict] | None = None)
161256
print_error(e)
162257
current_state["downloaded_size"], current_state["total_size"] = 0, 0
163258
current_state["finished"] = True
164-
current_state["failed_reason"] = traceback.format_exc().rstrip()
259+
current_state["failed_reason"] = redact_access_token(traceback.format_exc().rstrip())
165260
try:
166261
os.remove(temp_path)
167262
except Exception:
168263
pass
264+
finally:
265+
if response is not None:
266+
response.close()
169267

170268
if all(state["finished"] for state in download_states): # 所有文件下载完成
171269
ui_call(download_progress_bar.config, value=0) # 重置进度条

tests/test_download.py

Lines changed: 112 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,49 @@
11
import unittest
22

3+
from requests import ConnectionError
4+
35
from src.tchmaterial_parser.ui import download_panel, runtime
46

57

68
class FakeResponse:
7-
def __init__(self, status_code: int) -> None:
8-
self.ok = False
9+
def __init__(self, status_code: int, content: bytes = b"") -> None:
10+
self.ok = status_code < 400
911
self.status_code = status_code
12+
self.content = content
13+
14+
def close(self) -> None:
15+
pass
1016

1117

1218
class FakeSession:
13-
def __init__(self, status_code: int) -> None:
14-
self.status_code = status_code
19+
def __init__(self, status_code: int | list[int]) -> None:
20+
self.status_codes = status_code if isinstance(status_code, list) else [status_code]
21+
self.requested_urls: list[str] = []
1522

1623
def get(self, *args: tuple, **kwargs: dict) -> FakeResponse:
17-
return FakeResponse(self.status_code)
24+
self.requested_urls.append(args[0])
25+
status_code = self.status_codes[min(len(self.requested_urls) - 1, len(self.status_codes) - 1)]
26+
return FakeResponse(status_code)
1827

1928

2029
class FakeWidget:
2130
def config(self, **kwargs: dict) -> None:
2231
pass
2332

2433

34+
class FailingSession:
35+
def get(self, url: str, **kwargs: dict) -> FakeResponse:
36+
raise ConnectionError(f"无法访问 {url}")
37+
38+
2539
class DownloadFailureTest(unittest.TestCase):
2640
def setUp(self) -> None:
2741
# 置为关闭状态后 ui_call() 不会真正执行回调,因此桩控件只需提供 config 属性
2842
runtime.app_closing = True
2943
self.addCleanup(setattr, runtime, "app_closing", False)
3044
self.addCleanup(setattr, download_panel, "session", download_panel.session)
45+
previous_token = download_panel.config.access_token
46+
self.addCleanup(setattr, download_panel.config, "access_token", previous_token)
3147
widget = FakeWidget()
3248
download_panel.bind_widgets(widget, widget, widget, widget, widget)
3349

@@ -41,13 +57,104 @@ def test_reports_server_errors_unrelated_to_the_token(self) -> None:
4157
self.assertEqual(self.failure_reason(404), "服务器返回 HTTP 状态码 404")
4258

4359
def test_appends_a_token_hint_to_authentication_failures(self) -> None:
60+
download_panel.config.access_token = "private-token"
4461
for status_code in (401, 403):
4562
with self.subTest(status_code=status_code):
4663
self.assertEqual(
4764
self.failure_reason(status_code),
4865
f"服务器返回 HTTP 状态码 {status_code},Access Token 可能已过期或无效,请重新设置",
4966
)
5067

68+
def test_asks_for_token_when_anonymous_request_requires_authentication(self) -> None:
69+
download_panel.config.access_token = None
70+
self.assertEqual(
71+
self.failure_reason(401),
72+
"服务器返回 HTTP 状态码 401,该资源需要有效的 Access Token,请先设置",
73+
)
74+
75+
def test_adds_access_token_only_to_private_request_url(self) -> None:
76+
token = "private-token"
77+
download_panel.config.access_token = token
78+
fake_session = FakeSession(404)
79+
download_panel.session = fake_session
80+
original_url = "https://r1-ndr-private.ykt.cbern.com.cn/book.pdf?source=catalog"
81+
82+
download_panel.download_states = []
83+
download_panel.download_file(original_url, "book.pdf")
84+
85+
requested_url = fake_session.requested_urls[0]
86+
self.assertIn("source=catalog", requested_url)
87+
self.assertIn("accessToken=private-token", requested_url)
88+
self.assertEqual(download_panel.download_states[0]["download_url"], original_url)
89+
self.assertNotIn(token, download_panel.download_states[0]["failed_reason"])
90+
91+
def test_keeps_anonymous_and_non_private_urls_unchanged(self) -> None:
92+
private_url = "https://r1-ndr-private.ykt.cbern.com.cn/book.pdf"
93+
public_url = "https://example.com/book.pdf"
94+
95+
download_panel.config.access_token = None
96+
self.assertEqual(download_panel.authenticated_download_url(private_url), private_url)
97+
98+
download_panel.config.access_token = "private-token"
99+
self.assertEqual(download_panel.authenticated_download_url(public_url), public_url)
100+
101+
def test_retries_private_download_on_the_next_mirror(self) -> None:
102+
download_panel.config.access_token = "private-token"
103+
fake_session = FakeSession([500, 200])
104+
download_panel.session = fake_session
105+
original_url = "https://r1-ndr-private.ykt.cbern.com.cn/book.pdf"
106+
107+
response, attempted_urls = download_panel.request_download(original_url)
108+
109+
self.assertEqual(response.status_code, 200)
110+
self.assertEqual([url.split("/", 3)[2] for url in attempted_urls], [
111+
"r1-ndr-private.ykt.cbern.com.cn",
112+
"r2-ndr-private.ykt.cbern.com.cn",
113+
])
114+
self.assertTrue(all("accessToken=private-token" in url for url in fake_session.requested_urls))
115+
self.assertTrue(all("accessToken" not in url for url in attempted_urls))
116+
117+
def test_does_not_retry_authentication_failures_or_public_urls(self) -> None:
118+
private_url = "https://r1-ndr-private.ykt.cbern.com.cn/book.pdf"
119+
public_url = "https://example.com/book.pdf"
120+
121+
private_session = FakeSession(401)
122+
download_panel.session = private_session
123+
_response, private_attempts = download_panel.request_download(private_url)
124+
self.assertEqual(private_attempts, [private_url])
125+
126+
public_session = FakeSession(500)
127+
download_panel.session = public_session
128+
_response, public_attempts = download_panel.request_download(public_url)
129+
self.assertEqual(public_attempts, [public_url])
130+
131+
def test_explains_private_storage_authentication_errors(self) -> None:
132+
response = FakeResponse(400, b"<Error><Code>InvalidArgument</Code></Error>")
133+
attempted_urls = ["https://r1.example/book.pdf", "https://r2.example/book.pdf"]
134+
135+
download_panel.config.access_token = None
136+
self.assertEqual(
137+
download_panel.download_failure_reason(response, attempted_urls),
138+
"服务器返回 HTTP 状态码 400(InvalidArgument),该私有资源需要有效的 Access Token,请先设置,已尝试 2 个下载镜像",
139+
)
140+
141+
download_panel.config.access_token = "private-token"
142+
self.assertEqual(
143+
download_panel.download_failure_reason(response, attempted_urls),
144+
"服务器返回 HTTP 状态码 400(InvalidArgument),私有资源鉴权失败,Access Token 可能已过期或无效,请重新设置,已尝试 2 个下载镜像",
145+
)
146+
147+
def test_redacts_token_from_network_exceptions(self) -> None:
148+
token = "private-token"
149+
download_panel.config.access_token = token
150+
download_panel.session = FailingSession()
151+
152+
with self.assertRaises(RuntimeError) as context:
153+
download_panel.request_download("https://r1-ndr-private.ykt.cbern.com.cn/book.pdf")
154+
155+
self.assertNotIn(token, str(context.exception))
156+
self.assertIn("accessToken=<已隐藏>", str(context.exception))
157+
51158

52159
if __name__ == "__main__":
53160
unittest.main()

0 commit comments

Comments
 (0)