ai_helper.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336
  1. """
  2. AI 助手 — 调用 DeepSeek 分析 OCR 文本结果
  3. 只负责:接收文本 → 调 AI → 返回结构化数据
  4. 不负责 OCR 识别
  5. """
  6. import json
  7. import os
  8. import re
  9. import time
  10. from pathlib import Path
  11. from typing import Optional
  12. import requests
  13. # ── 配置 ────────────────────────────────────────────────
  14. DEEPSEEK_API_URL = "https://ai-api.dfwy.tech/v1/messages"
  15. DEEPSEEK_MODEL = "deepseek-v4-flash-nothinking"
  16. # 项目根目录(用于读取 config.json)
  17. PROJECT_ROOT = Path(__file__).parent
  18. class AIParser:
  19. """调用 DeepSeek 分析 OCR 文本,返回结构化店铺列表"""
  20. # 默认 API Key(写死,也可通过环境变量或 config.json 覆盖)
  21. _DEFAULT_KEY = ""
  22. def __init__(self, api_key: Optional[str] = None):
  23. """
  24. Args:
  25. api_key: DeepSeek API Key,不传则从环境变量或 config.json 读取
  26. """
  27. self.api_key = api_key or self._load_api_key()
  28. if not self.api_key:
  29. raise RuntimeError(
  30. "未设置 DeepSeek API Key。请:\n"
  31. "1. 设环境变量 DEEPSEEK_API_KEY\n"
  32. "2. 或在 config.json 中添加 deepseek_api_key 字段\n"
  33. "3. 或在初始化时传入 api_key"
  34. )
  35. @staticmethod
  36. def _load_api_key() -> str:
  37. """从环境变量、config.json 或默认值读取 API Key"""
  38. key = os.environ.get("DEEPSEEK_API_KEY", "")
  39. if key:
  40. return key
  41. cfg_path = PROJECT_ROOT / "config.json"
  42. if cfg_path.exists():
  43. try:
  44. with open(cfg_path) as f:
  45. cfg = json.load(f)
  46. key = cfg.get("deepseek_api_key", "")
  47. if key:
  48. return key
  49. except Exception:
  50. pass
  51. return AIParser._DEFAULT_KEY
  52. # ── 核心:分析 OCR 结果 → 店铺列表 ──────────────────
  53. def parse_shops(
  54. self,
  55. ocr_results: list,
  56. screen_size: tuple = (1220, 2712),
  57. ) -> list:
  58. """
  59. 将 OCR 识别结果传给 DeepSeek,返回结构化店铺列表
  60. Args:
  61. ocr_results: OCR.recognize(detail="all") 返回值
  62. [{"text": str, "confidence": float, "bbox": [[x1,y1],...]}, ...]
  63. screen_size: (宽, 高),默认 1220x2712
  64. Returns:
  65. [[店铺名, 商品标题, 价格, [点击x, 点击y], 配送距离y], ...]
  66. 点击坐标是店铺卡片中心点
  67. 配送距离y = 配送时间/距离文字的y坐标,用于滑动到其上方
  68. """
  69. # 1. 格式化 OCR 数据 — 按 y 坐标排序(从上到下)
  70. sorted_results = sorted(ocr_results, key=lambda r: r["bbox"][0][1])
  71. ocr_lines = []
  72. for r in sorted_results:
  73. box = r["bbox"]
  74. cy = (box[0][1] + box[2][1]) // 2
  75. cx = (box[0][0] + box[2][0]) // 2
  76. ocr_lines.append(
  77. f"[x={cx:4d}, y={cy:4d}, conf={r['confidence']:.2f}] {r['text']}"
  78. )
  79. ocr_text = "\n".join(ocr_lines)
  80. screen_w, screen_h = screen_size
  81. # 2. system prompt
  82. system_prompt = """你是OCR文本结构化助手。从OCR文字列表中找出每个"店铺卡片"的4个字段,必须严格使用OCR原文。
  83. 【OCR数据格式】
  84. 每行: [x=坐标x, y=坐标y, conf=置信度] OCR识别出的文字
  85. y越大越靠下,文字按从上到下排列。
  86. 【判断店铺卡片方法】
  87. - 同卡片的文字y坐标连续相近,卡片间有明显y间隔(>80px)
  88. - 卡片从上到下:商品标题(多行) → 价格(¥/¥开头) → 店铺名(以店/超市/旗舰店等结尾)
  89. 【字段规则 — 严格使用OCR原文,不得修改】
  90. 1. 店铺名 = 卡片最下方像店名的文字。
  91. 如果文字含"(",只取"("之前的部分(括号内是分店名,不重要)。
  92. 店铺名通常以"店/超市/旗舰店"结尾。
  93. 2. 商品标题 = 卡片上方描述商品的文字。
  94. 多行时要完整拼接在一起,不要截断。
  95. 3. 价格 = 卡片中间带¥或¥符号的文字。
  96. 只取**数值+货币符号**部分(如"¥12.8"、"¥67"),不要含"预估价"等额外文字。
  97. 如果找不到价格,留空字符串""。
  98. 4. 点击坐标[x,y] = OCR数据中该卡片区域中间位置的实际坐标,用整数。
  99. 【约束】
  100. - 所有字段值必须严格等于或取自OCR原文,不得修改、拼凑、推断
  101. - 店铺名如果找不到,留空字符串""
  102. - 找不到价格的卡片也要列出,价格留空
  103. - 有多少个卡片就输出多少个,不要遗漏
  104. - 输出纯JSON数组,不要任何多余文字
  105. 返回格式:
  106. [[店铺名(去括号), 商品标题(完整), 价格(纯数字), [点击x, 点击y], 店铺名y], ...]
  107. 店铺名y = 店铺名文字在OCR数据中的y坐标,必须从OCR数据中找"""
  108. # 3. user prompt
  109. user_prompt = (
  110. f"屏幕OCR识别结果(屏幕尺寸 {screen_w}x{screen_h}):\n\n"
  111. f"{ocr_text}\n\n请按格式返回店铺列表。"
  112. )
  113. # 4. 调 AI
  114. print(f"[ai] 分析 {len(ocr_results)} 个文本块...")
  115. response = self._call(system_prompt, user_prompt)
  116. # 5. 解析返回
  117. return self._parse_response(response, screen_w, screen_h)
  118. # ── 页面类型检测 ──────────────────────────────────
  119. def check_page(self, ocr_results: list) -> dict:
  120. """
  121. 分析进入商品页后的页面类型
  122. Returns:
  123. {"type": "normal"|"risk"|"qrcode"|"unknown",
  124. "shop": "店铺名(如有)",
  125. "detail": "额外描述"}
  126. """
  127. # 1. 格式化
  128. sorted_r = sorted(ocr_results, key=lambda r: r["bbox"][0][1])
  129. lines = []
  130. for r in sorted_r:
  131. box = r["bbox"]
  132. cy = (box[0][1] + box[2][1]) // 2
  133. cx = (box[0][0] + box[2][0]) // 2
  134. lines.append(f"[x={cx:4d}, y={cy:4d}] {r['text']}")
  135. ocr_text = "\n".join(lines)
  136. system_prompt = """你是手机屏幕分析助手。收到OCR文字列表,判断当前屏幕是什么类型的页面。
  137. 【页面类型】
  138. 1. normal = 正常商品/店铺详情页(可以看到商品信息、价格、评价、店铺名等)
  139. 2. risk = 风控/验证码/拦截页(包含"验证"、"拖动滑块"、"安全验证"、"禁止搜索"、"操作频繁"、"风险"、"请输入验证码"等)
  140. 3. qrcode = 二维码/扫码页(包含"二维码"、"扫码"、"请用微信"等)
  141. 4. unknown = 其他无法识别的页面
  142. **优先检测 risk 类型**:只要有类似验证码、风控拦截的文字,就返回risk
  143. 【输出格式】
  144. 只返回一个JSON对象,不要任何多余文字:
  145. {"type": "normal|risk|qrcode|unknown", "shop": "店铺名或空字符串", "detail": "简要说明"}
  146. """
  147. user_prompt = f"屏幕OCR文字:\n{ocr_text}\n\n请判断页面类型。"
  148. print(f"[ai] 检测页面类型...")
  149. response = self._call(system_prompt, user_prompt)
  150. import json
  151. cleaned = response.strip()
  152. if cleaned.startswith("```"):
  153. lines = cleaned.split("\n")
  154. if lines[0].startswith("```"):
  155. lines = lines[1:]
  156. if lines and lines[-1].strip() == "```":
  157. lines = lines[:-1]
  158. cleaned = "\n".join(lines).strip()
  159. try:
  160. data = json.loads(cleaned)
  161. return {
  162. "type": data.get("type", "unknown"),
  163. "shop": data.get("shop", ""),
  164. "detail": data.get("detail", ""),
  165. }
  166. except json.JSONDecodeError:
  167. if not response.strip():
  168. return {"type": "normal", "shop": "", "detail": "API调用失败,默认继续"}
  169. return {"type": "unknown", "shop": "", "detail": response[:100]}
  170. # ── API 调用 ────────────────────────────────────────
  171. def _call(self, system_prompt: str, user_content: str, timeout: int = 120, retries: int = 3) -> str:
  172. """调用 API(Anthropic 格式),失败自动重试"""
  173. headers = {
  174. "x-api-key": self.api_key,
  175. "anthropic-version": "2023-06-01",
  176. "Content-Type": "application/json",
  177. }
  178. payload = {
  179. "model": DEEPSEEK_MODEL,
  180. "max_tokens": 8192,
  181. "system": system_prompt,
  182. "messages": [
  183. {"role": "user", "content": user_content},
  184. ],
  185. }
  186. last_err = None
  187. for attempt in range(retries):
  188. try:
  189. resp = requests.post(DEEPSEEK_API_URL, json=payload, headers=headers, timeout=timeout)
  190. resp.raise_for_status()
  191. data = resp.json()
  192. # 从 content 中提取所有 text 类型块
  193. texts = [c["text"] for c in data.get("content", []) if c.get("type") == "text"]
  194. return "".join(texts)
  195. except Exception as e:
  196. last_err = e
  197. if attempt < retries - 1:
  198. print(f" [ai] API调用失败(第{attempt+1}次),重试... ({e})")
  199. time.sleep(2)
  200. print(f" [ai] API调用{retries}次均失败,跳过: {last_err}")
  201. return ""
  202. # ── 解析 ────────────────────────────────────────────
  203. @staticmethod
  204. def _parse_response(response: str, screen_w: int, screen_h: int) -> list:
  205. """解析 AI 返回的 JSON"""
  206. cleaned = response.strip()
  207. # 去掉 markdown 代码块包裹
  208. if cleaned.startswith("```"):
  209. lines = cleaned.split("\n")
  210. if lines[0].startswith("```"):
  211. lines = lines[1:]
  212. if lines and lines[-1].strip() == "```":
  213. lines = lines[:-1]
  214. cleaned = "\n".join(lines).strip()
  215. # 解析 JSON
  216. try:
  217. data = json.loads(cleaned)
  218. except json.JSONDecodeError:
  219. print(f"[ai] WARNING: JSON 解析失败,尝试提取数组片段")
  220. match = re.search(r'\[[\s\S]*\]', cleaned)
  221. if match:
  222. try:
  223. data = json.loads(match.group(0))
  224. except json.JSONDecodeError:
  225. print(f"[ai] ERROR: JSON 解析失败")
  226. return []
  227. else:
  228. print(f"[ai] ERROR: 未找到 JSON 数组")
  229. return []
  230. if not isinstance(data, list):
  231. print(f"[ai] ERROR: 返回不是数组")
  232. return []
  233. validated = []
  234. for item in data:
  235. # 兼容 dict 格式(模型偶发不按 list 模板输出时自动转换)
  236. if isinstance(item, dict):
  237. shop = str(item.get("name") or item.get("shop") or item.get("shop_name") or "")
  238. title = str(item.get("title") or item.get("description") or item.get("product_name") or "")
  239. price = str(item.get("price") or item.get("min_price") or "")
  240. coords = item.get("coords") or item.get("click_pos") or item.get("click")
  241. if not isinstance(coords, list) and item.get("x") is not None and item.get("y") is not None:
  242. coords = [item.get("x"), item.get("y")]
  243. shop_name_y = item.get("shop_name_y") or item.get("y")
  244. item = [shop, title, price, coords, shop_name_y]
  245. if not isinstance(item, list) or len(item) < 4:
  246. continue
  247. shop = str(item[0]) if item[0] else ""
  248. title = str(item[1]) if item[1] else ""
  249. price = str(item[2]) if item[2] else ""
  250. coords = item[3]
  251. if isinstance(coords, list) and len(coords) == 2:
  252. cx, cy = int(coords[0]), int(coords[1])
  253. if 0 <= cx <= screen_w and 0 <= cy <= screen_h:
  254. # 第5个字段=店铺名文字的y坐标
  255. shop_name_y = int(item[4]) if len(item) >= 5 and isinstance(item[4], (int, float)) else min(cy + 200, screen_h)
  256. validated.append([shop, title, price, [cx, cy], shop_name_y])
  257. print(f"[ai] 解析到 {len(validated)} 个店铺")
  258. return validated
  259. # ── 独立测试 ────────────────────────────────────────────
  260. if __name__ == "__main__":
  261. import sys
  262. from steps.ocr import OCR
  263. # 读取已有截图
  264. test_img = str(PROJECT_ROOT / "screenshots" / "step2_result.png")
  265. if not os.path.exists(test_img):
  266. print(f"测试图片不存在: {test_img}")
  267. print("请先跑一次 main.py 生成截图")
  268. sys.exit(1)
  269. # OCR 识别
  270. ocr = OCR()
  271. raw = ocr.recognize(test_img, detail="all")
  272. print(f"OCR 识别到 {len(raw)} 个文本块\n")
  273. # AI 分析
  274. parser = AIParser()
  275. shops = parser.parse_shops(raw)
  276. print("\n" + "=" * 60)
  277. print(f" 店铺列表 ({len(shops)} 个)")
  278. print("=" * 60)
  279. for s in shops:
  280. print(f" 店铺: {s[0]}")
  281. print(f" 商品: {s[1]}")
  282. print(f" 价格: {s[2]}")
  283. print(f" 点击: ({s[3][0]}, {s[3][1]})")
  284. print(f" 配送y: {s[4]}")
  285. print()