返回 douyin-downloader
naming.py
根目录 / utils / naming.py
1 """文件/目录命名模板渲染。
2
3 用户可在设置里自定义 `filename_template` 与 `folder_template`,此处把模板中
4 ``{var}`` 形式的占位符替换成上下文变量,未知变量会被保留成空字符串(而非抛错),
5 这样即使用户输入轻微笔误也不会导致下载失败。渲染结果最终仍会走
6 ``utils.validators.sanitize_filename``,因此模板里出现的路径分隔符、非法字符会
7 被统一清洗——模板语言本身不需要做安全校验。
8
9 仅允许的变量(详见 ``ALLOWED_VARIABLES``):
10 - ``id``: 作品 ID(视频/图集为 ``aweme_id``,音乐为 ``music_<music_id>``,
11 直播为 ``room_id``)
12 - ``title``: 作品标题或描述,空时为 ``no_title``
13 - ``author``: 作者昵称
14 - ``author_id``: 作者 sec_uid(便于同名区分,缺失为空)
15 - ``date``: 发布日期 ``YYYY-MM-DD``(缺失时为当前日期)
16 - ``year`` / ``month`` / ``day``: ``date`` 的年月日分量
17 - ``time``: 发布时间 ``HHMM``(仅当上下文提供时有值)
18 - ``hour`` / ``minute`` / ``second``: 发布时间的时/分/秒分量(两位数字)
19 - ``timestamp``: Unix 时间戳(秒,整型字符串;缺失为空)
20 - ``type``: ``video`` / ``gallery`` / ``music`` / ``live``
21 - ``mode``: 下载模式 ``post`` / ``like`` / ``mix`` / ``music`` / ``live`` …
22 """
23
24 from __future__ import annotations
25
26 import re
27 from datetime import datetime
28 from typing import Any, Dict, Mapping, Optional
29
30 from utils.validators import sanitize_filename
31
32 # 允许用户在模板中使用的变量白名单(必须与文档、桌面 UI 帮助面板保持一致)。
33 ALLOWED_VARIABLES = (
34 "id",
35 "title",
36 "author",
37 "author_id",
38 "date",
39 "year",
40 "month",
41 "day",
42 "time",
43 "hour",
44 "minute",
45 "second",
46 "timestamp",
47 "type",
48 "mode",
49 )
50
51 # 默认模板:与历史行为保持一致(`{date}_{title}_{id}`)。作者已经在上级目录,
52 # 所以这里不重复放作者名。
53 DEFAULT_FILE_TEMPLATE = "{date}_{title}_{id}"
54 DEFAULT_FOLDER_TEMPLATE = "{date}_{title}_{id}"
55
56 # 模板长度上限。既防用户贴进整段长文案,也给前端做一致校验。
57 MAX_TEMPLATE_LENGTH = 200
58
59 # 匹配 ``{var}`` 形式。不支持格式化说明符(:fmt)以降低心智负担。
60 _PLACEHOLDER_RE = re.compile(r"\{([a-zA-Z_][a-zA-Z0-9_]*)\}")
61
62
63 class TemplateValidationError(ValueError):
64 """模板语法或变量不合法。"""
65
66
67 def validate_template(template: str, *, field_name: str = "template") -> None:
68 """校验模板可用(用于 API 层早退)。
69
70 规则:
71 - 长度 ≤ ``MAX_TEMPLATE_LENGTH``
72 - 不得包含裸 ``/`` 或 ``\\``(这两种字符会被视为路径分隔符而不再当文件名
73 的一部分,极易造成越级写入或层级错乱;清洗函数虽然会替换,但模板层
74 显式拒绝更清晰)
75 - 不得只包含空白或空串
76 - 至少引用一个允许变量(防止用户写成纯静态常量导致不同作品互相覆盖)
77 - 引用的变量必须在 ``ALLOWED_VARIABLES`` 白名单内
78 - 必须引用 ``{id}`` —— 保证跨作品唯一性(否则同一作者同一天的两条作品
79 会因为 stem 相同而彼此覆盖)
80 """
81 if not isinstance(template, str):
82 raise TemplateValidationError(f"{field_name} must be a string")
83
84 stripped = template.strip()
85 if not stripped:
86 raise TemplateValidationError(f"{field_name} must not be empty")
87
88 if len(template) > MAX_TEMPLATE_LENGTH:
89 raise TemplateValidationError(f"{field_name} must be <= {MAX_TEMPLATE_LENGTH} characters")
90
91 if "/" in template or "\\" in template:
92 raise TemplateValidationError(
93 f"{field_name} must not contain path separators ('/' or '\\\\')"
94 )
95
96 variables = _PLACEHOLDER_RE.findall(template)
97 if not variables:
98 raise TemplateValidationError(
99 f"{field_name} must reference at least one variable like {{id}}"
100 )
101
102 unknown = [v for v in variables if v not in ALLOWED_VARIABLES]
103 if unknown:
104 raise TemplateValidationError(
105 f"{field_name} uses unknown variable(s): "
106 + ", ".join(sorted(set(unknown)))
107 + f"; allowed: {', '.join(ALLOWED_VARIABLES)}"
108 )
109
110 if "id" not in variables:
111 raise TemplateValidationError(f"{field_name} must reference {{id}} to guarantee uniqueness")
112
113
114 def render_template(
115 template: str,
116 context: Mapping[str, Any],
117 *,
118 fallback: Optional[str] = None,
119 ) -> str:
120 """根据 ``context`` 渲染模板并清洗最终文件名。
121
122 未知变量或 context 缺失的键会被替换成空字符串;清洗之后若结果为空/仅
123 符号(会被 sanitize_filename 吞掉并回退为 ``untitled``),调用方可通过
124 ``fallback`` 进一步兜底。
125 """
126
127 def replace(match: "re.Match[str]") -> str:
128 name = match.group(1)
129 value = context.get(name)
130 return "" if value is None else str(value)
131
132 rendered = _PLACEHOLDER_RE.sub(replace, template)
133 cleaned = sanitize_filename(rendered)
134 if cleaned == "untitled" and fallback:
135 return sanitize_filename(fallback)
136 return cleaned
137
138
139 def _split_date(date_str: str) -> Dict[str, str]:
140 """把 ``YYYY-MM-DD`` 拆成 ``{year, month, day}`` 三个字符串。"""
141 if not date_str:
142 return {"year": "", "month": "", "day": ""}
143 parts = date_str.split("-")
144 if len(parts) != 3:
145 return {"year": "", "month": "", "day": ""}
146 return {"year": parts[0], "month": parts[1], "day": parts[2]}
147
148
149 def _split_time(ts: Optional[int]) -> Dict[str, str]:
150 """把 Unix 时间戳拆成 ``{hour, minute, second}`` 三个两位字符串。"""
151 if not ts:
152 return {"hour": "", "minute": "", "second": ""}
153 try:
154 dt = datetime.fromtimestamp(ts)
155 return {
156 "hour": dt.strftime("%H"),
157 "minute": dt.strftime("%M"),
158 "second": dt.strftime("%S"),
159 }
160 except (OSError, OverflowError, ValueError):
161 return {"hour": "", "minute": "", "second": ""}
162
163
164 def build_aweme_context(
165 *,
166 aweme_id: str,
167 title: str,
168 author_name: str,
169 author_sec_uid: Optional[str],
170 publish_date: str,
171 publish_ts: Optional[int],
172 media_type: str,
173 mode: Optional[str] = None,
174 ) -> Dict[str, str]:
175 """为普通视频/图集下载构造模板上下文。"""
176 ctx: Dict[str, str] = {
177 "id": str(aweme_id or ""),
178 "title": title or "no_title",
179 "author": author_name or "",
180 "author_id": author_sec_uid or "",
181 "date": publish_date or "",
182 "time": "",
183 "hour": "",
184 "minute": "",
185 "second": "",
186 "timestamp": str(publish_ts) if publish_ts else "",
187 "type": media_type or "",
188 "mode": mode or "",
189 }
190 ctx.update(_split_date(publish_date))
191 # HHMM 对普通作品无意义,但仍基于 publish_ts 填一份,避免模板使用 {time}
192 # 时出现空串。
193 if publish_ts:
194 try:
195 ctx["time"] = datetime.fromtimestamp(publish_ts).strftime("%H%M")
196 except (OSError, OverflowError, ValueError):
197 ctx["time"] = ""
198 ctx.update(_split_time(publish_ts))
199 return ctx
200
201
202 def build_music_context(
203 *,
204 music_id: str,
205 title: str,
206 author_name: str,
207 publish_date: str,
208 mode: str = "music",
209 ) -> Dict[str, str]:
210 """音乐下载专用上下文(music_id 会加上 ``music_`` 前缀用作 ``id``)。"""
211 ctx: Dict[str, str] = {
212 "id": f"music_{music_id}" if music_id else "",
213 "title": title or "no_title",
214 "author": author_name or "",
215 "author_id": "",
216 "date": publish_date or "",
217 "time": "",
218 "hour": "",
219 "minute": "",
220 "second": "",
221 "timestamp": "",
222 "type": "music",
223 "mode": mode,
224 }
225 ctx.update(_split_date(publish_date))
226 return ctx
227
228
229 def build_live_context(
230 *,
231 room_id: str,
232 title: str,
233 author_name: str,
234 started_at: datetime,
235 mode: str = "live",
236 ) -> Dict[str, str]:
237 """直播录制上下文。
238
239 ``date`` 特意保留为 ``YYYY-MM-DD_HHMM``(保留历史行为:同一天可能录多次
240 直播,需要在文件名层面区分)。``year``/``month``/``day`` 仍按自然日拆分,
241 方便按月/按日分文件夹。``time`` 单独提供 ``HHMM`` 方便用户在模板里改放到
242 其他位置。``hour``/``minute``/``second`` 提供独立的时/分/秒分量。
243 """
244 iso_date = started_at.strftime("%Y-%m-%d")
245 date_with_time = started_at.strftime("%Y-%m-%d_%H%M")
246 ctx: Dict[str, str] = {
247 "id": str(room_id or ""),
248 "title": title or "no_title",
249 "author": author_name or "",
250 "author_id": "",
251 "date": date_with_time,
252 "time": started_at.strftime("%H%M"),
253 "hour": started_at.strftime("%H"),
254 "minute": started_at.strftime("%M"),
255 "second": started_at.strftime("%S"),
256 "timestamp": str(int(started_at.timestamp())),
257 "type": "live",
258 "mode": mode,
259 }
260 # 仍按自然日拆分 year/month/day,保证模板 {year}/{month}/{day} 语义一致。
261 ctx.update(_split_date(iso_date))
262 return ctx
263
263 lines PYTHON