fix: 用全角标点替换文件名中的非法字符,避免 Windows 下载失败 (#86)

This commit is contained in:
肥宅水水呀
2026-08-31 20:35:13 +08:00
parent 3e200c1697
commit 78938b1352
2 changed files with 110 additions and 4 deletions
+34 -3
View File
@@ -137,8 +137,39 @@ def download_failure_reason(response, attempted_urls: list[str]) -> str:
reason += f",已尝试 {len(attempted_urls)} 个下载镜像"
return reason
# Windows 禁止在文件名中使用半角 ? * : / 等;改为对应全角字符,尽量保留原标题读法(#86)。
_INVALID_FILENAME_REPLACEMENTS = str.maketrans({
"<": "<",
">": ">",
":": ":",
'"': """,
"/": "/",
"\\": "\",
"|": "|",
"?": "?",
"*": "*",
})
_CONTROL_FILENAME_CHARS = re.compile(r"[\x00-\x1f]")
_WINDOWS_RESERVED_NAMES = frozenset({
"CON", "PRN", "AUX", "NUL",
*(f"COM{i}" for i in range(10)),
*(f"LPT{i}" for i in range(10)),
})
def sanitize_filename(filename: str) -> str:
"""将非法文件名字符换成全角对应字符,并避开 Windows 保留设备名。"""
filename = _CONTROL_FILENAME_CHARS.sub("_", filename.translate(_INVALID_FILENAME_REPLACEMENTS))
filename = filename.rstrip(" .")
if not filename:
return "download"
stem, extension = os.path.splitext(filename)
if stem.upper() in _WINDOWS_RESERVED_NAMES:
return f"_{stem}{extension}"
return filename
def download_filename(resource: ResourceInfo) -> str:
return f"{resource.title or 'download'}.{resource.file_format}"
return sanitize_filename(f"{resource.title or 'download'}.{resource.file_format}")
def filename_key(filename: str) -> str:
"""以跨平台保守方式比较文件名,提前避开 Windows/macOS 上的大小写冲突。"""
@@ -153,7 +184,7 @@ def allocate_download_paths(resources: list[ResourceInfo], directory: str) -> li
for resource, filename in zip(resources, base_filenames):
# 例如人教版与北师大版的“普通高中教科书·英语必修 第三册”同名时,优先使用易读的版别前缀区分。
if base_counts[filename_key(filename)] > 1 and resource.edition:
filename = f"[{resource.edition}] {filename}"
filename = sanitize_filename(f"[{resource.edition}] {filename}")
edition_filenames.append(filename)
reserved_paths: set[str] = set()
@@ -258,7 +289,7 @@ def download() -> None: # 下载资源文件
save_path = filedialog.asksaveasfilename( # 选择保存路径
defaultextension=f".{resource.file_format}",
filetypes=[(f"{resource.file_format.upper()} 文件", f"*.{resource.file_format}"), ("所有文件", "*.*")],
initialfile=resource.title or "download",
initialfile=sanitize_filename(resource.title or "download"),
)
if not save_path: # 用户取消了文件保存操作
download_btn.config(state="normal") # 恢复下载按钮为启用状态
+76 -1
View File
@@ -3,7 +3,11 @@ import tempfile
import unittest
from src.tchmaterial_parser.api import ResourceInfo
from src.tchmaterial_parser.ui.download_panel import allocate_download_paths
from src.tchmaterial_parser.ui.download_panel import (
allocate_download_paths,
download_filename,
sanitize_filename,
)
def resource(
@@ -76,6 +80,77 @@ class DownloadPathTest(unittest.TestCase):
self.assertEqual([os.path.basename(path) for path in paths], ["已有教材 (2).pdf", "未完成教材 (2).pdf"])
def test_issue_86_replaces_windows_illegal_filename_characters(self) -> None:
resources = [
resource(
"(根据2022年版课程标准修订)义务教育教科书英语九年级上册 - Unit 3 Understanding ideas_2 Read the speech. What does the title mean?",
"audio-1",
file_format="mp3",
),
resource(
"(根据2022年版课程标准修订)义务教育教科书英语九年级上册 - Unit 4 Understanding ideas_2 Read the passage. What is the writer's relationship with Zhao Yiman?",
"audio-2",
file_format="mp3",
),
resource("听力 * 跟读 1/2", "audio-3", file_format="mp3"),
]
with tempfile.TemporaryDirectory() as directory:
paths = allocate_download_paths(resources, directory)
names = [os.path.basename(path) for path in paths]
self.assertEqual(names, [
"(根据2022年版课程标准修订)义务教育教科书英语九年级上册 - Unit 3 Understanding ideas_2 Read the speech. What does the title mean?.mp3",
"(根据2022年版课程标准修订)义务教育教科书英语九年级上册 - Unit 4 Understanding ideas_2 Read the passage. What is the writer's relationship with Zhao Yiman?.mp3",
"听力 * 跟读 1/2.mp3",
])
for path in paths:
with open(f"{path}.tmp", "wb") as file:
file.write(b"ok")
def test_fullwidth_replacements_keep_distinct_punctuation_apart(self) -> None:
resources = [
resource("同名教材?", "book-1"),
resource("同名教材*", "book-2"),
]
with tempfile.TemporaryDirectory() as directory:
paths = allocate_download_paths(resources, directory)
self.assertEqual([os.path.basename(path) for path in paths], [
"同名教材?.pdf",
"同名教材*.pdf",
])
def test_sanitized_titles_that_collide_still_get_a_sequence(self) -> None:
resources = [
resource("同名教材?", "book-1"),
resource("同名教材?", "book-2"),
]
with tempfile.TemporaryDirectory() as directory:
paths = allocate_download_paths(resources, directory)
self.assertEqual([os.path.basename(path) for path in paths], [
"同名教材?.pdf",
"同名教材? (2).pdf",
])
class SanitizeFilenameTest(unittest.TestCase):
def test_uses_fullwidth_punctuation_and_strips_trailing_dots(self) -> None:
self.assertEqual(sanitize_filename('a<>:"/\\|?*b'), "a<>:"/\|?*b")
self.assertEqual(sanitize_filename("结束."), "结束")
self.assertEqual(sanitize_filename("???"), "???")
self.assertEqual(sanitize_filename("..."), "download")
def test_prefixes_windows_reserved_device_names(self) -> None:
self.assertEqual(sanitize_filename("CON.mp3"), "_CON.mp3")
self.assertEqual(sanitize_filename("nul"), "_nul")
def test_download_filename_sanitizes_title_but_keeps_extension(self) -> None:
info = resource("What does the title mean?", "audio-1", file_format="mp3")
self.assertEqual(download_filename(info), "What does the title mean?.mp3")
if __name__ == "__main__":
unittest.main()