跳到主要内容

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_filestart 调了 .strip()(core.py:65 前的 _parse_llmsstart.strip()),对开头空白有一定容忍。

6. 代码地图

主题文件符号
顶层解析入口(返回 AttrDict)llms_txt/core.py:57parse_llms_file
横切:开头 vs 各分区llms_txt/core.py:50_parse_llms
抠单条链接llms_txt/core.py:36parse_link
分区体拆成多条链接llms_txt/core.py:46_parse_links
正则小工具llms_txt/core.py:22-33opt_re / named_re / search
20 行无依赖版llms_txt/miniparse.py:8parse_llms_txt
n 个一组llms_txt/miniparse.py:4chunked
单测(测 miniparse)tests/test-parse.pyTestParseLlmsFileShort