Skip to content

Playwright-based Claude plan usage scraper#31

Merged
BlockedPath merged 7 commits into
mainfrom
feat/claude-plan-usage-playwright
May 22, 2026
Merged

Playwright-based Claude plan usage scraper#31
BlockedPath merged 7 commits into
mainfrom
feat/claude-plan-usage-playwright

Conversation

@BlockedPath

Copy link
Copy Markdown
Owner

Summary

  • Adds scripts/claude-plan-usage-playwright.py, a Playwright-driven scraper that hits claude.ai/api/organizations/{org}/usage with a saved login session and writes the normalized payload the daemon expects (~/.config/codexmeter/claude-plan-usage.json).
  • Replaces the brittle HTML-text scrape in claude-plan-usage-cache.py that had been failing with Claude usage text did not include recognizable plan usage — the root cause of stale claude tile data in the widget.
  • Includes the Xcode-recommended project default bumps that landed during recent device builds (LastUpgradeCheck 1600 → 2650, ENABLE_USER_SCRIPT_SANDBOXING, STRING_CATALOG_GENERATE_SYMBOLS) and the matching scheme updates.

Notes for the reviewer

  • The cache file contract is unchanged. daemon/codex-usage-daemon.py:claude_plan_usage_snapshot consumes the same session/weekly shape, so no daemon edits are needed.
  • One-time setup: python scripts/claude-plan-usage-playwright.py login opens Chromium so you can sign in to claude.ai; the session is persisted to ~/.config/codexmeter/claude-state.json. After that, the script runs headless.
  • This branch stacks on fix/claude-multi-source-limits (PR Fix mDNS registration for multi-provider daemons #30) since it's based off its tip — happy to rebase onto main after Fix mDNS registration for multi-provider daemons #30 merges if you'd rather keep it independent.

Test plan

  • python scripts/claude-plan-usage-playwright.py login opens Chromium and saves state on successful login.
  • python scripts/claude-plan-usage-playwright.py scrape writes a fresh ~/.config/codexmeter/claude-plan-usage.json with session.used_pct, session.reset_mins, weekly.used_pct, weekly.reset_mins.
  • After running the scrape, restart the claude daemon (launchctl kickstart -k gui/$UID/com.justin.codexmeter.claude) and confirm curl localhost:9596/usage reflects the fresh plan numbers (no longer stuck on the stale hook payload).
  • iOS build still succeeds against a device with the bumped project defaults.

🤖 Generated with Claude Code

BlockedPath and others added 7 commits May 21, 2026 11:20
With the daemon now advertising as codexmeter-{provider} (codex/claude)
instead of a shared "codexmeter" name, iOS was still assigning every
discovery to codexServerURL. Whichever daemon's advertisement arrived
first would land in that slot, so the codex display could end up
pointing at the claude daemon (or vice versa) and both providers would
render identical data.

processDiscovery now parses the provider from the service instance
name suffix:
- codexmeter-codex  -> codexServerURL  (authoritative; overrides stale)
- codexmeter-claude -> claudeServerURL (authoritative; overrides stale)
- legacy codexmeter (no suffix) -> codexServerURL only if empty,
  so it doesn't stomp a real codexmeter-codex that came first.

The "override stale value" behavior is needed to recover users whose
codexServerURL was captured before per-provider names existed and now
points to the wrong daemon.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Previous routing used substring tests ("claude" in name → claude,
otherwise codex), which would mis-route unknown provider variants such
as a "codexmeter-opencode" or "codexmeter-test" announcement into the
codex slot and stomp the real codex URL.

Now match exact instance names: codexmeter-codex, codexmeter-claude,
or bare codexmeter (legacy, codex slot, fill-if-empty only). Anything
else still appears in discoveredServices but is not assigned to a
provider URL slot.

Verified against six scenarios (fresh, stale-correction, legacy
respect, legacy fill, unknown-variant ignore, case-insensitive).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Foundation raises "invalid reuse after initialization failure" if the
same NetServiceBrowser is reused after its previous search ended in a
failed state (e.g. didNotSearch). With per-provider mDNS the browser
runs through more start/stop cycles and is more likely to hit it.

Replace the shared NetServiceBrowser with a var that we drop and
recreate inside startBrowsing(). Also stop+detach any in-flight
NetService resolves in stopBrowsing() so they aren't reused either.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
When mDNS routing corrects a stale provider URL (e.g. codexServerURL
was pointing at the claude daemon from the pre-fix bug period), the
cached JSON in UserDefaults.sharedGroup is still wrong-provider data.
The widget would keep rendering it until its next live fetch, which
the user saw as both Codex and Claude tiles showing identical claude
numbers (0% / 91% / reset 0m).

processDiscovery now:
- Detects when a slot's URL changed (vs. first-time set).
- Clears the per-provider cached JSON + timestamp from the shared app
  group (and the legacy "last_usage_json" fallback for the codex slot).
- Triggers an immediate fetchUsage so the correct payload lands ASAP.
- Calls WidgetCenter.reloadAllTimelines() so the widget re-renders.

After this change, launching the app once on the new build is enough
for the widget to self-heal — no manual UserDefaults clearing needed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The previous fix (recreate NetServiceBrowser on each start) addressed
one source of "invalid reuse after initialization failure", but the
NSException is also raised by NetService.resolve(withTimeout:) when
Apple's browser re-delivers an already-resolved or already-failed
NetService instance. We were calling resolve on it again, which
re-tripped the exception.

Rewrite MDNSServiceBrowser using Network.framework's NWBrowser +
NWConnection. The modern API doesn't surface the legacy NSException
failure modes at all:
- NWBrowser handles re-announcements internally without re-delivering
  state objects to the caller.
- NWConnection resolves the endpoint to host:port via its path, and
  cancel/restart is well-defined — no "reuse" hazard.

Public API (startBrowsing, stopBrowsing, discoveryPublisher,
discoveriesAsync, firstDiscovery) is unchanged so callers don't move.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The hook snapshot is the highest-priority claude source. Claude Code's
statusline writes ~/.claude/plan-limits.json on every status-line tick
with the current five_hour / seven_day used_percentage and resets_at,
but it does NOT zero out used_percentage when the window actually
rolls over — it just keeps reporting the last in-window numbers until
the next session burns into the new window. So immediately after a
boundary, the file shows e.g. "five_hour: 100% used, resets_at = 8
minutes ago" even though the window has reset.

Result: the claude daemon was returning s:0, sr:0, status "5h 100%
used" while the plan-usage scraper had already captured fresh data
(s:48, sr:261, "5h 52% used"). The widget rendered the stale hook
data and showed 0%/0m left on the claude tile.

Fix: in claude_hook_limits_snapshot, if either window's resets_at is
in the past, treat the file as logically stale and return None so the
loop falls through to the plan-usage / rate-limit / activity sources
that do have current numbers.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The existing scripts/claude-plan-usage-cache.py extracts plan usage by
parsing the visible text of claude.ai/settings/usage. That stopped
matching as Claude's markup evolved — the daemon error log filled with
"Claude usage text did not include recognizable plan usage" and the
cached claude-plan-usage.json went stale, which is the upstream cause
of the widget's claude tile showing wrong / stuck data.

This new scraper goes through claude.ai's JSON API directly via a
saved Playwright session:

- `login` opens a real Chromium so the user signs in once; saves
  storage state to ~/.config/codexmeter/claude-state.json.
- `scrape` (default) launches headless Chromium with that state and
  hits https://claude.ai/api/organizations/{org}/usage, then writes a
  normalized payload to ~/.config/codexmeter/claude-plan-usage.json in
  the exact shape claude_plan_usage_snapshot() in the daemon expects
  (session / weekly dicts with used_pct + reset_mins).
- `print` does the scrape without writing the cache.

The cache contract is unchanged, so the daemon picks it up
automatically — no daemon changes required.

Also picks up the Xcode-recommended project defaults bump
(LastUpgradeCheck 1600 -> 2650, ENABLE_USER_SCRIPT_SANDBOXING,
STRING_CATALOG_GENERATE_SYMBOLS) and matching scheme updates that
Xcode applied during recent device builds.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: fbb1c35588

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread ios/CodexMeterApp/CodexMeterApp/ViewModels/MeterViewModel.swift
@BlockedPath
BlockedPath merged commit f447014 into main May 22, 2026
4 checks passed
@BlockedPath
BlockedPath deleted the feat/claude-plan-usage-playwright branch May 22, 2026 16:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant