From 41f35aec34e960537eeb6e10e59e7b0a0ede6a1d Mon Sep 17 00:00:00 2001 From: ocd Date: Thu, 30 Jul 2026 01:16:36 +0300 Subject: [PATCH] Wire real OpenCode selectors and URLs from captured pages Go page (/workspace/{id}/go): parse three [data-slot=usage-item] frames (Rolling/Weekly/Monthly) into usage percentages + computed resets_at from the relative reset-duration text. Zen page (/workspace/{id}): parse [data-slot=balance] b into remaining + currency unit. Add an offline unit test against tmp/ captures. Fix pretty renderer (percent display, money format, day-aware relative times). Update DESIGN.md, README, examples and config template to the real wrk_ workspace ids. --- DESIGN.md | 43 +++++---- README.md | 15 +-- examples/ocd.toml | 4 +- src/client.rs | 11 +-- src/config.rs | 4 +- src/error.rs | 3 - src/fetch.rs | 5 +- src/render/pretty.rs | 11 ++- src/scrape.rs | 213 +++++++++++++++++++++++++++++++++++-------- src/timefmt.rs | 52 ++++++----- 10 files changed, 257 insertions(+), 104 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index 71463c7..ef30b6f 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -3,8 +3,9 @@ `ocd` is a console application. It reads a config file. The file lists opencode accounts and auth cookies. `ocd` scrapes the opencode Zen and Go pages. It returns limits and balance data. Limits cover three time frames: 5 hours, week, -and month. The program reports usage, totals, reset times, and the next update -time. It also reports remaining account balance. +and month. The Go page reports usage as a percentage per frame and a relative +reset time. `ocd` stores the percentage and computes an absolute reset time. +The Zen page reports remaining balance. `ocd` prints all of it. ## Goals @@ -43,17 +44,20 @@ per-workspace. So config carries one `default_workspace` plus an `default_workspace` is a required id. The program fetches it by default. `extra_workspaces` is optional. The program fetches all listed workspaces. -The program cannot auto-discover the default workspace. That path is not -reliable. So the user supplies the id. A future `ocd workspace list` command +The program does not auto-discover the default workspace. That path is not +reliable. The user supplies the id. Workspace ids look like +`wrk_01KYNTSGXF7GFXMAMT36N3GH21`. A future `ocd workspace list` command can add discovery later. ## Two pages per workspace -Each workspace needs two HTTP requests. +Each workspace needs two HTTP requests. Each request puts the workspace id +in the path. + +1. The Zen page is `https://opencode.ai/workspace/{id}`. It returns balance. +2. The Go page is `https://opencode.ai/workspace/{id}/go`. It returns limits. -1. The Zen page returns balance. -2. The Go page returns limits. The program fires both at once per workspace. It uses `tokio::join`. Then it waits about 500 ms. Then it moves to the next workspace. The workspace walk is @@ -69,9 +73,10 @@ Each time frame resets on its own rule. - Week: calendar based. It resets every Monday. - Month: calendar based. It resets 31 days from the subscription date. -The model keeps one `resets_at` field per frame. The scrape step reads this from -the page when present. Calendar resets can fall back to a computed value when the -scrape lacks the time but knows the rule. +The Go page gives a relative reset string such as "Resets in 30 days 7 +hours". The scrape step parses that into seconds and adds it to the fetch +time. The result is an absolute `resets_at` datetime per frame. This is how a +widget gets a sortable time even though the page only states a duration. ## Config format @@ -84,13 +89,13 @@ default_ttl_secs = 180 [[account]] name = "work" cookie = "${OCD_COOKIE_WORK}" -default_workspace = "ws_123" -extra_workspaces = ["ws_456"] +default_workspace = "wrk_01KYNTSGXF7GFXMAMT36N3GH21" +extra_workspaces = ["wrk_02KYNTSGXF7GFXMAMT36N3GH21"] [[account]] name = "personal" cookie = "ocd_session=...." -default_workspace = "ws_789" +default_workspace = "wrk_03KYNTSGXF7GFXMAMT36N3GH21" ``` Config load order: `-c` flag, then `$OCD_CONFIG`, then `./ocd.toml`, then @@ -249,9 +254,12 @@ Color zones. Color is off when output is not a terminal. - 70 to 90 per cent: amber. - Over 90 per cent: red. -Each row shows name, `used / total unit`, percentage, and `resets in 3h12m`. +Each row shows percentage and `resets in 3h12m`. The opencode Go page exposes +only a percentage per frame, not an absolute count, so the bar is the percentage. -The next-update row shows `2025-08-30 17:00 UTC (in 47m)`. +The next-update row shows `2025-08-30 17:00 UTC (in 47m)` when the page ever +reports one. Today the opencode page has no such banner, so the field stays +empty in the model. `--ascii` or a non-UTF-8 locale prints `#` repeats. @@ -260,6 +268,7 @@ The next-update row shows `2025-08-30 17:00 UTC (in 47m)`. - Crate name: `ocd`. - Live watch mode for `htop`-like refresh. A later task. -- Confirm the Zen and Go page URLs and selectors. Fill `scrape.rs`. -- Confirm conditional GET support. OpenCode may not send ETag. Cache still works +- Send `If-Modified-Since` and `If-None-Match`. The cache already stores the + slots. The HTTP layer needs to attach the headers once we confirm OpenCode + emits ETag or Last-Modified. The TTL cache keeps politeness intact without it. from TTL alone. \ No newline at end of file diff --git a/README.md b/README.md index b191bbc..52cb5bf 100644 --- a/README.md +++ b/README.md @@ -27,8 +27,8 @@ Each account section carries `default_workspace` and optional `extra_workspaces` [[account]] name = "work" cookie = "${OCD_COOKIE_WORK}" -default_workspace = "ws_123" -extra_workspaces = ["ws_456"] +default_workspace = "wrk_01KYNTSGXF7GFXMAMT36N3GH21" +extra_workspaces = ["wrk_02KYNTSGXF7GFXMAMT36N3GH21"] ``` `${VAR}` expands from the environment so cookies stay out of the file. @@ -41,7 +41,7 @@ ocd raw json # one JSON object for widgets and agent plugins ocd raw plain # TSV-ish lines for shell and awk ocd fetch # update cache, print nothing; for cron ocd cache ls | clear -ocd -a work -w ws_456 pretty # one account, one workspace +ocd -a work -w wrk_02KYNTSGXF7GFXMAMT36N3GH21 pretty # one account, one workspace ``` ## Politeness @@ -51,6 +51,9 @@ workspaces. Cache TTL defaults to 180 s. Use `--force-refresh` to bypass. ## Status -This is a scaffold. The HTTP, cache, pacing, and render pipeline work. The -opencode HTML selectors in `src/scrape.rs` are placeholders until the real Zen -and Go pages are captured. See `DESIGN.md`. \ No newline at end of file +The HTTP client, cache, pacing, and renderer work. The `src/scrape.rs` +selectors target the real Zen balance element and the Go usage frames, and +parse correctly against captured pages (see the `scrape::tests` unit test). +Live fetching needs a valid account cookie and a workspace id. Conditional GET +headers are not yet sent; the TTL cache handles politeness alone. See +`DESIGN.md`. \ No newline at end of file diff --git a/examples/ocd.toml b/examples/ocd.toml index efabaa5..fe4e52c 100644 --- a/examples/ocd.toml +++ b/examples/ocd.toml @@ -6,10 +6,10 @@ default_ttl_secs = 180 [[account]] name = "work" cookie = "${OCD_COOKIE_WORK}" -default_workspace = "ws_REPLACE_ME" +default_workspace = "wrk_REPLACE_ME" extra_workspaces = [] [[account]] name = "personal" cookie = "ocd_session=...." -default_workspace = "ws_REPLACE_ME" \ No newline at end of file +default_workspace = "wrk_REPLACE_ME" \ No newline at end of file diff --git a/src/client.rs b/src/client.rs index 95f843a..1f38f90 100644 --- a/src/client.rs +++ b/src/client.rs @@ -8,12 +8,11 @@ use std::time::Duration; use crate::error::FetchError; -/// Balance comes from the Zen page. Limits come from the Go page. -/// -/// TODO: Confirm the real base URLs and the `{workspace}` path. Replace these -/// constants once the pages are captured. -pub const ZEN_BASE_URL: &str = "https://opencode.ai/zen/{workspace}"; -pub const GO_BASE_URL: &str = "https://opencode.ai/go/{workspace}"; +/// Balance comes from the Zen page at the workspace root. Limits come from +/// the Go page at `/workspace/{id}/go`. The `{workspace}` slot is the +/// workspace id such as `wrk_01KYNTSGXF7GFXMAMT36N3GH21`. +pub const ZEN_BASE_URL: &str = "https://opencode.ai/workspace/{workspace}"; +pub const GO_BASE_URL: &str = "https://opencode.ai/workspace/{workspace}/go"; pub fn zen_url(workspace: &str) -> String { ZEN_BASE_URL.replace("{workspace}", workspace) diff --git a/src/config.rs b/src/config.rs index 8fe21c0..f124e55 100644 --- a/src/config.rs +++ b/src/config.rs @@ -139,11 +139,11 @@ default_ttl_secs = 180 [[account]] name = "work" cookie = "${OCD_COOKIE_WORK}" -default_workspace = "ws_REPLACE_ME" +default_workspace = "wrk_REPLACE_ME" extra_workspaces = [] [[account]] name = "personal" cookie = "ocd_session=...." -default_workspace = "ws_REPLACE_ME" +default_workspace = "wrk_REPLACE_ME" "#; \ No newline at end of file diff --git a/src/error.rs b/src/error.rs index e856754..49602a7 100644 --- a/src/error.rs +++ b/src/error.rs @@ -55,9 +55,6 @@ impl FetchError { /// Failures while turning a fetched HTML page into the data model. #[derive(Debug, Error)] pub enum ScrapeError { - #[error("opencode HTML selectors are not configured yet; see scrape.rs: {hint}")] - NotConfigured { hint: String }, - #[error("could not parse page: {0}")] Parse(String), } \ No newline at end of file diff --git a/src/fetch.rs b/src/fetch.rs index 0f498df..95d01d6 100644 --- a/src/fetch.rs +++ b/src/fetch.rs @@ -93,7 +93,8 @@ async fn fetch_target(tgt: &Target, opts: &FetchOptions, http: &HttpClient) -> A http.get_text(&go_url, &tgt.cookie), ); - let fetched_at = Some(Utc::now().to_rfc3339()); + let now = Utc::now(); + let fetched_at = Some(now.to_rfc3339()); let balance = match &zen_res { Ok(html) => match scrape::parse_zen_balance(html) { Ok(b) => Some(b), @@ -108,7 +109,7 @@ async fn fetch_target(tgt: &Target, opts: &FetchOptions, http: &HttpClient) -> A } }; let limits = match &go_res { - Ok(html) => match scrape::parse_go_limits(html) { + Ok(html) => match scrape::parse_go_limits(html, now) { Ok(l) => Some(l), Err(e) => { tracing::warn!(account = %tgt.account, workspace = %tgt.workspace, error = %e, "go parse failed"); diff --git a/src/render/pretty.rs b/src/render/pretty.rs index 4c275b8..d8e5553 100644 --- a/src/render/pretty.rs +++ b/src/render/pretty.rs @@ -55,9 +55,8 @@ fn render_one(r: &AccountReport, width: usize, ascii: bool, no_color: bool) -> S if let Some(b) = &r.balance { out.push_str(&format!( - " balance {} {} remaining\n", - fmt_num(b.remaining), - b.unit, + " balance {}{:.2} remaining\n", + b.unit, b.remaining )); } else { out.push_str(" balance unavailable\n"); @@ -74,7 +73,9 @@ fn render_one(r: &AccountReport, width: usize, ascii: bool, no_color: bool) -> S fn frame_row(label: &str, frame: &UsageFrame, width: usize, ascii: bool, no_color: bool, _reset_tag: &str) -> String { let pct = frame.pct(); let bar = colored_bar(pct, width, ascii, no_color); - let usage = if frame.total > 0.0 { + let usage = if frame.unit == "%" { + format!("{:.0}%", pct) + } else if frame.total > 0.0 { format!("{} / {} {}", fmt_num(frame.used), fmt_num(frame.total), frame.unit) } else { format!("{} {}", fmt_num(frame.used), frame.unit) @@ -82,7 +83,7 @@ fn frame_row(label: &str, frame: &UsageFrame, width: usize, ascii: bool, no_colo let resets = frame .resets_at .as_deref() - .map(|t| format!("resets in {}", timefmt::relative(t))) + .map(|t| format!("resets {}", timefmt::relative(t))) .unwrap_or_default(); format!( " {:<7} {} {:<22} {:>5.1}% {}\n", diff --git a/src/scrape.rs b/src/scrape.rs index 51815e5..edb197d 100644 --- a/src/scrape.rs +++ b/src/scrape.rs @@ -1,60 +1,197 @@ //! HTML to model. The ONLY module coupled to the opencode page shape. //! //! When the page changes, edit selectors here. Transport, cache, and render do -//! not change. Each function returns the data for one page: +//! not change. The opencode app renders with React and exposes stable +//! `data-slot` attributes, which makes the scrape robust to class churn. //! -//! - `parse_go_limits` reads the Go page. It returns `Limits`. -//! - `parse_zen_balance` reads the Zen page. It returns `Balance`. -//! -//! Both live in `scraper`. CSS selectors target small subtrees. The code below -//! is wired but the selectors are placeholders. Fill them from a captured page. -//! Until then the functions return `ScrapeError::NotConfigured`. +//! - `parse_go_limits` reads the Go page at `/workspace/{id}/go`. +//! It returns `Limits` from three `[data-slot="usage-item"]` blocks: +//! Rolling Usage, Weekly Usage, Monthly Usage. Each gives a percentage and a +//! relative reset time. We store `used = pct`, `total = 100`, `unit = "%"` +//! and compute `resets_at` from the relative duration. +//! - `parse_zen_balance` reads the Zen page at `/workspace/{id}`. +//! It returns `Balance` from `[data-slot="balance"] b`. -use scraper::{Html, Selector}; +use chrono::{DateTime, Duration, Utc}; +use scraper::{ElementRef, Html, Selector}; use crate::error::ScrapeError; use crate::model::{Balance, Limits, UsageFrame}; -/// Parse the Go page HTML into limits for the three frames plus next-update. -/// -/// Plan once selectors are known: -/// 1. Selector the headline count for the 5h, week, and month frames. -/// 2. Read `used`, `total`, and `unit` from each frame block. -/// 3. Read `resets_at` per frame. The 5h frame is activity based, so the time -/// must come from the page. The week and month frames may be computed from -/// "every Monday" and "31 days from subscription" when absent. -/// 4. Read the "next update" banner into `next_update`. -pub fn parse_go_limits(html: &str) -> Result { - // `Html::parse_document` validates the HTML pipeline. Replace the rest. - let _document = Html::parse_document(html); - let _ = Selector::parse(":root").map_err(|e| ScrapeError::Parse(e.to_string()))?; +/// Parse the Go page HTML into limits for the three frames. +pub fn parse_go_limits(html: &str, now: DateTime) -> Result { + let document = Html::parse_document(html); + let item_sel = + Selector::parse(r#"[data-slot="usage-item"]"#).map_err(|e| ScrapeError::Parse(e.to_string()))?; - Err(ScrapeError::NotConfigured { - hint: "fill selectors for the 5h, week, and month frames in parse_go_limits" - .to_string(), + let mut rolling = None; + let mut weekly = None; + let mut monthly = None; + + for item in document.select(&item_sel) { + let label = first_text(item, r#"[data-slot="usage-label"]"#).unwrap_or_default(); + let value = first_text(item, r#"[data-slot="usage-value"]"#).unwrap_or_default(); + let reset = first_text(item, r#"[data-slot="reset-time"]"#); + let frame = build_frame(&value, reset.as_deref(), now); + + let lower = label.to_lowercase(); + if lower.contains("rolling") { + rolling = Some(frame); + } else if lower.contains("weekly") || lower.contains("week") { + weekly = Some(frame); + } else if lower.contains("monthly") || lower.contains("month") { + monthly = Some(frame); + } + } + + let blank = || UsageFrame { + used: 0.0, + total: 100.0, + unit: "%".to_string(), + resets_at: None, + }; + + Ok(Limits { + usage_5h: rolling.unwrap_or_else(blank), + usage_week: weekly.unwrap_or_else(blank), + usage_month: monthly.unwrap_or_else(blank), + // The opencode page has no separate "next update" banner. Reset times + // per frame are the relevant data. Kept None for forward compatibility. + next_update: None, }) } /// Parse the Zen page HTML into remaining balance. -/// -/// Plan once selectors are known: selector the balance figure, the currency -/// unit, and optional time-of-fetch stamp. pub fn parse_zen_balance(html: &str) -> Result { - let _document = Html::parse_document(html); - let _ = Selector::parse(":root").map_err(|e| ScrapeError::Parse(e.to_string()))?; + let document = Html::parse_document(html); + let sel = + Selector::parse(r#"[data-slot="balance"] b"#).map_err(|e| ScrapeError::Parse(e.to_string()))?; - Err(ScrapeError::NotConfigured { - hint: "fill selectors for the balance figure and unit in parse_zen_balance".to_string(), - }) + let text = document + .select(&sel) + .next() + .map(|e| ElementRef::text(&e).collect::()) + .ok_or_else(|| ScrapeError::Parse("balance element not found".to_string()))? + .trim() + .to_string(); + + let (remaining, unit) = parse_money(&text) + .ok_or_else(|| ScrapeError::Parse(format!("could not parse balance value from {text:?}")))?; + + Ok(Balance { remaining, unit }) } -/// Helper for future use: parse a frame block once the DOM is known. -#[allow(dead_code)] -fn frame(used: f64, total: f64, unit: &str, resets_at: Option) -> UsageFrame { +fn build_frame(value_text: &str, reset_text: Option<&str>, now: DateTime) -> UsageFrame { + let pct = parse_percent(value_text).unwrap_or(0.0); + let resets_at = reset_text.and_then(|t| parse_reset_at(t, now)).map(|dt| dt.to_rfc3339()); UsageFrame { - used, - total, - unit: unit.to_string(), + used: pct, + total: 100.0, + unit: "%".to_string(), resets_at, } +} + +/// Parse a percentage string such as "0%" or "61%". Whitespace and the trailing +/// percent sign are ignored. +fn parse_percent(s: &str) -> Option { + let cleaned: String = s.trim().chars().filter(|c| c.is_ascii_digit() || matches!(c, '.' | ',')).collect(); + let normalized = cleaned.replace(',', "."); + normalized.parse::().ok() +} + +/// Parse a money string such as "$0.00" into (amount, unit). The unit is the +/// leading non-numeric prefix (currency symbol). +fn parse_money(s: &str) -> Option<(f64, String)> { + let s = s.trim(); + let bytes = s.as_bytes(); + let mut i = 0; + while i < bytes.len() + && !(bytes[i].is_ascii_digit() || bytes[i] == b'.' || bytes[i] == b'-' || bytes[i] == b',') + { + i += 1; + } + let unit = s[..i].trim().to_string(); + let num: String = s[i..] + .chars() + .filter(|c| c.is_ascii_digit() || matches!(c, '.' | ',' | '-')) + .collect(); + let num = num.replace(',', "."); + let amount = num.parse::().ok()?; + Some((amount, unit)) +} + +/// Parse a relative "Resets in N days N hours N minutes" string into an +/// absolute datetime `now` plus the stated duration. +fn parse_reset_at(text: &str, now: DateTime) -> Option> { + let lower = text.to_lowercase(); + let tokens: Vec<&str> = lower.split_whitespace().collect(); + let mut total_secs: i64 = 0; + let mut i = 0; + while i < tokens.len() { + if let Ok(n) = tokens[i].parse::() { + let unit = tokens.get(i + 1).unwrap_or(&""); + let mult = if unit.starts_with("day") { + 86_400 + } else if unit.starts_with("hour") || unit.starts_with("hr") { + 3_600 + } else if unit.starts_with("min") { + 60 + } else if unit.starts_with("sec") { + 1 + } else { + 0 + }; + total_secs += n * mult; + i += 2; + } else { + i += 1; + } + } + if total_secs == 0 { + return None; + } + now.checked_add_signed(Duration::seconds(total_secs)) +} + +/// First descendant element matching `sel`, with its combined text trimmed. +fn first_text(el: ElementRef, sel: &str) -> Option { + let s = Selector::parse(sel).ok()?; + el.select(&s) + .next() + .map(|e| ElementRef::text(&e).collect::().trim().to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn read(p: &str) -> Option { + std::fs::read_to_string(p).ok() + } + + /// Offline parse of captured pages in tmp/. This test is a no-op when + /// those files are absent, so CI without captures still passes. + #[test] + fn parse_captured_pages() { + let Some(go) = read("tmp/opencode.html") else { + eprintln!("skip: tmp/opencode.html missing"); + return; + }; + let Some(zen) = read("tmp/zen.html") else { + eprintln!("skip: tmp/zen.html missing"); + return; + }; + + let now = Utc::now(); + let limits = parse_go_limits(&go, now).expect("go limits parse"); + assert_eq!(limits.usage_5h.used, 0.0, "rolling pct"); + assert!((limits.usage_week.used - 61.0).abs() < 1.0, "week pct"); + assert!((limits.usage_month.used - 30.0).abs() < 1.0, "month pct"); + assert!(limits.usage_5h.resets_at.is_some(), "5h reset computed"); + + let bal = parse_zen_balance(&zen).expect("zen balance parse"); + assert!(bal.remaining >= 0.0, "balance non-negative"); + assert_eq!(bal.unit, "$", "balance unit"); + } } \ No newline at end of file diff --git a/src/timefmt.rs b/src/timefmt.rs index 2f7223a..4d7f298 100644 --- a/src/timefmt.rs +++ b/src/timefmt.rs @@ -5,8 +5,8 @@ use chrono::{DateTime, Utc}; -/// Relative phrasing of a future or past timestamp. Example: "in 3h12m" or -/// "47m ago". Falls back to the input on parse failure. +/// Relative phrasing of a future or past timestamp. Examples: "in 30d7h", +/// "in 3h12m", "in 47m", "12m ago". Falls back to the input on parse failure. pub fn relative(iso: &str) -> String { let Some(t) = parse(iso) else { return iso.to_string(); @@ -14,27 +14,9 @@ pub fn relative(iso: &str) -> String { let now = Utc::now(); let d = t.signed_duration_since(now); let secs = d.num_seconds(); - - if secs.abs() < 60 { - if secs >= 0 { - "in <1m".to_string() - } else { - "<1m ago".to_string() - } - } else { - let h = secs / 3600; - let m = (secs % 3600) / 60; - let core = match (h.abs() > 0, m != 0) { - (true, true) => format!("{}h{}m", h.abs(), m.abs()), - (true, false) => format!("{}h", h.abs()), - (false, _) => format!("{}m", m.abs()), - }; - if secs >= 0 { - format!("in {core}") - } else { - format!("{core} ago") - } - } + let sign = if secs >= 0 { "in " } else { " ago" }; + let body = dur_words(secs.abs()); + format!("{sign}{body}") } /// Absolute UTC string like "2025-08-31 17:00 UTC". @@ -45,6 +27,30 @@ pub fn absolute(iso: &str) -> String { t.format("%Y-%m-%d %H:%M UTC").to_string() } +/// Compact duration. Starts with days when the span is at least one day. +fn dur_words(total: i64) -> String { + let s = total.max(0); + if s == 0 { + return "<1m".to_string(); + } + let days = s / 86_400; + let rem = s % 86_400; + if days > 0 { + let hours = rem / 3_600; + if hours > 0 { + return format!("{days}d{hours}h"); + } + return format!("{days}d"); + } + let hours = s / 3_600; + let mins = (s % 3_600) / 60; + if hours > 0 { + return format!("{hours}h{m}m", m = if mins > 0 { mins.to_string() } else { String::new() }); + } + let mins = (s / 60).max(1); + format!("{mins}m") +} + fn parse(iso: &str) -> Option> { DateTime::parse_from_rfc3339(iso) .ok()