Skip to main content

pagesift_frontier/frontier/
clean.rs

1//! URL 清洗、抓取前过滤和 URL 模板(固定规则)。
2
3use super::*;
4
5/// 确定是跟踪用途的查询参数(小写比较)。
6const TRACKERS: &[&str] = &[
7    "fbclid",
8    "gclid",
9    "gclsrc",
10    "dclid",
11    "gbraid",
12    "wbraid",
13    "msclkid",
14    "yclid",
15    "ysclid",
16    "twclid",
17    "ttclid",
18    "li_fat_id",
19    "igshid",
20    "igsh",
21    "mc_cid",
22    "mc_eid",
23    "_ga",
24    "_gl",
25    "_hsenc",
26    "_hsmi",
27    "__hstc",
28    "__hssc",
29    "__hsfp",
30    "hsctatracking",
31    "mkt_tok",
32    "oly_anon_id",
33    "oly_enc_id",
34    "vero_id",
35    "vero_conv",
36    "rb_clickid",
37    "s_cid",
38    "_openstat",
39    "srsltid",
40    "wt_mc",
41    "pk_campaign",
42    "pk_kwd",
43    "pk_source",
44    "pk_medium",
45    "pk_content",
46    "spm",
47    "scm",
48];
49/// 以这些前缀开头的查询参数都是跟踪参数。
50/// 带前缀的跟踪参数:utm_*、Matomo 的 mtm_*、内部链接归因的 internal_*、Eloqua 的 elq*。
51const TRACKER_PREFIXES: &[&str] = &["utm_", "mtm_", "itm_", "internal_", "elq"];
52/// 会话 ID 参数。`sid` 太短太常见,只在值像随机 ID 时才算(见 [`is_session`])。
53const SESSIONS: &[&str] = &[
54    "jsessionid",
55    "phpsessid",
56    "sessionid",
57    "session_id",
58    "cfid",
59    "cftoken",
60    "zenid",
61    "oscsid",
62];
63
64/// 转不成文本的文件扩展名:抓了也是白抓。PDF、HTML、纯文本、XML 这类照常抓。
65const BINARY_EXT: &[&str] = &[
66    "jpg", "jpeg", "png", "gif", "webp", "avif", "bmp", "ico", "svg", "tif", "tiff", "heic", "psd",
67    "mp3", "mp4", "m4a", "m4v", "mov", "avi", "wmv", "flv", "mkv", "webm", "wav", "ogg", "oga",
68    "ogv", "flac", "aac", "mid", "midi", "zip", "gz", "tgz", "bz2", "xz", "7z", "rar", "tar",
69    "zst", "iso", "dmg", "exe", "msi", "apk", "ipa", "deb", "rpm", "jar", "woff", "woff2", "ttf",
70    "otf", "eot", "css", "js", "mjs", "map", "wasm", "swf",
71];
72/// 路径最多多少段。
73/// [策略] 安全上限,取 Heritrix TooManyPathSegmentsDecideRule 的默认值 20(Heritrix 3 文档)。
74/// 不从数据学:单页拒绝门槛取分布分位数会按
75/// 比例丢掉正常页面;学到的陷阱由 [`super::Learner`] 的模板降级处理。
76const MAX_SEGMENTS: usize = 20;
77/// [协议] Sitemaps 协议 0.9(sitemaps.org):URL 必须少于 2,048 个字符;更长的 URL 站点自己
78/// 也不会在 sitemap 里列出,多半是越叠越长的相对链接或塞进查询串的状态。
79const MAX_URL_LEN: usize = 2048;
80/// 同一段(或连续几段)路径最多连续出现几次。
81/// [策略] 安全上限,取 Heritrix PathologicalPathDecideRule 的默认值 2(Heritrix 3 文档)。
82const MAX_REPEATS: usize = 2;
83
84fn is_session(name: &str, value: &str) -> bool {
85    if SESSIONS.contains(&name) || name.starts_with("aspsessionid") {
86        return true;
87    }
88    name == "sid" && value.len() >= 16 && value.bytes().all(|b| b.is_ascii_alphanumeric())
89}
90
91fn is_tracker(name: &str) -> bool {
92    TRACKERS.contains(&name) || TRACKER_PREFIXES.iter().any(|p| name.starts_with(p))
93}
94
95/// 清洗一个发现的 URL:去 fragment、路径里的会话参数、跟踪和会话查询参数、路径里多余的 `/`。
96/// 其余查询参数保持原有顺序(排序只用于去重键,见 `crawl::normalize`)。
97pub fn clean(u: &mut Url) {
98    u.set_fragment(None);
99    let path = u.path();
100    let lower = path.to_ascii_lowercase();
101    if lower.contains(";jsessionid=") || lower.contains(";phpsessid=") || path.contains("//") {
102        let raw: Vec<&str> = path.split('/').skip(1).collect();
103        let mut trailing = path.ends_with('/');
104        let mut parts: Vec<&str> = Vec::with_capacity(raw.len());
105        for (i, seg) in raw.iter().enumerate() {
106            let low = seg.to_ascii_lowercase();
107            let seg = match low.find(";jsessionid=").or_else(|| low.find(";phpsessid=")) {
108                Some(c) => &seg[..c],
109                None => seg,
110            };
111            if seg.is_empty() {
112                // `/p/4/;jsessionid=…` 去掉会话参数后是 `/p/4/`
113                trailing |= i + 1 == raw.len();
114                continue;
115            }
116            parts.push(seg);
117        }
118        let mut out = format!("/{}", parts.join("/"));
119        if trailing && !parts.is_empty() {
120            out.push('/');
121        }
122        u.set_path(&out);
123    }
124    if u.query().is_some() {
125        let pairs: Vec<(String, String)> = u
126            .query_pairs()
127            .map(|(k, v)| (k.into_owned(), v.into_owned()))
128            .collect();
129        let kept: Vec<&(String, String)> = pairs
130            .iter()
131            .filter(|(k, v)| {
132                let k = k.to_ascii_lowercase();
133                !is_tracker(&k) && !is_session(&k, v)
134            })
135            .collect();
136        if kept.len() != pairs.len() {
137            if kept.is_empty() {
138                u.set_query(None);
139            } else {
140                u.query_pairs_mut()
141                    .clear()
142                    .extend_pairs(kept.iter().map(|(k, v)| (k, v)));
143            }
144        }
145    }
146}
147
148/// 不值得抓的 URL:二进制文件、路径陷阱、过深过长。
149pub fn skip(u: &Url) -> bool {
150    if u.as_str().len() > MAX_URL_LEN {
151        return true;
152    }
153    let path = u.path();
154    let last = path.rsplit('/').next().unwrap_or_default();
155    if let Some((_, ext)) = last.rsplit_once('.')
156        && ext.len() <= 5
157        && BINARY_EXT.contains(&ext.to_ascii_lowercase().as_str())
158    {
159        return true;
160    }
161    let segs: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
162    segs.len() > MAX_SEGMENTS || repeats(&segs)
163}
164
165/// 有没有一段(或连续 1~3 段)路径连续重复超过 [`MAX_REPEATS`] 次。
166fn repeats(segs: &[&str]) -> bool {
167    for len in 1..=3 {
168        let need = len * (MAX_REPEATS + 1);
169        if segs.len() < need {
170            break;
171        }
172        for start in 0..=segs.len() - need {
173            let unit = &segs[start..start + len];
174            if (1..=MAX_REPEATS).all(|r| &segs[start + r * len..start + (r + 1) * len] == unit) {
175                return true;
176            }
177        }
178    }
179    false
180}
181
182/// URL 模板:主机 + 路径(数字串换成 `#`,含数字的长段换成 `*`)+ 排好序的查询参数名。
183pub fn shape(u: &Url) -> String {
184    let mut s = String::with_capacity(u.as_str().len());
185    s.push_str(u.host_str().unwrap_or_default());
186    for seg in u.path().split('/') {
187        s.push('/');
188        if seg.len() >= 16 && seg.bytes().any(|b| b.is_ascii_digit()) {
189            s.push('*');
190            continue;
191        }
192        let mut in_digits = false;
193        for c in seg.chars() {
194            if c.is_ascii_digit() {
195                if !in_digits {
196                    s.push('#');
197                }
198                in_digits = true;
199            } else {
200                in_digits = false;
201                s.push(c);
202            }
203        }
204    }
205    if u.query().is_some() {
206        let mut keys: Vec<String> = u.query_pairs().map(|(k, _)| k.into_owned()).collect();
207        keys.sort();
208        keys.dedup();
209        s.push('?');
210        s.push_str(&keys.join("&"));
211    }
212    s
213}
214
215/// [`shape`] 的 64 位哈希,队列里每个 URL 带着它,判断降级不用重算模板。
216pub fn shape_key(u: &Url) -> u64 {
217    fnv(shape(u).bytes())
218}