python

关注公众号 jb51net

关闭
首页 > 脚本专栏 > python > Python读取Word、Excel和Markdown

Python统一读取Word、Excel和Markdown的方法

作者:请为小H留灯

还在为处理Word、Excel和Markdown混合文件发愁吗,本文教你通过统一接口和数据结构,优雅实现多格式文件解析,让周报生成模块不再关心文件类型,代码可扩展,新增格式无需改业务逻辑,还能容错处理损坏文件,快来学习这套设计思路吧,需要的朋友可以参考下

前言

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

这个抽象类主要规定了两个能力:

每个具体读取器只需要声明自己支持的扩展名,并实现对应的读取逻辑。

例如,文本读取器支持 .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)

其中:

有了这层统一结构,后续模块只需要处理 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 行包含表头。如果表格超过限制,程序仍然会记录工作表的总行数和总列数,但不会把所有单元格都放进解析结果。

另外,当前代码设置了 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

这段代码会:

  1. 检查目标路径是否存在;
  2. 检查目标路径是否为文件夹;
  3. 递归查找文件夹及其子目录中的文件;
  4. 跳过隐藏文件和隐藏目录;
  5. 将每个文件交给对应读取器处理。

最终得到一个 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] == \
        ["完成登录模块", "已完成"]

除此之外,项目还测试了纯文本读取、不支持格式的保留,以及周报工作流。

与直接使用真实办公文件相比,测试时动态创建文件的好处是:测试数据固定、可以重复执行,也不会把个人工作资料上传到代码仓库。

十、这种设计带来了什么?

通过“统一接口、独立读取器、标准化结果”的设计,文件解析模块和周报生成模块实现了解耦。

它带来的主要好处包括:

当然,目前的实现仍然是第一版。例如 Word 图片、旧版 .doc、旧版 .xls 和复杂 Excel 公式结果暂时没有覆盖。

后续可以继续增加 CSV、PDF 等读取器,也可以把固定的读取器元组改成可配置或自动注册的方式。

总结

Work Inbox Agent 的多格式解析过程可以概括为:

文件夹扫描
→ 根据扩展名匹配读取器
→ 提取文本、段落或表格
→ 转换为 ExtractedDocument
→ 交给周报生成流程

这套设计并不只适用于周报工具。只要项目需要批量处理不同格式的办公文件,都可以采用类似思路:先把文件格式差异隔离在读取层,再让业务层面向统一的数据结构工作。

以上就是Python统一读取Word、Excel和Markdown的方法的详细内容,更多关于Python读取Word、Excel和Markdown的资料请关注脚本之家其它相关文章!

您可能感兴趣的文章:
阅读全文