mod kinds;
mod robots;
use std::cmp::Reverse;
use std::collections::{HashMap, HashSet};
use codoseo_core::change::{Change, ChangeKind};
use codoseo_core::check::Severity;
use codoseo_core::output::StopReason;
use codoseo_core::page::PageRecord;
use codoseo_core::snapshot::Snapshot;
use codoseo_core::url::url_hash;
use url::Url;
use kinds::{PrevPage, chain_grew, is_error, ordinal, page_changes, raise, sitemap_shrank};
pub use robots::robots_fingerprint;
const TOP_PAGES: usize = 20;
#[derive(Debug, Clone, PartialEq, Eq)]
struct OriginKey {
scheme: String,
host: String,
port: Option<u16>,
}
impl OriginKey {
fn of(url: &Url) -> OriginKey {
OriginKey {
scheme: url.scheme().to_owned(),
host: url.host_str().unwrap_or("").to_owned(),
port: url.port_or_known_default(),
}
}
fn contains(&self, url: &Url) -> bool {
url.scheme() == self.scheme
&& url.host_str() == Some(self.host.as_str())
&& url.port_or_known_default() == self.port
}
}
fn rebase(url: &Url, from: &OriginKey, to: &Url) -> Url {
if !from.contains(url) {
return url.clone();
}
let mut moved = to.clone();
moved.set_path(url.path());
moved.set_query(url.query());
moved.set_fragment(None);
moved
}
pub fn diff(prev: &Snapshot, curr: &Snapshot, key_pages: &HashSet<u64>) -> Vec<Change> {
let prev_origin = OriginKey::of(&prev.origin);
let curr_origin = OriginKey::of(&curr.origin);
let moved = prev_origin != curr_origin;
let origin_hash = url_hash(&curr.origin);
let is_key = |hash: u64| hash == origin_hash || key_pages.contains(&hash);
let mut changes = Vec::new();
if moved {
changes.push(Change {
kind: ChangeKind::SiteMoved,
severity: Severity::Critical,
url: None,
before: prev.origin.as_str().to_owned(),
after: curr.origin.as_str().to_owned(),
});
}
let prev_pages: HashMap<u64, PrevPage<'_>> = prev
.pages
.iter()
.filter(|p| prev_origin.contains(&p.url))
.map(|rec| {
let (url, canonical) = if moved {
(
rebase(&rec.url, &prev_origin, &curr.origin),
rec.fields
.canonical
.as_ref()
.map(|c| rebase(c, &prev_origin, &curr.origin)),
)
} else {
(rec.url.clone(), rec.fields.canonical.clone())
};
let hash = url_hash(&url);
(
hash,
PrevPage {
rec,
url,
canonical,
},
)
})
.collect();
let curr_pages: HashMap<u64, &PageRecord> = curr
.pages
.iter()
.filter(|p| curr_origin.contains(&p.url))
.map(|p| (url_hash(&p.url), p))
.collect();
let mut page_change = |hash: u64, kind, severity, url: &Url, before, after| {
changes.push(Change {
kind,
severity: if is_key(hash) {
raise(severity)
} else {
severity
},
url: Some(url.clone()),
before,
after,
});
};
let mut newly_failing = 0usize;
for (hash, page) in &curr_pages {
match prev_pages.get(hash) {
Some(old) => {
if let Some((kind, severity, before, after)) = chain_grew(old.rec, page) {
page_change(*hash, kind, severity, &page.url, before, after);
}
if !moved && old.rec.key_hash == page.key_hash {
continue;
}
if is_error(page) && !is_error(old.rec) {
newly_failing += 1;
}
for (kind, severity, before, after) in page_changes(old, page) {
page_change(*hash, kind, severity, &page.url, before, after);
}
}
None if prev.stop.is_complete() => {
if is_error(page) {
newly_failing += 1;
}
page_change(
*hash,
ChangeKind::NewUrl,
Severity::Notice,
&page.url,
String::new(),
page.status.to_string(),
);
}
None => {}
}
}
if curr.stop.is_complete() {
for (hash, old) in &prev_pages {
if !curr_pages.contains_key(hash) {
page_change(
*hash,
ChangeKind::RemovedUrl,
Severity::Notice,
&old.url,
old.rec.status.to_string(),
String::new(),
);
}
}
}
if newly_failing >= error_threshold(curr_pages.len()) {
changes.push(Change {
kind: ChangeKind::ErrorSpike,
severity: Severity::Critical,
url: None,
before: prev_pages
.values()
.filter(|p| is_error(p.rec))
.count()
.to_string(),
after: curr_pages
.values()
.filter(|p| is_error(p))
.count()
.to_string(),
});
}
let newly_blocked =
curr.stop == StopReason::RobotsBlocked && prev.stop != StopReason::RobotsBlocked;
changes.extend(robots::robots_change(
prev.robots.as_ref(),
curr.robots.as_ref(),
newly_blocked,
));
if sitemap_shrank(&prev.sitemap, &curr.sitemap) {
changes.push(Change {
kind: ChangeKind::SitemapShrank,
severity: Severity::Warning,
url: None,
before: prev.sitemap.url_count.to_string(),
after: curr.sitemap.url_count.to_string(),
});
}
changes.sort_by_cached_key(|c| {
(
c.severity,
ordinal(c.kind),
c.url.as_ref().map(|u| u.as_str().to_owned()),
)
});
changes
}
fn error_threshold(pages: usize) -> usize {
3.max((pages * 2).div_ceil(100))
}
pub fn key_pages(snap: &Snapshot, starred: &HashSet<u64>) -> HashSet<u64> {
let origin = OriginKey::of(&snap.origin);
let mut ranked: Vec<&PageRecord> = snap
.pages
.iter()
.filter(|p| origin.contains(&p.url))
.collect();
ranked.sort_by(|a, b| {
(Reverse(a.inlinks), a.url.as_str()).cmp(&(Reverse(b.inlinks), b.url.as_str()))
});
let mut keys = starred.clone();
keys.insert(url_hash(&snap.origin));
keys.extend(ranked.iter().take(TOP_PAGES).map(|p| url_hash(&p.url)));
keys
}