Skip to main content

pagesift_frontier/focus/
url_shape.rs

1//! 从 URL 和锚文本看出链接的形状:栏目入口、子站的根、语言段和英文版入口,以及 URL 里
2//! 可以当文字看的部分。链接打分([`super::Focus`])按这些形状给保底分或降权。
3
4use url::Url;
5
6#[derive(PartialEq, Eq, Debug)]
7pub(super) enum Section {
8    /// 本站的一级栏目(`/docs/`、`/services/`)。
9    SameHost,
10    /// 别的主机的根(子站首页)。
11    Subsite,
12}
13
14/// 栏目入口:路径一段的是栏目,路径为空的是子站的根;锚文本都要短(1 到 4 个词)。
15/// 开头的语言段不算(`/en/studies/`、`/english/research/` 是英文版的一级栏目)。
16pub(super) fn section(url: &Url, anchor: Option<&str>) -> Option<Section> {
17    let mut segs: Vec<&str> = url.path().split('/').filter(|s| !s.is_empty()).collect();
18    if segs
19        .first()
20        .is_some_and(|s| is_language_code(s) || is_language_name(s))
21    {
22        segs.remove(0);
23    }
24    let segments = segs.len();
25    let short = anchor.is_some_and(|a| (1..=4).contains(&a.split_whitespace().count()));
26    if !short || url.query().is_some() {
27        return None;
28    }
29    match segments {
30        0 => Some(Section::Subsite),
31        1 => Some(Section::SameHost),
32        _ => None,
33    }
34}
35
36/// 路径里有别的语言的代码段(`/de/`、`/ja/`、`/zh-cn/`):英文查询不该进去。
37pub(super) fn foreign_language_segment(url: &Url) -> bool {
38    const CODES: &[&str] = &[
39        "de", "fr", "es", "it", "nl", "pt", "ru", "ja", "jp", "zh", "zh-cn", "zh-tw", "zh-hans",
40        "zh-hant", "ko", "kr", "ar", "sv", "fi", "da", "no", "nb", "pl", "tr", "cs", "el", "he",
41        "hi", "th", "vi", "id", "hu", "ro", "uk", "ca", "eu", "gl", "cy", "ga", "mi",
42    ];
43    url.path()
44        .split('/')
45        .filter(|s| !s.is_empty())
46        .any(|seg| CODES.contains(&language_code(seg).as_str()))
47}
48
49/// 通往英文版的入口:锚文本是 "English"/"EN" 之类,或者路径里有 `/en/`、`/english/` 一段。
50pub(super) fn language_portal(url: &Url, anchor: Option<&str>) -> bool {
51    let anchor = anchor.unwrap_or_default().trim().to_ascii_lowercase();
52    if matches!(
53        anchor.as_str(),
54        "english"
55            | "en"
56            | "eng"
57            | "in english"
58            | "english site"
59            | "english version"
60            | "english page"
61            | "english top"
62            | "english home"
63    ) {
64        return true;
65    }
66    // 只认英文版的入口本身(`/en/`、`/top/en/`、`/en.html`),不认它下面的每一页(那些按自己的内容打分)
67    url.path()
68        .split('/')
69        .rfind(|seg| !seg.is_empty())
70        .is_some_and(|seg| {
71            matches!(
72                language_code(seg).as_str(),
73                "en" | "english" | "en-us" | "en-gb"
74            )
75        })
76}
77
78/// 路径段是英文版的名字(`english`、`eng`):和语言代码一样是语言段。
79pub(super) fn is_language_name(seg: &str) -> bool {
80    matches!(language_code(seg).as_str(), "english" | "eng")
81}
82
83/// 语言标签是英文(`en`、`en-US`)。
84pub(super) fn is_english(lang: &str) -> bool {
85    let primary = lang.split(['-', '_']).next().unwrap_or_default();
86    primary.eq_ignore_ascii_case("en")
87}
88
89/// 路径段是语言代码:两个字母,或者 `xx-yy`(可以带扩展名)。
90pub(super) fn is_language_code(seg: &str) -> bool {
91    let code = language_code(seg);
92    let two = |p: &str| p.len() == 2 && p.bytes().all(|b| b.is_ascii_alphabetic());
93    two(&code)
94        || code
95            .split_once('-')
96            .is_some_and(|(a, b)| two(a) && (2..=4).contains(&b.len()))
97}
98
99/// 路径段当语言代码看:去掉扩展名,小写(`en.html` → `en`)。
100fn language_code(seg: &str) -> String {
101    seg.split_once('.')
102        .map_or(seg, |(s, _)| s)
103        .to_ascii_lowercase()
104}
105
106/// URL 里可以当文字看的部分:主机名的标签(`iss.example.edu` 的 iss)、路径段和查询串。
107/// 子域名常常就是栏目名(international、admissions、iss),路径同理。
108pub(super) fn locator(url: &Url) -> String {
109    let mut s = String::with_capacity(url.as_str().len());
110    if let Some(host) = url.host_str() {
111        for label in host.split('.') {
112            if label != "www" {
113                s.push_str(label);
114                s.push(' ');
115            }
116        }
117    }
118    s.push_str(url.path());
119    if let Some(q) = url.query() {
120        s.push(' ');
121        s.push_str(q);
122    }
123    s
124}
125
126/// 网址的“形状”:同一模板生成的页面(`/item/123`、`/item/456`)形状相同。路径段全是数字的写成
127/// `{n}`,含数字的写成 `{id}`,其余照原样;查询串只留参数名(排序)。结构化抽取缓存 LLM 生成的
128/// schema 时用它作键的一部分(0.97)。
129pub fn shape(url: &Url) -> String {
130    let mut out = String::new();
131    for seg in url.path().split('/').filter(|s| !s.is_empty()) {
132        out.push('/');
133        if seg.bytes().all(|b| b.is_ascii_digit()) {
134            out.push_str("{n}");
135        } else if seg.bytes().any(|b| b.is_ascii_digit()) {
136            out.push_str("{id}");
137        } else {
138            out.push_str(seg);
139        }
140    }
141    let mut keys: Vec<String> = url.query_pairs().map(|(k, _)| k.into_owned()).collect();
142    keys.sort();
143    keys.dedup();
144    if !keys.is_empty() {
145        out.push('?');
146        out.push_str(&keys.join("&"));
147    }
148    if out.is_empty() {
149        out.push('/');
150    }
151    out
152}