Skip to main content

codoseo_diff/
lib.rs

1//! Change detection between two crawls of the same site.
2//!
3//! Pages are matched by the hash of their URL, recomputed here rather than trusted from
4//! the stored `url_hash`, and only pages on the snapshot's origin count. When the
5//! origin itself changed, the earlier URLs are moved onto the new origin first, so a move
6//! shows up as one `SiteMoved` and not as every page removed and added.
7
8mod kinds;
9mod robots;
10
11use std::cmp::Reverse;
12use std::collections::{HashMap, HashSet};
13
14use codoseo_core::change::{Change, ChangeKind};
15use codoseo_core::check::Severity;
16use codoseo_core::output::StopReason;
17use codoseo_core::page::PageRecord;
18use codoseo_core::snapshot::Snapshot;
19use codoseo_core::url::url_hash;
20use url::Url;
21
22use kinds::{PrevPage, chain_grew, is_error, ordinal, page_changes, raise, sitemap_shrank};
23pub use robots::robots_fingerprint;
24
25/// Pages from the top of the inlink ranking that count as key pages.
26const TOP_PAGES: usize = 20;
27
28/// Scheme, host and port of a URL: what makes two URLs the same site origin.
29#[derive(Debug, Clone, PartialEq, Eq)]
30struct OriginKey {
31    scheme: String,
32    host: String,
33    port: Option<u16>,
34}
35
36impl OriginKey {
37    fn of(url: &Url) -> OriginKey {
38        OriginKey {
39            scheme: url.scheme().to_owned(),
40            host: url.host_str().unwrap_or("").to_owned(),
41            port: url.port_or_known_default(),
42        }
43    }
44
45    fn contains(&self, url: &Url) -> bool {
46        url.scheme() == self.scheme
47            && url.host_str() == Some(self.host.as_str())
48            && url.port_or_known_default() == self.port
49    }
50}
51
52/// `url` with its path and query on `to`'s origin, if it is on `from`'s origin.
53fn rebase(url: &Url, from: &OriginKey, to: &Url) -> Url {
54    if !from.contains(url) {
55        return url.clone();
56    }
57    let mut moved = to.clone();
58    moved.set_path(url.path());
59    moved.set_query(url.query());
60    moved.set_fragment(None);
61    moved
62}
63
64/// Compares two crawls and returns the changes, sorted by severity, then kind, then URL.
65/// Changes to pages in `key_pages` (and to the origin page) are one severity step higher.
66pub fn diff(prev: &Snapshot, curr: &Snapshot, key_pages: &HashSet<u64>) -> Vec<Change> {
67    let prev_origin = OriginKey::of(&prev.origin);
68    let curr_origin = OriginKey::of(&curr.origin);
69    let moved = prev_origin != curr_origin;
70    let origin_hash = url_hash(&curr.origin);
71    let is_key = |hash: u64| hash == origin_hash || key_pages.contains(&hash);
72
73    let mut changes = Vec::new();
74    if moved {
75        changes.push(Change {
76            kind: ChangeKind::SiteMoved,
77            severity: Severity::Critical,
78            url: None,
79            before: prev.origin.as_str().to_owned(),
80            after: curr.origin.as_str().to_owned(),
81        });
82    }
83
84    let prev_pages: HashMap<u64, PrevPage<'_>> = prev
85        .pages
86        .iter()
87        .filter(|p| prev_origin.contains(&p.url))
88        .map(|rec| {
89            let (url, canonical) = if moved {
90                (
91                    rebase(&rec.url, &prev_origin, &curr.origin),
92                    rec.fields
93                        .canonical
94                        .as_ref()
95                        .map(|c| rebase(c, &prev_origin, &curr.origin)),
96                )
97            } else {
98                (rec.url.clone(), rec.fields.canonical.clone())
99            };
100            let hash = url_hash(&url);
101            (
102                hash,
103                PrevPage {
104                    rec,
105                    url,
106                    canonical,
107                },
108            )
109        })
110        .collect();
111    let curr_pages: HashMap<u64, &PageRecord> = curr
112        .pages
113        .iter()
114        .filter(|p| curr_origin.contains(&p.url))
115        .map(|p| (url_hash(&p.url), p))
116        .collect();
117
118    let mut page_change = |hash: u64, kind, severity, url: &Url, before, after| {
119        changes.push(Change {
120            kind,
121            severity: if is_key(hash) {
122                raise(severity)
123            } else {
124                severity
125            },
126            url: Some(url.clone()),
127            before,
128            after,
129        });
130    };
131
132    let mut newly_failing = 0usize;
133    for (hash, page) in &curr_pages {
134        match prev_pages.get(hash) {
135            Some(old) => {
136                // The chain length is not in the key hash, so it is always checked.
137                if let Some((kind, severity, before, after)) = chain_grew(old.rec, page) {
138                    page_change(*hash, kind, severity, &page.url, before, after);
139                }
140                // Across a move the key hash can't be trusted to be comparable, so compare fields.
141                if !moved && old.rec.key_hash == page.key_hash {
142                    continue;
143                }
144                if is_error(page) && !is_error(old.rec) {
145                    newly_failing += 1;
146                }
147                for (kind, severity, before, after) in page_changes(old, page) {
148                    page_change(*hash, kind, severity, &page.url, before, after);
149                }
150            }
151            None if prev.stop.is_complete() => {
152                if is_error(page) {
153                    newly_failing += 1;
154                }
155                page_change(
156                    *hash,
157                    ChangeKind::NewUrl,
158                    Severity::Notice,
159                    &page.url,
160                    String::new(),
161                    page.status.to_string(),
162                );
163            }
164            None => {}
165        }
166    }
167    if curr.stop.is_complete() {
168        for (hash, old) in &prev_pages {
169            if !curr_pages.contains_key(hash) {
170                page_change(
171                    *hash,
172                    ChangeKind::RemovedUrl,
173                    Severity::Notice,
174                    &old.url,
175                    old.rec.status.to_string(),
176                    String::new(),
177                );
178            }
179        }
180    }
181
182    if newly_failing >= error_threshold(curr_pages.len()) {
183        changes.push(Change {
184            kind: ChangeKind::ErrorSpike,
185            severity: Severity::Critical,
186            url: None,
187            before: prev_pages
188                .values()
189                .filter(|p| is_error(p.rec))
190                .count()
191                .to_string(),
192            after: curr_pages
193                .values()
194                .filter(|p| is_error(p))
195                .count()
196                .to_string(),
197        });
198    }
199
200    let newly_blocked =
201        curr.stop == StopReason::RobotsBlocked && prev.stop != StopReason::RobotsBlocked;
202    changes.extend(robots::robots_change(
203        prev.robots.as_ref(),
204        curr.robots.as_ref(),
205        newly_blocked,
206    ));
207
208    if sitemap_shrank(&prev.sitemap, &curr.sitemap) {
209        changes.push(Change {
210            kind: ChangeKind::SitemapShrank,
211            severity: Severity::Warning,
212            url: None,
213            before: prev.sitemap.url_count.to_string(),
214            after: curr.sitemap.url_count.to_string(),
215        });
216    }
217
218    changes.sort_by_cached_key(|c| {
219        (
220            c.severity,
221            ordinal(c.kind),
222            c.url.as_ref().map(|u| u.as_str().to_owned()),
223        )
224    });
225    changes
226}
227
228/// At least 3 pages, or 2% of the pages on the origin, whichever is more.
229fn error_threshold(pages: usize) -> usize {
230    3.max((pages * 2).div_ceil(100))
231}
232
233/// The pages whose changes matter more: the origin page, the 20 pages with the most
234/// inlinks (ties broken by URL) and the starred ones.
235pub fn key_pages(snap: &Snapshot, starred: &HashSet<u64>) -> HashSet<u64> {
236    let origin = OriginKey::of(&snap.origin);
237    let mut ranked: Vec<&PageRecord> = snap
238        .pages
239        .iter()
240        .filter(|p| origin.contains(&p.url))
241        .collect();
242    ranked.sort_by(|a, b| {
243        (Reverse(a.inlinks), a.url.as_str()).cmp(&(Reverse(b.inlinks), b.url.as_str()))
244    });
245
246    let mut keys = starred.clone();
247    keys.insert(url_hash(&snap.origin));
248    keys.extend(ranked.iter().take(TOP_PAGES).map(|p| url_hash(&p.url)));
249    keys
250}