import hashlib import mimetypes import re from pathlib import Path, PurePosixPath from zipfile import ZipFile from xml.etree import ElementTree from .config import get_settings CATEGORIES = ("stage1", "stage2", "stage3", "final_report", "presentation") SUPPORTED = {".pdf", ".docx", ".pptx"} NAMES = {"stage1": "阶段报告1", "stage2": "阶段报告2", "stage3": "阶段报告3", "final_report": "最终报告", "presentation": "汇报PPT"} def secure_path(relative: str, *, root: Path | None = None, must_exist=True) -> Path: root = (root or get_settings().inbox_path).resolve() if not relative or "\\" in relative or "\x00" in relative: raise ValueError("必须使用挂载目录内的相对路径") path = PurePosixPath(relative) if path.is_absolute() or any(part in ("..", "") for part in path.parts): raise ValueError("路径超出挂载目录") target = root.joinpath(*path.parts) if any(p.is_symlink() for p in (target, *target.parents) if p != root and root in p.parents): raise ValueError("不允许符号链接") resolved = target.resolve(strict=must_exist) if not resolved.is_relative_to(root): raise ValueError("路径超出挂载目录") return resolved def sha256(path: Path) -> str: digest = hashlib.sha256() with path.open("rb") as stream: for chunk in iter(lambda: stream.read(1024 * 1024), b""): digest.update(chunk) return digest.hexdigest() def parse_student_folder(name: str): parts = name.split("_", 2) if len(parts) != 3 or not re.fullmatch(r"\d{5,20}", parts[0]) or not all(parts[1:]): return {"student_no": "", "name": "", "topic": "", "status": "needs_confirmation"} return {"student_no": parts[0], "name": parts[1].strip(), "topic": parts[2].strip(), "status": "recognized"} def extract_identity(text: str): patterns = { "student_no": r"学号\s*[::]?\s*(\d{5,20})", "name": r"姓名\s*[::]?\s*([^\s::]{2,10})", "topic": r"(?:课题名称|项目名称|题目)\s*[::]?\s*([^\n\r]{3,100})", } return {key: (match.group(1).strip() if (match := re.search(pattern, text)) else "") for key, pattern in patterns.items()} def first_text(path: Path) -> str: try: if path.suffix.lower() == ".pdf": import pymupdf as fitz with fitz.open(path) as doc: return doc[0].get_text()[:6000] if len(doc) else "" if path.suffix.lower() in {".docx", ".pptx"}: part = "word/document.xml" if path.suffix.lower() == ".docx" else "ppt/slides/slide1.xml" with ZipFile(path) as archive: root = ElementTree.fromstring(archive.read(part)) return " ".join(element.text or "" for element in root.iter() if element.tag.endswith("}t"))[:6000] except Exception as exc: raise ValueError(f"无法读取文件首页: {exc}") from exc return "" def classify(path: Path, text: str = ""): suffix = path.suffix.lower() if suffix not in SUPPORTED: return "unknown", "unsupported" def identify(value): value = value.lower().replace(" ", "") if any(term in value for term in ("汇报ppt", "汇报幻灯", "答辩ppt", "演示文稿", "presentation")): return "presentation" for digit, chinese, category in (("1", "一", "stage1"), ("2", "二", "stage2"), ("3", "三", "stage3")): if any(term in value for term in (f"阶段报告{digit}", f"阶段报告{chinese}", f"第{chinese}阶段", f"阶段{digit}报告")): return category if any(term in value for term in ("最终报告", "期末报告", "综合实践报告", "结题报告")): return "final_report" return None if suffix == ".pptx": return "presentation", "recognized" category = identify(path.stem) or identify(text[:600]) if category: return category, "needs_confirmation" if category == "presentation" and suffix == ".pdf" else "recognized" return "unknown", "needs_confirmation" def directories(relative: str = ".", *, root: Path | None = None): path = secure_path(relative, root=root) if not path.is_dir(): raise ValueError("请选择目录") base = (root or get_settings().inbox_path).resolve() return [{"name": item.name, "relative_path": item.relative_to(base).as_posix()} for item in sorted(path.iterdir()) if item.is_dir() and not item.is_symlink()] def scan(relative: str, *, root: Path | None = None): base = (root or get_settings().inbox_path).resolve() path = secure_path(relative, root=base) if not path.is_dir(): raise ValueError("请选择学生总目录") projects = [] for folder in sorted(path.iterdir()): if not folder.is_dir() or folder.is_symlink(): continue info = parse_student_folder(folder.name) files = [] extracted = {} warnings = [] for candidate in sorted(folder.iterdir()): if not candidate.is_file() or candidate.is_symlink(): continue if candidate.suffix.lower() not in SUPPORTED: continue error = "" try: text = first_text(candidate) category, status = classify(candidate, text) identity = extract_identity(text) for key, value in identity.items(): if value and key not in extracted: extracted[key] = value except ValueError as exc: category, status, error = "unknown", "unparseable", str(exc) stat = candidate.stat() files.append({"relative_path": candidate.relative_to(base).as_posix(), "category": category, "status": status, "size": stat.st_size, "mtime": stat.st_mtime, "sha256": sha256(candidate), "mime_type": mimetypes.guess_type(candidate.name)[0] or "application/octet-stream", "error": error, "first_text": text[:500] if not error else ""}) for category in CATEGORIES: matches = [file for file in files if file["category"] == category] if len(matches) > 1: for file in matches: file["status"] = "duplicate" for key, value in extracted.items(): if info.get(key) and value != info[key]: warnings.append(f"封面{key}“{value}”与目录名“{info[key]}”不一致") if warnings: info["status"] = "needs_confirmation" info.update({"relative_dir": folder.relative_to(base).as_posix(), "files": files, "detected_identity": extracted, "warnings": warnings, "missing": [category for category in CATEGORIES if not any(file["category"] == category for file in files)]}) projects.append(info) return {"relative_dir": relative, "projects": projects}