Python统一读取Word、Excel和Markdown的方法
作者:请为小H留灯
前言
Word、Excel 和 Markdown 的数据结构完全不同,后续的周报生成模块应该怎样统一处理它们?
如果让周报模块分别判断 .docx、.xlsx、.md,代码会出现大量条件分支。以后增加新的文件格式,也需要不断修改业务逻辑。
一、不同文件格式有什么区别?
不同类型的文件,内容组织方式并不相同。
Markdown 和 TXT 基本属于纯文本,可以直接读取全部内容。
Word 文件除了普通段落,还可能包含表格。
Excel 文件则可能包含多个工作表,每个工作表又有表头、数据行以及行列信息。
例如,一个文件夹中可能同时存在:
work-records/ ├── weekly-note.md ├── meeting.docx ├── tasks.xlsx └── readme.txt
如果直接把这些格式的处理逻辑写进周报生成模块,代码很快就会变得复杂。
我的处理思路是:
扫描文件夹
↓
根据扩展名选择读取器
↓
解析不同格式
↓
转换成统一的 ExtractedDocument
↓
交给后续周报生成流程这样,周报生成模块不需要关心原始文件是 Word 还是 Excel,只需要处理统一的数据结构。
二、定义统一的文件读取接口
项目中的所有读取器都继承自 FileReader:
from 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 和 .txt:
class TextReader(FileReader):
extensions = frozenset({".md", ".txt"})Word 读取器支持 .docx:
class DocxReader(FileReader):
extensions = frozenset({".docx"})Excel 读取器支持 .xlsx:
class XlsxReader(FileReader):
extensions = frozenset({".xlsx"})使用这种方式,以后如果需要支持 CSV,只要新增一个 CsvReader,不需要重写现有的读取逻辑。
三、用统一数据结构保存解析结果
不同文件最终都会被转换成 ExtractedDocument:
from 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_factory=list)
tables: list[dict[str, Any]] = field(default_factory=list)
metadata: dict[str, Any] = field(default_factory=dict)
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(
encoding="utf-8-sig",
errors="replace",
)
return ExtractedDocument(
file_name=path.name,
file_type=path.suffix.lower().lstrip("."),
source_path=str(path),
content=content,
metadata={"character_count": len(content)},
)这里使用 utf-8-sig,可以兼容部分带 BOM 的 UTF-8 文本。
errors="replace" 表示遇到无法正常解码的字符时,使用替代字符,而不是直接让整个扫描任务中断。
读取完成后,还会在 metadata 中记录字符数量。
五、读取 Word 中的段落和表格
Word 文件使用 python-docx 解析。
Word 中的普通段落会保存到 sections,表格则保存到 tables:
class 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, start=1):
rows = [
[cell.text.strip() for cell in row.cells]
for row in table.rows
]
tables.append({
"name": f"table_{index}",
"rows": rows,
})
return ExtractedDocument(
file_name=path.name,
file_type="docx",
source_path=str(path),
sections=sections,
tables=tables,
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_only=True,
data_only=False,
)read_only=True 可以减少读取大型工作簿时的内存占用。
项目会遍历工作簿中的所有工作表,并读取前 50 行作为预览数据:
for sheet in workbook.worksheets:
rows = []
for row in sheet.iter_rows(
min_row=1,
max_row=self.preview_rows,
values_only=True,
):
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_only=False,因此遇到公式单元格时,读取到的是公式本身,而不是 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_name=path.name,
file_type=path.suffix.lower().lstrip(".")
or "unknown",
source_path=str(path),
error=f"读取失败: {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(rows=2, cols=2)
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 → 交给周报生成流程
这套设计并不只适用于周报工具。只要项目需要批量处理不同格式的办公文件,都可以采用类似思路:先把文件格式差异隔离在读取层,再让业务层面向统一的数据结构工作。
以上就是Python统一读取Word、Excel和Markdown的方法的详细内容,更多关于Python读取Word、Excel和Markdown的资料请关注脚本之家其它相关文章!
