Skip to main content

pagesift_frontier/focus/
queue.rs

1//! 相关性优先的待爬队列(best-first),容量有界,出队时按簇分散。
2
3use std::cmp::Reverse;
4use std::collections::{BTreeMap, HashMap};
5
6use url::Url;
7
8/// 相关性优先的待爬队列。容量由调用方按"还能再抓几页"给出,满了以后新链接只有比队里最差的
9/// 好才挤进来:队列长度不超过 limit,和广度优先一样内存有界。
10/// 每项带一个打分依据 `S`([`super::LinkSignal`]),词权重变了以后按它重新打分([`Self::rescore`])。
11pub struct BestFirst<S = ()> {
12    /// (分数的位表示, 发现顺序取反) → 项。分数非负,位表示的大小顺序和数值一致;
13    /// 最后一项是分数最高、同分里最早发现的。
14    order: BTreeMap<Key, Entry<S>>,
15    index: HashMap<u64, Key>,
16    seq: u64,
17    /// 最近一次出队的项的打分依据([`Self::take_signal`])。
18    taken: Option<S>,
19}
20
21type Key = (u32, Reverse<u64>);
22
23struct Entry<S> {
24    hash: u64,
25    url: Url,
26    depth: usize,
27    signal: S,
28}
29
30impl<S> Default for BestFirst<S> {
31    fn default() -> Self {
32        Self {
33            order: BTreeMap::new(),
34            index: HashMap::new(),
35            seq: 0,
36            taken: None,
37        }
38    }
39}
40
41#[derive(Debug, PartialEq, Eq)]
42pub enum Offer {
43    Queued,
44    /// 已经在队里,按更高的分数调整了位置(或者分数没更高,保持不变)。
45    AlreadyQueued,
46    /// 队列满了,挤掉了这个哈希对应的链接;调用方要把它从去重表里删掉,以后还能再进来。
47    Evicted(u64),
48    Rejected,
49}
50
51/// 出队时的分散规则:一个簇(主机 + 第一段路径)已经调度了 `cap` 页后,
52/// 只要别的簇里有分数不低于最高分 [`DIVERSITY_RATIO`] 倍的候选,就先出它。
53pub const DIVERSITY_RATIO: f32 = 0.5;
54
55fn score_bits(score: f32) -> u32 {
56    if score.is_nan() {
57        0
58    } else {
59        score.max(0.0).to_bits()
60    }
61}
62
63/// 簇的键:主机加第一段路径(`news`、`admissions`、`exhibition`…)。
64/// 各个主机的首页(`/`、`/en/`、`/index.html`)同属一个簇:大学首页链着几十个院系子站,
65/// 每个子站首页都是不同的主机,不归到一起的话按簇分散反而会把预算摊给它们。
66pub fn cluster_key(u: &Url) -> String {
67    let segs: Vec<&str> = u.path().split('/').filter(|s| !s.is_empty()).collect();
68    // 首页:没有路径,或者只有一段语言代码(`en`、`en-gb`)或 `index`(可以带扩展名)
69    let home_segment = |seg: &str| {
70        let stem = seg
71            .split_once('.')
72            .map_or(seg, |(s, _)| s)
73            .to_ascii_lowercase();
74        let lang = |p: &str| p.len() == 2 && p.bytes().all(|b| b.is_ascii_alphabetic());
75        stem == "index"
76            || lang(&stem)
77            || stem
78                .split_once('-')
79                .is_some_and(|(a, b)| lang(a) && (2..=4).contains(&b.len()))
80    };
81    let root = match segs.as_slice() {
82        [] => true,
83        [one] => home_segment(one),
84        _ => false,
85    };
86    if root {
87        return "*/".to_string();
88    }
89    format!("{}/{}", u.host_str().unwrap_or_default(), segs[0])
90}
91
92impl<S> BestFirst<S> {
93    pub fn len(&self) -> usize {
94        self.order.len()
95    }
96
97    pub fn is_empty(&self) -> bool {
98        self.order.is_empty()
99    }
100
101    pub fn contains(&self, hash: u64) -> bool {
102        self.index.contains_key(&hash)
103    }
104
105    /// 放入一个链接。`room` 是队列现在最多能有多少项(limit 减去已经调度的页数)。
106    /// 已经在队里的,分数更高时换成这次的分数和打分依据。
107    pub fn offer(
108        &mut self,
109        hash: u64,
110        url: Url,
111        depth: usize,
112        score: f32,
113        signal: S,
114        room: usize,
115    ) -> Offer {
116        let bits = score_bits(score);
117        if let Some(&old) = self.index.get(&hash) {
118            if bits > old.0
119                && let Some(mut entry) = self.order.remove(&old)
120            {
121                entry.signal = signal;
122                self.order.insert((bits, old.1), entry);
123                self.index.insert(hash, (bits, old.1));
124            }
125            return Offer::AlreadyQueued;
126        }
127        let mut evicted = None;
128        if self.order.len() >= room {
129            match self.order.first_key_value() {
130                Some((&worst, _)) if worst.0 < bits => {
131                    let e = self.order.remove(&worst).expect("present");
132                    self.index.remove(&e.hash);
133                    evicted = Some(e.hash);
134                }
135                _ => return Offer::Rejected,
136            }
137        }
138        let key = (bits, Reverse(self.seq));
139        self.seq += 1;
140        self.order.insert(
141            key,
142            Entry {
143                hash,
144                url,
145                depth,
146                signal,
147            },
148        );
149        self.index.insert(hash, key);
150        evicted.map_or(Offer::Queued, Offer::Evicted)
151    }
152
153    /// 按打分依据重算每一项的分数,同分的还按发现顺序。
154    pub fn rescore(&mut self, score: impl Fn(&S) -> f32) {
155        let old = std::mem::take(&mut self.order);
156        for ((_, seq), entry) in old {
157            let key = (score_bits(score(&entry.signal)), seq);
158            self.index.insert(entry.hash, key);
159            self.order.insert(key, entry);
160        }
161    }
162
163    /// 取出分数最高的链接:(URL, 深度, 分数)。
164    #[cfg(test)]
165    pub fn pop(&mut self) -> Option<(Url, usize, f32)> {
166        let key = *self.order.last_key_value()?.0;
167        self.take(key)
168    }
169
170    /// 按簇分散地出队:分数最高的链接所在的簇已经调度了 `cap` 页时,改出别的簇里分数不低于
171    /// 最高分一半的最好链接;没有这样的链接就还是出最高分的。`scheduled` 是每个簇已调度的页数,
172    /// `proven` 说一个簇是不是已经证明了产出好:这样的簇不受 `cap` 限制,而且队首在没证明过的簇
173    /// 里时,证明过的簇里分数不低于最高分一半的链接先出。
174    pub fn pop_diverse(
175        &mut self,
176        scheduled: &HashMap<String, usize>,
177        cap: usize,
178        proven: impl Fn(&str) -> bool,
179    ) -> Option<(Url, usize, f32)> {
180        let (&top_key, top) = self.order.last_key_value()?;
181        let top_url = &top.url;
182        let top_score = f32::from_bits(top_key.0);
183        let full = |u: &Url| {
184            let key = cluster_key(u);
185            scheduled.get(&key).copied().unwrap_or(0) >= cap && !proven(&key)
186        };
187        let floor = top_score * DIVERSITY_RATIO;
188        // 利用:队首所在的簇还没证明产出,而已经证明高产的簇里有分数够得上的链接时,先抓它
189        // (和下面的分散对称:一个是别把预算全押在一个簇上,一个是别为了没验证过的簇丢下高产的簇)
190        if !proven(&cluster_key(top_url))
191            && let Some(k) = self
192                .order
193                .iter()
194                .rev()
195                .take_while(|(k, _)| f32::from_bits(k.0) >= floor)
196                .find(|(_, e)| proven(&cluster_key(&e.url)))
197                .map(|(k, _)| *k)
198        {
199            return self.take(k);
200        }
201        if !full(top_url) {
202            return self.take(top_key);
203        }
204        let pick = self
205            .order
206            .iter()
207            .rev()
208            .take_while(|(k, _)| f32::from_bits(k.0) >= floor)
209            .find(|(_, e)| !full(&e.url))
210            .map(|(k, _)| *k)
211            .unwrap_or(top_key);
212        self.take(pick)
213    }
214
215    /// 队首(分数最高)的分数。
216    pub fn peek_score(&self) -> Option<f32> {
217        self.order
218            .last_key_value()
219            .map(|(k, _)| f32::from_bits(k.0))
220    }
221
222    fn take(&mut self, key: Key) -> Option<(Url, usize, f32)> {
223        let e = self.order.remove(&key)?;
224        self.index.remove(&e.hash);
225        self.taken = Some(e.signal);
226        Some((e.url, e.depth, f32::from_bits(key.0)))
227    }
228
229    /// 最近一次出队的项的打分依据(取走)。
230    pub fn take_signal(&mut self) -> Option<S> {
231        self.taken.take()
232    }
233}
234
235#[cfg(test)]
236mod tests {
237    use super::*;
238
239    fn u(s: &str) -> Url {
240        Url::parse(s).unwrap()
241    }
242
243    #[test]
244    fn best_first_pops_highest_score_then_discovery_order() {
245        let mut q = BestFirst::default();
246        q.offer(1, u("https://e.com/a"), 1, 0.5, (), 10);
247        q.offer(2, u("https://e.com/b"), 1, 2.0, (), 10);
248        q.offer(3, u("https://e.com/c"), 1, 0.5, (), 10);
249        assert_eq!(q.pop().unwrap().0.path(), "/b");
250        assert_eq!(q.pop().unwrap().0.path(), "/a");
251        assert_eq!(q.pop().unwrap().0.path(), "/c");
252        assert!(q.pop().is_none());
253    }
254
255    #[test]
256    fn best_first_is_bounded_and_evicts_the_worst() {
257        let mut q = BestFirst::default();
258        assert_eq!(
259            q.offer(1, u("https://e.com/a"), 1, 0.1, (), 2),
260            Offer::Queued
261        );
262        assert_eq!(
263            q.offer(2, u("https://e.com/b"), 1, 0.2, (), 2),
264            Offer::Queued
265        );
266        assert_eq!(
267            q.offer(3, u("https://e.com/c"), 1, 0.05, (), 2),
268            Offer::Rejected
269        );
270        assert_eq!(
271            q.offer(4, u("https://e.com/d"), 1, 0.3, (), 2),
272            Offer::Evicted(1)
273        );
274        assert_eq!(q.len(), 2);
275        assert!(!q.contains(1) && q.contains(4));
276        assert_eq!(
277            q.offer(4, u("https://e.com/d"), 1, 0.9, (), 2),
278            Offer::AlreadyQueued
279        );
280        assert_eq!(q.pop().unwrap().2, 0.9);
281    }
282
283    #[test]
284    fn diverse_pop_leaves_a_saturated_cluster_for_a_close_rival() {
285        let mut q = BestFirst::default();
286        q.offer(1, u("https://e.com/news/1"), 1, 1.0, (), 10);
287        q.offer(2, u("https://e.com/news/2"), 1, 0.9, (), 10);
288        q.offer(3, u("https://e.com/study/intl"), 1, 0.6, (), 10);
289        q.offer(4, u("https://e.com/about"), 1, 0.2, (), 10);
290        let mut counts = HashMap::new();
291        counts.insert("e.com/news".to_string(), 3);
292        // news 已经抓了 3 页:先出 study(0.6 ≥ 1.0 × 0.5),再回到 news
293        assert_eq!(
294            q.pop_diverse(&counts, 3, |_| false).unwrap().0.path(),
295            "/study/intl"
296        );
297        assert_eq!(
298            q.pop_diverse(&counts, 3, |_| false).unwrap().0.path(),
299            "/news/1"
300        );
301        // 剩下的 about 分太低,不够资格插队
302        assert_eq!(
303            q.pop_diverse(&counts, 3, |_| false).unwrap().0.path(),
304            "/news/2"
305        );
306        // 产出已经证明好的簇不受上限限制
307        let mut q = BestFirst::default();
308        q.offer(1, u("https://e.com/news/1"), 1, 1.0, (), 10);
309        q.offer(3, u("https://e.com/study/intl"), 1, 0.6, (), 10);
310        assert_eq!(
311            q.pop_diverse(&counts, 3, |k| k == "e.com/news")
312                .unwrap()
313                .0
314                .path(),
315            "/news/1"
316        );
317        assert_eq!(cluster_key(&u("https://e.com/")), "*/");
318        assert_eq!(cluster_key(&u("https://cs.e.com/en/")), "*/");
319        assert_eq!(cluster_key(&u("https://e.com/en.html")), "*/");
320        assert_eq!(cluster_key(&u("https://e.com/en-gb/")), "*/");
321        assert_eq!(cluster_key(&u("https://e.com/news/")), "e.com/news");
322        assert_eq!(cluster_key(&u("https://e.com/study")), "e.com/study");
323    }
324
325    #[test]
326    fn rescoring_reorders_by_the_new_scores_and_keeps_ties_in_discovery_order() {
327        let mut q: BestFirst<f32> = BestFirst::default();
328        q.offer(1, u("https://e.com/a"), 1, 0.9, 0.1, 10);
329        q.offer(2, u("https://e.com/b"), 1, 0.5, 0.7, 10);
330        q.offer(3, u("https://e.com/c"), 1, 0.4, 0.7, 10);
331        q.rescore(|s| *s);
332        assert_eq!(q.peek_score(), Some(0.7));
333        assert_eq!(q.pop().unwrap().0.path(), "/b");
334        assert_eq!(q.pop().unwrap().0.path(), "/c");
335        assert_eq!(q.pop().unwrap().0.path(), "/a");
336        assert!(q.is_empty() && !q.contains(1));
337    }
338}