Skip to main content

pagesift_frontier/focus/
mod.rs

1//! 带查询的整站爬取:相关性优先的调度队列(best-first)、链接打分和自适应停止。
2//!
3//! 链接分数 = 锚文本、URL(主机名和路径)、链接所在那一行各自覆盖了多少查询词(加权),
4//! 再加上所在页面本身的相关度,最后按深度衰减。查询词的权重按已抓页面的文档频率调整
5//! ([`weights`]):每页都有的词不再算数,少见的词才是证据。家具页(新闻、关于、法律页脚、
6//! 带日期的路径)和枢纽页降权([`furniture`]);队列出队时按簇分散([`queue`]),
7//! 一个子树吃不完预算;自适应停止只拿相关页面当证据([`adaptive`])。
8//! 分数相同的链接按发现顺序出队,所以完全不相关的站点上,顺序退化成广度优先。
9
10mod adaptive;
11mod context;
12mod facet;
13pub mod furniture;
14mod harvest;
15mod model;
16mod queue;
17mod url_shape;
18pub use url_shape::shape as url_shape;
19mod weights;
20
21pub use adaptive::{Adaptive, MIN_RELEVANCE};
22pub use context::link_contexts;
23use context::prose_words;
24pub use harvest::{Harvest, subtree};
25pub use queue::{BestFirst, Offer, cluster_key};
26
27use facet::{FacetStats, Facets};
28use model::LinkModel;
29use pagesift_extract::Query;
30use url::Url;
31use url_shape::{Section, foreign_language_segment, is_english, language_portal, locator, section};
32use weights::TermWeights;
33
34// 下面五个线性权重是在线逻辑回归的先验均值(先验包 v0:0.81 的现值,见 [`model`])
35const W_ANCHOR: f32 = 2.0;
36const W_LOCATOR: f32 = 1.5;
37const W_CONTEXT: f32 = 1.0;
38const W_PARENT: f32 = 0.5;
39/// 锚文本命中两个以上查询词("international students"、"tuition fees")的加分。
40const PHRASE_BONUS: f32 = 0.5;
41/// 每深一层,分数乘以它。0.8 时首页一级的门户页压过两级下的内容页,0.9 留给锚文本做主。
42const DEPTH_DECAY: f32 = 0.9;
43/// 英文查询在非英文站点上:"English" 入口本身不含查询词,但英文内容都在它后面;给它一个保底分。
44const LANGUAGE_PORTAL_SCORE: f32 = 0.6;
45/// 栏目入口(`/docs/`、`/services/`、`/admissions/` 这类一层路径、短锚文本的链接)的保底分:
46/// 锚文本本身常常不含查询词,内容却都在它下面。定义成"一条锚文本命中半个查询的家具链接"的分数:
47/// 栏目先于新闻、博客探,但不先于锚文本真命中查询词的普通链接(那是 W_ANCHOR × 覆盖度 ≥ 0.4)。
48const SECTION_PRIOR: f32 = furniture::FURNITURE_FACTOR * (W_ANCHOR * 0.5 + PHRASE_BONUS);
49/// 链接的分数不高于栏目保底分:它本身没有查询词的线索,抓它是"盲探"(自适应停止用,
50/// 见 [`Adaptive::observe`])。种子页(分数无穷大)不算。
51pub fn is_blind(score: f32) -> bool {
52    score <= SECTION_PRIOR
53}
54
55/// 别的主机的根(院系、图书馆、系统的子站首页)的保底分:是另一个站,排在本站的栏目之后。
56const SUBSITE_PRIOR: f32 = SECTION_PRIOR * 0.5;
57/// 英文查询碰到别的语言版本的入口(`/de/`、`/ja/`):和家具页一样打折。
58const FOREIGN_LANGUAGE_FACTOR: f32 = furniture::FURNITURE_FACTOR;
59/// 同一页上指向同一个簇的链接超过这个数(新闻列表、展品列表),每条按 sqrt(上限/条数) 打折。
60/// [策略] 先验包 v0:0.81 的现值
61pub const LIST_FREE: usize = 8;
62
63/// 词权重变化超过这么多(最大的那个词)才把排队的链接重新打分:权重随抓到的页面渐渐稳定,
64/// 后期几乎不用重算;队列长度不超过 limit,一次重算很便宜。
65const RESCORE_DELTA: f32 = 0.05;
66
67/// 页面相关度的组成:各查询方面的证据([`facet`]),枢纽页和家具页的折扣。
68/// `fixed` 是直接给定的相关度(没有页面可看的链接,测试)。
69#[derive(Clone, Copy, Debug, Default)]
70pub struct PageSignal {
71    title: u64,
72    body: u64,
73    facets: Facets,
74    factor: f32,
75    fixed: f32,
76    /// 页面声明的语言不是英文(`lang` 不以 `en` 开头)。
77    foreign: bool,
78}
79
80impl PageSignal {
81    pub fn fixed(relevance: f32) -> Self {
82        Self {
83            fixed: relevance,
84            ..Self::default()
85        }
86    }
87}
88
89/// URL 的形状对分数的作用:打折(家具、日期、别的语言),保底(栏目、子站、英文入口),
90/// 或者直接给定(种子页、hreflang 声明的英文版)。
91#[derive(Clone, Copy, Debug)]
92enum Shape {
93    Plain,
94    Factor(f32),
95    Floor(f32),
96    Fixed(f32),
97}
98
99/// 链接分数的组成(见 [`Focus::link_signal`])。
100#[derive(Clone, Copy, Debug)]
101pub struct LinkSignal {
102    anchor: u64,
103    locator: u64,
104    context: u64,
105    parent: PageSignal,
106    shape: Shape,
107    depth: u8,
108    /// 调用方另乘的折扣(模板降级、同一页上的长列表)。
109    factor: f32,
110}
111
112impl LinkSignal {
113    /// 直接给定分数的链接:重新打分时不变。
114    pub fn fixed(score: f32) -> Self {
115        Self {
116            anchor: 0,
117            locator: 0,
118            context: 0,
119            parent: PageSignal::default(),
120            shape: Shape::Fixed(score),
121            depth: 0,
122            factor: 1.0,
123        }
124    }
125
126    /// 另乘一个折扣。
127    pub fn scale(&mut self, factor: f32) {
128        self.factor *= factor;
129    }
130}
131
132/// 查询和从它派生的打分规则。
133pub struct Focus {
134    pub query: Query,
135    weights: TermWeights,
136    /// 页面相关度用的本站统计量。
137    facets: FacetStats,
138    /// 种子页的主机(去掉 www.):英文版入口的保底分只给本站的 `/en/`,别的主机的 `/en/` 是子站。
139    host: Option<String>,
140    /// 已经抓到本站的英文版首页:之后别的 `/xx/en/` 不再拿英文入口的保底分(那是子栏目的英文版)。
141    english_home: bool,
142    /// 排队链接上一次打分时的词权重(见 [`Self::weights_moved`])。
143    scored_with: Vec<f32>,
144    /// 种子页是英文的(见 [`Self::english_target`])。
145    english_seed: bool,
146    /// 链接分数五个线性项的权重(在线学习,见 [`model`])。
147    model: LinkModel,
148}
149
150impl Focus {
151    pub fn new(query: &str) -> Self {
152        let query = Query::new(query);
153        let weights = TermWeights::new(query.len());
154        let facets = FacetStats::new(query.len());
155        Self {
156            scored_with: weights
157                .current()
158                .iter()
159                .copied()
160                .chain(facets.shares())
161                .collect(),
162            query,
163            weights,
164            facets,
165            host: None,
166            english_home: false,
167            english_seed: false,
168            model: LinkModel::new([W_ANCHOR, W_LOCATOR, W_CONTEXT, W_PARENT, PHRASE_BONUS]),
169        }
170    }
171
172    /// 记下种子页声明的语言。
173    pub fn note_seed_language(&mut self, lang: Option<&str>) {
174        self.english_seed = lang.is_some_and(is_english);
175    }
176
177    /// 英文查询、而且已经知道站点的英文内容在哪(种子页就是英文的,或者找到了英文首页):
178    /// 这时别的语言的页面上的栏目链接不再拿保底分。挪威语主站、德语主站的导航不是线索,
179    /// 跟着它们走就把预算花在查询读不懂的页面上了。
180    /// 还在找本站的英文版:英文查询,种子页不是英文的,也还没找到英文首页。种子页本身就是英文时
181    /// 不找:`english.washington.edu`(英语系)以前被当成英文版入口拿保底分,英文站点上的
182    /// 计算机方向一路先抓英语系。
183    fn seeking_english(&self) -> bool {
184        self.english() && !self.english_home && !self.english_seed
185    }
186
187    fn english_target(&self) -> bool {
188        self.english() && (self.english_home || self.english_seed)
189    }
190
191    /// 记下抓到了本站的英文版首页(见 [`Self::is_language_portal`])。
192    pub fn found_english_home(&mut self) {
193        self.english_home = true;
194    }
195
196    /// 记下种子页的主机(见 [`Self::same_host`])。
197    pub fn with_host(mut self, host: &str) -> Self {
198        self.host = Some(host.trim_start_matches("www.").to_ascii_lowercase());
199        self
200    }
201
202    /// URL 在种子页的主机上(`www.` 不算区别);没记主机时都算。
203    fn same_host(&self, url: &Url) -> bool {
204        match (&self.host, url.host_str()) {
205            (Some(h), Some(u)) => u.trim_start_matches("www.").eq_ignore_ascii_case(h),
206            _ => true,
207        }
208    }
209
210    /// 这个 URL 是本站英文版的入口:本站主机上的 `/en/`、锚文本是 "English" 的链接,
211    /// 或者同一站点的英文子域名(`en.`、`eng.`、`english.`)的首页。别的子域名(院系子站)的
212    /// `/en/` 不算,那是子站自己的英文版。
213    fn english_entry(&self, url: &Url, anchor: Option<&str>) -> bool {
214        if self.same_host(url) {
215            return language_portal(url, anchor);
216        }
217        let host = url.host_str().unwrap_or_default().to_ascii_lowercase();
218        let same_site = self
219            .host
220            .as_deref()
221            .is_none_or(|h| pagesift_content::site_key(h) == pagesift_content::site_key(&host));
222        let english_label = matches!(
223            host.split('.').next().unwrap_or_default(),
224            "en" | "eng" | "english"
225        );
226        let root = url.path().split('/').filter(|s| !s.is_empty()).count() == 0;
227        same_site && english_label && root
228    }
229
230    /// 记下一页(正文命中的词、各方面的证据),调整词权重和本站统计量。抓到的每一页都要记
231    /// (包括无用页:正是它们说明哪些词常见)。
232    pub fn observe_page(&mut self, page: &PageSignal) {
233        self.weights.observe(page.body);
234        self.facets.observe(&page.facets);
235    }
236
237    /// 页面和查询的相关度,0 到 1:标题和正文各占一半;枢纽页(链接标签以外的正文比链接少)和
238    /// 家具页打折。`links` 是页面上的链接数。
239    #[cfg(test)]
240    pub fn page_relevance(
241        &self,
242        url: &Url,
243        title: Option<&str>,
244        markdown: &str,
245        links: usize,
246    ) -> f32 {
247        self.relevance(&self.page_signal(url, title, markdown, links, None))
248    }
249
250    /// 相关度的组成(见 [`Self::page_relevance`]):词权重变了以后按它重算。
251    pub fn page_signal(
252        &self,
253        url: &Url,
254        title: Option<&str>,
255        markdown: &str,
256        links: usize,
257        lang: Option<&str>,
258    ) -> PageSignal {
259        let mut factor = 1.0;
260        if furniture::is_hub(links, prose_words(markdown)) {
261            factor *= furniture::HUB_FACTOR;
262        }
263        if furniture::is_furniture_url(url, &self.wanted()) {
264            factor *= furniture::FURNITURE_FACTOR;
265        }
266        let title = title.unwrap_or_default();
267        PageSignal {
268            title: self.query.hit_mask(title),
269            body: self.query.hit_mask(markdown),
270            facets: Facets::of(&self.query, title, &locator(url), markdown),
271            factor,
272            fixed: 0.0,
273            foreign: lang.is_some_and(|l| !is_english(l)),
274        }
275    }
276
277    pub fn relevance(&self, p: &PageSignal) -> f32 {
278        // 只有给定值的信号(链接的父页面未知、测试)没有页面可看
279        if p.title == 0 && p.body == 0 && p.facets.is_empty() {
280            return p.fixed;
281        }
282        p.fixed + p.factor * self.facets.relevance(&p.facets)
283    }
284
285    /// 词权重和上次重新打分时比变得够多了(变化最大的词差 [`RESCORE_DELTA`] 以上):
286    /// 排队链接的分数要按新权重重算。返回 true 时记下现在的权重。
287    pub fn weights_moved(&mut self) -> bool {
288        let now: Vec<f32> = self
289            .weights
290            .current()
291            .iter()
292            .copied()
293            .chain(self.facets.shares())
294            .collect();
295        let moved = now
296            .iter()
297            .zip(&self.scored_with)
298            .any(|(a, b)| (a - b).abs() > RESCORE_DELTA);
299        if moved {
300            self.scored_with = now;
301        }
302        moved
303    }
304
305    /// 一个词是不是查询要找的:查 "api"、"events" 时,`/api/`、`/events/` 不是家具。
306    fn wanted(&self) -> impl Fn(&str) -> bool {
307        let query = &self.query;
308        move |w: &str| query.hit_mask(w) != 0
309    }
310
311    /// 一个链接的分数。`parent` 是所在页面的相关度,`depth` 是链接目标的深度。
312    pub fn link_score(
313        &self,
314        url: &Url,
315        anchor: Option<&str>,
316        context: Option<&str>,
317        parent: f32,
318        depth: usize,
319    ) -> f32 {
320        self.score(&self.link_signal(url, anchor, context, PageSignal::fixed(parent), depth))
321    }
322
323    /// 链接分数的组成(见 [`Self::score`]):命中了哪些词、所在页面、URL 的形状和深度。
324    /// 存下它,词权重变了以后不用再看一遍页面就能重算分数。
325    pub fn link_signal(
326        &self,
327        url: &Url,
328        anchor: Option<&str>,
329        context: Option<&str>,
330        parent: PageSignal,
331        depth: usize,
332    ) -> LinkSignal {
333        let wanted = self.wanted();
334        let furniture = furniture::is_furniture_url(url, &wanted)
335            || anchor.is_some_and(|a| furniture::is_furniture_anchor(a, &wanted));
336        let shape = if furniture::is_dated_url(url) {
337            // 按日期归档的文章是叶子:再切题也通不到别处
338            Shape::Factor(furniture::DATED_FACTOR)
339        } else if furniture {
340            Shape::Factor(furniture::FURNITURE_FACTOR)
341        } else if self.english() && foreign_language_segment(url) {
342            Shape::Factor(FOREIGN_LANGUAGE_FACTOR)
343        } else if self.seeking_english() && self.english_entry(url, anchor) {
344            Shape::Floor(LANGUAGE_PORTAL_SCORE)
345        } else {
346            // 本站的一级栏目拿栏目的保底分;别的主机上的(子站的根或它的一级栏目)拿子站的
347            match section(url, anchor) {
348                Some(Section::SameHost) if self.same_host(url) => Shape::Floor(SECTION_PRIOR),
349                Some(_) => Shape::Floor(SUBSITE_PRIOR),
350                None => Shape::Plain,
351            }
352        };
353        LinkSignal {
354            anchor: self.query.hit_mask(anchor.unwrap_or_default()),
355            locator: self.query.hit_mask(&locator(url)),
356            context: self.query.hit_mask(context.unwrap_or_default()),
357            parent,
358            shape,
359            depth: depth.min(64) as u8,
360            factor: 1.0,
361        }
362    }
363
364    /// 链接的分数:锚文本、URL、所在行按当前词权重的覆盖率,加上所在页面的相关度,
365    /// 按 URL 的形状打折或给保底分,再按深度衰减。
366    pub fn score(&self, s: &LinkSignal) -> f32 {
367        let raw: f32 = self
368            .features(s)
369            .iter()
370            .zip(self.model.weights())
371            .map(|(x, w)| x * w)
372            .sum();
373        let raw = match s.shape {
374            Shape::Plain => raw,
375            Shape::Factor(f) => raw * f,
376            Shape::Floor(_) if s.parent.foreign && self.english_target() => raw,
377            Shape::Floor(f) => raw.max(f),
378            Shape::Fixed(v) => return v * s.factor,
379        };
380        raw * DEPTH_DECAY.powi(i32::from(s.depth)) * s.factor
381    }
382
383    /// 链接分数的五个线性项(锚文本、URL、所在行的覆盖率,所在页面的相关度,词组指示),
384    /// 乘上 [`model`] 的权重相加就是打折和保底之前的分数。
385    fn features(&self, s: &LinkSignal) -> [f32; model::FEATURES] {
386        [
387            self.weights.coverage(s.anchor),
388            self.weights.coverage(s.locator),
389            self.weights.coverage(s.context),
390            self.relevance(&s.parent),
391            if s.anchor.count_ones() >= 2 { 1.0 } else { 0.0 },
392        ]
393    }
394
395    /// 记下一个链接抓回来的页面是否相关,更新链接权重。权重变了时返回 true,
396    /// 排队的链接要按新权重重新打分。直接给定分数的链接(种子页、hreflang)没有特征,不学。
397    pub fn learn(&mut self, s: &LinkSignal, hit: bool) -> bool {
398        if matches!(s.shape, Shape::Fixed(_)) {
399            return false;
400        }
401        let x = self.features(s);
402        self.model.observe(&x, hit)
403    }
404
405    /// 这一页是不是本站英文版的首页(`/en/`、`/en.html`、`/top/en/`),而且是第一个:
406    /// 它是第二个首页,链接不多衰减一层(它下面的 `/en/studies/` 按一级栏目算,见 [`section`])。
407    pub fn is_language_portal(&self, url: &Url) -> bool {
408        self.seeking_english() && self.english_entry(url, None)
409    }
410
411    /// 种子页用 hreflang 声明的英文版的分数(英文查询、还没找到英文首页时):和英文入口一样,
412    /// 深度不加(同一页的另一种语言);别的语言、别的页面声明的都是 None(每一页都声明自己的
413    /// 英文版,跟着走就成了按语言重抓一遍)。
414    pub fn alternate_score(&self, lang: &str, depth: usize) -> Option<f32> {
415        let english = lang == "en" || lang.starts_with("en-");
416        (self.seeking_english() && english && depth == 0).then_some(LANGUAGE_PORTAL_SCORE)
417    }
418
419    /// 查询词全是 ASCII(英文)。
420    fn english(&self) -> bool {
421        self.query.terms().iter().all(|t| t.is_ascii())
422    }
423}
424
425#[cfg(test)]
426mod tests {
427    use super::*;
428
429    fn u(s: &str) -> Url {
430        Url::parse(s).unwrap()
431    }
432
433    #[test]
434    fn relevant_links_score_higher_and_depth_decays() {
435        let f = Focus::new("refund policy");
436        let good = f.link_score(
437            &u("https://e.com/help/refund-policy"),
438            Some("Refund policy"),
439            None,
440            0.0,
441            1,
442        );
443        let ctx = f.link_score(
444            &u("https://e.com/help/x"),
445            Some("here"),
446            Some("Read our refund policy here"),
447            0.0,
448            1,
449        );
450        let bad = f.link_score(&u("https://e.com/team/x"), Some("Our team"), None, 0.0, 1);
451        assert!(good > ctx && ctx > bad, "{good} {ctx} {bad}");
452        assert_eq!(bad, 0.0);
453        // 栏目入口(一层路径、短锚文本)有保底分:先探栏目,再碰带日期的新闻里带一个查询词的
454        let section = f.link_score(&u("https://e.com/docs/"), Some("Docs"), None, 0.0, 1);
455        let dated = f.link_score(
456            &u("https://e.com/blog/2026/04/refund-story/"),
457            Some("A refund story"),
458            None,
459            0.0,
460            1,
461        );
462        assert!(section > dated && dated > 0.0, "{section} {dated}");
463        assert!(section < ctx, "{section} {ctx}");
464        // 子站的根排在本站栏目之后;别的语言的入口和家具一样
465        let subsite = f.link_score(&u("https://inf.e.com/"), Some("Informatics"), None, 0.0, 1);
466        let german = f.link_score(&u("https://e.com/de/"), Some("Deutsch"), None, 0.0, 1);
467        assert!(
468            subsite < section && subsite > german,
469            "{section} {subsite} {german}"
470        );
471        assert!(f.alternate_score("en-gb", 0).is_some());
472        assert!(f.alternate_score("de", 0).is_none());
473        assert!(
474            f.alternate_score("en", 1).is_none(),
475            "only the seed's alternate"
476        );
477        // 英文版下面的一级栏目按栏目算
478        let en_section = f.link_score(
479            &u("https://e.com/en/studies/"),
480            Some("Studies"),
481            None,
482            0.0,
483            1,
484        );
485        assert!(
486            (en_section - section).abs() < 1e-6,
487            "{en_section} {section}"
488        );
489        let english_section = f.link_score(
490            &u("https://e.com/english/research/"),
491            Some("Research"),
492            None,
493            0.0,
494            1,
495        );
496        assert!(
497            (english_section - section).abs() < 1e-6,
498            "{english_section} {section}"
499        );
500        assert!(f.is_language_portal(&u("https://e.com/en/")));
501        assert!(f.is_language_portal(&u("https://e.com/en.html")));
502        assert!(!f.is_language_portal(&u("https://e.com/en/docs/")));
503        // 记了种子主机以后:别的主机的 /en/ 是子站的根,不是本站的英文入口
504        let g = Focus::new("api authentication").with_host("www.e.com");
505        let own = g.link_score(&u("https://e.com/en/"), Some("English"), None, 0.0, 1);
506        let dept = g.link_score(&u("https://cs.e.com/en/"), Some("English"), None, 0.0, 1);
507        assert!(own > dept && dept < section, "{own} {dept} {section}");
508        assert!(!g.is_language_portal(&u("https://cs.e.com/en/")));
509        // 同一站点的英文子域名首页是英文入口
510        assert!(g.is_language_portal(&u("https://en.e.com/")));
511        assert!(!g.is_language_portal(&u("https://en.other.com/")));
512        assert!(!g.is_language_portal(&u("https://en.e.com/news/")));
513        // 抓到英文首页以后,别的英文入口不再拿保底分
514        let mut g = g;
515        g.found_english_home();
516        let later = g.link_score(
517            &u("https://e.com/staff/en.html"),
518            Some("English"),
519            None,
520            0.0,
521            1,
522        );
523        assert!(later < own, "{later} {own}");
524        assert!(!g.is_language_portal(&u("https://e.com/en/")));
525        // 查询要找的词不算家具:查 api 时 /docs/api/ 是正经链接
526        let api = Focus::new("api authentication");
527        let reference = api.link_score(
528            &u("https://e.com/docs/api/"),
529            Some("API reference"),
530            None,
531            0.0,
532            2,
533        );
534        let blog = api.link_score(
535            &u("https://e.com/blog/api-news/"),
536            Some("API news"),
537            None,
538            0.0,
539            2,
540        );
541        assert!(reference > 2.0 * blog, "{reference} {blog}");
542        let deep = f.link_score(
543            &u("https://e.com/help/refund-policy"),
544            Some("Refund policy"),
545            None,
546            0.0,
547            3,
548        );
549        assert!(deep < good);
550        let parent = f.link_score(&u("https://e.com/team/x"), Some("Our team"), None, 1.0, 1);
551        assert!(parent > bad);
552        let home = u("https://e.com/");
553        assert!(
554            f.page_relevance(&home, Some("Refund policy"), "refund", 3)
555                > f.page_relevance(&home, None, "refund", 3)
556        );
557    }
558
559    #[test]
560    fn furniture_and_hubs_are_demoted() {
561        let f = Focus::new("international students admission");
562        let content = f.link_score(
563            &u("https://e.edu/admissions/international/"),
564            Some("International students"),
565            None,
566            0.2,
567            2,
568        );
569        let news = f.link_score(
570            &u("https://e.edu/news/2026/05/story-1/"),
571            Some("International students celebrate the festival"),
572            None,
573            0.2,
574            1,
575        );
576        assert!(content > news, "{content} {news}");
577        let about = f.link_score(&u("https://e.edu/about/"), Some("About"), None, 0.9, 1);
578        let study = f.link_score(&u("https://e.edu/study/"), Some("Study"), None, 0.9, 1);
579        assert!(about < study);
580        let home = u("https://e.edu/");
581        let text = "international students admission";
582        let article = f.page_relevance(&home, None, text, 3);
583        let hub = f.page_relevance(&home, None, text, 40);
584        // 正文全是带查询词的链接标签(文章列表):也是枢纽页
585        let list = "- [International students admission](https://e.edu/a)\n- [International students admission](https://e.edu/b)\n- [International students admission](https://e.edu/c)\n- [International students admission](https://e.edu/d)\n- [International students admission](https://e.edu/e)\n- [International students admission](https://e.edu/f)\n- [International students admission](https://e.edu/g)\n- [International students admission](https://e.edu/h)\n";
586        assert!(f.page_relevance(&home, None, list, 8) < f.page_relevance(&home, None, list, 0));
587        let dated = f.page_relevance(&u("https://e.edu/news/2026/"), None, text, 3);
588        assert!(hub < article && dated < article, "{article} {hub} {dated}");
589    }
590
591    #[test]
592    fn host_labels_count_and_common_terms_fade() {
593        let mut f = Focus::new("international students visa");
594        let intl = f.link_score(&u("https://international.e.edu/"), None, None, 0.0, 1);
595        let plain = f.link_score(&u("https://www.e.edu/"), None, None, 0.0, 1);
596        assert!(intl > plain && plain == 0.0, "{intl} {plain}");
597        // 每一页都有 students,visa 只在一页出现:后者的权重更高
598        for i in 0..6 {
599            let text = if i == 0 {
600                "students visa"
601            } else {
602                "students and more students"
603            };
604            let page = f.page_signal(&u("https://e.edu/p"), None, text, 0, None);
605            f.observe_page(&page);
606        }
607        let students = f.link_score(&u("https://e.edu/x"), Some("students"), None, 0.0, 1);
608        let visa = f.link_score(&u("https://e.edu/y"), Some("visa"), None, 0.0, 1);
609        assert!(visa > students, "{visa} {students}");
610    }
611}