use reqwest::Client;
use serde::{Deserialize, Serialize};
use url::Url;
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct MachineAffordances {
#[serde(skip_serializing_if = "Option::is_none")]
pub llms_txt: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ai_catalog: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub openapi_spec: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub mcp_endpoint: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ax_score: Option<f64>,
}
impl MachineAffordances {
#[must_use]
pub fn has_affordances(&self) -> bool {
self.llms_txt.is_some()
|| self.ai_catalog.is_some()
|| self.openapi_spec.is_some()
|| self.mcp_endpoint.is_some()
}
}
pub struct MetadataSniffer {
client: Client,
}
impl MetadataSniffer {
#[must_use]
pub fn new(client: Client) -> Self {
Self { client }
}
pub async fn sniff_domain(&self, base_url: &str) -> MachineAffordances {
let Ok(parsed) = Url::parse(base_url) else {
return MachineAffordances::default();
};
let origin = match (parsed.scheme(), parsed.host_str(), parsed.port()) {
(scheme, Some(host), Some(port)) => format!("{scheme}://{host}:{port}"),
(scheme, Some(host), None) => format!("{scheme}://{host}"),
_ => return MachineAffordances::default(),
};
let mut affordances = MachineAffordances::default();
if let Ok(full_url) = Url::parse(&format!("{origin}/llms-full.txt"))
&& self.probe_endpoint(&full_url).await
{
affordances.llms_txt = Some(full_url.to_string());
} else if let Ok(url) = Url::parse(&format!("{origin}/llms.txt"))
&& self.probe_endpoint(&url).await
{
affordances.llms_txt = Some(url.to_string());
}
let catalog_candidates = [
format!("{origin}/.well-known/ai-catalog.json"),
format!("{origin}/.well-known/ai-catelog.json"),
format!("{origin}/ai-catalog.json"),
format!("{origin}/ai-catelog.json"),
];
for cand in &catalog_candidates {
if let Ok(url) = Url::parse(cand)
&& self.probe_endpoint(&url).await
{
affordances.ai_catalog = Some(url.to_string());
break;
}
}
if let Ok(url) = Url::parse(&format!("{origin}/openapi.json"))
&& self.probe_endpoint(&url).await
{
affordances.openapi_spec = Some(url.to_string());
}
affordances
}
async fn probe_endpoint(&self, url: &Url) -> bool {
let resp = self
.client
.head(url.as_str())
.timeout(std::time::Duration::from_millis(1500))
.send()
.await;
if let Ok(res) = resp
&& res.status().is_success()
{
return true;
}
let get_resp = self
.client
.get(url.as_str())
.header(reqwest::header::RANGE, "bytes=0-1024")
.timeout(std::time::Duration::from_millis(1500))
.send()
.await;
if let Ok(res) = get_resp {
res.status().is_success() || res.status() == reqwest::StatusCode::PARTIAL_CONTENT
} else {
false
}
}
pub async fn fetch_manifest_content(&self, url_str: &str) -> Option<String> {
let resp = self
.client
.get(url_str)
.timeout(std::time::Duration::from_millis(3500))
.send()
.await
.ok()?;
if resp.status().is_success() {
let body = resp.text().await.ok()?;
if !body.trim().is_empty() && !body.to_ascii_lowercase().contains("<html") {
return Some(body);
}
}
None
}
}