use crate::facade::{LinkIndex, PageChange, PageOutcome, RefreshReport, UpdateReport};
use graph_r::{DocRecord, EdgeType, Outcome, Result, SegmentRecord, Store, Touch, UrlKey};
use std::collections::HashMap;
pub use graph_r::fsio::now_ms;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct BridgeReport {
pub upserted: usize,
pub touched: usize,
pub tombstoned: usize,
pub edges: usize,
pub segments: usize,
}
fn gk(k: crate::UrlKey) -> UrlKey {
UrlKey(k.raw())
}
fn seg_importance(depth: u8, ordinal: usize, heading: &str, keywords: &[&str]) -> u16 {
let base = match depth {
0 | 1 => 1.0f32,
2 => 0.72,
_ => 0.5,
};
let pos = 1.0 / (1.0 + 0.15 * ordinal as f32);
let mut toks = 0u32;
let mut hits = 0u32;
graph_r::text::for_each_term(heading, |t| {
toks += 1;
if keywords.iter().any(|k| k.eq_ignore_ascii_case(t)) {
hits += 1;
}
});
let kd = if toks == 0 {
0.0
} else {
hits as f32 / toks as f32
};
((base * pos * (1.0 + kd)) * 32_767.0).clamp(1.0, 65_535.0) as u16
}
pub fn absorb<E: crate::Embedder>(store: &Store, index: &LinkIndex<E>) -> Result<BridgeReport> {
let mut report = BridgeReport::default();
let mut w = store.writer()?;
for doc in index.export().map_err(|e| map_linkr(&e))? {
let m = doc.meta;
w.upsert_node(&DocRecord {
url: &m.url,
url_key: gk(m.url_key),
content_hash: m.content_hash,
fetched_at_ms: m.fetched_at_ms,
title: m.title.as_deref(),
snippet: (!m.snippet.is_empty()).then_some(m.snippet.as_str()),
etag: m.etag.as_deref(),
pinned: m.pinned,
})?;
let edges: Vec<(UrlKey, EdgeType, u16)> = doc
.edges
.iter()
.map(|&e| (gk(e), EdgeType::Link, 65_535))
.collect();
report.edges += edges.len();
w.set_edges(gk(m.url_key), &edges);
report.upserted += 1;
}
w.commit()?;
drop(w);
store.compact()?;
Ok(report)
}
pub fn ingest_update<E: crate::Embedder>(
store: &Store,
index: &LinkIndex<E>,
report: &UpdateReport,
) -> Result<BridgeReport> {
ingest_pages(store, index, &report.pages)
}
pub fn ingest_refresh<E: crate::Embedder>(
store: &Store,
index: &LinkIndex<E>,
report: &RefreshReport,
) -> Result<BridgeReport> {
ingest_pages(store, index, &report.pages)
}
#[must_use]
pub fn canonical(url: &str) -> String {
crate::Url::parse(url).map_or_else(|_| url.to_owned(), |u| crate::canonicalize(&u))
}
#[must_use]
pub fn key_of(url: &str) -> UrlKey {
UrlKey::of(&canonical(url))
}
fn map_linkr(e: &crate::Error) -> graph_r::Error {
graph_r::Error::format(format!("link-r: {e}"))
}
#[derive(Clone, Debug, Default)]
pub struct CrawlSeed {
pub validators: Vec<(crate::UrlKey, compact_str::CompactString)>,
pub known_edges: Vec<(crate::UrlKey, Vec<crate::Url>)>,
}
#[must_use]
pub fn crawl_seed(store: &Store) -> CrawlSeed {
let snap = store.snapshot();
let mut seed = CrawlSeed::default();
let mut keys: Vec<UrlKey> = Vec::new();
snap.for_each_live(|n| {
if let Some(etag) = n.etag {
seed.validators.push((crate::UrlKey(n.key.0), etag.into()));
}
keys.push(n.key);
});
for key in keys {
let mut children = Vec::new();
let mut it = snap.neighbors(key);
while let Some(e) = graph_r::traverse::LendingIterator::next(&mut it) {
if let Some(u) = e.dst_url.and_then(|u| crate::Url::parse(u).ok()) {
children.push(u);
}
}
if !children.is_empty() {
seed.known_edges.push((crate::UrlKey(key.0), children));
}
}
seed
}
#[allow(clippy::too_many_lines)] fn ingest_pages<E: crate::Embedder>(
store: &Store,
index: &LinkIndex<E>,
pages: &[PageOutcome],
) -> Result<BridgeReport> {
let mut report = BridgeReport::default();
if pages.is_empty() {
return Ok(report);
}
let now = now_ms();
let mut w = store.writer()?;
let mut structural = false;
let wanted: HashMap<u64, &PageOutcome> = pages
.iter()
.filter(|p| matches!(p.change, PageChange::Added | PageChange::Updated))
.map(|p| (p.url_key.raw(), p))
.collect();
let committed_hashes: HashMap<u64, u64> = {
let snap = store.snapshot();
wanted
.keys()
.filter_map(|&k| snap.node(UrlKey(k)).map(|n| (k, n.content_hash)))
.collect()
};
if !wanted.is_empty() {
for doc in index.export().map_err(|e| map_linkr(&e))? {
let Some(page) = wanted.get(&doc.meta.url_key.raw()) else {
continue;
};
let m = doc.meta;
let key = UrlKey(m.url_key.raw());
let changed = page.change == PageChange::Updated
|| committed_hashes
.get(&key.0)
.is_some_and(|&h| h != m.content_hash);
structural |= changed || !committed_hashes.contains_key(&key.0);
w.upsert_node(&DocRecord {
url: &m.url,
url_key: key,
content_hash: m.content_hash,
fetched_at_ms: m.fetched_at_ms,
title: m.title.as_deref(),
snippet: (!m.snippet.is_empty()).then_some(m.snippet.as_str()),
etag: m.etag.as_deref(),
pinned: m.pinned,
})?;
let keywords: Vec<&str> = page
.keywords
.iter()
.map(compact_str::CompactString::as_str)
.collect();
let segs: Vec<SegmentRecord<'_>> = page
.headings
.iter()
.enumerate()
.map(|(i, (depth, h))| SegmentRecord {
label: h.as_str(),
byte_range: None,
depth: *depth,
importance: seg_importance(*depth, i, h, &keywords),
})
.collect();
report.segments += segs.len();
w.set_segments(key, &segs)?;
let graph_edges: Vec<(UrlKey, EdgeType, u16)> = doc
.edges
.iter()
.map(|&e| (gk(e), EdgeType::Link, 65_535))
.collect();
report.edges += graph_edges.len();
w.set_edges(key, &graph_edges);
report.upserted += 1;
if changed {
if w.touch(
key,
Touch {
checked_at_ms: now,
outcome: Outcome::Changed,
content_hash: Some(m.content_hash),
etag: m.etag.as_deref(),
},
)? {
report.touched += 1;
}
} else if committed_hashes.contains_key(&key.0)
&& w.touch(
key,
Touch {
checked_at_ms: now,
outcome: Outcome::Unchanged,
content_hash: None,
etag: None,
},
)?
{
report.touched += 1;
}
}
}
for page in pages {
let key = UrlKey(page.url_key.raw());
match page.change {
PageChange::Unchanged => {
if w.touch(
key,
Touch {
checked_at_ms: now,
outcome: Outcome::Unchanged,
content_hash: None,
etag: None,
},
)? {
report.touched += 1;
}
}
PageChange::Removed => {
if w.touch(
key,
Touch {
checked_at_ms: now,
outcome: Outcome::Gone,
content_hash: None,
etag: None,
},
)? {
report.tombstoned += 1;
}
}
PageChange::Added | PageChange::Updated | PageChange::Skipped => {}
}
}
w.commit()?;
drop(w);
if structural {
store.compact()?;
}
Ok(report)
}
#[must_use]
pub fn due_urls(store: &Store, now_ms: u64, max: usize) -> Vec<String> {
let snap = store.snapshot();
snap.due(now_ms, max)
.into_iter()
.map(|d| d.url.to_owned())
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn seg_importance_orders_by_depth_position_and_keywords() {
let h1 = seg_importance(1, 0, "Install", &[]);
let h2 = seg_importance(2, 0, "Install", &[]);
let h1_late = seg_importance(1, 5, "Install", &[]);
let h1_kw = seg_importance(1, 0, "Install", &["install"]);
assert!(h1 > h2, "H1 above H2");
assert!(h1 > h1_late, "earlier beats later");
assert!(h1_kw > h1, "keyword hit boosts");
}
}