price_parser.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331
  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 _extract_video_spec(label: str) -> Optional[str]:
  122. """从 label 中提取视频规格,如 '视频生成(720P)' -> '720P'。"""
  123. m = re.search(r"[((]([^))]+)[))]", label)
  124. if m:
  125. spec = m.group(1).strip()
  126. if spec.upper() in {k.upper() for k in _VIDEO_SPEC_MAX}:
  127. return spec.upper()
  128. # 直接在 label 里找
  129. for spec in _VIDEO_SPEC_MAX:
  130. if spec.upper() in label.upper():
  131. return spec.upper()
  132. return None
  133. def _build_video_tiers(items: List[Dict]) -> List[Dict]:
  134. """
  135. 把多个视频规格条目转成连续区间:
  136. 720P(0.6) + 1080P(1.0) ->
  137. [0, 720, input=0, output=0.6]
  138. [721, 1080, input=0, output=1.0]
  139. """
  140. # 按 tier_max 排序
  141. sorted_items = sorted(items, key=lambda x: x["_spec_max"])
  142. result = []
  143. prev_max = 0
  144. for item in sorted_items:
  145. spec_max = item["_spec_max"]
  146. result.append({
  147. "label": item["label"],
  148. "tier_min": prev_max + (1 if prev_max > 0 else 0),
  149. "tier_max": spec_max,
  150. "tier_unit": "seconds",
  151. "input_price": 0.0,
  152. "output_price": item["price"],
  153. "currency": item["currency"],
  154. "unit": item["unit"],
  155. })
  156. prev_max = spec_max
  157. return result
  158. def parse_prices(prices: Dict[str, Any]) -> List[Dict]:
  159. result: List[Dict] = []
  160. video_items: List[Dict] = []
  161. input_entry: Optional[Dict] = None
  162. _CACHE_RE = re.compile(r"缓存|cache", re.I)
  163. # 预扫描:是否存在阶梯 key
  164. _has_tiers = any(_parse_tier_key(k) is not None for k in prices)
  165. # 有阶梯时,预先提取顶层 "output"/"输出" 里的所有独立 output 价格
  166. # 爬虫把所有 output 价格汇总在顶层 output key 里,需要按大小分配给各阶梯
  167. _output_prices: List[float] = []
  168. if _has_tiers:
  169. for _ok in ("output", "输出"):
  170. if _ok in prices and isinstance(prices[_ok], dict):
  171. for _sub_v in prices[_ok].values():
  172. _p = _parse_price(_sub_v)
  173. if _p is not None:
  174. _output_prices.append(_p)
  175. break
  176. _output_prices.sort() # 从小到大,与阶梯从低到高对应
  177. for key, val in prices.items():
  178. # 跳过缓存相关条目
  179. if _CACHE_RE.search(key):
  180. continue
  181. if not isinstance(val, dict):
  182. continue
  183. # ── token 阶梯 ──
  184. tier = _parse_tier_key(key)
  185. if tier is not None:
  186. entry: Dict = {
  187. "label": key,
  188. "tier_min": tier[0],
  189. "tier_max": tier[1],
  190. "tier_unit": "tokens",
  191. "input_price": None,
  192. "output_price": None,
  193. "currency": "CNY",
  194. "unit": None,
  195. }
  196. for sub_key, sub_val in val.items():
  197. sk = sub_key.strip()
  198. if _CACHE_RE.search(sk):
  199. continue
  200. price = _parse_price(sub_val)
  201. unit = _parse_unit(sub_val)
  202. if unit:
  203. entry["unit"] = unit
  204. if re.match(r"^输入|^input", sk, re.I):
  205. entry["input_price"] = price
  206. elif re.match(r"^输出|^output", sk, re.I):
  207. entry["output_price"] = price
  208. elif price is not None and entry["input_price"] is None:
  209. # 无标签的纯价格 → 当作输入价格
  210. entry["input_price"] = price
  211. result.append(entry)
  212. continue
  213. # 有阶梯时,顶层 output 已预提取,跳过
  214. if _has_tiers and re.match(r"^(输出|output)$", key.strip(), re.I):
  215. continue
  216. price = _parse_price(val)
  217. unit = _parse_unit(val)
  218. # ── 视频/图像按单位计费 ──
  219. if _NON_TOKEN_UNITS.search(unit or ""):
  220. spec = _extract_video_spec(key)
  221. if spec and spec in _VIDEO_SPEC_MAX:
  222. video_items.append({
  223. "label": key,
  224. "_spec_max": _VIDEO_SPEC_MAX[spec],
  225. "price": price,
  226. "currency": "CNY",
  227. "unit": unit,
  228. })
  229. else:
  230. result.append({
  231. "label": key,
  232. "tier_min": None,
  233. "tier_max": None,
  234. "tier_unit": None,
  235. "input_price": 0.0,
  236. "output_price": price,
  237. "currency": "CNY",
  238. "unit": unit,
  239. })
  240. continue
  241. # ── 简单非阶梯:输入 → 暂存,输出 → 配对 ──
  242. if re.match(r"^输入|^input", key.strip(), re.I):
  243. input_entry = {"price": price, "unit": unit}
  244. continue
  245. if re.match(r"^输出|^output", key.strip(), re.I):
  246. result.append({
  247. "label": "input/output",
  248. "tier_min": None,
  249. "tier_max": None,
  250. "tier_unit": None,
  251. "input_price": input_entry["price"] if input_entry else None,
  252. "output_price": price,
  253. "currency": "CNY",
  254. "unit": unit or (input_entry["unit"] if input_entry else None),
  255. })
  256. input_entry = None
  257. continue
  258. # 其他普通标签
  259. result.append({
  260. "label": key,
  261. "tier_min": None,
  262. "tier_max": None,
  263. "tier_unit": None,
  264. "input_price": price,
  265. "output_price": None,
  266. "currency": "CNY",
  267. "unit": unit,
  268. })
  269. # 有阶梯时,把预提取的 output 价格按顺序分配给各阶梯
  270. if _has_tiers and _output_prices:
  271. # 按 tier_min 从小到大排序(与 output 价格从小到大对应)
  272. tier_entries = [r for r in result if r.get("tier_unit") == "tokens"]
  273. tier_entries.sort(key=lambda e: e["tier_min"] or 0)
  274. for i, entry in enumerate(tier_entries):
  275. if i < len(_output_prices) and entry["output_price"] is None:
  276. entry["output_price"] = _output_prices[i]
  277. # 只有输入没有输出 → 单独一条
  278. if input_entry:
  279. result.append({
  280. "label": "input",
  281. "tier_min": None,
  282. "tier_max": None,
  283. "tier_unit": None,
  284. "input_price": input_entry["price"],
  285. "output_price": None,
  286. "currency": "CNY",
  287. "unit": input_entry["unit"],
  288. })
  289. # 视频条目转成连续区间
  290. if video_items:
  291. result.extend(_build_video_tiers(video_items))
  292. return result