ソースを参照

feat: 优化号池管理与阿里云价格解析

- 修复分组 key 推送和取消绑定同步

- 优化阿里云价格解析与模型抓取

- 更新前后端生产环境配置
mengboxin137-blip 2 週間 前
コミット
6663fa3f40

+ 3 - 3
backend/.env

@@ -12,12 +12,12 @@ DB_USER=crawl
 DB_NAME=crawl
 
 # 测试
-# ALLOWED_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
+# ALLOWED_ORIGINS=http://127.0.0.1:5173,http://localhost:5173
 # 生产
 ALLOWED_ORIGINS=https://crawler.aitoolcore.com
 GEOIP_DB_PATH=./GeoLite2-City.mmdb
 #本地
-# PLAYWRIGHT_EXECUTABLE=D:\playwright-browsers\chromium-1208\chrome-win64\chrome.exe
+#PLAYWRIGHT_EXECUTABLE=D:\playwright-browsers\chromium-1208\chrome-win64\chrome.exe
 # 生产
 PLAYWRIGHT_EXECUTABLE=/www/wwwroot/playwright/chromium-1045/chrome-linux/chrome
 PLAYWRIGHT_HEADLESS=true
@@ -29,6 +29,6 @@ APIKEY_ENCRYPT_KEY=25e9e87b18cf40d0ed0f102b8d2ec3a8
 
 # company_base_pool 的地址
 # 测试
-# COMPANY_BASE_POOL_URL=http://localhost:8010
+#COMPANY_BASE_POOL_URL=http://localhost:8010
 # 生产
 COMPANY_BASE_POOL_URL=https://aigc-api.aitoolcore.com

+ 0 - 1
backend/app/routers/group_keys.py

@@ -388,6 +388,5 @@ async def get_group_keys_raw():
         FROM group_keys gk
         JOIN model_groups mg ON mg.id = gk.group_id
         JOIN api_keys ak ON ak.id = gk.api_key_id
-        WHERE gk.is_active = TRUE
     """)
     return [{"group_name": r["group_name"], "key_value": r["key_value"]} for r in rows]

+ 3 - 4
backend/app/services/key_pool_pusher.py

@@ -92,12 +92,12 @@ async def push_group_keys_to_pool(pool, group_id: int):
             if not group:
                 return
 
-            # 获取 keys
+            # 获取 keys(全量推送,不过滤 is_active,健康检测由 AIGC 端负责)
             keys = await conn.fetch("""
                 SELECT ak.key_value
                 FROM api_keys ak
                 JOIN group_keys gk ON gk.api_key_id = ak.id
-                WHERE gk.group_id = $1 AND gk.is_active = TRUE
+                WHERE gk.group_id = $1
             """, group_id)
 
             if not keys:
@@ -138,12 +138,11 @@ async def push_all_groups_to_pool(pool):
             # 设置 search_path
             await conn.execute("SET search_path TO crawl")
 
-            # 获取所有有 key 的分组
+            # 获取所有有 key 的分组(全量推送,不过滤 is_active)
             groups = await conn.fetch("""
                 SELECT DISTINCT g.id, g.name
                 FROM model_groups g
                 JOIN group_keys gk ON gk.group_id = g.id
-                WHERE gk.is_active = TRUE
             """)
 
             logger.info(f"开始推送 {len(groups)} 个分组到号池")

+ 84 - 10
backend/app/utils/price_parser.py

@@ -70,19 +70,54 @@ def _to_tokens(val: str) -> Optional[int]:
 def _parse_price(obj: Any) -> Optional[float]:
     if isinstance(obj, (int, float)):
         return float(obj)
-    if isinstance(obj, dict):
-        v = obj.get("price")
+    if not isinstance(obj, dict):
+        return None
+
+    # 优先取 price_original(原价),爬虫把折扣价和原价分开存
+    for key in ("price_original", "price", "price_current"):
+        v = obj.get(key)
         if v is not None:
             try:
                 return float(v)
             except (TypeError, ValueError):
-                pass
+                continue
+
+    # 嵌套结构:{"8元": {"price": 8}, "24元": {"price": 24}},取最大
+    candidates: List[float] = []
+    for sub_v in obj.values():
+        if isinstance(sub_v, dict):
+            p = _parse_price(sub_v)
+            if p is not None:
+                candidates.append(p)
+        elif isinstance(sub_v, list):
+            for item in sub_v:
+                if isinstance(item, dict):
+                    p = _parse_price(item)
+                    if p is not None:
+                        candidates.append(p)
+    if candidates:
+        return max(candidates)
     return None
 
 
 def _parse_unit(obj: Any) -> Optional[str]:
     if isinstance(obj, dict):
-        return obj.get("unit")
+        # 直接有 unit 字段
+        u = obj.get("unit")
+        if u:
+            return u
+        # 新格式:嵌套结构,递归搜索
+        for sub_v in obj.values():
+            if isinstance(sub_v, dict):
+                u = sub_v.get("unit")
+                if u:
+                    return u
+            elif isinstance(sub_v, list):
+                for item in sub_v:
+                    if isinstance(item, dict):
+                        u = item.get("unit")
+                        if u:
+                            return u
     return None
 
 
@@ -149,10 +184,35 @@ def parse_prices(prices: Dict[str, Any]) -> List[Dict]:
     video_items: List[Dict] = []
     input_entry: Optional[Dict] = None
 
+    _CACHE_RE = re.compile(r"缓存|cache", re.I)
+
+    # 预扫描:是否存在阶梯 key
+    _has_tiers = any(_parse_tier_key(k) is not None for k in prices)
+
+    # 有阶梯时,预先提取顶层 "output"/"输出" 里的所有独立 output 价格
+    # 爬虫把所有 output 价格汇总在顶层 output key 里,需要按大小分配给各阶梯
+    _output_prices: List[float] = []
+    if _has_tiers:
+        for _ok in ("output", "输出"):
+            if _ok in prices and isinstance(prices[_ok], dict):
+                for _sub_v in prices[_ok].values():
+                    _p = _parse_price(_sub_v)
+                    if _p is not None:
+                        _output_prices.append(_p)
+                break
+        _output_prices.sort()  # 从小到大,与阶梯从低到高对应
+
     for key, val in prices.items():
+        # 跳过缓存相关条目
+        if _CACHE_RE.search(key):
+            continue
+
+        if not isinstance(val, dict):
+            continue
+
         # ── token 阶梯 ──
         tier = _parse_tier_key(key)
-        if tier is not None and isinstance(val, dict):
+        if tier is not None:
             entry: Dict = {
                 "label": key,
                 "tier_min": tier[0],
@@ -165,6 +225,8 @@ def parse_prices(prices: Dict[str, Any]) -> List[Dict]:
             }
             for sub_key, sub_val in val.items():
                 sk = sub_key.strip()
+                if _CACHE_RE.search(sk):
+                    continue
                 price = _parse_price(sub_val)
                 unit = _parse_unit(sub_val)
                 if unit:
@@ -173,10 +235,14 @@ def parse_prices(prices: Dict[str, Any]) -> List[Dict]:
                     entry["input_price"] = price
                 elif re.match(r"^输出|^output", sk, re.I):
                     entry["output_price"] = price
+                elif price is not None and entry["input_price"] is None:
+                    # 无标签的纯价格 → 当作输入价格
+                    entry["input_price"] = price
             result.append(entry)
             continue
 
-        if not isinstance(val, dict):
+        # 有阶梯时,顶层 output 已预提取,跳过
+        if _has_tiers and re.match(r"^(输出|output)$", key.strip(), re.I):
             continue
 
         price = _parse_price(val)
@@ -194,7 +260,6 @@ def parse_prices(prices: Dict[str, Any]) -> List[Dict]:
                     "unit": unit,
                 })
             else:
-                # 未知规格,直接输出
                 result.append({
                     "label": key,
                     "tier_min": None,
@@ -207,7 +272,7 @@ def parse_prices(prices: Dict[str, Any]) -> List[Dict]:
                 })
             continue
 
-        # ── 简单非阶梯(输入/输出) ──
+        # ── 简单非阶梯:输入 → 暂存,输出 → 配对 ──
         if re.match(r"^输入|^input", key.strip(), re.I):
             input_entry = {"price": price, "unit": unit}
             continue
@@ -237,7 +302,16 @@ def parse_prices(prices: Dict[str, Any]) -> List[Dict]:
             "unit": unit,
         })
 
-    # 处理只有输入没有输出的情况
+    # 有阶梯时,把预提取的 output 价格按顺序分配给各阶梯
+    if _has_tiers and _output_prices:
+        # 按 tier_min 从小到大排序(与 output 价格从小到大对应)
+        tier_entries = [r for r in result if r.get("tier_unit") == "tokens"]
+        tier_entries.sort(key=lambda e: e["tier_min"] or 0)
+        for i, entry in enumerate(tier_entries):
+            if i < len(_output_prices) and entry["output_price"] is None:
+                entry["output_price"] = _output_prices[i]
+
+    # 只有输入没有输出 → 单独一条
     if input_entry:
         result.append({
             "label": "input",
@@ -250,7 +324,7 @@ def parse_prices(prices: Dict[str, Any]) -> List[Dict]:
             "unit": input_entry["unit"],
         })
 
-    # 视频条目转成连续区间
+    # 视频条目转成连续区间
     if video_items:
         result.extend(_build_video_tiers(video_items))
 

+ 39 - 13
backend/crawl/scrape_aliyun_models.py

@@ -310,11 +310,23 @@ def parse_prices_from_text(text: str) -> List[Dict]:
     _spec_re = re.compile(r"[((]\s*\d+[PpKk]\s*[))]")
     _prev_spec_label: str | None = None
 
+    # 过滤 Batch 相关行(Batch File / Batch Chat),只保留普通聊天价格
+    _batch_re = re.compile(r"batch", re.I)
+    lines = [ln for ln in lines if not _batch_re.search(ln)]
+
+    # 记住最近看到的"输入"/"输出"标签行,用于跨干扰行关联
+    _last_io_label: str | None = None
+    _io_re = re.compile(r"^(输入|输出)$")
+
     for idx, ln in enumerate(lines):
         # 记录最近的规格行(如"视频生成(720P)"),即使本行没有价格
         if _spec_re.search(ln):
             _prev_spec_label = ln
 
+        # 记住最近的纯"输入"或"输出"标签行
+        if _io_re.match(ln.strip()):
+            _last_io_label = ln.strip()
+
         matches = price_re.findall(ln)
         if not matches:
             continue
@@ -326,10 +338,18 @@ def parse_prices_from_text(text: str) -> List[Dict]:
             if before:
                 label = before
         if not label:
+            # 回溯时跳过"原价"、"限时N折"等干扰行,找最近的 io 标签
             for j in range(idx - 1, -1, -1):
-                if lines[j] and not price_re.search(lines[j]):
-                    label = lines[j]
-                    break
+                prev = lines[j].strip()
+                if not prev or price_re.search(lines[j]):
+                    continue
+                if prev in ("原价",) or re.match(r"限时\d*\.?\d*折", prev):
+                    continue
+                label = prev
+                break
+        # 如果回溯没找到有效 label,但有记住的 io 标签,用它
+        if not label and _last_io_label:
+            label = _last_io_label
         # 如果 label 不含视频规格,但前面有规格行,用规格行的文本作为 label
         if label and not _spec_re.search(label) and _prev_spec_label:
             label = _prev_spec_label
@@ -412,15 +432,17 @@ def extract_price_block_html(html: str) -> str:
         return soup.get_text(separator="\n")
 
     ancestor = node.parent
-    for _ in range(6):
+    container = ancestor
+    _has_price_data = re.compile(r"(原价|限时|折|\d+元|元\s*/|每百万|每千)", re.I)
+    for _ in range(15):
         txt = ancestor.get_text(separator="\n")
-        if "元" in txt or re.search(r"\d", txt) or "tokens" in txt.lower():
-            return txt
+        if _has_price_data.search(txt):
+            container = ancestor
         if ancestor.parent:
             ancestor = ancestor.parent
         else:
             break
-    return ancestor.get_text(separator="\n")
+    return container.get_text(separator="\n")
 
 
 def extract_price_items_from_html(html: str) -> List[Dict]:
@@ -440,15 +462,16 @@ def extract_price_items_from_html(html: str) -> List[Dict]:
 
     ancestor = node.parent
     container = ancestor
-    for _ in range(6):
+    # 向上遍历,找到最深的(范围最大的)包含价格数据的祖先
+    # 不能碰到第一个匹配就停——pricingHeader 也含 "tokens" 但只是标题栏
+    _has_price_data = re.compile(r"(原价|限时|折|\d+元|元\s*/|每百万|每千)", re.I)
+    for _ in range(15):
         txt = ancestor.get_text(separator="\n")
-        if "元" in txt or re.search(r"\d", txt) or "tokens" in txt.lower():
-            container = ancestor
-            break
+        if _has_price_data.search(txt):
+            container = ancestor  # 持续更新,取最深的匹配
         if ancestor.parent:
             ancestor = ancestor.parent
         else:
-            container = ancestor
             break
 
     price_re = re.compile(r"([0-9]+(?:\.[0-9]+)?)\s*元", re.I)
@@ -471,7 +494,6 @@ def extract_price_items_from_html(html: str) -> List[Dict]:
             ln.strip() for ln in _wide_text.splitlines()
             if ln.strip() and _spec_re.search(ln.strip()) and not price_re.search(ln.strip())
         ]
-        print(f"[DEBUG] found {_spec_lines!r} spec lines")
         if _spec_lines:
             _si = 0
             for it in items:
@@ -491,6 +513,10 @@ def extract_price_items_from_html(html: str) -> List[Dict]:
             if _is_tool_call_item(label, raw, unit):
                 continue
 
+            # 过滤 Batch File / Batch Chat 相关条目
+            if re.search(r"batch", raw, re.I) or re.search(r"batch", label, re.I):
+                continue
+
             if "原价" in raw and filtered:
                 if "price" in it:
                     filtered[-1]["price_original"] = it["price"]

+ 4 - 4
frontend/.env

@@ -1,6 +1,6 @@
 #测试
-VITE_API_BASE_URL=http://localhost:8000
-VITE_COMPANY_BASE_POOL_API=http://localhost:8011
+# VITE_API_BASE_URL=http://localhost:8000
+# VITE_COMPANY_BASE_POOL_API=http://localhost:8010
 #生产
-# VITE_API_BASE_URL=https://crawler-api.aitoolcore.com
-# VITE_COMPANY_BASE_POOL_API=https://aigc-api.wangxunai.com
+VITE_API_BASE_URL=https://crawler-api.aitoolcore.com
+VITE_COMPANY_BASE_POOL_API=https://aigc-api.wangxunai.com

+ 4 - 1
frontend/src/pages/GroupKeys.tsx

@@ -91,7 +91,10 @@ export function GroupKeys() {
     setTestResult(null);
     try {
       const result = await testAllGroupKeys(selectedGroup.id);
-      setTestResult(result);
+      setTestResult({
+        ...result,
+        health_rate: result.total > 0 ? Math.round((result.active / result.total) * 100) : 0,
+      });
       selectGroup(selectedGroup);
     } catch (e) {
       setError(`检测失败: ${e instanceof Error ? e.message : String(e)}`);