diff --git a/Cargo.lock b/Cargo.lock index f05d75f..d5b0b9a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -693,7 +693,7 @@ checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" [[package]] name = "crw-browse" -version = "0.10.0" +version = "0.11.0" dependencies = [ "anyhow", "async-trait", @@ -723,7 +723,7 @@ dependencies = [ [[package]] name = "crw-cli" -version = "0.10.0" +version = "0.11.0" dependencies = [ "anyhow", "axum", @@ -757,7 +757,7 @@ dependencies = [ [[package]] name = "crw-core" -version = "0.10.0" +version = "0.11.0" dependencies = [ "config", "crw-mcp-proto", @@ -775,7 +775,7 @@ dependencies = [ [[package]] name = "crw-crawl" -version = "0.10.0" +version = "0.11.0" dependencies = [ "crw-core", "crw-diff", @@ -800,7 +800,7 @@ dependencies = [ [[package]] name = "crw-diff" -version = "0.10.0" +version = "0.11.0" dependencies = [ "crw-core", "hex", @@ -815,7 +815,7 @@ dependencies = [ [[package]] name = "crw-extract" -version = "0.10.0" +version = "0.11.0" dependencies = [ "crw-core", "ego-tree", @@ -844,7 +844,7 @@ dependencies = [ [[package]] name = "crw-mcp" -version = "0.10.0" +version = "0.11.0" dependencies = [ "clap", "crw-core", @@ -859,7 +859,7 @@ dependencies = [ [[package]] name = "crw-mcp-proto" -version = "0.10.0" +version = "0.11.0" dependencies = [ "serde", "serde_json", @@ -868,7 +868,7 @@ dependencies = [ [[package]] name = "crw-monitor" -version = "0.10.0" +version = "0.11.0" dependencies = [ "crw-core", "crw-crawl", @@ -891,7 +891,7 @@ dependencies = [ [[package]] name = "crw-renderer" -version = "0.10.0" +version = "0.11.0" dependencies = [ "async-trait", "axum", @@ -918,7 +918,7 @@ dependencies = [ [[package]] name = "crw-search" -version = "0.10.0" +version = "0.11.0" dependencies = [ "crw-core", "futures", @@ -934,7 +934,7 @@ dependencies = [ [[package]] name = "crw-server" -version = "0.10.0" +version = "0.11.0" dependencies = [ "axum", "axum-test", diff --git a/Cargo.toml b/Cargo.toml index 9ce2f1e..2c0aba1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,7 +16,7 @@ members = [ resolver = "2" [workspace.package] -version = "0.10.0" +version = "0.11.0" edition = "2024" license = "AGPL-3.0" repository = "https://github.com/us/crw" diff --git a/crates/crw-browse/Cargo.toml b/crates/crw-browse/Cargo.toml index 880bed5..b7406ea 100644 --- a/crates/crw-browse/Cargo.toml +++ b/crates/crw-browse/Cargo.toml @@ -11,8 +11,8 @@ description = "MCP server for interactive browser automation over CDP" publish = false [dependencies] -crw-core = { path = "../crw-core", version = "0.10.0" } -crw-renderer = { path = "../crw-renderer", version = "0.10.0", features = ["cdp"] } +crw-core = { path = "../crw-core", version = "0.11.0" } +crw-renderer = { path = "../crw-renderer", version = "0.11.0", features = ["cdp"] } rmcp = { version = "1.5", features = ["server", "macros", "transport-io"] } clap = { workspace = true } diff --git a/crates/crw-cli/Cargo.toml b/crates/crw-cli/Cargo.toml index b452ddb..44039bf 100644 --- a/crates/crw-cli/Cargo.toml +++ b/crates/crw-cli/Cargo.toml @@ -28,17 +28,17 @@ browse = ["dep:crw-browse", "dep:rmcp", "dep:anyhow"] [dependencies] # Core crates -crw-core = { path = "../crw-core", version = "0.10.0" } -crw-renderer = { path = "../crw-renderer", version = "0.10.0", features = ["auto-browser", "cdp"] } -crw-extract = { path = "../crw-extract", version = "0.10.0" } -crw-crawl = { path = "../crw-crawl", version = "0.10.0" } -crw-search = { path = "../crw-search", version = "0.10.0" } +crw-core = { path = "../crw-core", version = "0.11.0" } +crw-renderer = { path = "../crw-renderer", version = "0.11.0", features = ["auto-browser", "cdp"] } +crw-extract = { path = "../crw-extract", version = "0.11.0" } +crw-crawl = { path = "../crw-crawl", version = "0.11.0" } +crw-search = { path = "../crw-search", version = "0.11.0" } # Server (for serve + mcp-embedded + setup commands) -crw-server = { path = "../crw-server", version = "0.10.0", optional = true, features = ["cdp"] } +crw-server = { path = "../crw-server", version = "0.11.0", optional = true, features = ["cdp"] } # Browse (for browse command) -crw-browse = { path = "../crw-browse", version = "0.10.0", optional = true } +crw-browse = { path = "../crw-browse", version = "0.11.0", optional = true } # Web framework (for serve command) axum = { workspace = true, optional = true } diff --git a/crates/crw-core/Cargo.toml b/crates/crw-core/Cargo.toml index 13ead64..8d709d9 100644 --- a/crates/crw-core/Cargo.toml +++ b/crates/crw-core/Cargo.toml @@ -19,7 +19,7 @@ description = "Core types, config, and error handling for the CRW web scraper" cdp = [] [dependencies] -crw-mcp-proto = { path = "../crw-mcp-proto", version = "0.10.0" } +crw-mcp-proto = { path = "../crw-mcp-proto", version = "0.11.0" } serde = { workspace = true } serde_json = { workspace = true } toml = { workspace = true } diff --git a/crates/crw-core/src/types.rs b/crates/crw-core/src/types.rs index ca850eb..5012eff 100644 --- a/crates/crw-core/src/types.rs +++ b/crates/crw-core/src/types.rs @@ -322,6 +322,24 @@ pub struct LlmUsage { pub truncated: bool, #[serde(default = "one_u32", skip_serializing_if = "is_one_u32")] pub calls: u32, + + // ── Wave 4 (R1) additions: SaaS billing correlation across legs ── + // + // The SaaS-side managed pricing path needs to know exactly how many + // summary calls executed AND whether the answer leg ran. The 5-branch + // fail-closed dispatch keys off these counters: + // - executedSummaries > 0 OR answerExecuted ⇒ engine did work + // - inputTokens == 0 AND outputTokens == 0 ⇒ no upstream cost + // Without the counters the SaaS cannot disambiguate "no work" from + // "work but missing telemetry" and would refund or charge wrong. + // + // Always serialized (no skip_serializing_if) so the always-present + // R1 invariant holds: when /v1/search returns llmUsage, both fields + // are explicitly visible. + #[serde(default)] + pub executed_summaries: u32, + #[serde(default)] + pub answer_executed: bool, } fn one_u32() -> u32 { diff --git a/crates/crw-crawl/Cargo.toml b/crates/crw-crawl/Cargo.toml index 76eeb63..3844ae0 100644 --- a/crates/crw-crawl/Cargo.toml +++ b/crates/crw-crawl/Cargo.toml @@ -10,10 +10,10 @@ categories.workspace = true description = "Async BFS web crawler with rate limiting and robots.txt support for CRW" [dependencies] -crw-core = { path = "../crw-core", version = "0.10.0" } -crw-diff = { path = "../crw-diff", version = "0.10.0" } -crw-renderer = { path = "../crw-renderer", version = "0.10.0" } -crw-extract = { path = "../crw-extract", version = "0.10.0" } +crw-core = { path = "../crw-core", version = "0.11.0" } +crw-diff = { path = "../crw-diff", version = "0.11.0" } +crw-renderer = { path = "../crw-renderer", version = "0.11.0" } +crw-extract = { path = "../crw-extract", version = "0.11.0" } reqwest = { workspace = true } serde_json = { workspace = true } scraper = { workspace = true } diff --git a/crates/crw-diff/Cargo.toml b/crates/crw-diff/Cargo.toml index 2bf4df5..6d1fe02 100644 --- a/crates/crw-diff/Cargo.toml +++ b/crates/crw-diff/Cargo.toml @@ -13,7 +13,7 @@ description = "Stateless change-tracking diff engine for the CRW web scraper" # Shared types only (ChangeTrackingOptions/Result, DiffAst, etc.). This crate # MUST NOT depend on crw-extract — judging is injected upstream so the diff # engine stays pure (no LLM, no HTTP, no I/O). -crw-core = { path = "../crw-core", version = "0.10.0" } +crw-core = { path = "../crw-core", version = "0.11.0" } serde = { workspace = true } serde_json = { workspace = true } similar = { workspace = true } diff --git a/crates/crw-extract/Cargo.toml b/crates/crw-extract/Cargo.toml index 2765ec3..45f3570 100644 --- a/crates/crw-extract/Cargo.toml +++ b/crates/crw-extract/Cargo.toml @@ -10,7 +10,7 @@ categories.workspace = true description = "HTML extraction and markdown conversion engine for the CRW web scraper" [dependencies] -crw-core = { path = "../crw-core", version = "0.10.0" } +crw-core = { path = "../crw-core", version = "0.11.0" } lol_html = { workspace = true } scraper = { workspace = true } htmd = { workspace = true } diff --git a/crates/crw-extract/src/llm.rs b/crates/crw-extract/src/llm.rs index 0176850..0fe5988 100644 --- a/crates/crw-extract/src/llm.rs +++ b/crates/crw-extract/src/llm.rs @@ -418,6 +418,11 @@ fn parse_anthropic_usage(payload: &serde_json::Value, model: &str) -> Option { + llm_attempted = true; + // Wave 4 (R2): cap max_tokens at SEARCH_LLM_MAX_TOKENS_PER_LEG so + // a single leg can never exceed the SaaS pre-reservation in + // estimateMaxCreditCostForSearch. + let mut leg_cfg = llm.clone(); + leg_cfg.max_tokens = leg_cfg.max_tokens.min(SEARCH_LLM_MAX_TOKENS_PER_LEG); if wants_summaries { - let count = attach_result_summaries( + let (count, usages) = attach_result_summaries( &mut data, - llm, - llm.max_concurrency, + &leg_cfg, + leg_cfg.max_concurrency, req.summary_prompt.as_deref(), req.max_content_chars, ) .await; + agg_executed_summaries = count.ok as u32; + for u in usages.into_iter().flatten() { + merge_usage( + &mut agg_input_tokens, + &mut agg_output_tokens, + &mut agg_cache_hit, + &mut agg_cache_miss, + &mut agg_calls, + &mut agg_provider, + &mut agg_model, + &mut agg_truncated, + &u, + ); + } if count.failed > 0 { warnings.push(format!( "{} of {} per-result summaries failed", @@ -133,14 +198,41 @@ pub async fn search_inner( } } if wants_answer { - match synthesize_answer(&req, &data, llm).await { - Ok((ans, cites, usage, mut ans_warns)) => { + match synthesize_answer(&req, &data, &leg_cfg).await { + Ok((ans, cites, ans_usage, mut ans_warns)) => { warnings.append(&mut ans_warns); + agg_answer_executed = true; + if let Some(ref u) = ans_usage { + merge_usage( + &mut agg_input_tokens, + &mut agg_output_tokens, + &mut agg_cache_hit, + &mut agg_cache_miss, + &mut agg_calls, + &mut agg_provider, + &mut agg_model, + &mut agg_truncated, + u, + ); + } + let aggregated = build_aggregated_usage( + agg_input_tokens, + agg_output_tokens, + agg_cache_hit, + agg_cache_miss, + agg_calls, + agg_executed_summaries, + agg_answer_executed, + agg_provider.clone(), + agg_model.clone(), + agg_truncated, + &leg_cfg, + ); let wrapped = SearchResponseData { results: data, answer: Some(ans), citations: cites, - llm_usage: usage, + llm_usage: Some(aggregated), warnings, }; let mut resp = ApiResponse::ok(wrapped); @@ -157,11 +249,51 @@ pub async fn search_inner( } } + // R1 always-present invariant: if we attempted LLM work, emit the + // aggregated usage even when zero tokens were consumed (e.g. all + // summaries failed and no answer leg ran). The SaaS dispatch maps + // (executedSummaries == 0 && answerExecuted == false && tokens == 0) + // to Branch 1 (no-op refund); anything else routes correctly. + let final_usage = if llm_attempted { + Some(build_aggregated_usage( + agg_input_tokens, + agg_output_tokens, + agg_cache_hit, + agg_cache_miss, + agg_calls, + agg_executed_summaries, + agg_answer_executed, + if agg_provider.is_empty() { + effective_llm + .map(|c| c.provider.clone()) + .unwrap_or_default() + } else { + agg_provider + }, + if agg_model.is_empty() { + effective_llm.map(|c| c.model.clone()).unwrap_or_default() + } else { + agg_model + }, + agg_truncated, + effective_llm + .map(|c| { + let mut c = c.clone(); + c.max_tokens = c.max_tokens.min(SEARCH_LLM_MAX_TOKENS_PER_LEG); + c + }) + .as_ref() + .unwrap_or(&crw_core::config::LlmConfig::default()), + )) + } else { + None + }; + let wrapped = SearchResponseData { results: data, answer: None, citations: Vec::new(), - llm_usage: None, + llm_usage: final_usage, warnings, }; let mut resp = ApiResponse::ok(wrapped); @@ -169,6 +301,52 @@ pub async fn search_inner( Ok(resp) } +#[allow(clippy::too_many_arguments)] +fn build_aggregated_usage( + input_tokens: u32, + output_tokens: u32, + cache_hit: u32, + cache_miss: u32, + calls: u32, + executed_summaries: u32, + answer_executed: bool, + provider: String, + model: String, + truncated: bool, + fallback_cfg: &crw_core::config::LlmConfig, +) -> LlmUsage { + LlmUsage { + input_tokens, + output_tokens, + total_tokens: input_tokens.saturating_add(output_tokens), + estimated_cost_usd: None, + model: if model.is_empty() { + fallback_cfg.model.clone() + } else { + model + }, + provider: if provider.is_empty() { + fallback_cfg.provider.clone() + } else { + provider + }, + cache_hit_input_tokens: if cache_hit == 0 { + None + } else { + Some(cache_hit) + }, + cache_miss_input_tokens: if cache_miss == 0 { + None + } else { + Some(cache_miss) + }, + truncated, + calls: calls.max(1), + executed_summaries, + answer_executed, + } +} + #[derive(Default)] struct SummaryFanoutCount { ok: usize, @@ -177,18 +355,22 @@ struct SummaryFanoutCount { /// Fan-out summary calls across all results that have markdown. Bounded by /// `max_concurrency`. Pattern mirrors `crates/crw-crawl/src/sitemap.rs`. +/// +/// Wave 4 (R1): returns the per-call `Option` for every job +/// alongside the ok/failed count so the caller can aggregate token totals +/// across summaries + answer. async fn attach_result_summaries( data: &mut SearchData, cfg: &LlmConfig, max_concurrency: usize, user_prompt: Option<&str>, max_content_chars: Option, -) -> SummaryFanoutCount { +) -> (SummaryFanoutCount, Vec>) { let targets: &mut Vec = match data { SearchData::Flat(v) => v, SearchData::Grouped(g) => match g.web.as_mut() { Some(v) if !v.is_empty() => v, - _ => return SummaryFanoutCount::default(), + _ => return (SummaryFanoutCount::default(), Vec::new()), }, }; // Capture markdown + index pairs first so we don't hold a borrow of @@ -199,12 +381,13 @@ async fn attach_result_summaries( .filter_map(|(idx, r)| r.markdown.as_ref().map(|md| (idx, md.clone()))) .collect(); if jobs.is_empty() { - return SummaryFanoutCount::default(); + return (SummaryFanoutCount::default(), Vec::new()); } let cfg_owned = cfg.clone(); let user_prompt_owned: Option = user_prompt.map(str::to_owned); let concurrency = max_concurrency.max(1); - let results: Vec<(usize, Result)> = stream::iter(jobs) + type SummaryOutcome = (usize, Result<(String, Option), String>); + let results: Vec = stream::iter(jobs) .map(|(idx, md)| { let cfg = cfg_owned.clone(); let user_prompt = user_prompt_owned.clone(); @@ -212,7 +395,7 @@ async fn attach_result_summaries( let outcome = summary::summarize(&md, &cfg, user_prompt.as_deref(), max_content_chars) .await - .map(|r| r.content) + .map(|r| (r.content, r.usage)) .map_err(|e| e.to_string()); (idx, outcome) } @@ -222,18 +405,23 @@ async fn attach_result_summaries( .await; let mut count = SummaryFanoutCount::default(); + let mut usages: Vec> = Vec::with_capacity(results.len()); for (idx, res) in results { match res { - Ok(text) => { + Ok((text, usage)) => { if let Some(slot) = targets.get_mut(idx) { slot.summary = Some(text); count.ok += 1; + usages.push(usage); } } - Err(_) => count.failed += 1, + Err(_) => { + count.failed += 1; + usages.push(None); + } } } - count + (count, usages) } async fn synthesize_answer( diff --git a/crates/crw-server/tests/search_route.rs b/crates/crw-server/tests/search_route.rs index eaf14ca..53f467d 100644 --- a/crates/crw-server/tests/search_route.rs +++ b/crates/crw-server/tests/search_route.rs @@ -28,6 +28,29 @@ timeout_ms = 5000 TestServer::new(app) } +fn test_app_with_searxng_and_llm(url: &str) -> TestServer { + // Sibling of `test_app_with_searxng` that also wires an `[extraction.llm]` + // leg so provider/model can fall back to server config. `api_key` has no + // serde default (required field) — must be present for the block to parse. + let toml = format!( + r#" +[search] +enabled = true +searxng_url = "{url}" +timeout_ms = 5000 + +[extraction.llm] +provider = "anthropic" +api_key = "sk-test" +model = "claude-sonnet-4-20250514" +"# + ); + let config: AppConfig = toml::from_str(&toml).unwrap(); + let state = AppState::new(config).expect("AppState::new failed"); + let app = create_app(state); + TestServer::new(app) +} + fn test_app_search_disabled() -> TestServer { // Default config has no searxng_url → state.searxng = None. let config: AppConfig = toml::from_str("").unwrap(); @@ -101,6 +124,53 @@ fn searxng_mixed_response() -> Value { }) } +#[tokio::test] +async fn search_llm_usage_always_present_on_zero_results() { + // Wave 4 (R1) invariant: once LLM mode is entered (summarizeResults + + // scrapeOptions present), `/v1/search` ALWAYS returns a non-null + // `llmUsage` object — even with ZERO search results and no LLM call + // actually running. The SaaS 5-branch credit dispatch relies on this. + let mock = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/search")) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(json!({"results": [], "number_of_results": 0})), + ) + .mount(&mock) + .await; + + let server = test_app_with_searxng_and_llm(&mock.uri()); + let resp = server + .post("/v1/search") + .json(&json!({ + "query": "rust async", + "summarizeResults": true, + "scrapeOptions": {"formats": ["markdown"]} + })) + .await; + resp.assert_status_ok(); + let body: Value = resp.json(); + assert_eq!(body["success"], true); + + let usage = &body["data"]["llmUsage"]; + assert!(usage.is_object(), "llmUsage must be present, got: {usage}"); + assert_eq!(usage["executedSummaries"], 0); + assert_eq!(usage["answerExecuted"], false); + assert_eq!(usage["inputTokens"], 0); + assert_eq!(usage["outputTokens"], 0); + assert!( + usage["provider"].is_string(), + "provider must be a string, got: {}", + usage["provider"] + ); + assert!( + usage["model"].is_string(), + "model must be a string, got: {}", + usage["model"] + ); +} + #[tokio::test] async fn search_returns_flat_results() { let mock = MockServer::start().await; diff --git a/npm/crw-mcp-darwin-arm64/package.json b/npm/crw-mcp-darwin-arm64/package.json index 75fdac0..c73a6c9 100644 --- a/npm/crw-mcp-darwin-arm64/package.json +++ b/npm/crw-mcp-darwin-arm64/package.json @@ -1,6 +1,6 @@ { "name": "crw-mcp-darwin-arm64", - "version": "0.10.0", + "version": "0.11.0", "description": "CRW MCP server binary for darwin arm64", "license": "AGPL-3.0", "homepage": "https://github.com/us/crw", diff --git a/npm/crw-mcp-darwin-x64/package.json b/npm/crw-mcp-darwin-x64/package.json index 9dd7bce..0cf0796 100644 --- a/npm/crw-mcp-darwin-x64/package.json +++ b/npm/crw-mcp-darwin-x64/package.json @@ -1,6 +1,6 @@ { "name": "crw-mcp-darwin-x64", - "version": "0.10.0", + "version": "0.11.0", "description": "CRW MCP server binary for darwin x64", "license": "AGPL-3.0", "homepage": "https://github.com/us/crw", diff --git a/npm/crw-mcp-linux-arm64/package.json b/npm/crw-mcp-linux-arm64/package.json index 7390e8c..4dc9306 100644 --- a/npm/crw-mcp-linux-arm64/package.json +++ b/npm/crw-mcp-linux-arm64/package.json @@ -1,6 +1,6 @@ { "name": "crw-mcp-linux-arm64", - "version": "0.10.0", + "version": "0.11.0", "description": "CRW MCP server binary for linux arm64", "license": "AGPL-3.0", "homepage": "https://github.com/us/crw", diff --git a/npm/crw-mcp-linux-x64/package.json b/npm/crw-mcp-linux-x64/package.json index 5f5a837..1f77fb0 100644 --- a/npm/crw-mcp-linux-x64/package.json +++ b/npm/crw-mcp-linux-x64/package.json @@ -1,6 +1,6 @@ { "name": "crw-mcp-linux-x64", - "version": "0.10.0", + "version": "0.11.0", "description": "CRW MCP server binary for linux x64", "license": "AGPL-3.0", "homepage": "https://github.com/us/crw", diff --git a/npm/crw-mcp-win32-arm64/package.json b/npm/crw-mcp-win32-arm64/package.json index d845765..b618dc0 100644 --- a/npm/crw-mcp-win32-arm64/package.json +++ b/npm/crw-mcp-win32-arm64/package.json @@ -1,6 +1,6 @@ { "name": "crw-mcp-win32-arm64", - "version": "0.10.0", + "version": "0.11.0", "description": "CRW MCP server binary for win32 arm64", "license": "AGPL-3.0", "homepage": "https://github.com/us/crw", diff --git a/npm/crw-mcp-win32-x64/package.json b/npm/crw-mcp-win32-x64/package.json index 71346ba..62e80f5 100644 --- a/npm/crw-mcp-win32-x64/package.json +++ b/npm/crw-mcp-win32-x64/package.json @@ -1,6 +1,6 @@ { "name": "crw-mcp-win32-x64", - "version": "0.10.0", + "version": "0.11.0", "description": "CRW MCP server binary for win32 x64", "license": "AGPL-3.0", "homepage": "https://github.com/us/crw", diff --git a/npm/crw-mcp/package.json b/npm/crw-mcp/package.json index 3283fef..48ba1a2 100644 --- a/npm/crw-mcp/package.json +++ b/npm/crw-mcp/package.json @@ -1,6 +1,6 @@ { "name": "crw-mcp", - "version": "0.10.0", + "version": "0.11.0", "description": "MCP server for CRW web scraper — scrape, crawl, and map tools for AI agents", "license": "AGPL-3.0", "homepage": "https://github.com/us/crw", @@ -27,11 +27,11 @@ "skills/SKILL.md" ], "optionalDependencies": { - "crw-mcp-darwin-x64": "0.10.0", - "crw-mcp-darwin-arm64": "0.10.0", - "crw-mcp-linux-x64": "0.10.0", - "crw-mcp-linux-arm64": "0.10.0", - "crw-mcp-win32-x64": "0.10.0", - "crw-mcp-win32-arm64": "0.10.0" + "crw-mcp-darwin-x64": "0.11.0", + "crw-mcp-darwin-arm64": "0.11.0", + "crw-mcp-linux-x64": "0.11.0", + "crw-mcp-linux-arm64": "0.11.0", + "crw-mcp-win32-x64": "0.11.0", + "crw-mcp-win32-arm64": "0.11.0" } } diff --git a/python/pyproject.toml b/python/pyproject.toml index aa3b82b..c13634e 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "crw" -version = "0.10.0" +version = "0.11.0" description = "Python SDK for CRW web scraper — scrape, crawl, and map any website from Python" readme = "README.md" requires-python = ">=3.9" diff --git a/server.json b/server.json index 5e943c6..8149c0f 100644 --- a/server.json +++ b/server.json @@ -3,7 +3,7 @@ "name": "io.github.us/crw", "title": "CRW Web Scraper", "description": "Open-source web scraper for AI agents with scrape, crawl, and map tools", - "version": "0.10.0", + "version": "0.11.0", "websiteUrl": "https://us.github.io/crw", "repository": { "url": "https://github.com/us/crw",