Files
blog/scripts/build-perf/gen-admin-stats.py
T
wait4xandGLM-5.3 5c0e6ed900 chore(spec): 归档 blog-5n——八任务收敛(后台深度优化全家桶)
Spec: 2026-11-05_blog-5n-admin-deep-optimization

Co-authored-by: GLM-5.3
2026-11-05 20:13:09 +08:00

296 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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())