#!/usr/bin/env python3 import argparse import unicodedata from dataclasses import dataclass from pathlib import Path from typing import List LABEL_BG = "#555555" DEFAULT_TOTAL_COLOR = "#1f6feb" DEFAULT_FILES_COLOR = "#2da44e" DEFAULT_LANGUAGE_COUNT_COLOR = "#8250df" TEXT_COLOR = "#ffffff" LIGHT_TEXT_COLOR = "#24292f" LIGHT_BADGE_BG = "#f6f8fa" LIGHT_BADGE_BORDER = "#d0d7de" @dataclass class LanguageStat: lang_id: str display_name: str code_lines: int formatted_lines: str file_count: int color: str def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser() parser.add_argument("--output-dir", required=True) parser.add_argument("--readme-path", required=True) parser.add_argument("--repo", required=True) parser.add_argument("--generated-by", required=True) parser.add_argument("--updated-at", required=True) parser.add_argument("--total-color", default=DEFAULT_TOTAL_COLOR) parser.add_argument("--files-color", default=DEFAULT_FILES_COLOR) parser.add_argument("--language-count-color", default=DEFAULT_LANGUAGE_COUNT_COLOR) parser.add_argument("--total-code", required=True, type=int) parser.add_argument("--formatted-total-code", required=True) parser.add_argument("--total-files", required=True, type=int) parser.add_argument("--formatted-total-files", required=True) parser.add_argument("--language-count", required=True, type=int) parser.add_argument("--exclude-dirs", required=True) parser.add_argument("--min-lines-threshold", required=True, type=int) parser.add_argument("--lang-summary", required=True) return parser.parse_args() def escape_xml(value: str) -> str: return ( value.replace("&", "&") .replace("<", "<") .replace(">", ">") .replace('"', """) .replace("'", "'") ) def display_units(value: str) -> int: units = 0 for char in value: if unicodedata.east_asian_width(char) in {"F", "W"}: units += 2 else: units += 1 return units def text_width(value: str) -> int: return display_units(value) * 7 + 20 def normalize_color(value: str, fallback: str) -> str: if not value: return fallback return value if value.startswith("#") else f"#{value}" def parse_language_summary(path: Path) -> List[LanguageStat]: stats: List[LanguageStat] = [] if not path.exists(): return stats for raw_line in path.read_text(encoding="utf-8").splitlines(): line = raw_line.strip() if not line: continue parts = line.split("|") if len(parts) < 6: continue while len(parts) < 7: parts.append("") lang_id, display_name, code_lines, formatted_lines, file_count, color, _icon = parts[:7] stats.append( LanguageStat( lang_id=lang_id, display_name=display_name, code_lines=int(code_lines), formatted_lines=formatted_lines, file_count=int(file_count), color=normalize_color(color, DEFAULT_TOTAL_COLOR), ) ) return stats def split_badge_svg(label: str, message: str, color: str) -> str: height = 20 left_width = max(46, text_width(label)) right_width = max(46, text_width(message)) total_width = left_width + right_width left_text_x = left_width / 2 right_text_x = left_width + right_width / 2 return f""" {escape_xml(label)}: {escape_xml(message)} {escape_xml(label)} {escape_xml(message)} """ def flat_badge_svg(label: str, message: str, color: str) -> str: height = 20 gap = 10 label_width = max(34, text_width(label) - 4) message_width = max(38, text_width(message) - 4) total_width = label_width + message_width + gap + 20 label_x = 10 message_x = label_x + label_width + gap label_text_x = label_x + label_width / 2 message_text_x = message_x + message_width / 2 return f""" {escape_xml(label)}: {escape_xml(message)} {escape_xml(label)} {escape_xml(message)} """ def write_badge(path: Path, label: str, message: str, color: str, variant: str = "split") -> None: if variant == "flat": svg = flat_badge_svg(label, message, color) else: svg = split_badge_svg(label, message, color) path.write_text(svg, encoding="utf-8") def render_readme(args: argparse.Namespace, languages: List[LanguageStat], badge_dir_name: str) -> str: language_rows = [] for stat in languages: share = (stat.code_lines * 100 / args.total_code) if args.total_code else 0.0 language_rows.append( f"| **{stat.display_name}** | {stat.formatted_lines} | {stat.file_count} | {share:.1f}% | " f"![{stat.display_name}](./{badge_dir_name}/{stat.lang_id}-lines.svg) |" ) if language_rows: language_table = "\n".join(language_rows) else: language_table = "| _未找到符合条件的语言_ | - | - | - | - |" return f"""# 📊 代码统计详细报告 > 此分支由 {args.generated_by} 自动生成和维护 > > ⚠️ **请勿手动修改此分支的内容!** > > 🤖 由 {args.generated_by} 自动生成 > > 📅 更新时间: {args.updated_at} ## 📈 总体统计 | 统计项 | 数值 | 徽章 | |--------|------|------| | 💻 总代码行数 | **{args.formatted_total_code}** 行 | ![总代码](./{badge_dir_name}/total-lines.svg) | | 📁 总文件数 | **{args.formatted_total_files}** 个 | ![总文件](./{badge_dir_name}/total-files.svg) | | 🌐 语言种类 | **{args.language_count}** 种 | ![语言种类](./{badge_dir_name}/language-count.svg) | ## 🌈 语言分布 | 语言 | 代码行数 | 文件数 | 占比 | 徽章 | |------|----------|--------|------|------| {language_table} --- ## ⚙️ 配置说明 ### Token 配置 - 当前使用: **WORKFLOW** - 需要在 Settings -> Actions -> Secrets 中配置 `WORKFLOW` ### 排除规则 当前排除的目录: ``` {args.exclude_dirs} ``` ### 阈值设置 - 最小代码行数阈值: **{args.min_lines_threshold}** 行 - 低于此阈值的语言不会生成徽章 --- *📊 统计仓库:`{args.repo}`* """ def main() -> None: args = parse_args() output_dir = Path(args.output_dir) readme_path = Path(args.readme_path) output_dir.mkdir(parents=True, exist_ok=True) readme_path.parent.mkdir(parents=True, exist_ok=True) languages = parse_language_summary(Path(args.lang_summary)) badge_dir_name = output_dir.name write_badge( output_dir / "total-lines.svg", "代码", f"{args.formatted_total_code} 行", normalize_color(args.total_color, DEFAULT_TOTAL_COLOR), ) write_badge( output_dir / "total-files.svg", "文件", f"{args.formatted_total_files} 个", normalize_color(args.files_color, DEFAULT_FILES_COLOR), ) write_badge( output_dir / "language-count.svg", "语言", f"{args.language_count} 种", normalize_color(args.language_count_color, DEFAULT_LANGUAGE_COUNT_COLOR), ) for stat in languages: write_badge( output_dir / f"{stat.lang_id}-lines.svg", stat.display_name, f"{stat.formatted_lines} 行", stat.color, variant="flat", ) readme_path.write_text(render_readme(args, languages, badge_dir_name), encoding="utf-8") if __name__ == "__main__": main()