feat(wiki): 新增 GHC06 任务页与图库发布体系,级联更新既有页面

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-11 15:03:14 +08:00
co-authored by Claude
parent 26d2fcd0b1
commit 4ef15545e3
34 changed files with 3515 additions and 1572 deletions
+173
View File
@@ -0,0 +1,173 @@
#!/usr/bin/env python3
"""Build the GHC-05 and GHC-06 galleries from the existing KSP screenshots."""
from __future__ import annotations
import html
import os
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
SOURCE_ROOT = ROOT / "user_input" / "广寒计划"
WIKI_ROOT = ROOT / "data" / "wiki"
STAGING_ROOT = ROOT / "output" / "wiki-gallery-upload"
GALLERIES = {
"GHC-05": {
"title": "广寒基地建设任务-05 任务图集",
"page": "home/mission/Guanghan_Program/GHC-05",
"previous": ("GHC-04", "/home/mission/Guanghan_Program/GHC-04/Gallery"),
"next": ("GHC-06", "/home/mission/Guanghan_Program/GHC-06/Gallery"),
"folder": "guanghan-ghc-05",
"output": "guanghan_ghc05_gallery_zh.html",
"intro": "现有任务影像按任务阶段整理,共 28 张。点击图片可打开大图并使用方向键切换。",
"items": [
("载人发射与地月转移", "CZ-16A二级视角-夜间发射.png", "crew_launch_night.png", "CZ-16A 夜间发射", "CZ-16A 搭载嫦娥六号与吴刚二号升空,画面为夜间上升段的二级视角。"),
("载人发射与地月转移", "CZ-16A执行TLI燃烧.png", "crew_tli_burn.png", "CZ-16A 执行 TLI", "运载火箭上面级执行地月转移注入,将嫦娥六号与吴刚二号组合体送往月球。"),
("载人发射与地月转移", "嫦娥6号分离.png", "change6_separation.png", "嫦娥六号分离", "嫦娥六号与上面级分离,准备执行掉头和着陆器提取操作。"),
("载人发射与地月转移", "嫦娥6号掉头,准备取出吴刚2.png", "change6_turnaround.png", "嫦娥六号掉头", "嫦娥六号完成掉头,接近仍固定在桁架上的吴刚二号。"),
("载人发射与地月转移", "嫦娥6号取出吴刚2.png", "change6_extracts_wugang2.png", "嫦娥六号取出吴刚二号", "嫦娥六号对接吴刚二号并将其从发射桁架中拖出,组合体进入巡航构型。"),
("载人发射与地月转移", "嫦娥6号近月制动.png", "change6_loi.png", "嫦娥六号近月制动", "嫦娥六号与吴刚二号组合体执行近月制动,进入绕月轨道。"),
("吴刚二号着陆", "吴刚2号与嫦娥分离准备着陆.png", "wugang2_separation.png", "吴刚二号分离", "吴刚二号与嫦娥六号分离,开始独立下降与着陆准备。"),
("吴刚二号着陆", "吴刚2号主减速段.png", "wugang2_descent_burn.png", "吴刚二号主减速", "吴刚二号主发动机点火执行下降主减速。"),
("吴刚二号着陆", "吴刚2号着陆.png", "wugang2_landing.png", "吴刚二号着陆", "吴刚二号在东方海广寒基地附近完成月面着陆。"),
("广寒 03 模块运输", "CZ-16发射广寒03模块.png", "gh03_launch.png", "CZ-16 发射广寒 03", "CZ-16 搭载广寒 03 扩建模块从文昌升空。"),
("广寒 03 模块运输", "CZ-16发射广寒03模块2.png", "gh03_launch_second_view.png", "广寒 03 发射另一视角", "广寒 03 模块发射上升段的另一视角。"),
("广寒 03 模块运输", "金蟾1号与广寒03模块对接.png", "jinchan1_docks_gh03.png", "金蟾一号对接广寒 03", "金蟾一号在近地轨道与广寒 03 模块完成对接。"),
("广寒 03 模块运输", "金蟾1号携带广寒03TLI.png", "jinchan1_gh03_tli.png", "金蟾一号携带广寒 03 执行 TLI", "金蟾一号携带广寒 03 模块执行地月转移点火。"),
("广寒 03 模块运输", "冰轮3号和金蟾1号交汇.png", "binglun3_rendezvous_jinchan1.png", "冰轮三号与金蟾一号交会", "冰轮三号在绕月轨道接近携带广寒 03 模块的金蟾一号。"),
("广寒 03 模块运输", "冰轮3号着陆.png", "binglun3_landing_gh03.png", "冰轮三号运送广寒 03 着陆", "冰轮三号携带广寒 03 模块在广寒基地着陆。"),
("冰轮运输调度", "冰轮2号起飞.png", "binglun2_takeoff.png", "冰轮二号起飞", "冰轮二号从月面起飞,执行燃料运输任务。"),
("冰轮运输调度", "冰轮2号与金蟾1号对接.png", "binglun2_docks_jinchan1.png", "冰轮二号对接金蟾一号", "冰轮二号在绕月轨道与金蟾一号对接并转移燃料。"),
("冰轮运输调度", "金蟾1号点火返回地球(可见后方冰轮2号).png", "jinchan1_return_burn_binglun2.png", "金蟾一号返航点火", "金蟾一号执行返回地球点火,画面后方可见冰轮二号。"),
("冰轮运输调度", "金蟾1号近地制动.png", "jinchan1_earth_braking.png", "金蟾一号近地制动", "金蟾一号返回地球附近并执行近地制动。"),
("冰轮运输调度", "冰轮2号着陆中.png", "binglun2_descent.png", "冰轮二号下降", "冰轮二号完成轨道补给后返回月面。"),
("冰轮运输调度", "冰轮2号着陆.png", "binglun2_landing.png", "冰轮二号着陆", "冰轮二号重新着陆于广寒基地。"),
("乘组撤离与返回", "吴刚2号起飞(01-19).png", "wugang2_takeoff.png", "吴刚二号起飞", "吴刚二号搭载 GHC-05 乘组从月面起飞。"),
("乘组撤离与返回", "嫦娥6号与吴刚2号对接(01-19).png", "change6_docks_wugang2.png", "嫦娥六号对接吴刚二号", "嫦娥六号在绕月轨道与吴刚二号完成对接。"),
("乘组撤离与返回", "嫦娥6号携带吴刚2号升轨(01-19).png", "change6_wugang2_raise_orbit.png", "嫦娥六号携带吴刚二号升轨", "组合体升轨至停泊轨道,吴刚二号随后留轨复用。"),
("乘组撤离与返回", "冰轮3号起飞.png", "binglun3_takeoff.png", "冰轮三号起飞", "冰轮三号从月面起飞执行下一轮轨道运输。"),
("乘组撤离与返回", "冰轮3号起飞过程中,从嫦娥6号45公里处略过.png", "binglun3_passes_change6.png", "冰轮三号掠过嫦娥六号", "冰轮三号上升过程中从嫦娥六号约 45 公里外掠过。"),
("乘组撤离与返回", "嫦娥6号点火返回地球(01-20).png", "change6_return_burn.png", "嫦娥六号返回点火", "嫦娥六号离开绕月轨道,执行返回地球点火。"),
("乘组撤离与返回", "嫦娥6号再入(01-22).png", "change6_reentry.png", "嫦娥六号再入", "嫦娥六号返回舱再入地球大气层。"),
],
},
"GHC-06": {
"title": "广寒基地建设任务-06 任务图集",
"page": "home/mission/Guanghan_Program/GHC-06",
"previous": ("GHC-05", "/home/mission/Guanghan_Program/GHC-05/Gallery"),
"next": None,
"folder": "guanghan-ghc-06",
"output": "guanghan_ghc06_gallery_zh.html",
"intro": "现有任务影像按任务阶段整理,共 25 张。点击图片可打开大图并使用方向键切换。",
"items": [
("载人飞行与月面着陆", "CZ-16A二级携带组合体进行TLI.png", "crew_tli.png", "CZ-16A 二级执行 TLI", "CZ-16A 二级携带嫦娥七号与吴刚三号组合体执行地月转移注入。"),
("载人飞行与月面着陆", "嫦娥7号准备和吴刚飞船对接(1月6日).png", "change7_dock_approach.png", "嫦娥七号接近吴刚三号", "嫦娥七号完成掉头,准备与吴刚三号对接。"),
("载人飞行与月面着陆", "嫦娥7号取出吴刚飞船.png", "change7_extracts_wugang3.png", "嫦娥七号取出吴刚三号", "嫦娥七号将吴刚三号从发射桁架中拖出。"),
("载人飞行与月面着陆", "嫦娥7号近月制动.png", "change7_loi.png", "嫦娥七号近月制动", "嫦娥七号与吴刚三号组合体执行近月制动。"),
("载人飞行与月面着陆", "吴刚3号准备着陆(1月11号).png", "wugang3_landing_approach.png", "吴刚三号准备着陆", "吴刚三号脱离绕月轨道,开始月面下降。"),
("载人飞行与月面着陆", "吴刚3号着陆中.png", "wugang3_landing.png", "吴刚三号着陆", "吴刚三号执行末段下降并着陆于东方海。"),
("广寒 04 模块运输", "CZ-10E发射广寒04模块(02-01).png", "gh04_launch.png", "CZ-10E 发射广寒 04", "CZ-10E 搭载广寒 04 扩建模块升空。"),
("广寒 04 模块运输", "金蟾3号准备和广寒04模块对接(02-01).png", "jinchan3_dock_gh04.png", "金蟾三号接近广寒 04", "金蟾三号在近地轨道接近并准备对接广寒 04 模块。"),
("广寒 04 模块运输", "金蟾3号携带广寒04模块执行TLI.png", "jinchan3_gh04_tli.png", "金蟾三号携带广寒 04 执行 TLI", "金蟾三号携带广寒 04 模块开始地月转移。"),
("广寒 04 模块运输", "冰轮3号变轨(02-08).png", "binglun3_deorbit_gh04.png", "冰轮三号变轨", "冰轮三号接收广寒 04 模块后执行下降变轨。"),
("广寒 04 模块运输", "冰轮3号着陆主减速段(02-08).png", "binglun3_descent_gh04.png", "冰轮三号主减速", "冰轮三号携带广寒 04 模块执行着陆主减速。"),
("广寒 04 模块运输", "冰轮3号着陆(02-08).png", "binglun3_landing_gh04.png", "冰轮三号运送广寒 04 着陆", "冰轮三号将广寒 04 模块送达广寒基地。"),
("冰轮编队调度", "冰轮2号起飞(02-08).png", "binglun2_takeoff.png", "冰轮二号起飞", "冰轮二号从月面起飞执行轨道燃料运输。"),
("冰轮编队调度", "冰轮2号与金蟾1号交汇(02-08).png", "binglun2_rendezvous_jinchan1.png", "冰轮二号与金蟾一号交会", "冰轮二号在绕月轨道接近金蟾一号。"),
("冰轮编队调度", "冰轮2号着陆(02-08).png", "binglun2_landing.png", "冰轮二号返回月面", "冰轮二号完成补给任务后返回广寒基地。"),
("冰轮编队调度", "冰轮3号起飞(02-08).png", "binglun3_takeoff.png", "冰轮三号起飞", "完成广寒 04 卸载后,冰轮三号再次起飞执行轨道任务。"),
("冰轮编队调度", "金蟾1号泊入近地轨道(02-13).png", "jinchan1_leo_arrival.png", "金蟾一号泊入近地轨道", "金蟾一号返回地球附近并泊入近地轨道。"),
("乘组撤离与返回", "吴刚3号准备起飞(25-03-14).png", "wugang3_prepares_takeoff.png", "吴刚三号准备起飞", "燃料系统修复完成后,吴刚三号进行月面起飞准备。"),
("乘组撤离与返回", "吴刚3号起飞(25-03-15).png", "wugang3_takeoff.png", "吴刚三号起飞", "吴刚三号搭载 GHC-06 乘组从月面起飞。"),
("乘组撤离与返回", "吴刚3号与嫦娥7号准备对接(25-03-15).png", "wugang3_change7_rendezvous.png", "吴刚三号接近嫦娥七号", "吴刚三号在绕月轨道接近嫦娥七号并完成乘组转移;随后组合体升轨至 400×400 km,吴刚三号留轨复用。"),
("乘组撤离与返回", "嫦娥7号点火返回地球(25-03-16).png", "change7_return_burn.png", "嫦娥七号返回点火", "嫦娥七号离开绕月轨道,开始返回地球。"),
("乘组撤离与返回", "嫦娥7号抛服务舱(25-03-19).png", "change7_service_module_sep.png", "嫦娥七号抛离服务舱", "再入前,嫦娥七号返回舱与服务舱分离。"),
("乘组撤离与返回", "嫦娥7号再入(25-03-19).png", "change7_reentry.png", "嫦娥七号再入", "嫦娥七号返回舱穿越地球大气层。"),
("乘组撤离与返回", "嫦娥7号开主伞(25-03-19).png", "change7_main_chutes.png", "嫦娥七号打开主伞", "返回舱主降落伞展开,进入最终下降阶段。"),
("乘组撤离与返回", "嫦娥7号返回文昌(25-03-19).png", "change7_wenchang_landing.png", "嫦娥七号返回文昌", "嫦娥七号返回舱在文昌回收场安全着陆,GHC-06 任务完成。"),
],
},
}
BASE_STYLE = """<style>
.wiki-article { max-width: 1180px; margin: 0 auto; padding: 24px 32px 48px; color: #202122; background: #fff; font-family: sans-serif; font-size: 14px; line-height: 1.65; }
.wiki-article h1 { font-family: \"Linux Libertine\", Georgia, serif; font-weight: normal; border-bottom: 1px solid #a2a9b1; margin: 0 0 8px; font-size: 2.15em; }
.wiki-article h2 { font-family: \"Linux Libertine\", Georgia, serif; font-weight: normal; border-bottom: 1px solid #a2a9b1; clear: both; margin: 1.35em 0 0.6em; padding-bottom: 0.18em; font-size: 1.55em; }
.wiki-article p { margin: 0.45em 0 0.85em; }
.wiki-article a { color: #0645ad; text-decoration: none; }
.wiki-article a:hover { text-decoration: underline; }
.wiki-article img { max-width: 100%; height: auto; }
.wiki-article .thumbcaption { padding: 4px 3px 2px; text-align: left; color: #202122; }
.wiki-article .gallery-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 16px; margin: 1em 0; }
.wiki-article .gallery-item { border: 1px solid #c8ccd1; background: #f8f9fa; padding: 4px; font-size: 88%; line-height: 1.4; }
.wiki-article .gallery-item img { display: block; width: 100%; cursor: zoom-in; }
.wiki-article .gallery-item .thumbcaption { padding: 4px 2px 2px; }
@media (max-width: 820px) { .wiki-article { padding: 18px 16px 36px; } .gallery-grid { grid-template-columns: 1fr; } }
</style>"""
def create_staged_asset(source: Path, staged: Path) -> None:
staged.parent.mkdir(parents=True, exist_ok=True)
if staged.exists():
if staged.stat().st_size != source.stat().st_size:
staged.unlink()
else:
return
try:
os.link(source, staged)
except OSError:
staged.write_bytes(source.read_bytes())
def build_gallery(code: str, config: dict) -> None:
source_dir = SOURCE_ROOT / code
staging_dir = STAGING_ROOT / code.lower()
sections: dict[str, list[str]] = {}
for section, source_name, upload_name, alt, caption in config["items"]:
source = source_dir / source_name
if not source.exists():
raise FileNotFoundError(source)
create_staged_asset(source, staging_dir / upload_name)
asset_path = f'/{config["folder"]}/{upload_name}'
card = (
f'<div class="gallery-item"><img src="{asset_path}" alt="{html.escape(alt)}" '
f'data-wiki-asset="{asset_path}"><div class="thumbcaption">{html.escape(caption)}</div></div>'
)
sections.setdefault(section, []).append(card)
previous = config["previous"]
next_item = config["next"]
nav_parts = [f'<a href="{previous[1]}">← {previous[0]} 图集</a>']
if next_item:
nav_parts.append(f'<a href="{next_item[1]}">{next_item[0]} 图集 →</a>')
nav = " | ".join(nav_parts)
body = [BASE_STYLE, '<article class="mw-parser-output wiki-article">']
body.append(f'<h1 id="top">{config["title"]}</h1>')
body.append(f'<p><a href="/{config["page"]}">← 返回任务页</a> {len(config["items"])} 张现有影像</p>')
body.append(f'<p style="margin-bottom:1.5em;"><b>任务图集导航</b>{nav}</p>')
body.append(f'<p>{config["intro"]}</p>')
for section, cards in sections.items():
body.extend((f'<h2>{section}</h2>', '<div class="gallery-grid">', *cards, '</div>'))
body.append(f'<p style="margin-top:1.5em;"><b>任务图集导航</b>{nav}</p>')
body.append(f'<p style="margin-top:1.5em;"><a href="/{config["page"]}">← 返回任务页</a></p>')
body.append('</article>')
output_path = WIKI_ROOT / config["output"]
output_path.write_text("\n".join(body) + "\n", encoding="utf-8", newline="\n")
print(f"gallery_built mission={code} images={len(config['items'])} output={output_path}")
def main() -> int:
for code, config in GALLERIES.items():
build_gallery(code, config)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+324
View File
@@ -0,0 +1,324 @@
#!/usr/bin/env python3
"""Apply the shared gallery presentation and lightbox behavior to mission galleries."""
from __future__ import annotations
import re
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
WIKI_DIR = ROOT / "data" / "wiki"
GALLERY_FILES = sorted(WIKI_DIR.glob("guanghan_*_gallery_zh.html"))
STYLE_END_RE = re.compile(r"</style>", re.IGNORECASE)
SCRIPT_RE = re.compile(r"\s*<script>.*?</script>\s*", re.IGNORECASE | re.DOTALL)
ASSET_IMAGE_RE = re.compile(r"<img\b(?=[^>]*\bdata-wiki-asset=)[^>]*>", re.IGNORECASE)
CSS_BLOCK_RE = re.compile(
r"\n?/\* mission-gallery-enhancements:start \*/.*?"
r"/\* mission-gallery-enhancements:end \*/\n?",
re.DOTALL,
)
LEGACY_LIGHTBOX_CSS_RE = re.compile(
r"\n?\.wiki-article img\[data-wiki-asset\][^\n]*\n"
r".*?@media \(max-width: 820px\) \{ \.wiki-image-lightbox \{.*?\}\s*\}\n?",
re.DOTALL,
)
GALLERY_CSS = r"""
/* mission-gallery-enhancements:start */
.wiki-article .gallery-grid {
grid-template-columns: repeat(auto-fill, minmax(min(100%, 300px), 1fr));
align-items: stretch;
}
.wiki-article .gallery-item {
display: grid;
grid-template-rows: auto 1fr;
overflow: hidden;
content-visibility: auto;
contain-intrinsic-size: 340px 300px;
}
.wiki-article .gallery-item img[data-wiki-asset] {
width: 100%;
aspect-ratio: 16 / 9;
object-fit: contain;
background: #202122;
cursor: zoom-in;
}
.wiki-article .gallery-item .thumbcaption {
padding: 8px 7px 7px;
}
.wiki-article .gallery-notice {
margin: 1em 0 1.25em;
padding: 10px 12px;
border-left: 4px solid #36c;
background: #f1f4fd;
color: #202122;
}
.wiki-article .image-placeholder {
position: relative;
border: 1px dashed #72777d;
background: linear-gradient(135deg, #f8f9fa, #eaecf0);
color: #54595d;
}
.wiki-article.gallery-planning .gallery-grid {
grid-template-columns: repeat(auto-fill, minmax(min(100%, 360px), 1fr));
gap: 12px;
}
.wiki-article.gallery-planning .gallery-item {
display: block;
content-visibility: visible;
contain-intrinsic-size: none;
border-color: #a2a9b1;
background: #fff;
box-shadow: 0 1px 2px rgba(32, 33, 34, 0.08);
}
.wiki-article.gallery-planning .image-placeholder {
min-height: 0 !important;
display: block;
padding: 14px 14px 12px;
border: 0;
border-left: 4px solid #72777d;
background: #f8f9fa;
color: #202122;
text-align: left;
font-size: 13px;
font-weight: 600;
line-height: 1.65;
}
.wiki-article.gallery-planning .gallery-item .thumbcaption {
padding: 10px 14px 12px;
border-top: 1px solid #eaecf0;
color: #54595d;
}
.wiki-article.gallery-planning > .image-placeholder {
min-height: 0;
padding: 22px 18px;
border-left: 4px solid #72777d;
}
.wiki-image-lightbox {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
background: rgba(32, 33, 34, 0.88);
z-index: 10000;
box-sizing: border-box;
}
.wiki-image-lightbox[hidden] { display: none; }
.wiki-image-lightbox-panel {
position: relative;
display: grid;
grid-template-columns: 44px minmax(0, 1fr) 44px;
grid-template-areas: "close close close" "prev image next" "counter caption caption";
gap: 8px;
width: min(1320px, 96vw);
max-height: 94vh;
overflow: auto;
margin: 0;
padding: 10px;
border: 1px solid #a2a9b1;
background: #fff;
box-shadow: 0 12px 38px rgba(0, 0, 0, 0.35);
box-sizing: border-box;
}
.wiki-image-lightbox-panel > img {
grid-area: image;
width: 100%;
height: min(78vh, 900px);
object-fit: contain;
background: #000;
}
.wiki-image-lightbox-close {
grid-area: close;
justify-self: end;
position: static;
float: none;
width: 34px;
height: 34px;
margin: 0;
border: 1px solid #a2a9b1;
border-radius: 3px;
background: #eaecf0;
color: #202122;
font-size: 22px;
line-height: 30px;
cursor: pointer;
}
.wiki-image-lightbox-close:hover,
.wiki-image-lightbox-close:focus { background: #fff; }
.wiki-image-lightbox-prev { grid-area: prev; }
.wiki-image-lightbox-next { grid-area: next; }
.wiki-image-lightbox-nav {
align-self: center;
width: 40px;
height: 56px;
border: 1px solid #a2a9b1;
border-radius: 3px;
background: #eaecf0;
color: #202122;
font-size: 26px;
cursor: pointer;
}
.wiki-image-lightbox-nav:hover,
.wiki-image-lightbox-nav:focus { background: #fff; }
.wiki-image-lightbox-counter { grid-area: counter; color: #54595d; white-space: nowrap; }
.wiki-image-lightbox-panel .thumbcaption { grid-area: caption; padding: 0 4px 3px; }
body.wiki-image-lightbox-open { overflow: hidden; }
@media (max-width: 820px) {
.wiki-article .gallery-grid { grid-template-columns: 1fr; }
.wiki-image-lightbox-panel {
grid-template-columns: 36px minmax(0, 1fr) 36px;
width: 98vw;
}
.wiki-image-lightbox-panel > img { height: 70vh; }
.wiki-image-lightbox-nav { width: 34px; height: 48px; }
}
/* mission-gallery-enhancements:end */
""".strip()
GALLERY_JS = r"""<script>
(function () {
function ready(fn) {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', fn, { once: true });
} else {
fn();
}
}
function captionFor(image) {
var container = image.closest('.gallery-item, figure, td, tr, .infobox-image');
var caption = container && container.querySelector('.thumbcaption');
return caption ? caption.textContent.trim() : (image.alt || '');
}
function install(article) {
if (!article || article.dataset.galleryLightboxReady === '1') { return; }
var images = Array.prototype.slice.call(article.querySelectorAll('img[data-wiki-asset]'));
if (!images.length) { return; }
article.dataset.galleryLightboxReady = '1';
var overlay = document.createElement('div');
overlay.className = 'wiki-image-lightbox';
overlay.hidden = true;
overlay.innerHTML = '<figure class="wiki-image-lightbox-panel" role="dialog" aria-modal="true" aria-label="任务图集图片预览">' +
'<button class="wiki-image-lightbox-close" type="button" aria-label="关闭图片预览">×</button>' +
'<button class="wiki-image-lightbox-nav wiki-image-lightbox-prev" type="button" aria-label="上一张"></button>' +
'<img alt="">' +
'<button class="wiki-image-lightbox-nav wiki-image-lightbox-next" type="button" aria-label="下一张"></button>' +
'<span class="wiki-image-lightbox-counter" aria-live="polite"></span>' +
'<figcaption class="thumbcaption"></figcaption></figure>';
document.body.appendChild(overlay);
var previewImage = overlay.querySelector('img');
var previewCaption = overlay.querySelector('figcaption');
var counter = overlay.querySelector('.wiki-image-lightbox-counter');
var closeButton = overlay.querySelector('.wiki-image-lightbox-close');
var previousButton = overlay.querySelector('.wiki-image-lightbox-prev');
var nextButton = overlay.querySelector('.wiki-image-lightbox-next');
var activeIndex = 0;
var lastTrigger = null;
function render(index) {
activeIndex = (index + images.length) % images.length;
var image = images[activeIndex];
previewImage.src = image.dataset.wikiAsset || image.currentSrc || image.src;
previewImage.alt = image.alt || '';
previewCaption.textContent = captionFor(image);
counter.textContent = (activeIndex + 1) + ' / ' + images.length;
}
function closePreview() {
overlay.hidden = true;
previewImage.removeAttribute('src');
document.body.classList.remove('wiki-image-lightbox-open');
if (lastTrigger) { lastTrigger.focus(); }
}
function openPreview(image, event) {
if (event) { event.preventDefault(); event.stopPropagation(); }
lastTrigger = image;
render(images.indexOf(image));
overlay.hidden = false;
document.body.classList.add('wiki-image-lightbox-open');
closeButton.focus();
}
images.forEach(function (image) {
image.tabIndex = 0;
image.setAttribute('role', 'button');
image.setAttribute('aria-label', '打开图片预览:' + (image.alt || '任务影像'));
image.addEventListener('click', function (event) { openPreview(image, event); });
image.addEventListener('keydown', function (event) {
if (event.key === 'Enter' || event.key === ' ') { openPreview(image, event); }
});
});
previousButton.hidden = images.length < 2;
nextButton.hidden = images.length < 2;
previousButton.addEventListener('click', function () { render(activeIndex - 1); });
nextButton.addEventListener('click', function () { render(activeIndex + 1); });
closeButton.addEventListener('click', closePreview);
overlay.addEventListener('click', function (event) {
if (event.target === overlay) { closePreview(); }
});
document.addEventListener('keydown', function (event) {
if (overlay.hidden) { return; }
if (event.key === 'Escape') { closePreview(); }
if (event.key === 'ArrowLeft') { render(activeIndex - 1); }
if (event.key === 'ArrowRight') { render(activeIndex + 1); }
});
}
ready(function () {
Array.prototype.forEach.call(document.querySelectorAll('.wiki-article'), install);
});
}());
</script>"""
def add_image_loading_attributes(match: re.Match[str]) -> str:
tag = match.group(0)
additions = []
if not re.search(r"\bloading=", tag, re.IGNORECASE):
additions.append('loading="lazy"')
if not re.search(r"\bdecoding=", tag, re.IGNORECASE):
additions.append('decoding="async"')
if not re.search(r"\bfetchpriority=", tag, re.IGNORECASE):
additions.append('fetchpriority="low"')
if not additions:
return tag
return tag[:-1].rstrip() + " " + " ".join(additions) + ">"
def normalize(path: Path) -> bool:
original = path.read_text(encoding="utf-8")
updated = LEGACY_LIGHTBOX_CSS_RE.sub("\n", original)
updated = CSS_BLOCK_RE.sub("\n", updated)
updated = STYLE_END_RE.sub(GALLERY_CSS + "\n</style>", updated, count=1)
updated = ASSET_IMAGE_RE.sub(add_image_loading_attributes, updated)
updated = SCRIPT_RE.sub("\n", updated).rstrip()
updated += "\n"
if updated == original:
return False
path.write_text(updated, encoding="utf-8", newline="\n")
return True
def main() -> int:
changed = 0
for path in GALLERY_FILES:
was_changed = normalize(path)
changed += int(was_changed)
print(f"gallery_normalized file={path.name} changed={str(was_changed).lower()}")
print(f"gallery_normalized_summary files={len(GALLERY_FILES)} changed={changed}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+108
View File
@@ -0,0 +1,108 @@
(function () {
'use strict';
if (window.__kspMissionGalleryRuntimeV1) { return; }
window.__kspMissionGalleryRuntimeV1 = true;
var selector = '.wiki-article .gallery-grid img[data-wiki-asset]';
var overlay = document.createElement('div');
overlay.className = 'wiki-image-lightbox';
overlay.hidden = true;
overlay.innerHTML = '<figure class="wiki-image-lightbox-panel" role="dialog" aria-modal="true" aria-label="Mission gallery image preview">' +
'<button class="wiki-image-lightbox-close" type="button" aria-label="Close image preview">&times;</button>' +
'<button class="wiki-image-lightbox-nav wiki-image-lightbox-prev" type="button" aria-label="Previous image">&lsaquo;</button>' +
'<img alt="">' +
'<button class="wiki-image-lightbox-nav wiki-image-lightbox-next" type="button" aria-label="Next image">&rsaquo;</button>' +
'<span class="wiki-image-lightbox-counter" aria-live="polite"></span>' +
'<figcaption class="thumbcaption"></figcaption></figure>';
var previewImage = overlay.querySelector('img');
var previewCaption = overlay.querySelector('figcaption');
var counter = overlay.querySelector('.wiki-image-lightbox-counter');
var closeButton = overlay.querySelector('.wiki-image-lightbox-close');
var previousButton = overlay.querySelector('.wiki-image-lightbox-prev');
var nextButton = overlay.querySelector('.wiki-image-lightbox-next');
var images = [];
var activeIndex = 0;
var lastTrigger = null;
function captionFor(image) {
var container = image.closest('.gallery-item, figure, td, tr, .infobox-image');
var caption = container && container.querySelector('.thumbcaption');
return caption ? caption.textContent.trim() : (image.alt || '');
}
function enhanceImages(root) {
var scope = root && root.querySelectorAll ? root : document;
Array.prototype.forEach.call(scope.querySelectorAll(selector), function (image) {
image.tabIndex = 0;
image.setAttribute('role', 'button');
image.setAttribute('aria-label', 'Open image preview: ' + (image.alt || 'mission image'));
var article = image.closest('.wiki-article');
if (article) { article.dataset.galleryLightboxReady = '1'; }
});
}
function render(index) {
activeIndex = (index + images.length) % images.length;
var image = images[activeIndex];
previewImage.src = image.dataset.wikiAsset || image.currentSrc || image.src;
previewImage.alt = image.alt || '';
previewCaption.textContent = captionFor(image);
counter.textContent = (activeIndex + 1) + ' / ' + images.length;
previousButton.hidden = images.length < 2;
nextButton.hidden = images.length < 2;
}
function openPreview(image, event) {
var article = image.closest('.wiki-article');
images = Array.prototype.slice.call(article.querySelectorAll('.gallery-grid img[data-wiki-asset]'));
if (!images.length) { return; }
if (event) { event.preventDefault(); event.stopPropagation(); }
lastTrigger = image;
render(images.indexOf(image));
overlay.hidden = false;
document.body.classList.add('wiki-image-lightbox-open');
closeButton.focus();
}
function closePreview() {
overlay.hidden = true;
previewImage.removeAttribute('src');
document.body.classList.remove('wiki-image-lightbox-open');
if (lastTrigger && lastTrigger.isConnected) { lastTrigger.focus(); }
}
document.body.appendChild(overlay);
enhanceImages(document);
document.addEventListener('click', function (event) {
var image = event.target.closest && event.target.closest(selector);
if (image) { openPreview(image, event); }
});
document.addEventListener('keydown', function (event) {
if (!overlay.hidden) {
if (event.key === 'Escape') { closePreview(); }
if (event.key === 'ArrowLeft') { render(activeIndex - 1); }
if (event.key === 'ArrowRight') { render(activeIndex + 1); }
return;
}
if ((event.key === 'Enter' || event.key === ' ') && document.activeElement.matches(selector)) {
openPreview(document.activeElement, event);
}
});
previousButton.addEventListener('click', function () { render(activeIndex - 1); });
nextButton.addEventListener('click', function () { render(activeIndex + 1); });
closeButton.addEventListener('click', closePreview);
overlay.addEventListener('click', function (event) {
if (event.target === overlay) { closePreview(); }
});
new MutationObserver(function (mutations) {
mutations.forEach(function (mutation) {
Array.prototype.forEach.call(mutation.addedNodes, function (node) {
if (node.nodeType === 1) { enhanceImages(node); }
});
});
}).observe(document.body, { childList: true, subtree: true });
}());
@@ -0,0 +1,114 @@
"""Publish the mission-gallery runtime through Wiki.js global theme injection."""
from __future__ import annotations
import json
import sys
from pathlib import Path
import psycopg
from psycopg.rows import dict_row
from wiki_update_page import (
WikiUpdateError,
connect_info,
find_auth_group,
get_json_cast,
graphql,
grant_temporary_system_permission,
load_env,
login,
normalize_json,
remove_temporary_system_permission,
)
START = "<!-- ksp-mission-gallery-runtime:start -->"
END = "<!-- ksp-mission-gallery-runtime:end -->"
def replace_managed_block(existing: str, block: str) -> str:
start = existing.find(START)
end = existing.find(END)
if start >= 0 and end >= start:
end += len(END)
return (existing[:start] + block + existing[end:]).strip()
return (existing.strip() + "\n" + block).strip()
def main() -> int:
root = Path(__file__).resolve().parents[1]
env = load_env(root / ".env")
conninfo = connect_info(env)
identities = [value for value in (env.get("wiki_useremail"), env.get("wiki_username")) if value]
if not identities or not env.get("wiki_password"):
raise WikiUpdateError("Missing Wiki credentials")
runtime = (root / "scripts" / "wiki_mission_gallery_runtime.js").read_text(encoding="utf-8").strip()
managed_block = f"{START}\n<script data-ksp-mission-gallery-runtime>\n{runtime}\n</script>\n{END}"
group_id = None
try:
with psycopg.connect(**conninfo, row_factory=dict_row) as conn:
with conn.cursor() as cur:
group = find_auth_group(cur, "automation", [identity.lower() for identity in identities])
group_id = group["id"]
grant_temporary_system_permission(cur, group)
conn.commit()
token = login(env.get("WIKI_URL", "http://192.168.195.241:38353").rstrip("/"), identities, env["wiki_password"])
wiki_url = env.get("WIKI_URL", "http://192.168.195.241:38353").rstrip("/")
query = """
query ThemeConfig {
theming { config { theme iconset darkMode tocPosition injectCSS injectHead injectBody } }
}
"""
current_payload = graphql(wiki_url, query, token=token)
config = current_payload.get("data", {}).get("theming", {}).get("config")
if not config:
raise WikiUpdateError(f"Unable to read theme config: {json.dumps(current_payload)}")
inject_body = replace_managed_block(config.get("injectBody") or "", managed_block)
mutation = """
mutation SetTheme($theme: String!, $iconset: String!, $darkMode: Boolean!, $tocPosition: String, $injectCSS: String, $injectHead: String, $injectBody: String) {
theming {
setConfig(theme: $theme, iconset: $iconset, darkMode: $darkMode, tocPosition: $tocPosition, injectCSS: $injectCSS, injectHead: $injectHead, injectBody: $injectBody) {
responseResult { succeeded errorCode slug message }
}
}
}
"""
updated = graphql(
wiki_url,
mutation,
{
"theme": config["theme"],
"iconset": config["iconset"],
"darkMode": config["darkMode"],
"tocPosition": config.get("tocPosition"),
"injectCSS": config.get("injectCSS") or "",
"injectHead": config.get("injectHead") or "",
"injectBody": inject_body,
},
token,
)
result = updated.get("data", {}).get("theming", {}).get("setConfig", {}).get("responseResult", {})
print(
"theming.setConfig succeeded={succeeded} errorCode={errorCode} slug={slug} message={message}".format(
**{key: result.get(key) for key in ("succeeded", "errorCode", "slug", "message")}
)
)
if not result.get("succeeded"):
raise WikiUpdateError(f"Theme runtime publish failed: {json.dumps(updated)}")
print(f"mission_gallery_runtime_published bytes={len(runtime.encode('utf-8'))}")
return 0
finally:
if group_id is not None:
remove_temporary_system_permission(conninfo, group_id)
if __name__ == "__main__":
try:
sys.exit(main())
except WikiUpdateError as exc:
print(f"wiki_runtime_error={exc}")
sys.exit(2)
+2 -2
View File
@@ -328,8 +328,8 @@ def extract_script_to_script_js(content):
if not match:
print("script_js_extracted=False")
return content, ""
# Wiki.js injects scriptJs verbatim into the rendered page body, so the
# payload must retain its script element instead of containing bare JS.
# Wiki.js injects scriptJs verbatim into the rendered body. Preserve the
# script element; a bare payload is parsed as body text instead of executed.
script_js = match.group(0).strip()
content_without_script = (content[: match.start()] + content[match.end() :]).rstrip()
print(f"script_js_extracted=True length={len(script_js)}")
+143
View File
@@ -0,0 +1,143 @@
"""End-to-end regression for every Guanghan mission gallery on Wiki.js."""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
from playwright.sync_api import TimeoutError as PlaywrightTimeoutError
from playwright.sync_api import sync_playwright
REAL_GALLERIES = (
"BL-01",
"BL-02",
"BL-03",
"GHC-01",
"GHC-02",
"GHC-03",
"GHC-04",
"GHC-05",
"GHC-06",
"JC-01",
)
PLANNING_GALLERIES = ("JC-02",)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--base-url", default="http://118.89.192.134:59100")
parser.add_argument("--screenshot-dir", type=Path)
return parser.parse_args()
def gallery_url(base_url: str, mission: str) -> str:
return f"{base_url.rstrip('/')}/zh/home/mission/Guanghan_Program/{mission}/Gallery"
def verify_real_gallery(page, mission: str, url: str, screenshot_dir: Path | None) -> dict:
page.goto(url, wait_until="domcontentloaded", timeout=60_000)
article = page.locator(".wiki-article")
article.wait_for(state="attached", timeout=30_000)
images = page.locator(".wiki-article img[data-wiki-asset]")
images.first.wait_for(state="attached", timeout=30_000)
image_count = images.count()
if image_count < 1:
raise AssertionError("no real gallery images found")
page.wait_for_function(
"document.querySelector('.wiki-article')?.dataset.galleryLightboxReady === '1'",
timeout=15_000,
)
first_image = images.first
first_image.scroll_into_view_if_needed(timeout=15_000)
first_image.wait_for(state="visible", timeout=15_000)
first_src = first_image.get_attribute("data-wiki-asset") or first_image.get_attribute("src")
first_image.click(timeout=15_000)
overlay = page.locator(".wiki-image-lightbox:not([hidden])")
overlay.wait_for(state="visible", timeout=10_000)
preview = overlay.locator("img")
page.wait_for_function(
"document.querySelector('.wiki-image-lightbox:not([hidden]) img')?.naturalWidth > 0",
timeout=30_000,
)
assert overlay.locator(".wiki-image-lightbox-counter").inner_text().strip() == f"1 / {image_count}"
assert preview.get_attribute("src") == first_src
if screenshot_dir is not None:
screenshot_dir.mkdir(parents=True, exist_ok=True)
page.screenshot(path=str(screenshot_dir / f"{mission.lower()}-lightbox.png"), timeout=120_000)
if image_count > 1:
page.get_by_role("button", name="Next image").click(timeout=10_000)
assert overlay.locator(".wiki-image-lightbox-counter").inner_text().strip() == f"2 / {image_count}"
assert preview.get_attribute("src") != first_src
page.keyboard.press("ArrowLeft")
assert overlay.locator(".wiki-image-lightbox-counter").inner_text().strip() == f"1 / {image_count}"
page.keyboard.press("Escape")
overlay.wait_for(state="hidden", timeout=10_000)
first_image.focus()
page.keyboard.press("Enter")
overlay.wait_for(state="visible", timeout=10_000)
page.get_by_role("button", name="Close image preview").click(timeout=10_000)
overlay.wait_for(state="hidden", timeout=10_000)
return {"mission": mission, "images": image_count, "click": True, "next": image_count > 1, "keyboard": True}
def verify_planning_gallery(page, mission: str, url: str) -> dict:
page.goto(url, wait_until="domcontentloaded", timeout=60_000)
page.locator(".wiki-article").wait_for(state="attached", timeout=30_000)
page.locator(".wiki-article.gallery-planning").wait_for(state="attached", timeout=30_000)
assert page.locator(".wiki-article.gallery-planning").count() == 1
assert page.locator("img[data-wiki-asset]").count() == 0
assert page.locator(".wiki-image-lightbox:not([hidden])").count() == 0
return {"mission": mission, "images": 0, "planning": True}
def main() -> int:
args = parse_args()
results = []
failures = []
with sync_playwright() as playwright:
browser = playwright.chromium.launch(headless=True)
context = browser.new_context(viewport={"width": 1440, "height": 1000})
for mission in REAL_GALLERIES:
page = context.new_page()
page_errors = []
page.on("pageerror", lambda error, bucket=page_errors: bucket.append(str(error)))
try:
result = verify_real_gallery(page, mission, gallery_url(args.base_url, mission), args.screenshot_dir)
if page_errors:
raise AssertionError(f"page errors: {page_errors}")
results.append(result)
print(f"gallery_e2e_ok mission={mission} images={result['images']}", flush=True)
except (AssertionError, PlaywrightTimeoutError, Exception) as exc:
failures.append({"mission": mission, "error": f"{type(exc).__name__}: {exc}"})
print(f"gallery_e2e_failed mission={mission} error={type(exc).__name__}: {exc}", flush=True)
finally:
page.close()
for mission in PLANNING_GALLERIES:
page = context.new_page()
try:
results.append(verify_planning_gallery(page, mission, gallery_url(args.base_url, mission)))
print(f"gallery_e2e_ok mission={mission} planning=true", flush=True)
except (AssertionError, PlaywrightTimeoutError, Exception) as exc:
failures.append({"mission": mission, "error": f"{type(exc).__name__}: {exc}"})
print(f"gallery_e2e_failed mission={mission} error={type(exc).__name__}: {exc}", flush=True)
finally:
page.close()
browser.close()
print(json.dumps({"results": results, "failures": failures}, ensure_ascii=False, indent=2))
return 1 if failures else 0
if __name__ == "__main__":
sys.exit(main())