#!/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 退出码: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'
{html.escape(str(value))}
' f'
{label}
' for label, value in cards ) tag_rows = ''.join( f'
  • {html.escape(tag)}' f'×{n}
  • ' for tag, n in stats['top_tags'] ) or '
  • (无)
  • ' pinned_rows = ''.join( f'
  • {html.escape(title)}
  • ' for title in stats['pinned'] ) or '
  • (无)
  • ' return f""" 内容仪表盘 · 构建期生成

    内容仪表盘 · 构建期生成

    生成时间 {html.escape(generated_at)}

    {card_html}

    标签 TOP 10(可见文章)

      {tag_rows}

    置顶文章

      {pinned_rows}
    口径:可见 = 无 draft、无 hidden、tags 不含 hidden(与站点过滤一致);字数 = 可见文章正文剥离 markdown 标记与空白后的字符数。
    """ def main() -> int: if len(sys.argv) != 2 or not sys.argv[1]: print('用法: python3 scripts/build-perf/gen-admin-stats.py ', 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())