""" AI 助手 — 调用 DeepSeek 分析 OCR 文本结果 只负责:接收文本 → 调 AI → 返回结构化数据 不负责 OCR 识别 """ import json import os import re import time from pathlib import Path from typing import Optional import requests # ── 配置 ──────────────────────────────────────────────── DEEPSEEK_API_URL = "https://ai-api.dfwy.tech/v1/messages" DEEPSEEK_MODEL = "glm-5.2" # 项目根目录(用于读取 config.json) PROJECT_ROOT = Path(__file__).parent class AIParser: """调用 DeepSeek 分析 OCR 文本,返回结构化店铺列表""" # 默认 API Key(写死,也可通过环境变量或 config.json 覆盖) _DEFAULT_KEY = "" def __init__(self, api_key: Optional[str] = None): """ Args: api_key: DeepSeek API Key,不传则从环境变量或 config.json 读取 """ self.api_key = api_key or self._load_api_key() if not self.api_key: raise RuntimeError( "未设置 DeepSeek API Key。请:\n" "1. 设环境变量 DEEPSEEK_API_KEY\n" "2. 或在 config.json 中添加 deepseek_api_key 字段\n" "3. 或在初始化时传入 api_key" ) @staticmethod def _load_api_key() -> str: """从环境变量、config.json 或默认值读取 API Key""" key = os.environ.get("DEEPSEEK_API_KEY", "") if key: return key cfg_path = PROJECT_ROOT / "config.json" if cfg_path.exists(): try: with open(cfg_path) as f: cfg = json.load(f) key = cfg.get("deepseek_api_key", "") if key: return key except Exception: pass return AIParser._DEFAULT_KEY # ── 核心:分析 OCR 结果 → 店铺列表 ────────────────── def parse_shops( self, ocr_results: list, screen_size: tuple = (1220, 2712), keyword: str = "", ) -> list: """ 将 OCR 识别结果传给 DeepSeek,返回结构化店铺列表 Args: ocr_results: OCR.recognize(detail="all") 返回值 [{"text": str, "confidence": float, "bbox": [[x1,y1],...]}, ...] screen_size: (宽, 高),默认 1220x2712 keyword: 当前搜索的商品关键词,用于帮助AI区分标题和功能描述 Returns: [[店铺名, 商品标题, 价格, [点击x, 点击y], 配送距离y], ...] 点击坐标是店铺卡片中心点 配送距离y = 配送时间/距离文字的y坐标,用于滑动到其上方 """ # 1. 格式化 OCR 数据 — 按 y 坐标排序(从上到下) sorted_results = sorted(ocr_results, key=lambda r: r["bbox"][0][1]) ocr_lines = [] for r in sorted_results: box = r["bbox"] cy = (box[0][1] + box[2][1]) // 2 cx = (box[0][0] + box[2][0]) // 2 ocr_lines.append( f"[x={cx:4d}, y={cy:4d}, conf={r['confidence']:.2f}] {r['text']}" ) ocr_text = "\n".join(ocr_lines) screen_w, screen_h = screen_size # 2. system prompt system_prompt = f"""你是OCR文本结构化助手。从OCR文字列表中找出每个"店铺卡片"的字段,必须严格使用OCR原文。 【重要:当前搜索的商品】 用户正在搜索「{keyword}」。请严格遵守以下规则: - 商品标题优先取卡片中与「{keyword}」匹配的文字行(含品牌名和规格) - 功能描述(如"养阴敛肺""解热镇痛""清热解毒""止咳化痰"等)通常字数少(2-8字)、无品牌名、在多个卡片中重复出现 → 绝对不能作为商品标题 - 如果找不到与「{keyword}」匹配的文字,商品标题取卡片中最像完整商品名的文字(含品牌+药名+规格结构) - 但是如果整个页面没有任何文字与「{keyword}」相关(例如搜索药品却显示快餐/外卖商家),说明页面不对,返回空数组 [] - 店铺名和商品标题不能是同一个文字(如果是同一个,说明该卡片没有独立的商品标题,应跳过) 【OCR数据格式】 每行: [x=坐标x, y=坐标y, conf=置信度] OCR识别出的文字 y越大越靠下,文字按从上到下排列。 【判断店铺卡片方法】 - 卡片判定(严格):同一卡片的文字块y坐标连续;任意两个文字块的y间隔≥80px则必属不同卡片。 商品标题的所有文字必须来自同一张卡片,绝不允许跨卡片拼接其他卡片的文字。 - 先分析布局:同一卡片内,x坐标接近的行组成一列。卡片通常有两列: 左列是商品图片(图片上印刷的文字,可能含英文、乱码、与标题重复的药名、孤立数字); 右列是商品信息(标题、价格、店铺名)。 如果某行无法确定属于哪列,以它和标题/价格/店铺名的x坐标接近程度判断。 - 提取字段时只使用右列(商品信息列)的文字,忽略左列(图片列)的文字。 - 卡片从上到下:商品标题(多行) → 价格(¥/¥开头) → 店铺名(以店/超市/旗舰店等结尾) - 如果某文字块与当前卡片的y间隔≥80px,它属于下一张卡片,不得用于当前卡片的任何字段。 【字段规则 — 严格使用OCR原文,不得修改】 1. 店铺名 = 卡片最下方像店名的文字。 如果文字含"(",只取"("之前的部分(括号内是分店名,不重要)。 店铺名通常以"店/超市/旗舰店"结尾。 2. 商品标题 = 右列(商品信息列)中描述商品名称和规格的行。 多行时要完整拼接在一起,不要截断; 拼接时遇到以下任一行立即停止: - 价格行(¥/¥开头) - 店铺名行 - 营销/标签行(如"原研药"、"处方药"、"热销"、"满减"等,不含商品名称和规格结构) 3. 价格 = 卡片中间带¥或¥符号的文字。 只取**数值+货币符号**部分(如"¥12.8"、"¥67"),不要含"预估价"等额外文字。 如果找不到价格,留空字符串""。 4. 点击坐标[x,y] = OCR数据中该卡片区域中间位置的实际坐标,用整数。 5. 月售量 = 卡片中含"月售"或"已售"的文字里的数字部分。 如"月售1000+" → "1000";含"万"时保留,如"月售1万+" → "1万"。 找不到留空字符串""。 【约束】 - 所有字段值必须严格等于或取自OCR原文,不得修改、拼凑、推断 - 店铺名如果找不到,留空字符串"" - 找不到价格的卡片也要列出,价格留空 - 找不到月售量的卡片也要列出,月售量留空 - 有多少个卡片就输出多少个,不要遗漏 - 输出纯JSON数组,不要任何多余文字 返回格式: [[店铺名(去括号), 商品标题(完整), 价格(纯数字), [点击x, 点击y], 店铺名y, 月售量], ...] 店铺名y = 店铺名文字在OCR数据中的y坐标,必须从OCR数据中找 月售量 = 第5条规则提取的数字,找不到就是""""" # 3. user prompt user_prompt = ( f"屏幕OCR识别结果(屏幕尺寸 {screen_w}x{screen_h}):\n\n" f"{ocr_text}\n\n请按格式返回店铺列表。" ) # 4. 调 AI print(f"[ai] 分析 {len(ocr_results)} 个文本块...") response = self._call(system_prompt, user_prompt) # 5. 解析返回 return self._parse_response(response, screen_w, screen_h) # ── 页面类型检测 ────────────────────────────────── def check_page(self, ocr_results: list) -> dict: """ 分析进入商品页后的页面类型 Returns: {"type": "normal"|"risk"|"qrcode"|"unknown", "shop": "店铺名(如有)", "detail": "额外描述"} """ # 1. 格式化 sorted_r = sorted(ocr_results, key=lambda r: r["bbox"][0][1]) lines = [] for r in sorted_r: box = r["bbox"] cy = (box[0][1] + box[2][1]) // 2 cx = (box[0][0] + box[2][0]) // 2 lines.append(f"[x={cx:4d}, y={cy:4d}] {r['text']}") ocr_text = "\n".join(lines) system_prompt = """你是手机屏幕分析助手。收到OCR文字列表,判断当前屏幕是什么类型的页面。 【页面类型】 1. normal = 正常商品/店铺详情页(可以看到商品信息、价格、评价、店铺名等) 2. risk = 风控/验证码/拦截页(包含"拖动滑块"、"请按住滑块"、"滑块验证"、"拼图"、"安全验证"、"图形验证"、"点击完成验证"、"没有新图片"、"操作频繁"等强特征) 注意:单独的"验证"、"安全"、"风险"字样不算risk(商品说明/证书里常见,会误判) 3. qrcode = 二维码/扫码页(包含"二维码"、"扫码"、"请用微信"等) 4. home = App首页(底部导航必须有"AI点外卖";或有多个频道入口如"看病买药/盒马鲜生/超市便利/美食外卖/到店团购"。 注意:店铺页顶部的"首页/商品/评价"页签只是页内切换按钮,含"首页"二字不代表当前是App首页; 真首页没有"刚刚搜过"和"去结算") 5. list = 搜索结果列表页(包含"筛选"、"综合"、"销量"等列表页特征) 6. login = 登录页(包含"一键登录"、"更多登录方式"、"用户服务协议"等——账号被踢/封号时会跳转登录页) 7. unknown = 其他无法识别的页面 **优先检测 risk 类型**:只要有类似验证码、风控拦截的文字,就返回risk 【输出格式】 只返回一个JSON对象,不要任何多余文字: {"type": "normal|risk|qrcode|home|list|login|unknown", "shop": "店铺名或空字符串", "detail": "简要说明"} """ user_prompt = f"屏幕OCR文字:\n{ocr_text}\n\n请判断页面类型。" print(f"[ai] 检测页面类型...") response = self._call(system_prompt, user_prompt) import json cleaned = response.strip() if cleaned.startswith("```"): lines = cleaned.split("\n") if lines[0].startswith("```"): lines = lines[1:] if lines and lines[-1].strip() == "```": lines = lines[:-1] cleaned = "\n".join(lines).strip() try: data = json.loads(cleaned) return { "type": data.get("type", "unknown"), "shop": data.get("shop", ""), "detail": data.get("detail", ""), } except json.JSONDecodeError: if not response.strip(): return {"type": "normal", "shop": "", "detail": "API调用失败,默认继续"} return {"type": "unknown", "shop": "", "detail": response[:100]} # ── API 调用 ──────────────────────────────────────── def _call(self, system_prompt: str, user_content: str, timeout: int = 120, retries: int = 3) -> str: """调用 API(Anthropic 格式),失败自动重试""" headers = { "x-api-key": self.api_key, "anthropic-version": "2023-06-01", "Content-Type": "application/json", } payload = { "model": DEEPSEEK_MODEL, "max_tokens": 8192, "thinking": {"type": "disabled"}, "system": system_prompt, "messages": [ {"role": "user", "content": user_content}, ], } last_err = None for attempt in range(retries): try: resp = requests.post(DEEPSEEK_API_URL, json=payload, headers=headers, timeout=timeout) resp.raise_for_status() data = resp.json() # 从 content 中提取所有 text 类型块 texts = [c["text"] for c in data.get("content", []) if c.get("type") == "text"] return "".join(texts) except Exception as e: last_err = e if attempt < retries - 1: print(f" [ai] API调用失败(第{attempt+1}次),重试... ({e})") time.sleep(2) print(f" [ai] API调用{retries}次均失败,跳过: {last_err}") return "" # ── 解析 ──────────────────────────────────────────── @staticmethod def _parse_response(response: str, screen_w: int, screen_h: int) -> list: """解析 AI 返回的 JSON""" cleaned = response.strip() # 去掉 markdown 代码块包裹 if cleaned.startswith("```"): lines = cleaned.split("\n") if lines[0].startswith("```"): lines = lines[1:] if lines and lines[-1].strip() == "```": lines = lines[:-1] cleaned = "\n".join(lines).strip() # 解析 JSON try: data = json.loads(cleaned) except json.JSONDecodeError: print(f"[ai] WARNING: JSON 解析失败,尝试提取数组片段") match = re.search(r'\[[\s\S]*\]', cleaned) if match: try: data = json.loads(match.group(0)) except json.JSONDecodeError: print(f"[ai] ERROR: JSON 解析失败") return [] else: print(f"[ai] ERROR: 未找到 JSON 数组") return [] if not isinstance(data, list): print(f"[ai] ERROR: 返回不是数组") return [] validated = [] for item in data: # 兼容 dict 格式(模型偶发不按 list 模板输出时自动转换) if isinstance(item, dict): shop = str(item.get("name") or item.get("shop") or item.get("shop_name") or "") title = str(item.get("title") or item.get("description") or item.get("product_name") or "") price = str(item.get("price") or item.get("min_price") or "") coords = item.get("coords") or item.get("click_pos") or item.get("click") if not isinstance(coords, list) and item.get("x") is not None and item.get("y") is not None: coords = [item.get("x"), item.get("y")] shop_name_y = item.get("shop_name_y") or item.get("y") sales = str(item.get("sales") or item.get("month_sales") or "") item = [shop, title, price, coords, shop_name_y, sales] if not isinstance(item, list) or len(item) < 4: continue shop = str(item[0]) if item[0] else "" title = str(item[1]) if item[1] else "" price = str(item[2]) if item[2] else "" coords = item[3] if isinstance(coords, list) and len(coords) == 2: cx, cy = int(coords[0]), int(coords[1]) if 0 <= cx <= screen_w and 0 <= cy <= screen_h: # 第5个字段=店铺名文字的y坐标 shop_name_y = int(item[4]) if len(item) >= 5 and isinstance(item[4], (int, float)) else min(cy + 200, screen_h) # 第6个字段=月售量(找不到留空) sales = str(item[5]) if len(item) >= 6 and item[5] not in (None, "") else "" validated.append([shop, title, price, [cx, cy], shop_name_y, sales]) print(f"[ai] 解析到 {validated} 个店铺") return validated def detect_popup(ocr_results: list) -> dict: """ AI判断当前屏幕是否有广告弹窗(红包活动弹窗/营销弹窗)。 ocr_results: OCR.recognize(detail="all") 返回值(云端OCR结果,含坐标) 返回 {"has_popup": bool, "close_xy": [x,y]或None, "reason": "..."} """ lines = [] for r in ocr_results: box = r["bbox"] cx = (box[0][0] + box[2][0]) // 2 cy = (box[0][1] + box[2][1]) // 2 lines.append(f"[x={cx}, y={cy}] {r['text']}") ocr_text = "\n".join(lines) system_prompt = """你是手机屏幕分析助手。根据OCR文字判断屏幕上是否有广告弹窗。 【弹窗特征】 - 红包活动弹窗/营销弹窗:覆盖页面主体,带关闭按钮(关闭/跳过/我知道了/×) - OCR中出现"红包具有随机性"、"祛暑品类券"、"去使用"、"立即领取"、"优惠券"等弹窗文案 = 有弹窗 - 出现"关闭"、"跳过"、"我知道了"等关闭按钮文字 = 有弹窗 - 普通页面元素(分类入口、商品卡片、底部导航、搜索结果)不是弹窗 只返回JSON,不要多余文字: {"has_popup": true/false, "close_xy": [x,y]或null, "reason": "一句话说明"} close_xy = 关闭按钮文字的坐标(必须从OCR数据中选取);纯图标打叉找不到文字时返回null""" user_prompt = f"屏幕OCR文字(带坐标):\n{ocr_text}\n\n请判断是否有广告弹窗。" parser = AIParser() # 从 config.json 读 key resp = parser._call(system_prompt, user_prompt, timeout=60) cleaned = resp.strip() if cleaned.startswith("```"): cl = cleaned.split("\n") if cl[0].startswith("```"): cl = cl[1:] if cl and cl[-1].strip() == "```": cl = cl[:-1] cleaned = "\n".join(cl).strip() try: data = json.loads(cleaned) return { "has_popup": bool(data.get("has_popup")), "close_xy": data.get("close_xy"), "reason": data.get("reason", ""), } except json.JSONDecodeError: return {"has_popup": False, "close_xy": None, "reason": f"AI返回解析失败: {resp[:50]}"} def extract_value_after(ocr_results: list, label: str) -> str: """ 从 OCR 结果中提取指定标签后面的值(美团 get_next_data 逻辑)。 兼容:标签值同块("有效期 36个月")、标签带冒号("批准文号:")、标签值分离两块。 """ rows = sorted(ocr_results, key=lambda r: (r["bbox"][0][1], r["bbox"][0][0])) # 场景1: 标签和值在同一块 for r in rows: t = r["text"].strip() if t.startswith(label): rest = t[len(label):].lstrip(":: \t") if rest: return rest # 场景2: 标签单独一块(允许尾部带冒号),值在下一块 for i, r in enumerate(rows): t = r["text"].strip().rstrip(":: \t") if t == label and i + 1 < len(rows): return rows[i + 1]["text"].strip() return "" def parse_instructions(ocr_results: list) -> dict: """ 从药品说明书页 OCR 结果提取批准文号/有效期(模仿美团 get_next_data 逻辑)。 Args: ocr_results: OCR.recognize(detail="all") 返回值 [{"text": str, "bbox": [[x1,y1],...], ...}, ...] Returns: {"approval_no": "批准文号值或''", "validity": "有效期值或''"} """ return { "approval_no": extract_value_after(ocr_results, "批准文号"), "validity": extract_value_after(ocr_results, "有效期"), } # ── 单元测试(不调真实 API)───────────────────────────── def _selftest() -> None: """mock AI 响应,验证月售字段解析、提示词规则、请求格式""" import sys import unittest.mock as _mock import requests as _requests passed = 0 total = 0 def check(name: str, cond: bool, detail: str = ""): nonlocal passed, total total += 1 if cond: passed += 1 print(f" [OK] {name}") else: print(f" [FAIL] {name} {detail}") # 1) _parse_response:月售字段解析 cases = [ ("正常6字段", '[[\"xx大药房\",\"商品标题\",\"12.8\",[100,200],150,\"1000\"]]', ["1000"]), ("带万字", '[[\"xx大药房\",\"商品标题\",\"12.8\",[100,200],150,\"1万\"]]', ["1万"]), ("老格式5字段", '[[\"xx大药房\",\"商品标题\",\"12.8\",[100,200],150]]', [""]), ("月售留空", '[[\"xx大药房\",\"商品标题\",\"12.8\",[100,200],150,\"\"]]', [""]), ("markdown包裹", '```json\n[[\"xx大药房\",\"商品标题\",\"12.8\",[100,200],150,\"500\"]]\n```', ["500"]), ("坐标越界过滤", '[[\"xx大药房\",\"商品标题\",\"12.8\",[99999,99999],150,\"500\"]]', []), ("非JSON返回空", "这不是json", []), ] for name, resp, want in cases: shops = AIParser._parse_response(resp, 1220, 2712) got = [s[5] for s in shops] check(f"_parse_response: {name}", got == want, f"期望{want} 实际{got}") # 2) parse_shops 全流程(mock _call,验证提示词含月售规则) fake_ocr = [ {"text": "xx大药房", "confidence": 0.9, "bbox": [[100, 300], [100, 330], [300, 330], [300, 300]]}, {"text": "商品标题", "confidence": 0.9, "bbox": [[100, 100], [100, 130], [300, 130], [300, 100]]}, {"text": "¥12.8", "confidence": 0.9, "bbox": [[100, 200], [100, 230], [300, 230], [300, 200]]}, {"text": "月售1000+", "confidence": 0.9, "bbox": [[100, 260], [100, 290], [300, 290], [300, 260]]}, ] fake_resp = '[[\"xx大药房\",\"商品标题\",\"12.8\",[200,215],285,\"1000\"]]' with _mock.patch.object(AIParser, "_call", return_value=fake_resp) as mc: shops = AIParser(api_key="test-key").parse_shops(fake_ocr) check("parse_shops返回1个店铺", len(shops) == 1, f"实际{len(shops)}") check("parse_shops带出月售", len(shops) == 1 and shops[0][5] == "1000", f"实际{shops}") sp = mc.call_args[0][0] # system prompt(patch后无self,第0个参数即system) check("提示词含月售规则", "月售" in sp and "已售" in sp) check("提示词含第6字段", "月售量" in sp and "第5条规则" in sp) # 3) _call 请求格式(mock requests.post,验证 payload) class _FakeResp: def raise_for_status(self): pass def json(self): return {"content": [{"type": "text", "text": "ok"}]} with _mock.patch.object(_requests, "post", return_value=_FakeResp()) as mp: r = AIParser(api_key="test-key")._call("sys", "user") kw = mp.call_args.kwargs.get("json", {}) check("payload含thinking关闭", kw.get("thinking") == {"type": "disabled"}) check("模型为deepseek-v4-flash", kw.get("model") == "deepseek-v4-flash") check("_call提取text返回", r == "ok", f"实际{r!r}") # 4) parse_instructions:说明书批准文号/有效期解析 def _blk(text, y, x=50): return {"text": text, "confidence": 0.9, "bbox": [[x, y], [x, y + 20], [x + 300, y + 20], [x + 300, y]]} inst_cases = [ ("标签值同一块", [ _blk("批准文号:国药准字Z20240001", 100), _blk("有效期 36个月", 140), ], {"approval_no": "国药准字Z20240001", "validity": "36个月"}), ("标签值分离两块", [ _blk("批准文号", 100), _blk("国药准字Z20240001", 100, 160), _blk("有效期", 140), _blk("36个月", 140, 160), ], {"approval_no": "国药准字Z20240001", "validity": "36个月"}), ("乱序输入也能解析", [ _blk("国药准字Z20240001", 100, 160), _blk("有效期", 140), _blk("批准文号", 100), ], {"approval_no": "国药准字Z20240001", "validity": ""}), ("标签带冒号值在下一块", [ _blk("批准文号:", 100), _blk("国药准字Z20063286", 100, 160), _blk("有效期:", 140), _blk("48个月", 140, 160), ], {"approval_no": "国药准字Z20063286", "validity": "48个月"}), ("缺有效期", [ _blk("批准文号:国药准字Z20240001", 100), ], {"approval_no": "国药准字Z20240001", "validity": ""}), ("空OCR", [], {"approval_no": "", "validity": ""}), ("页面无说明书字段", [ _blk("商品名称:感冒灵颗粒", 100), ], {"approval_no": "", "validity": ""}), ] for name, ocr, want in inst_cases: got = parse_instructions(ocr) check(f"parse_instructions: {name}", got == want, f"期望{want} 实际{got}") # 5) extract_value_after:资质编号等通用标签取值 lic_ocr = [ _blk("资质编号:JY1234567890", 100), _blk("营业执照信息", 200), ] check("extract_value_after: 资质编号同块", extract_value_after(lic_ocr, "资质编号") == "JY1234567890", f"实际{extract_value_after(lic_ocr, '资质编号')!r}") lic_ocr2 = [ _blk("资质编号", 100), _blk("JY1234567890", 100, 160), ] check("extract_value_after: 资质编号分离", extract_value_after(lic_ocr2, "资质编号") == "JY1234567890", f"实际{extract_value_after(lic_ocr2, '资质编号')!r}") check("extract_value_after: 找不到返回空", extract_value_after([_blk("随便", 100)], "资质编号") == "") print(f"\n测试结果: {passed}/{total} 通过") sys.exit(0 if passed == total else 1) # ── 独立测试 ──────────────────────────────────────────── if __name__ == "__main__": import sys if "--selftest" in sys.argv: _selftest() sys.exit(0) from steps.ocr import OCR # 读取已有截图 test_img = str(PROJECT_ROOT / "screenshots" / "step2_result.png") if not os.path.exists(test_img): print(f"测试图片不存在: {test_img}") print("请先跑一次 main.py 生成截图") sys.exit(1) # OCR 识别 ocr = OCR() raw = ocr.recognize(test_img, detail="all") print(f"OCR 识别到 {len(raw)} 个文本块\n") # AI 分析 parser = AIParser() shops = parser.parse_shops(raw) print("\n" + "=" * 60) print(f" 店铺列表 ({len(shops)} 个)") print("=" * 60) for s in shops: print(f" 店铺: {s[0]}") print(f" 商品: {s[1]}") print(f" 价格: {s[2]}") print(f" 点击: ({s[3][0]}, {s[3][1]})") print(f" 配送y: {s[4]}") print()