| 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 |