1mod 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
25const TOP_PAGES: usize = 20;
27
28#[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
52fn 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
64pub 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 if let Some((kind, severity, before, after)) = chain_grew(old.rec, page) {
138 page_change(*hash, kind, severity, &page.url, before, after);
139 }
140 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
228fn error_threshold(pages: usize) -> usize {
230 3.max((pages * 2).div_ceil(100))
231}
232
233pub 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}