diff --git a/CHANGELOG.md b/CHANGELOG.md index e5b02d3..c1c421c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ ## [Unreleased] +### Fixed + +- `kagi lens list` and related lens commands now parse the current edit links in every account language (#185). +- `kagi usage` now supports plans without AI cost tracking and reads balance and renewal details from Norwegian billing pages (#186). + ## [0.20.0] ### Added diff --git a/docs/content/docs/commands/usage.mdx b/docs/content/docs/commands/usage.mdx index 88477ce..cf37191 100644 --- a/docs/content/docs/commands/usage.mdx +++ b/docs/content/docs/commands/usage.mdx @@ -26,8 +26,8 @@ account configuration before the billing request is sent. The default JSON response contains: - `plan` when Kagi exposes the current plan name -- `ai_cost.used_usd` -- `ai_cost.limit_usd` +- `ai_cost.used_usd` and `ai_cost.limit_usd` when the plan includes AI cost + tracking; otherwise `ai_cost` is `null` - `account_balance_usd` when present - `next_renewal` when present - `usage_period` for the displayed calendar month diff --git a/src/parser.rs b/src/parser.rs index 8dd58b5..2ec1119 100644 --- a/src/parser.rs +++ b/src/parser.rs @@ -277,7 +277,8 @@ pub fn parse_lens_list(html: &str) -> Result, KagiError> { let item_selector = selector("form.__lens_item")?; let name_selector = selector(".lens_title div")?; let description_selector = selector(".lens_desc")?; - let edit_selector = selector(r#".lens_edit_lens a[aria-label="Edit lens"]"#)?; + // The route identifies this control across page layouts and account languages. + let edit_selector = selector(r#"a[href^="/settings/update_lens?id="]"#)?; let mut lenses = Vec::new(); @@ -781,6 +782,27 @@ mod tests { assert_eq!(lenses[1].toggle_field, "next_index"); } + /// Regression: Kagi no longer wraps edit links in `.lens_edit_lens`, and + /// the accessible label follows the account language. + #[test] + fn parses_lens_edit_link_from_its_route() { + let html = r#" +
+ + +
Norske kilder
+ Rediger +
+ "#; + + let lenses = parse_lens_list(html).expect("localized lens list should parse"); + + assert_eq!(lenses.len(), 1); + assert_eq!(lenses[0].edit_url, "/settings/update_lens?id=4248"); + } + #[test] fn parses_lens_form_fields() { let html = r#" diff --git a/src/usage.rs b/src/usage.rs index 3265b44..c43fbe1 100644 --- a/src/usage.rs +++ b/src/usage.rs @@ -13,17 +13,16 @@ use crate::http; const KAGI_BILLING_PATH: &str = "/settings/billing"; const TEXT_CANDIDATE_LIMIT: usize = 512; -const AI_COST_LABEL: &str = "total ai cost this period"; -const AI_USAGE_LABEL: &str = "ai usage (usd)"; -const ACCOUNT_BALANCE_LABEL: &str = "account balance"; -const NEXT_RENEWAL_LABEL: &str = "next renewal is"; +const AI_COST_LABELS: [&str; 2] = ["total ai cost this period", "ai usage (usd)"]; +const ACCOUNT_BALANCE_LABELS: [&str; 2] = ["account balance", "saldo"]; +const NEXT_RENEWAL_LABELS: [&str; 2] = ["next renewal is", "neste fornyelse er"]; /// Account billing and calendar-month usage reported by Kagi. #[derive(Debug, Clone, Serialize, PartialEq)] pub struct UsageReport { #[serde(skip_serializing_if = "Option::is_none")] pub plan: Option, - pub ai_cost: AiCostUsage, + pub ai_cost: Option, #[serde(skip_serializing_if = "Option::is_none")] pub account_balance_usd: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -53,8 +52,7 @@ pub struct DailyUsage { /// # Errors /// /// Returns authentication errors for a missing or expired session token, -/// network errors for failed HTTP requests, and parse errors when Kagi's page -/// no longer exposes the required AI cost values. +/// and network errors for failed HTTP requests. pub async fn execute_usage(session_token: &str) -> Result { let session_token = session_token.trim(); if session_token.is_empty() { @@ -122,10 +120,12 @@ pub fn format_pretty(report: &UsageReport) -> String { if let Some(plan) = report.plan.as_deref() { lines.push(format!("Plan: {plan}")); } - lines.push(format!( - "AI cost: ${:.2} / ${:.2}", - report.ai_cost.used_usd, report.ai_cost.limit_usd - )); + if let Some(ai_cost) = &report.ai_cost { + lines.push(format!( + "AI cost: ${:.2} / ${:.2}", + ai_cost.used_usd, ai_cost.limit_usd + )); + } if let Some(balance) = report.account_balance_usd { lines.push(format!("Account balance: ${balance:.2}")); } @@ -157,22 +157,32 @@ pub fn format_pretty(report: &UsageReport) -> String { fn parse_usage_html(html: &str) -> Result { let document = Html::parse_document(html); let candidates = text_candidates(&document); - let ai_cost = extract_ai_cost(&candidates).ok_or_else(|| { - KagiError::Parse( - "Kagi billing page did not contain the current AI cost and limit; the page layout may have changed" - .to_string(), - ) - })?; - Ok(UsageReport { + let report = UsageReport { plan: extract_plan(&candidates), - ai_cost, - account_balance_usd: extract_first_decimal_after_label(&candidates, ACCOUNT_BALANCE_LABEL), - next_renewal: extract_text_after_label(&candidates, NEXT_RENEWAL_LABEL) + ai_cost: extract_ai_cost(&candidates), + account_balance_usd: extract_first_decimal_after_label( + &candidates, + &ACCOUNT_BALANCE_LABELS, + ), + next_renewal: extract_text_after_label(&candidates, &NEXT_RENEWAL_LABELS) .and_then(|text| extract_iso_date(&text)), usage_period: extract_usage_period(&candidates), daily_usage: parse_daily_usage(&document), - }) + }; + + if report.ai_cost.is_none() + && report.account_balance_usd.is_none() + && report.next_renewal.is_none() + && report.daily_usage.is_empty() + { + return Err(KagiError::Parse( + "Kagi billing page did not contain recognized usage details; the page layout may have changed" + .to_string(), + )); + } + + Ok(report) } /// Collects short, normalized visible-text candidates from the page. @@ -207,8 +217,7 @@ fn text_candidates(document: &Html) -> Vec { /// aggregate into one ancestor candidate. fn extract_ai_cost(candidates: &[String]) -> Option { candidates.iter().find_map(|candidate| { - let tail = slice_after_label(candidate, AI_COST_LABEL) - .or_else(|| slice_after_label(candidate, AI_USAGE_LABEL))?; + let tail = slice_after_label(candidate, &AI_COST_LABELS)?; let values = decimal_values(tail); if values.len() < 2 { return None; @@ -221,25 +230,28 @@ fn extract_ai_cost(candidates: &[String]) -> Option { } /// Extracts the first decimal value following a case-insensitive label. -fn extract_first_decimal_after_label(candidates: &[String], label: &str) -> Option { +fn extract_first_decimal_after_label(candidates: &[String], labels: &[&str]) -> Option { candidates.iter().find_map(|candidate| { - slice_after_label(candidate, label).and_then(|tail| decimal_values(tail).into_iter().next()) + slice_after_label(candidate, labels) + .and_then(|tail| decimal_values(tail).into_iter().next()) }) } /// Extracts non-empty text following a case-insensitive label. -fn extract_text_after_label(candidates: &[String], label: &str) -> Option { +fn extract_text_after_label(candidates: &[String], labels: &[&str]) -> Option { candidates.iter().find_map(|candidate| { - let tail = slice_after_label(candidate, label)?.trim(); + let tail = slice_after_label(candidate, labels)?.trim(); (!tail.is_empty()).then_some(tail.to_string()) }) } -/// Returns the portion of text after a case-insensitive ASCII label. -fn slice_after_label<'a>(text: &'a str, label: &str) -> Option<&'a str> { +/// Returns the portion of text after the first matching case-insensitive label. +fn slice_after_label<'a>(text: &'a str, labels: &[&str]) -> Option<&'a str> { let lower = text.to_ascii_lowercase(); - let offset = lower.find(label)?; - Some(&text[offset + label.len()..]) + labels.iter().find_map(|label| { + let offset = lower.find(label)?; + Some(&text[offset + label.len()..]) + }) } /// Extracts the plan name from a monthly or yearly price description. @@ -534,10 +546,10 @@ mod tests { assert_eq!(report.plan.as_deref(), Some("Ultimate")); assert_eq!( report.ai_cost, - AiCostUsage { + Some(AiCostUsage { used_usd: 0.0, limit_usd: 25.0, - } + }) ); assert_eq!(report.account_balance_usd, Some(5.0)); assert_eq!(report.next_renewal.as_deref(), Some("2026-01-28")); @@ -589,16 +601,48 @@ mod tests { assert_eq!(parse_decimal_token("1.234.567"), Some(1_234_567.0)); } - /// Verifies missing required cost values produce a layout-change error. + /// Regression: Starter plans omit AI cost, and billing labels follow the + /// account language. #[test] - fn reports_layout_changes_when_cost_pair_is_missing() { + fn parses_localized_starter_plan_without_ai_cost() { + let html = r#" + + Starter +
+
Søk
+
3/300
+
+
+
Assistant-interaksjoner
+
8/300
+
+
+
Saldo
+
$0.07
+
+ Neste fornyelse er 2026-09-19 + + "#; + + let report = parse_usage_html(html).expect("localized Starter usage should parse"); + + assert_eq!(report.ai_cost, None); + assert_eq!(report.account_balance_usd, Some(0.07)); + assert_eq!(report.next_renewal.as_deref(), Some("2026-09-19")); + assert!(report.daily_usage.is_empty()); + + let json = serde_json::to_value(&report).expect("usage report should serialize"); + assert!(json["ai_cost"].is_null()); + assert!(!format_pretty(&report).contains("AI cost:")); + } + + /// Keeps changed or unrelated HTML from looking like a valid empty report. + #[test] + fn rejects_pages_without_recognized_billing_details() { let error = parse_usage_html("Billing Details") - .expect_err("missing cost pair should fail"); - assert!( - error - .to_string() - .contains("did not contain the current AI cost and limit") - ); + .expect_err("unrecognized billing page should fail"); + + assert!(error.to_string().contains("recognized usage details")); } /// Regression: the live billing page renders the newer `AI usage (USD)` @@ -623,8 +667,9 @@ mod tests { "#; let report = parse_usage_html(html).expect("current layout should parse"); - assert_eq!(report.ai_cost.used_usd, 12.76); - assert_eq!(report.ai_cost.limit_usd, 20.0); + let ai_cost = report.ai_cost.expect("AI cost should be present"); + assert_eq!(ai_cost.used_usd, 12.76); + assert_eq!(ai_cost.limit_usd, 20.0); assert_eq!(report.plan, None); assert_eq!(report.account_balance_usd, None); assert_eq!(report.next_renewal, None); diff --git a/tests/integration-cli.rs b/tests/integration-cli.rs index 32e2fcf..1d4d1e4 100644 --- a/tests/integration-cli.rs +++ b/tests/integration-cli.rs @@ -645,9 +645,7 @@ fn lens_settings_html_fixture() -> &'static str {
Rust Docs
-
- Edit -
+ Rediger "# }