兼容单科和总分
This commit is contained in:
@@ -1,10 +1,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import re
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
|
||||
from openpyxl import load_workbook
|
||||
from openpyxl.utils import column_index_from_string
|
||||
|
||||
|
||||
META_ALIASES = {
|
||||
@@ -18,9 +20,12 @@ META_ALIASES = {
|
||||
}
|
||||
|
||||
EXAM_HINT = re.compile(
|
||||
r"(总分|期末|联考|月考|模考|开学|学考|诊断|调研|高考)",
|
||||
r"(总分|期中|期末|联考|月考|模考|开学|学考|诊断|调研|高考|分班合成)",
|
||||
re.I,
|
||||
)
|
||||
SUBJECT_SUFFIX = re.compile(
|
||||
r"-(语文|数学|英语|外语|物理|化学|生物|历史|地理|政治|思想政治)分$"
|
||||
)
|
||||
SKIP_SHEETS = ("说明", "readme", "目录")
|
||||
|
||||
|
||||
@@ -32,6 +37,8 @@ def _is_skip_sheet(name: str) -> bool:
|
||||
|
||||
ART_RE = re.compile(r"艺术|体育|艺体")
|
||||
LEAVE_RE = re.compile(r"请假|出国|休学|国际|不在校|港澳|港籍.*假")
|
||||
CELL_REF_RE = re.compile(r"\$?([A-Za-z]+)\$?(\d+)")
|
||||
MISSING_TOKENS = {"", "缺考", "—", "-", "/", "none", "nan"}
|
||||
|
||||
|
||||
def _norm(v) -> str:
|
||||
@@ -42,13 +49,97 @@ def _norm(v) -> str:
|
||||
return str(v).strip()
|
||||
|
||||
|
||||
def _safe_arith(expr: str) -> float:
|
||||
tree = ast.parse(expr, mode="eval")
|
||||
allowed = (
|
||||
ast.Expression,
|
||||
ast.BinOp,
|
||||
ast.UnaryOp,
|
||||
ast.Add,
|
||||
ast.Sub,
|
||||
ast.Mult,
|
||||
ast.Div,
|
||||
ast.USub,
|
||||
ast.UAdd,
|
||||
ast.Constant,
|
||||
ast.Load,
|
||||
ast.Mod,
|
||||
)
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, allowed):
|
||||
raise ValueError("unsupported formula")
|
||||
return float(eval(compile(tree, "<score-formula>", "eval"), {"__builtins__": {}}, {}))
|
||||
|
||||
|
||||
def _eval_simple_formula(formula: str, raw, row_number: int) -> float | None:
|
||||
if not isinstance(formula, str) or not formula.startswith("="):
|
||||
return None
|
||||
expr = formula[1:].strip()
|
||||
if re.search(r"[^0-9A-Za-z+\-*/().$,\s]", expr):
|
||||
return None
|
||||
|
||||
def repl(match: re.Match) -> str:
|
||||
col = column_index_from_string(match.group(1).upper()) - 1
|
||||
src_row = int(match.group(2))
|
||||
if src_row != row_number or col < 0 or col >= len(raw):
|
||||
raise ValueError("formula refers outside the current row")
|
||||
val = raw[col]
|
||||
if isinstance(val, str) and val.startswith("="):
|
||||
raise ValueError("nested formula")
|
||||
if val is None or str(val).strip().lower() in MISSING_TOKENS:
|
||||
raise ValueError("missing ref")
|
||||
return str(float(val))
|
||||
|
||||
try:
|
||||
filled = CELL_REF_RE.sub(repl, expr)
|
||||
return _safe_arith(filled)
|
||||
except (TypeError, ValueError, SyntaxError, ZeroDivisionError):
|
||||
return None
|
||||
|
||||
|
||||
def _to_score(val, raw, row_number: int):
|
||||
if val is None:
|
||||
return None
|
||||
if isinstance(val, str):
|
||||
text = val.strip()
|
||||
if text.lower() in MISSING_TOKENS:
|
||||
return None
|
||||
if text.startswith("="):
|
||||
return _eval_simple_formula(text, raw, row_number)
|
||||
val = text
|
||||
try:
|
||||
return float(val)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _is_exam_header(text: str) -> bool:
|
||||
t = text.strip()
|
||||
if not t or t in {"考试次数"}:
|
||||
if not t or t in {"考试次数"} or "次数" in t:
|
||||
return False
|
||||
if t.endswith("-总分") or t.endswith("总分"):
|
||||
# 总分、数学分、语文分、分班合成-数学分 等都视为成绩列。
|
||||
if t.endswith("分"):
|
||||
return True
|
||||
return bool(EXAM_HINT.search(t)) and "次数" not in t
|
||||
return bool(EXAM_HINT.search(t))
|
||||
|
||||
|
||||
def infer_score_kind(exam_names: list[str], source_name: str = "") -> str:
|
||||
found: list[str] = []
|
||||
for name in exam_names:
|
||||
m = SUBJECT_SUFFIX.search(str(name).strip())
|
||||
if m:
|
||||
found.append(m.group(1))
|
||||
elif str(name).endswith("总分"):
|
||||
found.append("总分")
|
||||
uniq = list(dict.fromkeys(found))
|
||||
if len(uniq) == 1:
|
||||
return uniq[0]
|
||||
if uniq and "总分" not in uniq:
|
||||
return uniq[0]
|
||||
for subj in ("语文", "数学", "英语", "外语", "化学", "生物", "地理", "政治"):
|
||||
if subj in source_name:
|
||||
return subj
|
||||
return "总分"
|
||||
|
||||
|
||||
def _map_meta(header: str) -> str | None:
|
||||
@@ -76,11 +167,12 @@ def _sheet_kind(name: str) -> str:
|
||||
|
||||
|
||||
def parse_workbook(path: Path | BytesIO) -> dict:
|
||||
wb = load_workbook(path, data_only=True, read_only=True)
|
||||
wb = load_workbook(path, data_only=False, read_only=True)
|
||||
sheets = []
|
||||
students: list[dict] = []
|
||||
exam_order: list[str] = []
|
||||
warnings: list[str] = []
|
||||
source_name = path.name if isinstance(path, Path) else ""
|
||||
|
||||
for ws in wb.worksheets:
|
||||
kind = _sheet_kind(ws.title)
|
||||
@@ -112,7 +204,7 @@ def parse_workbook(path: Path | BytesIO) -> dict:
|
||||
continue
|
||||
|
||||
count = 0
|
||||
for raw in rows:
|
||||
for row_number, raw in enumerate(rows, start=2):
|
||||
if raw is None or all(v is None or str(v).strip() == "" for v in raw):
|
||||
continue
|
||||
rec = {
|
||||
@@ -143,14 +235,7 @@ def parse_workbook(path: Path | BytesIO) -> dict:
|
||||
for exam, idx in exam_idx.items():
|
||||
if idx >= len(raw):
|
||||
continue
|
||||
val = raw[idx]
|
||||
if val is None or str(val).strip() in ("", "缺考", "—", "-", "/"):
|
||||
rec["scores"][exam] = None
|
||||
continue
|
||||
try:
|
||||
rec["scores"][exam] = float(val)
|
||||
except (TypeError, ValueError):
|
||||
rec["scores"][exam] = None
|
||||
rec["scores"][exam] = _to_score(raw[idx], raw, row_number)
|
||||
rec["art"] = bool(ART_RE.search(rec["move"]) or ART_RE.search(ws.title))
|
||||
rec["leave"] = bool(LEAVE_RE.search(rec["move"]))
|
||||
students.append(rec)
|
||||
@@ -195,12 +280,19 @@ def parse_workbook(path: Path | BytesIO) -> dict:
|
||||
warnings.append(f"按学号去重 {dups} 条")
|
||||
|
||||
if not exam_order:
|
||||
warnings.append("未识别到考试成绩列,请确认表头含「总分」或「期末/联考/月考」")
|
||||
warnings.append("未识别到考试成绩列,请确认表头含「总分」或单科分(如「高一下期末-数学分」)")
|
||||
else:
|
||||
for exam in exam_order:
|
||||
present = sum(1 for s in students if s["scores"].get(exam) is not None)
|
||||
if present == 0:
|
||||
warnings.append(f"考试列「{exam}」没有读到数值,若是未计算的公式请先在 Excel 中打开保存后再上传")
|
||||
|
||||
score_kind = infer_score_kind(exam_order, source_name)
|
||||
return {
|
||||
"sheets": sheets,
|
||||
"students": students,
|
||||
"exams": exam_order,
|
||||
"score_kind": score_kind,
|
||||
"warnings": warnings,
|
||||
}
|
||||
|
||||
@@ -253,6 +345,7 @@ def summarize_parse(parsed: dict) -> dict:
|
||||
"score_scale": score_scale,
|
||||
"sheets": parsed["sheets"],
|
||||
"exams": exams,
|
||||
"score_kind": parsed.get("score_kind") or infer_score_kind(exams),
|
||||
"warnings": parsed["warnings"],
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user