feat(opencode): 适配用量页新格式(usagePercent 小数 + usage/limit 字段)

- parseWindows 的 usagePercent 改为 Float 解析,避免小数被 toIntOrNull 丢弃\n- 新增 usage/limit 字段解析(exactKey 匹配避免前缀误命中)\n- 首页卡片与桌面小组件读取端改为 toFloatOrNull()?.roundToInt()\n- 新增新格式/旧格式兼容测试
This commit is contained in:
WaterRain 2026-08-27 02:23:54 +00:00
parent bb309c92f0
commit 1a1066076a
No known key found for this signature in database
4 changed files with 122 additions and 20 deletions

View File

@ -104,18 +104,25 @@ class OpenCodeGoRepository(
val config = ServiceConfigProvider.get(ServiceType.OPENCODE_GO) val config = ServiceConfigProvider.get(ServiceType.OPENCODE_GO)
// 把 3 个窗口的用量百分比 + 重置时间全部塞进 extras(详情页按窗口渲染) // 把 3 个窗口的用量百分比 + 重置时间全部塞进 extras(详情页按窗口渲染)
// usage/limit 为页面新增的用量与限额字段(单位以服务端定义为准,保留供详情页展示,UI 兼容缺失场景)
val extras = buildMap { val extras = buildMap {
windows["rollingUsage"]?.let { w -> windows["rollingUsage"]?.let { w ->
put("rolling.pct", w.usagePercent.toString()) put("rolling.pct", w.usagePercent.toString())
put("rolling.resetInSec", w.resetInSec.toString()) put("rolling.resetInSec", w.resetInSec.toString())
w.usage?.let { put("rolling.usage", it.toString()) }
w.limit?.let { put("rolling.limit", it.toString()) }
} }
windows["weeklyUsage"]?.let { w -> windows["weeklyUsage"]?.let { w ->
put("weekly.pct", w.usagePercent.toString()) put("weekly.pct", w.usagePercent.toString())
put("weekly.resetInSec", w.resetInSec.toString()) put("weekly.resetInSec", w.resetInSec.toString())
w.usage?.let { put("weekly.usage", it.toString()) }
w.limit?.let { put("weekly.limit", it.toString()) }
} }
windows["monthlyUsage"]?.let { w -> windows["monthlyUsage"]?.let { w ->
put("monthly.pct", w.usagePercent.toString()) put("monthly.pct", w.usagePercent.toString())
put("monthly.resetInSec", w.resetInSec.toString()) put("monthly.resetInSec", w.resetInSec.toString())
w.usage?.let { put("monthly.usage", it.toString()) }
w.limit?.let { put("monthly.limit", it.toString()) }
} }
} }
@ -257,10 +264,14 @@ class OpenCodeGoRepository(
val braceEnd = findMatchingBrace(html, braceStart) ?: continue val braceEnd = findMatchingBrace(html, braceStart) ?: continue
val body = html.substring(braceStart, braceEnd + 1) val body = html.substring(braceStart, braceEnd + 1)
val pct = extractNumberAfterKey(body, "usagePercent")?.toIntOrNull() val pct = extractNumberAfterKey(body, "usagePercent")?.toFloatOrNull()
val reset = extractNumberAfterKey(body, "resetInSec")?.toLongOrNull() val reset = extractNumberAfterKey(body, "resetInSec")?.toLongOrNull()
// usage/limit 是页面新增的用量与限额字段(单位以服务端定义为准,缺失不影响窗口识别)
// exactKey=true:避免 "usage" 误命中 "usagePercent" 前缀
val usage = extractNumberAfterKey(body, "usage", exactKey = true)?.toLongOrNull()
val limit = extractNumberAfterKey(body, "limit", exactKey = true)?.toLongOrNull()
if (pct != null && reset != null) { if (pct != null && reset != null) {
result[field] = ScrapedWindow(pct, reset) result[field] = ScrapedWindow(pct, reset, usage, limit)
} }
} }
@ -288,9 +299,23 @@ class OpenCodeGoRepository(
/** /**
* 在 body 字符串中找 "key:" 后面紧跟的数字(含可选小数)。返回数字字符串,未找到返回 null。 * 在 body 字符串中找 "key:" 后面紧跟的数字(含可选小数)。返回数字字符串,未找到返回 null。
* 跳过 status 字符串值("ok" 之类)。 * 跳过 status 字符串值("ok" 之类)。
*
* @param exactKey 为 true 时要求 key 后紧跟 ':'(精确字段匹配),
* 避免 "usage" 误命中 "usagePercent" 这类前缀字段。
*/ */
private fun extractNumberAfterKey(body: String, key: String): String? { private fun extractNumberAfterKey(body: String, key: String, exactKey: Boolean = false): String? {
val keyIdx = body.indexOf(key) val keyIdx = if (exactKey) {
// 精确匹配:key 后必须紧跟 ':'(如 "usage:"),不能是 "usagePercent"
var idx = body.indexOf(key)
while (idx >= 0) {
val after = idx + key.length
if (after < body.length && body[after] == ':') break
idx = body.indexOf(key, idx + 1)
}
idx
} else {
body.indexOf(key)
}
if (keyIdx < 0) return null if (keyIdx < 0) return null
var i = keyIdx + key.length var i = keyIdx + key.length
// 跳过 ":" 后面所有非数字、非负号、非小数点字符 // 跳过 ":" 后面所有非数字、非负号、非小数点字符
@ -313,7 +338,12 @@ class OpenCodeGoRepository(
return body.substring(start, i).ifEmpty { null } return body.substring(start, i).ifEmpty { null }
} }
internal data class ScrapedWindow(val usagePercent: Int, val resetInSec: Long) internal data class ScrapedWindow(
val usagePercent: Float,
val resetInSec: Long,
val usage: Long? = null,
val limit: Long? = null
)
} }
} }

View File

@ -38,6 +38,7 @@ import com.rainy.token.ui.theme.StatusOrange
import com.rainy.token.ui.theme.inkMuted import com.rainy.token.ui.theme.inkMuted
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Date import java.util.Date
import kotlin.math.roundToInt
import java.util.Locale import java.util.Locale
// ── Service-specific balance Composables ── // ── Service-specific balance Composables ──
@ -153,9 +154,9 @@ internal fun OpenCodeGoMainBalance(balance: ServiceBalance) {
@Composable @Composable
internal fun OpenCodeGoUsageWindows(balance: ServiceBalance) { internal fun OpenCodeGoUsageWindows(balance: ServiceBalance) {
val windows = listOf( val windows = listOf(
Triple(stringResource(R.string.window_5h), balance.extras["rolling.pct"]?.toIntOrNull(), balance.extras["rolling.resetInSec"]?.toLongOrNull()), Triple(stringResource(R.string.window_5h), balance.extras["rolling.pct"]?.toFloatOrNull()?.roundToInt(), balance.extras["rolling.resetInSec"]?.toLongOrNull()),
Triple(stringResource(R.string.window_weekly), balance.extras["weekly.pct"]?.toIntOrNull(), balance.extras["weekly.resetInSec"]?.toLongOrNull()), Triple(stringResource(R.string.window_weekly), balance.extras["weekly.pct"]?.toFloatOrNull()?.roundToInt(), balance.extras["weekly.resetInSec"]?.toLongOrNull()),
Triple(stringResource(R.string.window_monthly), balance.extras["monthly.pct"]?.toIntOrNull(), balance.extras["monthly.resetInSec"]?.toLongOrNull()) Triple(stringResource(R.string.window_monthly), balance.extras["monthly.pct"]?.toFloatOrNull()?.roundToInt(), balance.extras["monthly.resetInSec"]?.toLongOrNull())
) )
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
windows.forEach { (label, pct, resetSec) -> windows.forEach { (label, pct, resetSec) ->

View File

@ -20,6 +20,7 @@ import kotlinx.coroutines.runBlocking
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Date import java.util.Date
import java.util.Locale import java.util.Locale
import kotlin.math.roundToInt
/** /**
* OpenCode Go 桌面小组件(MIUI Widget)。 * OpenCode Go 桌面小组件(MIUI Widget)。
@ -173,13 +174,13 @@ class OpenCodeGoWidgetProvider : AppWidgetProvider() {
ServiceType.OPENCODE_GO -> { ServiceType.OPENCODE_GO -> {
setRowLabel(views, context.getString(R.string.window_5h_short), context.getString(R.string.window_weekly), context.getString(R.string.window_monthly)) setRowLabel(views, context.getString(R.string.window_5h_short), context.getString(R.string.window_weekly), context.getString(R.string.window_monthly))
populateRow(views, R.id.row1_pct, R.id.row1_bar, R.id.row1_reset, populateRow(views, R.id.row1_pct, R.id.row1_bar, R.id.row1_reset,
pct = extras["rolling.pct"]?.toIntOrNull(), pct = extras["rolling.pct"]?.toFloatOrNull()?.roundToInt(),
resetSec = extras["rolling.resetInSec"]?.toLongOrNull()) resetSec = extras["rolling.resetInSec"]?.toLongOrNull())
populateRow(views, R.id.row2_pct, R.id.row2_bar, R.id.row2_reset, populateRow(views, R.id.row2_pct, R.id.row2_bar, R.id.row2_reset,
pct = extras["weekly.pct"]?.toIntOrNull(), pct = extras["weekly.pct"]?.toFloatOrNull()?.roundToInt(),
resetSec = extras["weekly.resetInSec"]?.toLongOrNull()) resetSec = extras["weekly.resetInSec"]?.toLongOrNull())
populateRow(views, R.id.row3_pct, R.id.row3_bar, R.id.row3_reset, populateRow(views, R.id.row3_pct, R.id.row3_bar, R.id.row3_reset,
pct = extras["monthly.pct"]?.toIntOrNull(), pct = extras["monthly.pct"]?.toFloatOrNull()?.roundToInt(),
resetSec = extras["monthly.resetInSec"]?.toLongOrNull()) resetSec = extras["monthly.resetInSec"]?.toLongOrNull())
} }
ServiceType.COMMANDCODE_GO -> { ServiceType.COMMANDCODE_GO -> {

View File

@ -19,15 +19,21 @@ class OpenCodeGoRepositoryTest {
* *
* The real format looks like: * The real format looks like:
* rollingUsage:$R[0]={status:"ok",usagePercent:42,resetInSec:12345} * rollingUsage:$R[0]={status:"ok",usagePercent:42,resetInSec:12345}
*
* @param usage 可选:页面新增的用量字段(usage)
* @param limit 可选:页面新增的限额字段(limit)
*/ */
private fun buildHtml( private fun buildHtml(
rolling: Pair<Int, Long>? = Pair(42, 3600), rolling: Pair<Int, Long>? = Pair(42, 3600),
weekly: Pair<Int, Long>? = Pair(15, 86400), weekly: Pair<Int, Long>? = Pair(15, 86400),
monthly: Pair<Int, Long>? = Pair(80, 2592000) monthly: Pair<Int, Long>? = Pair(80, 2592000),
usage: Long? = null,
limit: Long? = null
): String { ): String {
fun windowJs(name: String, data: Pair<Int, Long>?): String { fun windowJs(name: String, data: Pair<Int, Long>?): String {
if (data == null) return "" if (data == null) return ""
return """$name:${'$'}R[0]={status:"ok",usagePercent:${data.first},resetInSec:${data.second}}""" val extra = if (usage != null && limit != null) ",usage:$usage,limit:$limit" else ""
return """$name:${'$'}R[0]={status:"ok",usagePercent:${data.first},resetInSec:${data.second}$extra}"""
} }
return """ return """
@ -54,15 +60,15 @@ class OpenCodeGoRepositoryTest {
assertEquals(3, result.size) assertEquals(3, result.size)
assertNotNull(result["rollingUsage"]) assertNotNull(result["rollingUsage"])
assertEquals(42, result["rollingUsage"]!!.usagePercent) assertEquals(42f, result["rollingUsage"]!!.usagePercent)
assertEquals(3600L, result["rollingUsage"]!!.resetInSec) assertEquals(3600L, result["rollingUsage"]!!.resetInSec)
assertNotNull(result["weeklyUsage"]) assertNotNull(result["weeklyUsage"])
assertEquals(15, result["weeklyUsage"]!!.usagePercent) assertEquals(15f, result["weeklyUsage"]!!.usagePercent)
assertEquals(86400L, result["weeklyUsage"]!!.resetInSec) assertEquals(86400L, result["weeklyUsage"]!!.resetInSec)
assertNotNull(result["monthlyUsage"]) assertNotNull(result["monthlyUsage"])
assertEquals(80, result["monthlyUsage"]!!.usagePercent) assertEquals(80f, result["monthlyUsage"]!!.usagePercent)
assertEquals(2592000L, result["monthlyUsage"]!!.resetInSec) assertEquals(2592000L, result["monthlyUsage"]!!.resetInSec)
} }
@ -73,7 +79,7 @@ class OpenCodeGoRepositoryTest {
assertEquals(1, result.size) assertEquals(1, result.size)
assertNotNull(result["rollingUsage"]) assertNotNull(result["rollingUsage"])
assertEquals(42, result["rollingUsage"]!!.usagePercent) assertEquals(42f, result["rollingUsage"]!!.usagePercent)
} }
@Test @Test
@ -100,7 +106,7 @@ class OpenCodeGoRepositoryTest {
assertEquals(1, result.size) assertEquals(1, result.size)
assertNotNull(result["rollingUsage"]) assertNotNull(result["rollingUsage"])
assertEquals(42, result["rollingUsage"]!!.usagePercent) assertEquals(42f, result["rollingUsage"]!!.usagePercent)
assertEquals(3600L, result["rollingUsage"]!!.resetInSec) assertEquals(3600L, result["rollingUsage"]!!.resetInSec)
} }
@ -135,7 +141,7 @@ class OpenCodeGoRepositoryTest {
) )
val result = OpenCodeGoRepository.parseWindows(html) val result = OpenCodeGoRepository.parseWindows(html)
assertEquals(1, result.size) assertEquals(1, result.size)
assertEquals(0, result["rollingUsage"]!!.usagePercent) assertEquals(0f, result["rollingUsage"]!!.usagePercent)
assertEquals(0L, result["rollingUsage"]!!.resetInSec) assertEquals(0L, result["rollingUsage"]!!.resetInSec)
} }
@ -148,7 +154,7 @@ class OpenCodeGoRepositoryTest {
) )
val result = OpenCodeGoRepository.parseWindows(html) val result = OpenCodeGoRepository.parseWindows(html)
assertEquals(1, result.size) assertEquals(1, result.size)
assertEquals(99, result["rollingUsage"]!!.usagePercent) assertEquals(99f, result["rollingUsage"]!!.usagePercent)
assertEquals(9999999999L, result["rollingUsage"]!!.resetInSec) assertEquals(9999999999L, result["rollingUsage"]!!.resetInSec)
} }
@ -163,4 +169,68 @@ class OpenCodeGoRepositoryTest {
// Should not match because the prefix "rollingUsage:$R[" is missing // Should not match because the prefix "rollingUsage:$R[" is missing
assertTrue(result.isEmpty()) assertTrue(result.isEmpty())
} }
@Test
fun `parses new page format with decimal percent and usage fields`() {
// 新页面(2026-08)实测格式:usagePercent 为小数,并新增 usage/limit 字段
val html = """
<html><body><script>
rollingUsage:${'$'}R[37]={status:"ok",resetInSec:1406,usagePercent:1.1,usage:13314666,limit:1200000000}
weeklyUsage:${'$'}R[38]={status:"ok",resetInSec:339417,usagePercent:2.5,usage:74497156,limit:3000000000}
monthlyUsage:${'$'}R[39]={status:"ok",resetInSec:1229614,usagePercent:7.2,usage:434167747,limit:6000000000}
</script></body></html>
""".trimIndent()
val result = OpenCodeGoRepository.parseWindows(html)
assertEquals(3, result.size)
val rolling = result["rollingUsage"]!!
assertEquals(1.1f, rolling.usagePercent)
assertEquals(1406L, rolling.resetInSec)
assertEquals(13314666L, rolling.usage)
assertEquals(1200000000L, rolling.limit)
val weekly = result["weeklyUsage"]!!
assertEquals(2.5f, weekly.usagePercent)
assertEquals(339417L, weekly.resetInSec)
assertEquals(74497156L, weekly.usage)
assertEquals(3000000000L, weekly.limit)
val monthly = result["monthlyUsage"]!!
assertEquals(7.2f, monthly.usagePercent)
assertEquals(1229614L, monthly.resetInSec)
assertEquals(434167747L, monthly.usage)
assertEquals(6000000000L, monthly.limit)
}
@Test
fun `keeps decimal percent without rounding to int`() {
// 回归保护:usagePercent 为小数时不能被 toIntOrNull 丢掉
val html = """
<html><body><script>
rollingUsage:${'$'}R[0]={status:"ok",resetInSec:60,usagePercent:0.5}
</script></body></html>
""".trimIndent()
val result = OpenCodeGoRepository.parseWindows(html)
assertEquals(1, result.size)
assertEquals(0.5f, result["rollingUsage"]!!.usagePercent)
assertEquals(60L, result["rollingUsage"]!!.resetInSec)
}
@Test
fun `tolerates missing usage and limit fields`() {
// 旧页面/旧缓存格式:无 usage/limit,不应影响窗口解析
val html = buildHtml(
rolling = Pair(42, 3600),
weekly = null,
monthly = null
)
val result = OpenCodeGoRepository.parseWindows(html)
assertEquals(1, result.size)
assertEquals(42f, result["rollingUsage"]!!.usagePercent)
assertNull(result["rollingUsage"]!!.usage)
assertNull(result["rollingUsage"]!!.limit)
}
} }