Skip to main content

pagesift_frontier/frontier/
learn.rs

1//! 一次爬取里学到的东西:按模板的重复内容降级,以及从重复页面对学到的 URL 改写规则。
2
3use super::*;
4
5use pagesift_stat::Delta;
6
7/// 一条改写规则要被几对不同的页面证实才生效。
8///
9/// 单侧二项检验:零假设是"这条规则是巧合",即任意一对页面全文相同的概率是 `p₀`(本次爬取里
10/// 一页和已抓页面全文重复的比例)。只记证实、不记反例(改写后不同的页面对我们看不到),所以 `k`
11/// 次证实的尾概率是 `n = k` 的二项尾 `p₀ᵏ`;`p₀ᵏ ≤ δ` 时拒绝零假设,规则生效。
12/// 需要的证实数 `k* = ⌈ln δ / ln p₀⌉`,至少 1。
13///
14/// `p₀` 的先验(先验包 v0:0.81 的现值):0.81 要两次证实,对应 `p₀ = √δ`(`k* = 2` 的边界);
15/// 0.90 发布版固定用先验。
16pub(super) fn rule_support(_dups: u32, _pages: u32) -> usize {
17    let delta = Delta::DEFAULT;
18    // 0.90 发布版用先验(0.81 的两次证实);按重复率更新未经野外回放验证
19    let p0 = delta.get().sqrt();
20    if p0 <= 0.0 {
21        return 1;
22    }
23    // [推导] 浮点误差:先验恰在边界 p₀² = δ 上,ln δ / ln p₀ 算出 2.0000000000000004 时仍取 2
24    let k = (delta.get().ln() / p0.ln() - 1e-9).ceil();
25    k.max(1.0) as usize
26}
27
28/// 页面内容和已抓页面的关系。
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub enum Seen {
31    New,
32    /// 和之前某页全文相同:那一页的 URL。
33    Duplicate(String),
34    /// 和同一模板下之前的某页只差数字(日历、页码、计数器)。
35    Templated,
36}
37
38#[derive(Default)]
39struct ShapeStats {
40    fetched: u32,
41    repeated: u32,
42    /// 这个模板下各页去掉数字后的正文哈希
43    skeletons: HashSet<u64>,
44}
45
46/// 一次爬取里学到的内容重复情况。内存随抓过的页面数线性增长(每页一个哈希和一个 URL)。
47/// 从重复页面对里学到的 URL 改写规则,都限定在一个主机上。
48#[derive(Debug, Clone, PartialEq, Eq, Hash)]
49enum Rule {
50    /// 去掉这个查询参数(只限这个值)
51    DropPair {
52        host: String,
53        name: String,
54        value: String,
55    },
56    /// 去掉这个查询参数(任何值)
57    DropName { host: String, name: String },
58    /// 路径末尾去掉这一段(如 `index.html`、`more/`),只要前面还剩下 `/`
59    StripSuffix { host: String, suffix: String },
60}
61
62#[derive(Default)]
63struct Rules {
64    /// 每条候选规则被哪些页面(规范写法的哈希)证实过
65    evidence: HashMap<Rule, HashSet<u64>>,
66    active: HashSet<Rule>,
67    /// 同一参数名被证实可以去掉的取值(取值够多时整个参数名生效)
68    values: HashMap<(String, String), HashSet<String>>,
69    /// 生效的后缀规则,按主机(改写时按顺序反复尝试)
70    suffixes: HashMap<String, Vec<String>>,
71}
72
73impl Rules {
74    /// `need` 是 [`rule_support`] 算出的证实数;同一参数名证实了这么多种取值,整个参数名生效。
75    fn support(&mut self, rule: Rule, witness: u64, need: usize) {
76        if self.active.contains(&rule) {
77            return;
78        }
79        let ev = self.evidence.entry(rule.clone()).or_default();
80        ev.insert(witness);
81        if ev.len() >= need {
82            self.evidence.remove(&rule);
83            if let Rule::StripSuffix { host, suffix } = &rule {
84                self.suffixes
85                    .entry(host.clone())
86                    .or_default()
87                    .push(suffix.clone());
88            }
89            if let Rule::DropPair { host, name, value } = &rule {
90                let vals = self.values.entry((host.clone(), name.clone())).or_default();
91                vals.insert(value.clone());
92                if vals.len() >= need.max(2) {
93                    self.active.insert(Rule::DropName {
94                        host: host.clone(),
95                        name: name.clone(),
96                    });
97                }
98            }
99            self.active.insert(rule);
100        }
101    }
102
103    /// `long` 和 `short` 正文完全相同,`short` 是更简短的写法:记下把 `long` 变成 `short` 的规则。
104    fn learn(&mut self, long: &Url, short: &Url, need: usize) {
105        let (Some(host), Some(h2)) = (long.host_str(), short.host_str()) else {
106            return;
107        };
108        if host != h2 || long.scheme() != short.scheme() {
109            return;
110        }
111        let witness = fnv(short.as_str().bytes());
112        let lq: Vec<(String, String)> = long
113            .query_pairs()
114            .map(|(k, v)| (k.into_owned(), v.into_owned()))
115            .collect();
116        let sq: Vec<(String, String)> = short
117            .query_pairs()
118            .map(|(k, v)| (k.into_owned(), v.into_owned()))
119            .collect();
120        if long.path() == short.path() {
121            // 查询参数多了几个(其余完全相同)
122            if lq.len() > sq.len() && sq.iter().all(|p| lq.contains(p)) {
123                for (name, value) in lq.iter().filter(|p| !sq.contains(p)) {
124                    self.support(
125                        Rule::DropPair {
126                            host: host.to_string(),
127                            name: name.clone(),
128                            value: value.clone(),
129                        },
130                        witness,
131                        need,
132                    );
133                }
134            }
135        } else if lq == sq
136            && let Some(suffix) = long.path().strip_prefix(short.path())
137            && short.path().ends_with('/')
138            && !suffix.is_empty()
139        {
140            self.support(
141                Rule::StripSuffix {
142                    host: host.to_string(),
143                    suffix: suffix.to_string(),
144                },
145                witness,
146                need,
147            );
148        }
149    }
150
151    /// 按生效的规则改写 URL;改了返回 true。
152    fn apply(&self, u: &mut Url) -> bool {
153        if self.active.is_empty() {
154            return false;
155        }
156        let Some(host) = u.host_str().map(str::to_string) else {
157            return false;
158        };
159        let mut changed = false;
160        if let Some(suffixes) = self.suffixes.get(&host) {
161            let mut path = u.path().to_string();
162            loop {
163                let before = path.len();
164                for sfx in suffixes {
165                    if path.len() > sfx.len()
166                        && path.ends_with(sfx.as_str())
167                        && path[..path.len() - sfx.len()].ends_with('/')
168                    {
169                        path.truncate(path.len() - sfx.len());
170                    }
171                }
172                if path.len() == before {
173                    break;
174                }
175            }
176            if path != u.path() {
177                u.set_path(&path);
178                changed = true;
179            }
180        }
181        if u.query().is_some() {
182            let pairs: Vec<(String, String)> = u
183                .query_pairs()
184                .map(|(k, v)| (k.into_owned(), v.into_owned()))
185                .collect();
186            let kept: Vec<&(String, String)> = pairs
187                .iter()
188                .filter(|(name, value)| {
189                    !self.active.contains(&Rule::DropName {
190                        host: host.clone(),
191                        name: name.clone(),
192                    }) && !self.active.contains(&Rule::DropPair {
193                        host: host.clone(),
194                        name: name.clone(),
195                        value: value.clone(),
196                    })
197                })
198                .collect();
199            if kept.len() != pairs.len() {
200                if kept.is_empty() {
201                    u.set_query(None);
202                } else {
203                    u.query_pairs_mut()
204                        .clear()
205                        .extend_pairs(kept.iter().map(|(k, v)| (k, v)));
206                }
207                changed = true;
208            }
209        }
210        changed
211    }
212}
213
214#[derive(Default)]
215pub struct Learner {
216    exact: HashMap<u64, String>,
217    rules: Rules,
218    shapes: HashMap<u64, ShapeStats>,
219    /// 全站(本次爬取)记过的页数、其中重复(全文相同或只差数字)的页数、全文相同的页数。
220    pages: u32,
221    repeated: u32,
222    dups: u32,
223    /// 每有一个模板从正常变成降级就加一,队列据此判断要不要重新分层
224    pub(super) generation: u64,
225}
226
227/// 降级的先验规则(先验包 v0:0.81 的现值):模板至少抓了 4 页、重复占 3/4。
228/// [策略] 先验包 v0:0.81 的现值
229const DEMOTE_MIN_FETCHED: u32 = 4;
230/// [策略] 先验包 v0:0.81 的现值
231const DEMOTE_SHARE: f32 = 0.75;
232
233impl ShapeStats {
234    /// 模板降级。
235    ///
236    /// 先验规则是 0.81 的"至少 4 页、重复占 3/4";数据只能让降级**提前**:模板重复率的 Wilson
237    /// 下界高于站点其余部分重复率的 Wilson 上界(Wilson, JASA 22, 1927;两个区间各分 `δ/2`)时,
238    /// 不等满 4 页就降级。只往一个方向更新的理由:降级可逆(降级的 URL 照样会抓,只是排在后面,
239    /// 模板后来抓到新内容还会恢复),晚降级则每多一页都白占一个抓取名额。0.86 试过只用区间比较
240    /// 代替先验规则:站点其余部分本身有不少重复页时上界偏高,陷阱模板降级变晚,野外回放 polite
241    /// 的重复抓取 490 → 587、覆盖 0.719 → 0.703(benchmarks/crawl/run_wild.sh)。
242    /// `rest` 是站点其余部分的 (页数, 重复页数)。
243    fn demoted(&self, _rest: (u32, u32)) -> bool {
244        // 0.90 发布版只用 0.81 的先验规则;区间比较提前降级未经野外回放验证
245        self.fetched >= DEMOTE_MIN_FETCHED
246            && self.repeated as f32 >= DEMOTE_SHARE * self.fetched as f32
247    }
248}
249
250pub(super) fn fnv(bytes: impl Iterator<Item = u8>) -> u64 {
251    let mut h: u64 = 0xcbf2_9ce4_8422_2325;
252    for b in bytes {
253        h ^= u64::from(b);
254        h = h.wrapping_mul(0x0100_0000_01b3);
255    }
256    h
257}
258
259/// 正文的比较用形式:只看字母数字,空白和标点的差异不算。
260fn text_bytes(markdown: &str) -> impl Iterator<Item = u8> + '_ {
261    markdown
262        .bytes()
263        .filter(|b| !b.is_ascii() || b.is_ascii_alphanumeric())
264}
265
266impl Learner {
267    /// 记下抓到的一页,返回它是新内容还是重复。
268    pub fn observe(&mut self, url: &Url, markdown: &str) -> Seen {
269        let exact = fnv(text_bytes(markdown));
270        let skeleton = fnv(text_bytes(markdown).map(|b| if b.is_ascii_digit() { b'0' } else { b }));
271        let need = rule_support(self.dups, self.pages);
272        let key = shape_key(url);
273        let was = self.demoted_key(key);
274        let stats = self.shapes.entry(key).or_default();
275        stats.fetched += 1;
276        self.pages += 1;
277        let seen = if let Some(first) = self.exact.get(&exact) {
278            if let Ok(f) = Url::parse(first) {
279                // 短的那个算规范写法
280                if f.as_str().len() <= url.as_str().len() {
281                    self.rules.learn(url, &f, need);
282                } else {
283                    self.rules.learn(&f, url, need);
284                }
285            }
286            self.dups += 1;
287            Seen::Duplicate(first.clone())
288        } else if !stats.skeletons.insert(skeleton) {
289            Seen::Templated
290        } else {
291            Seen::New
292        };
293        if seen == Seen::New || matches!(seen, Seen::Templated) {
294            self.exact.entry(exact).or_insert_with(|| url.to_string());
295        }
296        if seen != Seen::New {
297            stats.repeated += 1;
298            self.repeated += 1;
299        }
300        if !was && self.demoted_key(key) {
301            self.generation += 1;
302        }
303        seen
304    }
305
306    /// 按学到的改写规则把 URL 改成规范写法;改了返回 true。
307    pub fn rewrite(&self, u: &mut Url) -> bool {
308        self.rules.apply(u)
309    }
310
311    /// 这个 URL 的模板是否已经降级(大多抓到重复内容)。
312    pub fn demoted(&self, url: &Url) -> bool {
313        self.demoted_key(shape_key(url))
314    }
315
316    pub(super) fn demoted_key(&self, key: u64) -> bool {
317        self.shapes.get(&key).is_some_and(|s| {
318            s.demoted((
319                self.pages.saturating_sub(s.fetched),
320                self.repeated.saturating_sub(s.repeated),
321            ))
322        })
323    }
324}