use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use serde_json::Value;
use swink_agent::{AgentTool, AgentToolResult, ToolFuture};
use tokio::sync::Mutex;
use tracing::warn;
use url::Url;
use crate::domain::DomainFilter;
use crate::playwright::{ExtractOutput, ExtractionPreset, PlaywrightBridge, PlaywrightError};
use crate::policy::ContentSanitizerPolicy;
use crate::tools::{
OperationOutcome, await_with_cancellation, sanitize_web_tool_text, validate_url_against_filter,
};
struct ExtractRequest {
url: String,
selector: Option<String>,
preset: Option<ExtractionPreset>,
}
pub struct ExtractTool {
bridge: Arc<Mutex<Option<PlaywrightBridge>>>,
playwright_path: Option<PathBuf>,
timeout: Duration,
domain_filter: Option<DomainFilter>,
sanitizer: Option<ContentSanitizerPolicy>,
schema: Value,
}
impl ExtractTool {
pub fn new(
bridge: Arc<tokio::sync::Mutex<Option<PlaywrightBridge>>>,
playwright_path: Option<PathBuf>,
timeout: Duration,
) -> Self {
let schema = serde_json::json!({
"type": "object",
"properties": {
"url": {
"type": "string",
"description": "The URL to extract content from. Must be http:// or https://."
},
"selector": {
"type": "string",
"description": "A CSS selector to match elements. Mutually exclusive with 'preset'."
},
"preset": {
"type": "string",
"enum": ["links", "headings", "tables"],
"description": "A built-in extraction preset. Mutually exclusive with 'selector'."
}
},
"required": ["url"]
});
Self {
bridge,
playwright_path,
timeout,
domain_filter: None,
sanitizer: Some(ContentSanitizerPolicy::new()),
schema,
}
}
#[must_use]
pub fn with_domain_filter(mut self, filter: DomainFilter) -> Self {
self.domain_filter = Some(filter);
self
}
#[must_use]
pub fn with_sanitizer_enabled(mut self, enabled: bool) -> Self {
self.sanitizer = enabled.then(ContentSanitizerPolicy::new);
self
}
}
impl AgentTool for ExtractTool {
fn name(&self) -> &str {
"extract"
}
fn label(&self) -> &str {
"Extract Web Content"
}
fn description(&self) -> &str {
"Extract structured content from a web page using CSS selectors or presets \
(links, headings, tables). Uses a headless browser for full JavaScript rendering."
}
fn parameters_schema(&self) -> &Value {
&self.schema
}
fn execute(
&self,
_tool_call_id: &str,
params: Value,
cancellation_token: tokio_util::sync::CancellationToken,
_on_update: Option<Box<dyn Fn(AgentToolResult) + Send + Sync>>,
_state: Arc<std::sync::RwLock<swink_agent::SessionState>>,
_credential: Option<swink_agent::ResolvedCredential>,
) -> ToolFuture<'_> {
Box::pin(async move {
if cancellation_token.is_cancelled() {
return AgentToolResult::error("Request cancelled");
}
let request = match parse_extract_params(¶ms) {
Ok(request) => request,
Err(error) => return AgentToolResult::error(error),
};
let parsed_url = match Url::parse(&request.url) {
Ok(url) => url,
Err(error) => return AgentToolResult::error(format!("Invalid URL: {error}")),
};
if let Err(error) =
validate_url_against_filter(self.domain_filter.as_ref(), &parsed_url, "Initial")
{
return AgentToolResult::error(error);
}
let mut guard = tokio::select! {
guard = self.bridge.lock() => guard,
() = cancellation_token.cancelled() => {
return AgentToolResult::error("Request cancelled");
}
};
if guard.is_none() {
let bridge_start = tokio::select! {
result = PlaywrightBridge::start(self.playwright_path.as_deref()) => result,
() = cancellation_token.cancelled() => {
return AgentToolResult::error("Request cancelled");
}
};
match bridge_start {
Ok(b) => *guard = Some(b),
Err(PlaywrightError::NotInstalled) => {
return AgentToolResult::error(
"Playwright/Node.js not found. Install with:\n\
npm install -g playwright && npx playwright install chromium",
);
}
Err(e) => {
return AgentToolResult::error(format!(
"Failed to start Playwright bridge: {e}"
));
}
}
}
let operation = {
let bridge = guard.as_mut().expect("bridge initialized above");
await_with_cancellation(
&cancellation_token,
self.timeout,
bridge.extract(
&request.url,
request.selector.as_deref(),
request.preset,
self.domain_filter.as_ref(),
),
)
.await
};
match operation {
OperationOutcome::Completed(Ok(extraction)) => build_extract_result(
extraction,
self.domain_filter.as_ref(),
self.sanitizer.as_ref(),
),
OperationOutcome::Completed(Err(PlaywrightError::NotInstalled)) => {
AgentToolResult::error(
"Playwright/Node.js not found. Install with:\n\
npm install -g playwright && npx playwright install chromium",
)
}
OperationOutcome::Completed(Err(e)) => {
AgentToolResult::error(format!("Extraction failed: {e}"))
}
OperationOutcome::Cancelled => {
*guard = None;
AgentToolResult::error("Request cancelled")
}
OperationOutcome::TimedOut => {
*guard = None;
AgentToolResult::error(format!("Extraction timed out after {:?}", self.timeout))
}
}
})
}
}
fn build_extract_result(
extraction: ExtractOutput,
domain_filter: Option<&DomainFilter>,
sanitizer: Option<&ContentSanitizerPolicy>,
) -> AgentToolResult {
let ExtractOutput {
elements,
final_url,
} = extraction;
match Url::parse(&final_url) {
Ok(final_url) => {
if let Err(error) = validate_url_against_filter(domain_filter, &final_url, "Final") {
return AgentToolResult::error(error);
}
}
Err(error) => {
return AgentToolResult::error(format!("Browser returned invalid final URL: {error}"));
}
}
if elements.is_empty() {
return AgentToolResult::text("No elements found matching the given criteria.");
}
match serde_json::to_string_pretty(&elements) {
Ok(json) => {
let json = sanitize_web_tool_text("web_extract", json, sanitizer);
AgentToolResult::text(json)
}
Err(e) => {
warn!("Failed to serialize extracted elements: {e}");
AgentToolResult::error(format!("Failed to serialize extraction results: {e}"))
}
}
}
fn parse_extract_params(params: &Value) -> Result<ExtractRequest, String> {
let url = params
.get("url")
.and_then(Value::as_str)
.ok_or_else(|| "Missing required parameter: url".to_owned())?
.to_owned();
let selector = params
.get("selector")
.and_then(Value::as_str)
.map(str::to_owned);
let preset = match params.get("preset").and_then(Value::as_str) {
Some("links") => Some(ExtractionPreset::Links),
Some("headings") => Some(ExtractionPreset::Headings),
Some("tables") => Some(ExtractionPreset::Tables),
Some(other) => {
return Err(format!(
"Unknown preset '{other}'. Valid values: links, headings, tables."
));
}
None => None,
};
if selector.is_some() && preset.is_some() {
return Err(
"Parameters 'selector' and 'preset' are mutually exclusive. Provide one or neither."
.to_owned(),
);
}
Ok(ExtractRequest {
url,
selector,
preset,
})
}