前言在上一篇文章中我介绍了自己开发并上传到 AtomGit 的开源项目Work Inbox Agent。这个项目的主要用途是读取一个文件夹中的工作记录并将分散在 Word、Excel、Markdown 和 TXT 文件中的内容整理成统一数据供后续生成工作周报。项目地址Work-Inbox-Agent:基于 Python 与 DeepSeek 的工作周报自动生成项目 - AtomGit在实现过程中我首先需要解决一个问题Word、Excel 和 Markdown 的数据结构完全不同后续的周报生成模块应该怎样统一处理它们如果让周报模块分别判断.docx、.xlsx、.md代码会出现大量条件分支。以后增加新的文件格式也需要不断修改业务逻辑。因此我在项目中设计了一套统一的文件读取机制。一、不同文件格式有什么区别不同类型的文件内容组织方式并不相同。Markdown 和 TXT 基本属于纯文本可以直接读取全部内容。Word 文件除了普通段落还可能包含表格。Excel 文件则可能包含多个工作表每个工作表又有表头、数据行以及行列信息。例如一个文件夹中可能同时存在work-records/ ├── weekly-note.md ├── meeting.docx ├── tasks.xlsx └── readme.txt如果直接把这些格式的处理逻辑写进周报生成模块代码很快就会变得复杂。我的处理思路是扫描文件夹 ↓ 根据扩展名选择读取器 ↓ 解析不同格式 ↓ 转换成统一的 ExtractedDocument ↓ 交给后续周报生成流程这样周报生成模块不需要关心原始文件是 Word 还是 Excel只需要处理统一的数据结构。二、定义统一的文件读取接口项目中的所有读取器都继承自FileReaderfrom abc import ABC, abstractmethod from pathlib import Path from work_inbox.models import ExtractedDocument class FileReader(ABC): extensions: frozenset[str] frozenset() def supports(self, path: Path) - bool: return path.suffix.lower() in self.extensions abstractmethod def read(self, path: Path) - ExtractedDocument: raise NotImplementedError这个抽象类主要规定了两个能力supports()判断当前读取器是否支持该文件。read()读取文件并返回统一的解析结果。每个具体读取器只需要声明自己支持的扩展名并实现对应的读取逻辑。例如文本读取器支持.md和.txtclass TextReader(FileReader): extensions frozenset({.md, .txt})Word 读取器支持.docxclass DocxReader(FileReader): extensions frozenset({.docx})Excel 读取器支持.xlsxclass XlsxReader(FileReader): extensions frozenset({.xlsx})使用这种方式以后如果需要支持 CSV只要新增一个CsvReader不需要重写现有的读取逻辑。三、用统一数据结构保存解析结果不同文件最终都会被转换成ExtractedDocumentfrom dataclasses import asdict, dataclass, field from typing import Any dataclass class ExtractedDocument: file_name: str file_type: str source_path: str content: str sections: list[dict[str, str]] field(default_factorylist) tables: list[dict[str, Any]] field(default_factorylist) metadata: dict[str, Any] field(default_factorydict) error: str | None None def to_dict(self) - dict[str, Any]: return asdict(self)其中content保存纯文本内容。sections保存 Word 段落等分段内容。tables保存 Word 表格和 Excel 工作表。metadata保存字符数、工作表数量等附加信息。error保存读取失败或格式不支持的信息。有了这层统一结构后续模块只需要处理ExtractedDocument不用重复判断文件类型。四、读取 Markdown 和 TXT文本文件的处理相对简单可以直接使用Path.read_text()class TextReader(FileReader): extensions frozenset({.md, .txt}) def read(self, path: Path) - ExtractedDocument: content path.read_text( encodingutf-8-sig, errorsreplace, ) return ExtractedDocument( file_namepath.name, file_typepath.suffix.lower().lstrip(.), source_pathstr(path), contentcontent, metadata{character_count: len(content)}, )这里使用utf-8-sig可以兼容部分带 BOM 的 UTF-8 文本。errorsreplace表示遇到无法正常解码的字符时使用替代字符而不是直接让整个扫描任务中断。读取完成后还会在metadata中记录字符数量。五、读取 Word 中的段落和表格Word 文件使用python-docx解析。Word 中的普通段落会保存到sections表格则保存到tablesclass DocxReader(FileReader): extensions frozenset({.docx}) def read(self, path: Path) - ExtractedDocument: document Document(path) sections [ { title: paragraph, content: paragraph.text.strip(), } for paragraph in document.paragraphs if paragraph.text.strip() ] tables [] for index, table in enumerate(document.tables, start1): rows [ [cell.text.strip() for cell in row.cells] for row in table.rows ] tables.append({ name: ftable_{index}, rows: rows, }) return ExtractedDocument( file_namepath.name, file_typedocx, source_pathstr(path), sectionssections, tablestables, metadata{ paragraph_count: len(sections), table_count: len(tables), }, )这里没有把 Word 文件简单地转换成一整段文字而是保留了段落和表格的区别。这样做的原因是工作记录中的表格往往包含任务名称、负责人和完成状态。如果把表格直接拼成普通文本后续处理时可能丢失原有结构。当前版本主要提取普通段落和基础表格还没有处理图片、文本框以及复杂样式。六、读取 Excel 的工作表和数据行Excel 文件使用openpyxl读取class XlsxReader(FileReader): extensions frozenset({.xlsx}) preview_rows 50读取工作簿时启用了只读模式workbook load_workbook( path, read_onlyTrue, data_onlyFalse, )read_onlyTrue可以减少读取大型工作簿时的内存占用。项目会遍历工作簿中的所有工作表并读取前 50 行作为预览数据for sheet in workbook.worksheets: rows [] for row in sheet.iter_rows( min_row1, max_rowself.preview_rows, values_onlyTrue, ): rows.append([ self._json_value(value) for value in row ]) headers rows[0] if rows else [] tables.append({ name: sheet.title, columns: headers, rows: rows[1:] if rows else [], row_count_preview: len(rows), max_row: sheet.max_row, max_column: sheet.max_column, })第一行被当作表头后面的内容作为数据行。同时解析结果会记录工作表名称工作表数量最大行数最大列数前 50 行预览数据。这里需要注意50 行包含表头。如果表格超过限制程序仍然会记录工作表的总行数和总列数但不会把所有单元格都放进解析结果。另外当前代码设置了data_onlyFalse因此遇到公式单元格时读取到的是公式本身而不是 Excel 上一次计算后缓存的结果。这是当前版本明确保留的行为。七、通过注册表自动选择读取器不同读取器统一放在READERS中READERS: tuple[FileReader, ...] ( TextReader(), DocxReader(), XlsxReader(), )读取文件时程序会依次判断哪个读取器支持当前文件def extract_file(path: Path) - ExtractedDocument: for reader in READERS: if reader.supports(path): try: return reader.read(path) except Exception as exc: return ExtractedDocument( file_namepath.name, file_typepath.suffix.lower().lstrip(.) or unknown, source_pathstr(path), errorf读取失败: {exc}, ) return ExtractedDocument.unsupported( path, f暂不支持文件格式: {path.suffix or none}, )这里有一个比较重要的设计单个文件读取失败不会导致整个文件夹扫描失败。假设文件夹中有 20 个文件其中一个 Word 文件已经损坏程序会把错误记录到该文件对应的error字段中然后继续处理其他文件。不支持的文件也不会被悄悄丢弃而是会保留一条带有错误说明的解析结果。这样可以让使用者知道哪些文件没有被处理。八、递归扫描整个文件夹文件夹扫描使用rglob(*)实现def scan_folder(folder: Path) - list[ExtractedDocument]: if not folder.exists(): raise FileNotFoundError(f目录不存在: {folder}) if not folder.is_dir(): raise NotADirectoryError(f不是目录: {folder}) documents [] for path in sorted(folder.rglob(*)): if ( path.is_file() and not any(part.startswith(.) for part in path.parts) ): documents.append(extract_file(path)) return documents这段代码会检查目标路径是否存在检查目标路径是否为文件夹递归查找文件夹及其子目录中的文件跳过隐藏文件和隐藏目录将每个文件交给对应读取器处理。最终得到一个ExtractedDocument列表供后续工作流继续使用。九、为不同文件格式编写测试为了确认读取结果符合预期我使用pytest编写了对应测试。例如Word 测试会临时创建一个包含段落和表格的文档def test_docx_reader_extracts_paragraphs_and_tables(tmp_path): source tmp_path / meeting.docx document Document() document.add_paragraph(客户项目本周已完成联调) table document.add_table(rows2, cols2) table.cell(0, 0).text 任务 table.cell(0, 1).text 状态 table.cell(1, 0).text 接口联调 table.cell(1, 1).text 完成 document.save(source) extracted extract_file(source) assert extracted.file_type docx assert extracted.sections[0][content] \ 客户项目本周已完成联调 assert extracted.tables[0][rows][1] \ [接口联调, 完成]Excel 测试则检查工作表名称、表头和数据行def test_xlsx_reader_extracts_sheet_and_rows(tmp_path): source tmp_path / tasks.xlsx workbook Workbook() sheet workbook.active sheet.title 本周任务 sheet.append([任务, 状态]) sheet.append([完成登录模块, 已完成]) workbook.save(source) extracted extract_file(source) assert extracted.file_type xlsx assert extracted.metadata[sheet_names] [本周任务] assert extracted.tables[0][columns] [任务, 状态] assert extracted.tables[0][rows][0] \ [完成登录模块, 已完成]除此之外项目还测试了纯文本读取、不支持格式的保留以及周报工作流。与直接使用真实办公文件相比测试时动态创建文件的好处是测试数据固定、可以重复执行也不会把个人工作资料上传到代码仓库。十、这种设计带来了什么通过“统一接口、独立读取器、标准化结果”的设计文件解析模块和周报生成模块实现了解耦。它带来的主要好处包括不同文件格式的解析逻辑彼此独立增加新格式时不需要修改周报生成逻辑单个文件损坏不会阻塞整个扫描任务不支持的文件会留下明确记录解析结果可以统一转换成 JSON每种文件格式都可以单独编写测试。当然目前的实现仍然是第一版。例如 Word 图片、旧版.doc、旧版.xls和复杂 Excel 公式结果暂时没有覆盖。后续可以继续增加 CSV、PDF 等读取器也可以把固定的读取器元组改成可配置或自动注册的方式。总结Work Inbox Agent 的多格式解析过程可以概括为文件夹扫描 → 根据扩展名匹配读取器 → 提取文本、段落或表格 → 转换为 ExtractedDocument → 交给周报生成流程这套设计并不只适用于周报工具。只要项目需要批量处理不同格式的办公文件都可以采用类似思路先把文件格式差异隔离在读取层再让业务层面向统一的数据结构工作。项目源码Work-Inbox-Agent:基于 Python 与 DeepSeek 的工作周报自动生成项目 - AtomGit本文对应自己的开源项目实现后续我还会继续分享异常隔离、周报生成流程以及模型输出校验方面的设计。