use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use lru::LruCache;
use serde::Deserialize;
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct LinkUnderstandingConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default = "default_max_links")]
pub max_links_per_turn: usize,
#[serde(default = "default_max_bytes")]
pub max_bytes: usize,
#[serde(default = "default_timeout_ms")]
pub timeout_ms: u64,
#[serde(default = "default_cache_ttl_secs")]
pub cache_ttl_secs: u64,
#[serde(default = "default_deny_hosts")]
pub deny_hosts: Vec<String>,
}
impl Default for LinkUnderstandingConfig {
fn default() -> Self {
Self {
enabled: false,
max_links_per_turn: default_max_links(),
max_bytes: default_max_bytes(),
timeout_ms: default_timeout_ms(),
cache_ttl_secs: default_cache_ttl_secs(),
deny_hosts: default_deny_hosts(),
}
}
}
fn default_max_links() -> usize {
3
}
fn default_max_bytes() -> usize {
1024 * 256 }
fn default_timeout_ms() -> u64 {
8_000
}
fn default_cache_ttl_secs() -> u64 {
600
}
fn default_deny_hosts() -> Vec<String> {
vec![
"localhost".into(),
"127.0.0.1".into(),
"0.0.0.0".into(),
"169.254.0.0".into(), "metadata.google.internal".into(),
]
}
#[derive(Clone)]
struct CacheEntry {
summary: Arc<str>,
inserted_at: Instant,
}
#[derive(Debug, Clone)]
pub struct LinkSummary {
pub url: String,
pub title: Option<String>,
pub body: String,
}
pub struct LinkExtractor {
http: reqwest::Client,
cache: Mutex<LruCache<String, CacheEntry>>,
cache_ttl: Duration,
cache_capacity: usize,
}
const DEFAULT_CACHE_CAPACITY: usize = 256;
impl LinkExtractor {
pub fn new(cfg: &LinkUnderstandingConfig) -> Self {
let http = reqwest::Client::builder()
.timeout(Duration::from_millis(cfg.timeout_ms))
.redirect(reqwest::redirect::Policy::limited(5))
.user_agent("nexo-link-understanding/0.1")
.build()
.unwrap_or_else(|e| {
tracing::warn!(error = %e, "link extractor: reqwest build failed; using default");
reqwest::Client::new()
});
Self {
http,
cache: Mutex::new(LruCache::new(
std::num::NonZeroUsize::new(DEFAULT_CACHE_CAPACITY).expect("cap > 0"),
)),
cache_ttl: Duration::from_secs(cfg.cache_ttl_secs),
cache_capacity: DEFAULT_CACHE_CAPACITY,
}
}
pub fn cache_capacity(&self) -> usize {
self.cache_capacity
}
pub async fn fetch(&self, url: &str, cfg: &LinkUnderstandingConfig) -> Option<LinkSummary> {
if !cfg.enabled {
return None;
}
if !host_allowed(url, &cfg.deny_hosts) {
crate::telemetry::inc_link_fetch("blocked");
return None;
}
if cfg.cache_ttl_secs > 0 {
let mut cache = self.cache.lock().ok()?;
if let Some(entry) = cache.get(url) {
if entry.inserted_at.elapsed() < self.cache_ttl {
crate::telemetry::inc_link_cache(true);
return Some(LinkSummary {
url: url.to_string(),
title: None,
body: entry.summary.to_string(),
});
}
}
crate::telemetry::inc_link_cache(false);
}
let started = std::time::Instant::now();
let resp = match self.http.get(url).send().await {
Ok(r) => r,
Err(e) => {
let result = if e.is_timeout() { "timeout" } else { "error" };
crate::telemetry::inc_link_fetch(result);
crate::telemetry::observe_link_fetch_ms(started.elapsed().as_millis() as u64);
return None;
}
};
if !resp.status().is_success() {
crate::telemetry::inc_link_fetch("error");
crate::telemetry::observe_link_fetch_ms(started.elapsed().as_millis() as u64);
return None;
}
let content_type = resp
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_lowercase();
if !content_type.contains("text/html")
&& !content_type.contains("text/plain")
&& !content_type.is_empty()
{
crate::telemetry::inc_link_fetch("non_html");
crate::telemetry::observe_link_fetch_ms(started.elapsed().as_millis() as u64);
return None;
}
let body = match read_capped(resp, cfg.max_bytes).await {
Ok(b) => b,
Err(_) => {
crate::telemetry::inc_link_fetch("error");
crate::telemetry::observe_link_fetch_ms(started.elapsed().as_millis() as u64);
return None;
}
};
let truncated = body.len() >= cfg.max_bytes;
let extracted = extract_main_text(&body, cfg.max_bytes);
if extracted.is_empty() {
let result = if truncated { "too_big" } else { "non_html" };
crate::telemetry::inc_link_fetch(result);
crate::telemetry::observe_link_fetch_ms(started.elapsed().as_millis() as u64);
return None;
}
if cfg.cache_ttl_secs > 0 {
if let Ok(mut cache) = self.cache.lock() {
cache.put(
url.to_string(),
CacheEntry {
summary: Arc::from(extracted.as_str()),
inserted_at: Instant::now(),
},
);
}
}
crate::telemetry::inc_link_fetch("ok");
crate::telemetry::observe_link_fetch_ms(started.elapsed().as_millis() as u64);
Some(LinkSummary {
url: url.to_string(),
title: extract_title(&body),
body: extracted,
})
}
}
pub fn detect_urls(text: &str, max: usize) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
let mut seen = std::collections::HashSet::new();
let mut i = 0;
let bytes = text.as_bytes();
while i < bytes.len() {
let rest = &text[i..];
let start_https = rest.find("https://");
let start_http = rest.find("http://");
let start = match (start_https, start_http) {
(Some(a), Some(b)) => Some(a.min(b)),
(a, b) => a.or(b),
};
let Some(rel) = start else { break };
let abs_start = i + rel;
let after = &text[abs_start..];
let end = after
.find(|c: char| c.is_whitespace() || c == '<' || c == '>' || c == '"' || c == '\'')
.unwrap_or(after.len());
let mut url = &after[..end];
while let Some(stripped) = url
.strip_suffix(',')
.or_else(|| url.strip_suffix('.'))
.or_else(|| url.strip_suffix(';'))
.or_else(|| url.strip_suffix(':'))
.or_else(|| url.strip_suffix(')'))
.or_else(|| url.strip_suffix(']'))
.or_else(|| url.strip_suffix('}'))
.or_else(|| url.strip_suffix('?'))
.or_else(|| url.strip_suffix('!'))
{
url = stripped;
}
if url.len() > 2048 {
i = abs_start + end;
continue;
}
if seen.insert(url.to_string()) {
out.push(url.to_string());
if out.len() >= max {
break;
}
}
i = abs_start + end;
}
out
}
fn host_allowed(url: &str, deny: &[String]) -> bool {
let after_scheme = url
.strip_prefix("https://")
.or_else(|| url.strip_prefix("http://"))
.unwrap_or(url);
let host = after_scheme
.split(['/', '?', '#'])
.next()
.unwrap_or("")
.split('@')
.next_back()
.unwrap_or("")
.split(':')
.next()
.unwrap_or("")
.to_lowercase();
if host.is_empty() {
return false;
}
!deny.iter().any(|pat| {
host == pat.to_lowercase() || host.ends_with(&format!(".{}", pat.to_lowercase()))
})
}
async fn read_capped(resp: reqwest::Response, cap: usize) -> Result<String, reqwest::Error> {
use futures::stream::StreamExt;
let mut stream = resp.bytes_stream();
let mut buf: Vec<u8> = Vec::with_capacity(cap.min(64 * 1024));
while let Some(chunk) = stream.next().await {
let chunk = chunk?;
let remaining = cap.saturating_sub(buf.len());
if remaining == 0 {
break;
}
let take = remaining.min(chunk.len());
buf.extend_from_slice(&chunk[..take]);
if buf.len() >= cap {
break;
}
}
Ok(String::from_utf8_lossy(&buf).into_owned())
}
pub fn extract_main_text(html: &str, max_bytes: usize) -> String {
let mut cleaned = String::from(html);
for tag in [
"script", "style", "noscript", "head", "nav", "header", "footer", "aside", "form",
"button", "menu", "iframe", "svg", "dialog", "template",
] {
cleaned = strip_block(&cleaned, tag);
}
let cleaned = strip_blocks_by_class_keyword(
&cleaned,
&[
"sidebar",
"side-bar",
"comment",
"advert",
"advertisement",
"share",
"social",
"cookie",
"popup",
"newsletter",
"related-article",
"related-posts",
"navigation",
"breadcrumb",
"promo",
"subscribe",
],
);
let mut buf = String::with_capacity(cleaned.len());
for token in tokenize(&cleaned) {
match token {
Token::Text(s) => buf.push_str(s),
Token::Tag(name) => {
let lname = name.trim_start_matches('/').to_ascii_lowercase();
if matches!(
lname.as_str(),
"p" | "br"
| "div"
| "li"
| "h1"
| "h2"
| "h3"
| "h4"
| "h5"
| "h6"
| "tr"
| "section"
) {
buf.push('\n');
}
}
}
}
let buf = buf
.replace(" ", " ")
.replace("&", "&")
.replace("<", "<")
.replace(">", ">")
.replace(""", "\"")
.replace("'", "'");
let mut out = String::with_capacity(buf.len());
let mut prev_blank = true;
let mut blank_run = 0;
for line in buf.lines() {
let trimmed = line.trim();
if trimmed.is_empty() {
blank_run += 1;
if blank_run <= 1 && !prev_blank {
out.push('\n');
}
continue;
}
blank_run = 0;
if !prev_blank {
out.push('\n');
}
let mut last_space = false;
for c in trimmed.chars() {
if c.is_whitespace() {
if !last_space {
out.push(' ');
}
last_space = true;
} else {
out.push(c);
last_space = false;
}
}
prev_blank = false;
}
let max_chars = max_bytes / 2; if out.chars().count() > max_chars {
out = out.chars().take(max_chars).collect::<String>() + "…";
}
out
}
fn extract_title(html: &str) -> Option<String> {
let lower = html.to_ascii_lowercase();
let start = lower.find("<title")?;
let end_open = lower[start..].find('>')?;
let body_start = start + end_open + 1;
let close = lower[body_start..].find("</title")?;
let raw = &html[body_start..body_start + close];
let trimmed = raw.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.chars().take(160).collect())
}
}
fn strip_blocks_by_class_keyword(html: &str, keywords: &[&str]) -> String {
let lower = html.to_ascii_lowercase();
let mut out = String::with_capacity(html.len());
let mut cursor = 0usize;
while cursor < html.len() {
let Some(open_rel) = lower[cursor..].find('<') else {
out.push_str(&html[cursor..]);
break;
};
let open_abs = cursor + open_rel;
let Some(end_rel) = lower[open_abs..].find('>') else {
out.push_str(&html[cursor..]);
break;
};
let tag_end = open_abs + end_rel + 1;
let tag_chunk = &lower[open_abs..tag_end];
if tag_chunk.starts_with("</") {
out.push_str(&html[cursor..tag_end]);
cursor = tag_end;
continue;
}
let after_lt = &tag_chunk[1..];
let name_end = after_lt
.find(|c: char| c.is_whitespace() || c == '>' || c == '/')
.unwrap_or(after_lt.len());
let tag_name = &after_lt[..name_end];
if tag_name.is_empty() {
out.push_str(&html[cursor..tag_end]);
cursor = tag_end;
continue;
}
let mut matched = false;
for attr in ["class", "id", "role"] {
if let Some(attr_pos) = tag_chunk.find(&format!(" {attr}=")) {
let after = &tag_chunk[attr_pos + attr.len() + 2..];
let quote = after.chars().next().unwrap_or('"');
if quote != '"' && quote != '\'' {
continue;
}
let value_start = 1usize;
let value_end = after[value_start..]
.find(quote)
.map(|p| value_start + p)
.unwrap_or(after.len());
let value = &after[value_start..value_end];
for kw in keywords {
if value.contains(kw) {
matched = true;
break;
}
}
if matched {
break;
}
}
}
if !matched {
out.push_str(&html[cursor..tag_end]);
cursor = tag_end;
continue;
}
out.push_str(&html[cursor..open_abs]);
let close_pat = format!("</{tag_name}");
let open_pat_nested = format!("<{tag_name}");
let mut depth: i32 = 1;
let mut scan = tag_end;
while scan < html.len() && depth > 0 {
let next_close = lower[scan..].find(&close_pat).map(|p| scan + p);
let next_open = lower[scan..].find(&open_pat_nested).map(|p| scan + p);
match (next_open, next_close) {
(Some(o), Some(c)) if o < c => {
let open_end = lower[o..]
.find('>')
.map(|p| o + p + 1)
.unwrap_or(html.len());
depth += 1;
scan = open_end;
}
(_, Some(c)) => {
let close_end = lower[c..]
.find('>')
.map(|p| c + p + 1)
.unwrap_or(html.len());
depth -= 1;
scan = close_end;
}
_ => break,
}
}
cursor = scan;
}
out
}
fn strip_block(html: &str, tag: &str) -> String {
let lower = html.to_ascii_lowercase();
let open_pat = format!("<{tag}");
let close_pat = format!("</{tag}");
let mut out = String::with_capacity(html.len());
let mut cursor = 0;
while cursor < html.len() {
let Some(open_rel) = lower[cursor..].find(&open_pat) else {
out.push_str(&html[cursor..]);
break;
};
let open_abs = cursor + open_rel;
out.push_str(&html[cursor..open_abs]);
let after_open = lower[open_abs..].find('>').map(|p| open_abs + p + 1);
let Some(after) = after_open else { break };
let Some(close_rel) = lower[after..].find(&close_pat) else {
break;
};
let close_abs = after + close_rel;
let close_end = lower[close_abs..]
.find('>')
.map(|p| close_abs + p + 1)
.unwrap_or(html.len());
cursor = close_end;
}
out
}
enum Token<'a> {
Text(&'a str),
Tag(&'a str),
}
fn tokenize(html: &str) -> Vec<Token<'_>> {
let mut out = Vec::new();
let mut cursor = 0;
while cursor < html.len() {
let Some(open) = html[cursor..].find('<') else {
out.push(Token::Text(&html[cursor..]));
break;
};
if open > 0 {
out.push(Token::Text(&html[cursor..cursor + open]));
}
let tag_start = cursor + open + 1;
let Some(close) = html[tag_start..].find('>') else {
break;
};
let tag_end = tag_start + close;
let tag_slice = &html[tag_start..tag_end];
let name_end = tag_slice
.find(|c: char| c.is_whitespace())
.unwrap_or(tag_slice.len());
out.push(Token::Tag(&tag_slice[..name_end]));
cursor = tag_end + 1;
}
out
}
pub fn render_block(summaries: &[LinkSummary]) -> String {
if summaries.is_empty() {
return String::new();
}
let mut out = String::from("# LINK CONTEXT\n\n");
out.push_str(
"The user's message included the following links. The runtime fetched each one and \
extracted a text summary so you can answer with grounded facts. Cite the link if you \
use it; do not invent details that aren't in the summary.\n\n",
);
for (idx, s) in summaries.iter().enumerate() {
out.push_str(&format!("## [{}] {}\n", idx + 1, s.url));
if let Some(title) = s.title.as_deref() {
out.push_str(&format!("Title: {title}\n"));
}
out.push('\n');
out.push_str(&s.body);
out.push_str("\n\n");
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn detect_picks_https_and_http_in_order_dedup() {
let txt = "see https://a.com/x and http://b.com? and https://a.com/x again";
let urls = detect_urls(txt, 10);
assert_eq!(urls, vec!["https://a.com/x", "http://b.com"]);
}
#[test]
fn detect_strips_trailing_punctuation() {
let urls = detect_urls("ok https://example.com/foo, bye.", 10);
assert_eq!(urls, vec!["https://example.com/foo"]);
}
#[test]
fn detect_caps_at_max() {
let urls = detect_urls("https://a.com https://b.com https://c.com https://d.com", 2);
assert_eq!(urls.len(), 2);
assert_eq!(urls[0], "https://a.com");
}
#[test]
fn detect_skips_hostile_long_urls() {
let huge = format!("https://a.com/{}", "x".repeat(3000));
let urls = detect_urls(&format!("look {huge} thanks"), 10);
assert!(urls.is_empty(), "URL > 2048 chars must be dropped");
}
#[test]
fn host_denylist_blocks_localhost_and_metadata() {
let deny = default_deny_hosts();
assert!(!host_allowed("http://localhost:8080/x", &deny));
assert!(!host_allowed("http://127.0.0.1/", &deny));
assert!(!host_allowed("http://metadata.google.internal/x", &deny));
assert!(!host_allowed(
"http://api.metadata.google.internal/x",
&deny
));
assert!(host_allowed("https://example.com/x", &deny));
}
#[test]
fn extract_strips_scripts_and_styles() {
let html = "<html><head><title>T</title></head>\
<body><script>alert(1)</script><p>Hello</p>\
<style>.x{}</style><p>World</p></body></html>";
let out = extract_main_text(html, 4096);
assert!(out.contains("Hello"));
assert!(out.contains("World"));
assert!(!out.contains("alert"));
assert!(!out.contains(".x{}"));
}
#[test]
fn extract_drops_semantic_boilerplate_tags() {
let html = r#"<html><body>
<header>SiteName · Login · Cart</header>
<nav>Home | Blog | Contact</nav>
<main><article>
<h1>The Article</h1>
<p>Real content lives here.</p>
</article></main>
<aside>Related links sidebar noise</aside>
<footer>Copyright 2026 · privacy · cookies</footer>
</body></html>"#;
let out = extract_main_text(html, 4096);
assert!(out.contains("The Article"));
assert!(out.contains("Real content lives here"));
assert!(!out.contains("SiteName"), "stripped <header>");
assert!(!out.contains("Home | Blog"), "stripped <nav>");
assert!(!out.contains("Related links sidebar"), "stripped <aside>");
assert!(!out.contains("Copyright"), "stripped <footer>");
}
#[test]
fn extract_drops_class_marked_sidebars() {
let html = r#"<html><body>
<article><p>Article body.</p></article>
<div class="sidebar widget">Newsletter signup form</div>
<div class="related-articles">More to read</div>
<div id="comments-section">User comments here</div>
</body></html>"#;
let out = extract_main_text(html, 4096);
assert!(out.contains("Article body"));
assert!(!out.contains("Newsletter signup"));
assert!(!out.contains("More to read"));
assert!(!out.contains("User comments here"));
}
#[test]
fn extract_drops_role_navigation_blocks() {
let html = r#"<html><body>
<div role="navigation"><a href=/>Home</a></div>
<p>Main paragraph.</p>
</body></html>"#;
let out = extract_main_text(html, 4096);
assert!(out.contains("Main paragraph"));
assert!(!out.contains("Home"));
}
#[test]
fn extract_keeps_class_when_no_keyword_match() {
let html = r#"<html><body>
<div class="content article-body">The actual article.</div>
<div class="byline">By Author</div>
</body></html>"#;
let out = extract_main_text(html, 4096);
assert!(out.contains("The actual article"));
assert!(out.contains("By Author"));
}
#[test]
fn extract_drops_button_and_form_clutter() {
let html = r#"<html><body>
<form><input/><button>Subscribe</button></form>
<p>Article opener.</p>
<button>Share</button>
</body></html>"#;
let out = extract_main_text(html, 4096);
assert!(out.contains("Article opener"));
assert!(!out.contains("Subscribe"));
assert!(!out.contains("Share"));
}
#[test]
fn extract_title_from_head() {
let html = "<html><head><title>My Page</title></head><body>x</body></html>";
assert_eq!(extract_title(html).as_deref(), Some("My Page"));
}
#[test]
fn extract_handles_missing_title() {
let html = "<html><body>no title here</body></html>";
assert!(extract_title(html).is_none());
}
#[test]
fn render_block_lists_summaries() {
let s = vec![
LinkSummary {
url: "https://a.com".into(),
title: Some("A".into()),
body: "alpha body".into(),
},
LinkSummary {
url: "https://b.com".into(),
title: None,
body: "bravo body".into(),
},
];
let out = render_block(&s);
assert!(out.contains("# LINK CONTEXT"));
assert!(out.contains("[1] https://a.com"));
assert!(out.contains("Title: A"));
assert!(out.contains("alpha body"));
assert!(out.contains("[2] https://b.com"));
assert!(out.contains("bravo body"));
}
#[test]
fn render_block_empty_yields_empty_string() {
assert_eq!(render_block(&[]), "");
}
#[test]
fn config_disabled_by_default() {
let cfg = LinkUnderstandingConfig::default();
assert!(!cfg.enabled);
assert_eq!(cfg.max_links_per_turn, 3);
assert_eq!(cfg.max_bytes, 256 * 1024);
assert!(cfg.deny_hosts.iter().any(|d| d == "localhost"));
}
#[tokio::test]
async fn fetch_skips_when_disabled() {
let cfg = LinkUnderstandingConfig::default(); let ext = LinkExtractor::new(&cfg);
let r = ext.fetch("https://example.com/", &cfg).await;
assert!(r.is_none(), "must short-circuit when disabled");
}
#[tokio::test]
async fn fetch_skips_denylisted_host() {
let cfg = LinkUnderstandingConfig {
enabled: true,
..LinkUnderstandingConfig::default()
};
let ext = LinkExtractor::new(&cfg);
let r = ext.fetch("http://localhost:65530/", &cfg).await;
assert!(r.is_none());
}
}