ai_helper_vision1.py 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488
  1. """
  2. AI 视觉助手1 — 列表页商品识别(最终版)
  3. =============================================================================
  4. 流程:PP-OCRv6 识别(左右切割:物理裁掉左列商品图,秒级,生僻字强,坐标=真实屏幕像素)
  5. → GLM-5.2 分卡整理(V5提示词:卡片结构 + 残卡丢弃 + 推荐区忽略)
  6. → 几何校验(价格/店铺/标题逐卡反查回贴:宁丢卡不配错,与类目无关)
  7. 返回格式与 ai_helper_vision.VisionParser 一致,可直接替换。
  8. 独立测试:PyCharm 里直接 Run(配置在 __main__ 的 TEST_IMAGE / TEST_KEYWORD)
  9. """
  10. import json
  11. import os
  12. import re
  13. import sys
  14. from typing import Optional
  15. import requests
  16. # ── 配置 ────────────────────────────────────────────────
  17. GLM_URL = "https://ai-api.dfwy.tech/v1/messages" # 中转站(Anthropic 格式端点)
  18. GLM_MODEL = "glm-5.2"
  19. GLM_KEY = "sk-Jzfd31hxASmK7nr9RyFfIEnn7dIA2e7zucfoovbOw4Hbo3kP" # 中转站令牌
  20. _PP_OCR = None # PP-OCRv6 懒加载单例(模型初始化需数秒,进程内复用)
  21. # ── 几何校验常量(按屏幕尺寸比例,类目无关,实测标定@1220x2712)──
  22. GAP_RATIO = 0.08 # 价格→店铺最大垂直距离216px(同卡实测82~152px,跨卡≥577px)
  23. TITLE_ABOVE_RATIO = 0.12 # 标题验证/回贴窗口(价格上方325px)
  24. TITLE_BELOW_RATIO = 0.19 # 缝合卡检测窗口(价格下方515px)
  25. RIGHT_COL_RATIO = 0.37 # 右列文字x中线阈值(过滤商品包装图噪声,包装字在更左侧)
  26. MIN_TITLE_LEN = 6 # 标题块最低字符数(功效行/截断碎片≈4字,真标题≥6字)
  27. # 促销/配送行特征(识别"店铺行"时排除用;满\d/减\d 带数字,避免误伤"满堂堂/减脂"类店名)
  28. AID_PROMO_RE = re.compile(r"起送|满\d|配送|发货|日达|分钟|准时|红包|已售|月售|预估|减\d|新客|专享|原研")
  29. def _get_ppocr():
  30. """懒加载 PP-OCRv6"""
  31. global _PP_OCR
  32. if _PP_OCR is None:
  33. from paddleocr import PaddleOCR
  34. _PP_OCR = PaddleOCR(
  35. use_doc_orientation_classify=False,
  36. use_doc_unwarping=False,
  37. use_textline_orientation=False,
  38. enable_mkldnn=False, # 绕过 Windows CPU oneDNN 推理 bug
  39. )
  40. return _PP_OCR
  41. class VisionParser1:
  42. """PP-OCRv6 识别 + GLM 逐卡整理"""
  43. def __init__(self, api_key: Optional[str] = None):
  44. self.api_key = api_key or GLM_KEY
  45. # ── 主流程 ──────────────────────────────────────────
  46. def parse_shops(self, image_path: str, screen_size: tuple = (720, 1640),
  47. keyword: str = "", crop_x: int = 0) -> tuple:
  48. """返回 (shops, status):status = "ok" | "page_wrong"(页面不是药品列表) | "failed"(GLM失败)"""
  49. screen_w, screen_h = screen_size
  50. # 1. PP-OCRv6 识别(先物理裁掉左列商品图,只看右列文字区)
  51. blocks = self._ppocr_blocks(image_path, crop_x)
  52. print(f"[v1] ---------- PP-OCRv6 块(文字+坐标) ----------")
  53. for i, b in enumerate(blocks, 1):
  54. box = b["box"]
  55. print(f" ocr[{i:02d}] box=[{box[0]},{box[1]}~{box[2]},{box[3]}] {b['text']}")
  56. if not blocks:
  57. print("[v1] PP-OCRv6 未识别到文字块(页面可能没加载出来)")
  58. return [], "failed"
  59. # 2. 全部文字块打包一次丢给 GLM:AI 自己划分卡片 + 提取字段(不做硬代码分组)
  60. shops, status = self._extract_all_cards(blocks, screen_w, screen_h, keyword)
  61. print(f"[v1] 最终 {len(shops)} 个店铺 (status={status})")
  62. return shops, status
  63. # ── PP-OCRv6 识别(左右切割:先物理裁掉左列商品图再识别)──
  64. def _ppocr_blocks(self, image_path: str, crop_x: int = 0) -> list:
  65. """PP-OCRv6 识别。crop_x>0 时先把左列商品图【物理切割】掉,
  66. 只识别右列文字区(模型完全看不到左列干扰),坐标加回偏移还原。
  67. 返回 [{text, box, bbox}](box 为真实屏幕坐标 [x1,y1,x2,y2])"""
  68. import numpy as np
  69. from PIL import Image
  70. ocr = _get_ppocr()
  71. img = Image.open(image_path).convert("RGB")
  72. W, H = img.size
  73. if crop_x and crop_x < W:
  74. seg = img.crop((crop_x, 0, W, H)) # 物理切割:只留右列
  75. xoff = crop_x
  76. else:
  77. seg = img
  78. xoff = 0
  79. blocks = []
  80. for res in ocr.predict(np.asarray(seg)):
  81. try:
  82. texts, boxes = res["rec_texts"], res["rec_boxes"]
  83. except Exception:
  84. j = res.json["res"]
  85. texts, boxes = j["rec_texts"], j["rec_boxes"]
  86. for t, bx in zip(texts, boxes):
  87. box = [int(v) for v in bx]
  88. box[0] += xoff
  89. box[2] += xoff
  90. x1, y1, x2, y2 = box
  91. if y2 < 80:
  92. continue # 状态栏
  93. blocks.append({"text": str(t), "box": box,
  94. "bbox": [[x1, y1], [x2, y1], [x2, y2], [x1, y2]]})
  95. return blocks
  96. # ── GLM 批量整理(全部文字块一次调用:AI自己分卡+提取)──
  97. def _extract_all_cards(self, blocks: list, screen_w: int, screen_h: int, keyword: str = "") -> tuple:
  98. """全部 OCR 文字块打包一次丢给 GLM,AI 自己划分卡片并提取字段。
  99. 返回 (shops, status):status = "ok" | "page_wrong"(页面不是药品列表) | "failed"(GLM失败)"""
  100. bs = sorted(blocks, key=lambda b: b["box"][1])
  101. lines = []
  102. for b in bs:
  103. box = b["box"]
  104. cx = (box[0] + box[2]) // 2
  105. cy = (box[1] + box[3]) // 2
  106. lines.append(f"[x={cx:4d}, y={cy:4d}] {b.get('text','')}")
  107. ocr_text = "\n".join(lines)
  108. system_prompt = self._cards_prompt()
  109. user_text = (
  110. f"屏幕OCR识别结果(共{len(bs)}个文字块):\n\n{ocr_text}\n\n"
  111. f"当前搜索的商品是「{keyword}」。请划分商品卡片并逐卡提取字段。"
  112. )
  113. resp = self._call_llm(system_prompt, user_text)
  114. cleaned = (resp or "").strip()
  115. if cleaned.startswith("```"):
  116. ls = cleaned.split("\n")
  117. if ls and ls[0].startswith("```"):
  118. ls = ls[1:]
  119. if ls and ls[-1].strip() == "```":
  120. ls = ls[:-1]
  121. cleaned = "\n".join(ls).strip()
  122. data = None
  123. try:
  124. data = json.loads(cleaned)
  125. except json.JSONDecodeError:
  126. m = re.search(r'\[[\s\S]*\]', cleaned)
  127. if m:
  128. try:
  129. data = json.loads(m.group(0))
  130. except json.JSONDecodeError:
  131. print(f"[v1] GLM 返回JSON解析失败: {cleaned[:200]}")
  132. if not isinstance(data, list):
  133. print(f"[v1] GLM 返回不是数组,按失败处理")
  134. return [], "failed"
  135. # GLM判定页面不是药品列表(美食/外卖/首页等频道)→ 交给上层走恢复流程
  136. if len(data) == 1 and isinstance(data[0], dict) and data[0].get("page_wrong"):
  137. print(f"[v1] GLM 判定: 当前页面不是药品搜索列表(page_wrong)")
  138. return [], "page_wrong"
  139. shops = []
  140. for i, item in enumerate(data, 1):
  141. if not isinstance(item, dict):
  142. continue
  143. title = str(item.get("title", "") or "")
  144. price = str(item.get("price", "") or "")
  145. shop = str(item.get("shop", "") or "")
  146. sales = str(item.get("sales", "") or "")
  147. click = item.get("click") or item.get("coords") or []
  148. if not (shop and title and price):
  149. print(f"[v1] 卡{i}: [丢弃] 字段不完整: {shop} | {title[:20]} | {price}")
  150. continue
  151. if '¥' in title or '¥' in title:
  152. print(f"[v1] 卡{i}: [丢弃] 标题含价格符号(残卡)")
  153. continue
  154. if isinstance(click, list) and len(click) == 2:
  155. try:
  156. cx, cy = int(click[0]), int(click[1])
  157. except (TypeError, ValueError):
  158. continue
  159. if not (0 <= cx <= screen_w and 0 <= cy <= screen_h):
  160. print(f"[v1] 卡{i}: [丢弃] 坐标越界: ({cx},{cy})")
  161. continue
  162. else:
  163. print(f"[v1] 卡{i}: [丢弃] 无有效点击坐标")
  164. continue
  165. shop_y = int(item.get("shop_y", cy)) if str(item.get("shop_y", "")).lstrip('-').isdigit() else cy
  166. print(f"[v1] 卡{i}: [收录] {shop} | {title[:36]} | {price} | click=({cx},{cy}) | 已售={sales}")
  167. shops.append([shop, title, price, [cx, cy], shop_y, sales])
  168. # 几何校验:宁丢卡不配错(残卡泄漏/店铺错配/标题错位/形近字在此修正)
  169. shops = self._validate_cards(shops, bs, screen_w, screen_h)
  170. return shops, "ok"
  171. # ── 几何校验(GLM返回后逐卡验证、回贴、丢弃;规则全部基于布局几何,无领域词)──
  172. @staticmethod
  173. def _gnorm(s) -> str:
  174. return re.sub(r"\s+", "", str(s or "")).replace("(", "(").replace(")", ")").lower()
  175. @staticmethod
  176. def _gcy(b: dict) -> int:
  177. return (b["box"][1] + b["box"][3]) // 2
  178. @staticmethod
  179. def _gcx(b: dict) -> int:
  180. return (b["box"][0] + b["box"][2]) // 2
  181. @staticmethod
  182. def _glcs(a: str, b: str) -> int:
  183. """最长公共子串长度"""
  184. best = 0
  185. prev = [0] * (len(b) + 1)
  186. for ca in a:
  187. cur = [0]
  188. for j, cb in enumerate(b, 1):
  189. cur.append(prev[j - 1] + 1 if ca == cb else 0)
  190. best = max(best, cur[-1])
  191. prev = cur
  192. return best
  193. @classmethod
  194. def _price_block(cls, blocks: list, price: str, click_y) -> Optional[dict]:
  195. """R1: 找含该价格数字的¥块(取离click_y最近的)。找不到=编造价格"""
  196. m = re.search(r"[\d.]+", str(price or ""))
  197. if not m:
  198. return None
  199. pn = m.group(0).rstrip(".")
  200. cands = [b for b in blocks if ("¥" in b["text"] or "¥" in b["text"])
  201. and re.search(r"(?<![\d.])" + re.escape(pn) + r"(?![\d])", cls._gnorm(b["text"]))]
  202. if not cands:
  203. return None
  204. ref = click_y[1] if isinstance(click_y, (list, tuple)) and len(click_y) == 2 else 10 ** 9
  205. return min(cands, key=lambda b: abs(cls._gcy(b) - ref))
  206. @classmethod
  207. def _shop_text(cls, text: str) -> str:
  208. """店铺最终文本 = OCR块文本取括号前"""
  209. return re.split(r"[((]", re.sub(r"\s+", "", str(text or "")))[0]
  210. @classmethod
  211. def _shop_block(cls, blocks: list, shop: str, y_from: int, y_to: int) -> Optional[dict]:
  212. """R2: [y_from,y_to)窗口内找店铺名块。
  213. 容错匹配:包含关系 或 括号前店名等长仅差1字(防 沪/沚/滪 形近字)"""
  214. ns = cls._gnorm(shop)
  215. if len(ns) < 3:
  216. return None
  217. best, best_score = None, 0
  218. for b in blocks:
  219. y = cls._gcy(b)
  220. if not (y_from <= y < y_to):
  221. continue
  222. ok = False
  223. for tt in (cls._gnorm(b["text"]), cls._gnorm(cls._shop_text(b["text"]))):
  224. if len(tt) < 3:
  225. continue
  226. if ns in tt or tt in ns:
  227. ok = True
  228. break
  229. if len(tt) == len(ns) and len(ns) >= 4 and sum(a != c for a, c in zip(tt, ns)) == 1:
  230. ok = True
  231. break
  232. if ok:
  233. score = min(len(ns), len(cls._gnorm(b["text"])))
  234. if score > best_score:
  235. best, best_score = b, score
  236. return best
  237. @classmethod
  238. def _shop_row_repair(cls, blocks: list, y_from: int, y_to: int) -> Optional[dict]:
  239. """R2修复: GLM把店名整列配错位时(典型=顶部残卡店名强配下一卡,引发级联),
  240. 按几何重新定位本卡的店铺行。店铺行特征:非促销、非价格、长度≥4,
  241. 且同一行(±40px)有 发货/起送/日达/分钟 等伴随块(标题行从来没有伴随块)。"""
  242. for b in sorted((x for x in blocks if y_from <= cls._gcy(x) < y_to), key=cls._gcy):
  243. t = cls._gnorm(b["text"])
  244. if len(t) < 4 or "¥" in b["text"] or "¥" in b["text"] or AID_PROMO_RE.search(t):
  245. continue
  246. by = cls._gcy(b)
  247. has_aid = any(abs(cls._gcy(a) - by) <= 40 and re.search(r"发货|起送|日达|分钟|准时|配送", cls._gnorm(a["text"]))
  248. for a in blocks)
  249. if has_aid:
  250. return b
  251. return None
  252. @classmethod
  253. def _title_blocks(cls, blocks: list, title: str, y_from: int, y_to: int, right_cx: int) -> list:
  254. """窗口内右列与标题匹配的块(包含关系 或 公共子串≥5),按匹配长度降序"""
  255. nt = cls._gnorm(title)
  256. if len(nt) < 4:
  257. return []
  258. out = []
  259. for b in blocks:
  260. y = cls._gcy(b)
  261. if not (y_from <= y < y_to) or cls._gcx(b) < right_cx:
  262. continue
  263. t = cls._gnorm(b["text"])
  264. if len(t) < 4:
  265. continue
  266. if nt in t or t in nt or cls._glcs(t, nt) >= 5:
  267. out.append((min(len(t), len(nt)), b))
  268. out.sort(key=lambda x: -x[0])
  269. return [b for _, b in out]
  270. @classmethod
  271. def _validate_cards(cls, cards: list, blocks: list, screen_w: int, screen_h: int) -> list:
  272. """几何校验:宁丢卡不配错。cards元素 = [shop,title,price,[cx,cy],shop_y,sales]
  273. R1 价格块可反查(防编造价格/坐标错卡)
  274. R2 店铺块在价格下方窗口内可反查(防店铺配到别的卡)
  275. R4 一个店铺块只属于一张卡(防一店名连配多卡)
  276. R5 标题可在价格上方验证到(≥6字;只在其下方=缝合卡,无法验证=碎片拼接)
  277. R6 店名/标题回贴为OCR原文(修正形近字/抄写走样,保留GLM多行拼接的标题)"""
  278. max_gap = max(int(screen_h * GAP_RATIO), 80)
  279. t_above = int(screen_h * TITLE_ABOVE_RATIO)
  280. t_below = int(screen_h * TITLE_BELOW_RATIO)
  281. right_cx = int(screen_w * RIGHT_COL_RATIO)
  282. kept, used_shop = [], []
  283. for s in cards:
  284. shop, title, price, click = s[0], s[1], s[2], s[3]
  285. tag = f"{shop}|{price}|{title[:14]}"
  286. pb = cls._price_block(blocks, price, click) # R1
  287. if pb is None:
  288. print(f"[v1] [校验丢弃·R1价格块不存在] {tag}")
  289. continue
  290. py = cls._gcy(pb)
  291. sb = cls._shop_block(blocks, shop, py + 5, py + max_gap) # R2
  292. repaired = False
  293. if sb is None:
  294. # 店名在价格下方找不到 → 多半是GLM把店名整列配错位(残卡引发级联)
  295. # 按几何找本卡真正的店铺行修复,救回整批卡片而不是全部丢弃
  296. sb = cls._shop_row_repair(blocks, py + 5, py + max_gap)
  297. if sb is None:
  298. print(f"[v1] [校验丢弃·R2价格下方无此店] {tag}")
  299. continue
  300. repaired = True
  301. sy = cls._gcy(sb)
  302. if any(abs(sy - u) < 25 for u in used_shop): # R4
  303. print(f"[v1] [校验丢弃·R4店铺块复用] {tag}")
  304. continue
  305. above = [b for b in cls._title_blocks(blocks, title, py - t_above, py, right_cx)
  306. if len(cls._gnorm(b["text"])) >= MIN_TITLE_LEN]
  307. below = cls._title_blocks(blocks, title, py + 5, py + t_below, right_cx)
  308. if not above: # R5
  309. reason = "标题偷自下方(缝合卡)" if below else "标题无法验证"
  310. print(f"[v1] [校验丢弃·R5{reason}] {tag}")
  311. continue
  312. # R6 回贴:GLM标题包含OCR块→保留GLM(多行拼接的规格);否则用OCR块(修正错位/形近字)
  313. t1 = re.sub(r"\s+", "", str(title))
  314. t2 = re.sub(r"\s+", "", str(above[0]["text"]))
  315. new_title = t1 if len(t1) > len(t2) and cls._gnorm(t2) in cls._gnorm(t1) else t2
  316. s[0] = cls._shop_text(sb["text"])
  317. s[1] = new_title
  318. s[3] = [cls._gcx(pb), py] # 点击坐标贴到真实价格块中心
  319. s[4] = sy # shop_y 贴到真实店铺块
  320. used_shop.append(sy)
  321. fix_note = " [R2修复·店名错位]" if repaired else ""
  322. print(f"[v1] [校验通过]{fix_note} {s[0]} | {s[1][:36]} | {price} | click=({s[3][0]},{s[3][1]})")
  323. kept.append(s)
  324. return kept
  325. def _cards_prompt(self) -> str:
  326. """整页整理提示词:AI 自己划分卡片 + 提取字段(不做硬代码分组)
  327. V5:V0 基础上打 3 个补丁——顶部孤儿店铺名规则 / 推荐区忽略 / 店铺名定义去类目化"""
  328. return """你是电商列表页 OCR 解析助手。收到一张饿了么闪购搜索结果页的 OCR 文字(每行格式 [x=横坐标, y=纵坐标] 文字,y 越大越靠屏幕下方)。
  329. 【任务】自己把文字块划分成商品卡片,再逐卡提取字段。
  330. 【卡片划分方法】
  331. - 卡片竖向排列:同一卡片的文字块 y 坐标相近;两个文字块的 y 间隔超过约 80px 就属于不同卡片
  332. - 页头(如"30分钟达""适用人群""筛选")不是商品卡片,忽略
  333. - 分隔说明行(如"搜索结果较少,为你推荐相关店铺")之后的推荐内容不是搜索结果卡片,全部忽略:
  334. 推荐店铺(店名/评分/月售/起送/距离)以及其中夹着的小商品瓦片(商品名+价格),
  335. 严禁把推荐店铺名和瓦片商品配对成卡片输出
  336. - 每张卡片结构固定,从上到下:
  337. 1. 商品标题(品牌标签如[理洫王]、[999] + 药名 + 规格,可能多行)
  338. 2. (中间可能夹杂:处方药、OTC 等标签行——忽略)
  339. 3. 价格(¥或¥开头,只有一行)
  340. 4. (下面可能夹杂:满减、起送、指定商品、配送费、快递发货、1-3日达等文字——忽略)
  341. 5. 店铺名(价格下方的商家/店铺名称)
  342. 【提取方法】(逐卡独立提取,不得把别的卡片的文字拼进来)
  343. - price = 本卡内以¥或¥开头的块。若一块里有两个价格(如"¥33¥38"),取第一个(主价),第二个是划线原价
  344. - title = 价格【上方】的本卡商品名文字,多行完整拼接
  345. (规格直接相连,如「0.33g*2」+「4粒/盒」拼成「0.33g*24粒/盒」);跳过"处方药"等标签行;保留开头的品牌标签
  346. - shop = 价格【下方】的商家名,含"("只取"("前;满减/起送/快递发货/日达这些行不是店铺名
  347. - click = [x, y]:直接取【价格块】的坐标(价格在商品信息中部,点价格进商品最稳,不会点错卡)
  348. - shop_y = 店铺名块的 y
  349. - sales = "已售"后面的数字,没有留空""
  350. 【残卡判断——标题、价格、店铺名三者缺一不可】
  351. 完整卡片必须同时具备:标题(价格上方)+ 价格 + 店铺名(价格下方)。
  352. 屏幕顶部/底部被截断的卡(缺标题或缺店铺名)直接不输出,不要强行拼凑。
  353. 特别注意屏幕顶部的残卡:如果一段内容直接以价格开头(它上方没有本卡的标题),
  354. 说明标题已滚出屏幕——这个价格和它紧下方的店铺名属于同一张被截断的卡,整卡丢弃;
  355. 严禁把这个店铺名(或这个价格)配给屏幕上其他任何一张完整卡片。
  356. 同样,屏幕底部最后一段若只有标题和价格、下面没有店铺名,也整卡丢弃。
  357. 其余正常卡片都正常提取,不要因为文字杂乱、有促销行或距离问题就丢卡。
  358. 【页面判断——先看页面对不对】
  359. 如果整个页面不是商品搜索结果列表(比如首页、美食/外卖商家频道、商品详情页等),
  360. 不要硬凑卡片,返回 [{"page_wrong": true}]。
  361. 特别提醒:采集中退回到错误页面时,页面上可能是餐饮等其他类目的商家,
  362. 它们不是当前搜索的商品,绝不能当卡片提取。
  363. 【约束】
  364. - 所有字段严格取自 OCR 原文,坐标用 OCR 里的真实坐标,不得编造
  365. - 输出纯 JSON 数组(字符串用双引号),不要任何多余文字:
  366. [{"title":"商品标题","price":"价格","shop":"店铺名","click":[x,y],"shop_y":店铺名y,"sales":"月售"}, ...]"""
  367. def _call_llm(self, system_prompt: str, user_text: str, timeout: int = 300) -> str:
  368. """调中转站 GLM(Anthropic 格式端点,关思考防吃光 token)"""
  369. headers = {"x-api-key": GLM_KEY, "anthropic-version": "2023-06-01",
  370. "Content-Type": "application/json"}
  371. payload = {"model": GLM_MODEL, "max_tokens": 8192,
  372. "system": system_prompt, "thinking": {"type": "disabled"},
  373. "messages": [{"role": "user", "content": user_text}]}
  374. last_err = None
  375. for attempt in range(3):
  376. try:
  377. r = requests.post(GLM_URL, headers=headers, json=payload, timeout=timeout)
  378. if r.status_code != 200:
  379. print(f"[v1] GLM HTTP {r.status_code}: {r.text[:200]}")
  380. last_err = Exception(f"HTTP {r.status_code}")
  381. import time as _t
  382. _t.sleep(2); continue
  383. data = r.json()
  384. if "error" in data:
  385. err = data["error"].get("message", str(data["error"])) if isinstance(data["error"], dict) else str(data["error"])
  386. print(f"[v1] GLM error: {err[:200]}")
  387. last_err = Exception(err)
  388. import time as _t
  389. _t.sleep(2); continue
  390. texts = [c.get("text", "") for c in data.get("content", []) if c.get("type") == "text"]
  391. return "".join(texts)
  392. except Exception as e:
  393. last_err = e
  394. print(f"[v1] GLM 调用失败(第{attempt+1}次)({e}),重试...")
  395. import time as _t
  396. _t.sleep(2)
  397. print(f"[v1] GLM 3次均失败: {last_err}")
  398. return ""
  399. # 别名:方便 main1.py 用 `from ai_helper_vision1 import VisionParser` 直接替换
  400. VisionParser = VisionParser1
  401. # ── 独立测试 ───────────────────────────────────────────
  402. if __name__ == "__main__":
  403. # ═══════ 测试配置:改这里就行,不用命令行传参 ═══════
  404. TEST_IMAGE = r"" # 要测的图片完整路径;留空 = 自动找最新 step3_b0.png
  405. TEST_KEYWORD = "血塞通软胶囊" # 搜索关键词
  406. TEST_CROP_X = 287 # 左列分界(列表页一般 287~289);0 = 不过滤
  407. # ═══════════════════════════════════
  408. kw = TEST_KEYWORD
  409. import glob
  410. import re as _re
  411. _proj_root = os.path.dirname(os.path.abspath(__file__))
  412. _shot_root = os.path.join(_proj_root, "screenshots")
  413. _all = []
  414. if os.path.isdir(_shot_root):
  415. for p in glob.glob(os.path.join(_shot_root, "**", "step3", "step3_b0.png"), recursive=True):
  416. if _re.match(r'^step3_b\d+\.png$', os.path.basename(p)):
  417. _all.append(p)
  418. _all.sort(key=os.path.getmtime, reverse=True)
  419. if TEST_IMAGE.strip():
  420. _all = [TEST_IMAGE.strip()]
  421. if not _all:
  422. print(f"没找到截图(在 {_shot_root})")
  423. sys.exit(1)
  424. print(f"找到 {len(_all)} 张截图,关键词: {kw}\n")
  425. for img_path in _all:
  426. print("=" * 60)
  427. print(f"图片: {img_path}")
  428. if not os.path.exists(img_path):
  429. print("文件不存在,跳过"); continue
  430. try:
  431. from PIL import Image
  432. w, h = Image.open(img_path).size
  433. except ImportError:
  434. w, h = 720, 1640
  435. try:
  436. parser = VisionParser1()
  437. shops, status = parser.parse_shops(img_path, screen_size=(w, h), keyword=kw, crop_x=TEST_CROP_X)
  438. print(f" -> 识别 {len(shops)} 个店铺:")
  439. for s in shops:
  440. print(f" {s[0]} | {s[1][:36]} | {s[2]} | click={s[3]} | 已售={s[5]}")
  441. except Exception as e:
  442. print(f" 异常: {e}")
  443. print()