// 抓取引擎:种子 → 规范化 → robots 检查 → origin 礼貌限速 → 抓取 →
// 链接提取 → 入队,直至页数/深度上限或队列耗尽。
//
// 传输层与结果消费都是注入点:fetch 闭包让引擎可以在测试里对假站点
// 运行(无网络依赖),真实抓取由 fetch_http 提供实现。
// 礼貌策略按 origin(scheme://host[:port])记账:robots.txt 与页面
// 请求共用同一时间表;robots 的 Crawl-delay 会抬高对应 origin 的间隔。
//
// robots.txt 的状态码语义按 RFC 9309 §2.3.1 处理:
// - 2xx:解析并执行(§2.3.1.1)
// - 4xx:视为 unavailable,允许访问该服务器资源(§2.3.1.3)
// - 5xx 或网络错误:视为 unreachable,默认完全禁止(§2.3.1.4);
// 需要旧版「不可达即放行」行为的调用方显式选择 RobotsUnreachable::Allow。
///|
/// 单页抓取结果。status=0 表示传输失败或被策略跳过,具体原因见 error。
pub(all) struct PageResult {
url : String // 规范化后的请求 URL
status : Int32 // HTTP 状态码;0 表示未完成传输
depth : Int32 // 距种子的跳数(种子为 0)
links_found : Int32 // 本页发现的合法站内链接数(已解析,去重前)
title : String?
body : String? // 仅在 CrawlOptions::keep_body 为真时填充
error : String?
}
///|
/// robots.txt 不可达(5xx 或网络错误)时的策略。
pub(all) enum RobotsUnreachable {
/// RFC 9309 §2.3.1.4 的强制行为:视为完全禁止。
Disallow
/// 旧版惯例:视为允许抓取。非规范行为,仅用于目标站点长期无
/// robots.txt 且明确接受风险的场景。
Allow
}
///|
/// 抓取配置。
pub(all) struct CrawlOptions {
max_pages : Int32 // 最多抓取的页面数(含失败)
max_depth : Int32 // 最大深度(种子为 0,链接为其子深度)
delay_ms : Int32 // 同 origin 两次请求的最小间隔;robots Crawl-delay 可抬高
max_concurrency : Int32 // 跨 origin 并行度;同 origin 始终串行。1 = 完全串行
respect_robots : Bool
robots_unreachable : RobotsUnreachable // 见枚举说明
keep_body : Bool // 是否把响应正文放进 PageResult(采集场景需要)
user_agent : String
}
///|
pub fn CrawlOptions::default() -> CrawlOptions {
{
max_pages: 50,
max_depth: 2,
delay_ms: 1000,
max_concurrency: 4,
respect_robots: true,
robots_unreachable: Disallow,
keep_body: false,
user_agent: "MoonCrawl/0.1 (+https://github.com/guangting0905/mooncrawl)",
}
}
///|
/// 传输层结果:所有失败折叠进 error 字段,引擎不感知传输细节。
pub(all) struct FetchOutcome {
status : Int32 // HTTP 状态码;传输失败为 0
body : String
error : String?
}
///|
/// 抓取统计。
pub struct CrawlStats {
mut pages_fetched : Int32 // 已抓取页面数(不含 robots.txt)
mut pages_ok : Int32
mut pages_failed : Int32
mut pages_skipped_robots : Int32 // 被 robots 规则拒绝的页面数
mut pages_skipped_unreachable : Int32 // 因 robots 不可达被拒的页面数
mut links_discovered : Int32
mut robots_unreachable : Int32 // robots.txt 不可达(5xx/网络错误)的 origin 数
}
///|
fn CrawlStats::new() -> CrawlStats {
{
pages_fetched: 0,
pages_ok: 0,
pages_failed: 0,
pages_skipped_robots: 0,
pages_skipped_unreachable: 0,
links_discovered: 0,
robots_unreachable: 0,
}
}
///|
fn origin_of(u : Url) -> String {
let buf = StringBuilder()
buf.write_string(u.scheme)
buf.write_string("://")
buf.write_string(u.host)
match u.port {
Some(p) =>
if !is_default_port(u.scheme, p) {
buf.write_string(":")
buf.write_string(p.to_string())
}
None => ()
}
buf.to_string()
}
///|
/// 礼貌调度状态:按 origin 记录最近请求时刻与生效间隔。
///
/// 时刻一律用 Int64 毫秒。`@async.now()` 返回 64 位毫秒,若截断成 32 位,
/// 差值在回绕处会算出巨大的等待时长(表现为抓取卡死),必须全程保留宽度。
priv struct Politeness {
last_visit : Map[String, Int64]
delay : Map[String, Int32] // origin -> robots Crawl-delay 抬高后的间隔
base_ms : Int32
}
///|
fn Politeness::new(base_ms : Int32) -> Politeness {
{ last_visit: Map([], capacity=64), delay: Map([], capacity=16), base_ms, }
}
///|
fn Politeness::delay_of(self : Politeness, origin : String) -> Int32 {
match self.delay.get(origin) {
Some(d) => d
None => self.base_ms
}
}
///|
/// 该 origin 最早允许发起下一次请求的时刻。
fn Politeness::next_allowed(
self : Politeness,
origin : String,
now_ms : Int64,
) -> Int64 {
let delay = self.delay_of(origin).to_int64()
let earliest = match self.last_visit.get(origin) {
Some(last) => last + delay
None => 0L
}
if now_ms > earliest {
now_ms
} else {
earliest
}
}
///|
/// 若需等待则挂起至允许时刻,然后登记本次访问。
async fn Politeness::gate(self : Politeness, origin : String) -> Unit {
let now = @async.now()
let t = self.next_allowed(origin, now)
if t > now {
@async.sleep((t - now).to_int())
}
let _ = self.last_visit.set(origin, @async.now())
}
///|
/// 引擎主循环:按批取待抓 URL,批内跨 host 并行、同 host 严格串行。
///
/// 三阶段结构让并发不污染共享状态:
/// 1. 串行:robots 加载与准入判定(会修改 robots 缓存、统计、blocked);
/// 2. 并行:对通过准入的 URL 按 origin 礼貌等待后抓取(只读共享状态);
/// 3. 串行:处理抓取结果、提取链接、入队、统计与回调。
///
/// `max_concurrency = 1` 时退化为完全串行,行为与单请求循环一致。
/// 无效种子返回全零统计。
pub async fn crawl(
seed : String,
options : CrawlOptions,
fetch : async (String, String) -> FetchOutcome,
on_page : (PageResult) -> Unit,
) -> CrawlStats {
let stats = CrawlStats::new()
let seed_url = match parse_url(seed) {
Some(u) => u
None => return stats
}
let seed_norm = normalize_url(seed_url)
let frontier = Frontier::new(options.delay_ms)
let depths : Map[String, Int32] = Map([], capacity=256)
let robots_map : Map[String, RobotsRules] = Map([], capacity=16)
let robots_tried : Map[String, Bool] = Map([], capacity=16)
// robots 不可达且策略为 Disallow 的 origin:其下所有页面一律不发请求
let blocked : Map[String, Bool] = Map([], capacity=16)
let polite = Politeness::new(options.delay_ms)
let _ = frontier.push(seed_norm)
let _ = depths.set(seed_norm, 0)
let concurrency = if options.max_concurrency < 1 {
1
} else {
options.max_concurrency
}
let mut fetched : Int32 = 0
while fetched < options.max_pages {
let batch = frontier.pop_batch(concurrency)
if batch.length() == 0 {
break
}
// ---- 阶段 1(串行):robots 加载 + 准入判定 ----
let ready_urls : Array[String] = []
let ready_origins : Array[String] = []
let ready_depths : Array[Int32] = []
for url in batch {
if fetched + ready_urls.length() >= options.max_pages {
break
}
let u = match parse_url(url) {
Some(u) => u
None => continue
}
let depth = match depths.get(url) {
Some(d) => d
None => 0
}
let origin = origin_of(u)
// robots(每个 origin 只取一次)
if options.respect_robots && robots_tried.get(origin) is None {
let robots_url = origin + "/robots.txt"
polite.gate(origin)
let out = fetch(robots_url, options.user_agent)
let _ = robots_tried.set(origin, true)
let robots_down = match out.error {
Some(_) => true
None => out.status == 0 || (out.status >= 500 && out.status <= 599)
}
if robots_down {
// RFC 9309 §2.3.1.4:robots.txt 未定义 → 视为完全禁止
stats.robots_unreachable = stats.robots_unreachable + 1
if options.robots_unreachable is Disallow {
let _ = blocked.set(origin, true)
}
} else if out.status >= 400 && out.status <= 499 {
// §2.3.1.3 unavailable:无规则适用,允许访问
} else {
let rules = parse_robots(out.body)
match rules.crawl_delay_ms(options.user_agent) {
Some(d) =>
if d > polite.delay_of(origin) {
let _ = polite.delay.set(origin, d)
}
None => ()
}
let _ = robots_map.set(origin, rules)
}
}
if options.respect_robots {
let mut skip : String? = None
match blocked.get(origin) {
Some(_) => {
stats.pages_skipped_unreachable = stats.pages_skipped_unreachable +
1
skip = Some("robots.txt unreachable: disallowed by policy")
}
None =>
match robots_map.get(origin) {
Some(rules) =>
if !rules.allowed_url(options.user_agent, u) {
stats.pages_skipped_robots = stats.pages_skipped_robots + 1
skip = Some("disallowed by robots.txt")
}
None => ()
}
}
match skip {
Some(reason) => {
on_page({
url,
status: 0,
depth,
links_found: 0,
title: None,
body: None,
error: Some(reason),
})
continue
}
None => ()
}
}
ready_urls.push(url)
ready_origins.push(origin)
ready_depths.push(depth)
}
if ready_urls.length() == 0 {
continue
}
// ---- 阶段 2(并行):跨 origin 抓取;同 origin 在批内最多一个 ----
let tasks : Array[async () -> FetchOutcome] = []
let n_ready : Int32 = ready_urls.length()
let mut k : Int32 = 0
while k < n_ready {
let url = ready_urls[k]
let origin = ready_origins[k]
tasks.push(async fn() -> FetchOutcome {
polite.gate(origin)
fetch(url, options.user_agent)
})
k = k + 1
}
let outcomes = @async.all(tasks)
// ---- 阶段 3(串行):结果处理、链接提取、入队与统计 ----
let mut i : Int32 = 0
while i < n_ready {
let url = ready_urls[i]
let depth = ready_depths[i]
let out = outcomes[i]
i = i + 1 // 先自增:下面的 continue 分支不会漏掉推进
fetched = fetched + 1
stats.pages_fetched = stats.pages_fetched + 1
match out.error {
Some(e) => {
stats.pages_failed = stats.pages_failed + 1
on_page({
url,
status: 0,
depth,
links_found: 0,
title: None,
body: None,
error: Some(e),
})
continue
}
None => ()
}
stats.pages_ok = stats.pages_ok + 1
let u = match parse_url(url) {
Some(u) => u
None => continue
}
let links = extract_links(out.body)
let mut discovered : Int32 = 0
for href in links {
match resolve_url(u, href) {
Some(child) => {
let child_norm = normalize_url(child)
discovered = discovered + 1
if depth + 1 <= options.max_depth {
if frontier.push(child_norm) {
let _ = depths.set(child_norm, depth + 1)
}
}
}
None => ()
}
}
stats.links_discovered = stats.links_discovered + discovered
on_page({
url,
status: out.status,
depth,
links_found: discovered,
title: extract_title(out.body),
body: if options.keep_body {
Some(out.body)
} else {
None
},
error: None,
})
}
}
stats
}