From 21416b68d478ac7f17b3d263c93283b612d1bc30 Mon Sep 17 00:00:00 2001 From: Jove Zhu Date: Mon, 17 Aug 2026 17:05:33 +0800 Subject: [PATCH] fix(api): parse zh-locale usage labels and reset phrases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The opencode.ai dashboard renders usage labels in the UI locale. For a cookie with oc_locale=zh the labels are Chinese (rolling/weekly/monthly usage) and the reset phrase is '重置于' instead of 'Resets in', so the SSR scrape parsed every window as empty and the chip showed (cookie expired or invalid?) despite the cookie being valid. - labelToKind: recognize the Chinese labels - reset-time regex: accept both 'Resets in' and '重置于' - parseDurationToSec: accept Chinese time units (秒/分钟/小时/天/周/月/年) Adds a zh-locale SSR fixture and zh unit tests. --- lib/index.js | 30 ++++++++++++++++++++-------- lib/types/api.d.ts | 14 ++++++++----- lib/types/api.d.ts.map | 2 +- lib/types/api.js | 35 +++++++++++++++++++++++++------- src/api.test.ts | 45 ++++++++++++++++++++++++++++++++++++++++++ src/api.ts | 32 +++++++++++++++++++++++------- 6 files changed, 130 insertions(+), 28 deletions(-) diff --git a/lib/index.js b/lib/index.js index 82f77b8..a47113b 100644 --- a/lib/index.js +++ b/lib/index.js @@ -185,7 +185,7 @@ function fromSSRHTML(html) { const block = html.slice(starts[i], starts[i + 1] ?? html.length); const labelMatch = block.match(/data-slot="usage-label"[^>]*>([^<]+)\s*(\d+)\s*/); - const resetMatch = block.match(/data-slot="reset-time"[\s\S]*?Resets in(?:\s*)?([\s\S]*?)(?:|<\/span>)/); + const resetMatch = block.match(/data-slot="reset-time"[\s\S]*?(?:Resets in|重置于)(?:\s*)?([\s\S]*?)(?:|<\/span>)/); if (!labelMatch || !valueMatch) continue; const label = labelMatch[1]?.trim() ?? ""; const percent = Number.parseInt(valueMatch[1] ?? "0", 10); @@ -214,17 +214,24 @@ function labelToKind(label) { if (lower.startsWith("rolling")) return "rolling"; if (lower.startsWith("weekly")) return "weekly"; if (lower.startsWith("monthly")) return "monthly"; + if (lower.startsWith("滚动")) return "rolling"; + if (lower.startsWith("每周")) return "weekly"; + if (lower.startsWith("每月")) return "monthly"; } /** Strip SolidStart HTML comments `` from a string. */ function stripHtmlComments(s) { return s.replace(//g, "").trim(); } /** -* Parse a human duration phrase into seconds. Examples: -* "2 hours 29 minutes" → 8940 -* "45 minutes" → 2700 -* "5 days" → 432000 -* "30 seconds" → 30 +* Parse a human duration phrase into seconds. Examples (English plus the +* Chinese renderings used by the zh locale): +* "2 hours 29 minutes" → 8940 "2 小时 29 分钟" → 8940 +* "45 minutes" → 2700 "45 分钟" → 2700 +* "5 days" → 432000 "5 天" → 432000 +* "30 seconds" → 30 "30 秒" → 30 +* "1 week" → 604800 "1 周" → 604800 +* "1 month" → 2592000 "1 个月" → 2592000 +* "1 year" → 31536000 "1 年" → 31536000 * * Returns 0 on unrecognized input. */ @@ -232,7 +239,7 @@ function parseDurationToSec(phrase) { if (!phrase) return 0; const p = phrase.replace(//g, " ").trim().replace(/\s+/g, " ").toLowerCase(); if (!p) return 0; - const re = /(\d+)\s*(second|minute|hour|day|week|month|year)s?/g; + const re = /(\d+)\s*(?:个\s*)?(second|minute|hour|day|week|month|year|秒|分钟|小时|天|周|月|年)s?/g; let total = 0; let matched = false; let m = re.exec(p); @@ -242,24 +249,31 @@ function parseDurationToSec(phrase) { matched = true; switch (unit) { case "second": + case "秒": total += n; break; case "minute": + case "分钟": total += n * 60; break; case "hour": + case "小时": total += n * 3600; break; case "day": + case "天": total += n * 86400; break; case "week": + case "周": total += n * 604800; break; case "month": + case "月": total += n * 2592e3; break; - case "year": total += n * 31536e3; + case "year": + case "年": total += n * 31536e3; } m = re.exec(p); } diff --git a/lib/types/api.d.ts b/lib/types/api.d.ts index 3f821d4..22c39d5 100644 --- a/lib/types/api.d.ts +++ b/lib/types/api.d.ts @@ -29,11 +29,15 @@ export declare function fetchViaCookie(cfg: OcgoConfig): Promise; /** - * Parse a human duration phrase into seconds. Examples: - * "2 hours 29 minutes" → 8940 - * "45 minutes" → 2700 - * "5 days" → 432000 - * "30 seconds" → 30 + * Parse a human duration phrase into seconds. Examples (English plus the + * Chinese renderings used by the zh locale): + * "2 hours 29 minutes" → 8940 "2 小时 29 分钟" → 8940 + * "45 minutes" → 2700 "45 分钟" → 2700 + * "5 days" → 432000 "5 天" → 432000 + * "30 seconds" → 30 "30 秒" → 30 + * "1 week" → 604800 "1 周" → 604800 + * "1 month" → 2592000 "1 个月" → 2592000 + * "1 year" → 31536000 "1 年" → 31536000 * * Returns 0 on unrecognized input. */ diff --git a/lib/types/api.d.ts.map b/lib/types/api.d.ts.map index 2ca3c70..f0f3292 100644 --- a/lib/types/api.d.ts.map +++ b/lib/types/api.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"api.d.ts","sourceRoot":"","sources":["../../src/api.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,KAAK,EAAE,eAAe,EAAE,UAAU,EAAgC,MAAM,YAAY,CAAA;AAM3F,iFAAiF;AACjF,qBAAa,UAAW,SAAQ,KAAK;aAIjB,IAAI,EAAE,MAAM;IAH9B,SAAkB,IAAI,gBAAe;gBAEnC,OAAO,EAAE,MAAM,EACC,IAAI,EAAE,MAAM;CAI/B;AAmDD,6EAA6E;AAC7E,wBAAsB,cAAc,CAAC,GAAG,EAAE,UAAU,GAAG,OAAO,CAAC,IAAI,CAAC,eAAe,EAAE,WAAW,CAAC,CAAC,CAkBjG;AAED;;;;;;GAMG;AACH,wBAAgB,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC,eAAe,EAAE,WAAW,CAAC,CA6C5E;AAeD;;;;;;;;GAQG;AACH,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CA0CzD;AAMD;;;GAGG;AACH,wBAAsB,UAAU,CAAC,GAAG,EAAE,UAAU,GAAG,OAAO,CAAC,eAAe,CAAC,CAG1E"} \ No newline at end of file +{"version":3,"file":"api.d.ts","sourceRoot":"","sources":["../../src/api.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,KAAK,EAAE,eAAe,EAAE,UAAU,EAAgC,MAAM,YAAY,CAAA;AAM3F,iFAAiF;AACjF,qBAAa,UAAW,SAAQ,KAAK;aAIjB,IAAI,EAAE,MAAM;IAH9B,SAAkB,IAAI,gBAAe;gBAEnC,OAAO,EAAE,MAAM,EACC,IAAI,EAAE,MAAM;CAI/B;AAmDD,6EAA6E;AAC7E,wBAAsB,cAAc,CAAC,GAAG,EAAE,UAAU,GAAG,OAAO,CAAC,IAAI,CAAC,eAAe,EAAE,WAAW,CAAC,CAAC,CAkBjG;AAED;;;;;;GAMG;AACH,wBAAgB,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC,eAAe,EAAE,WAAW,CAAC,CA+C5E;AAoBD;;;;;;;;;;;;GAYG;AACH,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAiDzD;AAMD;;;GAGG;AACH,wBAAsB,UAAU,CAAC,GAAG,EAAE,UAAU,GAAG,OAAO,CAAC,eAAe,CAAC,CAG1E"} \ No newline at end of file diff --git a/lib/types/api.js b/lib/types/api.js index d0ed217..0269e62 100644 --- a/lib/types/api.js +++ b/lib/types/api.js @@ -104,7 +104,9 @@ export function fromSSRHTML(html) { const block = html.slice(starts[i], starts[i + 1] ?? html.length); const labelMatch = block.match(/data-slot="usage-label"[^>]*>([^<]+)\s*(\d+)\s*/); - const resetMatch = block.match(/data-slot="reset-time"[\s\S]*?Resets in(?:\s*)?([\s\S]*?)(?:|<\/span>)/); + // The page renders the reset phrase in the UI locale — "Resets in" + // (en) or "重置于" (zh) — so accept both. + const resetMatch = block.match(/data-slot="reset-time"[\s\S]*?(?:Resets in|重置于)(?:\s*)?([\s\S]*?)(?:|<\/span>)/); if (!labelMatch || !valueMatch) continue; const label = labelMatch[1]?.trim() ?? ''; @@ -128,12 +130,20 @@ export function fromSSRHTML(html) { } function labelToKind(label) { const lower = label.toLowerCase(); + // English labels ("Rolling Usage", "Weekly Usage", "Monthly Usage"). if (lower.startsWith('rolling')) return 'rolling'; if (lower.startsWith('weekly')) return 'weekly'; if (lower.startsWith('monthly')) return 'monthly'; + // Chinese labels rendered for zh locale ("滚动用量", "每周用量", "每月用量"). + if (lower.startsWith('滚动')) + return 'rolling'; + if (lower.startsWith('每周')) + return 'weekly'; + if (lower.startsWith('每月')) + return 'monthly'; return undefined; } /** Strip SolidStart HTML comments `` from a string. */ @@ -141,11 +151,15 @@ function stripHtmlComments(s) { return s.replace(//g, '').trim(); } /** - * Parse a human duration phrase into seconds. Examples: - * "2 hours 29 minutes" → 8940 - * "45 minutes" → 2700 - * "5 days" → 432000 - * "30 seconds" → 30 + * Parse a human duration phrase into seconds. Examples (English plus the + * Chinese renderings used by the zh locale): + * "2 hours 29 minutes" → 8940 "2 小时 29 分钟" → 8940 + * "45 minutes" → 2700 "45 分钟" → 2700 + * "5 days" → 432000 "5 天" → 432000 + * "30 seconds" → 30 "30 秒" → 30 + * "1 week" → 604800 "1 周" → 604800 + * "1 month" → 2592000 "1 个月" → 2592000 + * "1 year" → 31536000 "1 年" → 31536000 * * Returns 0 on unrecognized input. */ @@ -158,7 +172,7 @@ export function parseDurationToSec(phrase) { const p = cleaned.trim().replace(/\s+/g, ' ').toLowerCase(); if (!p) return 0; - const re = /(\d+)\s*(second|minute|hour|day|week|month|year)s?/g; + const re = /(\d+)\s*(?:个\s*)?(second|minute|hour|day|week|month|year|秒|分钟|小时|天|周|月|年)s?/g; let total = 0; let matched = false; let m = re.exec(p); @@ -168,24 +182,31 @@ export function parseDurationToSec(phrase) { matched = true; switch (unit) { case 'second': + case '秒': total += n; break; case 'minute': + case '分钟': total += n * 60; break; case 'hour': + case '小时': total += n * 3600; break; case 'day': + case '天': total += n * 86400; break; case 'week': + case '周': total += n * 604800; break; case 'month': + case '月': total += n * 2592000; // 30 days; coarse but adequate for display break; case 'year': + case '年': total += n * 31536000; break; } diff --git a/src/api.test.ts b/src/api.test.ts index 72bbf33..21ffb58 100644 --- a/src/api.test.ts +++ b/src/api.test.ts @@ -98,6 +98,44 @@ describe('fromSSRHTML', () => { const parsed = fromSSRHTML(page) expect(parsed.monthly?.percent).toBe(100) }) + + it('parses a zh-locale page (Chinese labels and reset phrases)', () => { + const zhPage = ` +
+ 滚动用量 + 27% + 重置于3 小时 37 分钟 +
+
+ 每周用量 + 15% + 重置于6 天 15 小时 +
+
+ 每月用量 + 20% + 重置于15 天 17 小时 +
` + const parsed = fromSSRHTML(zhPage) + expect(parsed.rolling).toEqual({ + kind: 'rolling', + percent: 27, + resetInSec: 3 * 3600 + 37 * 60, + status: 'ok', + }) + expect(parsed.weekly).toEqual({ + kind: 'weekly', + percent: 15, + resetInSec: 6 * 86400 + 15 * 3600, + status: 'ok', + }) + expect(parsed.monthly).toEqual({ + kind: 'monthly', + percent: 20, + resetInSec: 15 * 86400 + 17 * 3600, + status: 'ok', + }) + }) }) describe('parseDurationToSec', () => { @@ -109,6 +147,13 @@ describe('parseDurationToSec', () => { ['1 week', 604800], ['1 month', 2592000], ['1 year', 31536000], + ['2 小时 29 分钟', 2 * 3600 + 29 * 60], + ['45 分钟', 45 * 60], + ['5 天', 5 * 86400], + ['30 秒', 30], + ['1 周', 604800], + ['1 个月', 2592000], + ['1 年', 31536000], ['', 0], ['garbage text', 0], ])('parses %j → %i', (phrase, expected) => { diff --git a/src/api.ts b/src/api.ts index bef21d0..042b47f 100644 --- a/src/api.ts +++ b/src/api.ts @@ -125,8 +125,10 @@ export function fromSSRHTML(html: string): Omit { const block = html.slice(starts[i], starts[i + 1] ?? html.length) const labelMatch = block.match(/data-slot="usage-label"[^>]*>([^<]+)\s*(\d+)\s*/) + // The page renders the reset phrase in the UI locale — "Resets in" + // (en) or "重置于" (zh) — so accept both. const resetMatch = block.match( - /data-slot="reset-time"[\s\S]*?Resets in(?:\s*)?([\s\S]*?)(?:|<\/span>)/, + /data-slot="reset-time"[\s\S]*?(?:Resets in|重置于)(?:\s*)?([\s\S]*?)(?:|<\/span>)/, ) if (!labelMatch || !valueMatch) continue const label = labelMatch[1]?.trim() ?? '' @@ -155,9 +157,14 @@ export function fromSSRHTML(html: string): Omit { function labelToKind(label: string): UsageWindowKind | undefined { const lower = label.toLowerCase() + // English labels ("Rolling Usage", "Weekly Usage", "Monthly Usage"). if (lower.startsWith('rolling')) return 'rolling' if (lower.startsWith('weekly')) return 'weekly' if (lower.startsWith('monthly')) return 'monthly' + // Chinese labels rendered for zh locale ("滚动用量", "每周用量", "每月用量"). + if (lower.startsWith('滚动')) return 'rolling' + if (lower.startsWith('每周')) return 'weekly' + if (lower.startsWith('每月')) return 'monthly' return undefined } @@ -167,11 +174,15 @@ function stripHtmlComments(s: string): string { } /** - * Parse a human duration phrase into seconds. Examples: - * "2 hours 29 minutes" → 8940 - * "45 minutes" → 2700 - * "5 days" → 432000 - * "30 seconds" → 30 + * Parse a human duration phrase into seconds. Examples (English plus the + * Chinese renderings used by the zh locale): + * "2 hours 29 minutes" → 8940 "2 小时 29 分钟" → 8940 + * "45 minutes" → 2700 "45 分钟" → 2700 + * "5 days" → 432000 "5 天" → 432000 + * "30 seconds" → 30 "30 秒" → 30 + * "1 week" → 604800 "1 周" → 604800 + * "1 month" → 2592000 "1 个月" → 2592000 + * "1 year" → 31536000 "1 年" → 31536000 * * Returns 0 on unrecognized input. */ @@ -183,7 +194,7 @@ export function parseDurationToSec(phrase: string): number { const p = cleaned.trim().replace(/\s+/g, ' ').toLowerCase() if (!p) return 0 - const re = /(\d+)\s*(second|minute|hour|day|week|month|year)s?/g + const re = /(\d+)\s*(?:个\s*)?(second|minute|hour|day|week|month|year|秒|分钟|小时|天|周|月|年)s?/g let total = 0 let matched = false let m = re.exec(p) @@ -193,24 +204,31 @@ export function parseDurationToSec(phrase: string): number { matched = true switch (unit) { case 'second': + case '秒': total += n break case 'minute': + case '分钟': total += n * 60 break case 'hour': + case '小时': total += n * 3600 break case 'day': + case '天': total += n * 86400 break case 'week': + case '周': total += n * 604800 break case 'month': + case '月': total += n * 2592000 // 30 days; coarse but adequate for display break case 'year': + case '年': total += n * 31536000 break }