@@ -153,71 +153,171 @@ jobs:
153153 echo "has_changes=true" >> "$GITHUB_OUTPUT"
154154 fi
155155
156- - name : Package changed courses
156+ if [ "$EVENT_NAME" = "workflow_dispatch" ] && [ "$FULL_REBUILD" = "true" ]; then
157+ echo "full_rebuild=true" >> "$GITHUB_OUTPUT"
158+ else
159+ echo "full_rebuild=false" >> "$GITHUB_OUTPUT"
160+ fi
161+
162+ - name : Resolve English slugs
163+ id : slugs
157164 if : steps.detect.outputs.has_changes == 'true'
158165 shell : bash
166+ env :
167+ GH_TOKEN : ${{ github.token }}
168+ DEEPSEEK_API_KEY : ${{ secrets.DEEPSEEK_API_KEY }}
159169 run : |
160170 set -euo pipefail
161171
172+ # 从 Release 下载已有的 slug 映射(首次不存在则为空)。
173+ # 映射存为 Release 资产 course-slugs.json,纯 ASCII,不受资产名净化影响。
174+ rm -f course-slugs.json
175+ if gh release view "$RELEASE_TAG" >/dev/null 2>&1; then
176+ gh release download "$RELEASE_TAG" --pattern course-slugs.json --dir . --clobber 2>/dev/null || true
177+ fi
178+ [ -f course-slugs.json ] || echo '{}' > course-slugs.json
179+
162180 python3 <<'PY'
163- from pathlib import Path
181+ import hashlib
182+ import json
183+ import os
164184 import re
165- import zipfile
166-
167- dist = Path("dist")
168- dist.mkdir(exist_ok=True)
169-
170- exclude_files = {
171- ".DS_Store",
172- "Thumbs.db",
173- }
174-
175- exclude_dirs = {
176- ".git",
177- "__MACOSX",
178- }
179-
180- def safe_name(name: str) -> str:
181- # 所有空白字符替换成下划线,包括普通空格
182- name = re.sub(r"\s+", "_", name)
185+ import sys
186+ import urllib.request
187+ from pathlib import Path
183188
184- # gh release upload 会把 file#label 里的半角 # 当作资产显示名分隔符,
185- # 导致 "数据结构(C#).zip" 被截断成 "数据结构(C"。
186- # 这里用全角井号 # 替换:gh 不会对它做分隔,且能完整保留语义,
187- # 避免把 # 直接换成 _ 而和 "数据结构(C)" 等课程产生重名。
188- name = name.replace("#", "#")
189+ MAP_PATH = Path("course-slugs.json") # 持久映射:课程路径 -> 英文 slug
190+ RUN_MAP_PATH = Path("slug_map.json") # 本次需打包课程的子集
189191
190- # release asset 文件名中不适合出现的字符也替换掉
191- name = re.sub(r'[\\/:*?"<>|]+', "_", name)
192+ api_key = os.environ.get("DEEPSEEK_API_KEY", "").strip()
192193
193- # 合并连续下划线
194- name = re.sub(r"_+", "_", name)
194+ try:
195+ slug_map = json.loads(MAP_PATH.read_text(encoding="utf-8")) or {}
196+ except Exception:
197+ slug_map = {}
195198
196- return name.strip("._") or "unnamed"
199+ used = set(slug_map.values())
197200
198201 courses = [
199202 line.strip()
200203 for line in Path("changed_courses.txt").read_text(encoding="utf-8").splitlines()
201204 if line.strip()
202205 ]
203206
204- created = 0
207+ def sanitize_slug(text: str) -> str:
208+ # 只保留 GitHub 资产名允许的字符 [A-Za-z0-9._-],其余折叠成下划线
209+ text = re.sub(r"[^A-Za-z0-9]+", "_", text.strip())
210+ text = re.sub(r"_+", "_", text).strip("._")
211+ return text[:80]
212+
213+ def short_hash(text: str, n: int = 6) -> str:
214+ return hashlib.sha1(text.encode("utf-8")).hexdigest()[:n]
215+
216+ def is_ascii_simple(text: str) -> bool:
217+ # 课名本就是 ASCII(如 Matlab)时直接用,省一次 API
218+ return bool(re.fullmatch(r"[A-Za-z0-9 ._\-+()]+", text))
219+
220+ def translate(name: str) -> str:
221+ if not api_key:
222+ return ""
223+ payload = json.dumps({
224+ "model": "deepseek-chat",
225+ "messages": [
226+ {"role": "system", "content": "You translate Chinese university course names into concise English. Reply with ONLY the English course name in Title Case. No quotes, no explanation, no trailing punctuation."},
227+ {"role": "user", "content": name},
228+ ],
229+ "temperature": 0,
230+ "stream": False,
231+ }).encode("utf-8")
232+ req = urllib.request.Request(
233+ "https://api.deepseek.com/chat/completions",
234+ data=payload,
235+ headers={
236+ "Authorization": f"Bearer {api_key}",
237+ "Content-Type": "application/json",
238+ },
239+ method="POST",
240+ )
241+ try:
242+ with urllib.request.urlopen(req, timeout=60) as resp:
243+ data = json.loads(resp.read().decode("utf-8"))
244+ return data["choices"][0]["message"]["content"].strip()
245+ except Exception as exc: # 翻译失败不阻断整个发布,回退到哈希名
246+ print(f"translate failed for {name!r}: {exc}", file=sys.stderr)
247+ return ""
248+
249+ def assign_slug(course_path: str) -> str:
250+ name = course_path.split("/", 1)[1] if "/" in course_path else course_path
251+
252+ if is_ascii_simple(name):
253+ base = sanitize_slug(name)
254+ else:
255+ base = sanitize_slug(translate(name))
256+
257+ if not base:
258+ base = "course_" + short_hash(course_path, 8)
259+
260+ candidate = base
261+ # 不同课程译名/净化后撞车时,用课程路径哈希保证唯一,绝不再 clobber
262+ while candidate in used:
263+ candidate = f"{base}_{short_hash(course_path + candidate)}"
264+ return candidate
265+
266+ run_map = {}
267+ changed = False
205268
206269 for course in courses:
270+ if course in slug_map:
271+ run_map[course] = slug_map[course]
272+ continue
273+ slug = assign_slug(course)
274+ slug_map[course] = slug
275+ used.add(slug)
276+ run_map[course] = slug
277+ changed = True
278+ print(f"resolved {course} -> {slug}")
279+
280+ RUN_MAP_PATH.write_text(
281+ json.dumps(run_map, ensure_ascii=False, indent=2),
282+ encoding="utf-8",
283+ )
284+ MAP_PATH.write_text(
285+ json.dumps(slug_map, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
286+ encoding="utf-8",
287+ )
288+
289+ print(f"this run: {len(run_map)} course(s); map total: {len(slug_map)}; map_changed={changed}")
290+ PY
291+
292+ - name : Package changed courses
293+ if : steps.detect.outputs.has_changes == 'true'
294+ shell : bash
295+ run : |
296+ set -euo pipefail
297+
298+ python3 <<'PY'
299+ import json
300+ import zipfile
301+ from pathlib import Path
302+
303+ dist = Path("dist")
304+ dist.mkdir(exist_ok=True)
305+
306+ exclude_files = {".DS_Store", "Thumbs.db"}
307+ exclude_dirs = {".git", "__MACOSX"}
308+
309+ run_map = json.loads(Path("slug_map.json").read_text(encoding="utf-8"))
310+
311+ created = 0
312+
313+ for course, slug in run_map.items():
207314 course_path = Path(course)
208315
209316 if not course_path.is_dir():
210317 print(f"skip deleted course: {course}")
211318 continue
212319
213- if len(course_path.parts) < 2:
214- print(f"skip invalid course path: {course}")
215- continue
216-
217- category = course_path.parts[0]
218- course_name = course_path.parts[1]
219-
220- zip_path = dist / f"{safe_name(category)}-{safe_name(course_name)}.zip"
320+ zip_path = dist / f"{slug}.zip"
221321
222322 if zip_path.exists():
223323 zip_path.unlink()
@@ -232,14 +332,11 @@ jobs:
232332 for item in sorted(course_path.rglob("*")):
233333 if not item.is_file():
234334 continue
235-
236335 if item.name in exclude_files:
237336 continue
238-
239337 if any(part in exclude_dirs for part in item.parts):
240338 continue
241-
242- # zip 内部保留原始路径,只修改外部 zip 文件名
339+ # zip 内部保留原始中文路径,仅外部资产名用英文 slug
243340 arcname = item.relative_to(Path("."))
244341 zf.write(item, arcname.as_posix())
245342
@@ -257,6 +354,38 @@ jobs:
257354 print(file)
258355 PY
259356
357+ - name : Build manifest
358+ if : steps.detect.outputs.has_changes == 'true'
359+ shell : bash
360+ run : |
361+ set -euo pipefail
362+
363+ python3 <<'PY'
364+ import json
365+ from pathlib import Path
366+
367+ dist = Path("dist")
368+ dist.mkdir(exist_ok=True)
369+
370+ slug_map = json.loads(Path("course-slugs.json").read_text(encoding="utf-8"))
371+
372+ courses = []
373+ for path, slug in sorted(slug_map.items()):
374+ parts = path.split("/")
375+ courses.append({
376+ "asset": f"{slug}.zip",
377+ "path": path,
378+ "category": parts[0] if parts else "",
379+ "name": parts[1] if len(parts) > 1 else path,
380+ })
381+
382+ (dist / "manifest.json").write_text(
383+ json.dumps({"courses": courses}, ensure_ascii=False, indent=2),
384+ encoding="utf-8",
385+ )
386+ print(f"manifest entries: {len(courses)}")
387+ PY
388+
260389 - name : Check archive size
261390 if : steps.detect.outputs.has_changes == 'true'
262391 shell : bash
@@ -296,10 +425,29 @@ jobs:
296425 else
297426 gh release create "$RELEASE_TAG" \
298427 --title "课程资料压缩包" \
299- --notes "自动生成课程压缩包。首次可全量生成,后续只更新发生变化的课程。"
428+ --notes "自动生成课程压缩包。资产名为英文(见 manifest.json 对应中文课名)。 首次可全量生成,后续只更新发生变化的课程。"
300429 fi
301430
302- - name : Upload changed course archives
431+ - name : Reset stale assets on full rebuild
432+ if : steps.detect.outputs.has_changes == 'true' && steps.detect.outputs.full_rebuild == 'true'
433+ shell : bash
434+ env :
435+ GH_TOKEN : ${{ github.token }}
436+ run : |
437+ set -euo pipefail
438+
439+ echo "Full rebuild: clearing existing release assets (keeping map/manifest)."
440+ gh release view "$RELEASE_TAG" --json assets --jq '.assets[].name' 2>/dev/null \
441+ | while IFS= read -r asset; do
442+ [ -z "$asset" ] && continue
443+ case "$asset" in
444+ course-slugs.json|manifest.json) continue ;;
445+ esac
446+ echo "deleting stale asset: $asset"
447+ gh release delete-asset "$RELEASE_TAG" "$asset" -y || true
448+ done
449+
450+ - name : Upload archives and metadata
303451 if : steps.detect.outputs.has_changes == 'true'
304452 shell : bash
305453 env :
@@ -308,38 +456,29 @@ jobs:
308456 set -euo pipefail
309457
310458 python3 <<'PY'
311- from pathlib import Path
312459 import os
313460 import subprocess
314461 import time
462+ from pathlib import Path
315463
316464 release_tag = os.environ["RELEASE_TAG"]
317- files = sorted(Path("dist").glob("*.zip"))
318465
319- if not files:
320- print("No archives to upload." )
321- raise SystemExit(0 )
466+ files = sorted(Path("dist").glob("*.zip"))
467+ files.append(Path("dist/manifest.json") )
468+ files.append(Path("course-slugs.json") )
322469
323470 uploaded = 0
324-
325471 for file in files:
326- file_str = str(file)
327- print(f"Uploading: {file_str}")
328-
472+ if not file.exists():
473+ print(f"skip missing: {file}")
474+ continue
475+ print(f"Uploading: {file}")
329476 subprocess.run(
330- [
331- "gh",
332- "release",
333- "upload",
334- release_tag,
335- file_str,
336- "--clobber",
337- ],
477+ ["gh", "release", "upload", release_tag, str(file), "--clobber"],
338478 check=True,
339479 )
340-
341480 uploaded += 1
342481 time.sleep(1)
343482
344- print(f"Uploaded {uploaded} archive (s).")
483+ print(f"Uploaded {uploaded} file (s).")
345484 PY
0 commit comments