python 以字符数量分割文档 (一)

第一版代码

#!/usr/bin/env python3

import sys
from pathlib import Path


INPUT = sys.argv[1] if len(sys.argv) > 1 else "book.md"
OUTPUT = Path("split")


MAX_CHARS = 90000   # 每个文件约9万字符


OUTPUT.mkdir(exist_ok=True)


lines = Path(INPUT).read_text(
    encoding="utf-8"
).splitlines(keepends=True)


# 一级标题切分点:行首为 "# " 且不在围栏代码块内。
# (不能盲切:代码块里的注释行(如 "# 注释")也以 "# " 开头,
#   盲切会把代码块从中间断开,导致围栏不配对、注释行被渲染成标题。)
boundaries = []
in_fence = False
for i, line in enumerate(lines):
    if line.lstrip().startswith("```"):
        in_fence = not in_fence
    if not in_fence and line.startswith("# "):
        boundaries.append(i)


# 按切分点切成若干节;跳过文件开头的第一个一级标题(它属于首节)
sections = []
start = 0
for b in boundaries[1:]:
    sections.append("".join(lines[start:b]))
    start = b
sections.append("".join(lines[start:]))


files = []
current = ""
for section in sections:
    if not current:
        current = section
        continue
    candidate = current + section
    if len(candidate) > MAX_CHARS:
        files.append(current)
        current = section
    else:
        current = candidate


if current:
    files.append(current)


for i, content in enumerate(files, 1):
    filename = OUTPUT / f"{i:03d}.md"
    filename.write_text(content, encoding="utf-8")
    print(filename, len(content))

第一版 解读

这段 Python 脚本相比前面的简单版,已经接近一个生产可用的 Markdown 分割器。它主要解决了两个问题:

  1. 不要破坏 Markdown 一级标题结构
  2. 不要误把代码块里的 # 注释 当成标题

下面逐段分析。


1. 文件头与导入

#!/usr/bin/env python3

Shebang。

Linux 下允许直接执行:

./split_md.py book.md

系统会调用:

/usr/bin/env python3

import sys
from pathlib import Path

导入:

sys

用于读取命令行参数:

sys.argv

例如:

python split_md.py linux.md

那么:

sys.argv
=
[
"split_md.py",
"linux.md"
]

pathlib.Path

现代 Python 文件操作库。

替代:

os.path
open()

例如:

传统:

open("book.md")

Path:

Path("book.md").read_text()

2. 输入输出参数

INPUT = sys.argv[1] if len(sys.argv) > 1 else "book.md"

这是 Python 条件表达式:

语法:

A if 条件 else B

等价:

if len(sys.argv)>1:
    INPUT=sys.argv[1]
else:
    INPUT="book.md"

作用:

支持:

python split.py mybook.md

读取:

mybook.md

如果:

python split.py

默认:

book.md

OUTPUT = Path("split")

输出目录:

split/

MAX_CHARS = 90000

每个文件最大字符数。

注意:

这里:

len(content)

统计的是:

Unicode 字符数量。

中文:

一个汉字 = 1

不是 token。


3. 创建输出目录

OUTPUT.mkdir(exist_ok=True)

创建:

split/

等价:

mkdir -p split

exist_ok=True

表示:

目录存在:

不报错。

例如:

第一次:

split/

创建。

第二次:

继续运行:

不会:

FileExistsError

4. 读取 Markdown

lines = Path(INPUT).read_text(
    encoding="utf-8"
).splitlines(keepends=True)

拆解:


read_text

读取:

book.md

返回:

str

encoding

encoding="utf-8"

明确:

中文不会乱码。


splitlines

例如:

原文:

# 第一章

hello

# 第二章

执行:

splitlines()

结果:

[
"# 第一章",
"",
"hello",
"# 第二章"
]

但是这里:

keepends=True

保留换行:

结果:

[
"# 第一章\n",
"\n",
"hello\n",
"# 第二章"
]

为什么?

因为后面:

"".join(lines)

需要恢复原始 Markdown。


5. 查找一级标题

核心部分:

boundaries = []
in_fence = False

两个变量:


boundaries

保存切割位置:

例如:

文件:

0 # 第一章
1 内容
2 内容
3 # 第二章
4 内容

结果:

boundaries=[
0,
3
]

in_fence

表示:

当前是否在代码围栏中。

Markdown代码块:

```python

print("hello")



进入:

```python
in_fence=True

退出:

in_fence=False

6. 遍历每一行

for i, line in enumerate(lines):

例如:

lines=[
"a",
"b",
"c"
]

得到:

第一次:

i=0
line="a"

第二次:

i=1
line="b"

7. 检测代码块

if line.lstrip().startswith("```"):
    in_fence = not in_fence

这里有两个技巧。


lstrip()

去除左侧空格:

例如:

    ```python

变成:

```python

避免:

    ```python

检测不到。


startswith

判断开头:

例如:

"```python".startswith("```")

结果:

True

not切换状态

第一次遇到:

```python

原:

False

执行:

not False

变:

True

第二次:


变:

```python
not True

结果:

False

这就是状态机:

普通文本
  |
  | ```
  v
代码块
  |
  | ```
  v
普通文本

8. 找一级标题

if not in_fence and line.startswith("# "):
    boundaries.append(i)

条件:

必须同时:

条件1

not in_fence

不在代码块。

条件2

line.startswith("# ")

真正标题:

# Linux安装

不是:

## 子标题

也不是:

# 注释

例如:

输入:

# 第一章

代码:

```python
# hello
print(1)

第二章



检测:

得到:

```python
boundaries=[
0,
6
]

不会:

错误加入:

# hello

9. 根据标题切章节

sections = []
start = 0

准备保存:

章节块。


循环:

for b in boundaries[1:]:

为什么:

不是:

boundaries

而是:

boundaries[1:]

例如:

boundaries=[
0,
100,
200
]

第一个:

0

代表:

文件开头。

不应该切。

所以跳过。


切:

sections.append(
    "".join(lines[start:b])
)

例如:

第一次:

start=0
b=100

保存:

0~99行

然后:

start=b

变:

start=100

继续。


最后:

sections.append(
    "".join(lines[start:])
)

保存最后章节。


10. 合并章节到9万字符

初始化:

files=[]
current=""

逻辑:

章节
 |
尝试加入当前文件
 |
超过90000?
 |
是:
保存
重新开始

循环:

for section in sections:

第一次:

if not current:
    current=section
    continue

空文件:

直接放。


后续:

candidate=current+section

预测大小。


判断:

if len(candidate)>MAX_CHARS:

超过:

保存:

files.append(current)

开启:

current=section

否则:

继续累计:

current=candidate

11. 输出文件

for i, content in enumerate(files,1):

注意:

第二参数:

1

表示编号从1开始。

否则:

默认:

0

生成:

filename = OUTPUT / f"{i:03d}.md"

这是:

Path拼接。

例如:

OUTPUT="split"
i=1

结果:

split/001.md

格式:

{ i:03d }

表示:

整数,3位补零。

例如:

1

变:

001

写文件:

filename.write_text(
    content,
    encoding="utf-8"
)

输出:

print(filename,len(content))

例如:

split/001.md 87432
split/002.md 90121

12. 这个脚本的算法总结

整体算法:

读取 Markdown
       |
       v
逐行扫描
       |
       |
       +--检测 ``` 状态
       |
       +--寻找一级标题
       |
       v
按章节切块
       |
       v
章节累积
       |
       |
       +-- <90000字符
       |
       +-- >90000
              |
              保存文件
       |
       v
输出 md

13. 这个版本仍然存在的几个边界问题

虽然很好,但还有几个可以改进:

① 代码围栏检测不够严格

现在:

startswith("```")

也会匹配:

````markdown

四反引号。

更严谨:

re.match(r"^\s*(```+|~~~+)", line)

② 不处理 YAML Front Matter

很多 Markdown:

---
title: xxx
author: xxx
---

# 第一章

这里:

# 

没问题。

但如果 front matter 中有:

# comment

可能误判。


③ 超大单章节问题

例如:

# 第一章

20万字

当前:

不会切。

因为:

一个 section 本身超过:

90000

但是:

只在 section 边界切。

解决:

需要二级标题:

# 第一章
## 1.1
## 1.2

作为二级切割点。


14.如果进一步优化,我建议增加:

一级标题优先
      ↓
二级标题补刀
      ↓
段落边界补刀
      ↓
90000字符硬限制

这样可以达到类似 LangChain/TextSplitter 的效果,同时保留 Markdown 结构。你当前这个版本属于 章节感知型 Markdown splitter

posted @ 2026-08-07 05:15  立体风  阅读(3)  评论(0)    收藏  举报