Skip to main content

pagesift_frontier/focus/
context.rs

1//! 链接在正文 Markdown 里的锚文本和所在行,给链接打分用。
2
3use std::collections::HashMap;
4use std::sync::LazyLock;
5
6use regex::Regex;
7
8/// 链接上下文最多取多少字节(一行很长时只看链接附近)。
9/// [策略] 先验包 v0:0.81 的现值(约一句话两侧各 30 个英文词);长行(整段没有换行的正文)里
10/// 离链接远的词不是它的上下文
11const CONTEXT_BYTES: usize = 400;
12
13/// 一个链接在正文 Markdown 里的锚文本和所在行(链接语法换成锚文本)。
14pub struct LinkContext {
15    pub anchor: String,
16    pub line: String,
17}
18
19static MD_LINK: LazyLock<Regex> = LazyLock::new(|| {
20    Regex::new(r"\[([^\[\]]*)\]\(([^()\s]+)(?:\s+\x22[^\x22]*\x22)?\)").expect("valid regex")
21});
22
23/// 从正文 Markdown 里找出每个链接(去掉 fragment 的绝对地址)的锚文本和上下文。
24/// 导航栏等不在正文里的链接没有上下文,调用方用 DOM 里的锚文本代替。同一链接取第一次出现。
25pub fn link_contexts(markdown: &str) -> HashMap<String, LinkContext> {
26    let mut out: HashMap<String, LinkContext> = HashMap::new();
27    for line in markdown.lines() {
28        if !line.contains("](") {
29            continue;
30        }
31        let plain = MD_LINK.replace_all(line, "$1");
32        for cap in MD_LINK.captures_iter(line) {
33            let url = cap[2].split('#').next().unwrap_or_default();
34            if url.is_empty() || out.contains_key(url) {
35                continue;
36            }
37            let anchor = cap[1].trim().to_string();
38            out.insert(
39                url.to_string(),
40                LinkContext {
41                    line: around(&plain, &anchor),
42                    anchor,
43                },
44            );
45        }
46    }
47    out
48}
49
50/// 正文里不属于链接标签的词数:一页的字大多是链接标签(目录页、文章列表)时它就是枢纽页,
51/// 标题里凑齐了查询词也不算内容。
52pub fn prose_words(markdown: &str) -> usize {
53    MD_LINK
54        .replace_all(markdown, "")
55        .split_whitespace()
56        .filter(|w| w.chars().any(char::is_alphanumeric))
57        .count()
58}
59
60/// 一行里锚文本附近的 [`CONTEXT_BYTES`] 字节;行不长时就是整行。
61fn around(line: &str, anchor: &str) -> String {
62    if line.len() <= CONTEXT_BYTES {
63        return line.to_string();
64    }
65    let at = line.find(anchor).unwrap_or(0);
66    let mut lo = at.saturating_sub(CONTEXT_BYTES / 2);
67    let mut hi = (lo + CONTEXT_BYTES).min(line.len());
68    while !line.is_char_boundary(lo) {
69        lo -= 1;
70    }
71    while !line.is_char_boundary(hi) {
72        hi += 1;
73    }
74    line[lo..hi].to_string()
75}
76
77#[cfg(test)]
78mod tests {
79    use super::*;
80
81    #[test]
82    fn link_contexts_come_from_markdown_lines() {
83        let md = "# T\n\nRead the [refund rules](https://e.com/r#top) before buying.\n\n- [About](https://e.com/a \"title\")\n- [Refund again](https://e.com/r)\n\n![img](https://e.com/i.png)\n";
84        let c = link_contexts(md);
85        let r = &c["https://e.com/r"];
86        assert_eq!(r.anchor, "refund rules");
87        assert_eq!(r.line, "Read the refund rules before buying.");
88        assert_eq!(c["https://e.com/a"].anchor, "About");
89        let long = format!(
90            "x{} [k](https://e.com/k) {}",
91            "a".repeat(600),
92            "b".repeat(600)
93        );
94        let c = link_contexts(&long);
95        assert!(c["https://e.com/k"].line.len() <= CONTEXT_BYTES + 1);
96        assert!(c["https://e.com/k"].line.contains('k'));
97    }
98
99    #[test]
100    fn prose_words_exclude_link_labels() {
101        let md = "Intro line here.\n\n- [Refund policy for everyone](https://e.com/a)\n- [Second long label](https://e.com/b)\n";
102        assert_eq!(prose_words(md), 3);
103        assert_eq!(prose_words("plain words only"), 3);
104    }
105}