| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589 |
- """
- 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()
|