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.
This commit is contained in:
ocd 2026-07-30 01:16:36 +03:00
parent 3b1d2a4389
commit 41f35aec34
10 changed files with 257 additions and 104 deletions

View File

@ -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.

View File

@ -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`.
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`.

View File

@ -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"
default_workspace = "wrk_REPLACE_ME"

View File

@ -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)

View File

@ -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"
"#;

View File

@ -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),
}

View File

@ -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");

View File

@ -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",

View File

@ -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<Limits, ScrapeError> {
// `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<Utc>) -> Result<Limits, ScrapeError> {
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<Balance, ScrapeError> {
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::<String>())
.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<String>) -> UsageFrame {
fn build_frame(value_text: &str, reset_text: Option<&str>, now: DateTime<Utc>) -> 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<f64> {
let cleaned: String = s.trim().chars().filter(|c| c.is_ascii_digit() || matches!(c, '.' | ',')).collect();
let normalized = cleaned.replace(',', ".");
normalized.parse::<f64>().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::<f64>().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<Utc>) -> Option<DateTime<Utc>> {
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::<i64>() {
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<String> {
let s = Selector::parse(sel).ok()?;
el.select(&s)
.next()
.map(|e| ElementRef::text(&e).collect::<String>().trim().to_string())
}
#[cfg(test)]
mod tests {
use super::*;
fn read(p: &str) -> Option<String> {
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");
}
}

View File

@ -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<Utc>> {
DateTime::parse_from_rfc3339(iso)
.ok()