ollama_cloud_usage_parser.go 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456
  1. package service
  2. import (
  3. "fmt"
  4. "regexp"
  5. "sort"
  6. "strconv"
  7. "strings"
  8. "time"
  9. "golang.org/x/net/html"
  10. )
  11. var (
  12. ollamaUsagePercentPattern = regexp.MustCompile(`(?i)([0-9]+(?:\.[0-9]+)?)\s*%`)
  13. ollamaUsageWidthPattern = regexp.MustCompile(`(?i)(?:^|;)\s*width\s*:\s*([0-9]+(?:\.[0-9]+)?)%`)
  14. ollamaBalancePattern = regexp.MustCompile(`(?i)(?:balance|credits?)(?:\s+[[:alpha:]]+){0,4}\s*[:\n]?\s*((?:USD\s*)?\$?\s*-?[0-9][0-9,]*(?:\.[0-9]{1,4})?)`)
  15. ollamaResetPattern = regexp.MustCompile(`(?i)\breset(?:s|ting)?\s*(?:at|in|on)?\s*[:\-]?\s*([^\n|]+)`)
  16. ollamaModelFallbackPattern = regexp.MustCompile(`(?i)^(.+?)\s+([0-9][0-9,]*)\s+requests?$`)
  17. ollamaFiveHourUsageAliases = []string{"session usage", "5 hour usage", "5-hour usage", "5h usage", "5 hour limit", "5-hour limit"}
  18. ollamaSevenDayUsageAliases = []string{"weekly usage", "7 day usage", "7-day usage", "7d usage", "weekly limit", "7 day limit"}
  19. )
  20. func parseOllamaCloudUsageHTML(body []byte) (*OllamaCloudUsageData, error) {
  21. doc, err := html.Parse(strings.NewReader(string(body)))
  22. if err != nil {
  23. return nil, fmt.Errorf("parse settings HTML: %w", err)
  24. }
  25. pageText := normalizedNodeText(doc)
  26. lowerPage := strings.ToLower(pageText)
  27. if containsAny(lowerPage, "sign in to ollama", "log in to ollama", "continue to sign in") {
  28. return nil, errOllamaCloudUsageUnauthorizedHTML
  29. }
  30. data := &OllamaCloudUsageData{}
  31. data.Plan = valueBesideLabel(doc, []string{"cloud usage"}, 80)
  32. if data.Plan == "" {
  33. data.Plan = valueBesideLabel(doc, []string{"plan", "subscription"}, 80)
  34. }
  35. data.FiveHour = parseOllamaUsageWindow(doc, ollamaFiveHourUsageAliases)
  36. data.SevenDay = parseOllamaUsageWindow(doc, ollamaSevenDayUsageAliases)
  37. data.Balance = valueBesideLabel(doc, []string{"balance remaining"}, 80)
  38. if data.Balance == "" {
  39. if match := ollamaBalancePattern.FindStringSubmatch(pageText); len(match) == 2 {
  40. data.Balance = strings.Join(strings.Fields(match[1]), "")
  41. }
  42. }
  43. data.Models = parseOllamaModels(doc)
  44. if data.Plan == "" && data.FiveHour == nil && data.SevenDay == nil && data.Balance == "" && len(data.Models) == 0 {
  45. return nil, fmt.Errorf("settings HTML does not contain recognizable usage fields")
  46. }
  47. return data, nil
  48. }
  49. func parseOllamaUsageWindow(root *html.Node, aliases []string) *OllamaCloudUsageWindow {
  50. label := findLabelElement(root, aliases)
  51. if label == nil {
  52. return nil
  53. }
  54. var candidate *OllamaCloudUsageWindow
  55. block := label
  56. for depth := 0; block != nil && depth < 6; depth, block = depth+1, block.Parent {
  57. text := normalizedNodeText(block)
  58. if len(text) > 600 {
  59. break
  60. }
  61. percent, ok := ollamaUsagePercentFromText(text)
  62. if !ok {
  63. percent, ok = ollamaUsagePercentFromTrack(block)
  64. }
  65. if !ok {
  66. continue
  67. }
  68. if strings.Contains(strings.ToLower(text), "remaining") && !strings.Contains(strings.ToLower(text), "used") {
  69. percent = 100 - percent
  70. }
  71. window := &OllamaCloudUsageWindow{UsedPercent: percent}
  72. window.ResetAt = timeElementValue(block)
  73. if reset := ollamaResetPattern.FindStringSubmatch(text); len(reset) == 2 {
  74. window.ResetText = strings.TrimSpace(reset[1])
  75. if window.ResetAt == nil {
  76. window.ResetAt = parseOllamaResetTime(window.ResetText)
  77. }
  78. }
  79. if candidate == nil {
  80. candidate = window
  81. }
  82. if window.ResetAt != nil || window.ResetText != "" {
  83. return window
  84. }
  85. }
  86. return candidate
  87. }
  88. func valueBesideLabel(root *html.Node, aliases []string, maxLen int) string {
  89. label := findLabelElement(root, aliases)
  90. if label == nil {
  91. return ""
  92. }
  93. if sibling := nextElementSibling(label); sibling != nil {
  94. value := strings.Trim(normalizedNodeText(sibling), ":-| ")
  95. if value != "" && len(value) <= maxLen && !strings.EqualFold(value, "manage") {
  96. return value
  97. }
  98. }
  99. for depth, block := 0, label.Parent; block != nil && depth < 4; depth, block = depth+1, block.Parent {
  100. text := normalizedNodeText(block)
  101. if text == "" || len(text) > maxLen {
  102. continue
  103. }
  104. value := text
  105. for _, alias := range aliases {
  106. if index := strings.Index(strings.ToLower(value), alias); index >= 0 {
  107. value = strings.TrimSpace(value[:index] + " " + value[index+len(alias):])
  108. break
  109. }
  110. }
  111. value = strings.Trim(value, ":-| ")
  112. if value != "" && !strings.EqualFold(value, "manage") {
  113. return value
  114. }
  115. }
  116. return ""
  117. }
  118. func findLabelElement(root *html.Node, aliases []string) *html.Node {
  119. var best *html.Node
  120. bestLen := int(^uint(0) >> 1)
  121. walkHTML(root, func(node *html.Node) {
  122. if node.Type != html.ElementNode || !isOllamaParserContainer(node.Data) {
  123. return
  124. }
  125. text := strings.ToLower(normalizedNodeText(node))
  126. if text == "" {
  127. return
  128. }
  129. for _, alias := range aliases {
  130. if (text == alias || strings.HasPrefix(text, alias+" ") || strings.HasPrefix(text, alias+":")) && len(text) < bestLen {
  131. best, bestLen = node, len(text)
  132. }
  133. }
  134. })
  135. return best
  136. }
  137. func parseOllamaModels(root *html.Node) []OllamaCloudUsageModel {
  138. models := make([]OllamaCloudUsageModel, 0)
  139. seen := make(map[string]struct{})
  140. appendModel := func(node *html.Node, modelValue, requestsValue string) {
  141. model := strings.TrimSpace(modelValue)
  142. requests, ok := parseOllamaRequestCount(requestsValue)
  143. window := ollamaModelWindow(node)
  144. if !ok || model == "" || len(model) > 128 || window == "" {
  145. return
  146. }
  147. key := model + "\x00" + string(window)
  148. if _, duplicate := seen[key]; duplicate {
  149. return
  150. }
  151. seen[key] = struct{}{}
  152. models = append(models, OllamaCloudUsageModel{Model: model, Window: window, Requests: requests})
  153. }
  154. walkHTML(root, func(node *html.Node) {
  155. if node.Type != html.ElementNode {
  156. return
  157. }
  158. if _, segment := htmlAttribute(node, "data-usage-segment"); !segment {
  159. return
  160. }
  161. model, modelOK := htmlAttributeInSubtree(node, "data-model")
  162. requests, requestsOK := htmlAttributeInSubtree(node, "data-requests")
  163. if modelOK && requestsOK {
  164. appendModel(node, model, requests)
  165. }
  166. })
  167. // Older settings variants may expose the same narrow attributes without the
  168. // segment marker. Do not infer request counts from percentages or limits.
  169. if len(models) == 0 {
  170. walkHTML(root, func(node *html.Node) {
  171. if node.Type != html.ElementNode {
  172. return
  173. }
  174. model, modelOK := htmlAttribute(node, "data-model")
  175. requests, requestsOK := htmlAttribute(node, "data-requests")
  176. if modelOK && requestsOK {
  177. appendModel(node, model, requests)
  178. }
  179. })
  180. }
  181. if len(models) == 0 {
  182. heading := findLabelElement(root, []string{"models", "available models"})
  183. for depth, block := 0, parentNode(heading); block != nil && depth < 4; depth, block = depth+1, block.Parent {
  184. walkHTML(block, func(node *html.Node) {
  185. if node.Type != html.ElementNode || (node.Data != "li" && node.Data != "code") {
  186. return
  187. }
  188. match := ollamaModelFallbackPattern.FindStringSubmatch(strings.TrimSpace(normalizedNodeText(node)))
  189. if len(match) == 3 {
  190. appendModel(node, match[1], match[2])
  191. }
  192. })
  193. if len(models) > 0 {
  194. break
  195. }
  196. }
  197. }
  198. sort.Slice(models, func(i, j int) bool {
  199. if models[i].Window != models[j].Window {
  200. return models[i].Window < models[j].Window
  201. }
  202. return models[i].Model < models[j].Model
  203. })
  204. if len(models) > 100 {
  205. models = models[:100]
  206. }
  207. return models
  208. }
  209. func ollamaModelWindow(node *html.Node) OllamaCloudUsageModelWindow {
  210. for block := parentNode(node); block != nil; block = block.Parent {
  211. if value, ok := htmlAttribute(block, "data-usage-window"); ok {
  212. normalized := strings.ToLower(strings.NewReplacer("-", " ", "_", " ").Replace(value))
  213. if containsAny(normalized, "five hour", "5 hour", "5h", "session") {
  214. return OllamaCloudUsageModelWindowFiveHour
  215. }
  216. if containsAny(normalized, "seven day", "7 day", "7d", "weekly") {
  217. return OllamaCloudUsageModelWindowSevenDay
  218. }
  219. }
  220. text := strings.ToLower(normalizedNodeText(block))
  221. fiveHour := containsAny(text, ollamaFiveHourUsageAliases...)
  222. sevenDay := containsAny(text, ollamaSevenDayUsageAliases...)
  223. if fiveHour && !sevenDay {
  224. return OllamaCloudUsageModelWindowFiveHour
  225. }
  226. if sevenDay && !fiveHour {
  227. return OllamaCloudUsageModelWindowSevenDay
  228. }
  229. }
  230. return ""
  231. }
  232. func parseOllamaRequestCount(value string) (int64, bool) {
  233. value = strings.ReplaceAll(strings.TrimSpace(value), ",", "")
  234. requests, err := strconv.ParseInt(value, 10, 64)
  235. return requests, err == nil && requests >= 0
  236. }
  237. func parentNode(node *html.Node) *html.Node {
  238. if node == nil {
  239. return nil
  240. }
  241. return node.Parent
  242. }
  243. func nextElementSibling(node *html.Node) *html.Node {
  244. if node == nil {
  245. return nil
  246. }
  247. for sibling := node.NextSibling; sibling != nil; sibling = sibling.NextSibling {
  248. if sibling.Type == html.ElementNode {
  249. return sibling
  250. }
  251. }
  252. return nil
  253. }
  254. func timeElementValue(root *html.Node) *time.Time {
  255. var parsed *time.Time
  256. walkHTML(root, func(node *html.Node) {
  257. if parsed != nil || node.Type != html.ElementNode {
  258. return
  259. }
  260. if node.Data == "time" {
  261. if value, ok := htmlAttribute(node, "datetime"); ok {
  262. parsed = parseOllamaResetTime(value)
  263. }
  264. if parsed != nil {
  265. return
  266. }
  267. }
  268. if node.Data == "local-time" || htmlClassToken(node, "local-time") {
  269. if value, ok := htmlAttribute(node, "data-time"); ok {
  270. parsed = parseOllamaResetTime(value)
  271. }
  272. }
  273. })
  274. return parsed
  275. }
  276. func ollamaUsagePercentFromText(value string) (float64, bool) {
  277. match := ollamaUsagePercentPattern.FindStringSubmatch(value)
  278. if len(match) != 2 {
  279. return 0, false
  280. }
  281. percent, err := strconv.ParseFloat(match[1], 64)
  282. return percent, err == nil && percent >= 0 && percent <= 100
  283. }
  284. func ollamaUsagePercentFromTrack(root *html.Node) (float64, bool) {
  285. var percent float64
  286. var found bool
  287. walkHTML(root, func(node *html.Node) {
  288. if found || node.Type != html.ElementNode {
  289. return
  290. }
  291. if _, ok := htmlAttribute(node, "data-usage-track"); !ok {
  292. return
  293. }
  294. var segmentTotal float64
  295. var segmentFound bool
  296. walkHTML(node, func(segment *html.Node) {
  297. if segment.Type != html.ElementNode {
  298. return
  299. }
  300. if _, ok := htmlAttribute(segment, "data-usage-segment"); !ok {
  301. return
  302. }
  303. if value, ok := cssWidthPercent(segment); ok {
  304. segmentTotal += value
  305. segmentFound = true
  306. }
  307. })
  308. if segmentFound && segmentTotal >= 0 && segmentTotal <= 100 {
  309. percent, found = segmentTotal, true
  310. return
  311. }
  312. walkHTML(node, func(child *html.Node) {
  313. if found || child.Type != html.ElementNode {
  314. return
  315. }
  316. if value, ok := cssWidthPercent(child); ok {
  317. percent, found = value, true
  318. }
  319. })
  320. })
  321. return percent, found
  322. }
  323. func cssWidthPercent(node *html.Node) (float64, bool) {
  324. style, ok := htmlAttribute(node, "style")
  325. if !ok {
  326. return 0, false
  327. }
  328. match := ollamaUsageWidthPattern.FindStringSubmatch(style)
  329. if len(match) != 2 {
  330. return 0, false
  331. }
  332. percent, err := strconv.ParseFloat(match[1], 64)
  333. return percent, err == nil && percent >= 0 && percent <= 100
  334. }
  335. func htmlAttribute(node *html.Node, key string) (string, bool) {
  336. if node == nil {
  337. return "", false
  338. }
  339. for _, attr := range node.Attr {
  340. if strings.EqualFold(attr.Key, key) {
  341. return attr.Val, true
  342. }
  343. }
  344. return "", false
  345. }
  346. func htmlAttributeInSubtree(root *html.Node, key string) (string, bool) {
  347. var value string
  348. var found bool
  349. walkHTML(root, func(node *html.Node) {
  350. if found || node.Type != html.ElementNode {
  351. return
  352. }
  353. value, found = htmlAttribute(node, key)
  354. })
  355. return value, found
  356. }
  357. func htmlClassToken(node *html.Node, token string) bool {
  358. value, ok := htmlAttribute(node, "class")
  359. if !ok {
  360. return false
  361. }
  362. for _, className := range strings.Fields(value) {
  363. if className == token {
  364. return true
  365. }
  366. }
  367. return false
  368. }
  369. func parseOllamaResetTime(value string) *time.Time {
  370. value = strings.TrimSpace(value)
  371. for _, layout := range []string{time.RFC3339Nano, time.RFC3339, "Jan 2, 2006 3:04 PM MST", "January 2, 2006 3:04 PM MST"} {
  372. if parsed, err := time.Parse(layout, value); err == nil && !parsed.IsZero() {
  373. parsed = parsed.UTC()
  374. return &parsed
  375. }
  376. }
  377. return nil
  378. }
  379. func normalizedNodeText(node *html.Node) string {
  380. if node == nil {
  381. return ""
  382. }
  383. var parts []string
  384. var collect func(*html.Node)
  385. collect = func(current *html.Node) {
  386. if current.Type == html.ElementNode && (current.Data == "script" || current.Data == "style" || current.Data == "noscript") {
  387. return
  388. }
  389. if current.Type == html.TextNode {
  390. if value := strings.TrimSpace(current.Data); value != "" {
  391. parts = append(parts, value)
  392. }
  393. }
  394. for child := current.FirstChild; child != nil; child = child.NextSibling {
  395. collect(child)
  396. }
  397. }
  398. collect(node)
  399. return strings.Join(strings.Fields(strings.Join(parts, "\n")), " ")
  400. }
  401. func walkHTML(node *html.Node, visit func(*html.Node)) {
  402. if node == nil {
  403. return
  404. }
  405. visit(node)
  406. for child := node.FirstChild; child != nil; child = child.NextSibling {
  407. walkHTML(child, visit)
  408. }
  409. }
  410. func isOllamaParserContainer(tag string) bool {
  411. switch tag {
  412. case "div", "section", "article", "li", "p", "span", "dt", "dd", "h1", "h2", "h3", "h4":
  413. return true
  414. default:
  415. return false
  416. }
  417. }
  418. func containsAny(value string, candidates ...string) bool {
  419. for _, candidate := range candidates {
  420. if strings.Contains(value, candidate) {
  421. return true
  422. }
  423. }
  424. return false
  425. }