#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ 青芯科技 · 智慧校园痛点调研问卷 —— Base64 解析 + matplotlib 中文统计绘图 用法: # 原有单条解码 python survey_stats.py python survey_stats.py --interactive # 批量统计绘图:code_list.txt 每行放一个 base64 答卷 python survey_stats.py --file code_list.txt --stats # 指定自定义中文字体(.otf / .ttf) python survey_stats.py --file code_list.txt --stats --font ./fonts/思源黑体.otf """ import base64 import json import sys import argparse import os import re from datetime import datetime import matplotlib matplotlib.use("Agg") # 只保存图片,无窗口环境也可运行 import matplotlib.pyplot as plt from matplotlib import font_manager # ============================================================ # ★ 自定义中文字体路径(改成你自己的 .otf / .ttf 路径即可) # 也可用命令行参数 --font 覆盖 # ============================================================ CUSTOM_FONT_PATH = "SourceHanSansCN-Regular.otf" # 自定义字体不可用时的系统字体回退列表 CN_FALLBACK_FONTS = [ "Source Han Sans SC", "Noto Sans CJK SC", "Noto Sans SC", "WenQuanYi Zen Hei", "WenQuanYi Micro Hei", "Microsoft YaHei", "SimHei", "SimSun", "PingFang SC", "Heiti SC", "STHeiti", "Arial Unicode MS", ] def setup_chinese_font(font_path=None): """配置 matplotlib 中文字体:优先自定义 OTF,其次系统字体。""" plt.rcParams["axes.unicode_minus"] = False path = font_path or CUSTOM_FONT_PATH chosen = None if path and os.path.isfile(path): try: font_manager.fontManager.addfont(path) name = font_manager.FontProperties(fname=path).get_name() plt.rcParams["font.family"] = "sans-serif" plt.rcParams["font.sans-serif"] = [name] + CN_FALLBACK_FONTS chosen = f"{name}(自定义:{path})" except Exception as e: print(f"⚠️ 加载自定义字体失败:{e}", file=sys.stderr) if chosen is None: available = {f.name for f in font_manager.fontManager.ttflist} for name in CN_FALLBACK_FONTS: if name in available: plt.rcParams["font.family"] = "sans-serif" plt.rcParams["font.sans-serif"] = [name] + CN_FALLBACK_FONTS chosen = f"{name}(系统字体)" break if chosen is None: plt.rcParams["font.sans-serif"] = CN_FALLBACK_FONTS chosen = "⚠️ 未找到可用中文字体,图表中文可能显示为方块" print(f"🖋 图表字体:{chosen}") return chosen # ========== 题目文本映射(控制台打印 + 图表标题用) ========== QUESTION_TEXT = { "teacher": { "q2_title": "校园日常管理场景困扰程度(1=完全不困扰 ~ 5=非常困扰)", "q3_title": "最希望优先解决的三项(多选,最多3项)", "q4_title": "产品使用意愿(1=完全不愿意 ~ 5=非常愿意)", "q5_title": "最理想的部署方式", "q6_title": "最「麻烦」但又不得不做的一件事", "q7_title": "希望设备「超出预期」做到的事", }, "student": { "q2_title": "校园生活场景不方便程度(1=完全不觉得 ~ 5=非常不方便)", "q3_title": "最想要的三项功能(多选,最多3项)", "q4_title": "产品接受度(1=完全不接受 ~ 5=非常接受)", "q5_title": "更希望的身份识别方式", "q6_title": "最「烦人」或「浪费时间」的一件事", "q7_title": "「超酷」智能设备希望能做什么", }, "parent": { "q2_title": "孩子校园生活事项关注程度(1=完全不关注 ~ 5=非常关注)", "q3_title": "最希望获得的三项信息(多选,最多3项)", "q4_title": "产品支持度(1=完全不支持 ~ 5=非常支持)", "q5_title": "更希望的信息接收渠道", "q6_title": "最「不放心」或「信息不透明」的一点", "q7_title": "希望设备能让您「特别安心」的功能", }, } IDENTITY_CN = { "teacher": "老师", "student": "学生", "parent": "家长", } # 各自身份的图表文件名标签 FILE_TAG = { "teacher": {"q2": "Q2_日常管理困扰程度", "q3": "Q3_最希望优先解决", "q4": "Q4_产品使用意愿", "q5": "Q5_理想部署方式"}, "student": {"q2": "Q2_校园生活不便程度", "q3": "Q3_最想要的功能", "q4": "Q4_产品接受度", "q5": "Q5_身份识别方式"}, "parent": {"q2": "Q2_对孩子校园生活关注程度", "q3": "Q3_最希望获知的信息", "q4": "Q4_产品支持度", "q5": "Q5_信息接收渠道"}, } Q2_XLABEL = { "teacher": "平均分(1 = 完全不困扰 ~ 5 = 非常困扰)", "student": "平均分(1 = 完全不觉得 ~ 5 = 非常不方便)", "parent": "平均分(1 = 完全不关注 ~ 5 = 非常关注)", } Q4_XLABEL = { "teacher": "评分(1 = 完全不愿意 ~ 5 = 非常愿意)", "student": "评分(1 = 完全不接受 ~ 5 = 非常接受)", "parent": "评分(1 = 完全不支持 ~ 5 = 非常支持)", } # 输出目录(全中文) OUTPUT_DIR = "统计图表输出" os.makedirs(OUTPUT_DIR, exist_ok=True) # ========== 中文标签换行 / 文件名清理 ========== def _wrap_cn(text, width: int = 18) -> str: """中文长标签换行:优先在标点、斜杠、括号处断开。""" text = str(text).strip() if len(text) <= width: return text break_before = "((" break_after = ",、;//))】。" lines, cur = [], "" for ch in text: if ch in break_before and cur and len(cur) >= width * 0.5: lines.append(cur.strip()) cur = ch continue cur += ch if len(cur) >= width or (ch in break_after and len(cur) >= width * 0.5): lines.append(cur.strip()) cur = "" if cur.strip(): lines.append(cur.strip()) return "\n".join([ln for ln in lines if ln]) def _safe_filename(s: str) -> str: """清理成安全的文件名(保留中文)。""" s = re.sub(r'[\\/:*?"<>|\n\r\t]+', "_", str(s)) s = re.sub(r"\s+", "_", s) return s.strip("_") or "untitled" # ========== 解码 & 文本格式化 ========== def decode_base64(code: str) -> dict: """将 Base64 作答代码解码为 JSON 字典。""" code = "".join(code.strip().split()) if not code: raise ValueError("Base64 代码为空。") # URL-safe 兼容 code = code.replace("-", "+").replace("_", "/") # 自动补 padding pad = len(code) % 4 if pad: code += "=" * (4 - pad) try: raw_bytes = base64.b64decode(code) json_str = raw_bytes.decode("utf-8") return json.loads(json_str) except Exception as e: raise ValueError(f"Base64 解码失败:{e}\n请确认代码完整且未被修改。") def format_answer(data: dict) -> str: """将解码后的答案格式化为可读文本。""" lines = [] sep = "=" * 60 thin_sep = "-" * 60 lines.append(sep) lines.append(" 青芯科技 · 智慧校园痛点调研问卷 —— 作答详情") lines.append(" SmartChip Junior Tech. Co.") lines.append(sep) resp = data.get("respondent", {}) identity = resp.get("identity", "unknown") identity_label = resp.get("identity_label", identity) generated_at = data.get("generated_at", "") try: dt = datetime.fromisoformat(generated_at.replace("Z", "+00:00")) time_str = dt.strftime("%Y-%m-%d %H:%M:%S") except Exception: time_str = generated_at lines.append("") lines.append(f" 答卷版本:{data.get('version', 'N/A')}") lines.append(f" 问卷名称:{data.get('survey_name', 'N/A')}") lines.append(f" 受访者身份:{identity_label}({identity})") lines.append(f" 提交时间:{time_str}") lines.append("") answers = data.get("answers", {}) qtext = QUESTION_TEXT.get(identity, {}) lines.append(thin_sep) lines.append(" Q1 您的身份是?") lines.append(f" → {identity_label}") lines.append("") lines.append(thin_sep) lines.append(f" Q2 {qtext.get('q2_title', '矩阵量表')}") q2 = answers.get("q2_matrix", {}) q2_labels = answers.get("q2_matrix_labels", {}) if q2: for key, score in q2.items(): label = q2_labels.get(key, key) stars = "●" * score + "○" * (5 - score) lines.append(f" [{score}] {stars} {label}") else: lines.append(" (无数据)") lines.append("") lines.append(thin_sep) lines.append(f" Q3 {qtext.get('q3_title', '多选题')}") q3 = answers.get("q3_multi", []) q3_labels = answers.get("q3_multi_labels", []) q3_other = answers.get("q3_other", "") if q3: for i, (val, label) in enumerate(zip(q3, q3_labels), 1): lines.append(f" {i}. [{val}] {label}") if q3_other: lines.append(f" 其他说明:{q3_other}") else: lines.append(" (无数据)") lines.append("") lines.append(thin_sep) lines.append(f" Q4 {qtext.get('q4_title', '量表题')}") q4 = answers.get("q4_scale") if q4 is not None: stars = "●" * q4 + "○" * (5 - q4) lines.append(f" → {q4} / 5 {stars}") else: lines.append(" (无数据)") lines.append("") lines.append(thin_sep) lines.append(f" Q5 {qtext.get('q5_title', '单选题')}") q5_val = answers.get("q5_single") q5_label = answers.get("q5_single_label") if q5_val: lines.append(f" → [{q5_val}] {q5_label or q5_val}") else: lines.append(" (无数据)") lines.append("") lines.append(thin_sep) lines.append(f" Q6 {qtext.get('q6_title', '开放题')}") q6 = answers.get("q6_open", "") lines.append(f" {q6 if q6 else '(未填写)'}") lines.append("") lines.append(thin_sep) lines.append(f" Q7 {qtext.get('q7_title', '开放题')}") q7 = answers.get("q7_open", "") lines.append(f" {q7 if q7 else '(未填写)'}") lines.append("") lines.append(thin_sep) lines.append(" Q8 联系方式(选填)") q8 = answers.get("q8_contact", "") lines.append(f" {q8 if q8 else '(未填写)'}") lines.append("") lines.append(sep) lines.append(" —— 问卷结束,感谢您的参与 ——") lines.append(" 青芯科技 SmartChip Junior Tech. Co. | JA 学生公司") lines.append(sep) return "\n".join(lines) def print_raw_json(data: dict): print(json.dumps(data, ensure_ascii=False, indent=2)) # ===================== 统计与绘图模块 ===================== class SurveyStat: def __init__(self): self.groups = { "teacher": {"count": 0, "q2_matrix": {}, "q3_multi": {}, "q4_scale": [], "q5_single": {}}, "student": {"count": 0, "q2_matrix": {}, "q3_multi": {}, "q4_scale": [], "q5_single": {}}, "parent": {"count": 0, "q2_matrix": {}, "q3_multi": {}, "q4_scale": [], "q5_single": {}}, } def feed_one(self, data: dict): resp = data.get("respondent", {}) identity = resp.get("identity") if identity not in self.groups: return g = self.groups[identity] g["count"] += 1 ans = data.get("answers", {}) q2_data = ans.get("q2_matrix", {}) q2_labels = ans.get("q2_matrix_labels", {}) for k, score in q2_data.items(): lab = q2_labels.get(k, k) g["q2_matrix"].setdefault(lab, []).append(int(score)) for lab in ans.get("q3_multi_labels", []): g["q3_multi"][lab] = g["q3_multi"].get(lab, 0) + 1 q4 = ans.get("q4_scale") if q4 is not None: g["q4_scale"].append(int(q4)) q5lab = ans.get("q5_single_label") if q5lab: g["q5_single"][q5lab] = g["q5_single"].get(q5lab, 0) + 1 # ---------- 绘图 ---------- def plot_all(self): print("\n===== 样本概况 =====") for identity, gdata in self.groups.items(): print(f" {IDENTITY_CN[identity]}:{gdata['count']} 份答卷") for identity, gdata in self.groups.items(): n = gdata["count"] if n == 0: continue qtext = QUESTION_TEXT[identity] id_cn = IDENTITY_CN[identity] fname_tag = FILE_TAG[identity] tag = f"【{id_cn}】 n={n}" # ---- 图1 Q2 矩阵量表:各项平均分(横向柱状图,按均分降序)---- if gdata["q2_matrix"]: items = sorted( gdata["q2_matrix"].items(), key=lambda kv: sum(kv[1]) / len(kv[1]), reverse=True, ) labels = [_wrap_cn(lab, 18) for lab, _ in items] avg = [sum(v) / len(v) for _, v in items] plt.figure(figsize=(11, 6.5)) bars = plt.barh(labels, avg, color="#4C9BE8") plt.gca().invert_yaxis() plt.xlim(0, 5) for b, v in zip(bars, avg): plt.text(v + 0.06, b.get_y() + b.get_height() / 2, f"{v:.2f}", va="center", fontsize=9, color="#333333") plt.title(f"{tag} · Q2\n{qtext['q2_title']}", fontsize=12) plt.xlabel(Q2_XLABEL[identity], fontsize=10) plt.yticks(fontsize=10) plt.tight_layout() fn = _safe_filename(f"{id_cn}_{fname_tag['q2']}") + ".png" plt.savefig(os.path.join(OUTPUT_DIR, fn), dpi=150, bbox_inches="tight") plt.close() print(f" ✔ 已生成 {fn}") # ---- 图2 Q3 多选:被选次数(横向柱状图,降序)---- if gdata["q3_multi"]: items = sorted(gdata["q3_multi"].items(), key=lambda kv: kv[1], reverse=True) labels = [_wrap_cn(lab, 18) for lab, _ in items] cnt = [c for _, c in items] plt.figure(figsize=(11, 6.5)) bars = plt.barh(labels, cnt, color="#5DBB63") plt.gca().invert_yaxis() plt.xlim(0, max(cnt) * 1.18) for b, v in zip(bars, cnt): plt.text(v + max(cnt) * 0.015, b.get_y() + b.get_height() / 2, str(v), va="center", fontsize=9, color="#333333") plt.title(f"{tag} · Q3\n{qtext['q3_title']}", fontsize=12) plt.xlabel("被选次数", fontsize=10) plt.yticks(fontsize=10) plt.tight_layout() fn = _safe_filename(f"{id_cn}_{fname_tag['q3']}") + ".png" plt.savefig(os.path.join(OUTPUT_DIR, fn), dpi=150, bbox_inches="tight") plt.close() print(f" ✔ 已生成 {fn}") # ---- 图3 Q4 量表:分数分布直方图 ---- scores = gdata["q4_scale"] if scores: plt.figure(figsize=(8.5, 5.5)) plt.hist(scores, bins=range(1, 7), align="left", rwidth=0.8, color="#E8A33D") plt.xticks([1, 2, 3, 4, 5]) plt.title(f"{tag} · Q4\n{qtext['q4_title']}", fontsize=12) plt.xlabel(Q4_XLABEL[identity], fontsize=10) plt.ylabel("人数", fontsize=10) plt.yticks(fontsize=10) plt.tight_layout() fn = _safe_filename(f"{id_cn}_{fname_tag['q4']}") + ".png" plt.savefig(os.path.join(OUTPUT_DIR, fn), dpi=150, bbox_inches="tight") plt.close() print(f" ✔ 已生成 {fn}") # ---- 图4 Q5 单选:饼图(降序)---- if gdata["q5_single"]: items = sorted(gdata["q5_single"].items(), key=lambda kv: kv[1], reverse=True) labels = [_wrap_cn(lab, 12) for lab, _ in items] vals = [c for _, c in items] plt.figure(figsize=(9, 9)) plt.pie(vals, labels=labels, autopct="%.1f%%", startangle=90, textprops={"fontsize": 10}, labeldistance=1.06, pctdistance=0.62, wedgeprops={"edgecolor": "white", "linewidth": 1}) plt.title(f"{tag} · Q5\n{qtext['q5_title']}", fontsize=12) plt.tight_layout() fn = _safe_filename(f"{id_cn}_{fname_tag['q5']}") + ".png" plt.savefig(os.path.join(OUTPUT_DIR, fn), dpi=150, bbox_inches="tight") plt.close() print(f" ✔ 已生成 {fn}") print(f"\n✅ 全部图表已经输出到文件夹:{OUTPUT_DIR}/") def main(): parser = argparse.ArgumentParser( description="青芯科技智慧校园问卷 Base64解析 + matplotlib 中文统计绘图", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" 示例: python survey_stats.py eyJ2ZXJzaW9uIjoiMS4wIi... python survey_stats.py --file code_list.txt --stats python survey_stats.py --file code_list.txt --stats --font ./fonts/思源黑体.otf python survey_stats.py --interactive """, ) parser.add_argument("code", nargs="?", help="单条Base64作答代码") parser.add_argument("--file", "-f", help="读取文件;--stats模式下每行一个base64答卷") parser.add_argument("--output", "-o", help="解析文本输出到文件") parser.add_argument("--json", action="store_true", help="输出原始JSON") parser.add_argument("--interactive", "-i", action="store_true", help="交互输入单份答卷") parser.add_argument("--stats", action="store_true", help="批量统计并生成matplotlib图表") parser.add_argument("--font", help="自定义中文字体 .otf/.ttf 文件路径") args = parser.parse_args() # 统一设置中文字体 setup_chinese_font(args.font) if args.stats: if not args.file: print("使用--stats必须配合--file,文件每行一个base64答卷", file=sys.stderr) sys.exit(1) try: with open(args.file, "r", encoding="utf-8") as f: lines = f.readlines() except Exception as e: print(f"读取文件失败 {e}", file=sys.stderr) sys.exit(1) stat = SurveyStat() ok_cnt = 0 fail_cnt = 0 for line in lines: c = line.strip() if not c: continue try: d = decode_base64(c) stat.feed_one(d) ok_cnt += 1 except Exception as e: print(f"⚠️ 该行解码跳过:{e}") fail_cnt += 1 print(f"读取完成:成功 {ok_cnt} 份答卷,失败 {fail_cnt} 份") stat.plot_all() return # ========== 原有单份解析逻辑 ========== code = None if args.interactive: print("请粘贴 Base64 作答代码(输入空行结束):") buf = [] while True: try: ln = input() if not ln.strip(): break buf.append(ln.strip()) except EOFError: break code = "".join(buf) elif args.file: with open(args.file, "r", encoding="utf-8") as f: code = f.read().strip() elif args.code: code = args.code else: if not sys.stdin.isatty(): code = sys.stdin.read().strip() else: parser.print_help() print("\n提示:提供base64代码,或--interactive;批量统计请加--stats", file=sys.stderr) sys.exit(1) if not code: print("错误:未获取Base64代码", file=sys.stderr) sys.exit(1) try: data = decode_base64(code) except ValueError as e: print(str(e), file=sys.stderr) sys.exit(1) if args.json: out_text = json.dumps(data, ensure_ascii=False, indent=2) else: out_text = format_answer(data) if args.output: with open(args.output, "w", encoding="utf-8") as f: f.write(out_text) print(f"结果保存到 {args.output}") else: print(out_text) if __name__ == "__main__": main()