Spec: 2026-11-05_blog-5n-admin-deep-optimization Co-authored-by: GLM-5.3
296 lines
12 KiB
Python
296 lines
12 KiB
Python
#!/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())
|