price_parser.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364
  1. """
  2. price_parser.py
  3. 统一价格结构,所有模型类型输出相同字段:
  4. tier_min : 档位下限(token数 或 秒数),0 表示从0开始,None 表示无档位
  5. tier_max : 档位上限(token数 或 秒数),None 表示无上限
  6. tier_unit : 档位单位,"tokens" 或 "seconds",None 表示无档位
  7. input_price : 输入价格(元/百万tokens 或 0),视频/图像为 0
  8. output_price : 输出价格(元/百万tokens)或视频/图像的生成价格
  9. currency : "CNY"
  10. unit : 计费单位原始字符串
  11. label : 原始 key
  12. 视频规格 -> 秒数映射:
  13. 480P -> 0 ~ 480
  14. 720P -> 0 ~ 720 (或 481 ~ 720)
  15. 1080P -> 0 ~ 1080 (或 721 ~ 1080)
  16. 4K -> 0 ~ 2160
  17. """
  18. from __future__ import annotations
  19. import re
  20. from typing import Any, Dict, List, Optional
  21. # ── 视频规格 -> 最大秒数 ────────────────────────────────────────────────────────
  22. _VIDEO_SPEC_MAX: Dict[str, int] = {
  23. "480P": 480,
  24. "480p": 480,
  25. "720P": 720,
  26. "720p": 720,
  27. "1080P": 1080,
  28. "1080p": 1080,
  29. "2K": 1440,
  30. "4K": 2160,
  31. }
  32. # 非 token 计费单位
  33. _NON_TOKEN_UNITS = re.compile(r"每秒|每张|每次|每帧|/秒|/张|/次", re.I)
  34. # token 阶梯 key 正则
  35. # 情况1:input<=128k / 32k<input<=128k(有上限)
  36. _TIER_RE = re.compile(
  37. r"^(?:([\d.]+[KkMm]?)\s*<\s*)?(?:input|输入)\s*<=?\s*([\d.]+[KkMm]?)$",
  38. re.I,
  39. )
  40. # 情况2:256k<input(只有下限,无上限)
  41. _TIER_RE_LO_ONLY = re.compile(
  42. r"^([\d.]+[KkMm]?)\s*<\s*(?:input|输入)$",
  43. re.I,
  44. )
  45. def _to_tokens(val: str) -> Optional[int]:
  46. """把 '32k'/'128K'/'1M' 转成 token 整数。"""
  47. s = str(val).strip().upper().replace(",", "")
  48. m = re.match(r"^([\d.]+)\s*([KMG]?)$", s)
  49. if not m:
  50. return None
  51. num = float(m.group(1))
  52. suffix = m.group(2)
  53. if suffix == "K":
  54. return int(num * 1_000)
  55. if suffix == "M":
  56. return int(num * 1_000_000)
  57. return int(num)
  58. def _parse_price(obj: Any) -> Optional[float]:
  59. if isinstance(obj, (int, float)):
  60. return float(obj)
  61. if not isinstance(obj, dict):
  62. return None
  63. # 优先取 price_original(原价),爬虫把折扣价和原价分开存
  64. for key in ("price_original", "price", "price_current"):
  65. v = obj.get(key)
  66. if v is not None:
  67. try:
  68. return float(v)
  69. except (TypeError, ValueError):
  70. continue
  71. # 嵌套结构:{"8元": {"price": 8}, "24元": {"price": 24}},取最大
  72. candidates: List[float] = []
  73. for sub_v in obj.values():
  74. if isinstance(sub_v, dict):
  75. p = _parse_price(sub_v)
  76. if p is not None:
  77. candidates.append(p)
  78. elif isinstance(sub_v, list):
  79. for item in sub_v:
  80. if isinstance(item, dict):
  81. p = _parse_price(item)
  82. if p is not None:
  83. candidates.append(p)
  84. if candidates:
  85. return max(candidates)
  86. return None
  87. def _parse_unit(obj: Any) -> Optional[str]:
  88. if isinstance(obj, dict):
  89. # 直接有 unit 字段
  90. u = obj.get("unit")
  91. if u:
  92. return u
  93. # 新格式:嵌套结构,递归搜索
  94. for sub_v in obj.values():
  95. if isinstance(sub_v, dict):
  96. u = sub_v.get("unit")
  97. if u:
  98. return u
  99. elif isinstance(sub_v, list):
  100. for item in sub_v:
  101. if isinstance(item, dict):
  102. u = item.get("unit")
  103. if u:
  104. return u
  105. return None
  106. def _parse_tier_key(key: str):
  107. """解析 token 阶梯 key,返回 (min_tokens, max_tokens) 或 None。"""
  108. k = key.strip().lower().replace(" ", "")
  109. m = _TIER_RE.match(k)
  110. if m:
  111. lo_str, hi_str = m.group(1), m.group(2)
  112. lo = _to_tokens(lo_str) if lo_str else 0
  113. hi = _to_tokens(hi_str) if hi_str else None
  114. return (lo, hi)
  115. # 只有下限:256k<input
  116. m2 = _TIER_RE_LO_ONLY.match(k)
  117. if m2:
  118. lo = _to_tokens(m2.group(1))
  119. return (lo, None)
  120. return None
  121. def _normalize_price_label(label: str) -> str:
  122. text = (label or "").strip().lower()
  123. if not text:
  124. return label
  125. text = text.replace("(", "(").replace(")", ")")
  126. if text in {"输入", "input"}:
  127. return "input"
  128. if text in {"输出", "output"}:
  129. return "output"
  130. if ("输入" in text or "input" in text) and "缓存命中" in text:
  131. return "input_cache_hit"
  132. if ("输入" in text or "input" in text) and "batch file" in text:
  133. return "input_batch"
  134. if ("输出" in text or "output" in text) and "batch file" in text:
  135. return "output_batch"
  136. if "显式缓存创建" in text:
  137. return "explicit_cache_create"
  138. if "显式缓存命中" in text:
  139. return "explicit_cache_hit"
  140. return label
  141. def _extract_video_spec(label: str) -> Optional[str]:
  142. """从 label 中提取视频规格,如 '视频生成(720P)' -> '720P'。"""
  143. m = re.search(r"[((]([^))]+)[))]", label)
  144. if m:
  145. spec = m.group(1).strip()
  146. if spec.upper() in {k.upper() for k in _VIDEO_SPEC_MAX}:
  147. return spec.upper()
  148. # 直接在 label 里找
  149. for spec in _VIDEO_SPEC_MAX:
  150. if spec.upper() in label.upper():
  151. return spec.upper()
  152. return None
  153. def _build_video_tiers(items: List[Dict]) -> List[Dict]:
  154. """
  155. 把多个视频规格条目转成连续区间:
  156. 720P(0.6) + 1080P(1.0) ->
  157. [0, 720, input=0, output=0.6]
  158. [721, 1080, input=0, output=1.0]
  159. """
  160. # 按 tier_max 排序
  161. sorted_items = sorted(items, key=lambda x: x["_spec_max"])
  162. result = []
  163. prev_max = 0
  164. for item in sorted_items:
  165. spec_max = item["_spec_max"]
  166. result.append({
  167. "label": item["label"],
  168. "tier_min": prev_max + (1 if prev_max > 0 else 0),
  169. "tier_max": spec_max,
  170. "tier_unit": "seconds",
  171. "input_price": 0.0,
  172. "output_price": item["price"],
  173. "currency": item["currency"],
  174. "unit": item["unit"],
  175. })
  176. prev_max = spec_max
  177. return result
  178. def parse_prices(prices: Dict[str, Any]) -> List[Dict]:
  179. result: List[Dict] = []
  180. video_items: List[Dict] = []
  181. input_entry: Optional[Dict] = None
  182. # 预扫描:是否存在阶梯 key
  183. _has_tiers = any(_parse_tier_key(k) is not None for k in prices)
  184. # 有阶梯时,预先提取顶层 "output"/"输出" 里的所有独立 output 价格
  185. # 爬虫把所有 output 价格汇总在顶层 output key 里,需要按大小分配给各阶梯
  186. _output_prices: List[float] = []
  187. if _has_tiers:
  188. for _ok in ("output", "输出"):
  189. if _ok in prices and isinstance(prices[_ok], dict):
  190. for _sub_v in prices[_ok].values():
  191. _p = _parse_price(_sub_v)
  192. if _p is not None:
  193. _output_prices.append(_p)
  194. break
  195. _output_prices.sort() # 从小到大,与阶梯从低到高对应
  196. for key, val in prices.items():
  197. if not isinstance(val, dict):
  198. continue
  199. # ── token 阶梯 ──
  200. tier = _parse_tier_key(key)
  201. if tier is not None:
  202. entry: Dict = {
  203. "label": key,
  204. "tier_min": tier[0],
  205. "tier_max": tier[1],
  206. "tier_unit": "tokens",
  207. "input_price": None,
  208. "output_price": None,
  209. "currency": "CNY",
  210. "unit": None,
  211. }
  212. for sub_key, sub_val in val.items():
  213. sk = sub_key.strip()
  214. normalized_key = _normalize_price_label(sk)
  215. price = _parse_price(sub_val)
  216. unit = _parse_unit(sub_val)
  217. if unit:
  218. entry["unit"] = unit
  219. if normalized_key == "input":
  220. entry["input_price"] = price
  221. elif normalized_key == "output":
  222. entry["output_price"] = price
  223. elif price is not None and normalized_key != sk:
  224. entry[normalized_key] = price
  225. elif price is not None and entry["input_price"] is None:
  226. # 无标签的纯价格 → 当作输入价格
  227. entry["input_price"] = price
  228. result.append(entry)
  229. continue
  230. # 有阶梯时,顶层 output 已预提取,跳过
  231. if _has_tiers and re.match(r"^(输出|output)$", key.strip(), re.I):
  232. continue
  233. price = _parse_price(val)
  234. unit = _parse_unit(val)
  235. # ── 视频/图像按单位计费 ──
  236. if _NON_TOKEN_UNITS.search(unit or ""):
  237. spec = _extract_video_spec(key)
  238. if spec and spec in _VIDEO_SPEC_MAX:
  239. video_items.append({
  240. "label": key,
  241. "_spec_max": _VIDEO_SPEC_MAX[spec],
  242. "price": price,
  243. "currency": "CNY",
  244. "unit": unit,
  245. })
  246. else:
  247. result.append({
  248. "label": key,
  249. "tier_min": None,
  250. "tier_max": None,
  251. "tier_unit": None,
  252. "input_price": 0.0,
  253. "output_price": price,
  254. "currency": "CNY",
  255. "unit": unit,
  256. })
  257. continue
  258. # ── 简单非阶梯:输入 → 暂存,输出 → 配对 ──
  259. normalized_label = _normalize_price_label(key.strip())
  260. if normalized_label == "input":
  261. input_entry = {"price": price, "unit": unit}
  262. continue
  263. if normalized_label == "output":
  264. result.append({
  265. "label": "input/output",
  266. "tier_min": None,
  267. "tier_max": None,
  268. "tier_unit": None,
  269. "input_price": input_entry["price"] if input_entry else None,
  270. "output_price": price,
  271. "currency": "CNY",
  272. "unit": unit or (input_entry["unit"] if input_entry else None),
  273. })
  274. input_entry = None
  275. continue
  276. if normalized_label != key.strip():
  277. is_output_side = normalized_label.startswith("output")
  278. result.append({
  279. "label": normalized_label,
  280. "tier_min": None,
  281. "tier_max": None,
  282. "tier_unit": None,
  283. "input_price": None if is_output_side else price,
  284. "output_price": price if is_output_side else None,
  285. "currency": "CNY",
  286. "unit": unit,
  287. })
  288. continue
  289. # 其他普通标签
  290. result.append({
  291. "label": key,
  292. "tier_min": None,
  293. "tier_max": None,
  294. "tier_unit": None,
  295. "input_price": price,
  296. "output_price": None,
  297. "currency": "CNY",
  298. "unit": unit,
  299. })
  300. # 有阶梯时,把预提取的 output 价格按顺序分配给各阶梯
  301. if _has_tiers and _output_prices:
  302. # 按 tier_min 从小到大排序(与 output 价格从小到大对应)
  303. tier_entries = [r for r in result if r.get("tier_unit") == "tokens"]
  304. tier_entries.sort(key=lambda e: e["tier_min"] or 0)
  305. for i, entry in enumerate(tier_entries):
  306. if i < len(_output_prices) and entry["output_price"] is None:
  307. entry["output_price"] = _output_prices[i]
  308. # 只有输入没有输出 → 单独一条
  309. if input_entry:
  310. result.append({
  311. "label": "input",
  312. "tier_min": None,
  313. "tier_max": None,
  314. "tier_unit": None,
  315. "input_price": input_entry["price"],
  316. "output_price": None,
  317. "currency": "CNY",
  318. "unit": input_entry["unit"],
  319. })
  320. # 视频条目转成连续区间
  321. if video_items:
  322. result.extend(_build_video_tiers(video_items))
  323. return result