Skip to content

Commit b4ac5ad

Browse files
Crescentmclaude
andcommitted
修复 Release 资产名被 ASCII 净化导致课程包丢失
GitHub Release 资产名只允许 [A-Za-z0-9._-],中文课程名上传后被整体 抹成分隔符,纯中文课名全部坍缩成同一个名字并被 --clobber 互相覆盖, 100+ 门课最终只剩 20 个乱名资产。 改为用 DeepSeek 把课程名翻译成英文 slug 作为资产名: - 新增 Resolve English slugs 步骤,映射持久化为 Release 资产 course-slugs.json,已有课程永不重译、名字永久稳定,纯 ASCII 课名 跳过翻译,撞名用课程路径哈希兜底保证唯一 - 生成 manifest.json(英文 slug -> 中文课名/路径)供前端显示中文 - 全量重建时清理历史遗留的乱名资产 - 上传 zip + manifest.json + course-slugs.json 需在仓库 Secrets 配置 DEEPSEEK_API_KEY。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 5a8bc11 commit b4ac5ad

1 file changed

Lines changed: 203 additions & 64 deletions

File tree

.github/workflows/release-updated-courses.yml

Lines changed: 203 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)