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:
parent
3b1d2a4389
commit
41f35aec34
43
DESIGN.md
43
DESIGN.md
@ -3,8 +3,9 @@
|
|||||||
`ocd` is a console application. It reads a config file. The file lists opencode
|
`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
|
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,
|
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
|
and month. The Go page reports usage as a percentage per frame and a relative
|
||||||
time. It also reports remaining account balance.
|
reset time. `ocd` stores the percentage and computes an absolute reset time.
|
||||||
|
The Zen page reports remaining balance. `ocd` prints all of it.
|
||||||
|
|
||||||
|
|
||||||
## Goals
|
## 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.
|
`default_workspace` is a required id. The program fetches it by default.
|
||||||
`extra_workspaces` is optional. The program fetches all listed workspaces.
|
`extra_workspaces` is optional. The program fetches all listed workspaces.
|
||||||
|
|
||||||
The program cannot auto-discover the default workspace. That path is not
|
The program does not auto-discover the default workspace. That path is not
|
||||||
reliable. So the user supplies the id. A future `ocd workspace list` command
|
reliable. The user supplies the id. Workspace ids look like
|
||||||
|
`wrk_01KYNTSGXF7GFXMAMT36N3GH21`. A future `ocd workspace list` command
|
||||||
can add discovery later.
|
can add discovery later.
|
||||||
|
|
||||||
|
|
||||||
## Two pages per workspace
|
## 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
|
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
|
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.
|
- Week: calendar based. It resets every Monday.
|
||||||
- Month: calendar based. It resets 31 days from the subscription date.
|
- 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 Go page gives a relative reset string such as "Resets in 30 days 7
|
||||||
the page when present. Calendar resets can fall back to a computed value when the
|
hours". The scrape step parses that into seconds and adds it to the fetch
|
||||||
scrape lacks the time but knows the rule.
|
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
|
## Config format
|
||||||
@ -84,13 +89,13 @@ default_ttl_secs = 180
|
|||||||
[[account]]
|
[[account]]
|
||||||
name = "work"
|
name = "work"
|
||||||
cookie = "${OCD_COOKIE_WORK}"
|
cookie = "${OCD_COOKIE_WORK}"
|
||||||
default_workspace = "ws_123"
|
default_workspace = "wrk_01KYNTSGXF7GFXMAMT36N3GH21"
|
||||||
extra_workspaces = ["ws_456"]
|
extra_workspaces = ["wrk_02KYNTSGXF7GFXMAMT36N3GH21"]
|
||||||
|
|
||||||
[[account]]
|
[[account]]
|
||||||
name = "personal"
|
name = "personal"
|
||||||
cookie = "ocd_session=...."
|
cookie = "ocd_session=...."
|
||||||
default_workspace = "ws_789"
|
default_workspace = "wrk_03KYNTSGXF7GFXMAMT36N3GH21"
|
||||||
```
|
```
|
||||||
|
|
||||||
Config load order: `-c` flag, then `$OCD_CONFIG`, then `./ocd.toml`, then
|
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.
|
- 70 to 90 per cent: amber.
|
||||||
- Over 90 per cent: red.
|
- 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.
|
`--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`.
|
- Crate name: `ocd`.
|
||||||
- Live watch mode for `htop`-like refresh. A later task.
|
- Live watch mode for `htop`-like refresh. A later task.
|
||||||
- Confirm the Zen and Go page URLs and selectors. Fill `scrape.rs`.
|
- Send `If-Modified-Since` and `If-None-Match`. The cache already stores the
|
||||||
- Confirm conditional GET support. OpenCode may not send ETag. Cache still works
|
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.
|
from TTL alone.
|
||||||
15
README.md
15
README.md
@ -27,8 +27,8 @@ Each account section carries `default_workspace` and optional `extra_workspaces`
|
|||||||
[[account]]
|
[[account]]
|
||||||
name = "work"
|
name = "work"
|
||||||
cookie = "${OCD_COOKIE_WORK}"
|
cookie = "${OCD_COOKIE_WORK}"
|
||||||
default_workspace = "ws_123"
|
default_workspace = "wrk_01KYNTSGXF7GFXMAMT36N3GH21"
|
||||||
extra_workspaces = ["ws_456"]
|
extra_workspaces = ["wrk_02KYNTSGXF7GFXMAMT36N3GH21"]
|
||||||
```
|
```
|
||||||
|
|
||||||
`${VAR}` expands from the environment so cookies stay out of the file.
|
`${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 raw plain # TSV-ish lines for shell and awk
|
||||||
ocd fetch # update cache, print nothing; for cron
|
ocd fetch # update cache, print nothing; for cron
|
||||||
ocd cache ls | clear
|
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
|
## Politeness
|
||||||
@ -51,6 +51,9 @@ workspaces. Cache TTL defaults to 180 s. Use `--force-refresh` to bypass.
|
|||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
This is a scaffold. The HTTP, cache, pacing, and render pipeline work. The
|
The HTTP client, cache, pacing, and renderer work. The `src/scrape.rs`
|
||||||
opencode HTML selectors in `src/scrape.rs` are placeholders until the real Zen
|
selectors target the real Zen balance element and the Go usage frames, and
|
||||||
and Go pages are captured. See `DESIGN.md`.
|
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`.
|
||||||
@ -6,10 +6,10 @@ default_ttl_secs = 180
|
|||||||
[[account]]
|
[[account]]
|
||||||
name = "work"
|
name = "work"
|
||||||
cookie = "${OCD_COOKIE_WORK}"
|
cookie = "${OCD_COOKIE_WORK}"
|
||||||
default_workspace = "ws_REPLACE_ME"
|
default_workspace = "wrk_REPLACE_ME"
|
||||||
extra_workspaces = []
|
extra_workspaces = []
|
||||||
|
|
||||||
[[account]]
|
[[account]]
|
||||||
name = "personal"
|
name = "personal"
|
||||||
cookie = "ocd_session=...."
|
cookie = "ocd_session=...."
|
||||||
default_workspace = "ws_REPLACE_ME"
|
default_workspace = "wrk_REPLACE_ME"
|
||||||
@ -8,12 +8,11 @@ use std::time::Duration;
|
|||||||
|
|
||||||
use crate::error::FetchError;
|
use crate::error::FetchError;
|
||||||
|
|
||||||
/// Balance comes from the Zen page. Limits come from the Go page.
|
/// 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
|
||||||
/// TODO: Confirm the real base URLs and the `{workspace}` path. Replace these
|
/// workspace id such as `wrk_01KYNTSGXF7GFXMAMT36N3GH21`.
|
||||||
/// constants once the pages are captured.
|
pub const ZEN_BASE_URL: &str = "https://opencode.ai/workspace/{workspace}";
|
||||||
pub const ZEN_BASE_URL: &str = "https://opencode.ai/zen/{workspace}";
|
pub const GO_BASE_URL: &str = "https://opencode.ai/workspace/{workspace}/go";
|
||||||
pub const GO_BASE_URL: &str = "https://opencode.ai/go/{workspace}";
|
|
||||||
|
|
||||||
pub fn zen_url(workspace: &str) -> String {
|
pub fn zen_url(workspace: &str) -> String {
|
||||||
ZEN_BASE_URL.replace("{workspace}", workspace)
|
ZEN_BASE_URL.replace("{workspace}", workspace)
|
||||||
|
|||||||
@ -139,11 +139,11 @@ default_ttl_secs = 180
|
|||||||
[[account]]
|
[[account]]
|
||||||
name = "work"
|
name = "work"
|
||||||
cookie = "${OCD_COOKIE_WORK}"
|
cookie = "${OCD_COOKIE_WORK}"
|
||||||
default_workspace = "ws_REPLACE_ME"
|
default_workspace = "wrk_REPLACE_ME"
|
||||||
extra_workspaces = []
|
extra_workspaces = []
|
||||||
|
|
||||||
[[account]]
|
[[account]]
|
||||||
name = "personal"
|
name = "personal"
|
||||||
cookie = "ocd_session=...."
|
cookie = "ocd_session=...."
|
||||||
default_workspace = "ws_REPLACE_ME"
|
default_workspace = "wrk_REPLACE_ME"
|
||||||
"#;
|
"#;
|
||||||
@ -55,9 +55,6 @@ impl FetchError {
|
|||||||
/// Failures while turning a fetched HTML page into the data model.
|
/// Failures while turning a fetched HTML page into the data model.
|
||||||
#[derive(Debug, Error)]
|
#[derive(Debug, Error)]
|
||||||
pub enum ScrapeError {
|
pub enum ScrapeError {
|
||||||
#[error("opencode HTML selectors are not configured yet; see scrape.rs: {hint}")]
|
|
||||||
NotConfigured { hint: String },
|
|
||||||
|
|
||||||
#[error("could not parse page: {0}")]
|
#[error("could not parse page: {0}")]
|
||||||
Parse(String),
|
Parse(String),
|
||||||
}
|
}
|
||||||
@ -93,7 +93,8 @@ async fn fetch_target(tgt: &Target, opts: &FetchOptions, http: &HttpClient) -> A
|
|||||||
http.get_text(&go_url, &tgt.cookie),
|
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 {
|
let balance = match &zen_res {
|
||||||
Ok(html) => match scrape::parse_zen_balance(html) {
|
Ok(html) => match scrape::parse_zen_balance(html) {
|
||||||
Ok(b) => Some(b),
|
Ok(b) => Some(b),
|
||||||
@ -108,7 +109,7 @@ async fn fetch_target(tgt: &Target, opts: &FetchOptions, http: &HttpClient) -> A
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
let limits = match &go_res {
|
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),
|
Ok(l) => Some(l),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::warn!(account = %tgt.account, workspace = %tgt.workspace, error = %e, "go parse failed");
|
tracing::warn!(account = %tgt.account, workspace = %tgt.workspace, error = %e, "go parse failed");
|
||||||
|
|||||||
@ -55,9 +55,8 @@ fn render_one(r: &AccountReport, width: usize, ascii: bool, no_color: bool) -> S
|
|||||||
|
|
||||||
if let Some(b) = &r.balance {
|
if let Some(b) = &r.balance {
|
||||||
out.push_str(&format!(
|
out.push_str(&format!(
|
||||||
" balance {} {} remaining\n",
|
" balance {}{:.2} remaining\n",
|
||||||
fmt_num(b.remaining),
|
b.unit, b.remaining
|
||||||
b.unit,
|
|
||||||
));
|
));
|
||||||
} else {
|
} else {
|
||||||
out.push_str(" balance unavailable\n");
|
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 {
|
fn frame_row(label: &str, frame: &UsageFrame, width: usize, ascii: bool, no_color: bool, _reset_tag: &str) -> String {
|
||||||
let pct = frame.pct();
|
let pct = frame.pct();
|
||||||
let bar = colored_bar(pct, width, ascii, no_color);
|
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)
|
format!("{} / {} {}", fmt_num(frame.used), fmt_num(frame.total), frame.unit)
|
||||||
} else {
|
} else {
|
||||||
format!("{} {}", fmt_num(frame.used), frame.unit)
|
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
|
let resets = frame
|
||||||
.resets_at
|
.resets_at
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.map(|t| format!("resets in {}", timefmt::relative(t)))
|
.map(|t| format!("resets {}", timefmt::relative(t)))
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
format!(
|
format!(
|
||||||
" {:<7} {} {:<22} {:>5.1}% {}\n",
|
" {:<7} {} {:<22} {:>5.1}% {}\n",
|
||||||
|
|||||||
213
src/scrape.rs
213
src/scrape.rs
@ -1,60 +1,197 @@
|
|||||||
//! HTML to model. The ONLY module coupled to the opencode page shape.
|
//! HTML to model. The ONLY module coupled to the opencode page shape.
|
||||||
//!
|
//!
|
||||||
//! When the page changes, edit selectors here. Transport, cache, and render do
|
//! 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_go_limits` reads the Go page at `/workspace/{id}/go`.
|
||||||
//! - `parse_zen_balance` reads the Zen page. It returns `Balance`.
|
//! It returns `Limits` from three `[data-slot="usage-item"]` blocks:
|
||||||
//!
|
//! Rolling Usage, Weekly Usage, Monthly Usage. Each gives a percentage and a
|
||||||
//! Both live in `scraper`. CSS selectors target small subtrees. The code below
|
//! relative reset time. We store `used = pct`, `total = 100`, `unit = "%"`
|
||||||
//! is wired but the selectors are placeholders. Fill them from a captured page.
|
//! and compute `resets_at` from the relative duration.
|
||||||
//! Until then the functions return `ScrapeError::NotConfigured`.
|
//! - `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::error::ScrapeError;
|
||||||
use crate::model::{Balance, Limits, UsageFrame};
|
use crate::model::{Balance, Limits, UsageFrame};
|
||||||
|
|
||||||
/// Parse the Go page HTML into limits for the three frames plus next-update.
|
/// Parse the Go page HTML into limits for the three frames.
|
||||||
///
|
pub fn parse_go_limits(html: &str, now: DateTime<Utc>) -> Result<Limits, ScrapeError> {
|
||||||
/// Plan once selectors are known:
|
let document = Html::parse_document(html);
|
||||||
/// 1. Selector the headline count for the 5h, week, and month frames.
|
let item_sel =
|
||||||
/// 2. Read `used`, `total`, and `unit` from each frame block.
|
Selector::parse(r#"[data-slot="usage-item"]"#).map_err(|e| ScrapeError::Parse(e.to_string()))?;
|
||||||
/// 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()))?;
|
|
||||||
|
|
||||||
Err(ScrapeError::NotConfigured {
|
let mut rolling = None;
|
||||||
hint: "fill selectors for the 5h, week, and month frames in parse_go_limits"
|
let mut weekly = None;
|
||||||
.to_string(),
|
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.
|
/// 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> {
|
pub fn parse_zen_balance(html: &str) -> Result<Balance, ScrapeError> {
|
||||||
let _document = Html::parse_document(html);
|
let document = Html::parse_document(html);
|
||||||
let _ = Selector::parse(":root").map_err(|e| ScrapeError::Parse(e.to_string()))?;
|
let sel =
|
||||||
|
Selector::parse(r#"[data-slot="balance"] b"#).map_err(|e| ScrapeError::Parse(e.to_string()))?;
|
||||||
|
|
||||||
Err(ScrapeError::NotConfigured {
|
let text = document
|
||||||
hint: "fill selectors for the balance figure and unit in parse_zen_balance".to_string(),
|
.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.
|
fn build_frame(value_text: &str, reset_text: Option<&str>, now: DateTime<Utc>) -> UsageFrame {
|
||||||
#[allow(dead_code)]
|
let pct = parse_percent(value_text).unwrap_or(0.0);
|
||||||
fn frame(used: f64, total: f64, unit: &str, resets_at: Option<String>) -> UsageFrame {
|
let resets_at = reset_text.and_then(|t| parse_reset_at(t, now)).map(|dt| dt.to_rfc3339());
|
||||||
UsageFrame {
|
UsageFrame {
|
||||||
used,
|
used: pct,
|
||||||
total,
|
total: 100.0,
|
||||||
unit: unit.to_string(),
|
unit: "%".to_string(),
|
||||||
resets_at,
|
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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -5,8 +5,8 @@
|
|||||||
|
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
|
|
||||||
/// Relative phrasing of a future or past timestamp. Example: "in 3h12m" or
|
/// Relative phrasing of a future or past timestamp. Examples: "in 30d7h",
|
||||||
/// "47m ago". Falls back to the input on parse failure.
|
/// "in 3h12m", "in 47m", "12m ago". Falls back to the input on parse failure.
|
||||||
pub fn relative(iso: &str) -> String {
|
pub fn relative(iso: &str) -> String {
|
||||||
let Some(t) = parse(iso) else {
|
let Some(t) = parse(iso) else {
|
||||||
return iso.to_string();
|
return iso.to_string();
|
||||||
@ -14,27 +14,9 @@ pub fn relative(iso: &str) -> String {
|
|||||||
let now = Utc::now();
|
let now = Utc::now();
|
||||||
let d = t.signed_duration_since(now);
|
let d = t.signed_duration_since(now);
|
||||||
let secs = d.num_seconds();
|
let secs = d.num_seconds();
|
||||||
|
let sign = if secs >= 0 { "in " } else { " ago" };
|
||||||
if secs.abs() < 60 {
|
let body = dur_words(secs.abs());
|
||||||
if secs >= 0 {
|
format!("{sign}{body}")
|
||||||
"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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Absolute UTC string like "2025-08-31 17:00 UTC".
|
/// 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()
|
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>> {
|
fn parse(iso: &str) -> Option<DateTime<Utc>> {
|
||||||
DateTime::parse_from_rfc3339(iso)
|
DateTime::parse_from_rfc3339(iso)
|
||||||
.ok()
|
.ok()
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user