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