llms.txt — 参考解析器
本章讲怎么把 llms.txt 文本读成结构化数据。核心就一句话:一次 split 分层 + 三条正则抠字段。源码在
llms_txt/core.py,另有一个 20 行无依赖版在llms_txt/miniparse.py。
1. 它要解决的小问题
输入是一坨 markdown 文本,要输出一个能这样访问的结构:
parsed.title # "FastHTML"
parsed.summary # "FastHTML is a python library..."
parsed.sections.Docs # [ {title, url, desc}, ... ]
难点不在"解析 markdown"(那会引入重型依赖),而在利用 llms.txt 的固定结构,用最轻的手段精确切分。规范特意把格式定得规整(见 01-spec.md),就是为了这里能用正则搞定。
2. 思路:先分层,再抠字段
整个解析分两级,对应文件的两级结构:
怎么读:上半是"横切"——把文件劈成开头和各分区;下半是"纵抠"——在每块里用正则拿字段。
原始文本
│
│ ① re.split('^##\\s*(.*?$)') 按 H2 横切
▼
start(开头) + rest = [名字1, 体1, 名字2, 体2, ...] ← 交替排列
│ │
│ ② 正则抠 │ ③ chunked(2) 配对 → {名字: 体}
│ title/summary/info │ 再对每个"体"抠出每条链接
▼ ▼
{title, summary, info, sections={名字:[{title,url,desc},...]}}
关键技巧藏在第 ① 步:re.split 的 pattern 带一个捕获组 (.*?$)。Python 的 re.split 有个特性——当 pattern 含捕获组时,被捕获的分隔符本身也会留在结果列表里。于是劈完得到的 rest 就是 [分区名, 分区体, 分区名, 分区体, ...] 这样交替的序列,正好能两两配对。
3. 逐段拆解真实实现
3.1 横切:一条 split 干两件事
真实实现 _parse_llms(llms_txt/core.py:50-54):
start,*rest = re.split(fr'^##\s*(.*?$)', txt, flags=re.MULTILINE)
d = dict(chunked(rest, 2))
start 拿到第一个 ## 之前的全部内容(即开头三段),rest 是交替的"名字/体"序列。chunked(rest, 2) 把它两两分组,dict(...) 变成 {分区名: 分区体}。
chunked 是个小工具(llms_txt/miniparse.py:4-6),用 itertools.islice + iter(callable, sentinel) 惰性地每次切 2 个,是标准的"n 个一组"惯用法。
3.2 纵抠之一:标题 / 摘要 / 说明
parse_llms_file(llms_txt/core.py:57-67)在 start 上跑这条正则拿开头三段:
pat = fr'^#\s*{title}\n+{summ_pat}\n+{info}'
d = search(pat, start, (re.MULTILINE|re.DOTALL))
展开后各命名组是(用 named_re/opt_re 拼出来的,core.py:22-33):
| 组 | 匹配 | 对应文件成分 |
|---|---|---|
title | .+?$(H1 那一行) | # 名字 |
summary | .+?$,整段用 opt_re 包成可选 | > 摘要 |
info | .*(配合 DOTALL 吃掉剩余全部) | 说明段 |
opt_re(s) 就是把子模式包成 (?:...)?(core.py:22),named_re(nm,pat) 包成命名捕获组 (?P<nm>...)(core.py:26)。摘要那段用 opt_re 包起来,所以没有 > 摘要行也能解析——测试 test_missing_optional_fields 正是验证这点(tests/test-parse.py:52)。
3.3 纵抠之二:每一条链接
分区体里的每条链接由 parse_link(llms_txt/core.py:36-43)抠:
pat = fr'-\s*\[{title}\]\({url}\){desc_pat}'
三个字段的匹配范围是精心选的:
| 字段 | 模式 | 为什么这样 |
|---|---|---|
title | [^\]]+ | 吃到右方括号 ] 前为止 |
url | [^\)]+ | 吃到右圆括号 ) 前为止 |
desc | .*,由 opt_re(':\s*...') 包成可选 | 冒号后的备注,可有可无 |
_parse_links(core.py:46-47)先用 re.split(r'\n+', ...) 把分区体按空行拆成一条条,过滤空行后逐条 parse_link。
3.4 收尾:变成能点属性的对象
parse_llms_file 最后一步 return dict2obj(d)(core.py:67)。dict2obj 是 fastcore 的工具,把普通 dict 递归转成 AttrDict——于是可以写 parsed.sections.Docs 而不必 parsed['sections']['Docs']。这是 core 版相对 mini 版唯一实质多出来的便利。
4. 20 行无依赖版:miniparse
仓库特意提供了一个"证明这事有多简单"的版本:parse_llms_txt(llms_txt/miniparse.py:8-20),只用标准库 re + itertools,不到 20 行,逻辑和 core 版一模一样,只是:
- 把三条正则内联写死,不拆
named_re/opt_re小工具; - 返回普通
dict,不做dict2obj(所以只能result['title']下标访问)。
它不是玩具:tests/test-parse.py 这套单测测的就是 miniparse(from llms_txt.miniparse import parse_llms_txt,tests/test-parse.py:2),覆盖了基础解析、多链接、缺摘要、无链接四种情况,全部通过。规范文档也把这段代码原样贴出,当作"解析 llms.txt 有多容易"的论据(nbs/00_intro.ipynb "Implementation and tests" 节)。
这是本项目一个值得借鉴的态度: 与其把"格式规范"写成大部头,不如给一个"20 行就能解析"的实现当活证明——格式的简单性由代码的简单性背书。
5. 边界与坑
- 链接行必须严格合规。
parse_link直接re.search(...).groupdict(),没有None兜底(core.py:43)。某条列表项若不含合法的[..](..),会抛AttributeError。解析器假定输入已合规,不做容错。 - 说明段不能出现标题。 因为横切完全靠
##,开头段里若混入 H2 会被误当分区起点。这正是规范"说明段不含 heading"那条约束的原因(01-spec.md§2)。 - BOM / 空白。
parse_llms_file对start调了.strip()(core.py:65前的_parse_llms里start.strip()),对开头空白有一定容忍。
6. 代码地图
| 主题 | 文件 | 符号 |
|---|---|---|
| 顶层解析入口(返回 AttrDict) | llms_txt/core.py:57 | parse_llms_file |
| 横切:开头 vs 各分区 | llms_txt/core.py:50 | _parse_llms |
| 抠单条链接 | llms_txt/core.py:36 | parse_link |
| 分区体拆成多条链接 | llms_txt/core.py:46 | _parse_links |
| 正则小工具 | llms_txt/core.py:22-33 | opt_re / named_re / search |
| 20 行无依赖版 | llms_txt/miniparse.py:8 | parse_llms_txt |
| n 个一组 | llms_txt/miniparse.py:4 | chunked |
| 单测(测 miniparse) | tests/test-parse.py | TestParseLlmsFileShort |