chore(spec): 归档 blog-5n——八任务收敛(后台深度优化全家桶)

Spec: 2026-11-05_blog-5n-admin-deep-optimization

Co-authored-by: GLM-5.3
This commit is contained in:
wait4xandGLM-5.3 committed 2026-11-05 20:13:09 +08:00
1 parent a555a3e450
commit 5c0e6ed900
29 files changed
+1559 -512

No files matched your search

+295
View File
@@ -0,0 +1,295 @@
#!/usr/bin/env python3
"""
构建期内容仪表盘生成器(blog-5n 后台深度优化包)
为什么需要:后台(Sveltia CMS)只管编辑不管盘点,"站上到底有多少篇可见文、
草稿积了多少、字数体量多大"每次都要手动数。本脚本在构建期扫 content/ 一次性
算好统计,产出两个静态产物挂到 dist/admin/ 下,浏览器直接打开 stats.html
即可看板(无 JS、无外部资源,纯构建期生成,不给运行时加任何负担)。
挂载位置:infra/deploy/build.sh 在 `pnpm astro build` 之后调用
`python3 scripts/build-perf/gen-admin-stats.py dist`(写不进产物树的活产物
就由构建脚本补写)。
用法:python3 scripts/build-perf/gen-admin-stats.py <dist目录>
退出码:0 成功;1 参数缺失(或 pyyaml 缺失)。单篇 frontmatter 解析失败
只告警跳过,不让整次构建失败。
"""
import html
import json
import re
import sys
import time
from datetime import date, datetime
from pathlib import Path
try:
import yaml
except ImportError:
print('[gen-admin-stats] 缺少 pyyaml:pip install pyyaml')
sys.exit(1)
# 仓库根 = 本文件上三级(scripts/build-perf/ → scripts/ → 根),
# 与 config-check.py 一样假定 content/ 布局,但从脚本自身定位根目录,
# 这样 build.sh 从任意 cwd 调用都不依赖工作目录。
ROOT = Path(__file__).resolve().parent.parent.parent
POSTS_DIR = ROOT / 'content' / 'posts'
POSTS_EN_DIR = ROOT / 'content' / 'posts-en'
MOMENTS_DIR = ROOT / 'content' / 'moments'
PHOTOS_DIR = ROOT / 'content' / 'photos'
# zh_chars 的正文剥离规则(粗粒度写作量口径):剔除代码块/图片/链接目标/HTML
# 这些"非正文"成分,保留链接文字、行内代码等文字本身,再去 markdown 标记。
# 顺序敏感:先整块删 fenced code 与图片,再做行内替换,最后删行首标记。
_MD_STRIP = [
(re.compile(r'```.*?```', re.S), ''), # 围栏代码块整块删(代码不是写作量)
(re.compile(r'!\[[^\]]*\]\([^)]*\)'), ''), # 图片(alt 不是正文)
(re.compile(r'\[([^\]]*)\]\([^)]*\)'), r'\1'), # 链接只留文字
(re.compile(r'<[^>\n]+>'), ''), # 裸 HTML 标签(瞬间体例里常见 span)
(re.compile(r'^\s{0,3}(?:#{1,6}|>|[-*+]|\d+[.)])\s*', re.M), ''), # 标题/引用/列表行首标记
(re.compile(r'[*_~]{1,3}'), ''), # 强调/删除线标记
(re.compile(r'^\s*\|?[-: |]+\|?\s*$', re.M), ''), # 表格分隔行
(re.compile(r'\|'), ''), # 表格竖线
]
_WHITESPACE = re.compile(r'[\s\u3000\u00a0\ufeff]+')
def split_frontmatter(text):
"""切出 (frontmatter 文本, 正文)。无 frontmatter 时返回 (None, 全文)。"""
if not text.startswith('---'):
return None, text
lines = text.splitlines(keepends=True)
for i in range(1, len(lines)):
if lines[i].strip() == '---':
return ''.join(lines[1:i]), ''.join(lines[i + 1:])
return None, text
def tag_list(frontmatter):
"""tags 归一为字符串列表(容忍单值标量写法与缺省)。"""
tags = frontmatter.get('tags') if isinstance(frontmatter, dict) else None
if tags is None:
return []
if isinstance(tags, str):
return [tags]
return [str(t) for t in tags]
def pubdate_iso(frontmatter):
"""pubDate 归一为可比对象;返回 (可比键, ISO 字符串) 或 (None, None)。"""
value = frontmatter.get('pubDate') if isinstance(frontmatter, dict) else None
if isinstance(value, (date, datetime)):
return value, value.date().isoformat() if isinstance(value, datetime) else value.isoformat()
if isinstance(value, str) and value:
try:
return date.fromisoformat(value[:10]), value[:10]
except ValueError:
return None, None
return None, None
def count_body_chars(body):
"""正文剥离 markdown 标记与空白后的字符数(粗粒度)。"""
text = body
for pattern, repl in _MD_STRIP:
text = pattern.sub(repl, text)
return len(_WHITESPACE.sub('', text))
def scan_posts():
"""扫 content/posts/,按站点可见性契约分类计数。
口径与 src/lib/post-visibility.ts 单一事实源对齐:可见 = 无 draft、无
hidden、tags 不含 'hidden'(hidden 两个字段都认,与站点过滤一致)。
三类互斥、draft 优先:draft 构建期连页面都不生成,hidden 标记对它无意义。
pinned 只从可见集取——站点上隐藏与置顶同开以隐藏为准
(src/components/PostList.astro 也是先 filter visible 再取 pinned)。
"""
stats = {
'posts_visible': 0,
'posts_draft': 0,
'posts_hidden': 0,
'zh_chars': 0,
'pinned': [],
}
tag_counter = {}
newest = None # (date/datetime 键, ISO 字符串)
for path in sorted(POSTS_DIR.glob('*.md')):
try:
fm_text, body = split_frontmatter(path.read_text(encoding='utf-8'))
# 无 frontmatter(或没闭合)的文章连站点 schema 都过不了,
# 与解析失败同待遇:跳过告警,不计入任何一类
if fm_text is None:
raise yaml.YAMLError('missing or unterminated frontmatter')
fm = yaml.safe_load(fm_text) if fm_text else {}
if not isinstance(fm, dict):
fm = {}
except (OSError, yaml.YAMLError, UnicodeDecodeError) as exc:
print(f'[gen-admin-stats] SKIP {path.name}(frontmatter 解析失败:{exc})')
continue
tags = tag_list(fm)
if fm.get('draft'):
stats['posts_draft'] += 1
continue
if fm.get('hidden') or 'hidden' in tags:
stats['posts_hidden'] += 1
continue
stats['posts_visible'] += 1
stats['zh_chars'] += count_body_chars(body)
for tag in tags:
tag_counter[tag] = tag_counter.get(tag, 0) + 1
if fm.get('pinned'):
title = str(fm.get('title') or path.stem)
stats['pinned'].append(title)
key, iso = pubdate_iso(fm)
if key is not None and (newest is None or key > newest[0]):
newest = (key, iso)
# 计数降序、同数按标签名排,保证产物可 diff、跨机器可复现
stats['top_tags'] = [
[tag, n]
for tag, n in sorted(tag_counter.items(), key=lambda kv: (-kv[1], kv[0]))[:10]
]
stats['newest_pubdate'] = newest[1] if newest else None
return stats
def render_html(stats, generated_at):
"""自包含浅色看板页:内联 CSS、无 JS、无外部资源、数字大字卡片网格。"""
cards = [
('可见文章', stats['posts_visible']),
('草稿', stats['posts_draft']),
('隐藏', stats['posts_hidden']),
('英文文章', stats['posts_en']),
('瞬间', stats['moments']),
('照片', stats['photos']),
('正文总字数', stats['zh_chars']),
('最新发布', stats['newest_pubdate'] or '—'),
]
card_html = ''.join(
f'<div class="card"><div class="num">{html.escape(str(value))}</div>'
f'<div class="label">{label}</div></div>'
for label, value in cards
)
tag_rows = ''.join(
f'<li><span class="tag">{html.escape(tag)}</span>'
f'<span class="count">×{n}</span></li>'
for tag, n in stats['top_tags']
) or '<li class="empty">(无)</li>'
pinned_rows = ''.join(
f'<li>{html.escape(title)}</li>' for title in stats['pinned']
) or '<li class="empty">(无)</li>'
return f"""<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>内容仪表盘 · 构建期生成</title>
<style>
:root {{
color-scheme: light;
}}
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
body {{
font-family: system-ui, -apple-system, 'Segoe UI', 'PingFang SC',
'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
background: #f6f7f9; color: #1f2328;
line-height: 1.6; padding: 32px 20px 48px;
}}
.wrap {{ max-width: 920px; margin: 0 auto; }}
header {{ margin-bottom: 28px; }}
h1 {{ font-size: 22px; font-weight: 600; }}
.meta {{ margin-top: 6px; font-size: 13px; color: #6a737d; }}
.cards {{
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 14px; margin-bottom: 28px;
}}
.card {{
background: #fff; border: 1px solid #e4e7eb; border-radius: 10px;
padding: 18px 16px 14px;
}}
.num {{ font-size: 34px; font-weight: 700; letter-spacing: -0.5px;
font-variant-numeric: tabular-nums; }}
.label {{ margin-top: 4px; font-size: 13px; color: #6a737d; }}
.panels {{ display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }}
@media (max-width: 640px) {{ .panels {{ grid-template-columns: 1fr; }} }}
.panel {{
background: #fff; border: 1px solid #e4e7eb; border-radius: 10px;
padding: 16px 18px;
}}
.panel h2 {{ font-size: 15px; font-weight: 600; margin-bottom: 10px; }}
.panel ol, .panel ul {{ padding-left: 20px; }}
.panel li {{ font-size: 14px; padding: 3px 0; }}
.panel li .count {{ color: #6a737d; font-size: 13px; margin-left: 8px; }}
.empty {{ color: #6a737d; list-style: none; margin-left: -20px; }}
footer {{ margin-top: 24px; font-size: 12px; color: #8b949e; }}
</style>
</head>
<body>
<div class="wrap">
<header>
<h1>内容仪表盘 · 构建期生成</h1>
<p class="meta">生成时间 {html.escape(generated_at)}</p>
</header>
<section class="cards">{card_html}</section>
<section class="panels">
<div class="panel">
<h2>标签 TOP 10(可见文章)</h2>
<ol>{tag_rows}</ol>
</div>
<div class="panel">
<h2>置顶文章</h2>
<ul>{pinned_rows}</ul>
</div>
</section>
<footer>口径:可见 = 无 draft、无 hidden、tags 不含 hidden(与站点过滤一致);字数 = 可见文章正文剥离 markdown 标记与空白后的字符数。</footer>
</div>
</body>
</html>
"""
def main() -> int:
if len(sys.argv) != 2 or not sys.argv[1]:
print('用法: python3 scripts/build-perf/gen-admin-stats.py <dist目录>', file=sys.stderr)
return 1
dist = Path(sys.argv[1])
out_dir = dist / 'admin'
out_dir.mkdir(parents=True, exist_ok=True)
counted = scan_posts()
# 键序固定为文档口径顺序,方便人工核对与 diff
stats = {
'posts_visible': counted['posts_visible'],
'posts_draft': counted['posts_draft'],
'posts_hidden': counted['posts_hidden'],
'moments': len(list(MOMENTS_DIR.glob('*.md'))),
'photos': len(list(PHOTOS_DIR.glob('*.jpg'))),
'posts_en': len(list(POSTS_EN_DIR.glob('*.md'))),
'zh_chars': counted['zh_chars'],
'top_tags': counted['top_tags'],
'pinned': counted['pinned'],
'newest_pubdate': counted['newest_pubdate'],
}
generated_at = time.strftime('%Y-%m-%d %H:%M:%S %z')
(out_dir / 'stats.json').write_text(
json.dumps(stats, ensure_ascii=False, indent=2) + '\n', encoding='utf-8'
)
(out_dir / 'stats.html').write_text(render_html(stats, generated_at), encoding='utf-8')
print(
f"[gen-admin-stats] 可见 {stats['posts_visible']} / 草稿 {stats['posts_draft']}"
f" / 隐藏 {stats['posts_hidden']} / 瞬间 {stats['moments']} / 照片 {stats['photos']}"
f" / 英文 {stats['posts_en']} → {out_dir}/stats.{{json,html}}"
)
return 0
if __name__ == '__main__':
raise SystemExit(main())
+41
View File
@@ -0,0 +1,41 @@
#!/bin/bash
# TMT 翻译构建链守卫(5n A1):包装 scripts/translate/translate_posts.py,
# 挂在 build.sh 的 astro build 之前——中文稿保存即自动出英文版,随同次部署上线。
#
# 部署哲学:翻译是增益件,绝不能阻断发布——任何失败(缺凭据/缺脚本/超时/
# TMT 报错)都只 WARN 并保留旧英文版,exit 0 让构建继续。
# 幂等语义见 translate_posts.py 头注:源文 sha256 未变跳过(零 API 调用);
# 译文 frontmatter locked: true 的人工润色版无条件不覆盖。
set -u
REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
SCRIPT="$REPO_ROOT/scripts/translate/translate_posts.py"
CRED="$HOME/docker_apps/blog/tencent-translate-credentials.txt"
TAG="[translate]"
TIMEOUT_SECS=180
if [ ! -f "$SCRIPT" ]; then
echo "$TAG SKIP 翻译脚本未就绪($SCRIPT)"
exit 0
fi
if ! command -v python3 >/dev/null 2>&1; then
echo "$TAG SKIP 无 python3"
exit 0
fi
if [ ! -f "$CRED" ]; then
echo "$TAG SKIP 无 TMT 凭据($CRED),保留现有英文版"
exit 0
fi
echo "$TAG 开始(TMT zh→en,幂等:已译且未变的源文零调用)"
if timeout "$TIMEOUT_SECS" python3 "$SCRIPT" "$@"; then
echo "$TAG 完成"
else
rc=$?
if [ "$rc" = 124 ]; then
echo "$TAG WARN 超时(>${TIMEOUT_SECS}s),保留旧英文版继续部署"
else
echo "$TAG WARN 失败(exit $rc),保留旧英文版继续部署"
fi
fi
exit 0