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 分割器。它主要解决了两个问题:
- 不要破坏 Markdown 一级标题结构
- 不要误把代码块里的
# 注释当成标题
下面逐段分析。
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。

浙公网安备 33010602011771号