// robots.txt 解析与匹配。
//
// 覆盖范围(逐条对应 RFC 9309,详见 docs/rfc9309-覆盖声明.md):
// - §2.2.1 组选择:product token 大小写不敏感前缀匹配;多个组匹配同一
// UA 时**合并**为一组解析;无具体匹配时回退 `*` 组;都没有则无规则。
// - §2.2.2 匹配:从路径第一个字节起匹配,最具体(最多字节)的规则胜出;
// Allow 与 Disallow 等长时 Allow 优先;组内无规则则允许;/robots.txt
// 隐式允许。
// - §2.2.3 特殊字符:`#` 行内注释、`*` 任意序列、`$` 锚定结尾。
//
// 不在覆盖范围内(明确不承诺):
// - §2.2.2 的百分号解码比较:`%7E` 与 `~` 不视为同一路径。
// - §2.3.1.2 重定向链:跟随行为交给传输层,本模块不解析重定向。
// - §2.4 缓存时效、§2.5 500 KiB 解析上限:由调用方(引擎/用户)决定。
// 状态码语义(4xx 放行 / 5xx 拒绝)在 crawl.mbt 中实现,见 RobotsPolicy。
//
// 匹配约定与主流实现一致:
// - 规则匹配:`*` 匹配任意序列,结尾 `$` 锚定路径末尾;
// Allow/Disallow 同时命中时取模式更长的;等长时 Allow 优先。
// - Crawl-delay 不是 RFC 9309 内容,属广泛部署的扩展;合并组时取最大值。
///|
/// 解析后的 robots.txt 规则集。
pub struct RobotsRules {
priv groups : Array[AgentGroup]
priv sitemaps : Array[String]
}
///|
priv struct AgentGroup {
agents : Array[String] // 已小写
rules : Array[(Bool, String)] // (allow, pattern),保持文件顺序
mut crawl_delay_ms : Int32?
}
///|
/// RFC 3986 的 unreserved 字符集:ALPHA / DIGIT / "-" / "." / "_" / "~"。
fn is_unreserved(c : Char) -> Bool {
(c >= 'a' && c <= 'z') ||
(c >= 'A' && c <= 'Z') ||
(c >= '0' && c <= '9') ||
c == '-' ||
c == '.' ||
c == '_' ||
c == '~'
}
///|
fn hex_val(c : Char) -> Int32? {
if c >= '0' && c <= '9' {
return Some(c.to_int() - '0'.to_int())
}
if c >= 'a' && c <= 'f' {
return Some(c.to_int() - 'a'.to_int() + 10)
}
if c >= 'A' && c <= 'F' {
return Some(c.to_int() - 'A'.to_int() + 10)
}
None
}
///|
/// RFC 9309 §2.2.2:比较前把 `%XX` 形式的 **unreserved** 字符解码,
/// 保留字符(如 `%2F`)、非 ASCII 编码与非法转义原样保留。
///
/// 规则与请求两侧都要过这一层,`/a%7Eb` 与 `/a~b` 才能判为同一路径;
/// 而 `/a%2Fb` 与 `/a/b` 必须保持不同(`/` 是保留字符)。
fn decode_unreserved(s : String) -> String {
let cs = chars_of(s)
let n : Int32 = cs.length()
let buf = StringBuilder()
let mut i : Int32 = 0
while i < n {
if cs[i] == '%' && i + 2 < n {
let hi = hex_val(cs[i + 1])
let lo = hex_val(cs[i + 2])
match (hi, lo) {
(Some(h), Some(l)) => {
let v = h * 16 + l
// unreserved 全在 ASCII 范围,超出即保留原样
if v < 128 {
let c = v.to_char().unwrap()
if is_unreserved(c) {
buf.write_char(c)
i = i + 3
continue
}
}
buf.write_char('%')
i = i + 1
}
_ => {
buf.write_char('%')
i = i + 1
}
}
} else {
buf.write_char(cs[i])
i = i + 1
}
}
buf.to_string()
}
///|
/// 逐字节(字符)匹配:`*` 匹配任意序列,结尾 `$` 锚定。
fn glob_match(pattern : String, path : String) -> Bool {
let mut pat = pattern
let mut anchored : Bool = false
let pc = chars_of(pattern)
if pc.length() > 0 && pc[pc.length() - 1] == '$' {
anchored = true
pat = slice(pc, 0, pc.length() - 1)
}
// 按 '*' 切分模式段
let pcs = chars_of(pat)
let segs : Array[Array[Char]] = []
let mut start : Int32 = 0
let mut i : Int32 = 0
while i <= pcs.length() {
if i == pcs.length() || pcs[i] == '*' {
segs.push(subarray(pcs, start, i))
if i < pcs.length() {
i = i + 1
} else {
break
}
start = i
} else {
i = i + 1
}
}
let a = chars_of(path)
return glob_segs_match(segs, 0, a, 0, anchored)
}
///|
fn subarray(cs : Array[Char], a : Int32, b : Int32) -> Array[Char] {
let out : Array[Char] = []
let mut i : Int32 = a
while i < b {
out.push(cs[i])
i = i + 1
}
out
}
///|
fn seg_eq(cs : Array[Char], pos : Int32, seg : Array[Char]) -> Bool {
if pos + seg.length() > cs.length() {
return false
}
let mut j : Int32 = 0
while j < seg.length() {
if cs[pos + j] != seg[j] {
return false
}
j = j + 1
}
true
}
///|
/// 依次消费模式段:非末段用「找子串并推进」处理,末段按锚定语义收尾。
fn glob_segs_match(
segs : Array[Array[Char]],
si : Int32,
path : Array[Char],
pos : Int32,
anchored : Bool,
) -> Bool {
if si == segs.length() - 1 {
let last = segs[si]
if anchored {
return pos + last.length() == path.length() && seg_eq(path, pos, last)
}
// 非锚定:last 段允许出现在 pos 之后的任意位置
let mut p = pos
while p + last.length() <= path.length() {
if seg_eq(path, p, last) {
return true
}
p = p + 1
}
return last.length() == 0
}
let seg = segs[si]
if !seg_eq(path, pos, seg) {
return false
}
let next = pos + seg.length()
if si + 1 <= segs.length() - 1 {
// 中间段:尝试所有可能的下一段起点(由后续递归处理)
let mut p = next
while p <= path.length() {
if glob_segs_match(segs, si + 1, path, p, anchored) {
return true
}
p = p + 1
}
return false
}
glob_segs_match(segs, si + 1, path, next, anchored)
}
///|
fn parse_delay_ms(v : String) -> Int32? {
let cs = chars_of(v)
if cs.length() == 0 {
return None
}
let mut int_part : Int32 = 0
let mut i : Int32 = 0
while i < cs.length() && cs[i] >= '0' && cs[i] <= '9' {
int_part = int_part * 10 + (cs[i].to_int() - '0'.to_int())
i = i + 1
}
let mut frac_ms : Int32 = 0
if i < cs.length() && cs[i] == '.' {
i = i + 1
let mut scale : Int32 = 100
while i < cs.length() && cs[i] >= '0' && cs[i] <= '9' && scale > 0 {
frac_ms = frac_ms + (cs[i].to_int() - '0'.to_int()) * scale
scale = scale / 10
i = i + 1
}
}
// 之后的非数字内容忽略(如单位后缀)
Some(int_part * 1000 + frac_ms)
}
///|
/// robots.txt 的解析上限(字符数)。1 MiB 高于 RFC 9309 §2.5 要求的 500 KiB。
const ROBOTS_MAX_CHARS : Int32 = 1_048_576
///|
/// 去掉 UTF-8 BOM 与开头空白。带 BOM 的文件若原样解析,第一条
/// `User-agent` 行会因前导 U+FEFF 变成无法识别的指令,整组规则失效。
fn strip_leading_noise(text : String) -> String {
let cs = chars_of(text)
let n : Int32 = cs.length()
let mut i : Int32 = 0
if n > 0 && cs[0] == '\u{FEFF}' {
i = 1
}
while i < n &&
(cs[i] == ' ' || cs[i] == '\t' || cs[i] == '\r' || cs[i] == '\n') {
i = i + 1
}
if i == 0 {
return text
}
slice(cs, i, n)
}
///|
/// 依据待归属的 UA 列表创建新组;无 UA 声明时归入隐式 * 组。
fn new_group(pending : Array[String]) -> AgentGroup {
let agents = if pending.length() > 0 { pending } else { ["*"] }
{ agents, rules: [], crawl_delay_ms: None, }
}
///|
/// 解析 robots.txt 文本。容错:忽略注释与无法识别的行;
/// 空的 Disallow/Allow 值视为无操作。
pub fn parse_robots(text : String) -> RobotsRules {
// RFC 9309 §2.5:解析限额不得低于 500 KiB。这里取 1 MiB,超出部分截断,
// 防止畸形文件把内存吃穿;截断点之前的规则照常生效。
let body = if text.length() > ROBOTS_MAX_CHARS {
slice(chars_of(text), 0, ROBOTS_MAX_CHARS)
} else {
text
}
let groups : Array[AgentGroup] = []
let sitemaps : Array[String] = []
let mut pending : Array[String] = []
let mut cur : AgentGroup? = None
let lines = split_lines(strip_leading_noise(body))
for line in lines {
// 去注释
let hash = find_char(line, '#')
let content = match hash {
Some(idx) => slice(chars_of(line), 0, idx)
None => line
}
let ccs = chars_of(content)
let colon = find_char(content, ':')
let key_val = match colon {
None => None
Some(idx) =>
Some(
(lower_trim(ccs, 0, idx), trim_str(slice(ccs, idx + 1, ccs.length()))),
)
}
match key_val {
Some((key, value)) =>
match key {
"user-agent" =>
if value != "" {
match cur {
// pending 为空说明上一组已产生内容(有规则/延迟),
// 或此前只有裸规则 → 该 UA 行开启新组
Some(_) if pending.length() == 0 => {
pending = [lower_str(value)]
cur = None
}
_ => pending.push(lower_str(value))
}
}
"disallow" | "allow" => {
let allow = key == "allow"
if cur is None {
let g = new_group(pending)
groups.push(g)
cur = Some(g)
pending = []
}
if value != "" {
match cur {
Some(g) => {
let _ = g.rules.push((allow, decode_unreserved(value)))
}
None => ()
}
}
}
"crawl-delay" => {
if cur is None {
let g = new_group(pending)
groups.push(g)
cur = Some(g)
pending = []
}
match cur {
Some(g) => g.crawl_delay_ms = parse_delay_ms(value)
None => ()
}
}
"sitemap" => if value != "" { sitemaps.push(value) }
_ => ()
}
None => ()
}
}
// 收尾:仅声明了 UA 但没有任何规则的组(语义为允许一切)
if pending.length() > 0 {
let g : AgentGroup = { agents: pending, rules: [], crawl_delay_ms: None, }
groups.push(g)
}
{ groups, sitemaps, }
}
///|
fn find_char(s : String, c : Char) -> Int32? {
let cs = chars_of(s)
let mut i : Int32 = 0
while i < cs.length() {
if cs[i] == c {
return Some(i)
}
i = i + 1
}
None
}
///|
fn lower_trim(cs : Array[Char], a : Int32, b : Int32) -> String {
let buf = StringBuilder()
let mut started : Bool = false
let mut end_ : Int32 = b
let mut i : Int32 = b
// 右侧去空白
while i > a {
let c = cs[i - 1]
if c == ' ' || c == '\t' || c == '\r' {
end_ = i - 1
i = i - 1
} else {
break
}
}
i = a
while i < end_ {
let c = cs[i]
if !started && (c == ' ' || c == '\t' || c == '\r') {
i = i + 1
continue
}
started = true
if c >= 'A' && c <= 'Z' {
buf.write_char((c.to_int() + 32).to_char().unwrap())
} else {
buf.write_char(c)
}
i = i + 1
}
buf.to_string()
}
///|
fn lower_str(s : String) -> String {
ascii_lower_str(s)
}
///|
fn trim_str(s : String) -> String {
let cs = chars_of(s)
let mut a : Int32 = 0
let mut b : Int32 = cs.length()
while a < b &&
(cs[a] == ' ' || cs[a] == '\t' || cs[a] == '\r' || cs[a] == '\n') {
a = a + 1
}
while b > a &&
(
cs[b - 1] == ' ' ||
cs[b - 1] == '\t' ||
cs[b - 1] == '\r' ||
cs[b - 1] == '\n'
) {
b = b - 1
}
slice(cs, a, b)
}
///|
fn split_lines(text : String) -> Array[String] {
let out : Array[String] = []
let cs = chars_of(text)
let n : Int32 = cs.length()
let mut start : Int32 = 0
let mut i : Int32 = 0
while i <= n {
if i == n || cs[i] == '\n' {
let mut end_ = i
if end_ > start && cs[end_ - 1] == '\r' {
end_ = end_ - 1
}
out.push(slice(cs, start, end_))
i = i + 1
start = i
} else {
i = i + 1
}
}
out
}
///|
/// 该组与 agent 的最长 product token 前缀长度;不匹配返回 -1。
/// `*` 不参与具体匹配,由 `has_wildcard` 单独处理。
fn agent_prefix_len(g : AgentGroup, agent : String) -> Int32 {
let mut best : Int32 = -1
for ag in g.agents {
if ag != "*" && starts_with(agent, ag) && ag.length() > best {
best = ag.length()
}
}
best
}
///|
fn has_wildcard(g : AgentGroup) -> Bool {
for ag in g.agents {
if ag == "*" {
return true
}
}
false
}
///|
/// 合并后的生效规则集:规则按文件顺序拼接,Crawl-delay 取最大值。
priv struct EffectiveRules {
rules : Array[(Bool, String)]
crawl_delay_ms : Int32?
}
///|
/// 按 RFC 9309 §2.2.1 选出并合并适用组:
/// 1. 所有与 product token 前缀匹配的组里,取最长的 token 长度;
/// 2. 长度等于该值的**全部**组合并(同名 UA 分散在多处时规则累加);
/// 3. 没有任何具体匹配时,合并全部 `*` 组;
/// 4. 都没有则为空规则集(无规则适用 → 允许)。
fn effective_rules(rules : RobotsRules, agent : String) -> EffectiveRules {
let a = lower_str(agent)
let mut best_len : Int32 = -1
for g in rules.groups {
let l = agent_prefix_len(g, a)
if l > best_len {
best_len = l
}
}
let out : Array[(Bool, String)] = []
let mut delay : Int32? = None
for g in rules.groups {
let hit = if best_len >= 0 {
agent_prefix_len(g, a) == best_len
} else {
has_wildcard(g)
}
if hit {
for r in g.rules {
out.push(r)
}
match g.crawl_delay_ms {
Some(d) =>
match delay {
Some(cur) => if d > cur { delay = Some(d) }
None => delay = Some(d)
}
None => ()
}
}
}
{ rules: out, crawl_delay_ms: delay, }
}
///|
/// `/robots.txt` 本身隐式允许(RFC 9309 §2.2.2)。
fn is_robots_txt_path(path : String) -> Bool {
path == "/robots.txt" || starts_with(path, "/robots.txt?")
}
///|
/// 判定 agent 是否允许抓取 path(含 "?query",不含 host 与 fragment)。
/// 无匹配组、组内无规则或路径为 /robots.txt 时允许。
pub fn RobotsRules::allowed(
self : RobotsRules,
agent : String,
path : String,
) -> Bool {
if is_robots_txt_path(path) {
return true
}
let eff = effective_rules(self, agent)
if eff.rules.length() == 0 {
return true
}
let path_octets = decode_unreserved(path)
let mut best_len : Int32 = -1
let mut verdict : Bool = true
for entry in eff.rules {
let (allow, pat) = entry
if glob_match(pat, path_octets) {
let plen = chars_of(pat).length()
if plen > best_len || (plen == best_len && allow) {
best_len = plen
verdict = allow
}
}
}
if best_len < 0 {
return true
}
verdict
}
///|
/// 生效规则集里声明的 Crawl-delay(毫秒),多组合并时取最大值。
/// 无组/未声明返回 None。
pub fn RobotsRules::crawl_delay_ms(
self : RobotsRules,
agent : String,
) -> Int32? {
effective_rules(self, agent).crawl_delay_ms
}
///|
fn starts_with(s : String, prefix : String) -> Bool {
let sc = chars_of(s)
let pc = chars_of(prefix)
if pc.length() > sc.length() {
return false
}
let mut i : Int32 = 0
while i < pc.length() {
if sc[i] != pc[i] {
return false
}
i = i + 1
}
true
}
///|
/// 文件中声明的 Sitemap URL 列表。
pub fn RobotsRules::sitemaps(self : RobotsRules) -> Array[String] {
self.sitemaps
}
///|
/// 便捷方法:对已解析的 Url 做 robots 判定(匹配目标为 path+query)。
pub fn RobotsRules::allowed_url(
self : RobotsRules,
agent : String,
url : Url,
) -> Bool {
let path = match url.query {
Some(q) => url.path + "?" + q
None => url.path
}
self.allowed(agent, path)
}